From 405634fb770ed27e9a5c326ea5e458eca205c7cb Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 14 Jun 2026 12:36:59 -0700 Subject: [PATCH 1/2] add some docs / comments / updated README --- .swiftlint.yml | 34 +++++++ Apps/MetaWear/MetaWear/App/AppStore.swift | 6 ++ Apps/MetaWear/MetaWear/App/RingBuffer.swift | 6 +- .../MetaWear/App/SensorSelection.swift | 6 ++ .../MetaWear/ViewModels/Channel.swift | 5 + .../ViewModels/ControlsViewModel.swift | 4 + .../MetaWear/ViewModels/DeviceViewModel.swift | 5 + .../ViewModels/DownloadViewModel.swift | 5 + .../ViewModels/LogSessionViewModel.swift | 6 ++ .../ViewModels/ScannerViewModel.swift | 4 + .../ViewModels/SessionHistoryViewModel.swift | 4 + .../ViewModels/StreamSessionViewModel.swift | 6 ++ README.md | 93 ++++++++++++++----- Sources/MetaWear/MetaWearDevice.swift | 66 +++++++++++++ Sources/MetaWear/MetaWearScanner.swift | 9 ++ Sources/MetaWear/Models/MWBoardState.swift | 3 + Sources/MetaWear/Modules/MWAmbientLight.swift | 24 +++-- .../MetaWear/Modules/MWDataProcessor.swift | 27 ++++-- Sources/MetaWear/Transport/BLETransport.swift | 4 + .../MetaWearFirmware/MWFirmwareServer.swift | 14 +++ .../MWSessionSnapshot.swift | 8 ++ .../DeviceConnectionTests.swift | 1 - .../MetaWearTests/MWModuleCommandTests.swift | 1 - Tests/MetaWearTests/MetaWearDeviceTests.swift | 1 - 24 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 .swiftlint.yml diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b1f8d73 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,34 @@ +excluded: + - .claude + - .build + - .swiftpm + - .git + - DerivedData + - "**/xcuserdata" + - "**/SourcePackages" + +# Start with low-noise rules that are useful immediately on the current codebase. +# Broaden this list gradually as existing style drift is cleaned up. +only_rules: + - blanket_disable_command + - closing_brace + - comment_spacing + - compiler_protocol_init + - duplicate_imports + - duplicated_key_in_dictionary_literal + - dynamic_inline + - empty_enum_arguments + - empty_parameters + - empty_parentheses_with_trailing_closure + - invalid_swiftlint_command + - leading_whitespace + - no_space_in_method_call + - orphaned_doc_comment + - protocol_property_accessors_order + - redundant_discardable_let + - redundant_objc_attribute + - redundant_void_return + - trailing_newline + - trailing_semicolon + +reporter: xcode diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index 4d7a1f2..3397aa2 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -4,6 +4,12 @@ import SwiftData import MetaWear import MetaWearPersistence +/// Main app coordinator shared by the SwiftUI scene. +/// +/// Owns long-lived services (`MetaWearScanner`, SwiftData containers, and the +/// persistence actor), tracks the active device, and handles cross-feature +/// flows that do not belong to a single screen: remembered devices, orphan log +/// recovery, connect/disconnect, and unexpected-disconnect handling. @Observable @MainActor final class AppStore { diff --git a/Apps/MetaWear/MetaWear/App/RingBuffer.swift b/Apps/MetaWear/MetaWear/App/RingBuffer.swift index 2f94b49..061a71c 100644 --- a/Apps/MetaWear/MetaWear/App/RingBuffer.swift +++ b/Apps/MetaWear/MetaWear/App/RingBuffer.swift @@ -1,5 +1,8 @@ import Foundation +// `nonisolated`: this is a pure value type with no actor affinity — the app +// target's default MainActor isolation would otherwise make every member +// main-actor-only, which blocks nonisolated tests and any future off-main use. /// Fixed-capacity FIFO buffer backed by a circular array. /// /// `append` is O(1) — once full, the oldest element is overwritten in place @@ -7,9 +10,6 @@ import Foundation /// which shifts every remaining element and made each append O(capacity); /// at 100 Hz × several channels that put hundreds of thousands of element /// moves per second on the main actor.) -// `nonisolated`: this is a pure value type with no actor affinity — the app -// target's default MainActor isolation would otherwise make every member -// main-actor-only, which blocks nonisolated tests and any future off-main use. nonisolated struct RingBuffer { private var storage: [Element] = [] /// Index of the oldest element once the buffer has wrapped. diff --git a/Apps/MetaWear/MetaWear/App/SensorSelection.swift b/Apps/MetaWear/MetaWear/App/SensorSelection.swift index 7c291f3..5c33414 100644 --- a/Apps/MetaWear/MetaWear/App/SensorSelection.swift +++ b/Apps/MetaWear/MetaWear/App/SensorSelection.swift @@ -2,6 +2,8 @@ import Foundation import SwiftUI import MetaWear +/// Stable identity for each sensor family or fusion output the demo app can +/// stream, log, display, and persist. enum SensorKey: Hashable, Sendable { case accelerometer case gyroscope @@ -75,6 +77,8 @@ enum SensorKey: Hashable, Sendable { } } +/// Sensor-fusion payloads exposed in the app. Each case maps to a concrete +/// `MWLoggable` / `MWStreamable` type in the SDK. enum SensorFusionOutput: String, CaseIterable, Sendable, Identifiable { case quaternion case eulerAngles @@ -99,6 +103,7 @@ enum SensorFusionOutput: String, CaseIterable, Sendable, Identifiable { } } +/// Firmware sensor-fusion algorithm modes surfaced in the UI. enum SensorFusionMode: String, CaseIterable, Sendable, Identifiable { case ndof = "NDoF" case imuPlus = "IMU Plus" @@ -108,6 +113,7 @@ enum SensorFusionMode: String, CaseIterable, Sendable, Identifiable { var id: String { rawValue } } +/// User-selected sensor configuration for one live or logged channel. struct SensorSelection: Identifiable, Sendable, Hashable { let id: SensorKey var hz: Double diff --git a/Apps/MetaWear/MetaWear/ViewModels/Channel.swift b/Apps/MetaWear/MetaWear/ViewModels/Channel.swift index 6fc3c3e..d78050b 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/Channel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/Channel.swift @@ -1,6 +1,11 @@ import Foundation import Observation +/// One charted sensor stream in a live session. +/// +/// Separates high-frequency sample ingestion from observed UI state: raw +/// samples accumulate in an ignored ring buffer, then the stream view model +/// snapshots into observed fields at a fixed UI cadence. @Observable @MainActor final class Channel: Identifiable { diff --git a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift index 2049abb..1d8f961 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift @@ -2,6 +2,10 @@ import Foundation import Observation import MetaWear +/// Presentation model for quick device controls. +/// +/// Keeps UI-editable command settings for LED and haptic actions, plus the +/// latest one-shot read values for temperature, pressure, and ambient light. @Observable @MainActor final class ControlsViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift index a6b5ea6..046a50f 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift @@ -2,6 +2,11 @@ import Foundation import Observation import MetaWear +/// Presentation model for the connected-device overview. +/// +/// Mirrors immutable device facts, live battery state, and module discovery +/// into main-actor state that SwiftUI can render without repeatedly crossing +/// the `MetaWearDevice` actor boundary. @Observable @MainActor final class DeviceViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 55268aa..0903928 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -4,6 +4,11 @@ import SwiftData import MetaWear import MetaWearPersistence +/// Coordinates download and persistence for stopped on-device logging sessions. +/// +/// Drains the board once into raw entries, decodes those entries per pending +/// `LogSessionRecord`, saves typed snapshots through `MWPersistenceStore`, and +/// updates download progress for the UI. @Observable @MainActor final class DownloadViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift index 6e99c1c..0ec7576 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift @@ -3,6 +3,12 @@ import Observation import SwiftData import MetaWear +/// Drives on-device logging setup and teardown. +/// +/// Converts selected UI sensors into SDK loggers, persists pending +/// `LogSessionRecord` rows so interrupted sessions can be recovered, and +/// keeps elapsed-time state for the logging UI while the board records to +/// flash independently of the app. @Observable @MainActor final class LogSessionViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift index e037a40..139f3d4 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift @@ -2,6 +2,10 @@ import Foundation import Observation import MetaWear +/// Presentation model for scan results. +/// +/// Wraps `MetaWearScanner` with sorted device lists and optional RSSI polling +/// for connected devices while keeping CoreBluetooth state owned by the SDK. @Observable @MainActor final class ScannerViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift index 74e7465..ac490af 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift @@ -2,6 +2,10 @@ import Foundation import Observation import MetaWearPersistence +/// Minimal view model for the session-history screen. +/// +/// Listing is driven directly by SwiftData queries in the view; this type owns +/// actions that need async persistence calls and error presentation. @Observable @MainActor final class SessionHistoryViewModel { diff --git a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift index 2599919..7be6785 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift @@ -3,6 +3,12 @@ import Observation import MetaWear import MetaWearPersistence +/// Drives the live-streaming screen. +/// +/// Owns the active sensor streams, per-sensor chart channels, pause/resume +/// lifecycle, and optional archive-to-history flow for the in-memory ring +/// buffers. Each stream consumes `MetaWearDevice.startStream(_:)` on a +/// background task and throttles UI updates through `Channel.displayBuffer`. @Observable @MainActor final class StreamSessionViewModel { diff --git a/README.md b/README.md index a083d7e..c0820dd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,24 @@ # MetaWear Swift SDK -A clean-room Swift 6 implementation of the MetaWear protocol. +A Swift 6 implementation of the [MetaWear protocol](https://github.com/mbientlab/MetaWear-API). + +This repository contains both the reusable Swift Package products and the MetaWear app: + +- Use `MetaWear` when you need the scanner, device actor, BLE transport, protocol layer, and sensor/module APIs. +- Add `MetaWearPersistence` when you want SwiftData-backed session storage and CSV export helpers. +- Add `MetaWearFirmware` only when your app needs over-the-air DFU firmware updates. +- Open `Apps/MetaWear/MetaWearApp.xcodeproj` for the full MetaWear app. + +## Table of Contents + +| Start here | Use it for | +|------------|------------| +| [Quick Start](#quick-start) | Adding the package, scanning, connecting, streaming, and sending simple commands | +| [Architecture](#architecture) | Understanding the scanner/device/protocol/transport layering | +| [Supported sensors and modules](#supported-sensors-and-modules) | Finding the Swift type and configuration shape for each MetaWear module | +| [Logging](#logging) | On-device flash logging, typed downloads, anonymous logger recovery, and CSV export | +| [Persistence (SwiftData)](#persistence-swiftdata) | Saving downloaded sessions and reconstructing typed samples | +| [Testing](#testing) | Running unit tests, hardware integration tests, and the macOS CLI demo | ## Requirements @@ -17,10 +35,10 @@ A clean-room Swift 6 implementation of the MetaWear protocol. This SDK targets MbientLab MetaMotion boards only. Anything else (legacy MetaWear R / RG / RPro / C / CPro, MetaMotion C, MetaEnvironment, MetaTracker, MetaHealth) is treated as `MWModel.unknown` — connect / read may still work but module-level behaviour is not validated. -| Board | Model number (`0x2A24`) | Hardware revisions (`0x2A27`) | Acc / Gyro chip | -|----------------------|:-----------------------:|:---------------------------------------------|:----------------| -| MetaMotion R / RL | `5` | `r0.1`, `r0.2`, `r0.3`, `r0.4`, `r0.5` | BMI160 | -| MetaMotion S | `8` | `r0.1` | BMI270 | +| Board | Model number (`0x2A24`) | Hardware revisions (`0x2A27`) | +|----------------------|:-----------------------:|:---------------------------------------------| +| MetaMotion R / RL | `5` | `r0.1`, `r0.2`, `r0.3`, `r0.4`, `r0.5` | +| MetaMotion S | `8` | `r0.1` | `MWModel` decodes the Model Number characteristic into a typed case; `MWDeviceInformation.isHardwareRevisionSupported` cross-checks the revision against the table above: @@ -39,15 +57,51 @@ The validator is forgiving about formatting — `"r0.4"`, `"R0.4"`, and `"0.4"` ## What ships in this repository -| Product / target | Kind | What it is | -|-----------------------------------|-------------|--------------------------------------------------------------------------------------------------| -| `MetaWear` | library | Core SDK — scanner, device actor, BLE transport, protocol layer, every sensor module | -| `MetaWearPersistence` | library | SwiftData session storage, depends on `MetaWear` | -| `MetaWearFirmware` | library | Over-the-air firmware update, wraps NordicDFU 4.16.0 (`@preconcurrency`) in an actor-isolated `DFUSession` | -| `MetaWearDemo` | executable | macOS CLI that exercises the core SDK against a real board | -| `Apps/MetaWear/MetaWearApp.xcodeproj` | iOS app | SwiftUI demo — scan / connect / live-stream / log / download / export, SwiftData-backed sessions | +| Product / target | Kind | What it is | +|---------------------------------------|-------------|--------------------------------------------------------------------------------------------------| +| `MetaWear` | library | Core SDK — scanner, device actor, BLE transport, protocol layer, every sensor module | +| `MetaWearPersistence` | library | SwiftData session storage, depends on `MetaWear` | +| `MetaWearFirmware` | library | Over-the-air firmware update, wraps NordicDFU 4.16.0 (`@preconcurrency`) in an actor-isolated `DFUSession` | +| `MetaWearDemo` | executable | macOS CLI that exercises the core SDK against a real board | +| `Apps/MetaWear/MetaWearApp.xcodeproj` | iOS app | MetaWear App — scan / connect / live-stream / log / download / export, SwiftData-backed sessions | + +The four SwiftPM products are intentionally split so an app can take just `MetaWear` without pulling NordicDFU or SwiftData. +Hardware integration tests (`MetaWearHardwareTests`) live in `Tests/IntegrationTests/MetaWearTestHost.xcodeproj` — see [Hardware integration tests](#hardware-integration-tests). + +--- + +## Development workflow + +### Repository layout + +| Path | Purpose | +|------|---------| +| `Sources/MetaWear` | Core SDK: public API, protocol layer, module implementations, CoreBluetooth transport, and mocks | +| `Sources/MetaWearPersistence` | SwiftData session storage and sample reconstruction | +| `Sources/MetaWearFirmware` | Firmware catalog lookup, downloads, and Nordic DFU orchestration | +| `Sources/MetaWearDemo` | macOS CLI smoke test against real hardware | +| `Apps/MetaWear/MetaWear` | SwiftUI iOS demo app, organized by app state, view models, features, and design components | +| `Tests/MetaWearTests` | Unit tests for protocol, parsing, modules, device state, and mock transport behavior | +| `Tests/MetaWearPersistenceTests` | In-memory SwiftData persistence tests | +| `Tests/MetaWearFirmwareTests` | Firmware catalog, version, and server behavior tests | +| `Tests/MetaWearHardwareTests` | Real-device integration tests hosted by `Tests/IntegrationTests/MetaWearTestHost.xcodeproj` | + +### Common commands + +```bash +# Run all SwiftPM tests that do not require Bluetooth hardware. +swift test + +# Run one suite while developing a module. +swift test --filter MWLEDTests + +# Exercise the SDK against a nearby board from the command line. +swift run MetaWearDemo +``` -The four SwiftPM products are intentionally split so an app can take just `MetaWear` without pulling NordicDFU or SwiftData. Hardware integration tests (`MetaWearHardwareTests`) live in `Tests/IntegrationTests/MetaWearTestHost.xcodeproj` — see [Hardware integration tests](#hardware-integration-tests). +### Documentation standards + +Public SDK types should have `///` documentation because they surface in Xcode Quick Help and generated symbol docs. Implementation comments should explain protocol quirks, firmware ordering constraints, or concurrency reasoning; avoid comments that merely restate a line of Swift. Markdown docs should prefer small, runnable snippets and should call out whether hardware is required. --- @@ -191,6 +245,7 @@ Enforces the device state machine at compile time; invalid transitions throw `MW ``` Key methods: + ```swift public func connect() async throws public func disconnect() async throws @@ -1352,18 +1407,6 @@ On first run macOS will prompt for Bluetooth permission — grant it once and it --- -## Protocol reference - -Full byte-level specification — every module opcode, register, command byte layout, config bit-field, response format, and scale factor: - -``` -/Users/kasso/Documents/MetaWear-API/docs/protocol-reference.md -``` - -Run `mkdocs serve` in that directory to browse as a formatted site. - ---- - ## What's not yet implemented ### Known small SDK gaps (deferred from legacy parity) diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index abff298..59c9b6d 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -3,12 +3,24 @@ import Foundation // MARK: - Device state +/// High-level lifecycle state for a single MetaWear connection. +/// +/// The actor uses this as a guardrail: operations that would conflict on the +/// board, such as streaming while downloading logs, fail early with +/// `MWError.invalidState` instead of racing firmware state. public enum DeviceState: Equatable, Sendable { + /// No active BLE connection is held by this actor. case disconnected + /// A connection attempt is in progress and initialization has not finished. case connecting + /// Connected, initialized, and ready for reads, commands, streams, or logs. case idle + /// One or more live BLE streams are active. case streaming + /// The logging module is recording one or more configured signals to flash. case logging + /// A flash-log readout is in progress. The associated value is the last + /// reported completion fraction (`0.0...1.0`). case downloading(progress: Double) } @@ -20,9 +32,14 @@ public actor MetaWearDevice { // MARK: - Public state + /// Current actor state. Observe this before starting mutually-exclusive + /// operations such as logging, streaming, and log download. public private(set) var state: DeviceState = .disconnected + /// Device Information Service values read during `connect()`. public private(set) var deviceInfo: MWDeviceInformation? + /// Module-discovery table keyed by `MWModule`. Populated during `connect()`. public private(set) var modules: [MWModule: MWModuleInfo] = [:] + /// CoreBluetooth peripheral UUID used to reconnect to this board. public nonisolated let identifier: UUID /// Called when BLE drops unexpectedly (not via `disconnect()`). @@ -67,6 +84,11 @@ public actor MetaWearDevice { // MARK: - Init + /// Create a device actor around a BLE transport. + /// + /// Production code usually receives instances from `MetaWearScanner`. + /// Tests can inject `MockBLETransport`; the demo app can inject + /// `DemoBLETransport`. public init(identifier: UUID, transport: any BLETransport) { self.identifier = identifier self.transport = transport @@ -75,6 +97,11 @@ public actor MetaWearDevice { // MARK: - Connection + /// Connect, start the protocol layer, read device information, and discover modules. + /// + /// A successful call leaves the actor in `.idle` with `deviceInfo` and + /// `modules` populated. If initialization fails, the actor returns to + /// `.disconnected` and rethrows the underlying error. public func connect() async throws { mwLog("[Device] connect: \(identifier)") guard case .disconnected = state else { @@ -101,6 +128,12 @@ public actor MetaWearDevice { try await connect() } + /// Disconnect intentionally and tear down local protocol subscriptions. + /// + /// This suppresses `onUnexpectedDisconnect`, stops any active streams on + /// the Swift side, and clears transient streaming/fusion state. Logger + /// registrations are intentionally preserved so callers can reconnect and + /// still download logs that remain on the board. public func disconnect() async throws { mwLog("[Device] disconnect: \(identifier)") // Unhook the callback first so the disconnect we're about to trigger @@ -364,6 +397,12 @@ public actor MetaWearDevice { for cmd in sensor.disableCommands where !cmd.isEmpty { try await proto.write(cmd) } } + /// Stop one active stream and unsubscribe its BLE notifications. + /// + /// If other streams remain active the actor stays in `.streaming`; once + /// the last stream stops it returns to `.idle`. Sensor-fusion streams share + /// a single firmware engine, so stopping one fusion output only disables + /// that output bit while other fusion outputs are still running. public func stopStreaming(_ sensor: S) async throws { mwLog("[Device] stopStreaming: \(sensor.module.name)") guard case .streaming = state else { return } @@ -401,6 +440,12 @@ public actor MetaWearDevice { // MARK: - Logging + /// Start logging a streamable sensor to on-device flash. + /// + /// Multiple distinct sensors may be registered in one logging session by + /// calling this while the actor is already `.logging`. Each signal is + /// split into firmware-sized chunks and registered under `loggerKey` for + /// later typed download via `downloadLogs(_:)`. public func startLogging(_ loggable: L) async throws { mwLog("[Device] startLogging: \(loggable.module.name)") // Allow stacking — multiple distinct sensors can be added to one @@ -484,6 +529,11 @@ public actor MetaWearDevice { } } + /// Stop sensor sampling for a loggable signal and stop the logging module. + /// + /// Logger IDs stay in the local registry so `downloadLogs(_:)` can decode + /// entries afterward. Call `clearLog()` after a successful download to + /// remove the board-side log entries and subscriptions. public func stopLogging(_ loggable: L) async throws { mwLog("[Device] stopLogging: \(loggable.module.name)") // No guard on `state == .logging`: the first call in a multi-sensor @@ -1522,11 +1572,16 @@ public actor MetaWearDevice { // MARK: - One-shot reads + /// Read battery voltage and charge percentage from the Settings module. public func readBattery() async throws -> BatteryState { let packet = try await proto.read(.settings, 0x0c) return try MWPacketParser.parseBatteryState(packet) } + /// Read one temperature channel in degrees Celsius. + /// + /// Channel availability depends on the board and is reported by the + /// temperature module's discovery response. public func readTemperature(channel: UInt8 = 0) async throws -> Float { let packet = try await proto.read(.temperature, 0x01, channel) return try MWPacketParser.parseTemperature(packet) @@ -1575,6 +1630,7 @@ public actor MetaWearDevice { // MARK: - Commands + /// Send a single fire-and-forget command to the board. public func send(_ command: any MWCommand) async throws { try await proto.write(command.commandData) } @@ -1680,13 +1736,19 @@ public actor MetaWearDevice { // MARK: - Module info convenience + /// Discovery information for one module, or `nil` if the module was not + /// present in the last initialization table. public func moduleInfo(for module: MWModule) -> MWModuleInfo? { modules[module] } + /// True when the board reported a gyroscope module during discovery. public var hasGyroscope: Bool { modules[.gyro]?.isPresent ?? false } + /// True when the board reported a magnetometer module during discovery. public var hasMagnetometer: Bool { modules[.magnetometer]?.isPresent ?? false } + /// True when the board reported a barometer module during discovery. public var hasBarometer: Bool { modules[.barometer]?.isPresent ?? false } + /// True when the board reported the sensor-fusion module during discovery. public var hasSensorFusion: Bool { modules[.sensorFusion]?.isPresent ?? false } // MARK: - Initialization @@ -1828,9 +1890,13 @@ extension MetaWearDevice { /// A single 8-byte on-device flash entry returned during log download. public struct RawLogEntry: Sendable { + /// Logger ID that produced this 4-byte chunk. public let id: UInt8 + /// Reset epoch marker carried in the upper three bits of byte 0. public let resetUID: UInt8 + /// 24-bit device tick count since reset. public let tick: UInt32 + /// Raw 32-bit little-endian payload captured from flash. public let rawData: UInt32 /// Elapsed milliseconds since the MetaWear last reset (tick × ms/tick). public let epochMs: Double diff --git a/Sources/MetaWear/MetaWearScanner.swift b/Sources/MetaWear/MetaWearScanner.swift index cbe48fa..0a1094a 100644 --- a/Sources/MetaWear/MetaWearScanner.swift +++ b/Sources/MetaWear/MetaWearScanner.swift @@ -73,6 +73,7 @@ public final class MetaWearScanner { // MARK: - Init + /// Create a scanner backed by a shared CoreBluetooth central manager. public init() { self.centralManager = MWCentralManager() let manager = centralManager @@ -91,6 +92,13 @@ public final class MetaWearScanner { // MARK: - Scanning + /// Start observing BLE advertisements. + /// + /// Scans without a service filter because MetaWear devices do not + /// consistently include the custom service UUID in advertisements. + /// Discovered devices whose local name starts with `"MetaWear"` are + /// exposed through `discoveredDevices`; all observed names/RSSI values + /// are still cached for remembered-device and rename workflows. public func startScan() { guard !isScanning else { return } mwLog("[Scanner] startScan") @@ -162,6 +170,7 @@ public final class MetaWearScanner { return device } + /// Stop the active scan task and leave already-discovered devices cached. public func stopScan() { mwLog("[Scanner] stopScan") scanTask?.cancel() diff --git a/Sources/MetaWear/Models/MWBoardState.swift b/Sources/MetaWear/Models/MWBoardState.swift index e89d670..8052a9e 100644 --- a/Sources/MetaWear/Models/MWBoardState.swift +++ b/Sources/MetaWear/Models/MWBoardState.swift @@ -18,8 +18,11 @@ public struct MWBoardState: Sendable, Equatable, Codable { /// Bump on any breaking layout change. public static let currentSchemaVersion: Int = 1 + /// Schema version of this serialized payload. public let schemaVersion: Int + /// Device identity captured during the connection handshake. public let deviceInformation: MWDeviceInformation + /// Module discovery table captured during initialization. public let modules: [MWModuleInfo] /// Wall-clock reference: the `Date` at which the board's logging tick was 0. /// `nil` when the logging module is not present or the tick reference wasn't read. diff --git a/Sources/MetaWear/Modules/MWAmbientLight.swift b/Sources/MetaWear/Modules/MWAmbientLight.swift index 0632a30..b7e8827 100644 --- a/Sources/MetaWear/Modules/MWAmbientLight.swift +++ b/Sources/MetaWear/Modules/MWAmbientLight.swift @@ -39,12 +39,18 @@ public struct MWAmbientLight: MWStreamable { /// (dense 0-5); the hardware register skips 4-5 and encodes 48X / 96X /// as 6 / 7, which `configByte0` handles. public enum Gain: UInt8, Sendable, CaseIterable { - case x1 = 0 ///< [1, 64 k] lux (default) - case x2 = 1 ///< [0.5, 32 k] lux - case x4 = 2 ///< [0.25, 16 k] lux - case x8 = 3 ///< [0.125, 8 k] lux - case x48 = 4 ///< [0.02, 1.3 k] lux - case x96 = 5 ///< [0.01, 600] lux + /// [1, 64 k] lux (default). + case x1 = 0 + /// [0.5, 32 k] lux. + case x2 = 1 + /// [0.25, 16 k] lux. + case x4 = 2 + /// [0.125, 8 k] lux. + case x8 = 3 + /// [0.02, 1.3 k] lux. + case x48 = 4 + /// [0.01, 600] lux. + case x96 = 5 /// Encoded value for the LTR329 `als_gain` bitfield (3 bits, bits 2-4 of byte 0). var registerValue: UInt8 { @@ -62,7 +68,8 @@ public struct MWAmbientLight: MWStreamable { /// Raw values match C++ `MblMwAlsLtr329IntegrationTime` (enum order is not /// sorted by milliseconds on purpose — 100 ms is the default at index 0). public enum IntegrationTime: UInt8, Sendable, CaseIterable { - case ms100 = 0 ///< Default + /// Default 100 ms integration time. + case ms100 = 0 case ms50 = 1 case ms200 = 2 case ms400 = 3 @@ -84,7 +91,8 @@ public struct MWAmbientLight: MWStreamable { case ms50 = 0 case ms100 = 1 case ms200 = 2 - case ms500 = 3 ///< Default + /// Default 500 ms measurement rate. + case ms500 = 3 case ms1000 = 4 case ms2000 = 5 diff --git a/Sources/MetaWear/Modules/MWDataProcessor.swift b/Sources/MetaWear/Modules/MWDataProcessor.swift index 19c3b8b..5c06c3d 100644 --- a/Sources/MetaWear/Modules/MWDataProcessor.swift +++ b/Sources/MetaWear/Modules/MWDataProcessor.swift @@ -757,9 +757,12 @@ public extension MWDataProcessor { struct Delta: MWDataProcessorConfig, Sendable { /// What value the delta stage emits when the threshold is crossed. public enum Mode: UInt8, Sendable { - case absolute = 0 ///< Output the raw input on each threshold crossing. - case differential = 1 ///< Output the raw delta from the last reference. - case binary = 2 ///< Output +1 when above, -1 when below. + /// Output the raw input on each threshold crossing. + case absolute = 0 + /// Output the raw delta from the last reference. + case differential = 1 + /// Output +1 when above, -1 when below. + case binary = 2 } /// Magnitude expressed in already-scaled board units (LSBs). public let magnitude: Int32 @@ -814,10 +817,14 @@ public extension MWDataProcessor { struct Pulse: MWDataProcessorConfig, Sendable { /// What value the pulse-detector emits per detected pulse. public enum Output: UInt8, Sendable { - case width = 0 ///< Pulse duration (samples above threshold). - case area = 1 ///< Integrated area above threshold. - case peak = 2 ///< Peak value during the pulse. - case onDetect = 3 ///< Boolean pulse detected indicator (UInt32). + /// Pulse duration (samples above threshold). + case width = 0 + /// Integrated area above threshold. + case area = 1 + /// Peak value during the pulse. + case peak = 2 + /// Boolean pulse detected indicator (UInt32). + case onDetect = 3 } /// Threshold in already-scaled board units (LSBs). public let threshold: Int32 @@ -926,8 +933,10 @@ public extension MWDataProcessor { struct Accounter: MWDataProcessorConfig, Sendable { /// What value the accounter prepends to each sample. public enum Mode: UInt8, Sendable { - case count = 0 ///< Prepend a monotonically-increasing packet counter. - case time = 1 ///< Prepend a compact epoch offset (ms since boot). + /// Prepend a monotonically-increasing packet counter. + case count = 0 + /// Prepend a compact epoch offset (ms since boot). + case time = 1 } public let mode: Mode /// Pinned to 4 bytes as firmware / logger expect. diff --git a/Sources/MetaWear/Transport/BLETransport.swift b/Sources/MetaWear/Transport/BLETransport.swift index 9654925..8e63246 100644 --- a/Sources/MetaWear/Transport/BLETransport.swift +++ b/Sources/MetaWear/Transport/BLETransport.swift @@ -3,9 +3,13 @@ import Foundation // MARK: - Scan result +/// One CoreBluetooth advertisement normalized for the SDK scanner. public struct ScanResult: Sendable { + /// Stable CoreBluetooth identifier for the advertising peripheral. public let identifier: UUID + /// Advertised local name, if present in the packet. public let name: String? + /// Received signal strength in dBm for this advertisement. public let rssi: Int /// Raw bytes from `CBAdvertisementDataManufacturerDataKey`, if present. /// For iBeacon advertisements this is the full manufacturer-specific payload diff --git a/Sources/MetaWearFirmware/MWFirmwareServer.swift b/Sources/MetaWearFirmware/MWFirmwareServer.swift index 5b64ef5..5b2cb9e 100644 --- a/Sources/MetaWearFirmware/MWFirmwareServer.swift +++ b/Sources/MetaWearFirmware/MWFirmwareServer.swift @@ -20,19 +20,25 @@ import Foundation /// firmware artifact (avoids loading the whole image /// into memory and lets us hand a `URL` to NordicDFU). public protocol MWFirmwareFetcher: Sendable { + /// Fetch raw bytes from a URL. Used for the firmware catalog JSON. func data(from url: URL) async throws -> (Data, HTTPURLResponse) + /// Download a URL to a temporary file. Used for firmware artifacts so + /// NordicDFU can consume a local file URL. func download(from url: URL) async throws -> (URL, HTTPURLResponse) } // MARK: - URLSession-backed fetcher (production default) +/// Production `MWFirmwareFetcher` backed by `URLSession`. public struct URLSessionFetcher: MWFirmwareFetcher { private let session: URLSession + /// - Parameter session: Session used for catalog requests and firmware downloads. public init(session: URLSession = .shared) { self.session = session } + /// Fetch catalog data and validate that the response is HTTP. public func data(from url: URL) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { @@ -43,6 +49,8 @@ public struct URLSessionFetcher: MWFirmwareFetcher { return (data, http) } + /// Download firmware bytes to a temporary file and validate that the + /// response is HTTP. public func download(from url: URL) async throws -> (URL, HTTPURLResponse) { let (tempURL, response) = try await session.download(from: url) guard let http = response as? HTTPURLResponse else { @@ -82,6 +90,12 @@ public struct MWFirmwareServer: Sendable { private let catalogURL: URL private let sdkVersion: String + /// Create a firmware server client. + /// + /// - Parameters: + /// - fetcher: Network implementation. Inject a test double for unit tests. + /// - catalogURL: URL for MbientLab's `info2.json` catalog. + /// - sdkVersion: SDK version used to filter catalog entries by minimum iOS SDK. public init( fetcher: MWFirmwareFetcher = URLSessionFetcher(), catalogURL: URL = MWFirmwareServer.defaultCatalogURL, diff --git a/Sources/MetaWearPersistence/MWSessionSnapshot.swift b/Sources/MetaWearPersistence/MWSessionSnapshot.swift index bc310db..6f3e931 100644 --- a/Sources/MetaWearPersistence/MWSessionSnapshot.swift +++ b/Sources/MetaWearPersistence/MWSessionSnapshot.swift @@ -9,13 +9,21 @@ public struct MWSessionSnapshot: Sendable, Identifiable { /// Stable UUID assigned when the session was created. /// Pass this back to `MWPersistenceStore` methods that operate on a specific session. public let id: UUID + /// CoreBluetooth peripheral UUID of the board that produced the samples. public let deviceID: UUID + /// Discriminator matching `MWPersistable.persistenceKind`. public let sensorKind: String + /// Wall-clock timestamp of the first sample in the session. public let startDate: Date + /// Wall-clock timestamp of the last sample in the session. public let endDate: Date + /// Number of persisted samples in the session. public let sampleCount: Int + /// Device serial copied from the Device Information Service at capture time. public let deviceSerial: String + /// Device model number copied from the Device Information Service. public let deviceModel: String + /// Firmware revision copied from the Device Information Service. public let deviceFirmware: String /// User-facing sensor + settings string, e.g. "Gyroscope · ±2000 dps · /// 25 Hz". Nil for older records persisted before the field existed — diff --git a/Tests/MetaWearHardwareTests/DeviceConnectionTests.swift b/Tests/MetaWearHardwareTests/DeviceConnectionTests.swift index 8c8a292..d60e7ad 100644 --- a/Tests/MetaWearHardwareTests/DeviceConnectionTests.swift +++ b/Tests/MetaWearHardwareTests/DeviceConnectionTests.swift @@ -79,4 +79,3 @@ struct DeviceConnectionTests { print("\n ✓ disconnect_whenConnected_returnsToDisconnected\n") } } - diff --git a/Tests/MetaWearTests/MWModuleCommandTests.swift b/Tests/MetaWearTests/MWModuleCommandTests.swift index 95c8027..166b413 100644 --- a/Tests/MetaWearTests/MWModuleCommandTests.swift +++ b/Tests/MetaWearTests/MWModuleCommandTests.swift @@ -2150,4 +2150,3 @@ struct AccBoschTapTests { #expect(t == .init(type: .double, isPositive: false)) } } - diff --git a/Tests/MetaWearTests/MetaWearDeviceTests.swift b/Tests/MetaWearTests/MetaWearDeviceTests.swift index e6d65c9..77f227c 100644 --- a/Tests/MetaWearTests/MetaWearDeviceTests.swift +++ b/Tests/MetaWearTests/MetaWearDeviceTests.swift @@ -589,4 +589,3 @@ struct DeviceCommandSequenceTests { #expect(new[1] == Data([0x11, 0x07] + Array(payload.dropFirst(13)))) } } - From 8940ede01a23abc83f69c6001387fdce416db34f Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 14 Jun 2026 12:42:35 -0700 Subject: [PATCH 2/2] add swiftlint workflow --- .github/workflows/swiftlint.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/swiftlint.yml diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 0000000..f6f3adc --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,25 @@ +name: SwiftLint + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + swiftlint: + runs-on: macos-15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: swiftlint --strict