diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7c72e556..42c3eeb2 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -47,6 +47,8 @@ DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AE626F6B38600ABCC23 /* Connect.swift */; }; DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D409287F04F100BAEB7A /* InvalidVersion.swift */; }; DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */; }; + DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */; }; + DD86D4112881D16900BAEB7A /* WriteCsvFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D4102881D16900BAEB7A /* WriteCsvFile.swift */; }; DD882F5D2772E4640005BF05 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD882F5C2772E4640005BF05 /* Contacts.swift */; }; DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8EBF42285058FA00426DCA /* DisplayConfig.swift */; }; DD90860C26F684AF00DC5189 /* BatteryIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD90860B26F684AF00DC5189 /* BatteryIcon.swift */; }; @@ -139,6 +141,8 @@ DD836AE626F6B38600ABCC23 /* Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connect.swift; sourceTree = ""; }; DD86D409287F04F100BAEB7A /* InvalidVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidVersion.swift; sourceTree = ""; }; DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelQRCode.swift; sourceTree = ""; }; + DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvDocument.swift; sourceTree = ""; }; + DD86D4102881D16900BAEB7A /* WriteCsvFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCsvFile.swift; sourceTree = ""; }; DD882F5C2772E4640005BF05 /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.swift; sourceTree = ""; }; DD8EBF42285058FA00426DCA /* DisplayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayConfig.swift; sourceTree = ""; }; DD90860A26F645B700DC5189 /* Meshtastic.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Meshtastic.entitlements; sourceTree = ""; }; @@ -254,7 +258,6 @@ DD3501882852FC3B000FC853 /* Settings.swift */, DD4A911D2708C65400501B7E /* AppSettings.swift */, DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */, - DD8169FE272476C700F4AB02 /* LogDocument.swift */, DD6B85A728009258000ACD6B /* ShareChannel.swift */, DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, @@ -288,6 +291,16 @@ path = Module; sourceTree = ""; }; + DD86D40D2881BDB300BAEB7A /* Export */ = { + isa = PBXGroup; + children = ( + DD8169FE272476C700F4AB02 /* LogDocument.swift */, + DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */, + DD86D4102881D16900BAEB7A /* WriteCsvFile.swift */, + ); + path = Export; + sourceTree = ""; + }; DD8EDE9226F97A2B00A5A10B /* Frameworks */ = { isa = PBXGroup; children = ( @@ -345,6 +358,7 @@ DD90860A26F645B700DC5189 /* Meshtastic.entitlements */, DDC4D5662754996200A4208E /* Persistence */, DDAF8C5626ED07740058C060 /* Protobufs */, + DD86D40D2881BDB300BAEB7A /* Export */, DDC2E1A526CEB32B0042C5E4 /* Helpers */, DDC2E18726CE24E40042C5E4 /* Views */, DDC2E18826CE24EE0042C5E4 /* Model */, @@ -667,6 +681,8 @@ DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */, DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */, DD2E65262767A01F00E45FC5 /* NodeDetail.swift in Sources */, + DD86D4112881D16900BAEB7A /* WriteCsvFile.swift in Sources */, + DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */, DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */, diff --git a/Meshtastic/Export/CsvDocument.swift b/Meshtastic/Export/CsvDocument.swift new file mode 100644 index 00000000..53f93a78 --- /dev/null +++ b/Meshtastic/Export/CsvDocument.swift @@ -0,0 +1,38 @@ +// +// CsvDocument.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 7/15/22. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct CsvDocument: FileDocument { + + static var readableContentTypes = [UTType.commaSeparatedText] + + @State var csvData: String + + init(emptyCsv: String = "" ) { + + csvData = emptyCsv + } + + init(configuration: ReadConfiguration) throws { + + if let data = configuration.file.regularFileContents { + + csvData = String(decoding: data, as: UTF8.self) + + } else { + + throw CocoaError(.fileReadCorruptFile) + } + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let data = Data(csvData.utf8) + return FileWrapper(regularFileWithContents: data) + } +} diff --git a/Meshtastic/Views/Settings/LogDocument.swift b/Meshtastic/Export/LogDocument.swift similarity index 100% rename from Meshtastic/Views/Settings/LogDocument.swift rename to Meshtastic/Export/LogDocument.swift diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift new file mode 100644 index 00000000..09c679b5 --- /dev/null +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -0,0 +1,65 @@ +// +// WriteCsvFile.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 7/15/22. +// + +import SwiftUI + + +func TelemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> String { + + var csvString: String = "" + + if metricsType == 0 { + + // Create Device Metrics Header + csvString = "Battery Level, Voltage, Channel Utilization, Airtime, Timestamp" + + for dm in telemetry{ + + if dm.metricsType == 0 { + + csvString += "\n" + csvString += String("\(dm.batteryLevel) %") + csvString += ", " + csvString += String(dm.voltage) + csvString += ", " + csvString += String(dm.channelUtilization) + csvString += ", " + csvString += String(dm.airUtilTx) + csvString += ", " + csvString += dm.time?.formattedDate(format: "yyyy-MM-dd HH:mm:ss") ?? "Unknown Age" + } + } + + } else { + // Create Device Telemetry Header + csvString = "Battery Level, Voltage, Channel Utilization, Airtime, Timestamp" + } + + return csvString +} + +func PositionToCsvFile(positions: [PositionEntity]) -> String { + + var csvString: String = "" + + // Create Position Header + csvString = "Latitude, Longitude, Altitude, Timestamp" + + for pos in positions { + + csvString += "\n" + csvString += String(pos.latitude ?? 0) + csvString += ", " + csvString += String(pos.longitude ?? 0) + csvString += ", " + csvString += String(pos.altitude) + csvString += ", " + csvString += pos.time?.formattedDate(format: "yyyy-MM-dd HH:mm:ss") ?? "Unknown Age" + } + + return csvString +} diff --git a/Meshtastic/Helpers/Extensions.swift b/Meshtastic/Helpers/Extensions.swift index 52bb1987..42b350e0 100644 --- a/Meshtastic/Helpers/Extensions.swift +++ b/Meshtastic/Helpers/Extensions.swift @@ -15,6 +15,12 @@ extension Date { static var currentTimeStamp: Int64 { return Int64(Date().timeIntervalSince1970 * 1000) } + + func formattedDate(format: String) -> String { + let dateformat = DateFormatter() + dateformat.dateFormat = format + return dateformat.string(from: self) + } } extension String { @@ -55,5 +61,4 @@ extension String { UIGraphicsEndImageContext() return image } - } diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 2564dfd0..e59de623 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -12,7 +12,7 @@ struct MeshtasticAppleApp: App { @ObservedObject private var userSettings: UserSettings = UserSettings() @State var saveQR = false - @State var channelUrl = "" + @State private var channelUrl: URL? @Environment(\.scenePhase) var scenePhase @@ -26,12 +26,11 @@ struct MeshtasticAppleApp: App { .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in print("QR Code URL received from the Camera \(userActivity)") - guard let url = userActivity.webpageURL else { + guard let channelUrl = userActivity.webpageURL else { return } - print("User wants to open URL: \(url)") - channelUrl = url.absoluteString + print("User wants to open URL: \(channelUrl)") saveQR = true } diff --git a/Meshtastic/Views/Nodes/LocationHistory.swift b/Meshtastic/Views/Nodes/LocationHistory.swift index e54c2d43..e0dedf92 100644 --- a/Meshtastic/Views/Nodes/LocationHistory.swift +++ b/Meshtastic/Views/Nodes/LocationHistory.swift @@ -11,6 +11,9 @@ struct LocationHistory: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager + @State var isExporting = false + @State var exportString = "" + var node: NodeInfoEntity var body: some View { @@ -96,7 +99,38 @@ struct LocationHistory: View { } } } + Button { + + exportString = PositionToCsvFile(positions: node.positions!.array as! [PositionEntity]) + isExporting = true + + } label: { + + Label("Export", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() } + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(node.user?.longName ?? "Node") Position Log"), + onCompletion: { result in + + if case .success = result { + + print("Position log download succeeded.") + self.isExporting = false + + } else { + + print("Position log download failed: \(result).") + } + } + ) .navigationTitle("Location History \(node.positions?.count ?? 0) Points") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: diff --git a/Meshtastic/Views/Nodes/TelemetryLog.swift b/Meshtastic/Views/Nodes/TelemetryLog.swift index 9f5a7c12..989505e1 100644 --- a/Meshtastic/Views/Nodes/TelemetryLog.swift +++ b/Meshtastic/Views/Nodes/TelemetryLog.swift @@ -11,6 +11,9 @@ struct TelemetryLog: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager + @State var isExporting = false + @State var exportString = "" + var node: NodeInfoEntity var body: some View { @@ -334,6 +337,19 @@ struct TelemetryLog: View { } } } + Button { + + exportString = TelemetryToCsvFile(telemetry: node.telemetries!.array as! [TelemetryEntity], metricsType: 0) + isExporting = true + + } label: { + + Label("Export", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() .navigationTitle("Telemetry Log \(node.telemetries?.count ?? 0) Readings") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: @@ -346,5 +362,24 @@ struct TelemetryLog: View { self.bleManager.context = context } + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(node.user!.longName ?? "Node") Telemetry Log"), + onCompletion: { result in + + if case .success = result { + + print("Telemetry log download succeeded.") + + self.isExporting = false + + } else { + + print("Telemetry log download failed: \(result).") + } + } + ) } } diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 103fabf1..47b3eea2 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -302,7 +302,7 @@ struct CannedMessagesConfig: View { if rotary1Enabled { /// Input event origin accepted by the canned messages - /// Can be e.g. "rotEnc1", "upDownEnc1", "cardkb", "faceskb" 623or keyword "_any" + /// Can be e.g. "rotEnc1", "upDownEnc1", "cardkb", "faceskb" or keyword "_any" cmc.allowInputSource = "rotEnc1" } else if updown1Enabled { @@ -373,6 +373,9 @@ struct CannedMessagesConfig: View { // RAK Rotary Encoder updown1Enabled = true rotary1Enabled = false + inputbrokerEventCw = InputEventChars.keyUp.rawValue + inputbrokerEventCcw = InputEventChars.keyDown.rawValue + inputbrokerEventPress = InputEventChars.keySelect.rawValue } else if newPreset == 2 { diff --git a/Meshtastic/Views/Settings/SaveChannelQRCode.swift b/Meshtastic/Views/Settings/SaveChannelQRCode.swift index 7d9246a6..9a329091 100644 --- a/Meshtastic/Views/Settings/SaveChannelQRCode.swift +++ b/Meshtastic/Views/Settings/SaveChannelQRCode.swift @@ -8,7 +8,7 @@ import SwiftUI struct SaveChannelQRCode: View { - @State var channelHash: String = "empty hash" + var channelHash: URL? var body: some View { @@ -24,7 +24,7 @@ struct SaveChannelQRCode: View { .font(.callout) .padding() - Text(channelHash) + Text(String(channelHash?.path ?? "empty")) .font(.title2) .padding()