Enigma-Labs-Technology/pocket-client

GitHub: Enigma-Labs-Technology/pocket-client

一个零依赖的 Swift 包,通过逆向工程 Pocket 录音笔的 BLE/WiFi 协议,让 Apple 平台开发者无需厂商 App 即可直接操控设备、下载录音和管理状态。

Stars: 0 | Forks: 0

Enigma Labs B.V.

pocket-client

A Swift package that speaks your Pocket voice recorder's own BLE protocol —
discovery, authentication, inventory, download, record control, live audio.
No vendor app. No cloud. Your device, your bytes.

Swift 6.0 toolchain Platforms: iOS 17, macOS 14 Dependencies: none CI status License: Apache-2.0

An independent, reverse-engineered client by Enigma Labs B.V.

## The wire The protocol is plain ASCII, `&`-delimited, request/response, over three GATT characteristics on one BLE link. Here is a whole working session — handshake, status, inventory, download — exactly as it appears on the link: PKT01_EXAMPLE · one BLE link · service 001120a0 → APP&SK&ExampleKey000000 the handshake. Mandatory: every command ← MCU&SK&OK sent before it is silently ignored. → APP&BAT state: one round trip per field ← MCU&BAT&100 → APP&SPACE ← MCU&SPA&059632&059636 free MB & total MB → APP&LIST_DIRS which days have recordings ← MCU&DIRS&2026-01-04 ← MCU&DIRS_SUM&1 the terminator carries the count → APP&LIST&2026-01-04 what is in that day ← MCU&F&2026-01-04&20260104101500&4 date & timestamp & duration-seconds ← MCU&LIST&1 → APP&U&2026-01-04&20260104101500 select it for upload ← MCU&U&15302 announced byte count — authoritative ⇐ FF F3 48 C4 … raw MP3 in 244-byte notifications ← MCU&OFF end marker. A hint — the byte count is the truth. → app writes 001120a2 · ← device notifies 001120a3 · ⇐ bulk bytes on 001120a1 The same session, through this package: import PocketClient let transport = BLETransport() _ = try await transport.connect() // scan, connect, resolve the 3 channels let device = PocketDevice(transport: transport, sessionKey: "ExampleKey000000") try await device.connect() // APP&SK&… → MCU&SK&OK let status = try await device.status() print("battery \(status.batteryPercent)% · \(status.storage.freeMB) MB free") for date in try await device.listDates() { // APP&LIST_DIRS for recording in try await device.listRecordings(on: date) { // APP&LIST& let mp3 = try await device.download(recording) // APP&U&… → bytes → MCU&OFF try mp3.write(to: URL(fileURLWithPath: "\(recording.id.timestamp).mp3")) try await device.delete(recording.id) // APP&D&… } } await device.disconnect() Recordings are raw MP3 elementary streams — MPEG-2 Layer III, 32 kbps, 16 kHz, mono, sync header `FF F3 48 C4`. No container to strip; `ffmpeg`, `whisper`, and `AVFoundation` all take them directly. ## Contents - [Safety — read this first](#safety--read-this-first) - [How claims in this document are graded](#how-claims-in-this-document-are-graded) - [Sharp edges](#sharp-edges) - [Requirements and installation](#requirements-and-installation) - **The reference, in the order you will use it** - [1 · Discover a device](#1--discover-a-device) - [2 · Authenticate](#2--authenticate) - [3 · Read state](#3--read-state) - [4 · Read the inventory](#4--read-the-inventory) - [5 · Get the audio off it](#5--get-the-audio-off-it) - [6 · Control recording](#6--control-recording) - [7 · Listen live](#7--listen-live) - [8 · Watch for events](#8--watch-for-events) - [9 · Manage the binding](#9--manage-the-binding) - [10 · Run in the background on iOS](#10--run-in-the-background-on-ios) - [11 · Handle errors](#11--handle-errors) - [12 · Go below the session](#12--go-below-the-session) - [`pocket-cli` — the hardware harness](#pocket-cli--the-hardware-harness) - [Testing](#testing) - [What this package is not sure about](#what-this-package-is-not-sure-about) - [Protocol reference](#protocol-reference) - [Disclaimer](#disclaimer) - [License](#license) - [Colophon](#colophon) ## Safety — read this first ### Three services that must never be discovered or written | Service | What it is | Why it is untouchable | Evidence | |---|---|---|---| | `ffd0` | Believed factory / test surface | Unidentified. Zero traffic in every capture; no reference anywhere in the vendor app. Writing unknown bytes to an unknown factory service is how devices die. | `inferred` | | `e49a3001-f69a-11e8-8eb2-f2801f1b9fd1` | Wi-Fi/BLE combo-chip OTA receive | The Wi-Fi firmware contains a packetized BLE OTA receiver behind it. A partial or malformed image leaves the radio unbootable. | `inferred` | | `e49a25f8-f69a-11e8-8eb2-f2801f1b9fd1` | Combo-chip provisioning / Wi-Fi config | Same chip, same failure mode; the vendor app's strings tie it to "WiFi OTA via BLE". | `inferred` | **The MCU firmware is encrypted** (an `AOTA` container, entropy 8.0 — it cannot be analysed offline), so a failed OTA cannot be diagnosed, undone, or reflashed by anyone outside the vendor. **There is no recovery path. A bricked unit is scrap.** `BLETransport` therefore performs **explicit-UUID discovery**: it asks CoreBluetooth only for the three `001120a*` characteristics it uses (command, response, bulk data) and never enumerates the device's full GATT tree. The three services above are not merely unwritten — they are never discovered. Do not "just add" a general discovery call to see what is there. The same rule holds on the iOS state-restoration path: a restored peripheral gets the same explicit UUID lists, never a wildcard. ### Commands this package cannot express These have **no `Command` case at all**, so no code path — including the `raw` diagnostic verb, whose allowlist is a fixed lookup table of read-only probes — can emit them. That is deliberate, and it is enforced by unit tests that assert no generated wire frame ever contains these substrings. | Command | What it does | Why it is forbidden | |---|---|---| | `APP&OTA&` (also `APP&OT&`, `APP&OT&OVER`) | MCU firmware update over BLE | Encrypted image, no recovery path — a failed flash bricks the recorder. | | `APP&WOTA` | Wi-Fi chip OTA | Bricks the Wi-Fi radio; the device's fast-transfer path dies with it. | | `APP&OTA&WIFI&` | Wi-Fi chip OTA over BLE | Same, and gated on firmware versions we cannot verify ("requires MCU T22+ and WiFi V10+"). | | `APP&WIFI&CH&&` | Provisions the device onto a Wi-Fi network | Reconfigures the radio out from under the transfer protocol. **Do not confuse it with the bare `APP&WIFI`**, which is a read-only credentials query and is safe — the forbidden command is the same verb with arguments appended. `Command.wifiCredentials` is argument-less by construction for exactly this reason. | ### `pocket-cli reset --wipe-all-recordings` destroys data It requires the current session key in `POCKET_SK` *and* the explicit flag *and* a typed confirmation naming the device (`WIPE PKT01_EXAMPLE`); it refuses without the flag and touches nothing. `APP&BLE&RESET` is deliberately not expressible as a `Command`, so nothing else in this package — and nothing in the library that links it — can reach it. The frame's bytes exist at exactly one call site, in the CLI, behind that opt-in. ### Rebinding is verified, but reversible only through the vendor's app After a reset the device trusts the next key it is offered: `pocket-cli adopt` sends `APP&SK&<16 chars>` and it sticks across reconnects. This is **verified on hardware**, and `adopt` proves it rather than assuming it — it reconnects, re-authenticates, and confirms `APP&WIFI` now returns the new key's first 8 characters, which is the only readable part of a key. **Undoing a rebind is not something this package can do.** Re-adoption runs through the vendor app's "unregistered-device recovery" path, which uses a Remote-Config fallback master key and needs the *original account's* app running near the device with that flag enabled. If you no longer have that app, that account, or that flag, you may not get the device back onto the vendor's ecosystem. (The consequence is reassuring in one direction: because recovery needs the original account, a stranger's phone cannot quietly take an adopted device.) ## How claims in this document are graded Most READMEs oversell. The protocol reference ([`docs/protocol/ble-protocol.md`](docs/protocol/ble-protocol.md)) grades every claim it makes by how it was established, and this document holds itself to the same standard. Every capability below carries one of four tags: | Tag | Meaning | |---|---| | `hardware` | Observed working against a real device — replayed from vendor-app btsnoop captures and confirmed by live probing, or run end to end by hand. | | `probed` | Settled by sending the frame and reading the answer. Includes **negative** results: "the firmware says `MCU&UNKNOWN`" is a result. | | `compile-only` | Compiles, and is unit-tested wherever the logic is pure — but the code path has never executed against real hardware or a real phone. | | `inferred` | Reasoned from firmware strings, the vendor app's string tables, and the absence of traffic in every capture. Not proven. Treated as a reason for caution, never as a licence to act. | Tags describe *device and protocol* behaviour. Platform obligations (Info.plist keys, entitlements, Keychain accessibility) are Apple's documented contract, not claims about this recorder, and carry no tag. ## Sharp edges The things that cost someone a day. Each is documented where it lives; this is the index. | Edge | What actually happens | Where | |---|---|---| | `APP&STA` **starts** a recording | It is one letter from `APP&STE`, which *queries*. `Command` keeps them as distinct cases so a method can never drift onto the wrong string. | [6 · Control recording](#6--control-recording) | | No unsolicited stop event | Stopping on the device sends **nothing**. `MCU&STO` exists only as the reply to a remote `APP&STO`. To notice a stop you must poll `isRecording()`. | [8 · Watch for events](#8--watch-for-events) | | `MCU&RT` is one-shot | It fires once at connect when a recording is already running, and **never repeats**. Nothing on the wire advances elapsed time; any UI clock must run locally from that anchor. | [8 · Watch for events](#8--watch-for-events) | | Ten bytes past the end | A Wi-Fi transfer's TCP stream carries a short trailer (10 bytes observed) past the announced length. The announced count is authoritative; drain-to-EOF overshoots and fails an exact-length check. | [5 · Get the audio off it](#5--get-the-audio-off-it) | | `APP&SHUT` may never answer | On an idle device there is **no** `MCU&SHUT`. Blocking on the reply hangs the happy path. | [12 · Go below the session](#12--go-below-the-session) | | `APP&U&WIFI` is a modifier | It reroutes the upload already selected by `APP&U&&` to the TCP socket. Sent alone it has nothing to reroute. | [5 · Get the audio off it](#5--get-the-audio-off-it) | | `WiFiState` counts *down* | The progression is `0` off → `3` AP up → `2` client associated → `1` TCP connected. The numbers are not a sequence. | [5 · Get the audio off it](#5--get-the-audio-off-it) | | There is no resume | `APP&U&&` takes no byte offset, and no other command does either. A transfer that dies at 99% restarts from byte zero. | [5 · Get the audio off it](#5--get-the-audio-off-it) | | One request at a time | A second concurrent request fails fast with `PocketError.busy`. The package serialises nothing for you. | [2 · Authenticate](#2--authenticate) | | Single-use lifecycle | A `BLETransport` + `PocketDevice` pair serves exactly one connection. To reconnect, build both again. | [2 · Authenticate](#2--authenticate) | | Single-consumer streams | `events`, `liveAudio()`, and the transport's two streams each expect exactly one iterator. A second `for await` splits the elements nondeterministically. | [8 · Watch for events](#8--watch-for-events) | | Recording IDs are not all dates | Real devices produce IDs like `PH260105143000`. `RecordingID.date` then carries the raw ID verbatim rather than a fabricated `YYYY-MM-DD`. | [4 · Read the inventory](#4--read-the-inventory) | | Zero-length recordings exist | A 0-second recording announces 0 bytes and fails fast with `PocketError.emptyRecording` — deliberately with no BLE fallback. | [5 · Get the audio off it](#5--get-the-audio-off-it) | | A wrong key drops the link | `MCU&SK&ERR` is followed by the device closing the connection, not by a retry prompt. | [2 · Authenticate](#2--authenticate) | | `MCU&OFF` is a hint | Completion is byte-count driven. The response and data channels have no cross-channel ordering guarantee. | [5 · Get the audio off it](#5--get-the-audio-off-it) | ## Requirements and installation - **iOS 17+, macOS 14+.** Swift 6 toolchain (`swift-tools-version: 6.0`, sources compiled in language mode 5). - **No third-party dependencies.** The package links Apple frameworks only: `CoreBluetooth`, `Network`, `Security` (for the optional Keychain store), and `NetworkExtension` on iOS (for the programmatic hotspot join). // Package.swift dependencies: [ .package(url: "https://github.com/Enigma-Labs-Technology/pocket-client.git", from: "0.1.0"), ], targets: [ .target(name: "YourApp", dependencies: [ .product(name: "PocketClient", package: "pocket-client"), ]), ] In Xcode: **File → Add Package Dependencies**, then paste `https://github.com/Enigma-Labs-Technology/pocket-client.git`. The package vends two products: the `PocketClient` library and the `pocket-cli` executable (macOS hardware harness). App-side obligations on iOS: | Need | Key / entitlement | |---|---| | Any Bluetooth use | `NSBluetoothAlwaysUsageDescription` in Info.plist | | Programmatic Wi-Fi join for `.wifi` downloads | Hotspot Configuration entitlement | | Background relaunch for BLE events | `bluetooth-central` in `UIBackgroundModes` | ## 1 · Discover a device **Evidence:** `hardware` for the scan and both connect paths, and both are additionally covered in `swift test` — including that a targeted connect never falls back to a different Pocket; `compile-only` for the state-restoration branch — see [10 · Run in the background on iOS](#10--run-in-the-background-on-ios). `BLETransport.connect()` takes whichever `PKT01_*` advertiser answers first — fine with one Pocket, wrong with two in range. A pairing flow therefore gets two separate primitives: **enumeration without connecting**, and **connecting to a chosen identifier**. The session key cannot substitute for the choice: it is an account credential shared across a user's devices, so it says nothing about *which* physical Pocket is intended. ### `PocketScanner` — the picker feed Strictly link-layer. It reads advertisements, never connects, sends no commands, and touches no GATT table at all. let scanner = PocketScanner() // or PocketScanner(namePrefix:ageOut:) for await state in scanner.updates() { switch state { case .scanning(let nearby): for pocket in nearby { print(pocket.name, pocket.identifier, pocket.rssi ?? -1) } case .poweredOff: print("Turn on Bluetooth") case .unauthorized: print("Allow Bluetooth access in Settings") case .unsupported: print("This device has no Bluetooth LE") case .starting: break // radio state not known yet } } `PocketScanner.State` keeps the three unavailability reasons distinct on purpose: "turn Bluetooth on", "allow the app to use Bluetooth", and "this device cannot do BLE" need different words, and a UI cannot invent that distinction itself. Each row is a `NearbyPocket`: public struct NearbyPocket: Sendable, Equatable, Identifiable { public let identifier: UUID // what BLETransport.connect(to:) takes public let name: String // PKT01__ public let rssi: Int? // dBm; nil until a usable reading arrives public var id: UUID { identifier } public init(identifier: UUID, name: String, rssi: Int?) } Scan-list behaviour — all of it pinned by unit tests on the pure policy type: - Rows are keyed by peripheral identifier: a repeated advertisement refreshes the row rather than duplicating it, and shows the **latest** RSSI. CoreBluetooth's `127` "unavailable" sentinel never blanks a real reading. - Ordering is **first-seen and never reshuffles** — that *is* the damping policy. RSSI jitters by tens of dB between advertisements; sorting by it would reorder the list under the user's thumb exactly as they reach for a row. New devices append, silent devices vanish, nothing else ever moves. Signal strength is row data, not a sort key. - A device silent for **10 s** ages out; if it reappears it re-joins at the end like a new arrival. The window is injectable (`PocketScanner(namePrefix:ageOut:)`) because the default is a judgment call pending real advertisement-cadence measurements — `inferred`, not measured. - The radio scans only while at least one `updates()` consumer is alive. Ending the loop, or cancelling its task, stops the scan. A scanner left running is a battery bug, so consumption is the on-switch. - The scanner is **restartable** (unlike `BLETransport`): calling `updates()` again after the last consumer went away scans afresh, and every session starts with an empty list. - It owns its own central manager, created on the **first `updates()` call** — which is what triggers the OS Bluetooth permission prompt, so a merely-instantiated scanner is inert. Being separate from any `BLETransport`'s central means enumeration can never leave a transport in a state that breaks a later connect. - Live RSSI relies on duplicate advertisement deliveries, which iOS provides in the **foreground only** — exactly where a pairing picker lives. Do not expect the list to stay live in the background. ### `BLETransport.connect()` — first matching advertiser let transport = BLETransport() // BLETransport(namePrefix:restoreIdentifier:) let found: DiscoveredDevice = try await transport.connect() // default timeout 20 s let alsoFound = try await transport.connect(timeout: .seconds(45)) // the whole sequence print(found.name, found.identifier) `connect()` covers the whole sequence under one timeout: wait for the radio, scan, connect, and resolve the three channel characteristics. It returns a `DiscoveredDevice` (`name`, `identifier`) — a value the package constructs; it has no public initialiser. ### `BLETransport.connect(to:)` — one specific Pocket // The user tapped a row: end the updates() loop (that stops the radio), then let transport = BLETransport() let found = try await transport.connect(to: chosenIdentifier) // no scan, no fallback `connect(to:)` resolves the identifier via `retrievePeripherals` — it never scans. An identifier this system has never seen fails **immediately** with `PocketError.deviceNotFound(_:)`; one the system knows but cannot reach (device asleep, out of range, or factory-reset into a new BLE identity) fails when the timeout expires. There is deliberately **no fallback to "some other Pocket"** — that would defeat the point of choosing one. Everything past resolution is the scanned path: the same explicit-UUID discovery, the same resolved link. ### Link diagnostics Two read-only accessors, added for the hardware harness. They report state that discovery already produced and perform no BLE operation of any kind. await transport.commandCharacteristicProperties() // "write-with-response, notify", or nil await transport.notifyStateSummary() // "response: enabled, bulk: enabled" A handshake timeout plus `"no callback yet"` in the notify summary points at the CCCD enable, not at authentication. That distinction is why these exist. ## 2 · Authenticate **Evidence:** `hardware`. The session-key handshake is **mandatory**: commands sent before it are silently ignored by the device, which is a far worse failure than a rejection. A wrong key answers `MCU&SK&ERR` **and then the device drops the link** — expect a disconnect, not a retry prompt. let device = PocketDevice(transport: transport, sessionKey: "ExampleKey000000") try await device.connect() // APP&SK&… → MCU&SK&OK // … work … await device.disconnect() // idempotent `PocketDevice` is an actor and the package's front door: public actor PocketDevice { public init(transport: PocketTransport, sessionKey: String, joiner: HotspotJoining = SystemHotspotJoiner()) public nonisolated var events: AsyncStream { get } public func connect() async throws public func disconnect() async } The `joiner` performs the Wi-Fi hotspot join for `.wifi` and `.auto` downloads. The default joins programmatically on iOS; on macOS pass `ManualHotspotJoiner()` (see [5 · Get the audio off it](#5--get-the-audio-off-it)). **The session key** is, on a factory device, the first 16 characters of the account's Firebase UID. It is also the root of the Wi-Fi AP password (its first 8 characters), so treat it as a device credential. This package never stores it; the caller supplies it per instance. If you want persistence, opt in to [`SessionKeyStore`](#sessionkeystore--opt-in-keychain-persistence). ### Lifecycle is single-use A `BLETransport` + `PocketDevice` pair serves **one** connection. After `disconnect()` — or a failed `connect()`, which also tears the session down — the pair is spent: | Call | State | Result | |---|---|---| | `connect()` | fresh | performs the handshake | | `connect()` | connect in flight | `PocketError.busy("connect already in progress")` | | `connect()` | already connected | `PocketError.busy("already connected")` | | `connect()` | spent (disconnected, or a failed connect) | `PocketError.disconnected` | | `disconnect()` | any | succeeds; idempotent | To reconnect, construct a fresh `BLETransport` **and** a fresh `PocketDevice`. This is not a limitation being apologised for: `disconnect()` permanently finishes the transport's single-consumer streams, which is exactly what lets the session's consume loops terminate. Reconnection through the same instance could never work, so it is rejected loudly instead of failing subtly. ### One request at a time The session allows a single in-flight request. A second concurrent request from another task fails fast with `PocketError.busy` rather than queueing. **An app must funnel all device access through one task or queue — the package serialises nothing for you.** Downloads and live audio additionally claim an exclusive *transfer* slot for their whole duration; the second concurrent caller gets `.busy` there too. A 30-second `APP&BAT` keepalive runs for the life of the session. It never arms a waiter, so it cannot race a user request into the busy guard, and the session absorbs exactly one `MCU&BAT` echo per ping so its own link filler never shows up as an anomaly. ## 3 · Read state **Evidence:** `hardware` for every field. let status: DeviceStatus = try await device.status() status.batteryPercent // 100 ← APP&BAT status.firmware // "1.7" ← APP&FW status.macAddress // "00005e005300" ← APP&MAC status.wifiFirmware // "V9" ← APP&WF status.storage.freeMB // 59632 ← APP&SPACE status.storage.totalMB // 59636 status.slider // .conversation ← APP&REC&SECEN status.isRecording // false ← APP&STE `status()` is **seven round trips**, one per field — there is no combined status frame in the protocol. When all you need is the recording flag, use the narrow query instead: let recording = try await device.isRecording() // one APP&STE round trip `isRecording()` exists to be polled cheaply. It is the **only** way to learn that a recording stopped, because a device-button stop sends nothing unsolicited. `SliderPosition` reports the physical switch: public enum SliderPosition: Sendable, Equatable { case conversation // MCU&REC&CON — slider down case call // MCU&REC&CALL — slider up } The slider emits **no BLE events on movement** (it is a hardware mic multiplexer), so its position is only ever readable by asking. Setting the clock takes UTC and is formatted for you: try await device.setClock() // now try await device.setClock(someDate) // APP&T&YYYYMMDDHHMMSS, always UTC ## 4 · Read the inventory **Evidence:** `hardware`. Recordings live in date directories. Listing is two levels, and each level is a stream of entries terminated by a summary frame carrying the count. let dates: [String] = try await device.listDates() // APP&LIST_DIRS for date in dates { let recordings: [RecordingInfo] = try await device.listRecordings(on: date) for recording in recordings { print(recording.id.timestamp, recording.durationSeconds, recording.estimatedBytes) // duration × 4000 } } public struct RecordingID: Sendable, Hashable, Codable { public let date: String // usually YYYY-MM-DD public let timestamp: String // usually YYYYMMDDHHMMSS public init(date: String, timestamp: String) } public struct RecordingInfo: Sendable, Equatable { public let id: RecordingID public let durationSeconds: Int public var estimatedBytes: Int { durationSeconds * 4000 } // fixed 32 kbps public init(id: RecordingID, durationSeconds: Int) } Deleting is a single round trip and is verified by the file disappearing from the next listing: try await device.delete(recording.id) // APP&D&& → MCU&D ## 5 · Get the audio off it **Evidence:** `hardware` — both transports verified **byte-identical** to a reference capture of the same recording. ### Into memory let mp3: Data = try await device.download(recording) // .auto let overBLE = try await device.download(recording, via: .ble) let withProgress = try await device.download(recording, via: .auto) { fraction in print("\(Int(fraction * 100))%") } Convenient at the device's observed sizes (≤ ~7 MB). ### Streaming to disk let out = URL(fileURLWithPath: "\(recording.id.timestamp).mp3") try await device.download(recording, to: out, via: .auto) { fraction in print("\(Int(fraction * 100))%") } Same routing, same progress, same integrity rules — exact announced byte count, `FF F3` MP3 sync checked on the first bytes as they stream — without ever holding the whole recording in memory. Use it for backlog syncs and long recordings. The bytes stream into a hidden `..partial-` companion **in the destination's own directory** (same volume, so the final move is an atomic rename) and are renamed into place only after validation passes. On **any** failure — truncation, bad payload, cancellation, a full disk — nothing appears at the destination, and a pre-existing file there is never damaged by a failed re-download. ### Choosing a transport public enum TransferMode: Sendable, Equatable { case ble case wifi case auto // WiFi above ~1 MB estimated, else BLE; falls back to BLE on WiFi failure } | Mode | Measured throughput | Notes | Evidence | |---|---|---|---| | `.ble` | ~35 KB/s | Always available. 244-byte notifications on `001120a1`. | `hardware` | | `.wifi` | ~36 KB/s on a small (~200 KB) file; the vendor capture moved 1.49 MB in 2.13 s (~700 KB/s) | The advantage grows with file size — AP setup and join dominate small transfers. Requires joining the recorder's AP. | `hardware` | | `.auto` | — | Estimates size from duration at the fixed 32 kbps (`durationSeconds × 4000`), picks Wi-Fi above 1 MB (≈ 4.4 minutes of audio), else BLE. | `hardware` | `.auto` must decide **before** the size is announced, which is why it estimates rather than asks. Its fallback is narrow on purpose: a failed Wi-Fi attempt degrades to BLE, but **not** for caller cancellation (you get `CancellationError`) and **not** for `PocketError.emptyRecording` (a BLE retry would fail identically, only slower). An explicit `.wifi` never falls back — it surfaces the failure. Downloads and live audio are mutually exclusive: the device has one transfer engine, and the second concurrent caller gets `PocketError.busy`. ### The Wi-Fi handoff **Evidence:** `hardware` for the whole sequence (decoded frame by frame from an HCI snoop of one complete vendor-app sync, TCP side from a simultaneous packet capture); `compile-only` for the iOS programmatic join. Control stays on **BLE for the entire flow**; only file bytes travel over TCP. The device serves `192.168.200.1:8475` on its own access point — SSID is the BLE name, WPA2 password is the session key's first 8 characters. sequenceDiagram autonumber participant A as Your app participant B as Pocket · BLE control participant T as Pocket · TCP 8475 A->>B: APP&SHUT Note over A,B: no reply on an idle device — never block on MCU&SHUT A->>B: APP&WIFIS B-->>A: MCU&WIFIS&0 A->>B: APP&WIFI B-->>A: MCU&WIFI&PKT01_EXAMPLE&ExampleK A->>B: APP&WIFIO B-->>A: MCU&WIFIO Note over B: MCU&WIFIS&3 — AP up, ~120 ms later A->>A: join the AP — HotspotJoining loop until MCU&WIFIS&2, WPING every ~10 s A->>B: APP&WIFIS / APP&WPING end A->>T: TCP connect Note over B: MCU&WIFIS&1 — TCP client connected A->>B: APP&U&2026-01-04&20260104101500 B-->>A: MCU&U&1492892 A->>B: APP&U&WIFI B-->>A: MCU&U&WIFI T-->>A: exactly 1492892 bytes of MP3 … then 10 surplus bytes A->>B: APP&WIFIC ×2 Three things in that flow bite: 1. **`APP&WIFIO` is what starts the AP.** Querying credentials does not. A `WIFIS` poll between the credentials query and `WIFIO` still returns `0`. 2. **`APP&U&WIFI` is a modifier, not a request.** It reroutes the upload already selected by the preceding `APP&U&&` onto the socket and names no recording itself. Sent alone it has nothing to reroute. During the selection the device may briefly restart BLE bulk (~15 KB of leakage in the capture); no bulk sink is installed on this path, so those notifications are discarded and never mixed into the file. 3. **The device sends more bytes than it announced.** Ten surplus bytes were observed past the announced length on live hardware — `BA 5A 02 8F 04` repeated twice, meaning unidentified (`inferred`, one sample). The announced count is authoritative: a BLE download of the same recording is byte-identical at exactly that length. Read exactly the announced bytes and ignore the rest. A client that drains to EOF overshoots and fails an exact-length check — which is precisely how the trailer was discovered. This package surfaces the surplus as `DeviceEvent.wifiTrailerReceived(byteCount:preview:)` and never lets it reach the file. The Wi-Fi state machine is worth reading twice, because the numbers do not ascend with progress: public enum WiFiState: Int, Sendable, Equatable { case off = 0 case tcpConnected = 1 case clientJoined = 2 case accessPointUp = 3 } // progression: 0 → 3 → 2 → 1 State `1` means the TCP client is connected on `:8475`. It is reported **before** any upload command, so it does not mean "transferring". ### Who joins the access point public protocol HotspotJoining: Sendable { func join(ssid: String, passphrase: String) async throws func leave() async } | Implementation | Platform | Behaviour | Evidence | |---|---|---|---| | `SystemHotspotJoiner` (default) | iOS | Joins programmatically via `NEHotspotConfiguration` with `joinOnce = true`. Requires the Hotspot Configuration entitlement **in the consuming app**, not in this package. Treats `alreadyAssociated` as success, so a half-failed earlier attempt does not silently degrade `.auto` to BLE. On macOS it throws `PocketError.wifiJoinFailed` naming the SSID and password. | `compile-only` on iOS | | `ManualHotspotJoiner` | macOS | Prints the SSID and password, blocks on `readLine()` until the operator joins in System Settings and presses return, then runs the transfer. Afterwards it says the operator may rejoin their normal network. | `hardware` | // macOS harness let device = PocketDevice(transport: transport, sessionKey: "ExampleKey000000", joiner: ManualHotspotJoiner()) // A custom joiner, e.g. one that drives your own UI struct PromptingJoiner: HotspotJoining { func join(ssid: String, passphrase: String) async throws { /* show, await user */ } func leave() async { /* dismiss */ } } The AP is closed on **every** exit path, success or failure — a still-broadcasting AP competes with BLE for the same 2.4 GHz radio, and a failed Wi-Fi attempt is exactly when the BLE fallback needs that radio. Timing is tunable through `WiFiReadiness`, which mirrors the vendor app's measured cadence: public struct WiFiReadiness: Sendable { public var timeout: Duration // default .seconds(30) — bounds the association wait AND the TCP connect public var pollInterval: Duration // default .seconds(1) — APP&WIFIS poll rate public var pingInterval: Duration // default .seconds(10) — APP&WPING keepalive rate public init(timeout: Duration = .seconds(30), pollInterval: Duration = .seconds(1), pingInterval: Duration = .seconds(10)) } `WiFiReadiness` is a `PocketSession`-level knob; `PocketDevice.download` uses the defaults. To tune it, drop to the session (see [12 · Go below the session](#12--go-below-the-session)): let data = try await session.downloadOverWiFi( recording, joiner: ManualHotspotJoiner(), readiness: WiFiReadiness(timeout: .seconds(60), pollInterval: .seconds(1), pingInterval: .seconds(8))) The readiness wait is **lenient by design**: if the association is never observed within the window, the transfer proceeds anyway and the fact is surfaced as `DeviceEvent.wifiReadinessNotObserved` rather than raised as an error. A firmware whose state machine differs from the capture should not be blocked from a transfer that would have worked — but it should not do it silently either. ### Empty recordings do { _ = try await device.download(recording) } catch PocketError.emptyRecording { // The device announced 0 bytes. 0-second recordings exist on real hardware. } Distinct from `.notMP3` on purpose: without the guard, an empty payload passes the byte-count check (`0 == 0`) and dies in the sync-word check, telling you the channel is corrupt when in fact the recording is empty. ## 6 · Control recording **Evidence:** `hardware` for start and stop; `probed` (negative) for pause and resume. let id: RecordingID = try await device.startRecording() // APP&STA → MCU&REC&CON + MCU&STA& print(id.date, id.timestamp) // the file key for later download/delete try await device.stopRecording() // APP&STO → MCU&STO **There is no `pauseRecording()` / `resumeRecording()`.** `APP&PAU` and `APP&RESU` exist in the vendor app's string table but appear in no capture, and when probed against firmware 1.7 on 2026-07-25 — with a recording in progress — both answered `MCU&UNKNOWN`. Shipping methods that always throw would be worse than omitting them; use `stopRecording()`. The `Command.pauseRecording` and `Command.resumeRecording` cases remain **only** so `pocket-cli probe-unverified` can re-test a future firmware. A `MCU&UNKNOWN` reply surfaces as `PocketError.unknownCommand(_:)`. ## 7 · Listen live **Evidence:** `hardware` — HCI snoop of a record-start session, MP3 syncs confirmed mid-stream. While a recording is active the device streams the audio in real time on the same bulk channel that file transfers use. let stream: AsyncStream = try await device.liveAudio() var captured = Data() for await chunk in stream { captured.append(chunk) // MP3 frames, same framing as stored files } - It **throws**: the stream claims the exclusive transfer slot, so starting it during a download — or starting a download while it runs — fails with `PocketError.busy`. Before `connect()` it throws `PocketError.notAuthenticated`. - Frames only flow while a recording is active. An empty stream usually means nothing is recording, not that the tap is broken. - The stream buffers the newest **32 BLE chunks** (~2 s of audio at 32 kbps). - On overflow it drops at **BLE-chunk granularity, not MP3-frame granularity**, so a drop splices the bitstream mid-frame: a decoder resyncs at the next frame header with an audible glitch rather than merely skipping old audio. Drops are **not observable by the consumer**. - A lossless copy therefore comes from downloading the file after recording stops — not from this tap. - The slot and the bulk sink are released when the stream terminates, from either side: the consumer's task being cancelled or the stream being dropped, or the session ending (`disconnect()`, link loss). ## 8 · Watch for events **Evidence:** `hardware` for every device-originated case; `DeviceEvent.wifiReadinessNotObserved` is a session-level diagnostic. `device.events` carries unsolicited traffic and session diagnostics: Task { for await event in device.events { switch event { case .recordingStarted(let id): print("started on-device:", id.timestamp) case .recordingInProgress(let since, let elapsedSeconds): print("already recording since \(since), \(elapsedSeconds)s in") case .recordingStopped: break // see the warning below case .disconnected: print("link lost") case .unmatchedResponse(let frame): print("device said:", frame) // verbatim case .wifiTrailerReceived(let byteCount, let preview): print("\(byteCount) surplus bytes:", preview as NSData) case .wifiReadinessNotObserved: print("no MCU&WIFIS&2 within the window — transferring anyway") } } } | Case | Fires when | |---|---| | `.recordingStarted(RecordingID)` | A recording started, from the device button or a remote `APP&STA`. | | `.recordingInProgress(since:elapsedSeconds:)` | `MCU&RT` at connect time, when a recording is **already** running. | | `.recordingStopped` | An `MCU&STO` reached the unsolicited path. **Not a stop signal** — see below. | | `.disconnected` | Link loss. | | `.unmatchedResponse(String)` | The device sent a frame that satisfied no armed request matcher and is not a known unsolicited event — carried verbatim, `.unparsed` frames included. | | `.wifiTrailerReceived(byteCount:preview:)` | Surplus TCP bytes past a Wi-Fi transfer's announced length. `preview` is capped at 64 bytes. | | `.wifiReadinessNotObserved` | A Wi-Fi transfer proceeded without the device reporting a joined client. Diagnostic, not an error. | `.unmatchedResponse` is the difference between "the device answered in a shape we did not expect" and "the device never answered" — a distinction that hid the real Wi-Fi handshake during development. It is observation only: yielding it never changes matching or timeouts. The one thing the session absorbs is the `MCU&BAT` echo of its own 30-second keepalive, one per ping. Every other frame surfaces. Stream discipline: - **Single-consumer.** One shared `AsyncStream` backs the property, so a second concurrent `for await` splits events between the iterators nondeterministically. Hand it to exactly one consumer and fan out from there. - **Buffers the newest 64 events**, dropping the oldest — which can never drop the final `.disconnected`, because it is the last thing yielded. - **It always finishes**, on link loss or `disconnect()`, so an event loop terminates instead of blocking on a dead session. - On **link loss** the final `.disconnected` is guaranteed to be delivered before the stream ends. After an explicit `disconnect()` the stream may finish without it — the caller already knows — so **treat stream termination, not the event, as the end signal.** ## 9 · Manage the binding **Evidence:** `hardware` for key generation, adoption, and the reset; `compile-only` for the Keychain store on iOS (its macOS behaviour is `hardware`-observed in the negative: unsigned test runners get `errSecMissingEntitlement`). ### `PocketKey` — mint your own session key The vendor's UID is only *a* key, not a required one. The firmware validates `APP&SK&` on **length alone** — 16 characters, no charset check — so a random 16-character string is a legal key. let key = PocketKey.generate() // 16 chars, uniform over [A-Za-z0-9] PocketKey.isValid(key) // true PocketKey.isValid("ExampleKey000000") // true PocketKey.isValid("short") // false PocketKey.length // 16 PocketKey.alphabet.count // 62 The alphabet is 62 symbols — uppercase, lowercase, digits — and stops there deliberately. Every key the firmware has ever been given is a Firebase-UID prefix, which is alphanumeric; its handling of anything outside that alphabet is **unknown**, and `&` in particular is the protocol delimiter and would corrupt the frame. `isValid` exists so an operator-supplied key is vetted *before* it reaches the device: the firmware would accept a wrong-length or exotic key on length alone, or mis-parse it. Keys are drawn from `SystemRandomNumberGenerator` (a CSPRNG on Apple platforms) via `randomElement(using:)`, whose rejection sampling makes the draw uniform with **no modulo bias**. The key is the device credential and the seed of its Wi-Fi AP password; weak randomness here would weaken both. The space is 62¹⁶ ≈ 4.7 × 10²⁸. Binding a key to a device is a CLI operation, not a library one — see [`pocket-cli adopt`](#pocket-cli--the-hardware-harness). ### `SessionKeyStore` — opt-in Keychain persistence The package itself never persists the session key. An app that wants "pair once, sync forever" can use this store instead of hand-rolling Keychain code. let store = SessionKeyStore() // or SessionKeyStore(service:account:) try store.save("ExampleKey000000") // overwrites any previous key let key = try store.load() // String? — nil means "nothing stored" try store.delete() // no-op if nothing is stored; safe on sign-out Storage is one `kSecClassGenericPassword` item keyed on `service` + `account`, UTF-8 encoded, with accessibility `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: - **AfterFirstUnlock**, not *WhenUnlocked*: a background sync may need the key while the phone is locked. *WhenUnlocked* would make background syncs fail whenever the screen is off. - **ThisDeviceOnly**: the key is a pairing credential and the root of the Wi-Fi AP password, so it must never leave the device. This excludes it from device-to-device backup restores and iCloud Keychain sync. A new phone re-pairs by signing in again, which is the desired flow. `kSecUseDataProtectionKeychain` is set on every call so macOS uses the iOS-style data protection keychain (on iOS the flag is a no-op). Without it macOS writes to the legacy file-based login keychain, where `kSecAttrAccessible` is ignored and access can raise blocking UI prompts. **A consequence:** a macOS process must be code-signed with an application identifier to use the store; unsigned processes — including bare SwiftPM test runners — get `errSecMissingEntitlement` (-34018). do { if let key = try store.load() { // paired } else { // genuinely not paired — show the pairing flow } } catch let error as SessionKeyStoreError where error.isTransient { // locked since boot — retry after unlock. Do NOT clear state. } catch { // real failure; SessionKeyStoreError prints a diagnosable description } public enum SessionKeyStoreError: Error, Equatable, Sendable { case emptyKey // save("") — an empty key can never authenticate case unexpectedItemData // the item's payload is not valid UTF-8 case keychainFailure(status: OSStatus) // raw OSStatus, so the failure is diagnosable public var isTransient: Bool // true for errSecInteractionNotAllowed } extension SessionKeyStoreError: CustomStringConvertible { … } // prose via SecCopyErrorMessageString It is deliberately separate from `PocketError`: Keychain persistence is an opt-in convenience layered on the protocol client, not part of the wire protocol. Key *shape* is the caller's responsibility — any non-empty string is accepted, because a future device may not use 16 characters. ## 10 · Run in the background on iOS **Evidence:** `compile-only`. The restoration logic — adoption, the deferred execution at power-up, `connect()` claiming the restored link, the mismatch teardown, the leftover bookkeeping — is driven end to end by `swift test` against a fake radio behind `BLETransport`'s internal CoreBluetooth seam. What that cannot establish is whether iOS relaunches the app and hands back the peripherals we assume: only the `willRestoreState` delegate witness is iOS-only, and **this has never run on a phone.** `BLETransport` supports CoreBluetooth state restoration, so a sync app can be relaunched in the background for Bluetooth events on a link it held when iOS terminated it. let transport = BLETransport(restoreIdentifier: "com.example.pocket.central") if await transport.wasRestored() { // iOS handed back the previous life's link } At relaunch the transport adopts the restored peripheral — matched by name prefix, driven back through the same explicit-UUID discovery path as a scanned connect. macOS has no state restoration; the identifier is ignored there, and the `nil` default constructs exactly the plain central this type has always used. App-side obligations — none of this happens automatically: - Declare `bluetooth-central` in `UIBackgroundModes` (Info.plist). - Pass a **stable** `restoreIdentifier` — the same string on every launch. - Handle the relaunch: when iOS launches the app with `UIApplication.LaunchOptionsKey.bluetoothCentrals`, recreate a `BLETransport` with the same identifier **immediately** (CoreBluetooth hands the restored link only to a central built with that identifier), then run the normal `connect()` + `PocketDevice.connect()` sequence. - Keep the session key readable at background launch — the SK handshake runs before the user unlocks or opens anything. `SessionKeyStore`'s `afterFirstUnlock` accessibility is chosen for exactly this. Not supported, not guaranteed: - **No background scanning.** This package scans without service filters, and iOS delivers no results for such scans in the background. Restoration resumes existing or pending links; it does not discover new devices while suspended, and no wake guarantees exist beyond what iOS itself provides. - A restored peripheral whose link had already dropped by relaunch time is not resurrected — it is released, and a normal foreground `connect()` starts over. Reconnecting in the background is the app's policy call, not the transport's. ## 11 · Handle errors Everything the library throws is a `PocketError`, except `SessionKeyStoreError` from the Keychain store and `CancellationError` from a cancelled caller. public enum PocketError: Error, Equatable { case authRejected // MCU&SK&ERR — the device then drops the link case notAuthenticated // a command ran before connect() case timeout(Command) case unknownCommand(Command) // MCU&UNKNOWN case unexpectedResponse(String) case sizeMismatch(expected: Int, received: Int) case notMP3 // payload did not start FF F3 case emptyRecording // the device announced 0 bytes case deviceNotFound(UUID) // connect(to:) — identifier unknown to this system case busy(String) case wifiJoinFailed(String) case transferFailed(String) case disconnected } | Error | What it usually implicates | |---|---| | `.authRejected` | Wrong session key. On a factory device it is the first 16 characters of the account's Firebase UID; after `adopt`, the key you generated. Expect the link to drop. | | `.notAuthenticated` | A request ran before `PocketDevice.connect()`, or after the session died. | | `.timeout(.auth(""))` | The **transport's** scan/connect deadline (the empty key is the sentinel). Device asleep or out of range, Bluetooth off, or the OS denied Bluetooth access. | | `.timeout(.auth(key))` | The handshake went out and was not answered. Suspect the CCCD notify-enable or the command write type — check `notifyStateSummary()`. | | `.timeout(other)` | No reply matching what the client expects. If `.unmatchedResponse` events appeared, the device **did** answer, just not in the expected shape. | | `.unknownCommand` | This firmware does not implement the verb (`APP&PAU` / `APP&RESU` on 1.7). | | `.unexpectedResponse` | Protocol drift between client and firmware. | | `.sizeMismatch` | Under-read means the stream died early; over-read means surplus past the announced length went unclipped. | | `.notMP3` | The payload did not start with an MP3 frame header — channel framing is suspect. | | `.emptyRecording` | A 0-second recording. Nothing to download; not a transport failure. | | `.deviceNotFound` | `connect(to:)` was given an identifier this system has never seen, or has forgotten (e.g. after a factory reset). Re-scan. | | `.busy` | One connection and one transfer at a time. The string says which guard fired. | | `.wifiJoinFailed` | The hotspot join failed, or `SystemHotspotJoiner` was used on macOS. | | `.transferFailed` | Carries a diagnostic string: GATT write failure, TCP connect failure, a stall, a disk error while streaming to a file. | | `.disconnected` | The link dropped, was never established, or the instance is spent. | `pocket-cli` prints one line of triage per failure mode from this same table — `hint(for:)` in `Sources/pocket-cli/main.swift` — so a hardware session does not require reverse-engineering the client to interpret an error. ## 12 · Go below the session `PocketDevice` is a facade over `PocketSession`, which is public. Drop to it when you need fire-and-forget sends, the raw event stream, per-transport download options, or `WiFiReadiness` tuning. The CLI's `raw`, `probe-unverified`, `adopt`, and `reset` flows all live at this level. public actor PocketSession { public init(transport: PocketTransport, sessionKey: String) public nonisolated let events: AsyncStream public func start(timeout: Duration = .seconds(5)) async throws // the SK handshake public func stop() async public func request(_ command: Command, timeout: Duration = .seconds(5), expecting: @escaping @Sendable (Response) -> Bool) async throws -> Response public func requestCollecting(_ command: Command, timeout: Duration = .seconds(10), element: @escaping @Sendable (Response) -> Bool, terminator: @escaping @Sendable (Response) -> Bool) async throws -> [Response] public func send(_ command: Command) async throws // fire-and-forget } let session = PocketSession(transport: transport, sessionKey: "ExampleKey000000") try await session.start() // One matched reply let battery = try await session.request(.battery) { if case .battery = $0 { true } else { false } } // Collect entries until a terminator let entries = try await session.requestCollecting( .listDates, element: { if case .dateEntry = $0 { true } else { false } }, terminator: { if case .dateSummary = $0 { true } else { false } }) // Fire-and-forget — APP&SHUT gets NO reply on an idle device, so never await one try await session.send(.wifiShutdown) await session.stop() `PocketSession` carries the same query, inventory, record-control, live-audio, and download APIs as `PocketDevice`, plus the two per-transport download families that `PocketDevice` routes between: // BLE, explicitly let data = try await session.downloadOverBLE(recording, idleTimeout: .seconds(5)) { _ in } try await session.downloadOverBLE(recording, to: url, idleTimeout: .seconds(5)) // WiFi, explicitly — with every knob let viaWiFi = try await session.downloadOverWiFi( recording, endpointOverride: nil, // tests point this at a local server joiner: ManualHotspotJoiner(), idleTimeout: .seconds(10), readiness: WiFiReadiness()) try await session.downloadOverWiFi(recording, to: url, joiner: ManualHotspotJoiner()) ### `Command` — every frame this package can emit `Command` is the safety boundary. If a frame has no case here, no code path in the library can produce it. Command.battery.wireFormat // "APP&BAT" Command.battery.encoded // Data("APP&BAT".utf8) Command.listRecordings(date: "2026-01-04").wireFormat // "APP&LIST&2026-01-04" Command.download(RecordingID(date: "2026-01-04", timestamp: "20260104101500")).wireFormat // "APP&U&2026-01-04&20260104101500" | Case | Wire | Notes | |---|---|---| | `.auth(String)` | `APP&SK&` | Mandatory first frame | | `.battery` | `APP&BAT` | Also the 30 s keepalive | | `.firmware` | `APP&FW` | | | `.macAddress` | `APP&MAC` | | | `.wifiFirmware` | `APP&WF` | | | `.storage` | `APP&SPACE` | free & total MB | | `.setClock(Date)` | `APP&T&YYYYMMDDHHMMSS` | UTC, formatted for you | | `.sliderQuery` | `APP&REC&SECEN` | Physical slider position | | `.recordingState` | `APP&STE` | **Queries** | | `.startRecording` | `APP&STA` | **Starts** | | `.stopRecording` | `APP&STO` | | | `.pauseRecording` | `APP&PAU` | `MCU&UNKNOWN` on FW 1.7 — kept only for re-probing | | `.resumeRecording` | `APP&RESU` | Same | | `.listDates` | `APP&LIST_DIRS` | | | `.listRecordings(date:)` | `APP&LIST&` | | | `.download(RecordingID)` | `APP&U&&` | No byte offset exists | | `.delete(RecordingID)` | `APP&D&&` | | | `.wifiShutdown` | `APP&SHUT` | **No reply on an idle device** | | `.wifiStatus` | `APP&WIFIS` | | | `.wifiCredentials` | `APP&WIFI` | Argument-less **by construction** — the forbidden `APP&WIFI&CH&…` shares the prefix | | `.wifiAccessPointOn` | `APP&WIFIO` | This is what starts the AP | | `.wifiKeepalive` | `APP&WPING` | `APP&PING` is not a real command | | `.wifiDownload` | `APP&U&WIFI` | A **modifier** on a prior selection | | `.wifiClose` | `APP&WIFIC` | The vendor app sends it twice | ### `Response` — every frame this package can parse Response.parse("MCU&SK&OK") // .authOK Response.parse("MCU&SPA&059632&059636") // .storage(StorageInfo(freeMB: 59632, totalMB: 59636)) Response.parse("MCU&F&2026-01-04&20260104101500&4") // .fileEntry(RecordingInfo(id: RecordingID(date: "2026-01-04", timestamp: "20260104101500"), // durationSeconds: 4)) Response.parse("MCU&WIFIS&2") // .wifiState(.clientJoined) Response.parse("MCU&SOMETHING&NEW") // .unparsed("MCU&SOMETHING&NEW") Response.parse(Data("MCU&BAT&100".utf8)) // .battery(100) Cases: `.authOK`, `.authError`, `.battery(Int)`, `.firmware(String)`, `.macAddress(String)`, `.wifiFirmware(String)`, `.storage(StorageInfo)`, `.clockSet`, `.sliderPosition(SliderPosition)`, `.recordingState(Bool)`, `.recordingStarted(String)`, `.recordingStopped`, `.recordingInProgress(since:elapsedSeconds:)`, `.dateEntry(String)`, `.dateSummary(count:)`, `.fileEntry(RecordingInfo)`, `.listSummary(count:)`, `.transferSize(Int)`, `.transferComplete`, `.deleted`, `.wifiCredentials(ssid:passphrase:)`, `.wifiState(WiFiState)`, `.shutdownAck`, `.wifiAccessPointOn`, `.wifiClosed`, `.wifiUploadAck`, `.pong`, `.unknown`, `.unparsed(String)`. **Nothing is ever silently dropped.** A frame that matches no known shape becomes `.unparsed(String)` and, if no request expected it, surfaces on the event stream as `.unmatchedResponse` with the original text. `Response`'s parsed cases discard the raw text, which is why the frame string rides alongside it through the session. ### `RawProbe` — the read-only probe allowlist RawProbe.allowedVerbs // ["BAT", "FW", "LIST_DIRS", "MAC", "REC&SECEN", // "SHUT", "SPACE", "STE", "WF", "WIFI", "WIFIC", // "WIFIS", "WPING"] RawProbe.command(forVerb: "wifis") // Optional(Command.wifiStatus) — case-insensitive RawProbe.command(forVerb: "OTA") // nil The safety argument, in full: operator input is used **only** as a lookup key into a fixed table — no user string is ever concatenated into a frame, and the bytes sent are `Command.encoded` of the mapped case, fixed at compile time. Every value is a read-only query or an idempotent Wi-Fi teardown. `WIFIO` (AP start) is deliberately absent because it changes device state. And `Command` itself has no OTA, rebind, or provisioning case, so even a bug in the lookup could not emit one: such frames are unrepresentable in the type this API returns. There is no partial or fuzzy matching. ### `PocketTransport` — the seam The three GATT channels, abstracted so the protocol stack can be tested without CoreBluetooth. `BLETransport` is the only shipped implementation; the test suite supplies a fake. public protocol PocketTransport: Sendable { func send(_ data: Data) async throws // write the command channel (001120a2) func responseStream() -> AsyncStream // 001120a3 — single-consumer, call once func bulkStream() -> AsyncStream // 001120a1 — single-consumer, call once func disconnect() async } ## `pocket-cli` — the hardware harness macOS only — CoreBluetooth needs a real radio. Diagnostic-grade on purpose: it prints GATT properties, notify-enable state, per-command round-trip times, transfer KB/s (measured from the **first byte**, so a manual Wi-Fi join does not skew it), and every unmatched frame the device sends. swift run pocket-cli scan 10 # nearby Pockets (no key needed) POCKET_SK=ExampleKey000000 swift run pocket-cli probe POCKET_SK=ExampleKey000000 swift run pocket-cli connect 00000000-0000-0000-0000-000000000000 POCKET_SK=ExampleKey000000 swift run pocket-cli list POCKET_SK=ExampleKey000000 swift run pocket-cli download 2026-01-04 20260104101500 wifi POCKET_SK=ExampleKey000000 swift run pocket-cli record start POCKET_SK=ExampleKey000000 swift run pocket-cli listen 10 POCKET_SK=ExampleKey000000 swift run pocket-cli raw WIFIS --listen 20 POCKET_SK=ExampleKey000000 swift run pocket-cli probe-unverified swift run pocket-cli adopt POCKET_SK=ExampleKey000000 swift run pocket-cli reset --wipe-all-recordings `POCKET_SK` is the device's current 16-character session key. On a factory device that is the first 16 characters of the account's Firebase UID; after `adopt` it is the key you generated. The CLI warns (but does not refuse) on other lengths. With no subcommand it runs `probe`. | Verb | Arguments | What it does | Key | |---|---|---|---| | `help` | `--help`, `-h` | Prints usage. | — | | `scan` | `[seconds]` (default 10) | Enumerates nearby `PKT01_*` devices — name, identifier, RSSI — **without connecting**. Prints arrivals and age-outs as they happen, then the final first-seen list. | not needed | | `probe` | — | Scan, connect, print GATT diagnostics, authenticate, print full status (7 round trips, timed). | required | | `connect` | `` | Same as `probe` but connects to exactly one peripheral identifier — no scan, no fallback. Validates the UUID before touching the radio. | required | | `list` | — | Dates, then recordings per date, with duration and estimated size. Each call timed. | required | | `download` | ` [ble\|wifi]` (default `ble`) | Looks the recording up in the day's listing (for its duration), streams it to `.mp3`, then reads the file back to report true size, KB/s, and its first four bytes for the `FF F3 48 C4` eyeball check. | required | | `record` | `[start\|stop\|pause\|resume]` (default `start`) | `start` prints the new `RecordingID`; `stop` stops. `pause`/`resume` connect, then print the firmware verdict — neither frame is ever sent. | required | | `listen` | `[seconds]` (default 10) | Captures live audio to `live.mp3`, reporting time-to-first-chunk and chunk count. | required | | `raw` | ` [--listen ]` (default window 15 s) | Sends **one** allowlisted probe frame, then prints every frame the device sends back with millisecond timestamps. | required | | `probe-unverified` | — | Sends `APP&PAU` then `APP&RESU` and prints exactly what the device answers, with an honest verdict per command. | required | | `adopt` | `[key]` | Binds a self-generated (or supplied) 16-character key to an **unbound** device, then proves it persisted. | not needed | | `reset` | `--wipe-all-recordings` | **Destroys every recording** and clears the binding. | required | ### `raw` The discovery tool for handshakes the client does not model yet. It never arms a waiter after the handshake, so **every** incoming frame reaches the event stream — verbatim as `.unmatchedResponse`, or as its structured event for the known unsolicited shapes. The verb is only a lookup key into `RawProbe`'s fixed allowlist (`WIFIS WIFI SHUT WIFIC WPING BAT FW MAC WF SPACE STE REC&SECEN LIST_DIRS`); anything else is refused *before the radio is touched*, and no operator input ever becomes frame bytes. Multi-token verbs join with `&`, so `raw REC SECEN` is the same key as `raw REC&SECEN` — and a key outside the allowlist is still refused. ### `probe-unverified` Sends the two commands that exist in the vendor app's string table but appear in no capture, and prints what the device answers with a verdict: - Only a **distinctive reply naming the verb** (an exact `&`-field match such as `MCU&PAU`, never a substring) proves support. - Only **`MCU&UNKNOWN`** proves absence. - Silence, keepalive noise, and unrelated frames are **inconclusive** — "the command did not error" is not "the command worked". It deliberately does not route through any convenience method whose matcher might let a coincidental keepalive reply read as success. **Already answered for firmware 1.7** (probed 2026-07-25 against the real device, with a recording in progress): both commands returned `MCU&UNKNOWN`. The re-test signal is running this verb against real hardware after a firmware update — the hermetic unit test only pins how the client *surfaces* an `MCU&UNKNOWN` reply (as `PocketError.unknownCommand`); it replays a fake transcript, so no firmware change can ever affect it. ### `adopt` On the wire an adoption is byte-identical to an ordinary successful auth (`MCU&SK&OK`), so the only way to tell "stored in non-volatile memory" from "accepted for this session" is to disconnect, reconnect, and re-authenticate. `adopt` does exactly that, in four steps: 1. Connect (scanning, since no identifier is known yet) and authenticate with the new key. **This alone proves nothing** — an already-bound device answers identically. 2. Disconnect, reconnect **to that same identifier**, and re-authenticate. This is the step that distinguishes a stored binding from a session-only accept. Targeting the identifier means the proof cannot silently switch units with a second Pocket in range. 3. Query `APP&WIFI` and confirm the returned PSK equals `newKey[0..<8]` — the only readable part of a key, re-derived by the device from its binding. 4. Optional negative control: confirm the **old** key (from `POCKET_SK`, if set) is now rejected. Best-effort; its failure never invalidates steps 2 and 3. An operator-supplied key is validated with `PocketKey.isValid` before the radio is touched, so a bad key never reaches the device. If the device answers `MCU&SK&ERR` in step 1 it is still bound to another key: adoption only works on an **unbound** device. ### `reset` Requires the `--wipe-all-recordings` flag (checked before the radio is touched), then authenticates, prints **every recording that is about to be destroyed**, and demands a typed confirmation naming the device (`WIPE PKT01_EXAMPLE`) — a timer would let inattention count as consent. Only then does it send the raw `APP&BLE&RESET` bytes, which exist at that one call site and are deliberately not a `Command`. If no `MCU&W91&RESET` acknowledgement arrives, the CLI says plainly that **unconfirmed does not mean nothing happened**: the frame was sent, the device may well have wiped and rebooted anyway (it drops BLE as it reboots), and the ack itself is `inferred` from the vendor app's strings, never observed on hardware. ## Testing The suite is hermetic — no hardware. It replays captured transcripts against a fake transport. `DEVELOPER_DIR` must point at Xcode; the command-line-tools toolchain cannot run XCTest. DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer swift test # iOS compile check DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer \ xcodebuild -scheme PocketClient -sdk iphoneos27.0 -destination 'generic/platform=iOS' build `PocketScanner`'s CoreBluetooth shell, the thirty-line CoreBluetooth adapter at the bottom of `BLESeam.swift`, and the iOS hotspot join are the only code paths `swift test` cannot cover — which is what `pocket-cli` exists for. `BLETransport` used to be on that list. It reaches CoreBluetooth only through an internal seam (`BLECentral` / `BLEPeripheral`), so the whole state machine runs in-process against a fake radio: connect and its timeout, targeted connect and its refusal to fall back, disconnect mid-connect and mid-transfer, cancellation at every suspension point, the attempt-token discipline, and state restoration including the mismatch teardown. The seam is internal — the public surface is unchanged, and `PocketTransport` remains the abstraction third-party code implements. Everything that can be made pure has been, and is covered too: the scan-list policy (dedupe, RSSI refresh, ordering, age-out), the radio-state mapping, and the state-restoration plan selection are all plain value types with no radio in them. Three tests are load-bearing for safety rather than correctness: a compiler-forced exhaustive walk over every `Command` case asserting that no generated wire frame ever contains an OTA, rebind, or provisioning substring; and two that record every GATT discovery call the transport makes and assert the UUID lists are exactly the command service and its three channels — on a fake device carrying the recorder's real map, `ffd0` and both combo-chip services included. The wildcard `nil` CoreBluetooth accepts is not merely unused: the seam's discovery methods take a non-optional list, so it cannot be written. The suite ships **one known-failing test**, so `swift test` ends on a `━` line rather than a `✔`. That is deliberate — see the `compile-only` entry below. ## What this package is not sure about The honest ledger, in the grading of [How claims in this document are graded](#how-claims-in-this-document-are-graded). **`hardware`** — session-key authentication, device status, inventory, delete, record start/stop, and live audio, all replayed from vendor-app btsnoop captures and confirmed by live probing. BLE and Wi-Fi downloads, verified **byte-identical** to a reference capture of the same recording. Rebinding to a self-generated key (`adopt`) and the guarded wipe (`reset`), both run end to end. **`probed`** — `APP&PAU` / `APP&RESU` answer `MCU&UNKNOWN` on firmware 1.7, so there is no pause/resume API. A negative result is still a result. **`compile-only`** — CoreBluetooth state restoration, and the iOS programmatic hotspot join (`SystemHotspotJoiner`, via `NEHotspotConfiguration`). Neither has executed on a phone. Restoration's *logic* is now unit-tested end to end against a fake radio, which is why one known-failing test ships with the package: see `aCancelledLeftoverMustNotTearDownTheAdoptedLink`, a defect that was invisible until the transport became testable. **`inferred`** — four things, and each is treated as a reason for caution rather than a claim: - The **purpose** of the `ffd0` / `e49a3001` / `e49a25f8` services. The inference comes from firmware and app strings and from the fact that no capture ever touched them. It is strong enough to justify never going near them and nowhere near strong enough to justify experimenting. - The **short TCP trailer** past a Wi-Fi transfer's announced byte count. Its meaning is unidentified from a single sample. Clients must honour the announced count and ignore the surplus, as this package does. - The scanner's **10 s age-out window** — a judgment call pending real advertisement-cadence measurements. It is injectable for that reason. - Recording IDs like `PH260105143000` are *probably* phone-call recordings; the package treats any non-14-digit ID as opaque rather than betting on it. **The sample size is one.** One physical device, one firmware version. A firmware update can invalidate any `hardware` claim above. **Every identifier in this repository is a placeholder.** Session keys, device names, MACs, and UIDs in the tests, docs, and this README (`ExampleKey000000`, `PKT01_EXAMPLE`, `00:00:5E:00:53:00` — an IANA documentation MAC) are fake values of the correct shape. None is a live credential. Your device's session key is yours; this package never stores it, and you should not commit it — see `.gitignore`, which also excludes `*.mp3` and packet captures, because the protocol is plain ASCII and **a capture of any session contains the session key in cleartext**. ## Protocol reference [`docs/protocol/ble-protocol.md`](docs/protocol/ble-protocol.md) documents the link layer, the complete GATT map, authentication and rebinding, every command and response, the file format, the BLE download, the Wi-Fi Quick Transfer, live streaming, and the string-table commands recovered from the vendor APK — each graded by how it was established. The capture files and the probe harness are **not part of this repository, and never will be.** The captures are recordings of a real person's voice, and because the protocol is plain ASCII, a capture of any session contains the session key in cleartext — publishing them would hand over the device along with the documentation. Citations there describe *how* a claim was established rather than pointing at a file you can open. ## Disclaimer **This is an independent, reverse-engineered client. It is not affiliated with, authorised by, endorsed by, or supported by the manufacturer of the device it talks to.** No relationship with the vendor is claimed or implied, and no vendor trademark is asserted here. The protocol was recovered by observing a device the author owns — from packet captures, the vendor app's string tables, and live probing — not from vendor documentation. **It is offered without warranty of any kind**, express or implied, including merchantability, fitness for a particular purpose, and non-infringement. **You use it entirely at your own risk.** It can erase your recordings and it drives hardware that can be damaged; the [Safety](#safety--read-this-first) section describes the specific ways. Neither the author nor any contributor is liable for any damage, data loss, bricked hardware, voided warranty, or any other harm arising from its use. Using it may void your device's warranty or conflict with the vendor's terms of service — that is your call to make, not one this package makes for you. ## License [**Apache License 2.0**](LICENSE) — SPDX identifier `Apache-2.0`. [`LICENSE`](LICENSE) carries the full, verbatim license text. The explicit patent grant in §3 and the warranty disclaimer and limitation of liability in §§7–8 are part of what you get; those last two are the legal counterpart to the Safety section above, and they are not optional garnish on a package that drives brickable hardware. Copyright 2026 Enigma Labs B.V. The license text above that notice is the verbatim Apache-2.0 text as published, unmodified. **There is no `NOTICE` file, and none is needed.** Apache-2.0 §4(d) obliges a redistributor to pass along a `NOTICE` file only *if the work already has one* — it never requires you to create one. A `NOTICE` exists to carry attribution for third-party material you have bundled, and this package bundles none: it has no dependencies whatsoever beyond Apple's own frameworks, which are linked against rather than vendored and impose no attribution requirement of their own. Adding a `NOTICE` would bind every downstream user to reproduce it in exchange for nothing. If third-party code is ever vendored in, that is the moment to add one. For the same reason the vendor disclaimer above stays in this README and does **not** go in a `NOTICE`: §4(d) notices are *attribution* notices, and text parked there becomes something every redistributor is contractually obliged to carry forward. ## Colophon

Enigma Labs B.V.

pocket-client is developed and maintained by Enigma Labs B.V..
Written in Swift with no dependencies. Released under Apache-2.0.
The protocol was recovered frame by frame from one device, and every claim in this document says how.

标签:iOS, Swift, 云资产清单, 客户端, 物联网, 蓝牙低能耗(BLE), 逆向工程