From 85f9cc8ad3eed0d43a14c391a17a42fbdf060f4f Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 24 Aug 2024 18:44:08 -0700 Subject: [PATCH] Upcate live activity to use the new local stats protobuf --- Localizable.xcstrings | 15 +- Meshtastic.xcodeproj/project.pbxproj | 4 +- Meshtastic/Helpers/MeshPackets.swift | 77 +-- .../Meshtastic.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 475 ++++++++++++++++++ Meshtastic/Views/Bluetooth/Connect.swift | 20 +- Widgets/MeshActivityAttributes.swift | 12 +- Widgets/WidgetsLiveActivity.swift | 118 ++--- 8 files changed, 601 insertions(+), 122 deletions(-) create mode 100644 Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents diff --git a/Localizable.xcstrings b/Localizable.xcstrings index f1806ddf..9989f2b7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -6325,7 +6325,7 @@ "Direct Message Help" : { }, - "Direct messages are using the new public key infrastructure for encryption. Reguires firmware version 2.5 or greater." : { + "Direct messages are using the new public key infrastructure for encryption. Requires firmware version 2.5 or greater." : { }, "Direct messages are using the shared key for the channel." : { @@ -7227,12 +7227,12 @@ }, "Favorites" : { - }, - "Fetch the latest position of a cetain node" : { - }, "Favorites and nodes with recent messages show up at the top of the contact list." : { - + + }, + "Fetch the latest position of a cetain node" : { + }, "Fifteen Minutes" : { @@ -14565,9 +14565,10 @@ }, "Message content exceeds 228 bytes." : { + }, "Message Status Options" : { - + }, "message.details" : { "localizations" : { @@ -22241,7 +22242,7 @@ } } }, - "Updated Device Metrics Data." : { + "Updated Node Stats Data." : { }, "Updated: %@" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6b02e8f1..dcb2a600 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -368,6 +368,7 @@ DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = ""; }; DD77093E2AA1B146007A8BF0 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = ""; }; + DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = ""; }; DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLogger.swift; sourceTree = ""; }; DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLog.swift; sourceTree = ""; }; DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; }; @@ -1881,6 +1882,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */, DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */, DD2984A82C5AEF7500B1268D /* MeshtasticDataModelV 41.xcdatamodel */, DD68BAE72C417A74004C01A0 /* MeshtasticDataModelV 40.xcdatamodel */, @@ -1924,7 +1926,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */; + currentVersion = DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index d3bef97a..12ed1f1b 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -681,14 +681,10 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage if let telemetryMessage = try? Telemetry(serializedData: packet.decoded.payload) { - // Only log telemetry from the mesh not the connected device - if connectedNode != Int64(packet.from) { - let logString = String.localizedStringWithFormat("mesh.log.telemetry.received %@".localized, String(packet.from)) - MeshLogger.log("📈 \(logString)") - } else { - // If it is the connected node - } - if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { + 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) { /// Other unhandled telemetry packets return } @@ -727,6 +723,18 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.windLull = telemetryMessage.environmentMetrics.windLull telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) telemetry.metricsType = 1 + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { + // Local Stats for Live activity + telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) + telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization + telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx + telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) + telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) + telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) + telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) + telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.metricsType = 6 + 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)") } telemetry.snr = packet.rxSnr telemetry.rssi = packet.rxRssi @@ -743,34 +751,45 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet } try context.save() - // Only log telemetry from the mesh not the connected device - if connectedNode != Int64(packet.from) { - Logger.data.info("💾 [TelemetryEntity] Saved for Node: \(packet.from.toHex())") - } else if telemetry.metricsType == 0 { + + Logger.data.info("💾 [TelemetryEntity] Saved for Node: \(packet.from.toHex())") + if telemetry.metricsType == 0 { // Connected Device Metrics // ------------------------ // Low Battery notification - if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(UUID().uuidString)"), - title: "Critically Low Battery!", - subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", - content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" - ) - ] - manager.schedule() + if connectedNode != Int64(packet.from) { + if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(UUID().uuidString)"), + title: "Critically Low Battery!", + subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", + content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + ) + ] + manager.schedule() + } } + } else if telemetry.metricsType == 6 { // Update our live activity if there is one running, not available on mac iOS >= 16.2 #if !targetEnvironment(macCatalyst) - let oneMinuteLater = Calendar.current.date(byAdding: .minute, value: (Int(1) ), to: Date())! - let date = Date.now...oneMinuteLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(timerRange: date, connected: true, channelUtilization: telemetry.channelUtilization, airtime: telemetry.airUtilTx, batteryLevel: UInt32(telemetry.batteryLevel), nodes: 17, nodesOnline: 9) - let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Device Metrics Data.", sound: .default) + let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! + let date = Date.now...fifteenMinutesLater + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: UInt32(telemetry.uptimeSeconds), + channelUtilization: telemetry.channelUtilization, + airtime: telemetry.airUtilTx, + sentPackets: UInt32(telemetry.numPacketsTx), + receivedPackets: UInt32(telemetry.numPacketsRx), + badReceivedPackets: UInt32(telemetry.numPacketsRxBad), + nodesOnline: UInt32(telemetry.numOnlineNodes), + totalNodes: UInt32(telemetry.numTotalNodes), + timerRange: date) + + let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 3ddf90f8..04d3cc1a 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 42.xcdatamodel + MeshtasticDataModelV 43.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents new file mode 100644 index 00000000..544d44c1 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents @@ -0,0 +1,475 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 0048e430..58d8757b 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -327,17 +327,25 @@ struct Connect: View { #if canImport(ActivityKit) func startNodeActivity() { liveActivityStarted = true - let timerSeconds = 60 - let deviceMetrics = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")) - let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity + // 15 Minutes Local Stats Interval + let timerSeconds = 900 + let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 6")) + let mostRecent = localStats?.lastObject as? TelemetryEntity let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName ?? "unknown") let future = Date(timeIntervalSinceNow: Double(timerSeconds)) + let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), + channelUtilization: mostRecent?.channelUtilization ?? 0.0, + airtime: mostRecent?.airUtilTx ?? 0.0, + sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0), + receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0), + badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0), + nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), + totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), + timerRange: Date.now...future) - let initialContentState = MeshActivityAttributes.ContentState(timerRange: Date.now...future, connected: true, channelUtilization: mostRecent?.channelUtilization ?? 0.0, airtime: mostRecent?.airUtilTx ?? 0.0, batteryLevel: UInt32(mostRecent?.batteryLevel ?? 0), nodes: 17, nodesOnline: 9) - - let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 2, to: Date())!) + let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) do { let myActivity = try Activity.request(attributes: activityAttributes, content: activityContent, diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 916377c9..a2abdbba 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -15,13 +15,15 @@ struct MeshActivityAttributes: ActivityAttributes { public typealias MeshActivityStatus = ContentState public struct ContentState: Codable, Hashable { // Dynamic stateful properties about your activity go here! - var timerRange: ClosedRange - var connected: Bool + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 + var timerRange: ClosedRange } // Fixed non-changing properties about your activity go here! diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 396aaac9..690fc298 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -13,7 +13,16 @@ struct WidgetsLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: MeshActivityAttributes.self) { context in - LiveActivityView(nodeName: context.attributes.name, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, batteryLevel: context.state.batteryLevel, nodes: 17, nodesOnline: 7, timerRange: context.state.timerRange) + LiveActivityView(nodeName: context.attributes.name, + uptimeSeconds: 0, // context.attributes.uptimeSeconds, + channelUtilization: context.state.channelUtilization, + airtime: context.state.airtime, + sentPackets: context.state.sentPackets, + receivedPackets: context.state.receivedPackets, + badReceivedPackets: context.state.badReceivedPackets, + nodesOnline: context.state.nodesOnline, + totalNodes: context.state.totalNodes, + timerRange: context.state.timerRange) .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) } dynamicIsland: { context in @@ -38,24 +47,7 @@ struct WidgetsLiveActivity: Widget { Spacer() } DynamicIslandExpandedRegion(.center) { - VStack(alignment: .center, spacing: 0) { - BatteryIcon(batteryLevel: Int32(context.state.batteryLevel), font: .title, color: .accentColor) - if context.state.batteryLevel == 0 { - Text("< 1%") - .font(.title3) - .foregroundColor(.gray) - .fixedSize() - } else if context.state.batteryLevel < 101 { - Text(String(context.state.batteryLevel) + "%") - .font(.title3) - .foregroundColor(.gray) - .fixedSize() - } else { - Text("PWD") - .font(.title3) - .foregroundColor(.gray) - } - } + // Used to be battery } DynamicIslandExpandedRegion(.trailing, priority: 1) { TimerView(timerRange: context.state.timerRange) @@ -100,38 +92,40 @@ struct WidgetsLiveActivity: Widget { } } -struct WidgetsLiveActivity_Previews: PreviewProvider { - static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") - static let state = MeshActivityAttributes.ContentState( - timerRange: Date.now...Date(timeIntervalSinceNow: 60), connected: true, channelUtilization: 25.84, airtime: 10.01, batteryLevel: 39, nodes: 17, nodesOnline: 9) - - static var previews: some View { - attributes - .previewContext(state, viewKind: .dynamicIsland(.compact)) - .previewDisplayName("Compact") - attributes - .previewContext(state, viewKind: .dynamicIsland(.minimal)) - .previewDisplayName("Minimal") - attributes - .previewContext(state, viewKind: .dynamicIsland(.expanded)) - .previewDisplayName("Expanded") - attributes - .previewContext(state, viewKind: .content) - .previewDisplayName("Notification") - } -} +//struct WidgetsLiveActivity_Previews: PreviewProvider { +// static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") +// static let state = MeshActivityAttributes.ContentState( +// timerRange: Date.now...Date(timeIntervalSinceNow: 60), connected: true, channelUtilization: 25.84, airtime: 10.01, batteryLevel: 39, nodes: 17, nodesOnline: 9) +// +// static var previews: some View { +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.compact)) +// .previewDisplayName("Compact") +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.minimal)) +// .previewDisplayName("Minimal") +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.expanded)) +// .previewDisplayName("Expanded") +// attributes +// .previewContext(state, viewKind: .content) +// .previewDisplayName("Notification") +// } +//} struct LiveActivityView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - // var connected: Bool + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 var timerRange: ClosedRange var body: some View { @@ -143,33 +137,8 @@ struct LiveActivityView: View { .aspectRatio(contentMode: .fit) .frame(width: 65) Spacer() - NodeInfoView(nodeName: nodeName, timerRange: timerRange, channelUtilization: channelUtilization, airtime: airtime, batteryLevel: batteryLevel, nodes: nodes, nodesOnline: nodesOnline) + NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets, nodesOnline: nodesOnline, totalNodes: totalNodes, timerRange: timerRange) Spacer() - VStack { - BatteryIcon(batteryLevel: Int32(batteryLevel), font: .title, color: .secondary) - if batteryLevel == 0 { - Text("< 1%") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } else if batteryLevel < 101 { - Text(String(batteryLevel) + "%") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } else { - Text("Plugged In") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } - } } .tint(.primary) .padding([.leading, .top, .bottom]) @@ -183,12 +152,15 @@ struct NodeInfoView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var timerRange: ClosedRange + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 + var timerRange: ClosedRange var body: some View { VStack(alignment: .leading, spacing: 0) {