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
6 changes: 6 additions & 0 deletions Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
56 changes: 55 additions & 1 deletion Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions Sources/MetaWear/Modules/MWEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading