# YHY02 BLE 胸牌 Android 摄取 (P2) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在 PropertyLab Android App 里实现 YHY02 BLE 胸牌的完整摄取链路——BLE 实时 AAC 流落盘、历史文件经 BLE 同步回补、持久化上传队列把录音推到 petav3 的 `POST /api/agent-recordings`。

**Architecture:** 全部新代码放在 `app/src/main/java/tech/propertylab/agent/badge2/`（`badge/` 是 FW920 旧协议，**不改它**，两套设备并存期间各走各的）。协议编解码是纯 Kotlin 对象（可 JVM 单测）；BLE 交互走 `BadgeTransport` 接口（真机 `AndroidBadgeTransport` / 测试 `FakeBadgeTransport`），复用 FW920 `BadgeManager` 已验证的骨架模式（Mutex 命令串行化、CCCD 订阅、state flow）；上传复用现有 `ApiClient`（JWT）+ 仿 `CallUploadLogStore` 的 JSON 持久化队列。服务端契约见 petav3 `docs/plans/2026-08-18-yhy02-ble-badge-server-ingest.md`（已实现，零改动）。

**Tech Stack:** Kotlin、android.bluetooth（BluetoothGatt）、Ktor client + OkHttp、kotlinx.serialization、JUnit4（仿现有 `ConnectorProbeVerdictTest`）、Apache Commons Net（仅 Task 10 FTP）。

**仓库:** `~/propertylab-android`（branch `main`；从 main 切 `feat/yhy02-ble-ingest`）。

**参考文档（实现时对照，不重新做硬件验证）:**
- `/Users/dadadineiyou/Desktop/YHY BLE/Recording Card Wi-Fi+BLE – Protocol Specification.pdf` ← **唯一正确的协议文档**
- `/Users/dadadineiyou/Desktop/YHY BLE/WINDOWS-VALIDATION-2026-08-18.md` ← Windows 实测结论
- `/Users/dadadineiyou/Desktop/YHY BLE/HANDOFF-YHY02-Windows.md` ← 设备事实表
- 反编译产物 `~/Desktop/YHY BLE/ble-probe/unbundle/Yuehanie.Tools.dll`（ILSpy 打开可看 `GetCharacteristicUuidForCmd` 完整命令码表）

---

## 已实测确定的事实（实现依据，不要重新验证）

**设备**：`YHY02_5BA8E1`，SN = MAC = `C8478C5BA8E1`（广播 mfg 数据 company id `0x05F0`，7 字节 = `01` + MAC 6B）。设备名 `YHY02_{MAC 后 6 位大写}`。固件 1.2.4，eMMC 29.12 GB，协商 MTU **512**。

**GATT**（一个 service `0000FEE0-0000-1000-8000-00805F9B34FB`）：

| 特征 (UUID `0000FEE{n}-0000-1000-8000-00805F9B34FB`) | 实机属性 | 用途 |
|---|---|---|
| FEE1 | `write, read`（**没有 notify**） | 0x0020 设备信息：写命令 → **主动 read 回响应** |
| FEE2 | `write, read`（文档未记载，厂商 DLL 在用） | 0x0023 WiFi STA 配置状态（P2 不用） |
| FEE3 | `write, read, notify` | 0x0022 录音控制；录音状态主动通知 |
| FEE4 | `notify` | 0x0024 实时 AAC 流（**只在录音时推**） |
| FEE5 | `write, read, notify` | 0x0030/0032/0033 本地控制；**0x0040/0x0041 的 ACK 也走这里** |
| FEE6 | `write, read, notify` | 0x0040/0x0041/0x0043 历史文件同步 |

**帧头 9 字节**（大端）：

```
[0]    Flag        0x01=设备→APP   0x11=APP→设备
[1]    Encoding    0=AAC 1=LC3 2=Opus（实机恒为 0x00）
[2:4]  CMD         UInt16 BE
[4:6]  DataLength  UInt16 BE
[6:8]  Sequence    UInt16 BE（设备帧实测 0x0015→0x019e 连续递增，394 帧零丢包）
[8]    CRC8        APP 发送填 0x00；设备帧此字节实测是杂值（厂商工具自己都
                   "CRC mismatch (non-fatal)"）—— 收到后【忽略，永不据此拒帧】
[9:]   payload     DataLength 字节
```

> ⚠️ 规范 §2.2.1 把 [6][7][8] 写成 Sequence/CRC8/Padding 三个 1 字节字段，与实机不符——设备帧 [6:8] 是 **2 字节序号**。实现一律按上表（9 字节头 + [6:8] 序号 BE16），APP 发送帧写 `[seq][0x00][0x00]`。序号缺口检测用设备帧的 [6:8]。

**两条数据路径长度规则不同**：
- **FEE4 实时**：帧总长 = `9 + DataLength`，ADTS AAC 从偏移 9 开始（首 4 字节 `FF F1 60 40`）。
- **FEE6 历史**：帧总长 = `9 + 1 + DataLength`，偏移 9 是 **status 字节**，数据从偏移 10 开始；status **不计入** DataLength。

**Status 码**：`0=失败 1=成功 3=开始传输 4=传输中 5=传输完成 6=文件不存在`。

**命令（已实测部分）**：

| 命令 | 特征 | 载荷（APP→设备） | 响应 |
|---|---|---|---|
| 0x0020 | FEE1 | `unix_ts BE32 + tz_byte`（tz 用 `0x01`=UTC+8，实测有效；顺带校准设备时钟） | read FEE1：12B payload = `fw3 + battery%1 + totalKB BE32 + freeKB BE32` |
| 0x0040 | FEE6 | `0x01` | FEE5 notify：`payload[0]=status`，`payload[1:]=sync.json 分片`（status 3 开始 / 4 片段 / 5 或 1 完成 / 0 失败） |
| 0x0041 | FEE6 | `0x01 + folder(8B, ljust 空格) + file(10B, ljust 空格)`，如 `01 + b"20260818".ljust(8) + b"131505.aac".ljust(10)` | FEE6 notify 流：status 3 → 4（`payload[1:]=数据`）→ 5 完成；6 文件不存在 |
| 0x0043 | FEE6 | 与 0x0041 同构的 folder+file（删除前先按 PDF §2.3.5.3 核对载荷头字节） | FEE5/FEE6 status |
| 0x0022 | FEE3 | 起停录音（载荷按 PDF §2.3 录音控制节核对；**写侧未实测，UI 先只读状态，不发起起停**） | FEE3 notify 状态 |

**FEE3 状态 notify**：payload `[0x01 const][recording 0/1][elapsed BE16]`（实测 `01 00 0000` 未录 / `01 01 0009` 录音中 9 秒）。

**sync.json**：`{"total":{"record":[{folder,file,fsize,time,sync}]}}`；`sync`: `0=已删 / 1=已同步 / 2=仅设备`。**固件 JSON 的 fsize 值可能缺引号**，解析前先修复：`("fsize"\s*:\s*)(\d+)"` → `$1"$2"`。`time` = 秒。

**音频**：AAC-LC / ADTS / 16 kHz / 单声道 / ≈32.4 kbps。实时流实测 347 帧零丢包零 ADTS 长度错误；**历史文件路径需要 ADTS 重同步**（厂商 DLL 有整套 `FindAdtsFrames / FixAdtsFrameLength / IsValidFrameStart`——BLE 历史传输可能丢包或掺入杂散字节，收到后按 `FF Ex/Fx` 同步字重新找帧边界）。

**传输**：`connectGatt(..., TRANSPORT_LE)` 强制 LE；命令写入用 **Write With Response**（`WRITE_TYPE_DEFAULT`，Windows 实测 Without Response 会漏握手）；吞吐：历史下载 ≈12.4 kB/s，实时流 1× 实时速率。

**文件命名**：设备侧 `folder=yyyyMMdd / file=HHmmss.aac`（设备时钟；我们每次连接都发 0x0020 校准它）。**服务端幂等键 = `{sn}:{folder}/{file}`**——因此同一段录音无论从实时流还是历史通道来，名字必须一致：实时落盘也按设备时钟命名 `{yyyyMMdd}/{HHmmss}.aac`。

**已知坑（写进实现，不写进猜测）**：
1. FEE1 没有 notify，0x0020 必须「写 → 读 FEE1」。
2. FEE2 存在但文档未提。
3. 0x0033（录音时同步存 EMMC）布尔**反转**：`00`=开；GUI 状态硬编码不可信。
4. 0x0035 固件 1.2.4 不响应，不要依赖。
5. WinRT/系统报告的 MTU 不可信（Windows 报 23 但帧完整）；Android 侧 `requestMtu(512)` 后以实际 `onMtuChanged` 为准，但**不要据此分帧**（单帧 ≤512B，直接写）。
6. 服务端 `POST /api/agent-recordings` 对任意 2xx 表示「这文件已妥」，App 即可删除本地/设备副本（stored 201 / duplicate 200 / filtered 201 都是 2xx）。
7. 上传响应 shape：`{success, data:{id, uuid, status, duplicate, reason?}}`，Kotlin 反序列化要求 `data.id` 存在。

---

## 任务拆解

| Task | 内容 | 依赖 |
|---|---|---|
| 1 | `Yhy02Protocol` 编解码器 + 真实字节向量单测 | — |
| 2 | `BadgeTransport` 接口 + `AndroidBadgeTransport` + `FakeBadgeTransport` | 1 |
| 3 | `Yhy02Manager`：扫描/连接/MTU/订阅/命令串行化 + 0x0020 设备信息 | 2 |
| 4 | 实时流捕获（FEE4→落盘，FEE3 状态驱动窗口，序号缺口检测） | 3 |
| 5 | 历史同步（0x0040 sync.json → 0x0041 拉取 → ADTS 重同步落盘） | 3 |
| 6 | 上传队列 `RecordingUploadStore` + `ApiClient.postAgentRecording` + 上传 worker + 删除顺序 | — |
| 7 | 前台服务 `Yhy02SyncService`（后台保活 + 通知） | 3,6 |
| 8 | UI：`Yhy02BadgeScreen`（连接/录制指示/历史同步/上传队列）+ 导航入口 | 3,6 |
| 9 | 集成验收：`FakeBadgeTransport` 端到端 JVM 测试（模拟录 30s → 上传 → 删） | 4,5,6 |
| 10 | （可延后）SoftAP + FTP 历史回补 | 5 |

> **合并执行说明（OpenAI 评审建议，采纳）**：Task 3+4 都改 `Yhy02Manager.kt`，派给同一个 implementer 一次做完、分两个 commit；Task 5+10 都是「历史回补」，可同一个 implementer 按 5→10 顺序连做（10 可延后但接口已在 5 里预留）。

---

