Merge pull request #1112 from meshtastic/nil-telemetry

Optional values for TelemetryEntity attributes
This commit is contained in:
Garth Vander Houwen 2025-02-25 06:58:59 -08:00 committed by GitHub
commit 80a89eccf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 692 additions and 444 deletions

View file

@ -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"
}
}

View file

@ -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 = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = "<group>"; };
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = "<group>"; };
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = "<group>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = "<group>"; };
@ -603,6 +610,15 @@
path = "Metrics Columns";
sourceTree = "<group>";
};
2344A2AC2D66978000170A77 /* CoreData */ = {
isa = PBXGroup;
children = (
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */,
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
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 */,

View file

@ -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
}

View file

@ -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 = "--"
}

View file

@ -0,0 +1,61 @@
//
// ManagedAttributePropertyWrapper.swift
// Meshtastic
//
// Created by Jake Bordens on 12/26/24.
//
import CoreData
@propertyWrapper
public struct ManagedAttribute<Value: Numeric> {
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<EnclosingSelf: NSManagedObject>(
_enclosingInstance observed: EnclosingSelf,
wrapped wrappedKeyPath: KeyPath<EnclosingSelf, Value?>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, ManagedAttribute<Value>>
) -> 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)
}
}
}
}

View file

@ -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
}

View file

@ -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<T>(_ 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),

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D70" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
@ -390,7 +390,7 @@
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES" codeGenerationType="class">
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>

View file

@ -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<Float>(attributeName: "airUtilTx") public var airUtilTx: Float?
@ManagedAttribute<Float>(attributeName: "barometricPressure") public var barometricPressure: Float?
@ManagedAttribute<Int32>(attributeName: "batteryLevel") public var batteryLevel: Int32?
@ManagedAttribute<Float>(attributeName: "channelUtilization") public var channelUtilization: Float?
@ManagedAttribute<Float>(attributeName: "current") public var current: Float?
@ManagedAttribute<Float>(attributeName: "distance") public var distance: Float?
@ManagedAttribute<Float>(attributeName: "gasResistance") public var gasResistance: Float?
@ManagedAttribute<Int32>(attributeName: "iaq") public var iaq: Int32?
@ManagedAttribute<Float>(attributeName: "powerCh1Current") var powerCh1Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh1Voltage") var powerCh1Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Current") var powerCh2Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Voltage") var powerCh2Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Current") var powerCh3Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Voltage") var powerCh3Voltage: Float?
@ManagedAttribute<Float>(attributeName: "relativeHumidity") public var relativeHumidity: Float?
@ManagedAttribute<Int32>(attributeName: "rssi") public var rssi: Int32?
@ManagedAttribute<Float>(attributeName: "snr") public var snr: Float?
@ManagedAttribute<Float>(attributeName: "temperature") public var temperature: Float?
@ManagedAttribute<Int32>(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32?
@ManagedAttribute<Float>(attributeName: "voltage") public var voltage: Float?
@ManagedAttribute<Float>(attributeName: "weight") public var weight: Float?
@ManagedAttribute<Int32>(attributeName: "windDirection") public var windDirection: Int32?
@ManagedAttribute<Float>(attributeName: "windGust") public var windGust: Float?
@ManagedAttribute<Float>(attributeName: "windLull") public var windLull: Float?
@ManagedAttribute<Float>(attributeName: "windSpeed") public var windSpeed: Float?
}

View file

@ -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<TelemetryEntity> {
return NSFetchRequest<TelemetryEntity>(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?
}

View file

@ -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<Value, TableContent: View>(
id: String,
keyPath: KeyPath<TelemetryEntity, Value>,
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)
}
}

View file

@ -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<Value, ChartBody: ChartContent, ForegroundStyle: ShapeStyle>(
id: String,
keyPath: KeyPath<TelemetryEntity, Value>,
name: String,
abbreviatedName: String,
@ -46,10 +48,10 @@ class MetricsChartSeries: ObservableObject {
visible: Bool = true,
foregroundStyle: @escaping ((ClosedRange<Float>?) -> ForegroundStyle?) = { _ in nil },
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, 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)
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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"
)
}

