Skip to content

Commit 109c965

Browse files
committed
fix: stabilize duplicate hardware model resolution
1 parent da47f06 commit 109c965

8 files changed

Lines changed: 279 additions & 28 deletions

File tree

Meshtastic/Helpers/MeshPackets.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -487,8 +487,8 @@ actor MeshPackets {
487487
let hwDescriptor = FetchDescriptor<DeviceHardwareEntity>(
488488
predicate: #Predicate { $0.hwModel == hwModelValue }
489489
)
490-
if let hardwareEntity = try? modelContext.fetch(hwDescriptor).first {
491-
newUser.hwDisplayName = hardwareEntity.displayName
490+
if let hardware = try? modelContext.fetch(hwDescriptor) {
491+
newUser.hwDisplayName = HardwareCatalogResolver.presentation(for: hwModelValue, in: hardware)?.displayName
492492
}
493493
newUser.isLicensed = nodeInfo.user.isLicensed
494494
newUser.role = Int32(nodeInfo.user.role.rawValue)
@@ -612,8 +612,8 @@ actor MeshPackets {
612612
let hwDescriptor2 = FetchDescriptor<DeviceHardwareEntity>(
613613
predicate: #Predicate { $0.hwModel == hwModelValue2 }
614614
)
615-
if let hardwareEntity = try? modelContext.fetch(hwDescriptor2).first {
616-
user.hwDisplayName = hardwareEntity.displayName
615+
if let hardware = try? modelContext.fetch(hwDescriptor2) {
616+
user.hwDisplayName = HardwareCatalogResolver.presentation(for: hwModelValue2, in: hardware)?.displayName
617617
}
618618
}
619619
} else {

Meshtastic/Model/DeviceHardwareEntity.swift

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class DeviceHardwareEntity {
1515
var displayName: String?
1616
var hasInkHud: Bool = false
1717
var hasMui: Bool = false
18-
@Attribute(.unique) var hwModel: Int64 = 0
18+
var hwModel: Int64 = 0
1919
var hwModelSlug: String?
2020
var key: String?
2121
var partitionScheme: String?
@@ -32,3 +32,112 @@ final class DeviceHardwareEntity {
3232

3333
init() {}
3434
}
35+
36+
/// A target-specific entry from the device-hardware catalog.
37+
///
38+
/// Firmware targets are not a one-to-one representation of the radio hardware model reported
39+
/// over the mesh protocol: multiple targets can intentionally report the same `hwModel`.
40+
struct HardwareCatalogRecord: Equatable {
41+
let hwModel: Int64
42+
let hwModelSlug: String
43+
let platformioTarget: String
44+
let displayName: String
45+
let activelySupported: Bool
46+
let supportLevel: SupportLevel
47+
let architecture: String?
48+
49+
init(
50+
hwModel: Int64,
51+
hwModelSlug: String,
52+
platformioTarget: String,
53+
displayName: String,
54+
activelySupported: Bool,
55+
supportLevel: SupportLevel,
56+
architecture: String? = nil
57+
) {
58+
self.hwModel = hwModel
59+
self.hwModelSlug = hwModelSlug
60+
self.platformioTarget = platformioTarget
61+
self.displayName = displayName
62+
self.activelySupported = activelySupported
63+
self.supportLevel = supportLevel
64+
self.architecture = architecture
65+
}
66+
67+
init(_ entity: DeviceHardwareEntity) {
68+
self.init(
69+
hwModel: entity.hwModel,
70+
hwModelSlug: entity.hwModelSlug ?? "",
71+
platformioTarget: entity.platformioTarget ?? "",
72+
displayName: entity.displayName ?? "",
73+
activelySupported: entity.activelySupported,
74+
supportLevel: SupportLevel(rawValue: entity.supportLevel) ?? .discontinued,
75+
architecture: entity.architecture
76+
)
77+
}
78+
}
79+
80+
/// Safe presentation metadata for a radio that only reports a protobuf `HardwareModel`.
81+
///
82+
/// Target-specific values are omitted when the protocol cannot distinguish the catalog variants.
83+
struct HardwareCatalogPresentation: Equatable {
84+
let displayName: String?
85+
let platformioTarget: String?
86+
let activelySupported: Bool?
87+
let supportLevel: SupportLevel?
88+
let architecture: String?
89+
}
90+
91+
enum HardwareCatalogResolver {
92+
static func presentation(for hwModel: Int64, in entities: [DeviceHardwareEntity]) -> HardwareCatalogPresentation? {
93+
presentation(for: hwModel, in: entities.map(HardwareCatalogRecord.init))
94+
}
95+
96+
static func presentation(for hwModel: Int64, in records: [HardwareCatalogRecord]) -> HardwareCatalogPresentation? {
97+
let matches = records.filter { $0.hwModel == hwModel }
98+
guard !matches.isEmpty else { return nil }
99+
100+
if matches.count == 1, let record = matches.first {
101+
return presentation(for: record)
102+
}
103+
104+
let canonicalTargets = matches.filter {
105+
$0.platformioTarget == normalizedTarget(from: $0.hwModelSlug)
106+
}
107+
if canonicalTargets.count == 1, let canonical = canonicalTargets.first {
108+
return presentation(for: canonical)
109+
}
110+
111+
// The radio protocol cannot supply target-level identity. Present the most desirable
112+
// catalog entry consistently instead of letting database/query order pick one at random.
113+
let preferred = matches.sorted(by: isPreferred(_:over:)).first!
114+
return presentation(for: preferred)
115+
}
116+
117+
private static func presentation(for record: HardwareCatalogRecord) -> HardwareCatalogPresentation {
118+
HardwareCatalogPresentation(
119+
displayName: record.displayName,
120+
platformioTarget: record.platformioTarget,
121+
activelySupported: record.activelySupported,
122+
supportLevel: record.supportLevel,
123+
architecture: record.architecture
124+
)
125+
}
126+
127+
private static func normalizedTarget(from hardwareModelSlug: String) -> String {
128+
hardwareModelSlug.lowercased().replacingOccurrences(of: "_", with: "-")
129+
}
130+
131+
private static func isPreferred(_ lhs: HardwareCatalogRecord, over rhs: HardwareCatalogRecord) -> Bool {
132+
if lhs.supportLevel != rhs.supportLevel {
133+
return lhs.supportLevel.rawValue < rhs.supportLevel.rawValue
134+
}
135+
if lhs.activelySupported != rhs.activelySupported {
136+
return lhs.activelySupported
137+
}
138+
if lhs.displayName != rhs.displayName {
139+
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
140+
}
141+
return lhs.platformioTarget.localizedStandardCompare(rhs.platformioTarget) == .orderedAscending
142+
}
143+
}

Meshtastic/Persistence/UpdateSwiftData.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,8 @@ extension MeshPackets {
413413
let hwDescriptor1 = FetchDescriptor<DeviceHardwareEntity>(
414414
predicate: #Predicate { $0.hwModel == fetchHwModel1 }
415415
)
416-
if let hardwareEntity = try? modelContext.fetch(hwDescriptor1).first {
417-
newUser.hwDisplayName = hardwareEntity.displayName
416+
if let hardware = try? modelContext.fetch(hwDescriptor1) {
417+
newUser.hwDisplayName = HardwareCatalogResolver.presentation(for: fetchHwModel1, in: hardware)?.displayName
418418
}
419419
newNode.user = newUser
420420

@@ -520,8 +520,8 @@ extension MeshPackets {
520520
let hwDescriptor2 = FetchDescriptor<DeviceHardwareEntity>(
521521
predicate: #Predicate { $0.hwModel == fetchHwModel2 }
522522
)
523-
if let hardwareEntity = try? modelContext.fetch(hwDescriptor2).first {
524-
user.hwDisplayName = hardwareEntity.displayName
523+
if let hardware = try? modelContext.fetch(hwDescriptor2) {
524+
user.hwDisplayName = HardwareCatalogResolver.presentation(for: fetchHwModel2, in: hardware)?.displayName
525525
}
526526
}
527527
}

Meshtastic/Views/Helpers/DeviceHardwareImage.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,28 @@ import SwiftDraw
1515
struct DeviceHardwareImage: View {
1616

1717
@Query var hardwareResults: [DeviceHardwareEntity]
18+
private let hardwareModel: Int64?
1819

1920
// Init for Integer ID
2021
init<T>(hwId: T) where T: BinaryInteger {
2122
let hwModel = Int64(hwId)
23+
hardwareModel = hwModel
2224
_hardwareResults = Query(filter: #Predicate<DeviceHardwareEntity> { hw in
2325
hw.hwModel == hwModel
2426
}, sort: [SortDescriptor(\.hwModelSlug)])
2527
}
2628

2729
// Init for String Target
2830
init(platformioTarget: String) {
31+
hardwareModel = nil
2932
_hardwareResults = Query(filter: #Predicate<DeviceHardwareEntity> { hw in
3033
hw.platformioTarget == platformioTarget
3134
}, sort: [SortDescriptor(\.hwModelSlug)])
3235
}
3336

3437
var body: some View {
3538
// Pass the raw fetched results to the logic layer
36-
DeviceHardwareImageProcessor(hardware: hardwareResults)
39+
DeviceHardwareImageProcessor(hardware: hardwareResults, hardwareModel: hardwareModel)
3740
}
3841
}
3942

@@ -42,6 +45,7 @@ struct DeviceHardwareImage: View {
4245
// This uses .task to step out of the Layout Loop.
4346
private struct DeviceHardwareImageProcessor: View {
4447
let hardware: [DeviceHardwareEntity]
48+
let hardwareModel: Int64?
4549
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
4650

4751
// We buffer the processed images in State.
@@ -50,7 +54,7 @@ private struct DeviceHardwareImageProcessor: View {
5054

5155
/// A stable identity that changes when the actual hardware models change, not just the count.
5256
private var hardwareIdentity: String {
53-
hardware.map { "\($0.hwModel)" }.joined(separator: ",")
57+
hardware.map { "\($0.hwModel):\($0.platformioTarget ?? "")" }.joined(separator: ",")
5458
}
5559

5660
var body: some View {
@@ -67,10 +71,18 @@ private struct DeviceHardwareImageProcessor: View {
6771

6872
// The heavy logic moved out of the computed property
6973
private func processImages() -> [DeviceHardwareImageEntity] {
74+
let displayedHardware: [DeviceHardwareEntity]
75+
if let hardwareModel,
76+
let target = HardwareCatalogResolver.presentation(for: hardwareModel, in: hardware)?.platformioTarget {
77+
displayedHardware = hardware.filter { $0.platformioTarget == target }
78+
} else {
79+
displayedHardware = hardware
80+
}
81+
7082
var returnImages = [DeviceHardwareImageEntity]()
7183
var seenFileNames = Set<String>()
7284

73-
for item in hardware {
85+
for item in displayedHardware {
7486
for image in item.images {
7587
if image.svgData != nil {
7688
let name = image.fileName ?? ""

Meshtastic/Views/Helpers/SupportedHardwareBadge.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ struct SupportedHardwareBadge: View {
2525
hw.platformioTarget == platformioTarget
2626
}, sort: [SortDescriptor(\.hwModelSlug)])
2727
}
28+
29+
private var presentation: HardwareCatalogPresentation? {
30+
guard let hwModel = hardware.first?.hwModel else { return nil }
31+
return HardwareCatalogResolver.presentation(for: hwModel, in: hardware)
32+
}
2833

2934
var body: some View {
30-
if let device = hardware.first {
35+
if let activelySupported = presentation?.activelySupported {
3136
VStack {
32-
Image(systemName: device.activelySupported ? "checkmark.seal.fill" : "x.circle")
37+
Image(systemName: activelySupported ? "checkmark.seal.fill" : "x.circle")
3338
.font(.largeTitle)
34-
.foregroundStyle(device.activelySupported ? .green : .red)
35-
Text( device.activelySupported ? "Supported" : "Unsupported")
39+
.foregroundStyle(activelySupported ? .green : .red)
40+
Text( activelySupported ? "Supported" : "Unsupported")
3641
.foregroundStyle(.gray)
3742
.font(.caption2)
3843
.fixedSize()

Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ struct NodeInfoItem: View {
2525
}
2626

2727
private var hasDevice: Bool {
28-
hardware.first != nil
28+
hardwarePresentation != nil
2929
}
3030

3131
private var isActivelySupported: Bool {
32-
hardware.first?.activelySupported ?? false
32+
hardwarePresentation?.activelySupported ?? false
33+
}
34+
35+
private var hardwarePresentation: HardwareCatalogPresentation? {
36+
HardwareCatalogResolver.presentation(for: Int64(node.user?.hwModelId ?? 0), in: hardware)
3337
}
3438

3539
private var supportRosette: some View {
@@ -38,21 +42,30 @@ struct NodeInfoItem: View {
3842
}
3943

4044
private var modelName: String {
41-
node.user?.hwDisplayName ?? node.user?.hwModel ?? "Unknown"
45+
hardwarePresentation?.displayName ?? node.user?.hwModel ?? "Unknown"
4246
}
4347

4448
private var isPortduino: Bool {
4549
node.user?.hwModel == "PORTDUINO"
4650
}
4751

48-
private var supportLevel: SupportLevel {
49-
guard let device = hardware.first else { return .discontinued }
50-
return SupportLevel(rawValue: device.supportLevel) ?? .discontinued
52+
private var supportLevel: SupportLevel? {
53+
hardwarePresentation?.supportLevel
54+
}
55+
56+
private var hardwareDescription: String {
57+
if let supportLevel {
58+
return supportLevel.description
59+
}
60+
return hasDevice
61+
? "This hardware model has multiple indistinguishable variants."
62+
: "Hardware model information is unavailable."
5163
}
5264

5365
private var sectionTitle: String {
5466
if node.user?.hwModel == "UNSET" { return "Hardware" }
5567
if isPortduino { return "Community Hardware" }
68+
guard let supportLevel else { return "Hardware" }
5669
switch supportLevel {
5770
case .flagship:
5871
return "Supported Hardware"
@@ -141,13 +154,19 @@ struct NodeInfoItem: View {
141154
} else {
142155
// MARK: - Discontinued / Unknown Device
143156
HStack(spacing: 16) {
144-
supportRosette
145-
.font(.system(size: 40))
157+
if hardwarePresentation?.activelySupported == nil {
158+
Image(systemName: "questionmark.circle.fill")
159+
.font(.system(size: 40))
160+
.foregroundStyle(.secondary)
161+
} else {
162+
supportRosette
163+
.font(.system(size: 40))
164+
}
146165
VStack(alignment: .leading, spacing: 4) {
147166
Text(modelName)
148167
.font(.subheadline)
149168
.foregroundStyle(.secondary)
150-
Text(supportLevel.description)
169+
Text(hardwareDescription)
151170
.font(.caption)
152171
.foregroundStyle(.tertiary)
153172
}
@@ -160,7 +179,7 @@ struct NodeInfoItem: View {
160179
.accessibilityElement(children: .combine)
161180

162181
// Device links section (shown only when device has a platformioTarget)
163-
if let target = hardware.first?.platformioTarget {
182+
if let target = hardwarePresentation?.platformioTarget {
164183
DeviceLinksSection(platformioTarget: target)
165184
}
166185
}

Meshtastic/Views/Settings/Config/PowerConfig.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ struct PowerConfig: View {
123123
let descriptor = FetchDescriptor<DeviceHardwareEntity>(
124124
predicate: #Predicate { $0.hwModel == hwModelValue }
125125
)
126-
if let hardwareEntity = try? context.fetch(descriptor).first,
127-
let archString = hardwareEntity.architecture,
126+
if let hardware = try? context.fetch(descriptor),
127+
let archString = HardwareCatalogResolver.presentation(for: hwModelValue, in: hardware)?.architecture,
128128
let arch = Architecture(rawValue: archString) {
129129
architecture = arch
130130
}

0 commit comments

Comments
 (0)