From 26e785926f89cacb3dacba55f8ec38e249e1f4db Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 23 Jun 2024 07:09:14 -0700 Subject: [PATCH] OS Log Viewer, device serial log over BLE --- Meshtastic.xcodeproj/project.pbxproj | 48 ++-- Meshtastic/Export/WriteCsvFile.swift | 22 ++ Meshtastic/Extensions/Logger.swift | 75 ++++++ Meshtastic/Helpers/BLEManager.swift | 78 +++++- Meshtastic/Helpers/Logger.swift | 19 -- .../Views/Settings/AdminMessageList.swift | 80 ------ Meshtastic/Views/Settings/AppLog.swift | 227 ++++++++++++++++++ .../Views/Settings/Logs/AppLogFilter.swift | 141 +++++++++++ .../Views/Settings/Logs/LogDetail.swift | 158 ++++++++++++ Meshtastic/Views/Settings/Settings.swift | 20 +- 10 files changed, 743 insertions(+), 125 deletions(-) create mode 100644 Meshtastic/Extensions/Logger.swift delete mode 100644 Meshtastic/Helpers/Logger.swift delete mode 100644 Meshtastic/Views/Settings/AdminMessageList.swift create mode 100644 Meshtastic/Views/Settings/AppLog.swift create mode 100644 Meshtastic/Views/Settings/Logs/AppLogFilter.swift create mode 100644 Meshtastic/Views/Settings/Logs/LogDetail.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index a6703a88..b40b557b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 25183D462C0A6D97001E31D5 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25183D452C0A6D97001E31D5 /* Logger.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */; }; @@ -33,7 +32,6 @@ DD0E20FD2B87090400F2D100 /* clientonly.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0E20FA2B87090400F2D100 /* clientonly.pb.swift */; }; DD0E20FE2B87090400F2D100 /* paxcount.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0E20FB2B87090400F2D100 /* paxcount.pb.swift */; }; DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */ = {isa = PBXBuildFile; fileRef = DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */; }; - DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */; }; DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */; }; DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD15E4F22B8BA56E00654F61 /* PaxCounterConfig.swift */; }; DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD15E4F42B8BFC8E00654F61 /* PaxCounterLog.swift */; }; @@ -168,6 +166,10 @@ DDCDC6CB29481FCC004C1DDA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DDCDC6CD29481FCC004C1DDA /* Localizable.strings */; }; DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */; }; DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD43FE22A78C8900083A3E9 /* MqttClientProxyManager.swift */; }; + DDD5BB092C285DDC007E03CA /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB082C285DDC007E03CA /* AppLog.swift */; }; + 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 */; }; 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 */; }; @@ -245,7 +247,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 25183D452C0A6D97001E31D5 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = ""; }; @@ -274,7 +275,6 @@ DD0E20FF2B892E1300F2D100 /* MeshtasticDataModelV 28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 28.xcdatamodel"; sourceTree = ""; }; DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DeviceHardware.json; sourceTree = ""; }; DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV14.xcdatamodel; sourceTree = ""; }; - DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminMessageList.swift; sourceTree = ""; }; DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionPopover.swift; sourceTree = ""; }; DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV15.xcdatamodel; sourceTree = ""; }; DD15E4F22B8BA56E00654F61 /* PaxCounterConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaxCounterConfig.swift; sourceTree = ""; }; @@ -443,6 +443,10 @@ DDD28D372C0CD2670063CFA3 /* MeshtasticDataModelV 37.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 37.xcdatamodel"; sourceTree = ""; }; DDD3BBD4292D763200D609B3 /* MeshtasticTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeshtasticTests.swift; sourceTree = ""; }; DDD43FE22A78C8900083A3E9 /* MqttClientProxyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MqttClientProxyManager.swift; sourceTree = ""; }; + DDD5BB082C285DDC007E03CA /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; + DDD5BB0A2C285E45007E03CA /* LogDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDetail.swift; sourceTree = ""; }; + DDD5BB0C2C285F00007E03CA /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogFilter.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 = ""; }; @@ -606,9 +610,9 @@ DD4A911C2708C57100501B7E /* Settings */ = { isa = PBXGroup; children = ( + DDD5BB0E2C285F92007E03CA /* Logs */, DD93800C2BA74CE3008BEC06 /* Channels */, DD97E96728EFE9A00056DDA4 /* About.swift */, - DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */, DD4A911D2708C65400501B7E /* AppSettings.swift */, DDAB580C2B0DAA9E00147258 /* Routes.swift */, DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */, @@ -622,6 +626,7 @@ DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, DD61937A2863876A00E59241 /* Config */, DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */, + DDD5BB082C285DDC007E03CA /* AppLog.swift */, ); path = Settings; sourceTree = ""; @@ -917,19 +922,19 @@ DDC2E18D26CE25CB0042C5E4 /* Helpers */ = { isa = PBXGroup; children = ( - DDF45C332BC1A48E005ED5F2 /* MQTTIcon.swift */, - DD5E523D298F5A7D00D21B61 /* Weather */, + DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */, + DDB75A222A13CDA9006ED576 /* BatteryLevelCompact.swift */, + DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */, DD47E3D526F17ED900029299 /* CircleText.swift */, DDF924C926FBB953009FE055 /* ConnectedDevice.swift */, - DDC3B273283F411B00AC321C /* LastHeardText.swift */, DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */, DDB6ABDA28B0AC6000384BA1 /* DistanceText.swift */, - DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */, - DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */, - DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */, - DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */, + DDC3B273283F411B00AC321C /* LastHeardText.swift */, DDB75A202A12B954006ED576 /* LoRaSignalStrength.swift */, - DDB75A222A13CDA9006ED576 /* BatteryLevelCompact.swift */, + DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */, + DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */, + DDF45C332BC1A48E005ED5F2 /* MQTTIcon.swift */, + DD5E523D298F5A7D00D21B61 /* Weather */, ); path = Helpers; sourceTree = ""; @@ -947,7 +952,6 @@ DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */, DDDB443C29F6592F00EE2349 /* NetworkManager.swift */, DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, - 25183D452C0A6D97001E31D5 /* Logger.swift */, ); path = Helpers; sourceTree = ""; @@ -970,6 +974,15 @@ path = Mqtt; sourceTree = ""; }; + DDD5BB0E2C285F92007E03CA /* Logs */ = { + isa = PBXGroup; + children = ( + DDD5BB0A2C285E45007E03CA /* LogDetail.swift */, + DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */, + ); + path = Logs; + sourceTree = ""; + }; DDDB26402AABEF7B003AFCB7 /* Helpers */ = { isa = PBXGroup; children = ( @@ -1003,6 +1016,7 @@ DD1933772B084F4200771CD5 /* Measurement.swift */, DDFFA7462B3A7F3C004730DB /* Bundle.swift */, DDF45C362BC46A5A005ED5F2 /* TimeZone.swift */, + DDD5BB0C2C285F00007E03CA /* Logger.swift */, ); path = Extensions; sourceTree = ""; @@ -1237,6 +1251,7 @@ DD93800E2BA74D0C008BEC06 /* ChannelForm.swift in Sources */, DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */, DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, + DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, @@ -1258,8 +1273,10 @@ DD5E5213298EE33B00D21B61 /* deviceonly.pb.swift in Sources */, DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */, DD5E5208298EE33B00D21B61 /* rtttl.pb.swift in Sources */, + DDD5BB0D2C285F00007E03CA /* Logger.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, + DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */, DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */, DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */, B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */, @@ -1307,7 +1324,6 @@ D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */, DD0E20FE2B87090400F2D100 /* paxcount.pb.swift in Sources */, DD5E520A298EE33B00D21B61 /* channel.pb.swift in Sources */, - 25183D462C0A6D97001E31D5 /* Logger.swift in Sources */, DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */, DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */, DD47E3D626F17ED900029299 /* CircleText.swift in Sources */, @@ -1323,6 +1339,7 @@ D93068DD2B81CA820066FBC8 /* ConfigHeader.swift in Sources */, DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */, D9BC22DB2B7DE8E2006A37D5 /* TileDownloadStatus.swift in Sources */, + DDD5BB092C285DDC007E03CA /* AppLog.swift in Sources */, DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */, DD5E5202298EE33B00D21B61 /* admin.pb.swift in Sources */, DDC1B81A2AB5377B00C71E39 /* MessagesTips.swift in Sources */, @@ -1371,7 +1388,6 @@ DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */, DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, - DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */, diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 521dc937..56776554 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -6,6 +6,7 @@ // import SwiftUI +import OSLog func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> String { var csvString: String = "" @@ -64,6 +65,27 @@ func detectionsToCsv(detections: [MessageEntity]) -> String { return csvString } +func logToCsvFile(log: [OSLogEntryLog]) -> String { + var csvString: String = "" + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") + // Create PAX Header + csvString = "Process, Category, Level, Message, \("timestamp".localized)" + for l in log { + csvString += "\n" + csvString += String(l.process) + csvString += ", " + csvString += String(l.category) + csvString += ", " + csvString += String(l.level.description) + csvString += ", " + csvString += String(l.composedMessage) + csvString += ", " + csvString += l.date.formattedDate(format: dateFormatString) + } + return csvString +} + func paxToCsvFile(pax: [PaxCounterEntity]) -> String { var csvString: String = "" let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) diff --git a/Meshtastic/Extensions/Logger.swift b/Meshtastic/Extensions/Logger.swift new file mode 100644 index 00000000..fbc68ea7 --- /dev/null +++ b/Meshtastic/Extensions/Logger.swift @@ -0,0 +1,75 @@ +// +// Logger.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/3/24. +// + +import OSLog + +extension Logger { + + /// The logger's subsystem. + private static var subsystem = Bundle.main.bundleIdentifier! + + /// All admin messages + static let admin = Logger(subsystem: subsystem, category: "πŸ› Admin") + + /// All logs related to data such as decoding error, parsing issues, etc. + static let data = Logger(subsystem: subsystem, category: "πŸ—„οΈ Data") + + /// All logs related to the mesh + static let mesh = Logger(subsystem: subsystem, category: "πŸ•ΈοΈ Mesh") + + /// All logs related to MQTT + static let mqtt = Logger(subsystem: subsystem, category: "πŸ“± MQTT") + + /// All detailed logs originating from the device (radio). + static let radio = Logger(subsystem: subsystem, category: "πŸ“Ÿ Radio") + + /// All logs related to services such as network calls, location, etc. + static let services = Logger(subsystem: subsystem, category: "🍏 Services") + + /// All logs related to tracking and analytics. + static let statistics = Logger(subsystem: subsystem, category: "πŸ“Š Stats") + + + /// Fetch from the logstore + static public func fetch(predicateFormat: String) async throws -> [OSLogEntryLog] { + + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = store.position(timeIntervalSinceLatestBoot: 0) + //let calendar = Calendar.current + //let dayAgo = calendar.date(byAdding: .day, value: -1, to: Date.now) + //let position = store.position(date: dayAgo!) + let predicate = NSPredicate(format: predicateFormat) + let entries = try store.getEntries(at: position, matching: predicate) + + var logs: [OSLogEntryLog] = [] + for entry in entries { + + try Task.checkCancellation() + + if let log = entry as? OSLogEntryLog { + logs.append(log) + } + } + + if logs.isEmpty { logs = [] } + return logs + } +} + +extension OSLogEntryLog.Level { + var description: String { + switch self { + case .undefined: "undefined" + case .debug: "🩺 Debug" + case .info: "ℹ️ Info" + case .notice: "⚠️ Notice" + case .error: "🚨 Error" + case .fault: "πŸ’₯ Fault" + @unknown default: "default" + } + } +} diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index caab235d..bda553a3 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -5,6 +5,7 @@ import SwiftUI import MapKit import CocoaMQTT import OSLog +import RegexBuilder // --------------------------------------------------------------------------------------- // Meshtastic BLE Device Manager @@ -41,11 +42,13 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var TORADIO_characteristic: CBCharacteristic! var FROMRADIO_characteristic: CBCharacteristic! var FROMNUM_characteristic: CBCharacteristic! + var LOGRADIO_characteristic: CBCharacteristic! let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") let TORADIO_UUID = CBUUID(string: "0xF75C76D2-129E-4DAD-A1DD-7866124401E7") let FROMRADIO_UUID = CBUUID(string: "0x2C55E69E-4993-11ED-B878-0242AC120002") let EOL_FROMRADIO_UUID = CBUUID(string: "0x8BA2BCC2-EE02-4A55-A531-C525C5E454D5") let FROMNUM_UUID = CBUUID(string: "0xED9DA18C-A800-4F66-A670-AA7547E34453") + let LOGRADIO_UUID = CBUUID(string: "0x6C6FD238-78FA-436B-AACF-15C5BE1EF2E2") // MARK: init BLEManager override init() { @@ -277,7 +280,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } guard let services = peripheral.services else { return } for service in services where service.uuid == meshtasticServiceCBUUID { - peripheral.discoverCharacteristics([TORADIO_UUID, FROMRADIO_UUID, FROMNUM_UUID], for: service) + peripheral.discoverCharacteristics([TORADIO_UUID, FROMRADIO_UUID, FROMNUM_UUID, LOGRADIO_UUID], for: service) Logger.services.info("βœ… BLE Service for Meshtastic discovered by \(peripheral.name ?? "Unknown")") } } @@ -310,6 +313,11 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate Logger.services.info("βœ… BLE did discover FROMNUM (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown")") 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")") + LOGRADIO_characteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) default: break @@ -514,6 +522,74 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } switch characteristic.uuid { + case LOGRADIO_UUID: + if (characteristic.value == nil || characteristic.value!.isEmpty) { + return + } + let coordsSearch = Regex { + Capture { + Regex { + "lat=" + OneOrMore(.digit) + } + } + Capture {" "} + Capture { + Regex { + "long=" + OneOrMore(.digit) + } + } + } + .anchorsMatchLineEndings() + if var log = String(data: characteristic.value!, encoding: .utf8) { + + /// Debug Log Level + if (log.starts(with: "DEBUG |")) { + do { + let logString = log + if let coordsMatch = try coordsSearch.firstMatch(in: logString) { + log = "\(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces))" + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("πŸ›°οΈ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)") + }else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🐞 \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + + } catch { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🐞 \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } else if (log.starts(with: "INFO |")) { + do { + let logString = log + if let coordsMatch = try coordsSearch.firstMatch(in: logString) { + log = "\(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces))" + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.info("πŸ›°οΈ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)") + } else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🐞 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } catch { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.info("βœ… \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } else if (log.starts(with: "WARN |")) { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.warning("⚠️ \(log.replacingOccurrences(of: "WARN |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else if (log.starts(with: "ERROR |")) { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.error("πŸ’₯ \(log.replacingOccurrences(of: "ERROR |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else if (log.starts(with: "CRIT |")) { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.critical("πŸ’₯ \(log.replacingOccurrences(of: "CRIT |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("πŸ“Ÿ \(log)") + } + } case FROMRADIO_UUID: diff --git a/Meshtastic/Helpers/Logger.swift b/Meshtastic/Helpers/Logger.swift deleted file mode 100644 index bf9ad575..00000000 --- a/Meshtastic/Helpers/Logger.swift +++ /dev/null @@ -1,19 +0,0 @@ -import OSLog - -extension Logger { - - /// The logger's subsystem. - private static var subsystem = Bundle.main.bundleIdentifier! - - /// All logs related to data such as decoding error, parsing issues, etc. - static let data = Logger(subsystem: subsystem, category: "πŸ—„οΈ Data") - - /// All logs related to the mesh - static let mesh = Logger(subsystem: subsystem, category: "πŸ•ΈοΈ Mesh") - - /// All logs related to services such as network calls, location, etc. - static let services = Logger(subsystem: subsystem, category: "🍏 Services") - - /// All logs related to tracking and analytics. - static let statistics = Logger(subsystem: subsystem, category: "πŸ“ˆ Stats") -} diff --git a/Meshtastic/Views/Settings/AdminMessageList.swift b/Meshtastic/Views/Settings/AdminMessageList.swift deleted file mode 100644 index fc4fa5f8..00000000 --- a/Meshtastic/Views/Settings/AdminMessageList.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// AdminMessageList.swift -// Meshtastic -// -// Created by Garth Vander Houwen on 7/2/22. -// -/* - Abstract: - A view showing the details for a node. - */ - -import SwiftUI -import MapKit -import CoreLocation - -struct AdminMessageList: View { - - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - - var user: UserEntity? - - var body: some View { - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmssa", options: 0, locale: Locale.current) - let localeTimeFormat = DateFormatter.dateFormat(fromTemplate: "h:mm:ss a", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss a") - let timeFormatString = (localeTimeFormat ?? "h:mm:ss a") - - List { - if user != nil { - - ForEach( user!.adminMessageList.reversed() ) { am in - - VStack(alignment: .leading) { - - Text("\(am.adminDescription ?? "unknown".localized)") - .font(.caption) - - Text("Sent \(Date(timeIntervalSince1970: TimeInterval(am.messageTimestamp)).formattedDate(format: dateFormatString))") - .foregroundColor(.gray) - .font(.caption2) - - HStack(spacing: 0) { - let ackErrorVal = RoutingError(rawValue: Int(am.ackError)) - - if am.ackTimestamp > 0 { - if am.realACK { - - Text(ackErrorVal?.display ?? "Empty Ack Error") - .foregroundColor(am.receivedACK ? .gray : .red) - .font(.caption2) - } else { - Text("Implicit ACK from another node") - .foregroundColor(.orange) - .font(.caption2) - } - } - - if am.receivedACK && am.ackTimestamp > 0 { - Text(" \(Date(timeIntervalSince1970: TimeInterval(am.ackTimestamp)).formattedDate(format: timeFormatString))") - .foregroundColor(am.realACK ? .gray : .orange) - .font(.caption2) - } - } - } - } - } - } - .navigationTitle("admin.log") - .navigationBarItems(trailing: - ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") - }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } - } -} diff --git a/Meshtastic/Views/Settings/AppLog.swift b/Meshtastic/Views/Settings/AppLog.swift new file mode 100644 index 00000000..45652edf --- /dev/null +++ b/Meshtastic/Views/Settings/AppLog.swift @@ -0,0 +1,227 @@ +// +// AppLog.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 6/4/24. +// + +import SwiftUI +import OSLog + +/// Needed for TableColumnForEach +@available(iOS 17.4, *) +struct AppLog: View { + + @State private var logs: [OSLogEntryLog] = [] + @State private var sortOrder = [KeyPathComparator(\OSLogEntryLog.date, order: .reverse)] + @State private var selection: OSLogEntry.ID? + @State private var selectedLog: OSLogEntryLog? + @State private var presentingErrorDetails: Bool = false + @State private var searchText = "" + @State private var category: Int = -1 + @State private var level: Int = -1 + @State var isExporting = false + @State var exportString = "" + @State var isEditingFilters = false + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + private let dateFormatStyle = Date.FormatStyle() + .hour(.twoDigits(amPM: .omitted)) + .minute() + .second() + .secondFraction(.fractional(3)) + + var body: some View { + + Table(logs, selection: $selection, sortOrder: $sortOrder) { + if idiom != .phone { + TableColumn("log.time") { value in + Text(value.date.formatted(dateFormatStyle)) + } + .width(min: 125, max: 150) + TableColumn("log.category", value: \.category) + .width(min: 125, max: 150) + TableColumn("log.level") { value in + Text(value.level.description) + } + .width(min: 75, max: 100) + + } + TableColumn("log.message", value: \.composedMessage) { value in + Text(value.composedMessage) + .font(idiom == .phone ? .caption : .body) + } + .width(ideal: 200, max: .infinity) + } + .monospaced() + .sheet(isPresented: $isEditingFilters) { + AppLogFilter(category: $category, level: $level) + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + HStack { + Button(action: { + withAnimation { + isEditingFilters = !isEditingFilters + } + }) { + Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + + } + .controlSize(.regular) + .padding(5) + } + .padding(.bottom, 5) + .padding(.trailing, 5) + .searchable(text: $searchText, placement: .navigationBarDrawer, prompt: "Search") + .disabled(selection != nil) + .overlay { + if logs.isEmpty { + ContentUnavailableView("No Logs Available", systemImage: "scroll") + } + } + .refreshable { + await logs = searchAppLogs() + logs.sort(using: sortOrder) + } + .onChange(of: sortOrder) { _, sortOrder in + withAnimation { + logs.sort(using: sortOrder) + } + } + .onChange(of: searchText) { _ in + Task { + await logs = searchAppLogs() + logs.sort(using: sortOrder) + } + } + .onChange(of: category) { _ in + Task { + await logs = searchAppLogs() + logs.sort(using: sortOrder) + } + } + .onChange(of: level) { _ in + Task { + await logs = searchAppLogs() + logs.sort(using: sortOrder) + } + } + .onChange(of: selection) { newSelection in + presentingErrorDetails = true + let log = logs.first { + $0.id == newSelection + } + selectedLog = log + } + .sheet(item: $selectedLog, onDismiss: didDismiss) { log in + LogDetail(log: log) + .padding() + } + .task { + logs = await searchAppLogs() + logs.sort(using: sortOrder) + } + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("Meshtastic Application Logs"), + onCompletion: { result in + switch result { + case .success: + self.isExporting = false + Logger.services.info("Application log download succeeded.") + case .failure(let error): + Logger.services.error("Application log download failed: \(error.localizedDescription)") + } + } + ) + .navigationBarTitle("Debug Logs\(logs.isEmpty ? "" : " (\(logs.count))")", displayMode: .inline) + .toolbar { +#if targetEnvironment(macCatalyst) + ToolbarItem(placement: .topBarLeading) { + Button(action: { + Task { + await logs = searchAppLogs() + logs.sort(using: sortOrder) + } + }) { + Image(systemName: "arrow.clockwise.circle") + } + } +#endif + if !logs.isEmpty { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + exportString = logToCsvFile(log: logs) + isExporting = true + }) { + Image(systemName: "square.and.arrow.down") + } + } + } + } + } + + func didDismiss() { + selection = nil + selectedLog = nil + } +} + +@available(iOS 17.4, *) +extension AppLog { + @MainActor + private func searchAppLogs() async -> [OSLogEntryLog] { + do { + /// Case Insensitive Search Text Predicates + let searchPredicates = ["composedMessage", "category", "subsystem", "process"].map { property in + return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) + } + /// Create a compound predicate using each text search preicate as an OR + let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) + /// Create an array of predicates to hold our AND predicates + var predicates: [NSPredicate] = [] + /// Subsystem Predicate + let subsystemPredicate = NSPredicate(format: "subsystem IN %@", ["com.apple.SwiftUI", "com.apple.coredata", "gvh.MeshtasticClient"]) + predicates.append(subsystemPredicate) + /// Category + if category > -1 { + let categoryPredicate = NSPredicate(format: "category == %@", LogCategories(rawValue: category)!.description) + predicates.append(categoryPredicate) + } + /// Log Level + if level > -1 { + let levelPredicate = NSPredicate(format: "messageType == %@", LogLevels(rawValue: level)?.level ?? "info") + predicates.append(levelPredicate) + } + + if predicates.count > 0 || !searchText.isEmpty { + if !searchText.isEmpty { + let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates) + let compoundPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates]) + let logs = try await Logger.fetch(predicateFormat: compoundPredicate.predicateFormat) + return logs + } else { + let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates) + let logs = try await Logger.fetch(predicateFormat: filterPredicates.predicateFormat) + return logs + } + } else { + let logs = try await Logger.fetch(predicateFormat: subsystemPredicate.predicateFormat) + + return logs + } + + } catch { + return [] + } + } +} + +extension OSLogEntry: Identifiable { } diff --git a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift new file mode 100644 index 00000000..39e5e113 --- /dev/null +++ b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift @@ -0,0 +1,141 @@ +// +// AppLogFilter.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 6/15/24. +// + +import Foundation +import OSLog +import SwiftUI + +enum LogCategories: Int, CaseIterable, Identifiable { + + case admin = 0 + case data = 1 + case mesh = 2 + case mqtt = 3 + case radio = 4 + case services = 5 + case stats = 6 + + var id: Int { self.rawValue } + var description: String { + switch self { + + case .admin: + return "πŸ› Admin" + case .data: + return "πŸ—„οΈ Data" + case .mesh: + return "πŸ•ΈοΈ Mesh" + case .mqtt: + return "πŸ“± MQTT" + case .radio: + return "πŸ“Ÿ Radio" + case .services: + return "🍏 Services" + case .stats: + return "πŸ“Š Stats" + } + } +} + +enum LogLevels: Int, CaseIterable, Identifiable { + + case debug = 0 + case info = 1 + case notice = 2 + case error = 3 + case fault = 4 + + var id: Int { self.rawValue } + var level: String { + switch self { + case .debug: + return "debug" + case .info: + return "info" + case .notice: + return "notice" + case .error: + return "error" + case .fault: + return "fault" + } + } + var description: String { + switch self { + case .debug: + return "🩺 Debug" + case .info: + return "ℹ️ Info" + case .notice: + return "⚠️ Notice" + case .error: + return "🚨 Error" + case .fault: + return "πŸ’₯ Fault" + } + } +} + +struct AppLogFilter: View { + + @Environment(\.dismiss) private var dismiss + /// Filters + var filterTitle = "App Log Filters" + //@Binding + @Binding var category: Int + @Binding var level: Int + + var body: some View { + + NavigationStack { + Form { + Section(header: Text(filterTitle)) { + HStack { + Label("Category", systemImage: "square.grid.2x2") + Picker("", selection: $category) { + Text("All Categories") + .tag(-1) + ForEach(LogCategories.allCases) { lc in + Text("\(lc.description)") + + } + } + .pickerStyle(DefaultPickerStyle()) + } + + HStack { + Label("Level", systemImage: "stairs") + Picker("", selection: $level) { + Text("All Levels") + .tag(-1) + ForEach(LogLevels.allCases) { ll in + Text("\(ll.description)") + //.tag(ll.rawValue) + + } + } + .pickerStyle(DefaultPickerStyle()) + } + } + } +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif + } + .presentationDetents([.fraction(0.6), .fraction(0.75)]) + .presentationDragIndicator(.visible) + } +} diff --git a/Meshtastic/Views/Settings/Logs/LogDetail.swift b/Meshtastic/Views/Settings/Logs/LogDetail.swift new file mode 100644 index 00000000..7308da5b --- /dev/null +++ b/Meshtastic/Views/Settings/Logs/LogDetail.swift @@ -0,0 +1,158 @@ +// +// LogDetail.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/5/24. +// + +import SwiftUI +import MapKit +import OSLog + +//@available(iOS 17.0, macOS 14.0, *) +struct LogDetail: View { + + @Environment(\.dismiss) private var dismiss + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + var log: OSLogEntryLog + var font: Font = .title2 + + private let dateFormatStyle = Date.FormatStyle() + .day(.defaultDigits) + .month(.defaultDigits) + .year(.twoDigits) + .hour(.twoDigits(amPM: .omitted)) + .minute() + .second() + .secondFraction(.fractional(3)) + + var body: some View { + + VStack { + HStack { + Text("OS Log Entry Details") + .font(.largeTitle) + } + Divider() + HStack(alignment: .top) { + VStack(alignment: .leading) { + List { + /// Time + Label { + Text("log.time".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.date.formatted(dateFormatStyle)) + .font(idiom == .phone ? .caption : .title) + } icon: { + Image(systemName: "timer") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + .padding(.bottom, 5) + .listSectionSeparator(.hidden, edges: .top) + .listSectionSeparator(.visible, edges: .bottom) + /// Subsystem + Label { + Text("log.subsystem".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.subsystem) + .font(idiom == .phone ? .caption : .title) + } icon: { + Image(systemName: "gear") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .caption : .title) + .frame(width: 35) + } + .padding(.bottom, 5) + .listRowSeparator(.visible) + /// Process + Label { + Text("log.process".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.process) + .font(idiom == .phone ? .caption : .title) + } icon: { + Image(systemName: "tag") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .caption : .title) + .frame(width: 35) + } + .padding(.bottom, 5) + .listRowSeparator(.visible) + /// Category + Label { + Text("log.category".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.category) + .font(idiom == .phone ? .caption : .title) + } icon: { + Image(systemName: "square.grid.2x2") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .caption : .title) + .frame(width: 35) + } + .padding(.bottom, 5) + .listRowSeparator(.visible) + /// Level + Label { + Text("log.level".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.level.description) + .font(idiom == .phone ? .caption : .title) + } icon: { + Image(systemName: "stairs") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .caption : .title) + .frame(width: 35) + } + .padding(.bottom, 5) + .listRowSeparator(.visible) + /// message + Label { + Text("log.message".localized + ":") + .font(idiom == .phone ? .caption : .title) + .frame(width: idiom == .phone ? 115 : 190, alignment: .trailing) + Text(log.composedMessage) + .textSelection(.enabled) + .font(idiom == .phone ? .body : .title) + .padding(.bottom, 5) + + } icon: { + Image(systemName: "text.bubble") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + .listRowSeparator(.hidden) + + } + .listStyle(.plain) + + } + Spacer() + } + .padding(.top) +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif + } + .monospaced() + .presentationDetents([.fraction(0.75), .fraction(0.85), .fraction(1.0)]) + .presentationDragIndicator(.visible) + } +} diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index f2a38030..2817e8b6 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -48,6 +48,7 @@ struct Settings: View { case meshLog case adminMessageLog case about + case appLog } var body: some View { NavigationSplitView { @@ -412,17 +413,18 @@ struct Settings: View { } } .tag(SettingsSidebar.meshLog) - NavigationLink { - let connectedNode = nodes.first(where: { $0.num == preferredNodeNum }) - AdminMessageList(user: connectedNode?.user) - } label: { - Label { - Text("admin.log") - } icon: { - Image(systemName: "building.columns") + if #available (iOS 17.4, *) { + NavigationLink { + AppLog() + } label: { + Label { + Text("Debug Logs") + } icon: { + Image(systemName: "stethoscope") + } } + .tag(SettingsSidebar.appLog) } - .tag(SettingsSidebar.adminMessageLog) } Section(header: Text("Firmware")) { NavigationLink {