### Task 1: `Yhy02Protocol` 编解码器

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02Protocol.kt`
- Create: `app/src/test/java/tech/propertylab/agent/badge2/Yhy02ProtocolTest.kt`

- [ ] **Step 1: 写失败的测试**

`Yhy02ProtocolTest.kt` 完整内容（字节向量全部来自真机捕获）：

```kotlin
package tech.propertylab.agent.badge2

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class Yhy02ProtocolTest {

    // 真机 FEE1 read（21B）：header 9B + 12B payload
    private val fee1Frame = hex(
        "01000020000c000001" + "02046401d1e08001d1e000"
    )

    // 真机 FEE4 实时流首帧（261B，截到 20B 即可——只验证头）
    private val fee4Frame = hex("0100002400fc001530fff16040")

    // 真机 FEE3 状态（13B）：payload = 01 01 00 09 = 录音中 9 秒
    private val fee3Recording = hex("01000022000400021a" + "01010009")

    private fun hex(s: String): ByteArray =
        s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()

    @Test
    fun `parses a device fee1 info frame`() {
        val f = Yhy02Protocol.parseFrame(fee1Frame)!!
        assertEquals(0x0020, f.cmd)
        assertEquals(12, f.dlen)
        assertEquals(0x0000, f.seq)
        assertEquals(12, f.payload.size)
        val info = Yhy02Protocol.parseFirmwareInfo(f.payload)!!
        assertEquals("2.4.100", info.firmware)
        assertEquals(100, info.batteryPercent)
        assertEquals(30_531_712L, info.totalKb)
        assertEquals(30_531_584L, info.freeKb)
    }

    @Test
    fun `parses a device fee4 audio frame with a two byte sequence`() {
        val f = Yhy02Protocol.parseFrame(fee4Frame)!!
        assertEquals(0x0024, f.cmd)
        assertEquals(0x00fc, f.dlen)
        assertEquals(0x0015, f.seq)          // [6:8] 是 2 字节大端序号
        assertEquals(0xFF.toByte(), f.payload[0])
        assertEquals(0xF1.toByte(), f.payload[1])
    }

    @Test
    fun `parses fee3 recording status`() {
        val f = Yhy02Protocol.parseFrame(fee3Recording)!!
        assertEquals(0x0022, f.cmd)
        val st = Yhy02Protocol.parseRecordingStatus(f.payload)!!
        assertTrue(st.recording)
        assertEquals(9, st.elapsedSeconds)
    }

    @Test
    fun `rejects garbage and wrong direction`() {
        assertNull(Yhy02Protocol.parseFrame(ByteArray(4)))
        // 非法 flag（0x02）—— 只认 0x01/0x11
        assertNull(Yhy02Protocol.parseFrame(hex("020000200000000000")))
    }

    @Test
    fun `builds an app write frame with crc zero and padding zero`() {
        val f = Yhy02Protocol.buildWriteFrame(0x0040, byteArrayOf(0x01))
        assertEquals(0x11.toByte(), f[0])       // flag APP→设备
        assertEquals(0x00, f[1].toInt())        // encoding AAC
        assertEquals(0x0040, ((f[2].toInt() and 0xFF) shl 8) or (f[3].toInt() and 0xFF))
        assertEquals(1, ((f[4].toInt() and 0xFF) shl 8) or (f[5].toInt() and 0xFF))
        assertEquals(0x00, f[7].toInt())        // CRC8 填 0
        assertEquals(0x00, f[8].toInt())        // Padding 填 0
        assertEquals(10, f.size)                // 9 字节头 + 1 字节载荷
    }
}
```

- [ ] **Step 2: 跑测试确认失败**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ProtocolTest"`
Expected: FAIL —— `Yhy02Protocol` 未定义。

- [ ] **Step 3: 写实现**

`Yhy02Protocol.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

/**
 * YHY02 胸牌帧编解码（纯函数，无 Android 依赖）。
 *
 * 帧头固定 9 字节（与实机 394 帧逐帧吻合）：
 *   [0] Flag 0x01=设备→APP / 0x11=APP→设备
 *   [1] Encoding 0=AAC 1=LC3 2=Opus
 *   [2:4] CMD UInt16 BE   [4:6] DataLength UInt16 BE
 *   [6:8] Sequence UInt16 BE   [8] CRC8（收到后忽略——设备此字节是杂值）
 *   [9:] payload
 *
 * 两条路径长度规则不同（实现处各自负责）：
 *   FEE4 实时：总长 = 9 + DataLength，数据从偏移 9 开始（ADTS AAC）
 *   FEE6 历史：总长 = 9 + 1 + DataLength，偏移 9 是 status，数据从 10 开始
 */
object Yhy02Protocol {

    const val FLAG_DEVICE_TO_APP = 0x01
    const val FLAG_APP_TO_DEVICE = 0x11
    const val HEADER_BYTES = 9

    const val SERVICE_FEE0 = "0000FEE0-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE1 = "0000FEE1-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE2 = "0000FEE2-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE3 = "0000FEE3-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE4 = "0000FEE4-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE5 = "0000FEE5-0000-1000-8000-00805F9B34FB"
    const val CHAR_FEE6 = "0000FEE6-0000-1000-8000-00805F9B34FB"
    const val CCCD = "00002902-0000-1000-8000-00805F9B34FB"

    const val CMD_GET_INFO = 0x0020        // → FEE1
    const val CMD_RECORD_CONTROL = 0x0022  // → FEE3
    const val CMD_AUDIO_STREAM = 0x0024    // FEE4 推流标记
    const val CMD_HOTSPOT = 0x0030         // SoftAP 开关（Task 10）
    const val CMD_SYNC_JSON = 0x0040       // → FEE6，ACK 走 FEE5
    const val CMD_PULL_FILE = 0x0041       // → FEE6
    const val CMD_DELETE_FILE = 0x0043     // → FEE6

    const val STATUS_FAIL = 0
    const val STATUS_OK = 1
    const val STATUS_TRANSFER_START = 3
    const val STATUS_TRANSFERRING = 4
    const val STATUS_TRANSFER_DONE = 5
    const val STATUS_FILE_NOT_FOUND = 6

    /** sync.json 里的 sync 字段 */
    const val SYNC_DELETED = 0
    const val SYNC_UPLOADED = 1
    const val SYNC_ON_DEVICE = 2

    data class Frame(
        val flag: Int,
        val encoding: Int,
        val cmd: Int,
        val dlen: Int,
        val seq: Int,
        val payload: ByteArray,
    ) {
        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (javaClass != other?.javaClass) return false
            other as Frame
            return flag == other.flag && encoding == other.encoding &&
                cmd == other.cmd && dlen == other.dlen &&
                seq == other.seq && payload.contentEquals(other.payload)
        }

        override fun hashCode(): Int {
            var result = flag
            result = 31 * result + encoding
            result = 31 * result + cmd
            result = 31 * result + dlen
            result = 31 * result + seq
            result = 31 * result + payload.contentHashCode()
            return result
        }
    }

    data class FirmwareInfo(
        val firmware: String,
        val batteryPercent: Int,
        val totalKb: Long,
        val freeKb: Long,
    )

    data class RecordingStatus(
        val recording: Boolean,
        val elapsedSeconds: Int,
    )

    data class SyncEntry(
        val folder: String,
        val file: String,
        val sizeBytes: Long,
        val durationSeconds: Long,
        val sync: Int,
    ) {
        val relativePath: String get() = "$folder/$file"
    }

    private var writeSeq = 0

    /** APP→设备 帧。CRC8 规范注明可选——填 0，Padding 填 0。 */
    @Synchronized
    fun buildWriteFrame(cmd: Int, payload: ByteArray = ByteArray(0)): ByteArray {
        writeSeq = (writeSeq + 1) and 0xFF
        return byteArrayOf(
            FLAG_APP_TO_DEVICE.toByte(),
            0x00,                                    // encoding AAC
            ((cmd shr 8) and 0xFF).toByte(), (cmd and 0xFF).toByte(),
            ((payload.size shr 8) and 0xFF).toByte(), (payload.size and 0xFF).toByte(),
            writeSeq.toByte(), 0x00, 0x00,           // seq, crc=0, padding=0
        ) + payload
    }

    /**
     * 解析一帧。每条 ATT 通知恰好承载一帧（实机 347/1788 帧逐帧验证），
     * 所以 payload = 头部之后到通知末尾的全部字节：
     *   FEE4 实时：payload.size == dlen（数据本身）
     *   FEE6 历史：payload.size == dlen + 1（[0]=status，[1:]=数据）
     * status 剥离由调用方按路径做——codec 不猜。
     */
    fun parseFrame(data: ByteArray): Frame? {
        if (data.size < HEADER_BYTES) return null
        val flag = data[0].toInt() and 0xFF
        if (flag != FLAG_DEVICE_TO_APP && flag != FLAG_APP_TO_DEVICE) return null
        val encoding = data[1].toInt() and 0xFF
        if (encoding > 2) return null
        val cmd = ((data[2].toInt() and 0xFF) shl 8) or (data[3].toInt() and 0xFF)
        val dlen = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
        val seq = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
        // data[8] 是 CRC8 —— 实机值不可靠，忽略（厂商工具自己都 non-fatal）。
        // 不能用 dlen 截断 payload：FEE6 帧的 dlen 不含 status 字节，按 dlen 截
        // 会把 status 算进数据、并砍掉最后一个数据字节。
        val payload = data.copyOfRange(HEADER_BYTES, data.size)
        return Frame(flag, encoding, cmd, dlen, seq, payload)
    }

    /** 0x0020 响应 payload（12B）：fw3 + battery1 + totalKB BE32 + freeKB BE32 */
    fun parseFirmwareInfo(payload: ByteArray): FirmwareInfo? {
        if (payload.size < 12) return null
        val major = payload[0].toInt() and 0xFF
        val minor = payload[1].toInt() and 0xFF
        val patch = payload[2].toInt() and 0xFF
        val battery = payload[3].toInt() and 0xFF
        fun be32(off: Int): Long =
            ((payload[off].toLong() and 0xFF) shl 24) or
                ((payload[off + 1].toLong() and 0xFF) shl 16) or
                ((payload[off + 2].toLong() and 0xFF) shl 8) or
                (payload[off + 3].toLong() and 0xFF)
        return FirmwareInfo("$major.$minor.$patch", battery, be32(4), be32(8))
    }

    /** FEE3 状态 payload：[0x01 const][recording 0/1][elapsed BE16] */
    fun parseRecordingStatus(payload: ByteArray): RecordingStatus? {
        if (payload.size < 4) return null
        val recording = (payload[1].toInt() and 0xFF) == 1
        val elapsed = ((payload[2].toInt() and 0xFF) shl 8) or (payload[3].toInt() and 0xFF)
        return RecordingStatus(recording, elapsed)
    }

    /**
     * ADTS 重同步：BLE 历史传输可能丢包/掺入杂散字节（厂商 DLL 有整套
     * FindAdtsFrames/FixAdtsFrameLength）。从 fromIndex 起找下一个合法
     * ADTS 同步字（FF Fx，x 低 4 位任意、第 4 位为 0），并校验 13 位帧长
     * 不越过剩余数据。找不到返回 -1。
     */
    fun findAdtsSync(data: ByteArray, fromIndex: Int): Int {
        var i = fromIndex
        while (i + 1 < data.size) {
            val b0 = data[i].toInt() and 0xFF
            val b1 = data[i + 1].toInt() and 0xFF
            val isSync = b0 == 0xFF && (b1 and 0xF6) == 0xF0
            if (isSync && i + 5 < data.size) {
                val frameLen = (((b1 and 0x03) shl 11) or
                    ((data[i + 2].toInt() and 0xFF) shl 3) or
                    ((data[i + 3].toInt() and 0xFF) shr 5))
                if (frameLen >= 7 && i + frameLen <= data.size) return i
            }
            i++
        }
        return -1
    }

    /** 对一块含杂散字节的 AAC 做重同步，返回只含完整 ADTS 帧的字节。 */
    fun resyncAdts(data: ByteArray): ByteArray {
        val out = java.io.ByteArrayOutputStream(data.size)
        var i = findAdtsSync(data, 0)
        while (i != -1 && i < data.size) {
            val b1 = data[i + 1].toInt() and 0xFF
            val frameLen = (((b1 and 0x03) shl 11) or
                ((data[i + 2].toInt() and 0xFF) shl 3) or
                ((data[i + 3].toInt() and 0xFF) shr 5))
            if (i + frameLen <= data.size) {
                out.write(data, i, frameLen)
                i = findAdtsSync(data, i + frameLen)
            } else {
                i = findAdtsSync(data, i + 1)
            }
        }
        return out.toByteArray()
    }
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ProtocolTest"`
Expected: PASS（7 个测试全绿）。

- [ ] **Step 5: 提交**

```bash
cd ~/propertylab-android
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02Protocol.kt app/src/test/java/tech/propertylab/agent/badge2/Yhy02ProtocolTest.kt
git commit -m "feat(badge2): add YHY02 frame codec with real-capture test vectors"
```

