From a1a38955a96d30f5f1efac8bf3db364aa6d94791 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 15 Jan 2025 13:33:25 -0600 Subject: [PATCH 1/5] Bump version --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 9c1c0d6d..238ed7d6 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1755,7 +1755,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.16; + MARKETING_VERSION = 2.5.17; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1789,7 +1789,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.16; + MARKETING_VERSION = 2.5.17; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1821,7 +1821,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.16; + MARKETING_VERSION = 2.5.17; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1854,7 +1854,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.16; + MARKETING_VERSION = 2.5.17; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 805dca65626ec1dba2031b8724020ccc8d62dcdb Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 15 Jan 2025 15:57:48 -0600 Subject: [PATCH 2/5] Add to channel notification as well --- Meshtastic/Helpers/MeshPackets.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 0310c9cc..d7a80c17 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -987,8 +987,8 @@ func textMessageAppPacket( path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)", messageId: newMessage.messageId, channel: newMessage.channel, - userNum: Int64(newMessage.fromUser?.userId ?? "0") - ) + userNum: Int64(newMessage.fromUser?.userId ?? "0"), + critical: critical) ] manager.schedule() Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") From bec24ba93cc49d29293a800608b429be1ec1f3b5 Mon Sep 17 00:00:00 2001 From: Matthew Davies Date: Fri, 24 Jan 2025 22:53:10 -0800 Subject: [PATCH 3/5] Add power metrics widgets for node detail screen --- Meshtastic.xcodeproj/project.pbxproj | 8 +- .../CoreData/NodeInfoEntityExtension.swift | 9 + Meshtastic/Helpers/MeshPackets.swift | 35 +- .../Meshtastic.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 2 +- .../contents | 493 ++++++++++++++++++ Meshtastic/Views/Helpers/PowerMetrics.swift | 97 ++++ .../Views/Nodes/Helpers/NodeDetail.swift | 9 + 8 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents create mode 100644 Meshtastic/Views/Helpers/PowerMetrics.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 238ed7d6..672c6f72 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 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 */; }; + 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */; }; B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; }; @@ -290,6 +291,8 @@ 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 = ""; }; + 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = ""; }; + 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = ""; }; BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = ""; }; @@ -1022,6 +1025,7 @@ DDF45C332BC1A48E005ED5F2 /* MQTTIcon.swift */, DD5E523D298F5A7D00D21B61 /* Weather */, DD6F65712C6AB8EC0053C113 /* SecureInput.swift */, + 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */, ); path = Helpers; sourceTree = ""; @@ -1492,6 +1496,7 @@ DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */, DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, + 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */, 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */, DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */, DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */, @@ -1966,6 +1971,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */, DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */, DDDFE7402D0D4A070044463C /* MeshtasticDataModelV 47.xcdatamodel */, DD0BE30C2CB785D8000BA445 /* MeshtasticDataModelV 46.xcdatamodel */, @@ -2015,7 +2021,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */; + currentVersion = 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift index 7d313191..b1bbb8c6 100644 --- a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift @@ -22,6 +22,10 @@ extension NodeInfoEntity { return self.telemetries?.filtered(using: NSPredicate(format: "metricsType == 1")).lastObject as? TelemetryEntity } + var latestPowerMetrics: TelemetryEntity? { + return self.telemetries?.filtered(using: NSPredicate(format: "metricsType == 2")).lastObject as? TelemetryEntity + } + var hasPositions: Bool { return self.positions?.count ?? 0 > 0 } @@ -39,6 +43,11 @@ extension NodeInfoEntity { return user?.sensorMessageList.count ?? 0 > 0 } + var hasPowerMetrics: Bool { + let powerMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 2 } + return powerMetrics?.count ?? 0 > 0 + } + var hasTraceRoutes: Bool { let routes = traceRoutes?.filter { ($0 as AnyObject).response } return routes?.count ?? 0 > 0 diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index d7a80c17..b792db29 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -682,7 +682,8 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let logString = String.localizedStringWithFormat("mesh.log.telemetry.received %@".localized, String(packet.from)) MeshLogger.log("📈 \(logString)") - if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { + + if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { /// Other unhandled telemetry packets return } @@ -736,6 +737,38 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) telemetry.metricsType = 4 Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + Logger.data.info("📈 [Power Metrics] Received for Node: \(packet.from.toHex(), privacy: .public)") + + if telemetryMessage.powerMetrics.hasCh1Voltage { + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.ch1Voltage + telemetry.metricsType = 2 + } + + if telemetryMessage.powerMetrics.hasCh1Current { + telemetry.powerCh1Current = telemetryMessage.powerMetrics.ch1Current + telemetry.metricsType = 2 + } + + if telemetryMessage.powerMetrics.hasCh2Voltage { + telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.ch2Voltage + telemetry.metricsType = 2 + } + + if telemetryMessage.powerMetrics.hasCh1Current { + telemetry.powerCh2Current = telemetryMessage.powerMetrics.ch2Current + telemetry.metricsType = 2 + } + + if telemetryMessage.powerMetrics.hasCh3Voltage { + telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.ch3Voltage + telemetry.metricsType = 2 + } + + if telemetryMessage.powerMetrics.hasCh3Current { + telemetry.powerCh3Current = telemetryMessage.powerMetrics.ch3Current + telemetry.metricsType = 2 + } } telemetry.snr = packet.rxSnr telemetry.rssi = packet.rxRssi diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index a702965e..0b4b8e13 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 48.xcdatamodel + MeshtasticDataModelV 49.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents index 709d5943..1ad6e791 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents @@ -1,5 +1,5 @@ - + diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents new file mode 100644 index 00000000..5f12d9d0 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Views/Helpers/PowerMetrics.swift b/Meshtastic/Views/Helpers/PowerMetrics.swift new file mode 100644 index 00000000..4b4076d5 --- /dev/null +++ b/Meshtastic/Views/Helpers/PowerMetrics.swift @@ -0,0 +1,97 @@ +// +// PowerMetrics.swift +// Meshtastic +// +// Created by Matthew Davies on 1/24/25. +// + +import Foundation +import SwiftUI + +struct PowerMetrics: View { + private let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) + + let metric: TelemetryEntity + + var body: some View { + + LazyVGrid(columns: gridItemLayout) { + + + if(metric.powerCh1Voltage != nil) { + PowerMetricCompactWidget( + type: .voltage, + value: metric.powerCh1Voltage, + title: "Channel 1 Voltage" + ) + } + + if(metric.powerCh1Current != nil) { + PowerMetricCompactWidget( + type: .current, + value: metric.powerCh1Current, + title: "Channel 1 Current" + ) + } + + if(metric.powerCh2Voltage != nil) { + PowerMetricCompactWidget( + type: .voltage, + value: metric.powerCh2Voltage, + title: "Channel 2 Voltage" + ) + } + + if(metric.powerCh2Current != nil) { + PowerMetricCompactWidget( + type: .current, + value: metric.powerCh2Current, + title: "Channel 2 Current" + ) + } + + if(metric.powerCh3Voltage != nil) { + PowerMetricCompactWidget( + type: .voltage, + value: metric.powerCh3Voltage, + title: "Channel 3 Voltage" + ) + } + + if(metric.powerCh3Current != nil) { + PowerMetricCompactWidget( + type: .current, + value: metric.powerCh3Current, + title: "Channel 3 Current" + ) + } + } + } +} + +enum PowerMetricType: String { + case current = "current" + case voltage = "voltage" +} + +struct PowerMetricCompactWidget: View { + let type: PowerMetricType + let value: Float + let title: String + var body: some View { + VStack(alignment: .leading) { + HStack(spacing: 5.0) { + Image(systemName: type == .current ? "bolt.fill" : "powerplug.fill") + .foregroundColor(.accentColor) + .font(.callout) + Text(title) + .font(.caption) + } + Text("\(value, specifier: type == .current ? "%.1f" : "%.2f") \(type == .current ? "mA" : "V")") + .font(type == .current ? .system(size: 35) : .system(size: 30)) + } + .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) + .padding() + .background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 706b9d6e..c8dc8f3d 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -237,6 +237,15 @@ struct NodeDetail: View { } } } + if node.hasPowerMetrics && node.latestPowerMetrics != nil { + Section("Power") { + VStack { + if let metric = node.latestPowerMetrics { + PowerMetrics(metric: metric) + } + } + } + } Section("Logs") { // Metrics NavigationLink { From c7a0ac5efbf1fdae0677fd5b9ebea579b5bacd6b Mon Sep 17 00:00:00 2001 From: Matthew Davies Date: Sat, 25 Jan 2025 00:09:10 -0800 Subject: [PATCH 4/5] Add power metric charts --- .../Views/Nodes/Helpers/NodeDetail.swift | 12 ++ Meshtastic/Views/Nodes/PowerMetricsLog.swift | 134 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 Meshtastic/Views/Nodes/PowerMetricsLog.swift diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index c8dc8f3d..e251c64c 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -260,6 +260,18 @@ struct NodeDetail: View { } .disabled(!node.hasDeviceMetrics) + NavigationLink{ + PowerMetricsLog(node: node) + } label: { + Label { + Text("Power Metrics Log") + } icon: { + Image(systemName: "bolt") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasPowerMetrics) + NavigationLink { NodeMapSwiftUI(node: node, showUserLocation: connectedNode?.num ?? 0 == node.num) } label: { diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift new file mode 100644 index 00000000..9feb4b98 --- /dev/null +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -0,0 +1,134 @@ +// +// PowerMetricsLog.swift +// Meshtastic +// +// Created by Matthew Davies on 1/24/25. +// + +import Foundation +import SwiftUI +import Charts + +struct PowerMetricsLog: View { + + @ObservedObject var node: NodeInfoEntity + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)] + @State private var selection: TelemetryEntity.ID? + @State private var chartSelection: Date? + + + @State private var channelSelection = 0 + + var body: some View { + VStack { + if node.hasPowerMetrics { + let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) + let powerMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 2")).reversed() as? [TelemetryEntity] ?? [] + let chartData = powerMetrics + .filter { $0.time != nil && $0.time! >= oneWeekAgo! } + .sorted { $0.time! < $1.time! } + if chartData.count > 0 { + GroupBox(label: Label("\(powerMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { + + // allow switching between different channels + Picker("Select Channel", selection: $channelSelection) { + Text("Channel 1").tag(0) + Text("Channel 2").tag(1) + Text("Channel 3").tag(2) + } + + Chart { + ForEach(chartData, id: \.self) { point in + + let voltage = channelSelection == 0 ? point.powerCh1Voltage : channelSelection == 1 ? point.powerCh2Voltage : point.powerCh3Voltage + let current = channelSelection == 0 ? point.powerCh1Current : channelSelection == 1 ? point.powerCh2Current : point.powerCh3Current + + LineMark( + x: .value("Time", point.time ?? Date()), + y: .value("Voltage", voltage) + ) + .foregroundStyle(by: .value("Series", "Voltage")) + .interpolationMethod(.linear) + .accessibilityLabel("Voltage") + .accessibilityValue("X: \(point.time ?? Date()), Y: \(voltage)") + + LineMark( + x: .value("Time", point.time ?? Date()), + y: .value("Current", current) + ) + .foregroundStyle(by: .value("Series", "Current")) + .interpolationMethod(.linear) + .accessibilityLabel("Current") + .accessibilityValue("X: \(point.time ?? Date()), Y: \(current)") + + } + + if let chartSelection { + RuleMark(x: .value("Second", chartSelection, unit: .second)) + .foregroundStyle(.tertiary.opacity(0.5)) + } + + } + .chartXAxis(content: { + AxisMarks(position: .top) + }) + .chartXAxis(.automatic) + .chartXSelection(value: $chartSelection) + .chartYScale(domain: -10...10) + .chartForegroundStyleScale([ + "Voltage": .blue, + "Current": .green + ]) + .chartLegend(position: .automatic, alignment: .bottom) + + } + } + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMdjmma", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "M/d/YY j:mma").replacingOccurrences(of: ",", with: "") + + if idiom == .phone { + } else { + Table(powerMetrics, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Ch1 Voltage") { dm in + Text("\(String(format: "%.2f", dm.powerCh1Voltage))V") + } + .width(min: 75) + TableColumn("Ch1 Current") { dm in + Text("\(String(format: "%.2f", dm.powerCh1Current))mA") + } + .width(min: 75) + TableColumn("Ch2 Voltage") { dm in + Text("\(String(format: "%.2f", dm.powerCh2Voltage))V") + } + .width(min: 75) + TableColumn("Ch2 Current") { dm in + Text("\(String(format: "%.2f", dm.powerCh2Current))mA") + } + .width(min: 75) + TableColumn("Ch3 Voltage") { dm in + Text("\(String(format: "%.2f", dm.powerCh3Voltage))V") + } + .width(min: 75) + TableColumn("Ch3 Current") { dm in + Text("\(String(format: "%.2f", dm.powerCh3Current))mA") + } + .width(min: 75) + TableColumn("timestamp") { dm in + Text(dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) + } + .width(min: 180) + + } + .onChange(of: selection) { _, newSelection in + guard let metrics = powerMetrics.first(where: { $0.id == newSelection }) else { + return + } + chartSelection = metrics.time + } + + } + } + } + } +} From d4b1c70a73ff4edeb76e07f4efc8ddc6262638b1 Mon Sep 17 00:00:00 2001 From: Matthew Davies Date: Sat, 25 Jan 2025 10:58:32 -0800 Subject: [PATCH 5/5] Update to power charts Added navigation bar Added clear and save buttons Added min/max to charts Also added iphone charts --- Localizable.xcstrings | 22 +++ Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Export/WriteCsvFile.swift | 19 +++ Meshtastic/Views/Nodes/PowerMetricsLog.swift | 165 ++++++++++++++++++- 4 files changed, 207 insertions(+), 3 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 6fdfdd7f..6884e096 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -22432,6 +22432,28 @@ } } }, + "power.metrics.delete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete all power metrics?" + } + } + } + }, + "power.metrics.log" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Power Metrics Log" + } + } + } + }, "Powered" : { "localizations" : { "de" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 672c6f72..76639ad7 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */; }; 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */; }; + 8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */; }; B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; }; @@ -293,6 +294,7 @@ 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = ""; }; 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = ""; }; 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = ""; }; + 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = ""; }; BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = ""; }; @@ -709,6 +711,7 @@ 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */, DD15E4F42B8BFC8E00654F61 /* PaxCounterLog.swift */, DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */, + 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */, ); path = Nodes; sourceTree = ""; @@ -1480,6 +1483,7 @@ DD1925B928CDA93900720036 /* SerialConfigEnums.swift in Sources */, 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */, D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */, + 8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */, DD86D4112881D16900BAEB7A /* WriteCsvFile.swift in Sources */, DD6F65762C6EA5490053C113 /* AckErrors.swift in Sources */, DDDB445029F8AC9C00EE2349 /* UIImage.swift in Sources */, diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 56776554..40913ea7 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -46,6 +46,25 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } + } else if metricsType == 2 { + // Create Power Metrics Header + csvString = "Channel 1 Voltage, Channel 1 Current, Channel 2 Voltage, Channel 2 Current, Channel 3 Voltage, Channel 3 Current, \("timestamp".localized)" + for dm in telemetry where dm.metricsType == 2 { + csvString += "\n" + csvString += String(dm.powerCh1Voltage) + csvString += ", " + csvString += String(dm.powerCh1Current) + csvString += ", " + csvString += String(dm.powerCh2Voltage) + csvString += ", " + csvString += String(dm.powerCh2Current) + csvString += ", " + csvString += String(dm.powerCh3Voltage) + csvString += ", " + csvString += String(dm.powerCh3Current) + csvString += ", " + csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized + } } return csvString } diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index 9feb4b98..4e0629a6 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -8,23 +8,51 @@ import Foundation import SwiftUI import Charts +import OSLog struct PowerMetricsLog: View { + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager @ObservedObject var node: NodeInfoEntity private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)] @State private var selection: TelemetryEntity.ID? @State private var chartSelection: Date? + @State private var isPresentingClearLogConfirm: Bool = false + @State var isExporting = false + @State var exportString = "" @State private var channelSelection = 0 + var powerMetrics: [TelemetryEntity] { + let telemetries = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 2")) + return (telemetries?.reversed() as? [TelemetryEntity]) ?? [] + } + + var minMax: (min: Double, max: Double) { + let allValues = powerMetrics.flatMap { [ + $0.powerCh1Voltage, + $0.powerCh1Current, + $0.powerCh2Voltage, + $0.powerCh2Current, + $0.powerCh3Voltage, + $0.powerCh3Current + ]} + + guard !allValues.isEmpty else { + return (min: -10, max: 10) + } + + return (min: floor(Double(allValues.min()!)), max: ceil(Double(allValues.max()!))) + } + var body: some View { VStack { if node.hasPowerMetrics { let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) - let powerMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 2")).reversed() as? [TelemetryEntity] ?? [] + let chartData = powerMetrics .filter { $0.time != nil && $0.time! >= oneWeekAgo! } .sorted { $0.time! < $1.time! } @@ -75,19 +103,85 @@ struct PowerMetricsLog: View { }) .chartXAxis(.automatic) .chartXSelection(value: $chartSelection) - .chartYScale(domain: -10...10) + .chartYScale(domain: minMax.min...minMax.max) .chartForegroundStyleScale([ "Voltage": .blue, "Current": .green ]) .chartLegend(position: .automatic, alignment: .bottom) - } } let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMdjmma", options: 0, locale: Locale.current) let dateFormatString = (localeDateFormat ?? "M/d/YY j:mma").replacingOccurrences(of: ",", with: "") if idiom == .phone { + Table(powerMetrics, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Timestamp") { m in + HStack { + Text(m.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) + Spacer() + HStack { + VStack { + Text("Channel 1") + HStack { + Image(systemName: "powerplug.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh1Voltage))V") + } + HStack { + Image(systemName: "bolt.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh1Current))mA") + } + } + } + Spacer() + HStack { + VStack { + Text("Channel 2") + HStack { + Image(systemName: "powerplug.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh2Voltage))V") + } + HStack { + Image(systemName: "bolt.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh2Current))mA") + } + } + } + Spacer() + HStack { + VStack { + Text("Channel 3") + HStack { + Image(systemName: "powerplug.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh3Voltage))V") + } + HStack { + Image(systemName: "bolt.fill") + .font(.caption) + .symbolRenderingMode(.multicolor) + Text("\(String(format: "%.2f", m.powerCh3Current))mA") + } + } + } + } + } + } + .onChange(of: selection) { _, newSelection in + guard let metrics = powerMetrics.first(where: { $0.id == newSelection }) else { + return + } + chartSelection = metrics.time + } } else { Table(powerMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("Ch1 Voltage") { dm in @@ -126,9 +220,74 @@ struct PowerMetricsLog: View { } chartSelection = metrics.time } + } + HStack { + Button(role: .destructive) { + isPresentingClearLogConfirm = true + } label: { + Label("clear.log", systemImage: "trash.fill") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(idiom == .phone ? .regular : .large) + .padding(.bottom) + .padding(.leading) + .confirmationDialog( + "are.you.sure", + isPresented: $isPresentingClearLogConfirm, + titleVisibility: .visible + ) { + Button("power.metrics.delete", role: .destructive) { + if clearTelemetry(destNum: node.num, metricsType: 2, context: context) { + Logger.data.notice("Cleared Power Metrics for \(node.num)") + } else { + Logger.data.error("Clear Power Metrics Log Failed") + } + } + } + Button { + exportString = telemetryToCsvFile(telemetry: powerMetrics, metricsType: 2) + isExporting = true + } label: { + Label("save", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(idiom == .phone ? .regular : .large) + .padding(.bottom) + .padding(.trailing) } + .onChange(of: selection) { _, newSelection in + guard let metrics = powerMetrics.first(where: { $0.id == newSelection }) else { + return + } + chartSelection = metrics.time + } + } else { + ContentUnavailableView("No Power Metrics", systemImage: "slash.circle") } } + .navigationTitle("power.metrics.log") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: + ZStack { + ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + }) + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(node.user?.longName ?? "Node") \("power.metrics.log".localized)"), + onCompletion: { result in + switch result { + case .success: + self.isExporting = false + Logger.services.info("Power metrics log download succeeded.") + case .failure(let error): + Logger.services.error("Power metrics log download failed: \(error.localizedDescription)") + } + } + ) } }