From dfc41c38deaf82bb1b0750aac1a6571387561273 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 21:16:04 -0700 Subject: [PATCH] Settings gains a Maintenance section: LED reset, macro/event clears, restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request plus the escape hatches the group-logging cycle showed we need: - Reset LED — stop playback and clear patterns immediately. The footer points at the real fix when the light returns after a disconnect: an on-board event is re-arming it. - Clear Macros — erases all on-boot macros, with the confirm warning that this includes the identity-broadcast macro (the app re-applies it automatically on the next connect via the existing gate). - Clear Events & Timers — removes ALL on-board bindings, the manual way out of a lingering recording-heartbeat disconnect event (the documented edge when a different phone collects a heartbeat-armed board and can't know the event ids). - Restart Board — new SDK MetaWearDevice.restart(): reboot WITHOUT erasing (logs and macros survive; volatile state — timers, events, a wedged NAND housekeeping pass — clears). Same intentional- disconnect suppression and MMS ResetAfterGC-no-op fallback as factoryReset, without the erase steps. The unwedge we kept wishing for during the GC saga, now one tap. All destructive actions confirm via centered alerts per the app convention; failures surface in a dedicated alert. Co-Authored-By: Claude Fable 5 --- .../Settings/DeviceSettingsView.swift | 101 ++++++++++++++++++ Sources/MetaWear/MetaWearDevice.swift | 25 +++++ 2 files changed, 126 insertions(+) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index 0ddd140..46584cb 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -19,6 +19,10 @@ struct DeviceSettingsView: View { @State private var activeLoggerCount: Int? @State private var isClearing = false @State private var clearLogError: AppError? + @State private var showClearMacrosConfirm = false + @State private var showClearEventsConfirm = false + @State private var showRestartConfirm = false + @State private var maintenanceError: AppError? var body: some View { Form { @@ -69,6 +73,25 @@ struct DeviceSettingsView: View { Text("Stops any active logging, drops every entry from flash, and removes all logger subscriptions. Local pending sessions for this device are also deleted (their backing data on the board is gone).") } + Section { + Button("Reset LED", systemImage: "lightbulb.slash") { + Task { await resetLED() } + } + Button("Clear Macros", systemImage: "memorychip", role: .destructive) { + showClearMacrosConfirm = true + } + Button("Clear Events & Timers", systemImage: "calendar.badge.minus", role: .destructive) { + showClearEventsConfirm = true + } + Button("Restart Board", systemImage: "arrow.counterclockwise") { + showRestartConfirm = true + } + } header: { + Text("Maintenance") + } footer: { + Text("Reset LED stops and clears the light immediately — if it relights after a disconnect, an on-board event is re-arming it; use Clear Events & Timers. Restart reboots the board without erasing logs or macros.") + } + Section { Button("Factory Reset", systemImage: "exclamationmark.triangle", role: .destructive) { showFactoryResetConfirm = true @@ -120,6 +143,38 @@ struct DeviceSettingsView: View { Text("Any logger subscriptions and pending entries will be removed from the board.") } } + .alert("Clear all macros on this board?", + isPresented: $showClearMacrosConfirm) { + Button("Clear Macros", role: .destructive) { + Task { await clearMacros() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Removes every on-boot macro — including the identity broadcast this app configures. The app re-applies its broadcast automatically on the next connect.") + } + .alert("Clear all events and timers?", + isPresented: $showClearEventsConfirm) { + Button("Clear Events & Timers", role: .destructive) { + Task { await clearEventsAndTimers() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Removes every on-board event binding and timer — from ALL apps, including this one's recording heartbeat. Use this if the LED keeps relighting after disconnects.") + } + .alert("Restart this MetaWear?", + isPresented: $showRestartConfirm) { + Button("Restart") { + Task { await restartBoard() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Reboots the board without erasing anything — logs and macros survive. The board will disconnect.") + } + .alert(item: $maintenanceError) { err in + Alert(title: Text("Maintenance failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } .alert(item: $clearLogError) { err in Alert(title: Text("Clear logs failed"), message: Text(err.message), @@ -170,6 +225,52 @@ struct DeviceSettingsView: View { } } + // MARK: - Maintenance actions + + /// Stop playback and clear all configured patterns. If the LED comes + /// back after a disconnect, an on-board disconnect EVENT is re-arming + /// it — that's what Clear Events & Timers is for. + private func resetLED() async { + guard let device = appStore.activeDevice else { return } + do { + try await device.stopLED(clearPattern: true) + } catch { + maintenanceError = AppError(error: error) + } + } + + private func clearMacros() async { + guard let device = appStore.activeDevice else { return } + do { + try await device.eraseAllMacros() + } catch { + maintenanceError = AppError(error: error) + } + } + + private func clearEventsAndTimers() async { + guard let device = appStore.activeDevice else { return } + do { + try await device.removeAllEvents() + try await device.removeAllTimers() + } catch { + maintenanceError = AppError(error: error) + } + } + + /// Soft reboot — the board disconnects itself; the same intentional- + /// disconnect handling as Factory Reset takes the user back to the + /// scan list. + private func restartBoard() async { + guard let device = appStore.activeDevice else { return } + do { + try await device.restart() + await appStore.disconnect() + } catch { + maintenanceError = AppError(error: error) + } + } + private func deleteLocalPendingRecords(for deviceID: UUID) { let context = appStore.containers.local.mainContext let descriptor = FetchDescriptor( diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index 958c144..78dbd51 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -311,6 +311,31 @@ public actor MetaWearDevice { mwLog("[Device] factoryReset: done") } + /// Reboot the board WITHOUT erasing anything. Flash-resident data — + /// log entries, macros — survives; volatile state (timers, events, + /// sensor enables, a wedged NAND housekeeping pass) is cleared, which + /// is the point: this is the unwedge for a misbehaving board when a + /// factory reset would cost real data. The link drops intentionally; + /// reconnect after ~1 s brings the board back. + public func restart() async throws { + mwLog("[Device] restart: \(identifier)") + guard state != .disconnected else { + throw MWError.invalidState("Cannot restart a disconnected device") + } + // Intentional link drop — same suppression + reset pair (and the + // same MMS fw-1.5.0 ResetAfterGC-no-op fallback) as factoryReset. + await proto.clearDisconnectHandler() + try await send(MWDebug.ResetAfterGC()) + try? await send(MWDebug.Reset()) + await proto.stop() + state = .disconnected + activeStreamKeys.removeAll() + activeFusionConfig = nil + loggerRegistry.removeAll() + logReferenceDate = nil + mwLog("[Device] restart: done") + } + // MARK: - Streaming /// Stream a sensor signal continuously.