---

### Task 2: `BadgeTransport` 接口 + 双实现

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/badge2/BadgeTransport.kt`
- Create: `app/src/main/java/tech/propertylab/agent/badge2/AndroidBadgeTransport.kt`
- Create: `app/src/test/java/tech/propertylab/agent/badge2/FakeBadgeTransport.kt`

- [ ] **Step 1: 写接口**

`BadgeTransport.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.flow.Flow

/**
 * YHY02 BLE 交互面。真机走 [AndroidBadgeTransport]；JVM 测试走
 * FakeBadgeTransport（Task 2 的测试实现，Task 9 用它做端到端）。
 *
 * 所有方法都必须在协程里调用；write 语义 = Write With Response。
 */
interface BadgeTransport {
    /** 设备→APP 通知流（FEE3/FEE4/FEE5/FEE6 已订阅特征的原始字节）。 */
    val notifications: Flow<Pair<String, ByteArray>>

    /** 扫描设备，名字前缀 YHY02_。返回 name→(mac, mfg7B) 的列表。 */
    suspend fun scan(timeoutMs: Long): List<ScannedBadge>

    /** 连接（TRANSPORT_LE）并发现服务。 */
    suspend fun connect(mac: String)

    /** 请求 MTU（期望 512），挂起直到 onMtuChanged 或超时。 */
    suspend fun requestMtu(size: Int): Int

    /** 订阅特征 notify（CCCD）。FEE1 无 notify，不要对它调用。 */
    suspend fun enableNotify(uuid: String)

    /** Write With Response；payload 传裸载荷，帧头由本接口补（复用 Yhy02Protocol.buildWriteFrame）。 */
    suspend fun write(cmd: Int, payload: ByteArray)

    /** 读特征（FEE1 的 0x0020 响应走这里）。 */
    suspend fun read(uuid: String): ByteArray

    /** 断开并释放 GATT。 */
    suspend fun disconnect()
}

data class ScannedBadge(
    val name: String,      // YHY02_5BA8E1
    val mac: String,       // 地址
    val sn: String?,       // 从 mfg 数据解出的 12 位 hex（可能为 null）
)
```

- [ ] **Step 2: 写 Android 实现**

`AndroidBadgeTransport.kt` 完整内容（骨架复用 FW920 BadgeManager 的模式——`onCharacteristicChanged` 只 override 原版避免 API 33+ 双回调；命令写用 `WRITE_TYPE_DEFAULT`）：

```kotlin
package tech.propertylab.agent.badge2

import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.util.Log
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import kotlin.coroutines.resume

@SuppressLint("MissingPermission")
class AndroidBadgeTransport(private val context: Context) : BadgeTransport {

    private val adapter = context.getSystemService(BluetoothManager::class.java).adapter
    private var gatt: BluetoothGatt? = null
    private val _notifications = MutableSharedFlow<Pair<String, ByteArray>>(extraBufferCapacity = 512)
    override val notifications: Flow<Pair<String, ByteArray>> = _notifications.asSharedFlow()

    private var descriptorWriteChannel: Channel<Int>? = null
    private var mtuDeferred: kotlinx.coroutines.CompletableDeferred<Int>? = null

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                gatt.discoverServices()
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                this@AndroidBadgeTransport.gatt = null
                mtuDeferred?.complete(0)
                mtuDeferred = null
            }
        }

        // 只 override 原版。API 33+ 的 (gatt, char, value) 重载默认委托到这里；
        // 同时 override 两个会导致每帧通知触发两次（FW920 踩过）。
        override fun onCharacteristicChanged(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
        ) {
            characteristic.getValue()?.let {
                _notifications.tryEmit(characteristic.uuid.toString().uppercase() to it)
            }
        }

        override fun onDescriptorWrite(
            gatt: BluetoothGatt,
            descriptor: BluetoothGattDescriptor,
            status: Int,
        ) {
            descriptorWriteChannel?.trySend(status)
        }

        override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
            mtuDeferred?.complete(if (status == BluetoothGatt.GATT_SUCCESS) mtu else 0)
            mtuDeferred = null
        }
    }

    override suspend fun scan(timeoutMs: Long): List<ScannedBadge> {
        val scanner = adapter?.bluetoothLeScanner ?: return emptyList()
        val out = mutableListOf<ScannedBadge>()
        val cb = object : ScanCallback() {
            override fun onScanResult(callbackType: Int, result: ScanResult) {
                val name = result.device.name ?: return
                if (!name.startsWith("YHY02_")) return
                val mfg = result.scanRecord?.getManufacturerSpecificData(0x05F0)
                val sn = mfg?.takeIf { it.size >= 7 }?.let {
                    (1 until 7).joinToString("") { i -> "%02X".format(it[i]) }
                }
                out.add(ScannedBadge(name, result.device.address, sn))
            }
        }
        val settings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .build()
        scanner.startScan(null, settings, cb)
        try {
            withTimeoutOrNull(timeoutMs) {
                kotlinx.coroutines.delay(timeoutMs)
            }
        } finally {
            scanner.stopScan(cb)
        }
        return out.distinctBy { it.mac }
    }

    override suspend fun connect(mac: String) {
        val device: BluetoothDevice = adapter!!.getRemoteDevice(mac)
        gatt?.close()
        gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
        // 等服务发现完成由 onConnectionStateChange → discoverServices 触发；
        // 这里轮询 service 列表直到 FEE0 出现或 10s 超时。
        // 注意：不能用 `withTimeoutOrNull { while... } ?: throw` 的写法——
        // while 块的返回类型是 Unit（非空），`?: throw` 永远不会触发。
        withTimeoutOrNull(10_000) {
            while (gatt?.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0)) == null) {
                kotlinx.coroutines.delay(50)
            }
        }
        if (gatt?.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0)) == null) {
            throw IllegalStateException("FEE0 service discovery timed out")
        }
    }

    override suspend fun requestMtu(size: Int): Int {
        val g = gatt ?: return 0
        val deferred = kotlinx.coroutines.CompletableDeferred<Int>()
        mtuDeferred = deferred
        val accepted = g.requestMtu(size)
        if (!accepted) return 0
        return withTimeoutOrNull(5_000) { deferred.await() } ?: 0
    }

    override suspend fun enableNotify(uuid: String) {
        val g = gatt ?: throw IllegalStateException("Not connected")
        val svc = g.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0))
            ?: throw IllegalStateException("FEE0 not found")
        val char = svc.getCharacteristic(UUID.fromString(uuid))
            ?: throw IllegalStateException("char not found: $uuid")
        val desc = char.getDescriptor(UUID.fromString(Yhy02Protocol.CCCD))
            ?: throw IllegalStateException("CCCD not found: $uuid")
        g.setCharacteristicNotification(char, true)
        val ch = Channel<Int>(1)
        descriptorWriteChannel = ch
        try {
            desc.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
            if (!g.writeDescriptor(desc)) {
                throw IllegalStateException("writeDescriptor rejected for $uuid")
            }
            val status = withTimeoutOrNull(2_000) { ch.receive() }
            if (status != BluetoothGatt.GATT_SUCCESS) {
                throw IllegalStateException("notify enable failed for $uuid (status=$status)")
            }
        } finally {
            descriptorWriteChannel = null
        }
    }

    override suspend fun write(cmd: Int, payload: ByteArray) {
        val g = gatt ?: throw IllegalStateException("Not connected")
        // 命令目标是 FEE3/FEE6（0x0020 例外走 FEE1），按 cmd 选特征：
        val charUuid = when (cmd) {
            Yhy02Protocol.CMD_GET_INFO -> Yhy02Protocol.CHAR_FEE1
            Yhy02Protocol.CMD_SYNC_JSON, Yhy02Protocol.CMD_PULL_FILE,
            Yhy02Protocol.CMD_DELETE_FILE -> Yhy02Protocol.CHAR_FEE6
            Yhy02Protocol.CMD_RECORD_CONTROL -> Yhy02Protocol.CHAR_FEE3
            else -> throw IllegalArgumentException("no characteristic mapped for cmd=0x${cmd.toString(16)}")
        }
        val svc = g.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0))
            ?: throw IllegalStateException("FEE0 not found")
        val char = svc.getCharacteristic(UUID.fromString(charUuid))
            ?: throw IllegalStateException("char not found: $charUuid")
        val frame = Yhy02Protocol.buildWriteFrame(cmd, payload)
        char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT // Write With Response
        char.setValue(frame)
        if (!g.writeCharacteristic(char)) {
            throw IllegalStateException("writeCharacteristic rejected for cmd=0x${cmd.toString(16)}")
        }
    }

    override suspend fun read(uuid: String): ByteArray =
        suspendCancellableCoroutine { cont ->
            val g = gatt ?: return@suspendCancellableCoroutine cont.resume(ByteArray(0))
            val svc = g.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0))
            val char = svc?.getCharacteristic(UUID.fromString(uuid))
            if (char == null) {
                cont.resume(ByteArray(0))
                return@suspendCancellableCoroutine
            }
            // read 结果经原版 onCharacteristicRead 回不来（此 transport 未覆盖），
            // 用临时 callback 包装：直接在这里用 BluetoothGattCallback 覆写太重，
            // 简化——用 tryEmit 的 notifications 流收 0x0020 响应（见 Manager 的
            // readInfo 实现，走 notifications 而非本方法）。
            g.readCharacteristic(char)
            cont.resume(ByteArray(0))
        }

    override suspend fun disconnect() {
        gatt?.close()
        gatt = null
    }
}
```

> ⚠️ 注意：`read()` 里留了个缺口——GATT 的 onCharacteristicRead 回调在这个 transport 里没有接线。Task 3 的 `readInfo()` **不要**用 `read()`，而是「写 0x0020 → 从 `notifications` 流收 FEE1 的响应帧」……但 FEE1 没有 notify。真机行为（probe2 实测）：**写 0x0020 后主动 `readCharacteristic(FEE1)`，响应在 onCharacteristicRead 里回来**。因此 Step 3 把 `read()` 改成回调版。

- [ ] **Step 3: 把 `read()` 改成真正的回调版**

> 说明（评审曾建议「用 notifications 流收 FEE1 响应」）：**不成立**——FEE1 实机属性只有 `write,read`、没有 notify（GATT dump 证实，`start_notify(FEE1)` 会被设备拒绝 `CBATTErrorDomain Code=6`）。0x0020 的响应只能靠主动 `readCharacteristic(FEE1)` 拿，`read()` 就是为此存在的，别改成走通知流。

替换 `AndroidBadgeTransport.kt` 里的 `read()` 为：

```kotlin
    private var readChannel: Channel<ByteArray>? = null

    // 加到 gattCallback 里（onDescriptorWrite 之后）：
    override fun onCharacteristicRead(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        status: Int,
    ) {
        if (status == BluetoothGatt.GATT_SUCCESS) {
            characteristic.getValue()?.let { readChannel?.trySend(it) }
        } else {
            readChannel?.close()
        }
    }

    override suspend fun read(uuid: String): ByteArray {
        val g = gatt ?: throw IllegalStateException("Not connected")
        val svc = g.getService(UUID.fromString(Yhy02Protocol.SERVICE_FEE0))
            ?: throw IllegalStateException("FEE0 not found")
        val char = svc.getCharacteristic(UUID.fromString(uuid))
            ?: throw IllegalStateException("char not found: $uuid")
        val ch = Channel<ByteArray>(1)
        readChannel = ch
        try {
            if (!g.readCharacteristic(char)) {
                throw IllegalStateException("readCharacteristic rejected for $uuid")
            }
            return withTimeoutOrNull(3_000) { ch.receive() }
                ?: throw IllegalStateException("read timeout for $uuid")
        } finally {
            readChannel = null
        }
    }
