Skip to content

Commit d6f2178

Browse files
committed
feat(telemetry): ingest and display Air Quality (PM) metrics — Stage 1 (#2040)
iOS previously discarded the firmware's AirQualityMetrics telemetry variant (only counted in the Discovery scan). This lands Stage 1 of #2040 / design#54: ingest and persist particulate-matter data and surface the raw readings. - TelemetryEntity: add six PM concentration fields (pm10/25/100 standard + environmental) as UInt32?, persisted like other environment telemetry. - MeshPackets.telemetryPacket: parse the .airQualityMetrics variant into a metricsType == 3 row (refactors the unhandled-variant guard to a clearer, line-length-compliant list membership check). - NodeInfoEntity: latestAirQualityMetrics / hasAirQualityMetrics helpers. - Node detail: new "Air Quality" section showing raw PM2.5/PM1.0/PM10 (µg/m³) via a new ParticulateMatterCompactWidget, plus an "Air Quality Metrics Log" navigation link (chart + table + CSV export), modeled on the environment log. - CSV export: metricsType == 3 case for the six PM fields. - Tests: AirQualityTelemetryExportTests covering PM CSV serialization. Per design#54, this satisfies the "not enough data → show raw readings" fallback. Stage 2 (NowCast + EPA breakpoint AQI wired into AirQualityIndex) is left as a follow-up.
1 parent 8e0eaaf commit d6f2178

13 files changed

Lines changed: 624 additions & 3 deletions

File tree

Localizable.xcstrings

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
{
22
"sourceLanguage" : "en",
33
"strings" : {
4+
"Air Quality" : {
5+
"comment" : "Node detail section header for particulate-matter air quality readings."
6+
},
7+
"Air Quality Metrics Log" : {
8+
"comment" : "Navigation title and log link for the particulate-matter telemetry log."
9+
},
10+
"Delete all air quality metrics?" : {
11+
"comment" : "Confirmation button to clear the air quality metrics log."
12+
},
13+
"No Air Quality Metrics" : {
14+
"comment" : "Empty state shown when a node has no air quality telemetry."
15+
},
416
"%@ entered %@" : {
517
"comment" : "Geofence notification body. First %@ is the node name, second %@ is the waypoint name."
618
},

Meshtastic.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
4242
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
4343
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
44+
D9E2D2530DBAEBAE908F705A /* AirQualityDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560BEC731E8290A9DE5D87BA /* AirQualityDefaultColumns.swift */; };
45+
45BA94472D98A71C4455A639 /* AirQualityDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51EFEEAA4AF3B42760093E5 /* AirQualityDefaultSeries.swift */; };
46+
1937AD59460EECF5D340F686 /* AirQualityMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F1072895C377A3FA9080E /* AirQualityMetricsLog.swift */; };
4447
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; };
4548
232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C22E2C5E89009DA392 /* TCPTransport.swift */; };
4649
232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */; };
@@ -51,6 +54,7 @@
5154
233E99BA2D849C7000CC3A77 /* PressureCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B92D849C7000CC3A77 /* PressureCompactWidget.swift */; };
5255
233E99BC2D849C8C00CC3A77 /* WindCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99BB2D849C8C00CC3A77 /* WindCompactWidget.swift */; };
5356
233E99BE2D849D3200CC3A77 /* RadiationCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99BD2D849D3200CC3A77 /* RadiationCompactWidget.swift */; };
57+
B27F2D7046759EE0F598A767 /* ParticulateMatterCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C603D09C8DA5703272994165 /* ParticulateMatterCompactWidget.swift */; };
5458
233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99C02D849D6000CC3A77 /* DistanceCompactWidget.swift */; };
5559
233E99C32D849D7A00CC3A77 /* WeightCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99C22D849D7A00CC3A77 /* WeightCompactWidget.swift */; };
5660
233E99C52D84A0B600CC3A77 /* CompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99C42D84A0B600CC3A77 /* CompactWidget.swift */; };
@@ -637,6 +641,9 @@
637641
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
638642
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
639643
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
644+
560BEC731E8290A9DE5D87BA /* AirQualityDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirQualityDefaultColumns.swift; sourceTree = "<group>"; };
645+
F51EFEEAA4AF3B42760093E5 /* AirQualityDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirQualityDefaultSeries.swift; sourceTree = "<group>"; };
646+
163F1072895C377A3FA9080E /* AirQualityMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirQualityMetricsLog.swift; sourceTree = "<group>"; };
640647
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
641648
232ED4C22E2C5E89009DA392 /* TCPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPTransport.swift; sourceTree = "<group>"; };
642649
232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPConnection.swift; sourceTree = "<group>"; };
@@ -648,6 +655,7 @@
648655
233E99B92D849C7000CC3A77 /* PressureCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressureCompactWidget.swift; sourceTree = "<group>"; };
649656
233E99BB2D849C8C00CC3A77 /* WindCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindCompactWidget.swift; sourceTree = "<group>"; };
650657
233E99BD2D849D3200CC3A77 /* RadiationCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiationCompactWidget.swift; sourceTree = "<group>"; };
658+
C603D09C8DA5703272994165 /* ParticulateMatterCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticulateMatterCompactWidget.swift; sourceTree = "<group>"; };
651659
233E99C02D849D6000CC3A77 /* DistanceCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DistanceCompactWidget.swift; sourceTree = "<group>"; };
652660
233E99C22D849D7A00CC3A77 /* WeightCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightCompactWidget.swift; sourceTree = "<group>"; };
653661
233E99C42D84A0B600CC3A77 /* CompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactWidget.swift; sourceTree = "<group>"; };
@@ -1381,6 +1389,8 @@
13811389
isa = PBXGroup;
13821390
children = (
13831391
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */,
1392+
560BEC731E8290A9DE5D87BA /* AirQualityDefaultColumns.swift */,
1393+
F51EFEEAA4AF3B42760093E5 /* AirQualityDefaultSeries.swift */,
13841394
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */,
13851395
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */,
13861396
);
@@ -1397,6 +1407,7 @@
13971407
233E99BB2D849C8C00CC3A77 /* WindCompactWidget.swift */,
13981408
DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */,
13991409
233E99BD2D849D3200CC3A77 /* RadiationCompactWidget.swift */,
1410+
C603D09C8DA5703272994165 /* ParticulateMatterCompactWidget.swift */,
14001411
233E99C02D849D6000CC3A77 /* DistanceCompactWidget.swift */,
14011412
233E99C22D849D7A00CC3A77 /* WeightCompactWidget.swift */,
14021413
233E99C62D84A70900CC3A77 /* SoilCompactWidgets.swift */,
@@ -1891,6 +1902,7 @@
18911902
B990EE65E4D5CABBA4DD8DC2 /* MapWindow.swift */,
18921903
DD73FD1028750779000852D6 /* PositionLog.swift */,
18931904
DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */,
1905+
163F1072895C377A3FA9080E /* AirQualityMetricsLog.swift */,
18941906
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */,
18951907
DD15E4F42B8BFC8E00654F61 /* PaxCounterLog.swift */,
18961908
BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */,
@@ -2975,6 +2987,9 @@
29752987
DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */,
29762988
DD1933782B084F4200771CD5 /* Measurement.swift in Sources */,
29772989
DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */,
2990+
D9E2D2530DBAEBAE908F705A /* AirQualityDefaultColumns.swift in Sources */,
2991+
45BA94472D98A71C4455A639 /* AirQualityDefaultSeries.swift in Sources */,
2992+
1937AD59460EECF5D340F686 /* AirQualityMetricsLog.swift in Sources */,
29782993
DD93800E2BA74D0C008BEC06 /* ChannelForm.swift in Sources */,
29792994
DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */,
29802995
DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */,
@@ -3194,6 +3209,7 @@
31943209
2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */,
31953210
DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */,
31963211
233E99BE2D849D3200CC3A77 /* RadiationCompactWidget.swift in Sources */,
3212+
B27F2D7046759EE0F598A767 /* ParticulateMatterCompactWidget.swift in Sources */,
31973213
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
31983214
23C2BE312EEB823900F6A997 /* NRFDFUSheet.swift in Sources */,
31993215
DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */,