View file

@ -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)")
}
}

View file

@ -112,8 +112,16 @@ struct ChannelMessageList: View {
.frame(maxWidth: .infinity)
.id(message.messageId)
.onAppear {
Task {
await markMessageAsRead(message)
if !message.read {
message.read = true
do {
try context.save()
Logger.data.info("📖 [App] Read message \(message.messageId) ")
appState.unreadChannelMessages = myInfo.unreadMessages
context.refresh(myInfo, mergeChanges: true)
} catch {
Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)")
}
}
}
}
@ -170,21 +178,4 @@ struct ChannelMessageList: View {
}
}
}
@MainActor
func markMessageAsRead(_ message: MessageEntity) async {
guard !message.read else { return }
message.read = true
do {
try await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce
try context.save()
Logger.data.info("📖 [App] Read message \(message.messageId)")
appState.unreadChannelMessages = myInfo.unreadMessages
context.refresh(myInfo, mergeChanges: true)
} catch {
Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)")
}
}
}

View file

@ -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..<later).formatted(.components(style: .narrow))
Text(components)
if let uptimeSeconds = dm.uptimeSeconds {
let now = Date.now
let later = now + TimeInterval(uptimeSeconds)
let components = (now..<later).formatted(.components(style: .narrow))
Text(components)
} else {
Text(Constants.nilValueIndicator)
}
}
.width(min: 100)
TableColumn("Timestamp") { dm in

View file

@ -56,25 +56,25 @@ struct EnvironmentMetricsLog: View {
// Add a table for mac and ipad
Table(environmentMetrics) {
TableColumn("Temperature") { em in
columnList.column(forAttribute: "temperature")?.body(em)
columnList.column(withId: "temperature")?.body(em)
}
TableColumn("Humidity") { em in
columnList.column(forAttribute: "relativeHumidity")?.body(em)
columnList.column(withId: "relativeHumidity")?.body(em)
}
TableColumn("Barometric Pressure") { em in
columnList.column(forAttribute: "barometricPressure")?.body(em)
columnList.column(withId: "barometricPressure")?.body(em)
}
TableColumn("Indoor Air Quality") { em in
columnList.column(forAttribute: "iaq")?.body(em)
columnList.column(withId: "iaq")?.body(em)
}
TableColumn("Wind Speed") { em in
columnList.column(forAttribute: "windSpeed")?.body(em)
columnList.column(withId: "windSpeed")?.body(em)
}
TableColumn("Wind Direction") { em in
columnList.column(forAttribute: "windDirection")?.body(em)
columnList.column(withId: "windDirection")?.body(em)
}
TableColumn("Timestamp") { em in
columnList.column(forAttribute: "time")?.body(em)
columnList.column(withId: "time")?.body(em)
}
.width(min: 180)
}

View file

@ -15,10 +15,11 @@ extension MetricsSeriesList {
MetricsSeriesList([
// Temperature Series Configuration
MetricsChartSeries(
id: "temperature",
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
conversion: { Float($0.localeTemperature()) },
conversion: { t in t.map { Float($0.localeTemperature()) } },
foregroundStyle: { chartRange in
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
@ -29,30 +30,33 @@ extension MetricsSeriesList {
return LinearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
},
chartBody: { series, chartRange, time, temperature in
AreaMark(
x: .value("Time", time),
yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0),
yEnd: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
.opacity(0.6)
LineMark(
x: .value("Time", time),
y: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let temperature {
AreaMark(
x: .value("Time", time),
yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0),
yEnd: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
.opacity(0.6)
LineMark(
x: .value("Time", time),
y: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Relative Humidity Series Configuration
MetricsChartSeries(
id: "relativeHumidity",
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
@ -63,18 +67,21 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, humidity in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, humidity)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let humidity {
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, humidity)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Barometric Pressure Series Configuration
MetricsChartSeries(
id: "barometricPressure",
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
@ -86,44 +93,49 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, pressure in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, pressure)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let pressure {
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, pressure)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Indoor Air Quality Series Configuration
MetricsChartSeries(
id: "iaq",
keyPath: \.iaq,
name: "Indoor Air Quality",
abbreviatedName: "IAQ",
visible: false,
foregroundStyle: { _ in .gray },
chartBody: { series, _, time, iaq in
let iaqEnum = Iaq.getIaq(for: Int(iaq))
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.symbol(Circle())
.foregroundStyle(iaqEnum.color)
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let iaq {
let iaqEnum = Iaq.getIaq(for: Int(iaq))
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.symbol(Circle())
.foregroundStyle(iaqEnum.color)
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Combined Wind Speed and Direction Series Configuration -- For use in Chart only
MetricsChartSeries(
id: "windSpeedAndDirection",
keyPath: \.windSpeedAndDirection,
name: "Wind Speed/Direction",
abbreviatedName: "Speed/Dir",
@ -135,26 +147,30 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, wsad in
// debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 )
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.symbol {
Image(systemName: "location.north.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
.rotationEffect(
.degrees(Double(wsad.windDirection)))
}.foregroundStyle(.yellow)
if let wsad {
// debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 )
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.symbol {
if let wd = wsad.windDirection {
Image(systemName: "location.north.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
.rotationEffect(
.degrees(Double(wd)))
}
}.foregroundStyle(.yellow)
}
})
])
}
@ -165,8 +181,8 @@ extension MetricsSeriesList {
@objc class WindSpeedAndDirection: NSObject, Plottable, Comparable {
let windSpeed: Float
let windDirection: Int32
init(windSpeed: Float, windDirection: Int32) {
let windDirection: Int32?
init(windSpeed: Float, windDirection: Int32?) {
self.windSpeed = windSpeed
self.windDirection = windDirection
}
@ -181,9 +197,10 @@ extension MetricsSeriesList {
}
@objc extension TelemetryEntity {
var windSpeedAndDirection: WindSpeedAndDirection {
return WindSpeedAndDirection(
windSpeed: self.windSpeed, windDirection: self.windDirection)
var windSpeedAndDirection: WindSpeedAndDirection? {
guard let windSpeed = self.windSpeed else { return nil }
return WindSpeedAndDirection(windSpeed: windSpeed, windDirection: self.windDirection)
}
}

View file

@ -15,50 +15,67 @@ extension MetricsColumnList {
MetricsColumnList(columns: [
// Temperature Series Configuration
MetricsTableColumn(
id: "temperature",
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
minWidth: 30, maxWidth: 45,
tableBody: { _, temp in
Text(temp.formattedTemperature())
temp.map {
Text($0.formattedTemperature())
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Relative Humidity Series Configuration
MetricsTableColumn(
id: "relativeHumidity",
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
minWidth: 30, maxWidth: 45,
tableBody: { _, humidity in
Text("\(String(format: "%.0f", humidity))%")
humidity.map {
Text("\($0.formatted(.number.grouping(.never).precision(.fractionLength(0))))%")
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Barometric Pressure Series Configuration
MetricsTableColumn(
id: "barometricPressure",
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
minWidth: 30, maxWidth: 50,
tableBody: { _, pressure in
if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
Text("\(String(format: "%.1f hPa", pressure))")
} else {
Text("\(String(format: "%.1f", pressure))")
}
pressure.map {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
// Text("\(String(format: "%.1f hPa", $0))")
Text(Measurement(value: Double($0), unit: UnitPressure.hectopascals), format: .measurement(width: .abbreviated, numberFormatStyle: .number.grouping(.never).precision(.fractionLength(1))))
} else {
// Text("\(String(format: "%.1f", $0))")
Text($0, format: .number.grouping(.never).precision(.fractionLength(1)))
}
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Indoor Air Quality Series Configuration
MetricsTableColumn(
id: "iaq",
keyPath: \.iaq,
name: "Indoor Air Quality",
abbreviatedName: "IAQ",
minWidth: 30, maxWidth: 50,
tableBody: { _, iaq in
IndoorAirQuality(iaq: Int(iaq), displayMode: .dot)
if let iaq {
IndoorAirQuality(iaq: Int(iaq), displayMode: .dot)
} else {
Text(verbatim: Constants.nilValueIndicator)
}
}),
// Wind Direction Series Configuration
MetricsTableColumn(
id: "windDirection",
keyPath: \.windDirection,
name: "Wind Direction",
abbreviatedName: "Dir",
@ -67,41 +84,52 @@ extension MetricsColumnList {
tableBody: { _, wind in
HStack(spacing: 1.0) {
// debug data: let wind = Double.random(in: 0..<360.0)
let wind = Double(wind)
Image(systemName: "location.north")
.imageScale(.small)
.scaleEffect(0.9, anchor: .center)
.rotationEffect(.degrees(wind))
.foregroundStyle(.blue)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Text(cardinalValue(from: wind))
if let wind {
HStack(spacing: 1.0) {
// debug data: let wind = Double.random(in: 0..<360.0)
let wind = Double(wind)
Image(systemName: "location.north")
.imageScale(.small)
.scaleEffect(0.9, anchor: .center)
.rotationEffect(.degrees(wind))
.foregroundStyle(.blue)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Text(cardinalValue(from: wind))
} else {
Text(abbreviatedCardinalValue(from: wind))
}
}
} else {
Text(abbreviatedCardinalValue(from: wind))
Text(verbatim: Constants.nilValueIndicator)
}
}
}),
// Wind Speed Series Configuration
MetricsTableColumn(
id: "windSpeed",
keyPath: \.windSpeed,
name: "Wind Speed",
abbreviatedName: "Wind",
minWidth: 30, maxWidth: 60,
visible: false,
tableBody: { _, speed in
let windSpeed = Measurement(
value: Double(speed), unit: UnitSpeed.kilometersPerHour)
Text(
windSpeed.formatted(
.measurement(
width: .abbreviated,
numberFormatStyle: .number.precision(
.fractionLength(0))))
)
speed.map {
let windSpeed = Measurement(
value: Double($0), unit: UnitSpeed.kilometersPerHour)
return Text(
windSpeed.formatted(
.measurement(
width: .abbreviated,
numberFormatStyle: .number.grouping(.never)
.precision(.fractionLength(0))))
)
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Timestamp Series Configuration -- for use in table only
MetricsTableColumn(
id: "time",
keyPath: \.time,
name: "Timestamp",
abbreviatedName: "Time",

View file

@ -144,7 +144,7 @@ struct NodeDetail: View {
}
}
if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, dm.uptimeSeconds > 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..<later).formatted(.components(style: .narrow))
Text(uptime)
.textSelection(.enabled)
@ -206,7 +206,13 @@ struct NodeDetail: View {
}
}
}
if node.hasPositions && UserDefaults.environmentEnableWeatherKit || node.hasEnvironmentMetrics {
// Note, as you add widgets, you should add to the `hasDataForLatestPositions` array
// This will make sure the "Environment" section is only displayed when the node has a position
// to use with WeatherKit, or has actual data in the most recent EnvironmentMetrics entity
// that will be rendered in this section.
if node.hasPositions && UserDefaults.environmentEnableWeatherKit
|| node.hasDataForLatestEnvironmentMetrics(attributes: ["iaq", "temperature", "relativeHumidity", "barometricPressure", "windSpeed"]) {
Section("Environment") {
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
@ -217,19 +223,27 @@ struct NodeDetail: View {
.padding(.vertical)
}
LazyVGrid(columns: gridItemLayout) {
WeatherConditionsCompactWidget(temperature: String(node.latestEnvironmentMetrics?.temperature.shortFormattedTemperature() ?? "99°"), symbolName: "cloud.sun", description: "TEMP")
if node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0 > 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)

View file

@ -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

View file

@ -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

View file

@ -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)