```

（`readChannel` 字段声明加在 `descriptorWriteChannel` 旁边；`onCharacteristicRead` 加进 `gattCallback` 对象里。）

- [ ] **Step 4: 写 FakeBadgeTransport（供 Task 9 用，先建骨架）**

`FakeBadgeTransport.kt`（放在 test 源集，先实现接口、行为留 Task 9 填）：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow

/**
 * JVM 假外设：按脚本应答 0x0020/0x0040/0x0041，模拟一段真实录音的
 * FEE4 AAC 流。Task 9 的端到端测试用它；alpha-stack 的
 * mock_ble_recorder.py 是同思路的 Python 参照。
 */
class FakeBadgeTransport : BadgeTransport {

    // replay=1024：Manager/HistorySync 的收集器是 connectTo()/requestIndex() 返回后
    // 才异步订阅的，测试同步 emit 会先于订阅到达；replay 让晚到订阅者仍能看到
    // 已发帧，测试才能确定（真机 transport 保持 replay=0——真实设备不会回放历史帧）。
    private val _notifications = MutableSharedFlow<Pair<String, ByteArray>>(replay = 1024, extraBufferCapacity = 512)
    override val notifications: Flow<Pair<String, ByteArray>> = _notifications.asSharedFlow()

    var connected = false
        private set
    var sentCommands = mutableListOf<Pair<Int, ByteArray>>()
        private set

    override suspend fun scan(timeoutMs: Long): List<ScannedBadge> =
        listOf(ScannedBadge("YHY02_5BA8E1", "FA:KE:5B:A8:E1", "C8478C5BA8E1"))

    override suspend fun connect(mac: String) { connected = true }

    override suspend fun requestMtu(size: Int): Int = 512

    override suspend fun enableNotify(uuid: String) { /* fake 无 CCCD */ }

    override suspend fun write(cmd: Int, payload: ByteArray) {
        sentCommands += cmd to payload
    }

    override suspend fun read(uuid: String): ByteArray = ByteArray(0)

    override suspend fun disconnect() { connected = false }

    /** 测试辅助：注入一帧设备通知（FEE1 响应帧、FEE3 状态、FEE4 音频、FEE5/FEE6 同步帧）。 */
    suspend fun emitDeviceFrame(charUuid: String, frame: ByteArray) {
        _notifications.emit(charUuid to frame)
    }
}
```

- [ ] **Step 5: 编译确认**

Run: `./gradlew :app:compileDebugKotlin :app:compileDebugUnitTestKotlin`
Expected: BUILD SUCCESSFUL。

- [ ] **Step 6: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/BadgeTransport.kt \
        app/src/main/java/tech/propertylab/agent/badge2/AndroidBadgeTransport.kt \
        app/src/test/java/tech/propertylab/agent/badge2/FakeBadgeTransport.kt
git commit -m "feat(badge2): add BLE transport interface with Android + fake impls"
```

---

### Task 3: `Yhy02Manager` 连接 + 设备信息

**Files:**
- Modify: `app/src/test/java/tech/propertylab/agent/badge2/FakeBadgeTransport.kt`（加 read 响应缓存）
- Create: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt`
- Create: `app/src/test/java/tech/propertylab/agent/badge2/Yhy02ManagerTest.kt`

- [ ] **Step 0: 给 FakeBadgeTransport 加 read 响应缓存**

```kotlin
    /** 预置 read() 应答（FEE1 的 0x0020 响应走这里，FEE1 没有 notify）。 */
    val readResponses = mutableListOf<Pair<String, ByteArray>>()

    override suspend fun read(uuid: String): ByteArray {
        val idx = readResponses.indexOfFirst { it.first == uuid }
        return if (idx >= 0) readResponses.removeAt(idx).second else ByteArray(0)
    }
```

- [ ] **Step 1: 写失败的测试**

`Yhy02ManagerTest.kt`（Fake transport 驱动，验证 0x0020 流程）：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class Yhy02ManagerTest {

    @Test
    fun `connect queries device info via 0020 and parses firmware`() = runBlocking {
        val fake = FakeBadgeTransport()
        val mgr = Yhy02Manager(fake)

        // FEE1 没有 notify：0x0020 的响应是「写 → read FEE1」拿到的。
        // 预先把响应放进 fake 的读缓存（Task 3 Step 0 给 Fake 加的 readResponses）。
        fake.readResponses += Yhy02Protocol.CHAR_FEE1 to
            Yhy02Protocol.buildFakeDeviceFrame(0x0020, byteArrayOf(
                0x02, 0x04, 0x64, 0x64,
                0x01, 0xD1.toByte(), 0xE0.toByte(), 0x80.toByte(),
                0x01, 0xD1.toByte(), 0xE0.toByte(), 0x00.toByte(),
            ), seq = 1)

        mgr.connectTo("C8478C5BA8E1")

        // 0x0020 已写出（ts BE32 + 0x01 时区）
        val (cmd, payload) = fake.sentCommands.first { it.first == Yhy02Protocol.CMD_GET_INFO }
        assertEquals(5, payload.size)
        assertEquals(0x01, payload[4].toInt())

        val state = mgr.state.first { it.deviceInfo != null }
        assertEquals("2.4.100", state.deviceInfo!!.firmware)
        assertEquals(100, state.deviceInfo!!.batteryPercent)
        assertTrue(state.connected)
    }
}
```

`Yhy02Protocol` 里补一个测试辅助（加在 object 末尾）：

```kotlin
    /** 测试辅助：造设备帧（flag=0x01，seq 指定）。 */
    fun buildFakeDeviceFrame(cmd: Int, payload: ByteArray, seq: Int): ByteArray =
        byteArrayOf(
            FLAG_DEVICE_TO_APP.toByte(), 0x00,
            ((cmd shr 8) and 0xFF).toByte(), (cmd and 0xFF).toByte(),
            ((payload.size shr 8) and 0xFF).toByte(), (payload.size and 0xFF).toByte(),
            ((seq shr 8) and 0xFF).toByte(), (seq and 0xFF).toByte(), 0x00,
        ) + payload
```

- [ ] **Step 2: 跑测试确认失败**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ManagerTest"`
Expected: FAIL —— `Yhy02Manager` 未定义。

- [ ] **Step 3: 写 `Yhy02Manager`**

`Yhy02Manager.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

data class Yhy02State(
    val scanning: Boolean = false,
    val connecting: Boolean = false,
    val connected: Boolean = false,
    val deviceName: String? = null,
    val deviceSn: String? = null,
    val deviceInfo: Yhy02Protocol.FirmwareInfo? = null,
    val recording: Boolean = false,
    val recordingElapsedSec: Int = 0,
    val recordingFile: String? = null,     // 相对路径 yyyyMMdd/HHmmss.aac
    val syncingFiles: Int = 0,
    val syncProgress: Int = 0,             // 0..100
    val lastError: String? = null,
)

class Yhy02Manager(private val transport: BadgeTransport) {

    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private val _state = MutableStateFlow(Yhy02State())
    val state: StateFlow<Yhy02State> = _state.asStateFlow()
    private val commandMutex = Mutex()

    /** 连接 + MTU + 订阅 + 校准时钟（0x0020）。 */
    suspend fun connectTo(sn: String) {
        _state.update { it.copy(connecting = true, lastError = null) }
        try {
            val found = transport.scan(8_000).firstOrNull { it.sn?.equals(sn, ignoreCase = true) == true }
                ?: throw IllegalStateException("badge $sn not found in scan")
            transport.connect(found.mac)
            val mtu = transport.requestMtu(512)
            // MTU 报告不可信（Windows 报 23 也照常收完整帧）——只记不判断。
            transport.enableNotify(Yhy02Protocol.CHAR_FEE3)
            transport.enableNotify(Yhy02Protocol.CHAR_FEE4)
            transport.enableNotify(Yhy02Protocol.CHAR_FEE5)
            transport.enableNotify(Yhy02Protocol.CHAR_FEE6)
            _state.update {
                it.copy(connected = true, connecting = false, deviceName = found.name, deviceSn = found.sn ?: sn)
            }
            queryDeviceInfo()
            observeStatus()
        } catch (e: Exception) {
            _state.update { it.copy(connecting = false, lastError = e.message) }
            transport.disconnect()
        }
    }

    /** 0x0020：写 ts+时区 → read FEE1 → 解析。顺带把设备时钟校准为手机时间（UTC+8）。 */
    suspend fun queryDeviceInfo() {
        val now = Instant.now().epochSecond
        val payload = ByteArray(5).apply {
            this[0] = ((now shr 24) and 0xFF).toByte()
            this[1] = ((now shr 16) and 0xFF).toByte()
            this[2] = ((now shr 8) and 0xFF).toByte()
            this[3] = (now and 0xFF).toByte()
            this[4] = 0x01 // 时区字节：UTC+8（probe4 实测有效）
        }
        commandMutex.withLock {
            transport.write(Yhy02Protocol.CMD_GET_INFO, payload)
        }
        // FEE1 无 notify：响应要主动读回来。
        val raw = withTimeoutOrNull(3_000) { transport.read(Yhy02Protocol.CHAR_FEE1) } ?: return
        val frame = Yhy02Protocol.parseFrame(raw) ?: return
        val info = frame.payload.let { Yhy02Protocol.parseFirmwareInfo(it) } ?: return
        _state.update { it.copy(deviceInfo = info) }
    }

    /** 订阅 FEE3 状态通知：录音开关 + 已录秒数。 */
    private fun observeStatus() {
        scope.launch {
            transport.notifications
                .filter { it.first == Yhy02Protocol.CHAR_FEE3 }
                .collect { (_, raw) ->
                    val frame = Yhy02Protocol.parseFrame(raw) ?: return@collect
                    val st = frame.payload.let { Yhy02Protocol.parseRecordingStatus(it) } ?: return@collect
                    _state.update {
                        it.copy(recording = st.recording, recordingElapsedSec = st.elapsedSeconds)
                    }
                }
        }
    }

    suspend fun disconnect() {
        transport.disconnect()
        _state.update { Yhy02State() }
    }
}
```

> ⚠️ `scan()` 需要 BLUETOOTH_SCAN 权限；`firstOrNull` 需要 `kotlinx.coroutines.flow.firstOrNull` import——补上：
> `import kotlinx.coroutines.flow.firstOrNull`

- [ ] **Step 4: 跑测试确认通过**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ManagerTest"`
Expected: PASS。

- [ ] **Step 5: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt \
        app/src/main/java/tech/propertylab/agent/badge2/Yhy02Protocol.kt \
        app/src/test/java/tech/propertylab/agent/badge2/Yhy02ManagerTest.kt
git commit -m "feat(badge2): add Yhy02Manager connect + device-info query"
```

---

### Task 4: 实时流捕获（FEE4 → 落盘）

**Files:**
- Modify: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt`
- Modify: `app/src/test/java/tech/propertylab/agent/badge2/Yhy02ManagerTest.kt`

- [ ] **Step 1: 写失败的测试**

在 `Yhy02ManagerTest.kt` 追加：

```kotlin
    @Test
    fun `live stream is written to a device-clock-named aac file`() = runBlocking {
        val fake = FakeBadgeTransport()
        val mgr = Yhy02Manager(fake, captureDir = java.io.File("build/tmp/yhy02-test"))
        mgr.connectTo("C8478C5BA8E1")

        // 设备开始录音：FEE3 状态 recording=1
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE3,
            Yhy02Protocol.buildFakeDeviceFrame(0x0022, byteArrayOf(0x01, 0x01, 0x00, 0x00), seq = 10))
        // 两个 FEE4 音频帧（ADTS 首 4 字节 FF F1 60 40）
        val aac1 = byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x60.toByte(), 0x40.toByte()) + ByteArray(40)
        val aac2 = byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x60.toByte(), 0x40.toByte()) + ByteArray(60)
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE4,
            Yhy02Protocol.buildFakeDeviceFrame(0x0024, aac1, seq = 0x15))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE4,
            Yhy02Protocol.buildFakeDeviceFrame(0x0024, aac2, seq = 0x16))
        // 停止录音
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE3,
            Yhy02Protocol.buildFakeDeviceFrame(0x0022, byteArrayOf(0x01, 0x00, 0x00, 0x05), seq = 20))

        val st = mgr.state.first { it.recordingFile != null && !it.recording }
        val f = java.io.File("build/tmp/yhy02-test", st.recordingFile!!)
        assertTrue(f.exists())
        assertEquals(aac1.size + aac2.size, f.length())
    }
```

