diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e061cceb..92bf62b7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -36,6 +36,12 @@ } } }, + " %@%%" : { + + }, + "--" : { + "shouldTranslate" : false + }, ": %@" : { "localizations" : { "sr" : { @@ -67,6 +73,9 @@ } } } + }, + "?" : { + }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { @@ -728,6 +737,9 @@ } } } + }, + "%f%%" : { + }, "%lf" : { "localizations" : { @@ -1452,22 +1464,6 @@ } } }, - "Airtime %@%%" : { - "localizations" : { - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Време емитовања %@%%" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "广播时间 %@%%" - } - } - } - }, "Alert" : { "localizations" : { "sr" : { @@ -3522,7 +3518,7 @@ "Channel URL" : { }, - "Channel Utilization %@%% " : { + "Channel Utilization %@%%" : { "localizations" : { "sr" : { "stringUnit" : { @@ -12026,22 +12022,6 @@ } } }, - "HUMIDITY" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "LUFTFEUCHTIGKEIT" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ВЛАЖНОСТ" - } - } - } - }, "hybrid" : { "extractionState" : "migrated", "localizations" : { @@ -23300,18 +23280,18 @@ } } }, - "PRESSURE" : { + "Pressure" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "DRUCK" + "value" : "Druck" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : "ПРИТИСАК" + "value" : "Притисак" } } } @@ -32195,7 +32175,7 @@ } } }, - "voltage" : { + "Voltage" : { "localizations" : { "de" : { "stringUnit" : { @@ -32259,10 +32239,7 @@ } } }, - "Voltage" : { - - }, - "Volts %@ " : { + "Volts %@" : { "localizations" : { "de" : { "stringUnit" : { @@ -32521,18 +32498,12 @@ } } }, - "WIND" : { + "Wind" : { "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "WIND" - } - }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : "ВЕТАР" + "value" : "Ветар" } } } @@ -32791,4 +32762,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 489b0e85..b395a2ad 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; }; 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; }; 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; + 2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */; }; + 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */; }; + 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */; }; + 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; }; @@ -275,6 +279,9 @@ 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = ""; }; + 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = ""; }; + 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = ""; }; @@ -603,6 +610,15 @@ path = "Metrics Columns"; sourceTree = ""; }; + 2344A2AC2D66978000170A77 /* CoreData */ = { + isa = PBXGroup; + children = ( + 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */, + 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */, + ); + path = CoreData; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -688,6 +704,7 @@ DD007BB12AA59B9A00F5FA12 /* CoreData */ = { isa = PBXGroup; children = ( + 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */, DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */, 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */, DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */, @@ -989,6 +1006,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( + 2344A2AC2D66978000170A77 /* CoreData */, 231B3F1E2D0879BC0069A07D /* Metrics Visualization */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); @@ -1542,12 +1560,15 @@ DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */, DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */, DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */, + 2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */, BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */, D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */, DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, + 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, + 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */, DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */, DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */, @@ -1570,6 +1591,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */, DDC94FC229CE063B0082EA6E /* BatteryLevel.swift in Sources */, DDDE5A1129AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, DDDE59FB29AF163D00490C6C /* WidgetsLiveActivity.swift in Sources */, diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index eb424586..66835b4a 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -14,35 +14,35 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if metricsType == 0 { // Create Device Metrics Header - csvString = "\("battery.level".localized), \("voltage".localized), \("channel.utilization".localized), \("airtime".localized), \("uptime".localized), \("Timestamp".localized)" + csvString = "\("battery.level".localized), \("Voltage".localized), \("channel.utilization".localized), \("airtime".localized), \("uptime".localized), \("Timestamp".localized)" for dm in telemetry where dm.metricsType == 0 { - csvString += "\n" - csvString += String(dm.batteryLevel) - csvString += ", " - csvString += String(dm.voltage) - csvString += ", " - csvString += String(dm.channelUtilization) - csvString += ", " - csvString += String(dm.airUtilTx) - csvString += ", " - csvString += String(dm.uptimeSeconds) - csvString += ", " - csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized + csvString += "\n" + csvString += dm.batteryLevel?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.voltage?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.channelUtilization?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.airUtilTx?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.uptimeSeconds?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } } else if metricsType == 1 { // Create Environment Telemetry Header csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, \("Timestamp".localized)" for dm in telemetry where dm.metricsType == 1 { csvString += "\n" - csvString += String(dm.temperature.localeTemperature()) + csvString += dm.temperature?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.relativeHumidity) + csvString += dm.relativeHumidity?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.barometricPressure) + csvString += dm.barometricPressure?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.iaq) + csvString += dm.iaq?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.gasResistance) + csvString += dm.gasResistance?.formatted(.number.grouping(.never)) ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } @@ -51,17 +51,17 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin 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 += dm.powerCh1Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh1Current) + csvString += dm.powerCh1Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh2Voltage) + csvString += dm.powerCh2Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh2Current) + csvString += dm.powerCh2Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh3Voltage) + csvString += dm.powerCh3Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh3Current) + csvString += dm.powerCh3Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } diff --git a/Meshtastic/Extensions/Constants.swift b/Meshtastic/Extensions/Constants.swift index 03a3cc31..7b6288a2 100644 --- a/Meshtastic/Extensions/Constants.swift +++ b/Meshtastic/Extensions/Constants.swift @@ -1,5 +1,5 @@ import Foundation - +import SwiftUI enum Constants { /// `UInt32.max` or FFFF,FFFF in hex is used to identify messages that are being /// sent to a channel and are not a DM to an individual user. This is used @@ -8,4 +8,8 @@ enum Constants { /// Based on the NUM_RESERVED from the firmware. /// https://github.com/meshtastic/firmware/blob/46d7b82ac1a4292ba52ca690e1a433d3a501a9e5/src/mesh/NodeDB.cpp#L522 static let minimumNodeNum = 4 + + // String used to render a nil value. If changed, mark the new entry in + // Localizable.xcstrings as "do not translate" and remove the old key. + static let nilValueIndicator = "--" } diff --git a/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift new file mode 100644 index 00000000..3b123207 --- /dev/null +++ b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift @@ -0,0 +1,61 @@ +// +// ManagedAttributePropertyWrapper.swift +// Meshtastic +// +// Created by Jake Bordens on 12/26/24. +// +import CoreData + +@propertyWrapper +public struct ManagedAttribute { + private let attributeName: String + private let converter: (NSNumber) -> Value? + + public init(attributeName: String) { + self.attributeName = attributeName + + // Define the converter closure based on the generic type Value + if Value.self == Float.self { + converter = { $0.floatValue as? Value } + } else if Value.self == Double.self { + converter = { $0.doubleValue as? Value } + } else if Value.self == Int.self { + converter = { $0.intValue as? Value } + } else if Value.self == Int8.self { + converter = { $0.int8Value as? Value } + } else if Value.self == Int16.self { + converter = { $0.int16Value as? Value } + } else if Value.self == Int32.self { + converter = { $0.int32Value as? Value } + } else if Value.self == Int64.self { + converter = { $0.int64Value as? Value } + } else { + fatalError("Unsupported type: \(Value.self)") + } + } + + public var wrappedValue: Value? { + get { fatalError("Access via enclosing instance required.") } + set { fatalError("Access via enclosing instance required.") } + } + + public static subscript( + _enclosingInstance observed: EnclosingSelf, + wrapped wrappedKeyPath: KeyPath, + storage storageKeyPath: ReferenceWritableKeyPath> + ) -> Value? { + get { + let wrapper = observed[keyPath: storageKeyPath] + let number = observed.primitiveValue(forKey: wrapper.attributeName) as? NSNumber + return number.flatMap { wrapper.converter($0) } + } + set { + let wrapper = observed[keyPath: storageKeyPath] + if let newValue = newValue { + observed.setPrimitiveValue(NSNumber(value: Double("\(newValue)")!), forKey: wrapper.attributeName) + } else { + observed.setPrimitiveValue(nil, forKey: wrapper.attributeName) + } + } + } +} diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift index b1bbb8c6..bed6d970 100644 --- a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift @@ -39,6 +39,19 @@ extension NodeInfoEntity { let environmentMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 1 } return environmentMetrics?.count ?? 0 > 0 } + + func hasDataForLatestEnvironmentMetrics(attributes: [String]) -> Bool { + for attribute in attributes { + guard self.latestEnvironmentMetrics?.entity.attributesByName.keys.contains(attribute) ?? false else { + return false + } + if self.latestEnvironmentMetrics?.value(forKey: attribute) != nil { + return true + } + } + return false + } + var hasDetectionSensorMetrics: Bool { return user?.sensorMessageList.count ?? 0 > 0 } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 8c85751f..8ab6dc97 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -15,6 +15,13 @@ import OSLog import ActivityKit #endif +// Simple extension to consicely pass values through a has_XXX boolean check +fileprivate extension Bool { + func then(_ value: T) -> T? { + self ? value : nil + } +} + func generateMessageMarkdown (message: String) -> String { if !message.isEmoji() { let types: NSTextCheckingResult.CheckingType = [.address, .link, .phoneNumber] @@ -698,28 +705,28 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage /// Currently only Device Metrics and Environment Telemetry are supported in the app if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { // Device Metrics - telemetry.airUtilTx = telemetryMessage.deviceMetrics.airUtilTx - telemetry.channelUtilization = telemetryMessage.deviceMetrics.channelUtilization - telemetry.batteryLevel = Int32(telemetryMessage.deviceMetrics.batteryLevel) - telemetry.voltage = telemetryMessage.deviceMetrics.voltage - telemetry.uptimeSeconds = Int32(telemetryMessage.deviceMetrics.uptimeSeconds) + telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) + telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) + telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) + telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) + telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) telemetry.metricsType = 0 Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { // Environment Metrics - telemetry.barometricPressure = telemetryMessage.environmentMetrics.barometricPressure - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.iaq = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq) - telemetry.gasResistance = telemetryMessage.environmentMetrics.gasResistance - telemetry.relativeHumidity = telemetryMessage.environmentMetrics.relativeHumidity - telemetry.temperature = telemetryMessage.environmentMetrics.temperature - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.voltage = telemetryMessage.environmentMetrics.voltage - telemetry.weight = telemetryMessage.environmentMetrics.weight - telemetry.windSpeed = telemetryMessage.environmentMetrics.windSpeed - telemetry.windGust = telemetryMessage.environmentMetrics.windGust - telemetry.windLull = telemetryMessage.environmentMetrics.windLull - telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) + telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) + telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) + telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) + telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) + telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) + telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) + telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) + telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) + telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) telemetry.metricsType = 1 } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { // Local Stats for Live activity @@ -739,35 +746,14 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } 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 - } + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) + telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) + telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) + telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current) + telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) + telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) + 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 @@ -791,14 +777,15 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage // ------------------------ // Low Battery notification if connectedNode == Int64(packet.from) { - if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { + let batteryLevel = telemetry.batteryLevel ?? 0 + if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && 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.", + content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", target: "nodes", path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" ) @@ -813,7 +800,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage 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), + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, channelUtilization: telemetry.channelUtilization, airtime: telemetry.airUtilTx, sentPackets: UInt32(telemetry.numPacketsTx), diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents index 5f12d9d0..9e23de8a 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -390,7 +390,7 @@ - + diff --git a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift new file mode 100644 index 00000000..bcbaf4b9 --- /dev/null +++ b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift @@ -0,0 +1,46 @@ +// +// TelemetryEntity+CoreDataClass.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +// Manual implementation of the TelemetryEntry object for CoreData. +// Add optional scalar types here using the @ManagedAttribute property wrapper. +// CoreData is based on Objective-C, which doesn't have optional scalars. +// The @ManagedAttribute property wrapper handles the conversion to optional scalars. + +@objc(TelemetryEntity) +public class TelemetryEntity: NSManagedObject, Identifiable { + + @ManagedAttribute(attributeName: "airUtilTx") public var airUtilTx: Float? + @ManagedAttribute(attributeName: "barometricPressure") public var barometricPressure: Float? + @ManagedAttribute(attributeName: "batteryLevel") public var batteryLevel: Int32? + @ManagedAttribute(attributeName: "channelUtilization") public var channelUtilization: Float? + @ManagedAttribute(attributeName: "current") public var current: Float? + @ManagedAttribute(attributeName: "distance") public var distance: Float? + @ManagedAttribute(attributeName: "gasResistance") public var gasResistance: Float? + @ManagedAttribute(attributeName: "iaq") public var iaq: Int32? + @ManagedAttribute(attributeName: "powerCh1Current") var powerCh1Current: Float? + @ManagedAttribute(attributeName: "powerCh1Voltage") var powerCh1Voltage: Float? + @ManagedAttribute(attributeName: "powerCh2Current") var powerCh2Current: Float? + @ManagedAttribute(attributeName: "powerCh2Voltage") var powerCh2Voltage: Float? + @ManagedAttribute(attributeName: "powerCh3Current") var powerCh3Current: Float? + @ManagedAttribute(attributeName: "powerCh3Voltage") var powerCh3Voltage: Float? + @ManagedAttribute(attributeName: "relativeHumidity") public var relativeHumidity: Float? + @ManagedAttribute(attributeName: "rssi") public var rssi: Int32? + @ManagedAttribute(attributeName: "snr") public var snr: Float? + @ManagedAttribute(attributeName: "temperature") public var temperature: Float? + @ManagedAttribute(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32? + @ManagedAttribute(attributeName: "voltage") public var voltage: Float? + @ManagedAttribute(attributeName: "weight") public var weight: Float? + @ManagedAttribute(attributeName: "windDirection") public var windDirection: Int32? + @ManagedAttribute(attributeName: "windGust") public var windGust: Float? + @ManagedAttribute(attributeName: "windLull") public var windLull: Float? + @ManagedAttribute(attributeName: "windSpeed") public var windSpeed: Float? + +} diff --git a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift new file mode 100644 index 00000000..278a322a --- /dev/null +++ b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift @@ -0,0 +1,36 @@ +// +// TelemetryEntity+CoreDataProperties.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +// Manual implementation of the TelemetryEntry object for CoreData. +// Add non-optional scalar types here using the standard @NSManaged proprty wrapper +// Add optional/non-optional object types here using the standard @NSManaged proprty wrapper +// CoreData is based on Objective-C which natively supports optionals for class types and +// non-optional scalars. + +extension TelemetryEntity { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TelemetryEntity") + } + + @NSManaged public var time: Date? + @NSManaged public var metricsType: Int32 + @NSManaged public var numOnlineNodes: Int32 + @NSManaged public var numPacketsRx: Int32 + @NSManaged public var numPacketsRxBad: Int32 + @NSManaged public var numPacketsTx: Int32 + @NSManaged public var numRxDupe: Int32 + @NSManaged public var numTotalNodes: Int32 + @NSManaged public var numTxRelay: Int32 + @NSManaged public var numTxRelayCanceled: Int32 + @NSManaged public var nodeTelemetry: NodeInfoEntity? + +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift index 188e4eba..5a3c53de 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift @@ -13,8 +13,9 @@ import SwiftUI // Given a keypath, this class holds information about how to render the attrbute in // the table. MetricsTableColumn objects are collected in a MetricsColumnList class MetricsTableColumn: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for wider tables let name: String @@ -37,6 +38,7 @@ class MetricsTableColumn: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -47,7 +49,7 @@ class MetricsTableColumn: ObservableObject { @ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent? ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.minWidth = minWidth @@ -72,13 +74,11 @@ class MetricsTableColumn: ObservableObject { } extension MetricsTableColumn: Identifiable, Hashable { - var id: String { self.attribute } - static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index add0318e..b535099e 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -14,8 +14,9 @@ import SwiftUI // the chart. MetricsChartSeries objects are collected in a MetricsSeriesList class MetricsChartSeries: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for areas that have the room let name: String @@ -39,6 +40,7 @@ class MetricsChartSeries: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -46,10 +48,10 @@ class MetricsChartSeries: ObservableObject { visible: Bool = true, foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange?, Date, Value) -> ChartBody? - ) where Value: Plottable & Comparable { + ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.visible = visible @@ -63,9 +65,15 @@ class MetricsChartSeries: ObservableObject { } self.valueClosure = { te in if let conversion { - return conversion(te[keyPath: keyPath]).floatValue + if let value = conversion(te[keyPath: keyPath]) as? (any Plottable) { + return value.floatValue ?? 0.0 + } + } else { + if let value = te[keyPath: keyPath] as? (any Plottable) { + return value.floatValue + } } - return te[keyPath: keyPath].floatValue + return nil } } @@ -82,14 +90,13 @@ class MetricsChartSeries: ObservableObject { } extension MetricsChartSeries: Identifiable, Hashable { - var id: String { self.attribute } static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift index ccb0b758..a40057ea 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -43,8 +43,8 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea return returnValues } - func column(forAttribute attribute: String) -> MetricsTableColumn? { - return columns.first(where: { $0.attribute == attribute}) + func column(withId id: String) -> MetricsTableColumn? { + return columns.first(where: { $0.id == id}) } // Collection conformance diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index bc714da6..4ac61d0c 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -7,59 +7,72 @@ import SwiftUI struct BatteryCompact: View { - var batteryLevel: Int32 + var batteryLevel: Int32? var font: Font var iconFont: Font var color: Color var body: some View { HStack(alignment: .center, spacing: 0) { - if batteryLevel == 100 { - Image(systemName: "battery.100.bolt") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 100 && batteryLevel > 74 { - Image(systemName: "battery.75") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 75 && batteryLevel > 49 { - Image(systemName: "battery.50") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 50 && batteryLevel > 14 { - Image(systemName: "battery.25") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 15 && batteryLevel > 0 { + if let batteryLevel { + if batteryLevel == 100 { + Image(systemName: "battery.100.bolt") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 100 && batteryLevel > 74 { + Image(systemName: "battery.75") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 75 && batteryLevel > 49 { + Image(systemName: "battery.50") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 50 && batteryLevel > 14 { + Image(systemName: "battery.25") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 15 && batteryLevel > 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel == 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(.red) + .symbolRenderingMode(.multicolor) + } else if batteryLevel > 100 { + Image(systemName: "powerplug") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } + } else { Image(systemName: "battery.0") .font(iconFont) .foregroundColor(color) .symbolRenderingMode(.multicolor) - } else if batteryLevel == 0 { - Image(systemName: "battery.0") - .font(iconFont) - .foregroundColor(.red) - .symbolRenderingMode(.multicolor) - } else if batteryLevel > 100 { - Image(systemName: "powerplug") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) } - if batteryLevel > 100 { - Text("PWD") - .foregroundStyle(.secondary) - .font(font) - } else if batteryLevel == 100 { - Text("CHG") - .foregroundStyle(.secondary) - .font(font) + if let batteryLevel { + if batteryLevel > 100 { + Text("PWD") + .foregroundStyle(.secondary) + .font(font) + } else if batteryLevel == 100 { + Text("CHG") + .foregroundStyle(.secondary) + .font(font) + } else { + Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%") + .foregroundStyle(.secondary) + .font(font) + } } else { - Text("\(batteryLevel)%") + Text(verbatim: "?") .foregroundStyle(.secondary) .font(font) } diff --git a/Meshtastic/Views/Helpers/PowerMetrics.swift b/Meshtastic/Views/Helpers/PowerMetrics.swift index cbff60a2..7f9fdcdd 100644 --- a/Meshtastic/Views/Helpers/PowerMetrics.swift +++ b/Meshtastic/Views/Helpers/PowerMetrics.swift @@ -17,50 +17,50 @@ struct PowerMetrics: View { LazyVGrid(columns: gridItemLayout) { - if metric.powerCh1Voltage != nil { + if let powerCh1Voltage = metric.powerCh1Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh1Voltage, + value: powerCh1Voltage, title: "Channel 1 Voltage" ) } - if metric.powerCh1Current != nil { + if let powerCh1Current = metric.powerCh1Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh1Current, + value: powerCh1Current, title: "Channel 1 Current" ) } - if metric.powerCh2Voltage != nil { + if let powerCh2Voltage = metric.powerCh2Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh2Voltage, + value: powerCh2Voltage, title: "Channel 2 Voltage" ) } - if metric.powerCh2Current != nil { + if let powerCh2Current = metric.powerCh2Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh2Current, + value: powerCh2Current, title: "Channel 2 Current" ) } - if metric.powerCh3Voltage != nil { + if let powerCh3Voltage = metric.powerCh3Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh3Voltage, + value: powerCh3Voltage, title: "Channel 3 Voltage" ) } - if metric.powerCh3Current != nil { + if let powerCh3Current = metric.powerCh3Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh3Current, + value: powerCh3Current, title: "Channel 3 Current" ) } diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index e4867544..b4e5336c 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -116,24 +116,27 @@ struct WeatherConditionsCompactWidget: View { struct HumidityCompactWidget: View { let humidity: Int - let dewPoint: String + let dewPoint: String? var body: some View { VStack(alignment: .leading) { HStack(spacing: 5.0) { Image(systemName: "humidity") .foregroundColor(.accentColor) .font(.callout) - Text("HUMIDITY") + Text("Humidity") + .textCase(.uppercase) .font(.caption) } Text("\(humidity)%") .font(.largeTitle) .padding(.bottom, 5) - Text("The dew point is \(dewPoint) right now.") - .lineLimit(3) - .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) - .fixedSize(horizontal: false, vertical: true) - .font(.caption2) + if let dewPoint { + Text("The dew point is \(dewPoint) right now.") + .lineLimit(3) + .allowsTightening(true) + .fixedSize(horizontal: false, vertical: true) + .font(.caption2) + } } .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) .padding() @@ -151,7 +154,8 @@ struct PressureCompactWidget: View { Image(systemName: "gauge") .foregroundColor(.accentColor) .font(.callout) - Text("PRESSURE") + Text("Pressure") + .textCase(.uppercase) .font(.caption) } Text(pressure) @@ -168,17 +172,21 @@ struct PressureCompactWidget: View { struct WindCompactWidget: View { let speed: String - let gust: String - let direction: String + let gust: String? + let direction: String? + var body: some View { + let hasGust = ((gust ?? "").isEmpty == false) VStack(alignment: .leading) { - Label { Text("WIND") } icon: { Image(systemName: "wind").foregroundColor(.accentColor) } - Text("\(direction)") - .font(gust.isEmpty ? .callout : .caption) - .padding(.bottom, 10) + Label { Text("Wind").textCase(.uppercase) } icon: { Image(systemName: "wind").foregroundColor(.accentColor) } + if let direction { + Text("\(direction)") + .font(!hasGust ? .callout : .caption) + .padding(.bottom, 10) + } Text(speed) - .font(gust.isEmpty ? .system(size: 45) : .system(size: 35)) - if !gust.isEmpty { + .font(!hasGust ? .system(size: 45) : .system(size: 35)) + if let gust, !gust.isEmpty { Text("Gusts \(gust)") } } diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 958fefa4..9132d068 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -38,26 +38,30 @@ struct DeviceMetricsLog: View { GroupBox(label: Label("\(deviceMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { Chart { ForEach(chartData, id: \.self) { point in - Plot { - LineMark( - x: .value("x", point.time!), - y: .value("y", point.batteryLevel) - ) + if let batteryLevel = point.batteryLevel { + Plot { + LineMark( + x: .value("x", point.time!), + y: .value("y", batteryLevel) + ) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(batteryLevel)") + .foregroundStyle(batteryChartColor) + .interpolationMethod(.linear) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)") - .foregroundStyle(batteryChartColor) - .interpolationMethod(.linear) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.channelUtilization) - ) - .symbolSize(25) + if let channelUtilization = point.channelUtilization { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", channelUtilization) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(channelUtilization)") + .foregroundStyle(channelUtilizationChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.channelUtilization)") - .foregroundStyle(channelUtilizationChartColor) if let chartSelection { RuleMark(x: .value("Second", chartSelection, unit: .second)) .foregroundStyle(.tertiary.opacity(0.5)) @@ -81,16 +85,18 @@ struct DeviceMetricsLog: View { RuleMark(y: .value("Network Status Red", 50)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 10])) .foregroundStyle(.red) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.airUtilTx) - ) - .symbolSize(25) + if let airUtilTx = point.airUtilTx { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", airUtilTx) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(airUtilTx)") + .foregroundStyle(airtimeChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.airUtilTx)") - .foregroundStyle(airtimeChartColor) } } .chartXAxis(content: { @@ -122,14 +128,21 @@ struct DeviceMetricsLog: View { Image(systemName: "bolt") .font(.caption) .symbolRenderingMode(.multicolor) - Text("Volts \(String(format: "%.2f", dm.voltage)) ") + Text("Volts \(dm.voltage?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") .font(.caption2) BatteryCompact(batteryLevel: dm.batteryLevel, font: .caption, iconFont: .callout, color: .accentColor) } HStack { - Text("Channel Utilization \(String(format: "%.2f", dm.channelUtilization))% ") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) - Text("Airtime \(String(format: "%.2f", dm.airUtilTx))%") + if let channelUtilization = dm.channelUtilization { + // Text("Channel Utilization \(String(format: "%.2f%%", channelUtilization))") + Text("Channel Utilization \(channelUtilization.formatted(.number.precision(.fractionLength(2))))%") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } else { + Text("Channel Utilization " + Constants.nilValueIndicator) + .foregroundColor(.gray) + } + // Keep "Airtime" separate here as to avoid creating a new localization key + Text("Airtime") + Text(" \(dm.airUtilTx?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .foregroundColor(.secondary) Spacer() } @@ -141,27 +154,37 @@ struct DeviceMetricsLog: View { /// Multi Column table for ipads and mac Table(deviceMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("Battery Level") { dm in - if dm.batteryLevel > 100 { + if dm.batteryLevel ?? 0 > 100 { Text("Powered") } else { - Text("\(String(dm.batteryLevel))%") + // dm.batteryLevel.map { Text("\(String($0))%") } ?? Text("--") + Text("\(dm.batteryLevel?.formatted(.number.precision(.fractionLength(0))) ?? Constants.nilValueIndicator)%") } } - TableColumn("voltage") { dm in - Text("\(String(format: "%.2f", dm.voltage))") + TableColumn("Voltage") { dm in + // dm.voltage.map { Text("\(String(format: "%.2f", $0))") } ?? Text("--") + Text("\(dm.voltage?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") } TableColumn("channel.utilization") { dm in - Text("\(String(format: "%.2f", dm.channelUtilization))%") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) + dm.channelUtilization.map { channelUtilization in + // Text("\(String(format: "%.2f", channelUtilization))%") + Text("\(channelUtilization.formatted(.number.precision(.fractionLength(2))))%") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } ?? Text(Constants.nilValueIndicator) } TableColumn("Airtime") { dm in - Text("\(String(format: "%.2f", dm.airUtilTx))%") + // dm.airUtilTx.map { Text("\(String(format: "%.2f", $0))%") } ?? Text("--") + Text("\(dm.airUtilTx?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") } TableColumn("Uptime") { dm in - let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) - let components = (now.. 0 { + if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds { HStack { Label { Text("\("uptime".localized)") @@ -156,7 +156,7 @@ struct NodeDetail: View { Spacer() let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) + let later = now + TimeInterval(uptimeSeconds) let uptime = (now.. 0.0 { - HumidityCompactWidget(humidity: Int(node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0), dewPoint: String(format: "%.0f", calculateDewPoint(temp: node.latestEnvironmentMetrics?.temperature ?? 0.0, relativeHumidity: node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0)) + "°") + if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() { + WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP") } - if node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 > 0.0 { - PressureCompactWidget(pressure: String(format: "%.2f", node.latestEnvironmentMetrics?.barometricPressure ?? 0.0), unit: "hPA", low: node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 <= 1009.144) + if let humidity = node.latestEnvironmentMetrics?.relativeHumidity { + if let temperature = node.latestEnvironmentMetrics?.temperature { + let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity) + .formatted(.number.precision(.fractionLength(0))) + "°" + HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint) + } else { + HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil) + } } - if node.latestEnvironmentMetrics?.windSpeed ?? 0.0 > 0.0 { - let windSpeed = Measurement(value: Double(node.latestEnvironmentMetrics?.windSpeed ?? 0.0), unit: UnitSpeed.metersPerSecond) - let windGust = Measurement(value: Double(node.latestEnvironmentMetrics?.windGust ?? 0.0), unit: UnitSpeed.metersPerSecond) + if let pressure = node.latestEnvironmentMetrics?.barometricPressure { + PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144) + } + if let windSpeed = node.latestEnvironmentMetrics?.windSpeed { + let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond) + let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) } let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0)) - WindCompactWidget(speed: windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), - gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction) + WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), + gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction) } } .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index 32faabf2..3fd5582a 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -39,7 +39,8 @@ struct PowerMetricsLog: View { $0.powerCh2Current, $0.powerCh3Voltage, $0.powerCh3Current - ]} + ].compactMap({$0}) // Remove nils + } guard !allValues.isEmpty else { return (min: -10, max: 10) @@ -72,24 +73,27 @@ struct PowerMetricsLog: View { 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 voltage { + 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)") + } + if let current { + 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 { @@ -127,13 +131,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh1Voltage))V") + m.powerCh1Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh1Current))mA") + m.powerCh1Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -145,13 +149,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh2Voltage))V") + m.powerCh2Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh2Current))mA") + m.powerCh2Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -163,13 +167,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh3Voltage))V") + m.powerCh3Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh3Current))mA") + m.powerCh3Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -185,27 +189,27 @@ struct PowerMetricsLog: View { } else { Table(powerMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("Ch1 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh1Voltage))V") + dm.powerCh1Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch1 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh1Current))mA") + dm.powerCh1Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch2 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh2Voltage))V") + dm.powerCh2Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch2 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh2Current))mA") + dm.powerCh2Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch3 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh3Voltage))V") + dm.powerCh3Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch3 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh3Current))mA") + dm.powerCh3Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Timestamp") { dm in diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 37376531..8d7ea9af 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -15,9 +15,9 @@ struct MeshActivityAttributes: ActivityAttributes { public typealias MeshActivityStatus = ContentState public struct ContentState: Codable, Hashable { // Dynamic stateful properties about your activity go here! - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 7c6396a3..231efec2 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -42,11 +42,13 @@ struct WidgetsLiveActivity: Widget { .foregroundStyle(.secondary) .fixedSize() } - Text("\(String(format: "Ch. Util: %.2f", context.state.channelUtilization))%") + // Text("\(context.state.channelUtilization.map { String(format: "Ch. Util: %.2f", $0) } ?? "--")%") + Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("\(String(format: "Airtime: %.2f", context.state.airtime))%") + // Text("\(context.state.airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") + Text("Airtime: \(context.state.airtime?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() @@ -118,7 +120,7 @@ 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(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100 , packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) + static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) static var previews: some View { attributes @@ -140,9 +142,9 @@ struct LiveActivityView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -179,9 +181,9 @@ struct NodeInfoView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -199,7 +201,8 @@ struct NodeInfoView: View { .font(nodeName.count > 14 ? .callout : .title3) .fontWeight(.semibold) .foregroundStyle(.tint) - Text("\(String(format: "Ch. Util: %.2f", channelUtilization))% \(String(format: "Airtime: %.2f", airtime))%") + // Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") + Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) @@ -211,21 +214,21 @@ struct NodeInfoView: View { .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() - Text("Bad: \(badReceivedPackets) \(String(format: "Error Rate: %.2f", errorRate))%") + Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() if totalNodes >= 100 { - Text("\(String(format: "Connected: %d nodes online", nodesOnline))") + Text("Connected: \(nodesOnline) nodes online") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() } else { - Text("\(String(format: "Connected: %d of %d nodes online", nodesOnline, totalNodes))") + Text("Connected: \(nodesOnline) of \(totalNodes) nodes online") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary)