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
49 changes: 49 additions & 0 deletions Apps/MetaWear/MetaWear/App/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ final class AppStore {
var connectionState: DeviceState = .disconnected
var lastError: AppError?

/// Live RSSI of the active connection, polled every 3 s. `nil` when
/// disconnected or before the first read completes. Boards stop
/// advertising once connected, so `scanner.advertisementRSSI` freezes —
/// this poll is the only live signal source for a connected board.
private(set) var connectedRSSI: Int?
@ObservationIgnored private var rssiPollTask: Task<Void, Never>?

var rememberedDevices: [RememberedDevice] = []
var pendingLogSessions: [LogSessionRecord] = []

Expand Down Expand Up @@ -115,6 +122,7 @@ final class AppStore {
}
connectionState = await device.state
connectingDeviceID = nil
startRSSIPolling(for: device)
await rememberDevice(device)
} catch {
// Same staleness rule on the failure path: only reset shared
Expand Down Expand Up @@ -382,6 +390,7 @@ final class AppStore {
private func handleUnexpectedDisconnect(deviceID: UUID, error: Error) {
// Ignore stale callbacks from a device we've since moved away from.
guard activeDeviceID == deviceID else { return }
stopRSSIPolling()
connectionState = .disconnected
activeDevice = nil
activeDeviceID = nil
Expand All @@ -391,12 +400,52 @@ final class AppStore {

func disconnect() async {
guard let device = activeDevice else { return }
stopRSSIPolling()
try? await device.disconnect()
connectionState = .disconnected
activeDevice = nil
activeDeviceID = nil
}

// MARK: - Connected-RSSI polling

private func startRSSIPolling(for device: MetaWearDevice) {
stopRSSIPolling()
rssiPollTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self, self.activeDeviceID == device.identifier else { return }
// Best-effort: a failed read (mid-teardown, DFU handoff)
// just leaves the last value; the poller is cancelled by
// every disconnect path.
if let rssi = try? await device.readRSSI(), !Task.isCancelled {
self.connectedRSSI = rssi
}
try? await Task.sleep(for: .seconds(3))
}
}
}

private func stopRSSIPolling() {
rssiPollTask?.cancel()
rssiPollTask = nil
connectedRSSI = nil
}

/// Rename the synced record for a board immediately. Without this,
/// the record's name only refreshes on a future reconnect after fresh
/// advertisements — the rename would look like it did nothing, and the
/// user's other devices wouldn't see the new name until then either.
func renameRememberedDevice(peripheralUUID: UUID, mac: String?, to name: String) {
let record = rememberedDevices.first {
($0.macAddress != nil && $0.macAddress == mac)
|| $0.peripheralUUID == peripheralUUID
}
guard let record, record.name != name else { return }
record.name = name
try? containers.cloud.mainContext.save()
refreshRememberedDevices()
}

func forget(_ remembered: RememberedDevice) {
let context = containers.cloud.mainContext
context.delete(remembered)
Expand Down
29 changes: 26 additions & 3 deletions Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftUI

enum DeviceConnectionStatus: Equatable {
case connected
case connected(rssi: Int?)
case connecting
case available(rssi: Int?)
case offline
Expand All @@ -12,8 +12,31 @@ struct DeviceStatusBadge: View {

var body: some View {
switch status {
case .connected:
label("Connected", systemImage: "circle.fill", tint: Palette.success)
case .connected(let rssi):
// Live link quality rides inside the badge: boards stop
// advertising once connected, so this RSSI comes from the
// AppStore's connection poll, not from advertisements. Bars
// only, tinted to match the badge — the precise dBm lives in
// Device Info's Signal row, and the extra text made the badge
// wrap to two lines in the scan row.
HStack(spacing: 6) {
Image(systemName: "circle.fill")
.font(.caption2)
.foregroundStyle(Palette.success)
Text("Connected")
.font(.caption.weight(.medium))
.foregroundStyle(Palette.success)
if let rssi {
RSSIBars(dBm: rssi, tint: Palette.success)
}
}
.lineLimit(1)
.fixedSize()
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Palette.success.opacity(0.15)))
.accessibilityElement(children: .combine)
.accessibilityLabel(rssi.map { "Connected, signal \($0) dBm" } ?? "Connected")
case .connecting:
HStack(spacing: 6) {
ProgressView()
Expand Down
5 changes: 4 additions & 1 deletion Apps/MetaWear/MetaWear/Designs/Components/RSSIBars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import SwiftUI

struct RSSIBars: View {
let dBm: Int?
/// Fill for the active bars. Defaults to the accent used in scan rows;
/// the connected badge passes its green so the bars match the badge.
var tint: Color = Palette.accent

private var bars: Int {
guard let dBm else { return 0 }
Expand All @@ -17,7 +20,7 @@ struct RSSIBars: View {
HStack(spacing: 2) {
ForEach(0..<4) { index in
Capsule()
.fill(index < bars ? Palette.accent : Color.secondary.opacity(0.3))
.fill(index < bars ? tint : Color.secondary.opacity(0.3))
.frame(width: 3, height: 6 + CGFloat(index) * 3)
}
}
Expand Down
23 changes: 23 additions & 0 deletions Apps/MetaWear/MetaWear/Designs/Components/RSSIPill.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import SwiftUI

/// Live signal-strength pill: bars + dBm in the same blue capsule styling as
/// the scan screen's "available" badge, for showing a connected board's link
/// quality (fed by `AppStore.connectedRSSI` — boards stop advertising once
/// connected, so this value comes from the connection poll).
struct RSSIPill: View {
let dBm: Int

var body: some View {
HStack(spacing: 6) {
RSSIBars(dBm: dBm)
Text("\(dBm) dBm")
.font(.caption2.monospaced())
.foregroundStyle(Palette.info)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Palette.info.opacity(0.12)))
.accessibilityElement(children: .combine)
.accessibilityLabel("Signal \(dBm) dBm")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,19 @@ struct DeviceDetailView: View {
GlassEffectContainer {
VStack(alignment: .leading, spacing: 12) {
HStack {
// Sized to share the row with the signal + state pills:
// one line always, shrinking slightly on narrow phones
// rather than wrapping.
Text(vm.macAddress ?? "—")
.font(.title3.weight(.semibold).monospaced())
.font(.callout.weight(.semibold).monospaced())
.lineLimit(1)
.minimumScaleFactor(0.7)
Spacer()
// Live link quality sits beside the state pill — the
// top row has the free width, unlike the pill row below.
if let rssi = appStore.connectedRSSI {
RSSIPill(dBm: rssi)
}
StatePill(state: appStore.connectionState,
isLogging: isDeviceLogging)
}
Expand Down
12 changes: 9 additions & 3 deletions Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ struct DeviceInfoView: View {
if let mac = viewModel.macAddress {
LabeledContent("MAC", value: mac).font(.body.monospaced())
}
LabeledContent("Battery") {
BatteryPill(battery: viewModel.battery)
}
// Plain value rows, deliberately matching the anatomy of
// the text rows above so every row in the section has
// identical height and separation. (The richer
// BatteryPill / RSSIPill treatments live in the device
// header card, where they belong.)
LabeledContent("Battery",
value: viewModel.battery.map { "\($0.charge)%" } ?? "—")
LabeledContent("Signal",
value: appStore.connectedRSSI.map { "\($0) dBm" } ?? "—")
}

// List every module the SDK knows about. Present modules
Expand Down
2 changes: 1 addition & 1 deletion Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ struct ScanView: View {
if appStore.activeDeviceID == uuid,
appStore.connectionState != .disconnected,
appStore.connectingDeviceID != uuid {
return .connected
return .connected(rssi: appStore.connectedRSSI)
}
if appStore.connectingDeviceID == uuid {
return .connecting
Expand Down
63 changes: 51 additions & 12 deletions Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MetaWearFirmware

struct DeviceSettingsView: View {
@Environment(AppStore.self) private var appStore
@Environment(\.dismiss) private var dismiss
@State private var viewModel: DeviceViewModel?
@State private var firmware: FirmwareUpdateViewModel?
@State private var draftName: String = ""
Expand All @@ -21,13 +22,24 @@ struct DeviceSettingsView: View {

var body: some View {
Form {
Section("Rename") {
Section {
TextField("Device name", text: $draftName)
.textInputAutocapitalization(.never)
Button("Save", systemImage: "checkmark") {
Task { await viewModel?.rename(to: draftName) }
Task {
// Pop back to the device page on success — the new
// name in its header IS the confirmation. On failure
// stay put; the lastError alert explains.
if await viewModel?.rename(to: draftName) == true {
dismiss()
}
}
}
.disabled(draftName.isEmpty)
.disabled(!MWSettings.isNameValid(draftName))
} header: {
Text("Rename")
} footer: {
Text("Up to \(MWSettings.maxDeviceNameLength) characters: letters, numbers, spaces, _ and -. The name updates in the app immediately and on the board's next advertisement.")
}

if let firmware {
Expand Down Expand Up @@ -83,19 +95,20 @@ struct DeviceSettingsView: View {
}
await refreshLogStats()
}
.confirmationDialog("Factory reset this MetaWear?",
isPresented: $showFactoryResetConfirm,
titleVisibility: .visible) {
// Alerts, not confirmation dialogs, for every destructive confirm:
// dialogs anchor to the triggering control (a popover on iPad/Mac)
// and land in odd places — alerts center on screen everywhere.
.alert("Factory reset this MetaWear?",
isPresented: $showFactoryResetConfirm) {
Button("Reset", role: .destructive) {
Task { await viewModel?.factoryReset() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will erase all on-device state. The board will reboot and disconnect.")
}
.confirmationDialog("Clear all log data and loggers?",
isPresented: $showClearLogsConfirm,
titleVisibility: .visible) {
.alert("Clear all log data and loggers?",
isPresented: $showClearLogsConfirm) {
Button("Clear", role: .destructive) {
Task { await clearLogs() }
}
Expand All @@ -112,6 +125,16 @@ struct DeviceSettingsView: View {
message: Text(err.message),
dismissButton: .default(Text("OK")))
}
// Rename / factory-reset errors were previously swallowed — the
// view model recorded them but nothing here presented them.
.alert(item: Binding(
get: { viewModel?.lastError },
set: { viewModel?.lastError = $0 }
)) { err in
Alert(title: Text("Operation failed"),
message: Text(err.message),
dismissButton: .default(Text("OK")))
}
}

/// Read `LOG_LENGTH` and enumerate active loggers so the section
Expand Down Expand Up @@ -161,6 +184,7 @@ struct DeviceSettingsView: View {
private struct FirmwareSection: View {
let viewModel: FirmwareUpdateViewModel
@State private var showUpdateConfirm = false
@State private var showReinstallConfirm = false

var body: some View {
Section {
Expand All @@ -175,16 +199,26 @@ private struct FirmwareSection: View {
} footer: {
footer
}
.confirmationDialog("Update firmware?",
isPresented: $showUpdateConfirm,
titleVisibility: .visible) {
// Alerts (centered) rather than anchored confirmation dialogs — see
// the note on the settings-screen confirms above.
.alert("Update firmware?",
isPresented: $showUpdateConfirm) {
Button("Update") {
Task { await viewModel.startUpdate() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Keep MetaWear open with the board nearby and powered until the update finishes. The board restarts automatically when it's done.")
}
.alert("Reinstall current firmware?",
isPresented: $showReinstallConfirm) {
Button("Reinstall", role: .destructive) {
Task { await viewModel.startUpdate(forceReinstall: true) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Reflashes the latest firmware even though the board is already up to date. On-board logs, macros, and settings are erased. Keep MetaWear open with the board nearby and powered until it finishes.")
}
}

@ViewBuilder
Expand Down Expand Up @@ -212,6 +246,11 @@ private struct FirmwareSection: View {
Button("Check Again", systemImage: "arrow.triangle.2.circlepath") {
Task { await viewModel.checkForUpdate() }
}
// Recovery path: reflash the current firmware on a board that's
// misbehaving despite being up to date.
Button("Reinstall Firmware", systemImage: "arrow.counterclockwise.circle") {
showReinstallConfirm = true
}

case .updateAvailable(let build):
LabeledContent("Latest") {
Expand Down
Loading
Loading