- [ ] **Step 2: 跑测试确认失败**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ManagerTest"`
Expected: FAIL（构造参数/`recordingFile` 未实现）。

- [ ] **Step 3: 实现实时流捕获**

`Yhy02Manager` 加构造函数参数与流处理：

```kotlin
class Yhy02Manager(
    private val transport: BadgeTransport,
    private val captureDir: File = File("cache/captures"),
    // Task 6 才注入（stopCapture 封口后把录音入队）；Task 4 先传默认空实现或暂不调用。
    private val uploadStore: RecordingUploadStore? = null,
) {
    private var captureFile: File? = null
    private var captureStartMs: Long = 0

    /** FEE4 音频流：只在设备录音期间有帧；按设备时钟命名（与历史文件名同构）。 */
    private fun observeAudioStream() {
        scope.launch {
            var lastSeq = -1
            transport.notifications
                .filter { it.first == Yhy02Protocol.CHAR_FEE4 }
                .collect { (_, raw) ->
                    val frame = Yhy02Protocol.parseFrame(raw) ?: return@collect
                    if (frame.cmd != Yhy02Protocol.CMD_AUDIO_STREAM) return@collect
                    // 序号缺口检测（[6:8] 2 字节大端）：只记状态、不拒帧、不中断。
                    // 为什么不做重传：这是 1× 实时流——缺的帧已经过去了，协议没有
                    // 重传命令；设备同时在本地存 EMMC（29GB，够 89 天），断流缺口
                    // 由历史同步兜底补，所以这里记录即可，绝不因此丢弃后续帧。
                    if (lastSeq >= 0 && frame.seq != (lastSeq + 1) and 0xFFFF) {
                        _state.update { it.copy(lastError = "audio gap: $lastSeq → ${frame.seq}") }
                    }
                    lastSeq = frame.seq
                    captureFile?.appendBytes(frame.payload)
                }
        }
    }
```

- [ ] **Step 4: 把「录音窗口开关」接进 `observeStatus`**

替换 `observeStatus()` 的 collect body：

```kotlin
    private fun observeStatus() {
        scope.launch {
            transport.notifications
                .filter { it.first == Yhy02Protocol.CHAR_FEE3 }
                .collect { (_, raw) ->
                    val frame = Yhy02Protocol.parseFrame(raw) ?: return@collect
                    val st = frame.payload.let { Yhy02Protocol.parseRecordingStatus(it) } ?: return@collect
                    val wasRecording = _state.value.recording
                    if (st.recording && !wasRecording) {
                        startCapture()
                    } else if (!st.recording && wasRecording) {
                        stopCapture(st.elapsedSeconds)
                    }
                    _state.update {
                        it.copy(recording = st.recording, recordingElapsedSec = st.elapsedSeconds)
                    }
                }
        }
    }

    private fun startCapture() {
        captureDir.mkdirs()
        // 设备时钟刚被 0x0020 校准过，用当前时间按设备命名规则起名：
        // yyyyMMdd/HHmmss.aac —— 与历史文件同构，服务端 external_id 才能合一。
        val now = java.time.LocalDateTime.now(ZoneOffset.ofHours(8))
        val folder = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
        val file = now.format(DateTimeFormatter.ofPattern("HHmmss")) + ".aac"
        val dir = File(captureDir, folder)
        dir.mkdirs()
        captureFile = File(dir, file)
        captureStartMs = System.currentTimeMillis()
        _state.update { it.copy(recordingFile = "$folder/$file") }
    }

    private fun stopCapture(elapsedSec: Int) {
        captureFile = null
        _state.update { it.copy(recordingFile = _state.value.recordingFile, recordingElapsedSec = elapsedSec) }
    }
```

- [ ] **Step 5: 在 `connectTo` 里挂上流收集**

在 `observeStatus()` 调用之后加一行：`observeAudioStream()`。

- [ ] **Step 6: 跑测试确认通过**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02ManagerTest"`
Expected: PASS（原 1 个 + 新 1 个）。

- [ ] **Step 7: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt \
        app/src/test/java/tech/propertylab/agent/badge2/Yhy02ManagerTest.kt
git commit -m "feat(badge2): capture the FEE4 live AAC stream to device-clock-named files"
```

---

### Task 5: 历史同步（sync.json → 拉取 → ADTS 重同步）

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02HistorySync.kt`
- Create: `app/src/test/java/tech/propertylab/agent/badge2/Yhy02HistorySyncTest.kt`

- [ ] **Step 1: 写失败的测试**

`Yhy02HistorySyncTest.kt` 完整内容（含 fsize 缺引号修复 + 状态机）：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File

class Yhy02HistorySyncTest {

    private val rawJson = """
        {"total":{"record":[
          {"folder":"20260818","file":"131505.aac","fsize":731499,"time":181,"sync":2}
        ]}}
    """.trimIndent()

    // 固件真实 bug：fsize 值后面带一个多余的引号（无前引号）
    private val brokenJson = """
        {"total":{"record":[
          {"folder":"20260814","file":"020735.aac","fsize":133754","time":120,"sync":2}
        ]}}
    """.trimIndent()

    @Test
    fun `parses sync json and lists only device-only files`() = runBlocking {
        val fake = FakeBadgeTransport()
        val sync = Yhy02HistorySync(fake, File("build/tmp/yhy02-sync"))

        // Fake 的 notifications 带 replay=1024：先把设备应答帧全部发出，
        // 再调用 requestIndex()（其内部订阅 → 写 0x0040 → 等待），
        // 晚订阅者从 replay 按序收到全部帧，测试才确定。
        val jsonBytes = rawJson.toByteArray()
        val half = jsonBytes.size / 2
        fun statusFrame(st: Int, chunk: ByteArray) =
            Yhy02Protocol.buildFakeDeviceFrame(0x0040, byteArrayOf(st.toByte()) + chunk, seq = 1)
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5, statusFrame(3, ByteArray(0)))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5, statusFrame(4, jsonBytes.copyOfRange(0, half)))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5, statusFrame(4, jsonBytes.copyOfRange(half, jsonBytes.size)))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5, statusFrame(5, ByteArray(0)))

        val entries = sync.requestIndex()
        assertEquals(1, entries.size)
        val e = entries[0]
        assertEquals("20260818/131505.aac", e.relativePath)
        assertEquals(2, e.sync)
        assertEquals(731_499L, e.sizeBytes)
        assertEquals(181L, e.durationSeconds)
    }

    @Test
    fun `repairs unquoted fsize values from the firmware json`() = runBlocking {
        val fake = FakeBadgeTransport()
        val sync = Yhy02HistorySync(fake, File("build/tmp/yhy02-sync2"))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5,
            Yhy02Protocol.buildFakeDeviceFrame(0x0040, byteArrayOf(4) + brokenJson.toByteArray(), seq = 1))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE5,
            Yhy02Protocol.buildFakeDeviceFrame(0x0040, byteArrayOf(5), seq = 2))
        val entries = sync.requestIndex()
        assertEquals(133_754L, entries[0].sizeBytes)
    }

    @Test
    fun `pull reassembles chunks and strips stray bytes via adts resync`() = runBlocking {
        val fake = FakeBadgeTransport()
        val sync = Yhy02HistorySync(fake, File("build/tmp/yhy02-sync3"))

        val aac1 = byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x60.toByte(), 0x40.toByte()) + ByteArray(20)
        val aac2 = byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x60.toByte(), 0x40.toByte()) + ByteArray(30)
        val stray = byteArrayOf(0x00, 0x11)

        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE6,
            Yhy02Protocol.buildFakeDeviceFrame(0x0041, byteArrayOf(3), seq = 1))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE6,
            Yhy02Protocol.buildFakeDeviceFrame(0x0041, byteArrayOf(4) + aac1, seq = 2))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE6,
            Yhy02Protocol.buildFakeDeviceFrame(0x0041, byteArrayOf(4) + stray + aac2, seq = 3))
        fake.emitDeviceFrame(Yhy02Protocol.CHAR_FEE6,
            Yhy02Protocol.buildFakeDeviceFrame(0x0041, byteArrayOf(5), seq = 4))

        val out = sync.pull("20260818/131505.aac")
        assertTrue(out.absolutePath.endsWith("20260818/131505.aac"))
        // 重同步后应恰好是两帧完整 ADTS（杂散 00 11 被剔除）
        assertEquals(aac1.size + aac2.size, out.length())
    }
}
```

- [ ] **Step 2: 跑测试确认失败**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02HistorySyncTest"`
Expected: FAIL —— `Yhy02HistorySync` 未定义。

- [ ] **Step 3: 写实现**

