> ## Documentation Index
> Fetch the complete documentation index at: https://docs.plaud.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# iOS SDK

> Integrate your iOS app via the Embedded SDK for iOS.

Start with the iOS SDK's high-level interfaces (**PlaudDeviceAgent and PlaudWiFiAgent**) outlined on this page to handle:

1. **Device Connection**: Connecting to & disconnecting from Plaud devices via mobile app
2. **File Management**: Syncing files from Plaud device to mobile app
3. **Firmware Updates**: Updating Plaud device firmware

These methods should cover majority of Plaud Embedded use cases. For advanced usage, see the [advanced iOS SDK usage](/plaud-embedded/advanced-ios-sdk).

## Installation

**Requirements: iOS 14.0+, Xcode 16.0+**

The iOS SDK ships as pre-built frameworks.

| Framework                       | Action                |
| ------------------------------- | --------------------- |
| `PlaudBleSDK.framework`         | Embed & Sign          |
| `PlaudWiFiSDK.framework`        | Embed & Sign          |
| `PlaudDeviceBasicSDK.framework` | Embed & Sign          |
| `PlaudDeviceBasicSDK.bundle`    | Copy Bundle Resources |

<Steps>
  <Step title="Clone the Plaud SDK repo">
    ```bash theme={"system"}
    git clone https://github.com/Plaud-AI/plaud-sdk-public.git
    ```
  </Step>

  <Step title="Copy the Framework files and add them to your Xcode target">
    Frameworks are located at `sdk/ios/` in the [Plaud SDK repo](https://github.com/Plaud-AI/plaud-sdk-public/tree/main/sdk/ios).

    ```bash theme={"system"}
    cp -R sdk/ios your/ios-app/library
    ```
  </Step>
</Steps>

<Note>
  SDK frameworks are compiled for `arm64` (physical devices only). Simulator is not supported.
</Note>

<Tip>
  **Try the Plaud Embedded Skill** to have your coding agent help you with your iOS implementation.

  ```bash theme={"system"}
  npx skills add Plaud-AI/plaud-embedded-skills
  ```

  Visit our [GitHub repo](https://github.com/Plaud-AI/plaud-embedded-skills.git) for more details on the skill.
</Tip>

***

## Getting Started

Import `PlaudDeviceAgent` and `PlaudWiFiAgent` through `PlaudDeviceBasicSDK`. Both facades are accessed through their shared singleton:

```swift Swift icon="swift" theme={"system"}
import PlaudDeviceBasicSDK

let deviceAgent = PlaudDeviceAgent.shared
let wifiAgent = PlaudWiFiAgent.shared

// Initialize once with your user token and regional domain
deviceAgent.initSDK(
    userAccessToken: "user-token",
    customDomain: "platform-us.plaud.ai"  // domain only, no https://
)

// Assign delegates to receive device and transfer events
deviceAgent.delegate = self
wifiAgent.delegate = self
```

Use the facade methods and callbacks to drive device interactions between your mobile app and your users' Plaud devices.

***

## Methods

### Plaud Device SDK Initialization

The SDK is initialized with a **User Token** and your **regional** domain.

<Note>
  If you haven't onboarded to the Plaud Developer Platform, see our [quickstart onboarding steps](/plaud-embedded/quickstart#onboard-to-the-plaud-developer-platform).

  If you'd like more details on how to retrieve your User Token and the token exchange flow, see the [Authentication API reference](/plaud-embedded/auth-api-overview).
</Note>

```swift Swift icon="swift" theme={"system"}
import PlaudDeviceBasicSDK

PlaudDeviceAgent.shared.initSDK(
    userAccessToken: "user-token",
    customDomain: "platform-us.plaud.ai"  // domain only, no https://
)
```

<ParamField path="userAccessToken" type="string" required>
  User Access Token (JWT), used for device authentication. The handshake token is automatically parsed from the JWT `sub` field.
</ParamField>

<ParamField path="customDomain" type="string" required>
  Your regional Plaud server domain **without `https://` prefix**. All SDK network requests use this domain.

  For more information on how to find your regional server domain, see the [Authentication API docs](/plaud-embedded/auth-api-overview#find-your-region).
</ParamField>

#### Refreshing User Token

If the User Token is refreshed (e.g., after re-login), you can update it:

```swift Swift icon="swift" theme={"system"}
PlaudDeviceAgent.shared.setUserAccessToken(newToken)
```

<ParamField path="newToken" type="string" required>
  Refreshed User Token (JWT)
</ParamField>

This automatically updates the handshake token and refreshes the RSA key pair.

***

### Connecting (binding) to a Plaud Device

Binding a Plaud device generates a key pair using the **user token** and creates an ownership lock on your users' Plaud device. This makes sure their files on device is always encrypted and can only be decrypted with a valid user token by your application.

Binding a device requires an API call to Plaud's cloud services, so you can track device statuses remotely. And a local bind triggered by the Embedded SDK to verify and generate keys on Plaud device.

<Steps>
  <Step title="Bind from the cloud">
    Registers the device/owner association in the Plaud registry. Re-binding a device to the same owner is idempotent, so multiple calls will not have side effects.

    ```swift Swift icon="swift" theme={"system"}
    // POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind
    let body: [String: String] = [
        "type": snType,  // e.g. "notepro" / "notepins"
        "sn": sn
    ]

    var request = URLRequest(url: URL(string: "https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind")!)
    request.httpMethod = "POST"
    request.setValue("Bearer \(userAccessToken)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request).resume()
    ```

    <ParamField path="type" type="String" required>
      Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`).
    </ParamField>

    <ParamField path="sn" type="String" required>
      Device serial number.
    </ParamField>

    <Note>
      A `403` response means the device is already bound to **another** account.
    </Note>
  </Step>

  <Step title="Bind the device over BLE">
    Scans for the device, connects, and generates the key-pair on the device itself. The `.startScan()` will call the `bleScanResult` callback defined in the [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol), and the bind result is delivered on `bleBind(sn:status:protVersion:timezone:)`.

    ```swift Swift icon="swift" theme={"system"}
    extension DeviceManager: PlaudDeviceAgentProtocol {
        func bleScanResult(bleDevices: [BleDevice]) {
            guard let match = bleDevices.first(where: { $0.serialNumber == lastSN }) else { return }
            PlaudDeviceAgent.shared.connectBleDevice(bleDevice: match, deviceToken: userId)
        }

        func bleConnectState(state: Int) {
            switch state {
            case 1:          // connected
            case 0:          // disconnected
            case 2, -1, -2:  // connection failed
            default: break
            }
        }

        func bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int) { /* status == 0 → bound */ }

        func blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int,
                         findMyToken: Int, hasSndpKey: Int, deviceAccessToken: Int) { /* handshake complete */ }

        // ... recording / file-list / battery / storage callbacks
    }

    PlaudDeviceAgent.shared.delegate = self
    PlaudDeviceAgent.shared.startScan()
    ```

    <ParamField path="bleDevice" type="BleDevice" required>
      A scanned/connected device
    </ParamField>

    <ParamField path="deviceToken" type="String">
      If needed, a unique identifier for that device. There is also a `connectBleDevice(bleDevice:)` overload that omits it.
    </ParamField>
  </Step>
</Steps>

#### PlaudDeviceAgentProtocol Callbacks

The [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol) is a delegate protocol with a few key callbacks for the **PlaudDeviceAgent**. See the [Protocols](#protocols) section for the full list of callbacks.

| Callback                                                                              | Description                                                                |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `bleScanResult(bleDevices: [BleDevice])`                                              | Scan results updated                                                       |
| `bleConnectState(state: Int)`                                                         | `1` = connected, `0` = disconnected, `2` / `-1` / `-2` = connection failed |
| `bleBind(sn:status:protVersion:timezone:)`                                            | Device bound successfully (`status == 0`)                                  |
| `blePenState(state:privacy:keyState:uDisk:findMyToken:hasSndpKey:deviceAccessToken:)` | Secure handshake complete                                                  |

<Warning>
  Plaud devices can only be bound to one mobile application. If a user is **uninstalling your mobile app, make sure to unbind your Plaud device.**
</Warning>

***

### Depair (unbind) a device

Plaud devices **can only be bound to one application at a time**. This is done to properly secure and encrypt files stored on a Plaud device. When a user wants to unbind a Plaud device (whether to use with another Plaud Embedded App or the core Plaud app), unbind over both cloud and BLE.

Unbinding via the cloud allows you to track Connected Device statuses remotely and via API.

<Steps>
  <Step title="Unbind from the cloud">
    Removes the device/owner association in the Plaud registry (visible on the [Plaud developer portal](https://portal.plaud.ai/)). Unbinding an already-unbound device is idempotent, so multiple calls have no side effects.

    ```swift Swift icon="swift" theme={"system"}
    // POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/unbind
    let body: [String: String] = [
        "type": snType, // e.g. "notepro" / "notepins"
        "sn": sn
    ]

    var request = URLRequest(url: URL(string: "https://platform-us.plaud.ai/developer/api/open/partner/sdk/unbind")!)
    request.httpMethod = "POST"
    request.setValue("Bearer \(userAccessToken)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request).resume()
    ```

    <ParamField path="type" type="String" required>
      Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`).
    </ParamField>

    <ParamField path="sn" type="String" required>
      Device serial number.
    </ParamField>
  </Step>

  <Step title="Depair the device over BLE">
    Clears the pairing/handshake on the device itself. Requires the device to be connected. On success the SDK disconnects and clears the session. The result is delivered on the `bleDepair(_ status: Int)` delegate callback.

    ```swift Swift icon="swift" theme={"system"}
    PlaudDeviceAgent.shared.delegate = self

    extension DeviceManager: PlaudDeviceAgentProtocol {
        func bleDepair(_ status: Int) {
            if status == 0 {
                // device unpaired and disconnected
            }
        }
        // ...
    }

    PlaudDeviceAgent.shared.depair(clear: true)
    ```

    <ParamField path="clear" type="Bool" default="false">
      **Pass `true`** to clear all connections. The argument defaults to `false`, so pass it explicitly for the unbind flow.
    </ParamField>
  </Step>
</Steps>

***

### File Synchronization

The `exportAudio` method exports audio files from Plaud device to your users' phone, reporting progress through the [**AudioExportCallback**](#audioexportcallback). The `.getFileList` accesses files on your users' Plaud device, and the `.deleteFile` method will delete an audio file off of your users' Plaud device.

<Note>
  Plaud devices will record **up to 5 hours**. Recordings longer should be broken up.
</Note>

Request the file list, then export each session from the `bleFileList` callback. `exportAudio` reports back on an [**AudioExportCallback**](#audioexportcallback) you supply — the delegate below and the export callback are separate objects.

```swift Swift icon="swift" theme={"system"}
PlaudDeviceAgent.shared.getFileList(startSessionId: 0)

extension SyncManager: PlaudDeviceAgentProtocol {
    func bleFileList(bleFiles: [BleFile]) {
        guard let next = bleFiles.first else { return }
        exportHandler = ExportHandler()        // retain it — see the note below
        PlaudDeviceAgent.shared.exportAudio(
            sessionId: next.sessionId,
            outputDir: outputDir,
            format: .mp3,
            channels: 1,
            callback: exportHandler!
        )
    }
}

private final class ExportHandler: NSObject, AudioExportCallback {
    func onProgress(_ progress: Int, message: String) { }
    func onComplete(outputPath: String) {
        // decoded file is ready at outputPath
    }
    func onError(_ error: String) { }
}
```

<Note>
  `AudioExportCallback` is an `@objc` protocol, so your conformer must be an `NSObject` subclass.
</Note>

<ParamField path="sessionId" type="Int" required>
  Session ID
</ParamField>

<ParamField path="outputDir" type="String" required>
  Output Directory
</ParamField>

<ParamField path="format" type="AudioExportFormat" required>
  `.pcm` (0) | `.mp3` (1) | `.wav` (2) | `.opus` (3). We recommend `.mp3` — it plays everywhere and is accepted directly by the transcription upload API.
</ParamField>

<ParamField path="channels" type="Int" default="1">
  Number of audio channels in the exported file (`1` = mono).
</ParamField>

<ParamField path="callback" type="AudioExportCallback" required>
  See [**AudioExportCallback**](#audioexportcallback) for details.

  func onProgress(\_ progress: Int, message: String)

  func onComplete(outputPath: String)

  func onError(\_ error: String)
</ParamField>

<Note>
  For large audio files, we recommend UX considerations:

  * Progress indicators and setting expectations for long transfers (i.e. "This file is large. May take \~X minutes")
  * Supporting **background/resumable** transfer so closing the app doesn't kill the session.
  * Using WiFi Fast Transfer (see below)
</Note>

#### Delete File from Device

Once an audio file has been synced, you can delete the file from your users' Plaud device with the `.deleteFile` method.

```swift Swift icon="swift" theme={"system"}
PlaudDeviceAgent.shared.deleteFile(sessionId: sessionId)
```

<ParamField path="sessionId" type="Int" required>
  Session ID
</ParamField>

***

### WiFi Fast Transfer

An alternative to a BLE (Bluetooth Low Energy) file transfer, WiFi Fast Transfer is \~10x faster than BLE transfers.

<Tip>
  Requires the `Hotspot Configuration` entitlement in your iOS app settings.
</Tip>

`PlaudWiFiAgent` is a high-level facade that manages the WiFi Fast Transfer lifecycle for most cases.

```swift theme={"system"}
PlaudDeviceAgent.shared.setDeviceWiFi(open: true)

extension DeviceManager: PlaudDeviceAgentProtocol {
    func bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String) {
        guard status == 0 else { return }

        PlaudWiFiAgent.shared.bleDevice = BleAgent.shared.bleDevice
        PlaudWiFiAgent.shared.delegate = self

        // 2. Give the device ~3s to bring its hotspot fully up before joining 
        //    join to the SDK with timeout and retry
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            PlaudWiFiAgent.shared.connectWifi(wholeName, wifiPass, 180)
        }
    }
}

extension DeviceManager: PlaudWiFiAgentProtocol {
    func wifiHandshake(_ status: Int) {
        guard status == 0 else { return }
        PlaudWiFiAgent.shared.getFileList(Int(Date().timeIntervalSince1970), 0, false)
    }

    func wifiFileList(_ files: [BleFile]) {
        guard let next = files.first else { return }
        wifiExportHandler = WiFiExportHandler()   // retain it — the SDK does not
        PlaudWiFiAgent.shared.exportAudioViaWiFi(
            sessionId: next.sessionId,
            outputDir: outputDir,
            format: .mp3,
            channels: 1,
            callback: wifiExportHandler!
        )
    }
}
```

<ParamField path="ssid" type="String" required>
  The `wholeName` value from `bleWiFiOpen`.
</ParamField>

<ParamField path="passphrase" type="String" required>
  The `wifiPass` value from `bleWiFiOpen`.
</ParamField>

<ParamField path="overtimeSec" type="Int" default="60">
  Join timeout in seconds. The SDK re-applies the hotspot configuration and retries internally until handshake or timeout.
</ParamField>

#### Ending Wifi Fast Transfer

Close the session on every exit path — success, failure, and user cancel — or the device stays in WiFi mode (and keeps draining battery) until its own \~2 minute firmware timeout.

```swift theme={"system"}
PlaudDeviceAgent.shared.setDeviceWiFi(open: false)
PlaudDeviceAgent.shared.endWiFiTransfer()
```

<Note>
  `endWiFiTransfer()` only reaches the device while BLE is up.

  Use `PlaudDeviceAgent.shared.isWiFiTransferActive` to check whether a session is currently open.
</Note>

For lower-level control, you can use `PlaudWiFiAgent.shared.syncFile`.

<Accordion title="Low-level WiFi Fast Transfer implementation">
  ```swift theme={"system"}
  PlaudWiFiAgent.shared.syncFile(
       file.sessionId, // sessionId
      0, // start offset
      0, // end (0 = whole file)
      file.scenes // scene — match the file's own value, not the default 1
  )

  extension SyncManager: PlaudWiFiAgentProtocol {
      func wifiSyncFile(_ sessionId: Int, _ status: Int) {
          // status == 0 → accepted; non-zero → rejected (e.g. scene mismatch)
      }
      func wifiSyncFileData(_ sessionId: Int, _ offset: Int, _ count: Int, _ binData: Data) {
          // Append binData at offset to your file handle
      }
      func wifiDataComplete() {
          // All bytes received — close the file
      }
      func wifiSyncFileStop(_ status: Int) {
          // Transfer stopped/aborted (also triggered by stopSyncFile(_:_ scene:))
      }
  }
  ```

  <ParamField path="sessionId" type="Int" required>
    Session ID of the recording
  </ParamField>

  <ParamField path="start" type="Int" required>
    Start offset in bytes. Use `0` to transfer from the beginning, or a `BleFile.offset` to resume.
  </ParamField>

  <ParamField path="end" type="Int" default="0">
    End offset in bytes. `0` transfers the whole file.
  </ParamField>

  <ParamField path="scene" type="Int" default="1">
    The file's scene, from `BleFile.scenes`. Must match the file's own value or the transfer is rejected; defaults to `1`.
  </ParamField>

  <Note>
    If you encounter the error message: `wifiCommonErr(cmd: 16, status: 0)`, this is an expected behavior and indicates a successful sync.
  </Note>
</Accordion>

#### Protocol Callbacks

The WiFi Fast Transfer has two key callbacks, one on the [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol) and another on the [**AudioExportCallback**](#audioexportcallback).

| Protocol                  | Callback                                                                                  | Description                            |
| ------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------- |
| PlaudDeviceAgent Protocol | `bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String)` | Called when Plaud device opens hotspot |
| AudioExportCallback       | `onComplete`                                                                              | Called when a file has downloaded      |

<Note>
  While faster than BLE transfers, we still recommend the following UX considerations for WiFi Fast Transfer syncs:

  * Progress indicators and setting expectations for long transfers (i.e. "This file is large. May take \~X minutes")
  * Supporting **background/resumable** transfer so closing the app doesn't kill the session.
</Note>

***

### Firmware Update (OTA)

The Embedded SDK handles the entire OTA flow: version query → download → MD5 verify → CRC → BLE packet push → device restart → reconnect.

#### Check for Firmware Updates

```swift Swift icon="swift" theme={"system"}
// Check for update
PlaudDeviceAgent.shared.checkFirmwareUpdate { result in
    guard result.hasUpdate else { return }
    print("New version: \(result.latestVersion), release notes: \(result.releaseNotes)")
}
```

<ParamField path="firmwareCheck" type="(PlaudFirmwareCheckResult) -> Void" required>
  Callback function with type PlaudFirmwareCheckResult

  <Expandable title="PlaudFirmwareCheckResult">
    <ParamField path="hasUpdate" type="Bool">
      Whether a firmware update is available
    </ParamField>

    <ParamField path="currentVersion" type="String">
      The device's current firmware version
    </ParamField>

    <ParamField path="latestVersion" type="String">
      The latest available firmware version
    </ParamField>

    <ParamField path="versionCode" type="Int">
      Numeric version code
    </ParamField>

    <ParamField path="releaseNotes" type="String">
      Release notes for the update
    </ParamField>

    <ParamField path="downloadUrl" type="String">
      URL to download the firmware
    </ParamField>

    <ParamField path="md5" type="String">
      MD5 checksum for verification
    </ParamField>

    <ParamField path="isForce" type="Bool">
      Whether the update is mandatory
    </ParamField>
  </Expandable>
</ParamField>

#### Run the Firmware Update

`startFirmwareUpdate` performs the whole flow in one call — download, install, and device restart — reporting each stage through the `progress` closure.

```swift Swift icon="swift" theme={"system"}
// One-call firmware update
PlaudDeviceAgent.shared.startFirmwareUpdate(
    progress: { phase, percentage in
        // phase: .downloading / .installing / .restarting / .complete
        // percentage: 0.0 ~ 1.0
    },
    completion: { result in
        if result.success {
            print("Updated to \(result.version)")
        } else {
            print("Failed: \(result.errorMessage ?? "")")
        }
    }
)
```

<ParamField path="progress" type="(PlaudFirmwarePhase, Float) -> Void" required>
  Callback that reports firmware update progress.

  <Expandable title="PlaudFirmwarePhase">
    | Case          | Description                               |
    | ------------- | ----------------------------------------- |
    | `downloading` | Firmware binary is being downloaded       |
    | `installing`  | Firmware is being installed on the device |
    | `restarting`  | Device is restarting after installation   |
    | `complete`    | Update finished successfully              |
  </Expandable>

  * `phase`: Current phase of the update (`PlaudFirmwarePhase`)
  * `percentage`: Progress from 0.0 to 1.0
</ParamField>

<ParamField path="completion" type="(PlaudFirmwareUpdateResult) -> Void" required>
  Callback when the update completes.

  <Expandable title="PlaudFirmwareUpdateResult">
    <ParamField path="success" type="Bool">
      Whether the update succeeded
    </ParamField>

    <ParamField path="version" type="String">
      The firmware version after update
    </ParamField>

    <ParamField path="errorMessage" type="String">
      Error description if update failed
    </ParamField>
  </Expandable>
</ParamField>

If you already have the firmware file downloaded, use `pushFirmwareFile()` instead:

#### Push Firmware Update

```swift Swift icon="swift" theme={"system"}
PlaudDeviceAgent.shared.pushFirmwareFile(
    filePath: localPath,
    toVersion: "V1.2.8",
    progress: { phase, pct in },
    completion: { result in }
)
```

<ParamField path="filePath" type="String" required>
  Full path to the locally downloaded firmware file
</ParamField>

<ParamField path="toVersion" type="String" required>
  Target firmware version to update to (e.g., "V1.2.8")
</ParamField>

<ParamField path="progress" type="(PlaudFirmwarePhase, Float) -> Void" required>
  Callback that reports firmware update progress.

  * `phase`: Current phase (`downloading` / `installing` / `restarting` / `complete`)
  * `percentage`: Progress from 0.0 to 1.0
</ParamField>

<ParamField path="completion" type="(PlaudFirmwareUpdateResult) -> Void" required>
  Callback when the update completes with `success`, `version`, and `errorMessage` fields.
</ParamField>

***

## Protocols

The Embedded SDK is delegate-driven. You implement protocols and assign yourself as the delegate to receive device events, transfer progress, and results. These three protocols should cover most use cases.

<Note>
  Most callbacks are delivered on the SDK's internal dispatch queues — **not** the main thread. Marshal to the main queue before touching UIKit or published state.
</Note>

### PlaudDeviceAgentProtocol

The primary delegate for `PlaudDeviceAgent`. Assign it once and it drives the entire BLE lifecycle — scan, connect, bind, device state, recording, and file sync.

```swift Swift icon="swift" theme={"system"}
PlaudDeviceAgent.shared.delegate = self

extension DeviceManager: PlaudDeviceAgentProtocol {
    // REQUIRED — overall device state after handshake
    func blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int,
                     findMyToken: Int, hasSndpKey: Int, deviceAccessToken: Int) {
        // ...
    }
}
```

<Note>
  `blePenState` is the **only required** member. Every other callback is `@objc optional` — implement only the ones you need.
</Note>

The most commonly used callbacks, grouped by concern:

| Group        | Callback                                                                                  | Description                                                                |
| ------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Connection   | `blePenState(...)`                                                                        | **Required.** Device state reported after the handshake completes          |
| Connection   | `bleConnectState(state: Int)`                                                             | `1` = connected, `0` = disconnected, `2` / `-1` / `-2` = connection failed |
| Connection   | `bleScanResult(bleDevices: [BleDevice])`                                                  | Scan results updated                                                       |
| Connection   | `bleScanOverTime()`                                                                       | Scan timed out with no results                                             |
| Connection   | `bleBind(sn:status:protVersion:timezone:)`                                                | Binding status updated                                                     |
| Device state | `bleStorage(total: Int, free: Int, duration: Int)`                                        | Storage usage on the device                                                |
| Device state | `bleChargingState(isCharging: Bool, level: Int)`                                          | Charging / battery level changed                                           |
| Recording    | `bleRecordStart(sessionId:start:status:scene:startTime:reason:)`                          | Recording started                                                          |
| Recording    | `bleRecordStop(sessionId:reason:fileExist:fileSize:)`                                     | Recording stopped                                                          |
| Recording    | `blePcmData(sessionId:millsec:pcmData:isMusic:)`                                          | Live PCM chunks for waveform / metering                                    |
| File sync    | `bleFileList(bleFiles: [BleFile])`                                                        | Result of `getFileList(...)`                                               |
| File sync    | `bleData(sessionId: Int, start: Int, data: Data)`                                         | A chunk of file data during sync                                           |
| File sync    | `bleDataComplete()`                                                                       | File transfer finished                                                     |
| File sync    | `bleDeleteFile(sessionId: Int, status: Int)`                                              | Result of `deleteFile(...)`                                                |
| WiFi         | `bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String)` | Device opened its hotspot — hand off to `PlaudWiFiAgent`                   |
| OTA          | `bleFotaResult(uid: Int, status: Int, errmsg: String?)`                                   | Firmware push result                                                       |

<Note>
  The SDK also exposes a lower-level `BleAgentProtocol` on `BleAgent` with 97 members, **96 of them required**. Prefer `PlaudDeviceAgentProtocol` — the facade handles the handshake, decryption, and format conversion for you, and lets you implement only the callbacks you care about.
</Note>

### AudioExportCallback

Reports progress, completion, and errors for `.exportAudio` (BLE) and `.exportAudioViaWiFi` (WiFi).

```swift Swift icon="swift" theme={"system"}
extension SyncManager: AudioExportCallback {
    func onProgress(_ progress: Int, message: String) {
        // progress: 0–100
    }
    func onComplete(outputPath: String) {
        // decoded file is ready at outputPath
    }
    func onError(_ error: String) {
        // ...
    }
}
```

<ParamField path="onProgress(_ progress: Int, message: String)" type="func" required>
  Export progress, `0`–`100`, with a human-readable status message.
</ParamField>

<ParamField path="onComplete(outputPath: String)" type="func" required>
  Called when decoding finishes; `outputPath` is the full path to the written file.
</ParamField>

<ParamField path="onError(_ error: String)" type="func" required>
  Called if export fails, with a description of the error.
</ParamField>

### PlaudWiFiAgentProtocol

The delegate for `PlaudWiFiAgent`, used during WiFi Fast Transfer. Assign it before calling `connectWifi(...)`. The handshake must complete (`wifiHandshake` with status `0`) before listing or transferring files.

```swift Swift icon="swift" theme={"system"}
PlaudWiFiAgent.shared.delegate = self

extension DeviceManager: PlaudWiFiAgentProtocol {
    func wifiHandshake(_ status: Int) {
        guard status == 0 else { return }   // 0 = handshake done, ready to transfer
        // begin transfer
    }
}
```

All members are `@objc optional`.

| Callback                                                                                  | Description                                                                                                                                                                                                  |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `wifiHandshake(_ status: Int)`                                                            | `0` = handshake complete, ready to list/transfer                                                                                                                                                             |
| `wifiConnectionStatus(_ ssid: String, _ connected: Bool)`                                 | WiFi connection state changed                                                                                                                                                                                |
| `wifiFileList(_ files: [BleFile])`                                                        | Result of `getFileList(_ uid:_ sessionId:_ single:)` over WiFi — all three arguments are unlabeled; pass a unique `uid` (e.g. a timestamp), `0` for `sessionId` to list everything, and `false` for `single` |
| `wifiSyncFileData(_ sessionId:_ offset:_ count:_ binData:)`                               | A chunk of file data                                                                                                                                                                                         |
| `wifiDataComplete()`                                                                      | Single-file transfer finished                                                                                                                                                                                |
| `wifiDownloadAllProgress(_ totalFiles:_ currentFileIndex:_ currentFile:_ totalProgress:)` | Progress while downloading all files                                                                                                                                                                         |
| `wifiDownloadAllCompleted(_ completedFiles: Int, _ failedFiles: Int)`                     | Batch download finished                                                                                                                                                                                      |
| `wifiCommonErr(_ cmd: Int, _ status: Int)`                                                | A command failed                                                                                                                                                                                             |
| `wifiClose(_ status: Int)`                                                                | WiFi transfer session closed                                                                                                                                                                                 |

<Note>
  WiFi Fast Transfer requires the `Hotspot Configuration` entitlement in your app. See [WiFi Fast Transfer](#wifi-fast-transfer) for the full connect-and-transfer flow.
</Note>