Meshtastic/Export/WriteCsvFile.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@ func telemetryToCsvFile<S: Sequence>(telemetry: S, metricsType: Int) -> String w
6363
csvString += ", "
6464
csvString += dm.time?.formatted(date: .numeric, time: .shortened).replacing(",", with: "") ?? ""
6565
}
66+
} else if metricsType == 3 {
67+
// Create Air Quality Metrics Header (particulate matter, µg/m³)
68+
csvString = "PM1.0 Std, PM2.5 Std, PM10 Std, PM1.0 Env, PM2.5 Env, PM10 Env, \("Timestamp".localized)"
69+
for dm in telemetry where dm.metricsType == 3 {
70+
csvString += "\n"
71+
csvString += dm.pm10Standard?.formatted(.number.grouping(.never)) ?? ""
72+
csvString += ", "
73+
csvString += dm.pm25Standard?.formatted(.number.grouping(.never)) ?? ""
74+
csvString += ", "
75+
csvString += dm.pm100Standard?.formatted(.number.grouping(.never)) ?? ""
76+
csvString += ", "
77+
csvString += dm.pm10Environmental?.formatted(.number.grouping(.never)) ?? ""
78+
csvString += ", "
79+
csvString += dm.pm25Environmental?.formatted(.number.grouping(.never)) ?? ""
80+
csvString += ", "
81+
csvString += dm.pm100Environmental?.formatted(.number.grouping(.never)) ?? ""
82+
csvString += ", "
83+
csvString += dm.time?.formatted(date: .numeric, time: .shortened).replacing(",", with: "") ?? ""
84+
}
6685
} else if metricsType == 4 {
6786
// Create Local Stats Header
6887
csvString = "Noise Floor, Uptime, Relayed, Canceled, Dupes, Packets Tx, Packets Rx, Bad Rx, Nodes Online, Total Nodes, \("Timestamp".localized)"

Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,29 @@ extension NodeInfoEntity {
107107
return try? ctx.fetch(descriptor).first
108108
}
109109

110+
var latestAirQualityMetrics: TelemetryEntity? {
111+
guard let ctx = modelContext else { return nil }
112+
let nodeNum = self.num
113+
let metricsType: Int32 = 3
114+
var descriptor = FetchDescriptor<TelemetryEntity>(
115+
predicate: #Predicate<TelemetryEntity> { $0.nodeTelemetry?.num == nodeNum && $0.metricsType == metricsType },
116+
sortBy: [SortDescriptor(\TelemetryEntity.time, order: .reverse)]
117+
)
118+
descriptor.fetchLimit = 1
119+
return try? ctx.fetch(descriptor).first
120+
}
121+
122+
var hasAirQualityMetrics: Bool {
123+
guard let ctx = modelContext else { return false }
124+
let nodeNum = self.num
125+
let metricsType: Int32 = 3
126+
var descriptor = FetchDescriptor<TelemetryEntity>(
127+
predicate: #Predicate<TelemetryEntity> { $0.nodeTelemetry?.num == nodeNum && $0.metricsType == metricsType }
128+
)
129+
descriptor.fetchLimit = 1
130+
return ((try? ctx.fetch(descriptor)) ?? []).isEmpty == false
131+
}
132+
110133
var latestPowerMetrics: TelemetryEntity? {
111134
guard let ctx = modelContext else { return nil }
112135
let nodeNum = self.num

Meshtastic/Helpers/MeshPackets.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,14 @@ actor MeshPackets {
918918

919919
func telemetryPacket(packet: MeshPacket, connectedNode: Int64) {
920920
if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) {
921-
if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
921+
let handledVariants: [Telemetry.OneOf_Variant] = [
922+
.deviceMetrics(telemetryMessage.deviceMetrics),
923+
.environmentMetrics(telemetryMessage.environmentMetrics),
924+
.localStats(telemetryMessage.localStats),
925+
.powerMetrics(telemetryMessage.powerMetrics),
926+
.airQualityMetrics(telemetryMessage.airQualityMetrics)
927+
]
928+
if !handledVariants.contains(where: { $0 == telemetryMessage.variant }) {
922929
/// Other unhandled telemetry packets
923930
return
924931
}
@@ -1008,6 +1015,16 @@ actor MeshPackets {
10081015
telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage)
10091016
telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current)
10101017
telemetry.metricsType = 2
1018+
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.airQualityMetrics(telemetryMessage.airQualityMetrics) {
1019+
// Air Quality Metrics — particulate matter (µg/m³). Issue #2040 / design#54.
1020+
Logger.data.debug("📈 [Telemetry] Air Quality Metrics Received for Node: \(packet.from.toHex(), privacy: .public)")
1021+
telemetry.pm10Standard = telemetryMessage.airQualityMetrics.hasPm10Standard.then(telemetryMessage.airQualityMetrics.pm10Standard)
1022+
telemetry.pm25Standard = telemetryMessage.airQualityMetrics.hasPm25Standard.then(telemetryMessage.airQualityMetrics.pm25Standard)
1023+
telemetry.pm100Standard = telemetryMessage.airQualityMetrics.hasPm100Standard.then(telemetryMessage.airQualityMetrics.pm100Standard)
1024+
telemetry.pm10Environmental = telemetryMessage.airQualityMetrics.hasPm10Environmental.then(telemetryMessage.airQualityMetrics.pm10Environmental)
1025+
telemetry.pm25Environmental = telemetryMessage.airQualityMetrics.hasPm25Environmental.then(telemetryMessage.airQualityMetrics.pm25Environmental)
1026+
telemetry.pm100Environmental = telemetryMessage.airQualityMetrics.hasPm100Environmental.then(telemetryMessage.airQualityMetrics.pm100Environmental)
1027+
telemetry.metricsType = 3
10111028
}
10121029
telemetry.snr = packet.rxSnr
10131030
telemetry.rssi = packet.rxRssi

Meshtastic/Model/TelemetryEntity.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ final class TelemetryEntity {
3535
var irLux: Float?
3636
var lux: Float?
3737
var noiseFloor: Int32?
38+
// Air Quality Metrics — particulate matter concentrations (µg/m³), from the
39+
// firmware's AirQualityMetrics telemetry variant (metricsType 3). See issue #2040.
40+
var pm10Standard: UInt32?
41+
var pm25Standard: UInt32?
42+
var pm100Standard: UInt32?
43+
var pm10Environmental: UInt32?
44+
var pm25Environmental: UInt32?
45+
var pm100Environmental: UInt32?
3846
var powerCh1Current: Float?
3947
var powerCh1Voltage: Float?
4048
var powerCh2Current: Float?

Meshtastic/Services/DiscoveryScanEngine.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,8 +1210,9 @@ extension DiscoveryScanEngine {
12101210
}
12111211
}
12121212
dn.messageCount = messageCounts[node.num] ?? 0
1213-
// Sensor packets ≈ environment (1) + air-quality (2) telemetry the node has reported.
1214-
dn.sensorPacketCount = node.telemetries.filter { $0.metricsType == 1 || $0.metricsType == 2 }.count
1213+
// Sensor packets ≈ environment (1) + air-quality (3) telemetry the node has reported,
1214+
// matching the live scan's sensorPacketCount (.environmentMetrics + .airQualityMetrics).
1215+
dn.sensorPacketCount = node.telemetries.filter { $0.metricsType == 1 || $0.metricsType == 3 }.count
12151216
dn.presetName = presetName
12161217
dn.session = session
12171218
dn.presetResult = result
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// ParticulateMatterCompactWidget.swift
3+
// Meshtastic
4+
//
5+
// Raw particulate-matter reading tile for node detail (issue #2040 / design#54).
6+
//
7+
8+
import SwiftUI
9+
10+
struct ParticulateMatterCompactWidget: View {
11+
let label: String
12+
let value: UInt32
13+
14+
private var formattedValue: String {
15+
value.formatted(.number.grouping(.never))
16+
}
17+
18+
var body: some View {
19+
VStack(alignment: .leading) {
20+
HStack(alignment: .firstTextBaseline) {
21+
Image(systemName: "aqi.medium")
22+
.font(.system(size: 30))
23+
.tint(.accentColor)
24+
Text(verbatim: label)
25+
.textCase(.uppercase)
26+
.font(.callout)
27+
}
28+
HStack(alignment: .firstTextBaseline) {
29+
Text(verbatim: formattedValue)
30+
.font(formattedValue.count < 4 ? .system(size: 50) : .system(size: 34))
31+
Text(verbatim: "µg/m³")
32+
.font(.system(size: 14))
33+
}
34+
}
35+
.frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140)
36+
.padding()
37+
.background(Color("Colors/MeshtasticTile"), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
38+
}
39+
}
40+
41+
#Preview {
42+
ParticulateMatterCompactWidget(label: "PM2.5", value: 42)
43+
}

0 commit comments

Comments
 (0)