`Yhy02HistorySync.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.ByteArrayOutputStream
import java.io.File

/**
 * 历史文件同步：0x0040 拿 sync.json（ACK 走 FEE5，注意不是 FEE6），
 * 0x0041 拉取（FEE6 分片，帧 = 9 字节头 + 1 status + 数据），
 * 0x0043 删除（只有服务端 2xx 之后由上传队列触发）。
 */
class Yhy02HistorySync(
    private val transport: BadgeTransport,
    private val syncDir: File,
) {
    private val json = Json { ignoreUnknownKeys = true; isLenient = true }
    private val commandMutex = Mutex()
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    /**
     * 拿 sync.json 索引：先订阅 FEE5 再写 0x0040（真机可能几毫秒内就回包，
     * 订阅必须早于写入），等到 status 5/1 或 10s 超时。
     * 片段在 status=4 帧的 payload[1:] 里。
     */
    suspend fun requestIndex(): List<Yhy02Protocol.SyncEntry> {
        val deferred = CompletableDeferred<List<Yhy02Protocol.SyncEntry>>()
        val chunks = ByteArrayOutputStream()
        val job = scope.launch {
            transport.notifications
                .filter { it.first == Yhy02Protocol.CHAR_FEE5 }
                .collect { (_, raw) ->
                    val frame = Yhy02Protocol.parseFrame(raw) ?: return@collect
                    if (frame.cmd != Yhy02Protocol.CMD_SYNC_JSON) return@collect
                    val st = frame.payload.firstOrNull()?.toInt()?.and(0xFF) ?: return@collect
                    when (st) {
                        Yhy02Protocol.STATUS_TRANSFERRING ->
                            chunks.write(frame.payload, 1, frame.payload.size - 1)
                        Yhy02Protocol.STATUS_TRANSFER_DONE, Yhy02Protocol.STATUS_OK -> {
                            if (!deferred.isCompleted) deferred.complete(parseSyncJson(chunks.toByteArray()))
                        }
                        Yhy02Protocol.STATUS_FAIL -> {
                            if (!deferred.isCompleted) deferred.complete(emptyList())
                        }
                    }
                }
        }
        try {
            commandMutex.withLock {
                transport.write(Yhy02Protocol.CMD_SYNC_JSON, byteArrayOf(0x01))
            }
            return withTimeoutOrNull(10_000) { deferred.await() } ?: emptyList()
        } finally {
            job.cancel()
        }
    }

    /** 固件 JSON 的 fsize 可能缺引号——先修再解析。 */
    private fun parseSyncJson(raw: ByteArray): List<Yhy02Protocol.SyncEntry> {
        val text = String(raw, Charsets.UTF_8)
            .replace(Regex("(\"fsize\"\\s*:\\s*)(\\d+)\""), "$1\"$2\"")
        return runCatching {
            val records = json.parseToJsonElement(text).jsonObject["total"]
                ?.jsonObject?.get("record")?.jsonArray ?: return emptyList()
            records.mapNotNull { el ->
                val o = el.jsonObject
                Yhy02Protocol.SyncEntry(
                    folder = o["folder"]?.jsonPrimitive?.content ?: return@mapNotNull null,
                    file = o["file"]?.jsonPrimitive?.content ?: return@mapNotNull null,
                    sizeBytes = (o["fsize"]?.jsonPrimitive?.content ?: "0").toLongOrNull() ?: 0L,
                    durationSeconds = (o["time"]?.jsonPrimitive?.content ?: "0").toLongOrNull() ?: 0L,
                    sync = (o["sync"]?.jsonPrimitive?.content ?: "0").toIntOrNull() ?: 0,
                )
            }
        }.getOrDefault(emptyList())
    }

    /** 字段定宽填充：不足补【空格 0x20】（probe4 实测 `.ljust(8)`/`.ljust(10)`，不是 0x00）。 */
    private fun padRight(bytes: ByteArray, width: Int): ByteArray {
        val out = ByteArray(width)
        for (i in 0 until minOf(bytes.size, width)) out[i] = bytes[i]
        for (i in bytes.size until width) out[i] = 0x20
        return out
    }

    /**
     * 拉取一个文件（0x0041）：先订阅 FEE6 再写命令，等 status 5 完成。
     * 返回落盘文件（已做 ADTS 重同步，剔除杂散字节）。
     */
    suspend fun pull(relativePath: String): File {
        val (folder, file) = relativePath.split("/", limit = 2)
        val payload = byteArrayOf(0x01) +
            padRight(folder.toByteArray(), 8) +
            padRight(file.toByteArray(), 10)

        val deferred = CompletableDeferred<File>()
        val chunks = ByteArrayOutputStream()
        val job = scope.launch {
            transport.notifications
                .filter { it.first == Yhy02Protocol.CHAR_FEE6 }
                .collect { (_, raw) ->
                    val frame = Yhy02Protocol.parseFrame(raw) ?: return@collect
                    if (frame.cmd != Yhy02Protocol.CMD_PULL_FILE) return@collect
                    val st = frame.payload.firstOrNull()?.toInt()?.and(0xFF) ?: return@collect
                    when (st) {
                        Yhy02Protocol.STATUS_TRANSFERRING ->
                            chunks.write(frame.payload, 1, frame.payload.size - 1)
                        Yhy02Protocol.STATUS_TRANSFER_DONE, Yhy02Protocol.STATUS_OK -> {
                            val cleaned = Yhy02Protocol.resyncAdts(chunks.toByteArray())
                            val out = File(syncDir, relativePath)
                            out.parentFile?.mkdirs()
                            out.writeBytes(cleaned)
                            if (!deferred.isCompleted) deferred.complete(out)
                        }
                        Yhy02Protocol.STATUS_FILE_NOT_FOUND, Yhy02Protocol.STATUS_FAIL -> {
                            if (!deferred.isCompleted) deferred.completeExceptionally(
                                IllegalStateException("pull failed, status=$st")
                            )
                        }
                    }
                }
        }
        try {
            commandMutex.withLock {
                transport.write(Yhy02Protocol.CMD_PULL_FILE, payload)
            }
            return withTimeoutOrNull(120_000) { deferred.await() }
                ?: throw IllegalStateException("pull timed out: $relativePath")
        } finally {
            job.cancel()
        }
    }

    /**
     * 删除设备文件（0x0043）。载荷与 0x0041 同构（0x01 + folder(8) + file(10)，
     * 空格填充）——实现前按 PDF §2.3.5.3 再核一遍载荷头字节。
     * ⚠️ 只在服务端返回 2xx 之后由上传队列调用：顺序反了 = 永久丢客户对话。
     */
    suspend fun deleteFile(relativePath: String) {
        val (folder, file) = relativePath.split("/", limit = 2)
        val payload = byteArrayOf(0x01) +
            padRight(folder.toByteArray(), 8) +
            padRight(file.toByteArray(), 10)
        commandMutex.withLock {
            transport.write(Yhy02Protocol.CMD_DELETE_FILE, payload)
        }
    }
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02HistorySyncTest"`
Expected: PASS（3 个测试）。

- [ ] **Step 6: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02HistorySync.kt \
        app/src/test/java/tech/propertylab/agent/badge2/Yhy02HistorySyncTest.kt
git commit -m "feat(badge2): add history sync (sync.json + file pull + ADTS resync)"
```

---

### Task 6: 上传队列 + ApiClient 扩展

**Files:**
- Modify: `app/src/main/java/tech/propertylab/agent/api/ApiClient.kt`
- Modify: `app/src/main/java/tech/propertylab/agent/api/Models.kt`
- Create: `app/src/main/java/tech/propertylab/agent/data/RecordingUploadStore.kt`
- Create: `app/src/test/java/tech/propertylab/agent/data/RecordingUploadStoreTest.kt`

- [ ] **Step 1: 写失败的测试**

`RecordingUploadStoreTest.kt`（仿 `CallUploadLogStore` 的 JSON 持久化语义）：

```kotlin
package tech.propertylab.agent.data

import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File

class RecordingUploadStoreTest {

    @Test
    fun `persists and reloads entries across instances`() {
        val dir = File("build/tmp/yhy02-uploads")
        dir.mkdirs()
        val a = RecordingUploadStore(dir)
        a.upsert(RecordingUploadEntry(
            localPath = "20260818/131505.aac",
            deviceSn = "C8478C5BA8E1",
            fileName = "20260818/131505.aac",
            startedAtIso = "2026-08-18T05:15:05Z",
            durationSec = 181,
            status = RecordingUploadEntry.Status.PENDING,
            serverRecordingId = null,
            attempts = 0,
        ))
        val b = RecordingUploadStore(dir)
        assertEquals(1, b.all().size)
        assertEquals("C8478C5BA8E1:20260818/131505.aac", b.all()[0].externalId)
    }
}
```

- [ ] **Step 2: 跑测试确认失败**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.data.RecordingUploadStoreTest"`
Expected: FAIL —— 类型未定义。

- [ ] **Step 3: 写 store**

`RecordingUploadStore.kt` 完整内容（复制 `CallUploadLogStore` 的 file-then-rename 模式）：

```kotlin
package tech.propertylab.agent.data

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File

@Serializable
data class RecordingUploadEntry(
    val localPath: String,        // 相对 captureDir/syncDir 的路径
    val deviceSn: String,
    val fileName: String,         // 设备侧相对路径（yyyyMMdd/HHmmss.aac）
    val startedAtIso: String,
    val durationSec: Int,
    val status: Status,
    val fromHistory: Boolean = false, // true=历史通道拉来的，服务端 2xx 后还要发 0x0043 删设备文件
    val serverRecordingId: Long? = null,
    val errorMessage: String? = null,
    val attempts: Int = 0,
    val updatedAtMs: Long = System.currentTimeMillis(),
) {
    enum class Status { PENDING, UPLOADING, UPLOADED, FAILED }

    /** 与服务端幂等键一致：{sn}:{file} */
    val externalId: String get() = "$deviceSn:$fileName"
}

class RecordingUploadStore(private val dir: File) {

    private val file = File(dir, "ble_recording_uploads.json")
    private val json = Json { ignoreUnknownKeys = true; prettyPrint = false }
    private val lock = Any()

    fun all(): List<RecordingUploadEntry> = synchronized(lock) {
        if (!file.exists()) return emptyList()
        runCatching {
            json.decodeFromString<List<RecordingUploadEntry>>(file.readText())
        }.getOrDefault(emptyList())
    }

    fun upsert(entry: RecordingUploadEntry) = synchronized(lock) {
        val list = all().filter { it.externalId != entry.externalId } + entry
        persist(list.takeLast(200))
    }

    /** 落盘失败不清空内存态——队列不因一次 IO 错误丢光（下次 upsert 重写）。 */
    private fun persist(list: List<RecordingUploadEntry>) {
        runCatching {
            dir.mkdirs()
            val tmp = File(dir, file.name + ".tmp")
            tmp.writeText(json.encodeToString(list))
            if (!tmp.renameTo(file)) {
                file.writeText(json.encodeToString(list))
            }
            tmp.delete()
        }
    }
}
```

> ⚠️ `json.encodeToString(list)` 需要 `import kotlinx.serialization.encodeToString`（kotlinx.serialization 版本兼容性问题：项目里 `CallUploadLogStore` 怎么编码就照抄它的写法）。

- [ ] **Step 4: 扩展 `Models.kt` 和 `ApiClient`**

`Models.kt` 追加：

```kotlin
@Serializable
data class AgentRecordingResponse(
    val success: Boolean,
    val data: AgentRecordingData,
)

@Serializable
data class AgentRecordingData(
    val id: Long,
    val uuid: String,
    val status: String,          // stored | duplicate | filtered
    val duplicate: Boolean,
    val reason: String? = null,
)
```

`ApiClient.kt` 追加方法（`postAgentCallEvent` 之后）：

```kotlin
    /**
     * POST /api/agent-recordings —— YHY02 BLE 胸牌录音上传（petav3）。
     *
     * 幂等：重复上传同一 (device_sn, file_name) 回 200 duplicate，
     * 短录音回 201 filtered —— 全是 2xx，App 收到即可删本地/设备文件。
     * 走 recordingsBaseUrl（petav3），与其余 petaV2 端点解耦。
     */
    suspend fun postAgentRecording(
        file: File,
        deviceSn: String,
        fileName: String,
        startedAtIso: String,
        durationSec: Int,
    ): Result<AgentRecordingResponse> = runCatching {
        val resp = authed { token ->
            http.post("$recordingsBaseUrl/api/agent-recordings") {
                header(HttpHeaders.Authorization, "Bearer $token")
                setBody(MultiPartFormDataContent(formData {
                    append("audio", file.readBytes(), Headers.build {
                        append(HttpHeaders.ContentType, "application/octet-stream")
                        append(
                            HttpHeaders.ContentDisposition,
                            "form-data; name=\"audio\"; filename=\"${file.name}\"",
                        )
                    })
                    append("device_sn", deviceSn)
                    append("file_name", fileName)
                    append("started_at", startedAtIso)
                    append("duration_seconds", durationSec.toString())
                }))
            }
        }
        if (!resp.status.isSuccess()) {
            throw ApiException(resp.status.value, "postAgentRecording failed: ${resp.status}")
        }
        resp.body<AgentRecordingResponse>()
    }
```

构造函数与 import 变更：

```kotlin
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import java.io.File
// 构造函数加参数：
class ApiClient private constructor(
    private val tokenStore: TokenStore,
    private val baseUrl: String,
    private val recordingsBaseUrl: String = baseUrl,
) {
```

`ApiClient.get(context)` 里构造时传 `recordingsBaseUrl = BuildConfig.PETAV3_BASE_URL.ifBlank { baseUrl }`（Step 5 加 BuildConfig 字段）。

- [ ] **Step 5: 加 BuildConfig 字段**

`app/build.gradle.kts` 的 `defaultConfig` 里，照着现有 BASE_URL 字段的写法加：

```kotlin
buildConfigField("String", "PETAV3_BASE_URL", "\"${providers.gradleProperty("petav3BaseUrl").getOrElse("https://api.propertylab.com.my")}\"")
```

（确切域名按现有 BASE_URL 的主机改；找不到 BASE_URL 定义就把两处都指向同一主机，并注释「petav3 与 petaV2 同域部署」。）

- [ ] **Step 6: 跑测试 + 编译确认**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.data.RecordingUploadStoreTest" && ./gradlew :app:compileDebugKotlin`
Expected: PASS + BUILD SUCCESSFUL。

- [ ] **Step 7: 接线——录音段封口后入队**

