Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Apps/MetaWear/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>10.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
Expand Down
175 changes: 167 additions & 8 deletions Apps/MetaWear/MetaWear/App/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Observation
import SwiftData
import MetaWear
import MetaWearPersistence
import os

/// Main app coordinator shared by the SwiftUI scene.
///
Expand All @@ -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
Expand Down Expand Up @@ -402,7 +409,82 @@ final class AppStore {
let descriptor = FetchDescriptor<RememberedDevice>(
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() {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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<String> = []

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 {
Expand Down
19 changes: 19 additions & 0 deletions Apps/MetaWear/MetaWear/Features/Scan/DeviceFreshness.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
51 changes: 35 additions & 16 deletions Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,30 @@ 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 {
Text("No remembered devices yet.")
.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
Expand All @@ -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) }
)
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading