From f4a1944fd8dc73445d0cf54a46f06890c3ae3387 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 5 Jul 2026 22:03:31 -0700 Subject: [PATCH 1/4] Boards broadcast their MAC so other devices recognize them on air MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remembered board synced through iCloud was a stranger on the user's other Apple devices until connected once per host: peripheral UUIDs are generated per host, the MAC is only readable over a connection, and — verified over the air on fw 1.7.x — stock firmware does NOT broadcast the MAC despite the API docs suggesting otherwise. So the app teaches boards to identify themselves: - MWMACAdvertisement (new): assembles the scan-response payload — a Complete Local Name AD structure (keeps the board discoverable; the scanner filters on the "MetaWear" prefix) plus a manufacturer-data AD structure under MbientLab's company identifier 0x626D carrying the 6 MAC bytes LSB-first — and parses the same structure back out of CBAdvertisementDataManufacturerDataKey bytes. - MetaWearDevice.enableMACAdvertisement(): records the scan response as an on-boot macro (persists across power cycles) AND applies it live, since macro recording stores commands without executing them. Builds on the existing SetScanResponse chunking and MWMacro machinery. - MetaWearScanner.advertisedMACs: parsed MACs per peripheral, populated passively during scanning. - App: rememberDevice configures each board once (pure decision gate, unit-tested: only when a FRESH advertisement was observed WITHOUT a MAC and this host hasn't configured it — on-boot macros consume finite flash slots, so configuration must never stack duplicates). localPeripheralUUID(for:) now resolves remembered records against live advertisements, so a board configured by the iPhone matches its Nearby twin on the Mac with no connection ever made there. Known limitation: the broadcast name is frozen at configuration time; a later rename won't update it until reconfiguration (documented on enableMACAdvertisement). Includes a hardware test (MetaWearHardwareTests) that configures a real board, then verifies the advertised MAC over the air matches the connection-read MAC and the name survived — cleaning up its macro so the board reverts on next power cycle. Co-Authored-By: Claude Opus 4.8 --- Apps/MetaWear/MetaWear/App/AppStore.swift | 98 ++++++++++++- .../MACAdvertisementGateTests.swift | 67 +++++++++ Sources/MetaWear/MetaWearScanner.swift | 15 ++ .../MetaWear/Models/MWMACAdvertisement.swift | 130 ++++++++++++++++++ .../MACAdvertisementHardwareTests.swift | 72 ++++++++++ .../MWMACAdvertisementTests.swift | 102 ++++++++++++++ 6 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 Apps/MetaWear/MetaWearTests/MACAdvertisementGateTests.swift create mode 100644 Sources/MetaWear/Models/MWMACAdvertisement.swift create mode 100644 Tests/MetaWearHardwareTests/MACAdvertisementHardwareTests.swift create mode 100644 Tests/MetaWearTests/MWMACAdvertisementTests.swift diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index b90f5d8..4369e2b 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 @@ -434,7 +441,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 +523,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 +538,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/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/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) + } +} From 0f171c2a18c7a66206b90cbb54fab90a51f36b44 Mon Sep 17 00:00:00 2001 From: lkasso Date: Mon, 6 Jul 2026 16:50:39 -0700 Subject: [PATCH 2/4] Fold duplicate remembered-device rows CloudKit resurrects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report from the Mac: "the ID 0665D988… occurs multiple times within the collection" — two RememberedDevice rows with the same peripheralUUID. CloudKit forbids unique constraints, and its sync-state resets ("Change Token Expired" recovery, observed in the same log after tonight's Development-database surgery) can re-import server rows next to their local twins. The connect-time reconciler folds pairs it touches, but nothing swept duplicates that already existed — and the scan list's ForEach was keyed on peripheralUUID, which is undefined under duplicates. - refreshRememberedDevices now dedupes on every pass: rows sharing a MAC address or a peripheralUUID fold into one keeper (newest lastConnected, deterministic tie-break so every host deletes the SAME losers and the fold converges), merging fields the keeper lacks. Deletions sync, so one host's sweep cleans every host. - The Remembered list is keyed by SwiftData identity so rendering stays defined in the window before a sweep runs. Co-Authored-By: Claude Opus 4.8 --- Apps/MetaWear/MetaWear/App/AppStore.swift | 77 +++++++++- .../MetaWear/Features/Scan/ScanView.swift | 7 +- .../RememberedDeviceDedupeTests.swift | 143 ++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 Apps/MetaWear/MetaWearTests/RememberedDeviceDedupeTests.swift diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index 4369e2b..24b52d0 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -409,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() { diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift index ce6a6cb..c086190 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -24,7 +24,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 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") + } +} From 0bb0ce9ace347ebc353df238e4f9c7786b2fb0a8 Mon Sep 17 00:00:00 2001 From: lkasso Date: Mon, 6 Jul 2026 17:00:17 -0700 Subject: [PATCH 3/4] Drop silent boards from Nearby instead of showing them connectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board that powers off, dies, or connects to another phone stops advertising — but the scanner's discovery cache is append-only and the Nearby list never re-evaluated silence, so the board kept rendering as present and connectable with a frozen RSSI. Worse, silence produces no observation updates, so even a freshness check at render time would never re-run once advertisements stopped. - DeviceFreshness: the shared 8 s advertisement-freshness rule, now used by BOTH the Remembered rows' status and the Nearby filter (previously only Remembered applied it). - The scan screen renders inside a 1 s TimelineView so the passage of time itself re-evaluates freshness: silent boards drop out of Nearby within the window, and a remembered board that dies flips to Offline instead of freezing on "available". Co-Authored-By: Claude Opus 4.8 --- .../Features/Scan/DeviceFreshness.swift | 19 ++++++++ .../MetaWear/Features/Scan/ScanView.swift | 44 ++++++++++++------- .../MetaWearTests/DeviceFreshnessTests.swift | 40 +++++++++++++++++ 3 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 Apps/MetaWear/MetaWear/Features/Scan/DeviceFreshness.swift create mode 100644 Apps/MetaWear/MetaWearTests/DeviceFreshnessTests.swift 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 c086190..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 { @@ -39,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) } ) @@ -48,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 } @@ -150,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 { @@ -164,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/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 + )) + } +} From eaa5dc955d42eefa2e454f00961cb20a6da34d38 Mon Sep 17 00:00:00 2001 From: lkasso Date: Mon, 6 Jul 2026 17:13:29 -0700 Subject: [PATCH 4/4] Bump to build 2 for the second v10.0 upload App Store Connect requires a unique build number per upload; 10.0 (1) is already on TestFlight. Co-Authored-By: Claude Opus 4.8 --- Apps/MetaWear/Info.plist | 2 +- Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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/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;