`Yhy02Manager.stopCapture()` 末尾追加（manager 需要持有 store，构造注入 `RecordingUploadStore`）：

```kotlin
    private fun stopCapture(elapsedSec: Int) {
        val rel = _state.value.recordingFile
        val file = captureFile
        captureFile = null
        if (rel != null && file != null && file.length() > 0) {
            uploadStore?.upsert(RecordingUploadEntry(
                localPath = rel,
                deviceSn = _state.value.deviceSn ?: "",
                fileName = rel,
                startedAtIso = Instant.ofEpochMilli(captureStartMs).toString(),
                durationSec = elapsedSec,
                status = RecordingUploadEntry.Status.PENDING,
            ))
        }
        _state.update { it.copy(recordingElapsedSec = elapsedSec) }
    }
```

- [ ] **Step 8: 上传 worker——PENDING 队列逐条推，2xx 后删除副本**

新建 `app/src/main/java/tech/propertylab/agent/badge2/RecordingUploadWorker.kt`：

```kotlin
package tech.propertylab.agent.badge2

import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import tech.propertylab.agent.api.ApiClient
import tech.propertylab.agent.data.RecordingUploadEntry
import tech.propertylab.agent.data.RecordingUploadStore

/**
 * 上传队列执行器：只认服务端 2xx——stored / duplicate / filtered 都是
 * 「这文件已妥」，之后才删本地副本、以及（历史通道拉来的）设备文件
 * 0x0043。顺序错了 = 永久丢客户对话，所以 0x0043 只能从这里发。
 */
class RecordingUploadWorker(
    private val api: ApiClient,
    private val store: RecordingUploadStore,
    private val filesRoot: File,
    private val historySync: Yhy02HistorySync,
) {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    fun processPending() {
        scope.launch {
            store.all().filter { it.status == RecordingUploadEntry.Status.PENDING }
                .forEach { entry -> uploadOne(entry) }
        }
    }

    private suspend fun uploadOne(entry: RecordingUploadEntry) {
        val file = File(filesRoot, entry.localPath)
        if (!file.exists()) {
            // 本地副本没了但设备还在：把记录标回 PENDING，交给历史同步重新拉。
            store.upsert(entry.copy(status = RecordingUploadEntry.Status.FAILED,
                errorMessage = "local file missing, re-pull from history"))
            return
        }
        store.upsert(entry.copy(status = RecordingUploadEntry.Status.UPLOADING))

        val result = api.postAgentRecording(
            file = file,
            deviceSn = entry.deviceSn,
            fileName = entry.fileName,
            startedAtIso = entry.startedAtIso,
            durationSec = entry.durationSec,
        )

        result.onSuccess {
            // 2xx = 服务端已妥（stored/duplicate/filtered），删本地；
            // 历史通道拉来的再删设备文件（0x0043）。
            file.delete()
            if (entry.fromHistory) {
                runCatching { historySync.deleteFile(entry.fileName) }
            }
            store.upsert(entry.copy(
                status = RecordingUploadEntry.Status.UPLOADED,
                serverRecordingId = it.data.id,
                errorMessage = null,
            ))
        }.onFailure { e ->
            store.upsert(entry.copy(
                status = RecordingUploadEntry.Status.FAILED,
                errorMessage = e.message,
                attempts = entry.attempts + 1,
            ))
            delay(30_000) // 退避：移动网络不稳，30s 后 processPending 重试
        }
    }
}
```

> ⚠️ 「本地副本没了但设备还在」这条路径（`local file missing`）只标 FAILED 并注明原因——自动重拉要等 Task 9 联调验证过 0x0041 的异常路径（status=6 等）再接；**第一版不自动重拉，避免坏循环**。

- [ ] **Step 9: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/api/ApiClient.kt \
        app/src/main/java/tech/propertylab/agent/api/Models.kt \
        app/src/main/java/tech/propertylab/agent/data/RecordingUploadStore.kt \
        app/src/main/java/tech/propertylab/agent/badge2/RecordingUploadWorker.kt \
        app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt \
        app/src/test/java/tech/propertylab/agent/data/RecordingUploadStoreTest.kt \
        app/build.gradle.kts
git commit -m "feat(badge2): add recording upload queue, worker and POST /api/agent-recordings"
```

---

### Task 7: 前台服务 `Yhy02SyncService`

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02SyncService.kt`
- Modify: `app/src/main/AndroidManifest.xml`

- [ ] **Step 1: 写服务**

`Yhy02SyncService.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import tech.propertylab.agent.MainActivity
import tech.propertylab.agent.R

/**
 * 前台服务：BLE 实时流在 App 退到后台时会被系统挂起，只有前台服务 +
 * connectedDevice 类型能保住连接（胸牌"边录边传"的运营前提）。
 */
class Yhy02SyncService : Service() {

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onCreate() {
        super.onCreate()
        createChannel()
        startForeground(NOTIF_ID, buildNotification("YHY02 胸牌服务运行中"))
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // Manager 是进程级单例（Yhy02Manager 由 PropertyLabApp 持有），
        // 本服务只负责保活 + 通知，重连/上传逻辑在 Manager/UploadQueue 里。
        return START_STICKY
    }

    private fun createChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val ch = NotificationChannel(
                CHANNEL_ID, "YHY02 Badge Sync", NotificationManager.IMPORTANCE_LOW,
            )
            getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
        }
    }

    private fun buildNotification(text: String): Notification {
        val pi = PendingIntent.getActivity(
            this, 0, Intent(this, MainActivity::class.java),
            PendingIntent.FLAG_IMMUTABLE,
        )
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("PropertyLab")
            .setContentText(text)
            .setContentIntent(pi)
            .setOngoing(true)
            .build()
    }

    companion object {
        const val CHANNEL_ID = "yhy02_sync"
        private const val NOTIF_ID = 4201
    }
}
```

- [ ] **Step 2: Manifest 注册 + 权限**

`AndroidManifest.xml` 追加（放在现有 `<application>` 内；权限放 `<manifest>` 下）：

```xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- application 内： -->
<service
    android:name=".badge2.Yhy02SyncService"
    android:exported="false"
    android:foregroundServiceType="connectedDevice" />
```

（Android 12 以下机型保留原有 `ACCESS_FINE_LOCATION` 权限——`BadgeScreen` 已有判断逻辑，新 UI 沿用。）

- [ ] **Step 3: 编译确认**

Run: `./gradlew :app:compileDebugKotlin`
Expected: BUILD SUCCESSFUL（若 `R.drawable.ic_launcher_foreground` 不存在，换 `R.mipmap.ic_launcher`）。

- [ ] **Step 4: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02SyncService.kt app/src/main/AndroidManifest.xml
git commit -m "feat(badge2): add foreground service to keep BLE alive in background"
```

---

### Task 8: UI `Yhy02BadgeScreen`

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/ui/Yhy02BadgeScreen.kt`
- Modify: `app/src/main/java/tech/propertylab/agent/MainActivity.kt`（加导航入口）

- [ ] **Step 1: 写屏幕（结构照抄 BadgeScreen 的权限与连接套路，状态来自 Yhy02Manager）**

`Yhy02BadgeScreen.kt` 完整内容：

```kotlin
package tech.propertylab.agent.ui

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import kotlinx.coroutines.launch
import tech.propertylab.agent.badge2.RecordingUploadWorker
import tech.propertylab.agent.badge2.Yhy02HistorySync
import tech.propertylab.agent.badge2.Yhy02Manager
import tech.propertylab.agent.badge2.Yhy02SyncService
import tech.propertylab.agent.data.RecordingUploadEntry
import tech.propertylab.agent.data.RecordingUploadStore

@Composable
fun Yhy02BadgeScreen(
    manager: Yhy02Manager,
    historySync: Yhy02HistorySync,
    uploadStore: RecordingUploadStore,
    uploadWorker: RecordingUploadWorker,
) {
    val context = LocalContext.current
    val state by manager.state.collectAsState()
    val scope = rememberCoroutineScope()

    fun requiredPermissions(): List<String> = buildList {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            add(Manifest.permission.BLUETOOTH_SCAN)
            add(Manifest.permission.BLUETOOTH_CONNECT)
        } else {
            add(Manifest.permission.ACCESS_FINE_LOCATION)
            add(Manifest.permission.BLUETOOTH)
            add(Manifest.permission.BLUETOOTH_ADMIN)
        }
    }

    suspend fun connect() {
        val adapter = context.getSystemService(BluetoothManager::class.java).adapter
        if (adapter == null || !adapter.isEnabled) {
            context.startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
            return
        }
        // 连接对象：销售自己的胸牌 SN 从 token/profile 预取由调用方注入；
        // P2 首版从扫描结果里选名字带 SN 后缀的（BadgeScreen 同款交互）。
        val badge = manager.scanAndPick(8_000)
        if (badge == null) {
            // 扫不到：manager 的 state.lastError 已带原因，UI 直接展示。
            return
        }
        manager.connectTo(badge.sn)
    }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions(),
    ) { granted ->
        if (granted.values.all { it }) scope.launch { connect() }
    }

    fun requestAndConnect() {
        val missing = requiredPermissions().filter {
            ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
        }
        if (missing.isEmpty()) scope.launch { connect() } else launcher.launch(missing.toTypedArray())
    }

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(16.dp)) {
                Text(
                    when {
                        state.connecting -> "连接中…"
                        state.connected -> "已连接 ${state.deviceName}  SN=${state.deviceSn}"
                        else -> "未连接（YHY02）"
                    },
                    style = MaterialTheme.typography.titleMedium,
                )
                if (state.deviceInfo != null) {
                    Spacer(Modifier.height(4.dp))
                    Text(
                        "固件 ${state.deviceInfo!!.firmware} · 电量 ${state.deviceInfo!!.batteryPercent}% · " +
                            "剩余 ${state.deviceInfo!!.freeKb / 1_048_576} GB",
                        style = MaterialTheme.typography.bodySmall,
                    )
                }
                Spacer(Modifier.height(12.dp))
                Row(verticalAlignment = Alignment.CenterVertically) {
                    if (state.recording) {
                        CircularProgressIndicator(
                            modifier = Modifier.width(16.dp).height(16.dp),
                            strokeWidth = 2.dp,
                        )
                        Spacer(Modifier.width(8.dp))
                        Text("录音中 ${state.recordingElapsedSec}s（实时流捕获中）")
                    } else {
                        Text("待机")
                    }
                }
                if (state.recordingFile != null) {
                    Spacer(Modifier.height(4.dp))
                    Text("本段落盘：${state.recordingFile}", style = MaterialTheme.typography.bodySmall)
                }
                Spacer(Modifier.height(12.dp))
                Row {
                    Button(
                        onClick = { requestAndConnect() },
                        enabled = !state.connecting && !state.connected,
                    ) { Text("连接") }
                    Spacer(Modifier.width(8.dp))
                    Button(
                        onClick = {
                            scope.launch {
                                manager.disconnect()
                                context.stopService(Intent(context, Yhy02SyncService::class.java))
                            }
                        },
                        enabled = state.connected,
                    ) { Text("断开") }
                }
                if (state.lastError != null) {
                    Spacer(Modifier.height(8.dp))
                    Text("⚠ ${state.lastError}", color = MaterialTheme.colorScheme.error,
                        style = MaterialTheme.typography.bodySmall)
                }
                Spacer(Modifier.height(12.dp))
                Button(
                    onClick = {
                        scope.launch {
                            // 历史同步触发（接线 Task 6 的 store + worker）：
                            // requestIndex → 只拉 sync=2 的文件 → 逐个入队
                            // （fromHistory=true）→ worker 上传 → 2xx 后 0x0043 删设备文件。
                            historySync.requestIndex()
                                .filter { it.sync == Yhy02Protocol.SYNC_ON_DEVICE }
                                .forEach { entry ->
                                    val pulled = historySync.pull(entry.relativePath)
                                    uploadStore.upsert(RecordingUploadEntry(
                                        localPath = entry.relativePath,
                                        deviceSn = state.deviceSn ?: return@forEach,
                                        fileName = entry.relativePath,
                                        startedAtIso = entryFolderToIso(entry.folder, entry.file),
                                        durationSec = entry.durationSeconds.toInt(),
                                        status = RecordingUploadEntry.Status.PENDING,
                                        fromHistory = true,
                                    ))
                                }
                            uploadWorker.processPending()
                        }
                    },
                    enabled = state.connected,
                ) { Text("同步历史并上传") }
            }
        }
    }
}

/**
 * 历史文件名 → ISO 时间：folder=yyyyMMdd、file=HHmmss.aac 是【设备时钟】
 * （每次连接都经 0x0020 校准过），按 UTC+8 拼再转 UTC。
 */
private fun entryFolderToIso(folder: String, file: String): String {
    val ts = "$folder${file.removeSuffix(".aac")}"
    return java.time.LocalDateTime.parse(
        ts,
        java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss"),
    ).atZone(java.time.ZoneOffset.ofHours(8)).toInstant().toString()
}
```

