diff --git a/Apps/MetaWear/Info.plist b/Apps/MetaWear/Info.plist
index 0751a1d..81a814c 100644
--- a/Apps/MetaWear/Info.plist
+++ b/Apps/MetaWear/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
10.0
CFBundleVersion
- 1
+ 2
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift
index b90f5d8..24b52d0 100644
--- a/Apps/MetaWear/MetaWear/App/AppStore.swift
+++ b/Apps/MetaWear/MetaWear/App/AppStore.swift
@@ -3,6 +3,7 @@ import Observation
import SwiftData
import MetaWear
import MetaWearPersistence
+import os
/// Main app coordinator shared by the SwiftUI scene.
///
@@ -14,6 +15,12 @@ import MetaWearPersistence
@MainActor
final class AppStore {
+ /// Cross-feature diagnostics (filter Console on "MetaWearSync").
+ static let log = Logger(
+ subsystem: "com.mbientlab.MetaWear",
+ category: "MetaWearSync"
+ )
+
let scanner: MetaWearScanner
let containers: AppContainers
let persistence: MWPersistenceStore
@@ -402,7 +409,82 @@ final class AppStore {
let descriptor = FetchDescriptor(
sortBy: [SortDescriptor(\.lastConnected, order: .reverse)]
)
- rememberedDevices = (try? context.fetch(descriptor)) ?? []
+ var records = (try? context.fetch(descriptor)) ?? []
+ // CloudKit forbids unique constraints and its sync-state resets
+ // ("Change Token Expired" recoveries) can re-import server rows next
+ // to their local twins, so duplicates are a fact of life — sweep them
+ // on every refresh. The keeper choice is deterministic so every host
+ // deletes the SAME losers and the fold converges instead of hosts
+ // fighting over which twin survives.
+ let deduped = Self.dedupeRememberedDevices(records, in: context)
+ if deduped.count != records.count {
+ Self.log.info("Folded \(records.count - deduped.count) duplicate remembered-device rows")
+ try? context.save()
+ records = deduped
+ }
+ rememberedDevices = records
+ }
+
+ /// Fold records describing the same physical board — same MAC address,
+ /// or same peripheral UUID (possible after a CloudKit sync-state reset
+ /// resurrects a row) — into one deterministic keeper, merging fields the
+ /// keeper lacks and deleting the rest.
+ ///
+ /// Keeper rule: newest `lastConnected`, tie-broken by MAC presence and
+ /// then peripheral UUID string — all values that sync identically to
+ /// every host, so all hosts converge on the same keeper.
+ static func dedupeRememberedDevices(
+ _ records: [RememberedDevice],
+ in context: ModelContext
+ ) -> [RememberedDevice] {
+ func identity(_ record: RememberedDevice) -> String {
+ record.macAddress ?? "uuid:\(record.peripheralUUID.uuidString)"
+ }
+ func precedence(_ record: RememberedDevice) -> (Date, Int, String) {
+ (record.lastConnected, record.macAddress == nil ? 0 : 1,
+ record.peripheralUUID.uuidString)
+ }
+
+ var keepers: [String: RememberedDevice] = [:]
+ var order: [String] = []
+ for record in records {
+ let key = identity(record)
+ guard let existing = keepers[key] else {
+ keepers[key] = record
+ order.append(key)
+ continue
+ }
+ let (keeper, loser) = precedence(record) > precedence(existing)
+ ? (record, existing) : (existing, record)
+ // Merge anything the keeper is missing before dropping the twin.
+ keeper.macAddress = keeper.macAddress ?? loser.macAddress
+ keeper.serialNumber = keeper.serialNumber ?? loser.serialNumber
+ keeper.firmwareRevision = keeper.firmwareRevision ?? loser.firmwareRevision
+ keeper.modelNumber = keeper.modelNumber ?? loser.modelNumber
+ keepers[key] = keeper
+ context.delete(loser)
+ }
+ // Second pass: a MAC-less row whose peripheralUUID matches a
+ // MAC-bearing keeper is the same board pre-backfill — fold it too.
+ var byUUID: [UUID: String] = [:]
+ for key in order {
+ guard let record = keepers[key] else { continue }
+ if let existingKey = byUUID[record.peripheralUUID],
+ let existing = keepers[existingKey] {
+ let (keeper, loser) = precedence(record) > precedence(existing)
+ ? (record, existing) : (existing, record)
+ keeper.macAddress = keeper.macAddress ?? loser.macAddress
+ keeper.serialNumber = keeper.serialNumber ?? loser.serialNumber
+ keeper.firmwareRevision = keeper.firmwareRevision ?? loser.firmwareRevision
+ keeper.modelNumber = keeper.modelNumber ?? loser.modelNumber
+ keepers[existingKey] = keeper
+ keepers[key] = nil
+ context.delete(loser)
+ } else {
+ byUUID[record.peripheralUUID] = key
+ }
+ }
+ return order.compactMap { keepers[$0] }
}
func refreshPendingLogSessions() {
@@ -434,7 +516,12 @@ final class AppStore {
// per host, so a record synced from the iPhone can't be matched to
// an on-air peripheral on the Mac by UUID alone.
let mac = try? await device.read(MWSettings.ReadMacAddress()).value
- if let mac { recordLocalPeripheral(mac: mac, uuid: id) }
+ if let mac {
+ recordLocalPeripheral(mac: mac, uuid: id)
+ // While connected anyway, teach the board to broadcast its MAC so
+ // the user's OTHER devices recognize it on air (one-time, gated).
+ await configureMACAdvertisementIfNeeded(device, id: id, mac: mac)
+ }
let context = containers.cloud.mainContext
let existing = Self.reconcileRememberedDevice(
@@ -511,6 +598,9 @@ final class AppStore {
func loadLocalPeripheralMap() {
let raw = UserDefaults.standard.dictionary(forKey: Self.localPeripheralsKey) as? [String: String] ?? [:]
localPeripheralUUIDByMAC = raw.compactMapValues(UUID.init(uuidString:))
+ macAdvertisementConfigured = Set(
+ UserDefaults.standard.stringArray(forKey: Self.configuredMACAdKey) ?? []
+ )
}
private func recordLocalPeripheral(mac: String, uuid: UUID) {
@@ -523,17 +613,86 @@ final class AppStore {
}
/// Resolve which peripheral UUID represents a remembered board on THIS
- /// host: the local mapping by MAC when we've connected it here before,
- /// else the record's own UUID (correct on the host that remembered it).
- /// A record synced from another host resolves to its foreign UUID until
- /// the board is connected once on this host — until then it can appear
- /// "offline" while the same hardware shows under Nearby.
+ /// host, in order of confidence:
+ /// 1. The host-local mapping by MAC (built when the board was connected
+ /// here before).
+ /// 2. A live advertisement broadcasting the record's MAC — boards the
+ /// app has touched anywhere are configured to self-identify on air
+ /// (`enableMACAdvertisement`), so a record synced from another host
+ /// matches its nearby twin without ever connecting here.
+ /// 3. The record's own UUID (correct on the host that remembered it).
func localPeripheralUUID(for remembered: RememberedDevice) -> UUID {
- if let mac = remembered.macAddress, let local = localPeripheralUUIDByMAC[mac] {
+ guard let mac = remembered.macAddress else { return remembered.peripheralUUID }
+ if let local = localPeripheralUUIDByMAC[mac] {
return local
}
+ if let onAir = scanner.advertisedMACs.first(where: { $0.value == mac })?.key {
+ return onAir
+ }
return remembered.peripheralUUID
}
+
+ // MARK: - Board MAC self-identification
+
+ /// Host-local set of board MACs this host has already configured to
+ /// broadcast their MAC (an on-boot macro uses a finite flash slot, so
+ /// configuration must not repeat on every connect).
+ private static let configuredMACAdKey = "MWMACAdvertisementConfigured"
+
+ private(set) var macAdvertisementConfigured: Set = []
+
+ private func markMACAdvertisementConfigured(_ mac: String) {
+ guard macAdvertisementConfigured.insert(mac).inserted else { return }
+ UserDefaults.standard.set(
+ Array(macAdvertisementConfigured),
+ forKey: Self.configuredMACAdKey
+ )
+ }
+
+ /// One-time board configuration: make it broadcast its MAC so every
+ /// other Apple device recognizes it during scanning (peripheral UUIDs
+ /// are host-specific; the advertised MAC is the shared identity).
+ /// Failures are logged and ignored — the connect-time mapping still
+ /// covers this host, and the next connect retries.
+ private func configureMACAdvertisementIfNeeded(
+ _ device: MetaWearDevice, id: UUID, mac: String
+ ) async {
+ guard Self.shouldConfigureMACAdvertisement(
+ observedMAC: scanner.advertisedMACs[id],
+ lastAdvertisementSeen: scanner.advertisementLastSeen[id],
+ alreadyConfigured: macAdvertisementConfigured.contains(mac),
+ now: .now
+ ) else { return }
+ do {
+ try await device.enableMACAdvertisement(
+ advertisedName: scanner.advertisedNames[id] ?? "MetaWear"
+ )
+ markMACAdvertisementConfigured(mac)
+ Self.log.info("Configured MAC advertisement for \(mac, privacy: .public)")
+ } catch {
+ Self.log.error("MAC advertisement configuration failed for \(mac, privacy: .public): \(error, privacy: .public)")
+ }
+ }
+
+ /// Pure decision gate for the one-time configuration, unit-tested.
+ ///
+ /// Configure only when this host RECENTLY observed the board advertising
+ /// WITHOUT a MAC (a fresh advertisement proves the current scan-response
+ /// content) and hasn't already configured it. If no advertisement was
+ /// observed — e.g. a direct reconnect to a known identifier without a
+ /// scan — we can't judge the board's current state, so do nothing rather
+ /// than risk stacking duplicate on-boot macros.
+ static func shouldConfigureMACAdvertisement(
+ observedMAC: String?,
+ lastAdvertisementSeen: Date?,
+ alreadyConfigured: Bool,
+ now: Date
+ ) -> Bool {
+ guard observedMAC == nil, !alreadyConfigured,
+ let seen = lastAdvertisementSeen,
+ now.timeIntervalSince(seen) < 15 * 60 else { return false }
+ return true
+ }
}
struct AppError: Identifiable, Sendable {
diff --git a/Apps/MetaWear/MetaWear/Features/Scan/DeviceFreshness.swift b/Apps/MetaWear/MetaWear/Features/Scan/DeviceFreshness.swift
new file mode 100644
index 0000000..b03619d
--- /dev/null
+++ b/Apps/MetaWear/MetaWear/Features/Scan/DeviceFreshness.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// Shared advertisement-freshness rule for the scan screen.
+///
+/// A board is treated as "on air" only if an advertisement arrived within
+/// `window`. Both the Remembered rows' status and the Nearby list use this —
+/// a board that powers off, dies, or connects to another phone stops
+/// advertising, and must stop looking present/connectable here.
+enum DeviceFreshness {
+ /// MetaWear boards advertise several times per second, so 8 s tolerates
+ /// a missed scan cycle without flicker while still dropping silent
+ /// boards promptly.
+ static let window: TimeInterval = 8
+
+ static func isFresh(lastSeen: Date?, now: Date) -> Bool {
+ guard let lastSeen else { return false }
+ return now.timeIntervalSince(lastSeen) < window
+ }
+}
diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift
index ce6a6cb..4aa3f4e 100644
--- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift
+++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift
@@ -17,6 +17,17 @@ struct ScanView: View {
}
var body: some View {
+ // Staleness must become visible even when NOTHING changes: a board
+ // that powers off or connects elsewhere stops advertising, and silence
+ // triggers no observation updates — without a clock, its last-seen
+ // state (row present, RSSI frozen) would linger indefinitely. The
+ // 1 s timeline re-evaluates freshness so silent boards drop out.
+ TimelineView(.periodic(from: .now, by: 1)) { timeline in
+ content(now: timeline.date)
+ }
+ }
+
+ private func content(now: Date) -> some View {
List {
Section("Remembered") {
if appStore.rememberedDevices.isEmpty {
@@ -24,7 +35,12 @@ struct ScanView: View {
.font(.footnote)
.foregroundStyle(.secondary)
} else {
- ForEach(appStore.rememberedDevices, id: \.peripheralUUID) { device in
+ // Keyed by SwiftData identity, not peripheralUUID: CloudKit
+ // sync resets can transiently duplicate rows (same UUID),
+ // and duplicate ForEach IDs are undefined behavior. The
+ // dedupe sweep in refreshRememberedDevices folds them, but
+ // rendering must stay safe in the window before it runs.
+ ForEach(appStore.rememberedDevices, id: \.persistentModelID) { device in
// Resolve to THIS host's peripheral UUID (by MAC) so a
// record synced from another Apple device still gets
// live status/pending-log info once the board has been
@@ -34,7 +50,7 @@ struct ScanView: View {
remembered: device,
isPinned: device.peripheralUUID == pinnedID,
hasPendingLog: appStore.hasPendingLog(forPeripheral: localID),
- status: status(for: localID),
+ status: status(for: localID, now: now),
onTap: { Task { await connect(to: device) } },
onForget: { appStore.forget(device) }
)
@@ -43,12 +59,20 @@ struct ScanView: View {
}
Section("Nearby") {
- // A discovered peripheral is "nearby" only if no remembered
- // record claims it — either directly by UUID or through the
- // host-local MAC mapping (a board remembered on another Apple
- // device, already connected once here).
+ // A discovered peripheral is "nearby" only if it's actually
+ // ON AIR — an advertisement within the freshness window; the
+ // scanner's discovery cache is append-only, so without this a
+ // powered-off or connected-elsewhere board would keep showing
+ // as connectable with a frozen RSSI — and no remembered record
+ // claims it, either directly by UUID or through the host-local
+ // MAC mapping (a board remembered on another Apple device,
+ // already connected once here).
let nearby = (viewModel?.devices ?? []).filter { d in
- !appStore.rememberedDevices.contains {
+ DeviceFreshness.isFresh(
+ lastSeen: appStore.scanner.advertisementLastSeen[d.identifier],
+ now: now
+ )
+ && !appStore.rememberedDevices.contains {
$0.peripheralUUID == d.identifier
|| appStore.localPeripheralUUID(for: $0) == d.identifier
}
@@ -145,12 +169,7 @@ struct ScanView: View {
.onDisappear { viewModel?.stopScan() }
}
- /// Window after the last advertisement during which we still consider the
- /// device "available" on air. Advertisements normally arrive several times
- /// per second; 8 s tolerates one missed scan cycle without flicker.
- private static let availableFreshnessWindow: TimeInterval = 8
-
- private func status(for uuid: UUID) -> DeviceConnectionStatus {
+ private func status(for uuid: UUID, now: Date) -> DeviceConnectionStatus {
if appStore.activeDeviceID == uuid,
appStore.connectionState != .disconnected,
appStore.connectingDeviceID != uuid {
@@ -159,9 +178,9 @@ struct ScanView: View {
if appStore.connectingDeviceID == uuid {
return .connecting
}
- let lastSeen = appStore.scanner.advertisementLastSeen[uuid]
- let fresh = lastSeen.map { Date.now.timeIntervalSince($0) < Self.availableFreshnessWindow } ?? false
- if fresh {
+ if DeviceFreshness.isFresh(
+ lastSeen: appStore.scanner.advertisementLastSeen[uuid], now: now
+ ) {
return .available(rssi: appStore.scanner.advertisementRSSI[uuid])
}
return .offline
diff --git a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj
index 7105165..81eae59 100644
--- a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj
+++ b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj
@@ -412,7 +412,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = MetaWear.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -449,7 +449,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = MetaWear.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -484,7 +484,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
@@ -506,7 +506,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
@@ -527,7 +527,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = S2273LZ6GL;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
diff --git a/Apps/MetaWear/MetaWearTests/DeviceFreshnessTests.swift b/Apps/MetaWear/MetaWearTests/DeviceFreshnessTests.swift
new file mode 100644
index 0000000..8254381
--- /dev/null
+++ b/Apps/MetaWear/MetaWearTests/DeviceFreshnessTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@testable import MetaWearApp
+
+/// Covers the advertisement-freshness rule that decides whether a board is
+/// "on air": stale and never-seen boards must not render as connectable.
+@MainActor
+@Suite("Device freshness")
+struct DeviceFreshnessTests {
+
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ @Test
+ func recentAdvertisementIsFresh() {
+ #expect(DeviceFreshness.isFresh(lastSeen: now.addingTimeInterval(-2), now: now))
+ }
+
+ @Test
+ func silenceBeyondWindowIsStale() {
+ #expect(!DeviceFreshness.isFresh(
+ lastSeen: now.addingTimeInterval(-DeviceFreshness.window - 1), now: now
+ ))
+ }
+
+ @Test
+ func neverSeenIsStale() {
+ #expect(!DeviceFreshness.isFresh(lastSeen: nil, now: now))
+ }
+
+ @Test
+ func boundaryIsExclusive() {
+ // Exactly `window` old = stale; a hair inside = fresh.
+ #expect(!DeviceFreshness.isFresh(
+ lastSeen: now.addingTimeInterval(-DeviceFreshness.window), now: now
+ ))
+ #expect(DeviceFreshness.isFresh(
+ lastSeen: now.addingTimeInterval(-DeviceFreshness.window + 0.01), now: now
+ ))
+ }
+}
diff --git a/Apps/MetaWear/MetaWearTests/MACAdvertisementGateTests.swift b/Apps/MetaWear/MetaWearTests/MACAdvertisementGateTests.swift
new file mode 100644
index 0000000..80835b5
--- /dev/null
+++ b/Apps/MetaWear/MetaWearTests/MACAdvertisementGateTests.swift
@@ -0,0 +1,67 @@
+import Foundation
+import Testing
+@testable import MetaWearApp
+
+/// Covers `AppStore.shouldConfigureMACAdvertisement` — the gate that keeps
+/// the one-time board configuration from stacking duplicate on-boot macros
+/// (macro flash slots are finite).
+@MainActor
+@Suite("MAC advertisement configuration gate")
+struct MACAdvertisementGateTests {
+
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ @Test
+ func configuresFreshUnconfiguredBoard() {
+ #expect(AppStore.shouldConfigureMACAdvertisement(
+ observedMAC: nil,
+ lastAdvertisementSeen: now.addingTimeInterval(-30),
+ alreadyConfigured: false,
+ now: now
+ ))
+ }
+
+ @Test
+ func skipsBoardAlreadyBroadcastingItsMAC() {
+ #expect(!AppStore.shouldConfigureMACAdvertisement(
+ observedMAC: "ED:AA:A4:CE:A6:A4",
+ lastAdvertisementSeen: now.addingTimeInterval(-30),
+ alreadyConfigured: false,
+ now: now
+ ))
+ }
+
+ @Test
+ func skipsBoardThisHostAlreadyConfigured() {
+ // Covers the reconnect-before-reboot window where the board hasn't
+ // re-advertised with the new scan response yet.
+ #expect(!AppStore.shouldConfigureMACAdvertisement(
+ observedMAC: nil,
+ lastAdvertisementSeen: now.addingTimeInterval(-30),
+ alreadyConfigured: true,
+ now: now
+ ))
+ }
+
+ @Test
+ func skipsWhenNoAdvertisementWasObserved() {
+ // Direct reconnect without a scan: the board's current scan-response
+ // content is unknown — don't risk a duplicate macro.
+ #expect(!AppStore.shouldConfigureMACAdvertisement(
+ observedMAC: nil,
+ lastAdvertisementSeen: nil,
+ alreadyConfigured: false,
+ now: now
+ ))
+ }
+
+ @Test
+ func skipsWhenObservationIsStale() {
+ #expect(!AppStore.shouldConfigureMACAdvertisement(
+ observedMAC: nil,
+ lastAdvertisementSeen: now.addingTimeInterval(-16 * 60),
+ alreadyConfigured: false,
+ now: now
+ ))
+ }
+}
diff --git a/Apps/MetaWear/MetaWearTests/RememberedDeviceDedupeTests.swift b/Apps/MetaWear/MetaWearTests/RememberedDeviceDedupeTests.swift
new file mode 100644
index 0000000..7edc7c7
--- /dev/null
+++ b/Apps/MetaWear/MetaWearTests/RememberedDeviceDedupeTests.swift
@@ -0,0 +1,143 @@
+import Foundation
+import Testing
+import SwiftData
+@testable import MetaWearApp
+
+/// Covers `AppStore.dedupeRememberedDevices` — the sweep that folds duplicate
+/// rows CloudKit can produce (no unique constraints; sync-state resets
+/// re-import server rows next to their local twins).
+@MainActor
+@Suite("RememberedDevice dedupe")
+struct RememberedDeviceDedupeTests {
+
+ private let containers: AppContainers
+ private var context: ModelContext { containers.cloud.mainContext }
+
+ init() throws {
+ containers = try AppModelContainer.makeShared(inMemory: true)
+ }
+
+ private func fetchAll() throws -> [RememberedDevice] {
+ try context.fetch(FetchDescriptor())
+ }
+
+ @Test
+ func foldsSamePeripheralUUIDTwins() throws {
+ // The exact shape from the field: a CloudKit reset resurrected a row,
+ // leaving two records with identical peripheralUUID and no MAC.
+ let id = UUID()
+ context.insert(RememberedDevice(
+ peripheralUUID: id, name: "MetaWear",
+ lastConnected: Date(timeIntervalSince1970: 100)
+ ))
+ context.insert(RememberedDevice(
+ peripheralUUID: id, name: "MetaWear",
+ lastConnected: Date(timeIntervalSince1970: 200)
+ ))
+ try context.save()
+
+ let survivors = AppStore.dedupeRememberedDevices(try fetchAll(), in: context)
+ try context.save()
+
+ #expect(survivors.count == 1)
+ #expect(survivors.first?.lastConnected == Date(timeIntervalSince1970: 200))
+ #expect(try fetchAll().count == 1)
+ }
+
+ @Test
+ func foldsSameMACTwinsAndMergesFields() throws {
+ // Two hosts remembered the same board before either synced: same MAC,
+ // different peripheral UUIDs, complementary fields.
+ context.insert(RememberedDevice(
+ peripheralUUID: UUID(), name: "MetaWear",
+ macAddress: "ED:AA:A4:CE:A6:A4",
+ lastConnected: Date(timeIntervalSince1970: 300),
+ serialNumber: nil, firmwareRevision: "1.7.2", modelNumber: nil
+ ))
+ context.insert(RememberedDevice(
+ peripheralUUID: UUID(), name: "MetaWear",
+ macAddress: "ED:AA:A4:CE:A6:A4",
+ lastConnected: Date(timeIntervalSince1970: 100),
+ serialNumber: "0648AF", firmwareRevision: nil, modelNumber: "5"
+ ))
+ try context.save()
+
+ let survivors = AppStore.dedupeRememberedDevices(try fetchAll(), in: context)
+ try context.save()
+
+ #expect(survivors.count == 1)
+ let keeper = try #require(survivors.first)
+ #expect(keeper.lastConnected == Date(timeIntervalSince1970: 300))
+ // Fields the keeper lacked came over from the folded twin.
+ #expect(keeper.serialNumber == "0648AF")
+ #expect(keeper.firmwareRevision == "1.7.2")
+ #expect(keeper.modelNumber == "5")
+ }
+
+ @Test
+ func foldsMACLessTwinIntoMACBearingKeeperByUUID() throws {
+ // Pre-backfill row (no MAC) + the same board's post-backfill row
+ // sharing the peripheral UUID.
+ let id = UUID()
+ context.insert(RememberedDevice(
+ peripheralUUID: id, name: "MetaWear",
+ lastConnected: Date(timeIntervalSince1970: 100)
+ ))
+ context.insert(RememberedDevice(
+ peripheralUUID: id, name: "MetaWear",
+ macAddress: "ED:AA:A4:CE:A6:A4",
+ lastConnected: Date(timeIntervalSince1970: 100)
+ ))
+ try context.save()
+
+ let survivors = AppStore.dedupeRememberedDevices(try fetchAll(), in: context)
+ try context.save()
+
+ #expect(survivors.count == 1)
+ #expect(survivors.first?.macAddress == "ED:AA:A4:CE:A6:A4")
+ }
+
+ @Test
+ func distinctBoardsAreUntouched() throws {
+ context.insert(RememberedDevice(
+ peripheralUUID: UUID(), name: "MetaWear",
+ macAddress: "ED:AA:A4:CE:A6:A4", lastConnected: .now
+ ))
+ context.insert(RememberedDevice(
+ peripheralUUID: UUID(), name: "MetaWear",
+ macAddress: "F1:4A:04:29:26:8B", lastConnected: .now
+ ))
+ context.insert(RememberedDevice(
+ peripheralUUID: UUID(), name: "MetaWear", lastConnected: .now
+ ))
+ try context.save()
+
+ let survivors = AppStore.dedupeRememberedDevices(try fetchAll(), in: context)
+ #expect(survivors.count == 3)
+ }
+
+ @Test
+ func keeperChoiceIsDeterministicRegardlessOfInputOrder() throws {
+ // Same data, both orders → same survivor. This is what keeps multiple
+ // hosts from deleting different twins and never converging.
+ let id = UUID()
+ let a = RememberedDevice(
+ peripheralUUID: id, name: "A",
+ lastConnected: Date(timeIntervalSince1970: 500)
+ )
+ let b = RememberedDevice(
+ peripheralUUID: id, name: "B",
+ lastConnected: Date(timeIntervalSince1970: 400)
+ )
+ context.insert(a)
+ context.insert(b)
+ try context.save()
+
+ let forward = AppStore.dedupeRememberedDevices([a, b], in: context)
+ #expect(forward.first?.name == "A")
+ // (b was deleted; a survives regardless of visit order because the
+ // precedence tuple compares identically.)
+ let reversed = AppStore.dedupeRememberedDevices([a], in: context)
+ #expect(reversed.first?.name == "A")
+ }
+}
diff --git a/Sources/MetaWear/MetaWearScanner.swift b/Sources/MetaWear/MetaWearScanner.swift
index 040c8cc..f796ddb 100644
--- a/Sources/MetaWear/MetaWearScanner.swift
+++ b/Sources/MetaWear/MetaWearScanner.swift
@@ -33,6 +33,16 @@ public final class MetaWearScanner {
/// before a scan to prove a fresh advertisement arrived.
public private(set) var advertisementManufacturerData: [UUID: Data] = [:]
+ /// MAC addresses parsed from MbientLab manufacturer-specific
+ /// advertisement data (company `0x626D`), keyed by peripheral UUID.
+ /// Only boards configured via `MetaWearDevice.enableMACAdvertisement()`
+ /// broadcast this — stock firmware does not — so absence here says
+ /// nothing about a board. The MAC is the only identity that survives
+ /// across the user's Apple devices (peripheral UUIDs are generated per
+ /// host), so this map lets apps recognize a synced remembered board on
+ /// air without connecting.
+ public private(set) var advertisedMACs: [UUID: String] = [:]
+
/// Most-recently-seen RSSI (in dBm) for each peripheral UUID observed in
/// advertisements during a scan. Refreshed on every advertisement so UI
/// can show live signal strength without polling the connected device.
@@ -121,6 +131,11 @@ public final class MetaWearScanner {
// company-ID payload broadcast on air.
if let mfg = result.manufacturerData {
self.advertisementManufacturerData[id] = mfg
+ // Boards configured to broadcast their MAC (company
+ // 0x626D) identify themselves here without a connection.
+ if let mac = MWMACAdvertisement.mac(fromManufacturerData: mfg) {
+ self.advertisedMACs[id] = mac
+ }
}
mwLogVerbose("[Scanner] discovered: \(id) name='\(name)'")
// Accept only MetaWear peripherals (name starts with "MetaWear").
diff --git a/Sources/MetaWear/Models/MWMACAdvertisement.swift b/Sources/MetaWear/Models/MWMACAdvertisement.swift
new file mode 100644
index 0000000..ff746c1
--- /dev/null
+++ b/Sources/MetaWear/Models/MWMACAdvertisement.swift
@@ -0,0 +1,130 @@
+//
+// MWMACAdvertisement.swift
+// MetaWear
+//
+// Builds and parses the MetaWear MAC-broadcast scan response.
+//
+// iOS/macOS hide the true Bluetooth address of peripherals, so two Apple
+// devices cannot recognize the same physical board by CoreBluetooth
+// identifier — those are generated independently per host. The board itself
+// can bridge that gap: its scan response can carry a manufacturer-specific
+// AD structure (MbientLab company identifier 0x626D) containing the 6-byte
+// MAC. `MetaWearDevice.enableMACAdvertisement()` programs it persistently
+// (on-boot macro + immediate apply), and `MetaWearScanner.advertisedMACs`
+// surfaces the parsed values during scanning.
+//
+// Out-of-the-box firmware does NOT broadcast the MAC (verified on fw 1.7.x
+// over the air) — the capability exists but the default scan response
+// doesn't use it, which is why this is an app-applied configuration.
+//
+
+import Foundation
+
+public enum MWMACAdvertisement {
+
+ /// MbientLab's Bluetooth SIG company identifier (0x626D — "mb" in ASCII),
+ /// transmitted little-endian on air.
+ public static let companyIdentifier: UInt16 = 0x626D
+
+ // MARK: - Payload assembly
+
+ /// Assemble a scan-response payload: a Complete Local Name AD structure
+ /// (so the advertised name survives replacing the default scan response)
+ /// followed by a manufacturer-data AD structure carrying the company ID
+ /// and the 6 MAC bytes LSB-first — the same byte order as every other
+ /// MAC transport in the MetaWear protocol.
+ ///
+ /// Layout for name "MetaWear", MAC ED:AA:A4:CE:A6:A4 (20 of 31 bytes):
+ /// ```
+ /// 09 09 4D 65 74 61 57 65 61 72 len=9 type=CompleteLocalName "MetaWear"
+ /// 09 FF 6D 62 A4 A6 CE A4 AA ED len=9 type=ManufacturerData 0x626D + MAC
+ /// ```
+ ///
+ /// - Parameters:
+ /// - name: Advertised device name. Clamped to 19 UTF-8 bytes so the
+ /// payload fits the 31-byte scan response alongside the 10-byte
+ /// manufacturer structure. An empty name omits the name structure.
+ /// - mac: Display form, e.g. `"ED:AA:A4:CE:A6:A4"`.
+ public static func scanResponsePayload(name: String, mac: String) throws -> [UInt8] {
+ let displayBytes = try macBytes(fromDisplay: mac)
+ let nameBytes = Array(name.utf8.prefix(19))
+ var payload: [UInt8] = []
+ if !nameBytes.isEmpty {
+ payload += [UInt8(nameBytes.count + 1), 0x09] + nameBytes
+ }
+ payload += [0x09, 0xFF,
+ UInt8(companyIdentifier & 0xFF),
+ UInt8(companyIdentifier >> 8)]
+ payload += displayBytes.reversed()
+ return payload
+ }
+
+ // MARK: - Parsing
+
+ /// Extract a MAC address from `CBAdvertisementDataManufacturerDataKey`
+ /// bytes. CoreBluetooth strips the AD structure header, so the data
+ /// begins at the (little-endian) company identifier. Returns `nil` for
+ /// non-MbientLab payloads — e.g. the Apple company ID a board broadcasts
+ /// in iBeacon mode — or truncated data.
+ public static func mac(fromManufacturerData data: Data) -> String? {
+ let bytes = [UInt8](data)
+ guard bytes.count >= 8,
+ bytes[0] == UInt8(companyIdentifier & 0xFF),
+ bytes[1] == UInt8(companyIdentifier >> 8) else { return nil }
+ return bytes[2..<8].reversed()
+ .map { String(format: "%02X", $0) }
+ .joined(separator: ":")
+ }
+
+ // MARK: - Internal
+
+ /// Parse a display-form MAC ("ED:AA:A4:CE:A6:A4") into its 6 bytes,
+ /// most-significant first.
+ static func macBytes(fromDisplay display: String) throws -> [UInt8] {
+ let parts = display.split(separator: ":")
+ guard parts.count == 6 else {
+ throw MWError.operationFailed("Invalid MAC address: \(display)")
+ }
+ return try parts.map {
+ guard $0.count == 2, let byte = UInt8($0, radix: 16) else {
+ throw MWError.operationFailed("Invalid MAC address: \(display)")
+ }
+ return byte
+ }
+ }
+}
+
+// MARK: - Device convenience
+
+public extension MetaWearDevice {
+
+ /// Make the board broadcast its MAC address in its scan response —
+ /// persistently — so any Apple device can recognize it during scanning,
+ /// before ever connecting.
+ ///
+ /// Records an on-boot macro (macros survive resets and power cycles) and
+ /// ALSO applies the scan response immediately: macro recording stores
+ /// commands without executing them, so without the live apply the change
+ /// would only take effect after the next reboot.
+ ///
+ /// - Note: The scan response freezes `advertisedName` as broadcast. A
+ /// later `MWSettings.SetDeviceName` rename won't update it until this
+ /// method is called again (which records an additional macro — macro
+ /// slots are finite, so callers should gate reconfiguration, e.g. on
+ /// whether the board's advertisements already carry a MAC).
+ /// - Parameter advertisedName: Name to embed alongside the MAC.
+ /// - Returns: The handle of the recorded on-boot macro.
+ @discardableResult
+ func enableMACAdvertisement(advertisedName: String = "MetaWear") async throws -> MWMacro {
+ let mac = try await read(MWSettings.ReadMacAddress()).value
+ let payload = try MWMACAdvertisement.scanResponsePayload(
+ name: advertisedName, mac: mac
+ )
+ let command = MWSettings.SetScanResponse(payload)
+ let macro = try await recordMacro(executeOnBoot: true) { recorder in
+ await recorder.send(command)
+ }
+ try await send(command)
+ return macro
+ }
+}
diff --git a/Tests/MetaWearHardwareTests/MACAdvertisementHardwareTests.swift b/Tests/MetaWearHardwareTests/MACAdvertisementHardwareTests.swift
new file mode 100644
index 0000000..4406e46
--- /dev/null
+++ b/Tests/MetaWearHardwareTests/MACAdvertisementHardwareTests.swift
@@ -0,0 +1,72 @@
+//
+// MACAdvertisementHardwareTests.swift
+// MetaWear
+//
+// Hardware-required test for `MetaWearDevice.enableMACAdvertisement()`:
+// configures the board to broadcast its MAC in the scan response, then
+// verifies the claim over the air.
+//
+// Out-of-the-box firmware (verified on 1.7.x) does NOT broadcast the MAC —
+// this feature is app-applied configuration. The test leaves the board with
+// NO persistent macro (macros are erased in cleanup); the live scan response
+// reverts to stock on the board's next power cycle.
+//
+
+import Testing
+import MetaWear
+import Foundation
+
+@Suite("MAC advertisement", .serialized)
+struct MACAdvertisementHardwareTests {
+
+ // MARK: test_enable_mac_advertisement
+ // enableMACAdvertisement records an on-boot macro AND applies the scan
+ // response live. After disconnect, the very next advertisement cycle
+ // must carry manufacturer data: company 0x626D + the MAC that the
+ // settings register reported over the connection.
+
+ @Test @MainActor
+ func advertisementCarriesMACAfterConfiguration() async throws {
+ var identifier: UUID?
+ var connectedMAC: String?
+ var advertisedName: String?
+
+ try await withConnectedDevice { device in
+ identifier = device.identifier
+ connectedMAC = try await device.read(MWSettings.ReadMacAddress()).value
+
+ // Erase macros FIRST so the macro this test records is the only
+ // one, and the board is left clean afterwards.
+ try await resetBoardState(device)
+
+ let macro = try await device.enableMACAdvertisement()
+ print(" Recorded on-boot macro id \(macro.id) for MAC \(connectedMAC ?? "?")")
+
+ // Clean up the persistent half immediately — the live scan
+ // response survives until reboot, which is all the air check
+ // needs, and the board isn't left permanently modified by a test.
+ try await device.eraseAllMacros()
+ }
+
+ let id = try #require(identifier)
+ let expectedMAC = try #require(connectedMAC)
+
+ // The board re-advertises after disconnect; its scan response must
+ // now carry the MbientLab manufacturer structure.
+ let manufacturerData = try #require(
+ try await awaitManufacturerData(for: id, timeout: .seconds(10)),
+ "No manufacturer data observed after configuration"
+ )
+ let advertisedMAC = try #require(
+ MWMACAdvertisement.mac(fromManufacturerData: manufacturerData),
+ "Manufacturer data present but not parseable as a MbientLab MAC: \(Array(manufacturerData))"
+ )
+ #expect(advertisedMAC == expectedMAC)
+
+ // The name AD embedded alongside the MAC must keep the board
+ // discoverable — the scanner filters on the "MetaWear" prefix.
+ advertisedName = try await awaitAdvertisedName(for: id, timeout: .seconds(5))
+ #expect(advertisedName?.hasPrefix("MetaWear") == true,
+ "Name lost from scan response: \(advertisedName ?? "nil")")
+ }
+}
diff --git a/Tests/MetaWearTests/MWMACAdvertisementTests.swift b/Tests/MetaWearTests/MWMACAdvertisementTests.swift
new file mode 100644
index 0000000..adae824
--- /dev/null
+++ b/Tests/MetaWearTests/MWMACAdvertisementTests.swift
@@ -0,0 +1,102 @@
+//
+// MWMACAdvertisementTests.swift
+// MetaWearTests
+//
+// Byte-exact coverage for the MAC-broadcast scan response: the payload the
+// app writes to boards, and the parser the scanner runs on advertisements.
+// The reference MAC is a real board captured during development.
+//
+
+import Foundation
+import Testing
+@testable import MetaWear
+
+@Suite("MAC advertisement")
+struct MWMACAdvertisementTests {
+
+ // MARK: - Payload assembly
+
+ @Test
+ func payloadLayoutIsNameThenManufacturerData() throws {
+ let payload = try MWMACAdvertisement.scanResponsePayload(
+ name: "MetaWear", mac: "ED:AA:A4:CE:A6:A4"
+ )
+ #expect(payload == [
+ // len=9, Complete Local Name, "MetaWear"
+ 0x09, 0x09, 0x4D, 0x65, 0x74, 0x61, 0x57, 0x65, 0x61, 0x72,
+ // len=9, Manufacturer Specific, company 0x626D LE, MAC LSB-first
+ 0x09, 0xFF, 0x6D, 0x62, 0xA4, 0xA6, 0xCE, 0xA4, 0xAA, 0xED,
+ ])
+ // Must fit a BLE 4.x scan response.
+ #expect(payload.count <= 31)
+ }
+
+ @Test
+ func emptyNameOmitsNameStructure() throws {
+ let payload = try MWMACAdvertisement.scanResponsePayload(
+ name: "", mac: "ED:AA:A4:CE:A6:A4"
+ )
+ #expect(payload == [
+ 0x09, 0xFF, 0x6D, 0x62, 0xA4, 0xA6, 0xCE, 0xA4, 0xAA, 0xED,
+ ])
+ }
+
+ @Test
+ func longNamesAreClampedToFitScanResponse() throws {
+ let payload = try MWMACAdvertisement.scanResponsePayload(
+ name: String(repeating: "x", count: 40), mac: "ED:AA:A4:CE:A6:A4"
+ )
+ #expect(payload.count == 31)
+ // Name structure holds exactly 19 clamped bytes.
+ #expect(payload[0] == 20) // 19 chars + type byte
+ #expect(payload[1] == 0x09)
+ }
+
+ @Test
+ func invalidMACThrows() {
+ #expect(throws: MWError.self) {
+ _ = try MWMACAdvertisement.scanResponsePayload(name: "MetaWear", mac: "nope")
+ }
+ #expect(throws: MWError.self) {
+ _ = try MWMACAdvertisement.scanResponsePayload(name: "MetaWear", mac: "ED:AA:A4:CE:A6")
+ }
+ #expect(throws: MWError.self) {
+ _ = try MWMACAdvertisement.scanResponsePayload(name: "MetaWear", mac: "ED:AA:A4:CE:A6:ZZ")
+ }
+ }
+
+ // MARK: - Parsing
+
+ @Test
+ func parsesMbientLabManufacturerData() {
+ // CoreBluetooth strips the AD header: data starts at the company ID.
+ let data = Data([0x6D, 0x62, 0xA4, 0xA6, 0xCE, 0xA4, 0xAA, 0xED])
+ #expect(MWMACAdvertisement.mac(fromManufacturerData: data) == "ED:AA:A4:CE:A6:A4")
+ }
+
+ @Test
+ func rejectsForeignCompanyIdentifiers() {
+ // Apple's company ID — what a board broadcasts in iBeacon mode.
+ let iBeacon = Data([0x4C, 0x00, 0x02, 0x15] + Array(repeating: 0 as UInt8, count: 20))
+ #expect(MWMACAdvertisement.mac(fromManufacturerData: iBeacon) == nil)
+ }
+
+ @Test
+ func rejectsTruncatedPayloads() {
+ let short = Data([0x6D, 0x62, 0xA4, 0xA6, 0xCE])
+ #expect(MWMACAdvertisement.mac(fromManufacturerData: short) == nil)
+ #expect(MWMACAdvertisement.mac(fromManufacturerData: Data()) == nil)
+ }
+
+ @Test
+ func buildParseRoundTrip() throws {
+ let mac = "F1:4A:04:29:26:8B"
+ let payload = try MWMACAdvertisement.scanResponsePayload(name: "bob", mac: mac)
+ // Extract the manufacturer AD structure the way a scanner's radio
+ // would deliver it: drop the name structure and the AD header.
+ let nameLength = Int(payload[0]) + 1
+ let mfgStructure = Array(payload.dropFirst(nameLength))
+ let deliveredToCoreBluetooth = Data(mfgStructure.dropFirst(2))
+ #expect(MWMACAdvertisement.mac(fromManufacturerData: deliveredToCoreBluetooth) == mac)
+ }
+}