From 3c14582c77d2ec57f32625dfb6627bc11f910ef1 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 21:05:55 -0700 Subject: [PATCH] =?UTF-8?q?The=20recording=20heartbeat=20survives=20discon?= =?UTF-8?q?nect=20=E2=80=94=20the=20board=20re-arms=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the group-logging cycle: the LED pattern and play were byte-perfect on the wire, but the firmware stops LED playback the moment the link drops, so unattended boards never blinked. The board now re-arms its own heartbeat: group start records two DISCONNECT EVENTS (Settings 0x0A, revision ≥ 2) — re-set the red pattern, then play — so every link drop relights the blink with no phone involved. The immediate setLED still covers the connected window, giving continuous indication across the handoff. The event ids are stamped onto the board's LogSessionRecords (ledEventIDsJSON, lightweight local-only migration) because the bindings OUTLIVE app sessions: an un-removed pair would relight the LED on every future disconnect forever. The collect pass tears them down right after stopping the LED, resolving ids from the records so cleanup survives app restarts. All best-effort — the heartbeat is a courtesy and can never fail a logging session. MWEvent gains a public init(id:) so persisted handles can be reconstructed for removal — bindings live on the board, not in the process. Co-Authored-By: Claude Fable 5 --- .../Persistence/LogSessionRecord.swift | 6 ++ .../ViewModels/GroupCaptureCoordinator.swift | 56 ++++++++++++++++++- Sources/MetaWear/Modules/MWEvent.swift | 7 +++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift b/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift index 2fd392a..32b9ba8 100644 --- a/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift +++ b/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift @@ -23,6 +23,12 @@ final class LogSessionRecord { /// logging is started on several boards together so the downloaded /// `MWSessionRecord`s can inherit it. Nil for solo sessions. var groupID: UUID? + /// JSON-encoded `[UInt8]` of on-board disconnect-event ids arming the + /// LED recording heartbeat — the board re-lights its LED on every + /// disconnect until these are removed, so the collect pass MUST be + /// able to find them even across app restarts. Nil for solo sessions + /// and boards where arming failed. + var ledEventIDsJSON: String? var status: Status { get { Status(rawValue: statusRaw) ?? .running } diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift index 66b8c7e..6a99c8a 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -165,8 +165,21 @@ final class GroupCaptureCoordinator { if await confirmEntriesLanding(on: member.device) { // Best-effort: the heartbeat is a courtesy // indicator — an LED hiccup must not fail a - // successfully started board. + // successfully started board. The immediate play + // covers the connected window; the firmware stops + // LED playback the moment the link drops, so the + // BOARD re-arms it via disconnect events. Their + // ids are stamped onto the records so the collect + // pass can tear them down even after an app + // restart — an un-removed pair would relight the + // LED on every future disconnect, forever. try? await member.device.setLED(red: Self.recordingHeartbeat) + let eventIDs = await armDisconnectHeartbeat(on: member.device) + if !eventIDs.isEmpty, + let json = Self.encodeEventIDs(eventIDs) { + vm.activeRecords.forEach { $0.ledEventIDsJSON = json } + try? containers.local.mainContext.save() + } setPhase(id, .logging) } else { // The session is doomed — the sensors run but the @@ -232,6 +245,14 @@ final class GroupCaptureCoordinator { // if the download below fails. Best-effort, like the set. try? await member.device.stopLED() let pending = appStore.pendingLogSessions.filter { $0.deviceID == id } + // Tear down the disconnect-event heartbeat armed at start — + // without this the board relights its LED on EVERY disconnect. + if let json = pending.compactMap(\.ledEventIDsJSON).first, + let eventIDs = Self.decodeEventIDs(json) { + for eventID in eventIDs { + try? await member.device.removeEvent(MWEvent(id: eventID)) + } + } let running = pending.filter { $0.status == .running } if !running.isEmpty { @@ -346,6 +367,39 @@ final class GroupCaptureCoordinator { return true } + /// Record the LED heartbeat into the board's DISCONNECT EVENT so the + /// blinking survives the link drop: two events fire on disconnect — + /// re-set the red pattern, then play. Requires Settings revision ≥ 2 + /// (the disconnect signal's floor); best-effort like every LED touch. + /// - Returns: the board-assigned event ids (for collect-time removal), + /// empty when arming was skipped or failed. + private func armDisconnectHeartbeat(on device: MetaWearDevice) async -> [UInt8] { + guard (await device.modules[.settings]?.revision ?? 0) >= 2 else { return [] } + do { + let pattern = try await device.createEvent( + source: .disconnected(), + action: try MWEventAction( + command: MWLED.SetPattern(color: .red, pattern: Self.recordingHeartbeat) + ) + ) + let play = try await device.createEvent( + source: .disconnected(), + action: try MWEventAction(command: MWLED.Play()) + ) + return [pattern.id, play.id] + } catch { + return [] + } + } + + private static func encodeEventIDs(_ ids: [UInt8]) -> String? { + (try? JSONEncoder().encode(ids)).flatMap { String(data: $0, encoding: .utf8) } + } + + private static func decodeEventIDs(_ json: String) -> [UInt8]? { + try? JSONDecoder().decode([UInt8].self, from: Data(json.utf8)) + } + /// Poll the live entry count until it rises — proof the board is /// genuinely recording. 90 s bound covers the longest post-clear GC /// observed in the field; healthy boards confirm on the first poll. diff --git a/Sources/MetaWear/Modules/MWEvent.swift b/Sources/MetaWear/Modules/MWEvent.swift index aa182eb..cf65b39 100644 --- a/Sources/MetaWear/Modules/MWEvent.swift +++ b/Sources/MetaWear/Modules/MWEvent.swift @@ -24,6 +24,13 @@ import Foundation public struct MWEvent: Sendable { /// Board-assigned ID used for removal. public let id: UInt8 + + /// Reconstruct a handle from a persisted id — event bindings outlive + /// app sessions (they live on the board), so callers that stored an id + /// need a way back to a removable handle. + public init(id: UInt8) { + self.id = id + } } // MARK: - MWEventSource