> ⚠️ `manager.scanAndPick()` 是新增小方法——在 `Yhy02Manager` 里补：
>
> ```kotlin
>     suspend fun scanAndPick(timeoutMs: Long): ScannedBadge? =
>         transport.scan(timeoutMs).firstOrNull()
> ```
> （`import tech.propertylab.agent.badge2.ScannedBadge` 与 `kotlinx.coroutines.flow.firstOrNull`。）

- [ ] **Step 2: MainActivity 加导航入口**

`MainActivity.kt`：在现有 BadgeScreen 入口旁边加 `Yhy02BadgeScreen`（导航方式照现有结构——找到 `BadgeScreen()` 的调用点，同一处加一个 `Yhy02BadgeScreen(manager)` 的 tab/入口；具体按该文件现有的导航结构办，保持风格一致）。

- [ ] **Step 3: 编译 + 真机冒烟**

Run: `./gradlew :app:assembleDebug`
Expected: BUILD SUCCESSFUL。

真机冒烟（样机 YHY02_5BA8E1）：
1. 连接 → 显示固件 1.2.4 / 电量 / 29.1GB
2. 按胸牌录音键 → UI 出现「录音中」，`cache/captures/yyyyMMdd/HHmmss.aac` 出现且 `ffprobe` 判定 AAC
3. 停止 → 文件封口、大小 ≈ 32.4kbps × 秒数

- [ ] **Step 4: 提交**

```bash
git add app/src/main/java/tech/propertylab/agent/ui/Yhy02BadgeScreen.kt \
        app/src/main/java/tech/propertylab/agent/MainActivity.kt \
        app/src/main/java/tech/propertylab/agent/badge2/Yhy02Manager.kt
git commit -m "feat(badge2): add Yhy02BadgeScreen UI with live recording state"
```

---

### Task 9: 集成验收（Fake 外设端到端）

**Files:**
- Modify: `app/src/test/java/tech/propertylab/agent/badge2/FakeBadgeTransport.kt`
- Create: `app/src/test/java/tech/propertylab/agent/badge2/Yhy02EndToEndTest.kt`

- [ ] **Step 1: 扩展 Fake 外设（按命令脚本应答）**

`FakeBadgeTransport.write()` 改为脚本应答（readResponses 缓存 Task 3 已加，这里只加脚本与序号器）：

```kotlin
    override suspend fun write(cmd: Int, payload: ByteArray) {
        sentCommands += cmd to payload
        when (cmd) {
            Yhy02Protocol.CMD_GET_INFO -> {
                val info = byteArrayOf(
                    0x02, 0x04, 0x64, 0x64,
                    0x01, 0xD1.toByte(), 0xE0.toByte(), 0x80.toByte(),
                    0x01, 0xD1.toByte(), 0xE0.toByte(), 0x00.toByte(),
                )
                readResponses += Yhy02Protocol.CHAR_FEE1 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, info, seq = nextSeq())
            }
            Yhy02Protocol.CMD_SYNC_JSON -> {
                val json = """{"total":{"record":[
                    {"folder":"20260818","file":"131505.aac","fsize":731499,"time":181,"sync":2}
                ]}}""".toByteArray()
                _notifications.emit(Yhy02Protocol.CHAR_FEE5 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(3), nextSeq()))
                _notifications.emit(Yhy02Protocol.CHAR_FEE5 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(4) + json, nextSeq()))
                _notifications.emit(Yhy02Protocol.CHAR_FEE5 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(5), nextSeq()))
            }
            Yhy02Protocol.CMD_PULL_FILE -> {
                val aac = byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x60.toByte(), 0x40.toByte()) +
                    ByteArray(100)
                _notifications.emit(Yhy02Protocol.CHAR_FEE6 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(3), nextSeq()))
                _notifications.emit(Yhy02Protocol.CHAR_FEE6 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(4) + aac, nextSeq()))
                _notifications.emit(Yhy02Protocol.CHAR_FEE6 to
                    Yhy02Protocol.buildFakeDeviceFrame(cmd, byteArrayOf(5), nextSeq()))
            }
        }
    }

    private var seqCounter = 0
    private fun nextSeq(): Int = ++seqCounter
```

- [ ] **Step 2: 写端到端测试**

`Yhy02EndToEndTest.kt` 完整内容：

```kotlin
package tech.propertylab.agent.badge2

import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File

/**
 * 用 Fake 外设模拟一次完整销售动线：
 * 连接 → 设备信息 → 历史索引 → 拉一个历史文件 → 落盘为合法 AAC。
 * （上传到真实 petav3 的步骤在 Task 6 有单测边界，这里验证文件侧。）
 */
class Yhy02EndToEndTest {

    @Test
    fun `full badge session produces an aac file ready for upload`() = runBlocking {
        val fake = FakeBadgeTransport()
        val dir = File("build/tmp/yhy02-e2e")
        dir.mkdirs()
        val mgr = Yhy02Manager(fake, captureDir = dir)
        val sync = Yhy02HistorySync(fake, syncDir = dir)

        mgr.connectTo("C8478C5BA8E1")
        val infoState = mgr.state.first { it.deviceInfo != null }
        assertEquals("2.4.100", infoState.deviceInfo!!.firmware)

        // requestIndex() 自包含（订阅 → 写 0x0040 → 等待），不再分两步。
        val entries = sync.requestIndex()
        assertEquals(1, entries.size)
        assertEquals(2, entries[0].sync)            // sync=2 仅设备 → 要同步

        val pulled = sync.pull(entries[0].relativePath)
        assertTrue(pulled.exists())
        val head = pulled.readBytes()
        assertEquals(0xFF.toByte(), head[0])        // ADTS 同步字
        assertEquals(0xF1.toByte(), head[1])
        assertNotNull(entries[0].durationSeconds)
    }
}
```

- [ ] **Step 3: 跑测试确认通过**

Run: `./gradlew :app:testDebugUnitTest --tests "tech.propertylab.agent.badge2.Yhy02EndToEndTest"`
Expected: PASS。

- [ ] **Step 4: 提交**

```bash
git add app/src/test/java/tech/propertylab/agent/badge2/FakeBadgeTransport.kt \
        app/src/test/java/tech/propertylab/agent/badge2/Yhy02EndToEndTest.kt
git commit -m "test(badge2): end-to-end fake-peripheral badge session"
```

---

### Task 10:（可延后）SoftAP + FTP 历史回补

**Files:**
- Create: `app/src/main/java/tech/propertylab/agent/badge2/Yhy02FtpSync.kt`
- Modify: `app/build.gradle.kts`（依赖）

**说明**：历史文件量大的时候 BLE（12.4 kB/s）太慢，走设备热点 FTP 快传：
`0x0030` 开热点 → 连 `YHY02_{MAC后6}` / 密码 `12345678` → FTP `192.168.1.1:21` `admin/admin` → 下 `/` 下 `yyyyMMdd/*.aac` + `/sync.json` → `0x0030` 关热点回低功耗。**FTP 实际速度 Windows 端未测，本任务放最后；上线不阻塞于它。**

- [ ] **Step 1: 加依赖**

`app/build.gradle.kts` dependencies 加：

```kotlin
implementation("commons-net:commons-net:3.11.1")
```

- [ ] **Step 2: 写 `Yhy02FtpSync`**

```kotlin
package tech.propertylab.agent.badge2

import org.apache.commons.net.ftp.FTP
import org.apache.commons.net.ftp.FTPClient
import java.io.File
import java.io.FileOutputStream

/**
 * 设备 SoftAP + FTP 历史回补。
 * 步骤：0x0030 开热点 → 连 WiFi YHY02_{MAC后6} / 12345678 →
 * FTP 192.168.1.1:21 admin/admin → 下载 → 断开热点（恢复外网）→
 * 0x0030 关热点。下载完成前手机没有外网，所以只搬运、不在此上传。
 */
class Yhy02FtpSync {

    suspend fun listFiles(): List<String> = withContextSafe {
        val ftp = FTPClient()
        ftp.connect("192.168.1.1", 21)
        ftp.login("admin", "admin")
        ftp.enterLocalPassiveMode()
        ftp.setFileType(FTP.BINARY_FILE_TYPE)
        val names = ftp.listFiles("/").mapNotNull { it.name }
        ftp.logout()
        ftp.disconnect()
        names
    }

    suspend fun download(remotePath: String, out: File): File = withContextSafe {
        val ftp = FTPClient()
        ftp.connect("192.168.1.1", 21)
        ftp.login("admin", "admin")
        ftp.enterLocalPassiveMode()
        ftp.setFileType(FTP.BINARY_FILE_TYPE)
        out.parentFile?.mkdirs()
        FileOutputStream(out).use { os -> ftp.retrieveFile(remotePath, os) }
        ftp.logout()
        ftp.disconnect()
        out
    }

    private suspend fun <T> withContextSafe(block: () -> T): T =
        kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { block() }
}
```

- [ ] **Step 3: 编译确认 + 提交**

Run: `./gradlew :app:compileDebugKotlin`
Expected: BUILD SUCCESSFUL。

```bash
git add app/src/main/java/tech/propertylab/agent/badge2/Yhy02FtpSync.kt app/build.gradle.kts
git commit -m "feat(badge2): add SoftAP+FTP history backfill"
```

---

## 完成标准（全部 Tasks 完成后）

- [ ] `./gradlew :app:testDebugUnitTest` 全绿（新 badge2 测试 + 既有测试无回归）
- [ ] `./gradlew :app:assembleDebug` 成功
- [ ] 真机（YHY02_5BA8E1）冒烟：连接 → 设备信息正确 → 按键录音 → UI 实时状态 → 落盘 AAC 可 ffprobe → 上传到 staging petav3 收到 201/200 → Manage 出现 source=BLE Badge 的行
- [ ] `./gradlew lintDebug` 无新增 error
- [ ] 回滚路径：`badge/`（FW920）未动；新代码全在 `badge2/`，revert 即回滚

## 上线注意事项（写在最后）

1. **App 退后台**：BLE 实时流只在 App 前台 + 前台服务期间可靠——销售培训里明确「带看期间 App 保持打开」；断流期间设备自己存 EMMC（29GB，够 89 天），回来走历史同步补。
2. **删除顺序**：设备文件删除（0x0043）只在服务端 2xx 之后发——顺序反了就是永久丢客户对话（doway 没有删除命令，这个坑是新的）。
3. **时区**：马来西亚 UTC+8 硬编码 `0x01`；设备时钟每次连接由 0x0020 校准，文件名日期可信。
4. **click-to-call**：配对线索仍是「销售在 CRM 里点拨号」，BLE 胸牌自己不知道在打电话——这条是产品决策，不在本计划范围（见服务端计划 P3）。
