mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge branch '2.5.20' into feature/Sync_Serbian_translations
This commit is contained in:
commit
d9035c7893
29 changed files with 723 additions and 430 deletions
|
|
@ -36,6 +36,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
" %@%%" : {
|
||||
|
||||
},
|
||||
"--" : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
": %@" : {
|
||||
"localizations" : {
|
||||
"sr" : {
|
||||
|
|
@ -67,6 +73,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"?" : {
|
||||
|
||||
},
|
||||
"(Re)define PIN_GPS_EN for your board." : {
|
||||
"localizations" : {
|
||||
|
|
@ -742,6 +751,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%f%%" : {
|
||||
|
||||
},
|
||||
"%lf" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1480,22 +1492,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Airtime %@%%" : {
|
||||
"localizations" : {
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Време емитовања %@%%"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "广播时间 %@%%"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Alert" : {
|
||||
"localizations" : {
|
||||
"sr" : {
|
||||
|
|
@ -3620,7 +3616,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Channel Utilization %@%% " : {
|
||||
"Channel Utilization %@%%" : {
|
||||
"localizations" : {
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -12164,22 +12160,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"HUMIDITY" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "LUFTFEUCHTIGKEIT"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ВЛАЖНОСТ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hybrid" : {
|
||||
"extractionState" : "migrated",
|
||||
"localizations" : {
|
||||
|
|
@ -23485,18 +23465,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"PRESSURE" : {
|
||||
"Pressure" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "DRUCK"
|
||||
"value" : "Druck"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ПРИТИСАК"
|
||||
"value" : "Притисак"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32489,7 +32469,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"voltage" : {
|
||||
"Voltage" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -32829,18 +32809,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"WIND" : {
|
||||
"Wind" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "WIND"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ВЕТАР"
|
||||
"value" : "Ветар"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
@ -1786,7 +1808,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.19;
|
||||
MARKETING_VERSION = 2.5.20;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1820,7 +1842,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.19;
|
||||
MARKETING_VERSION = 2.5.20;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1852,7 +1874,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.19;
|
||||
MARKETING_VERSION = 2.5.20;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1885,7 +1907,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.19;
|
||||
MARKETING_VERSION = 2.5.20;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
|
|||
case allSkipDecoding = 1
|
||||
case localOnly = 2
|
||||
case knownOnly = 3
|
||||
case corePortnums = 4
|
||||
|
||||
var id: Int { self.rawValue }
|
||||
|
||||
|
|
@ -157,6 +158,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
|
|||
return "Local Only"
|
||||
case .knownOnly:
|
||||
return "Known Only"
|
||||
case .corePortnums:
|
||||
return "Core Portnums Only"
|
||||
}
|
||||
}
|
||||
var description: String {
|
||||
|
|
@ -169,6 +172,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
|
|||
return "Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels."
|
||||
case .knownOnly:
|
||||
return "Ignores observed messages from foreign meshes like Local Only, but takes it step further by also ignoring messages from nodes not already in the node's known list."
|
||||
case .corePortnums:
|
||||
return "Only rebroadcasts packets from the core portnums: NodeInfo, Text, Position, Telemetry, and Routing."
|
||||
}
|
||||
}
|
||||
func protoEnumValue() -> Config.DeviceConfig.RebroadcastMode {
|
||||
|
|
@ -182,6 +187,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
|
|||
return Config.DeviceConfig.RebroadcastMode.localOnly
|
||||
case .knownOnly:
|
||||
return Config.DeviceConfig.RebroadcastMode.knownOnly
|
||||
case .corePortnums:
|
||||
return Config.DeviceConfig.RebroadcastMode.corePortnumsOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "--"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,4 +90,15 @@ extension String {
|
|||
let end = index(start, offsetBy: range.upperBound - range.lowerBound)
|
||||
return String(self[start ..< end])
|
||||
}
|
||||
|
||||
// Filter out variation selectors from the string
|
||||
var withoutVariationSelectors: String {
|
||||
return self.unicodeScalars
|
||||
.filter { scalar in
|
||||
return !scalar.properties.isVariationSelector
|
||||
}
|
||||
.compactMap { UnicodeScalar($0) }
|
||||
.map { String($0) }
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,8 +225,19 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
|
|||
|
||||
// Called when a Peripheral fails to connect
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
cancelPeripheralConnection()
|
||||
Logger.services.error("🚫 [BLE] Failed to Connect: \(peripheral.name ?? "Unknown", privacy: .public)")
|
||||
if let e = error {
|
||||
// https://developer.apple.com/documentation/corebluetooth/cberror/code
|
||||
let errorCode = (e as NSError).code
|
||||
cancelPeripheralConnection()
|
||||
if errorCode == 14 { // Peer removed pairing information
|
||||
// Forgetting and reconnecting seems to be necessary so we need to show the user an error telling them to do that
|
||||
lastConnectionError = "🚨 " + String.localizedStringWithFormat("%@ This error usually cannot be fixed without forgetting the device unders Settings > Bluetooth and re pairing the radio.".localized, e.localizedDescription)
|
||||
Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized) Error Code: \(errorCode, privacy: .public) Error: \(self.lastConnectionError, privacy: .public)")
|
||||
} else {
|
||||
lastConnectionError = "🚨 \(e.localizedDescription)"
|
||||
Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect Peripheral Event
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -196,7 +196,6 @@ struct MQTTConfig: View {
|
|||
}
|
||||
.keyboardType(.default)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
|
||||
HStack {
|
||||
Label("password", systemImage: "wallet.pass")
|
||||
TextField("password", text: $password)
|
||||
|
|
@ -206,7 +205,7 @@ struct MQTTConfig: View {
|
|||
.onChange(of: password) {
|
||||
var totalBytes = password.utf8.count
|
||||
// Only mess with the value if it is too big
|
||||
while totalBytes > 62 {
|
||||
while totalBytes > 30 {
|
||||
password = String(password.dropLast())
|
||||
totalBytes = password.utf8.count
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,11 +75,16 @@ struct UserConfig: View {
|
|||
TextField("Short Name", text: $shortName)
|
||||
.foregroundColor(.gray)
|
||||
.onChange(of: shortName) {
|
||||
var totalBytes = shortName.utf8.count
|
||||
let newValue = shortName.withoutVariationSelectors
|
||||
let totalBytes = newValue.utf8.count
|
||||
// Only mess with the value if it is too big
|
||||
if totalBytes > 4 {
|
||||
// If too long, drop the last thing entered
|
||||
shortName = String(shortName.dropLast())
|
||||
totalBytes = shortName.utf8.count
|
||||
} else if shortName != newValue {
|
||||
// If not too long, make sure the stripped
|
||||
// variant is placed back in text field if necessary
|
||||
shortName = newValue
|
||||
}
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue