请你先阅读/Users/admin/workspace/jyq/imgs/MCU固件问题反馈.md，然后我需要将问题反馈中的相关数值直接显示在我们的测试软件中/Users/admin/workspace/jyq/qsjnic/release/v1.0.0-beta6/qsmw-radio-demo.apk ，先不要写代码，请你先思考方案


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


The user wants me to:
1. Read a markdown file about MCU firmware issue feedback
2. Look at an APK file to understand the test software
3. Think about a solution for displaying relevant values from the issue feedback in the test software
4. NOT write code yet - just think about the approach

Let me start by reading the feedback file and examining the APK.


Let me start by reading the feedback document and examining the APK.


Bash

Check APK file exists


-rw-r--r--@ 1 admin  staff  603298 May 27 15:03 /Users/admin/workspace/jyq/qsjnic/release/v1.0.0-beta6/qsmw-radio-demo.apk


1	# MCU 固件数据上报异常反馈
2
3	**固件版本**：0302_20260527
4
5	---
6
7	## 问题概述
8
9	收音机功能中，MCU 上报的 FREQ / PS_PTY / RT 三帧数据，载荷字段全部为 0。Android 端全链路排查完毕，解析逻辑没问题，怀疑是 MCU 固件没填数据。
10
11	具体现象：
12	- 搜台正常，频率在变，但 valid 始终为 0
13	- signal 始终为 0
14	- PS（台名）和 RT（广播文本）都是空的
15
16	---
17
18	## 原始数据
19
20	直接抓的串口 body 字节，未经 Android 业务处理。
21
22	### FREQ 帧（body 7 字节）
23
24	搜台中（SEEK_FF）：
25	```
26	26 B0 00 42 00 00 00
27	26 B1 00 42 00 00 00
28	26 B2 00 42 00 00 00
29	...（连续 40+ 帧）
30	26 C4 00 42 00 00 00
31	```
32
33	搜台结束：
34	```
35	26 D8 00 40 00 00 00
36	```
37
38	Byte8-9 频率正常，Byte10 band 正常，Byte11 seekMode 正常。
39	**Byte12（valid）和 Byte14（signal）全程为 0。**
40
41	### PS_PTY 帧（body 9 字节）
42
43	```
44	00 00 00 00 00 00 00 00 00
45	```
46	9 字节全 0。
47
48	### RT 帧（body 64 字节）
49
50	```
51	00 00 00 ... 00
52	```
53	64 字节全 0。
54
55	---
56
57	## Android 端排查结果
58
59	| 检查项 | 结果 |
60	|--------|------|
61	| C 协议层 payload 偏移 | OK |
62	| JNI body 提取 | OK |
63	| Java 层转发 | OK |
64	| valid 解析 `(p[4]>>7)&1` | OK |
65	| signal 解析 `p[6]&0xFF` | OK |
66	| PS / RT 解析 | OK |
67	| AIDL 序列化 | OK |
68
69	每一层都正常，字节映射和 Radio.xlsx 一致。
70
71	---
72
73	## 需要确认
74
75	1. 固件 `0302_20260527` 是否实现了 valid / signal / PTY / PS / RT 这些字段的填充？
76	2. 搜到台后 valid 位是否有置 1 的逻辑？
77	3. signal 是否从射频模块读取并填入？
78	4. PS / RT 是否从 RDS 解码后填入？
79	5. 如为已知问题，修复版本号及发布时间？
80	6. 如协议有更新，请提供最新文档。
81
82	---
83
84	如有疑问或需要更多日志，随时联系。
85
86	Android 中间件测试组
87	2026-05-28
88

# MCU 固件数据上报异常反馈

**固件版本**：0302_20260527

---

## 问题概述

收音机功能中，MCU 上报的 FREQ / PS_PTY / RT 三帧数据，载荷字段全部为 0。Android 端全链路排查完毕，解析逻辑没问题，怀疑是 MCU 固件没填数据。

具体现象：
- 搜台正常，频率在变，但 valid 始终为 0
- signal 始终为 0
- PS（台名）和 RT（广播文本）都是空的

---

## 原始数据

直接抓的串口 body 字节，未经 Android 业务处理。

### FREQ 帧（body 7 字节）

搜台中（SEEK_FF）：
```
26 B0 00 42 00 00 00
26 B1 00 42 00 00 00
26 B2 00 42 00 00 00
...（连续 40+ 帧）
26 C4 00 42 00 00 00
```

搜台结束：
```
26 D8 00 40 00 00 00
```

Byte8-9 频率正常，Byte10 band 正常，Byte11 seekMode 正常。
**Byte12（valid）和 Byte14（signal）全程为 0。**

### PS_PTY 帧（body 9 字节）

```
00 00 00 00 00 00 00 00 00
```
9 字节全 0。

### RT 帧（body 64 字节）

```
00 00 00 ... 00
```
64 字节全 0。

---

## Android 端排查结果

| 检查项 | 结果 |
|--------|------|
| C 协议层 payload 偏移 | OK |
| JNI body 提取 | OK |
| Java 层转发 | OK |
| valid 解析 `(p[4]>>7)&1` | OK |
| signal 解析 `p[6]&0xFF` | OK |
| PS / RT 解析 | OK |
| AIDL 序列化 | OK |

每一层都正常，字节映射和 Radio.xlsx 一致。

---

## 需要确认

1. 固件 `0302_20260527` 是否实现了 valid / signal / PTY / PS / RT 这些字段的填充？
2. 搜到台后 valid 位是否有置 1 的逻辑？
3. signal 是否从射频模块读取并填入？
4. PS / RT 是否从 RDS 解码后填入？
5. 如为已知问题，修复版本号及发布时间？
6. 如协议有更新，请提供最新文档。

---

如有疑问或需要更多日志，随时联系。

Android 中间件测试组
2026-05-28


Let me understand the situation:

1. The feedback document describes MCU firmware issues where certain data fields (valid, signal, PS, RT) are always 0 or empty
2. The user wants to display these relevant values in their test APK

Let me understand the protocol structure from the feedback:
- FREQ frame (7 bytes): Byte8-9 = frequency, Byte10 = band, Byte11 = seekMode, Byte12 = valid (bit 7), Byte14 = signal
- PS_PTY frame (9 bytes): PS (station name) and PTY data
- RT frame (64 bytes): Radio Text data

Now let me look at the APK to understand the current test software. I need to decompile or at least understand the app structure. Let me check the project source code first since we're in the project directory.

Let me look at the existing source code to understand the radio-related code.


Good, I've read the feedback. Now let me examine the existing source code to understand how the radio data is currently handled.


Agent

Explore radio-related source code

Prompt:
I need to understand how the radio demo app handles and displays MCU data. Please do a thorough search:

1. Find all Java/Kotlin files related to "radio" in the project at /Users/admin/workspace/jyq/qsjnic
2. Look for files that handle: FREQ, PS_PTY, RT frames, valid, signal, frequency display
3. Find any UI layout files (XML) related to radio display
4. Find any data model classes for radio frames
5. Look at the Radio.xlsx reference if it exists in the docs folder

The project is at /Users/admin/workspace/jyq/qsjnic. Focus on the service/ and app/ directories. Report file paths, key class names, and how the data flows from MCU serial → JNI → Java → UI.


以下是无线电演示应用程序如何处理和显示 MCU 数据的完整分析。

---

## 从 MCU 串口到用户界面的数据流

### 1. 物理层：JNI/C++ 桥接 (`jni_bridge.cpp`)

**文件：** `/Users/admin/workspace/jyq/qsjnic/service/src/main/cpp/jni_bridge.cpp`

C++ 层通过串行 UART 连接 MCU。`JNI_OnLoad` 缓存了 `FrameRouter.onMcuFrame` 的 `jmethodID`。接收线程调用 `jni_notify_cb`（由 `jni_bridge.cpp:71` 注册），该函数：
1.  从帧有效载荷中剥离前 2 个字节（`msg_type` + `cmd`）。
2.  将剩余的有效载荷（`body`）复制到一个 `jbyteArray` 中。
3.  调用 `FrameRouter.onMcuFrame(frameType, msgType, cmd, body)`（`jni_bridge.cpp:108`）。

**关键规则：** 本机缓冲区仅在同步调用期间有效，因此有效载荷会立即被克隆（`FrameRouter.java:57`）。

---

### 2. 路由层：FrameRouter

**文件：** `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java`

`FrameRouter.onMcuFrame`（第 53 行）从本机 RX 线程调用。它：
- 将有效载荷字节克隆到 Java 堆中。
- 将帧发布到专用的 `HandlerThread`（`"FrameRouter"`）以进行反序列化。
- 在 `dispatch()`（第 61 行）中，它：
  - 广播到原始的 `IMcuFrameListener` 客户端（用于应用程序范围的原始帧监听器）。
  - 将帧推送到 `DomainBus`（将所有特征链接在一起的通用调度程序）。
  - 对于 `msgType == 0x05`（`MSG_TYPE_RADIO`），调用 `handleRadio()`（第 147 行），该函数记录帧的十六进制转储。

---

### 3. 领域调度：DomainBus

**文件：** `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/feature/DomainBus.java`

`DomainBus.onFrame()`（第 14 行）迭代所有已注册的特征，并对每个匹配 `msgType` 的特征调用 `f.onFrame()`。对于无线电，匹配的特征是 `RadioFeature`（它在 `MsgType.RADIO == 0x05` 时返回 `accepts() == true`）。

---

### 4. MCU 命令解码

**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java`

无线电模块定义了 5 个 M2A（MCU 到 ARM）命令和 7 个 A2M（ARM 到 MCU）命令：

| 方向 | 常量 | 值 | 含义 |
|-----------|--------|-------|---------|
| M2A | `M2A_FREQ` | `0x01` | 频率/频段/信号强度更新 |
| M2A | `M2A_PS_PTY` | `0x02` | RDS 节目服务名称 + PTY |
| M2A | `M2A_RT` | `0x03` | 无线电文本 |
| M2A | `M2A_TA_PTY_TIPS` | `0x04` | TA/PTY 搜索提示 |
| M2A | `M2A_WORK_STATUS` | `0x05` | 调谐器内部状态 |
| A2M | `SEEK` | `0x81` | 搜索模式 (stop/AMS/fwd/bwd/PTY/TA) |
| A2M | `SWITCH` | `0x82` | 切换 (TA/AF/立体声/本地/RDS/CT) |
| A2M | `SET_BAND` | `0x83` | 设置频段 (FM=0, AM=1) |
| A2M | `SET_FREQ` | `0x84` | 设置频率 |
| A2M | `FREQ_RANGE` | `0x85` | 设置搜索边界/步长 |
| A2M | `ANT_PWR` | `0x86` | 天线电源开/关 |
| A2M | `REG_MUTE` | `0x87` | 寄存器静音控制 |

---

### 5. 特征处理程序：RadioFeature

**文件：** `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java`

这是核心处理类。它维护一个单一的 `RadioState` 对象，并按命令分派：

**`handleFreq(byte[] p)` (第 70 行)** 解析 FREQ 帧 (cmd 0x01)：
```
p[0..1]  = freq (big-endian 16-bit)
p[2]     = band (bit3-0: 0=FM, 1=AM)
p[3]     = bit3-0: seekMode (0=stop, 1=AMS, 2=fwd, 3=bwd, 4=PTY, 5=TA)
           bit4:   switchTa, bit5: switchAf
p[4]     = bit0: flagStereo, bit1: flagTp, bit2: flagTa,
           bit3: afBlink, bit4: bandSwitching, bit7: valid
p[5]     = pty (0-31)
p[6]     = signal (0-255)
```

解析后，它调用 `broadcast()`（第 126 行），该函数：
1.  创建 `RadioState` 的快照副本。
2.  广播到已注册的 `IMcuRadioListener` AIDL 回调（`onFreqChanged`）。
3.  广播到 `IMcuFrameListener` 客户端（`onRadioStateChanged`）。

**`handlePsPty(byte[] p)` (第 96 行)** 通过 `Decoders.parsePsPty()` 解析 cmd 0x02，该函数提取：
- `pi`（节目标识符，当前为 0 以实现 AIDL 兼容性）
- `pty`（节目类型，0-31）
- `ps`（节目服务名称，最多 8 字节的 UTF-8 字符串，以空字符/0xFF 结尾）

**`handleRadioText(byte[] p)` (第 104 行)** 通过 `Decoders.parseRadioText()` 解析 cmd 0x03，该函数将原始字节作为 UTF-8 字符串返回（最多 64 字节）。

**`handleTaPtyTips(byte[] p)` (第 115 行)** 通过 `Decoders.parseTaPtyTips()` 解析 cmd 0x04，该函数从单个字节返回 `[mode, value]`：
- `mode`（高 4 位）：0 = TA 提示，1 = PTY 提示
- `value`（低 4 位）：0 = 清除，1 = 无结果，2 = 搜索中

**`handleWorkStatus(byte[] p)` (第 109 行)** 解析 cmd 0x05：一个字节的调谐器状态（0 = 无错误，1 = 错误）。

---

### 6. 解码器实用程序

**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java`

无线电特定的解码器方法：
- `parsePsPty(byte[])` (第 118 行) — 返回 `[pi, pty, ps]` 对象数组
- `parseRadioText(byte[])` (第 127 行) — 返回 UTF-8 字符串
- `parseWorkStatus(byte[])` (第 133 行) — 返回 int
- `parseRadioFreq(byte[])` (第 138 行) — 返回一个只有 freq/band/signal 的 `RadioFreq` POJO（这是 `RadioFeature.handleFreq` 内部更全面解析之前的原始版本）
- `parseTaPtyTips(byte[])` (第 156 行) — 返回 `[mode, value]`

---

### 7. 数据模型类

#### `RadioState`（IPC 领域模型）
**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/contract/RadioState.java`
**AIDL：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/RadioState.aidl`

`Parcelable` 类，包含 13 个字段。所有字段在未初始化时默认为 `-1`：

| 字段 | 源字节 | 含义 |
|--------|----------|---------|
| `band` | p[2] bit3-0 | 0=FM, 1=AM |
| `freq` | p[0..1] | FM: 100KHz 单位，AM: KHz 单位 |
| `signal` | p[6] | RSSI 0..255 |
| `valid` | p[4] bit7 | 调谐锁定（1=有效电台） |
| `seekMode` | p[3] bit3-0 | 0=停止, 1=AMS, 2=前进, 3=后退, 4=PTY, 5=TA |
| `pty` | p[5] | 节目类型 0-31 |
| `flagStereo` | p[4] bit0 | 立体声指示 |
| `flagTa` | p[4] bit2 | 交通公告电台 |
| `flagTp` | p[4] bit1 | 交通节目显示 |
| `switchTa` | p[3] bit4 | TA 开关状态反馈 |
| `switchAf` | p[3] bit5 | AF 开关状态反馈 |
| `afBlink` | p[4] bit3 | AF 切换闪烁 |
| `bandSwitching` | p[4] bit4 | 频段切换中 |

#### `RadioFreq`（轻量级 POJO）
**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/RadioFreq.java`

一个更简单的 3 字段 POJO（`freq`、`band`、`signal`），由 `Decoders.parseRadioFreq()` 使用，但不被 `RadioFeature` 使用（它内部执行完整的解析）。

---

### 8. AIDL IPC 层

#### 服务接口
**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl`

定义了无线电相关的 IPC 方法：
- `registerRadioListener(IMcuRadioListener)` / `unregisterRadioListener(...)` — 订阅无线电事件。
- `getLatestRadioState()` — 拉取缓存的 `RadioState`。
- `setBand(int)`、`setFrequency(int)`、`setFreqRange(...)`、`setSeek(int,int)`、`setSwitch(int,int)`、`setAntPower(boolean)`、`setRegMute(boolean)`、`setBandWithFreq(int,int)` — 所有 A2M 命令。

#### 监听器接口
**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuRadioListener.aidl`

`oneway` 接口（发射后不管）包含 5 个回调方法，与 `RadioListener` SDK 接口匹配：
- `onFreqChanged(RadioState state)` — cmd 0x01
- `onPsPty(int pi, int pty, String ps)` — cmd 0x02
- `onRadioText(String rt)` — cmd 0x03
- `onTaPtyTips(int mode, int value)` — cmd 0x04
- `onWorkStatus(int status)` — cmd 0x05

#### 框架监听器（原始帧访问）
**文件：** `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuFrameListener.aidl`

提供一个低级 `onMcuFrame(int frameType, int msgType, int cmd, byte[] body)` 回调，以及解码后的 `onRadioStateChanged(RadioState)` 以进行便捷监听。

---

### 9. Service Binder：McuBinder

**文件：** `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java`

实现 `IMcuService.Stub`。无线电方法（第 144-219 行）委托给：
- `RadioFeature.register/unregister` — 管理 AIDL 监听器列表。
- `RadioFeature.snapshot()` — 获取缓存的 `RadioState`。
- `Commands.Radio.setBand/frequency/...` — 构建 A2M 命令有效载荷。
- `McuLink.get().sendAndWaitAck(MsgType.RADIO, cmd, payload, 1500)` — 通过 JNI 发送帧并等待 1500 毫秒内的 ACK。

---

### 10. SDK 客户端层（面向应用程序的 API）

#### `McuClient`
**文件：** `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java`

入口点。单例将 Binder 绑定到远程服务。在 `McuRadioClient` 中公开 `radio()` 外观。

#### `McuRadioClient`
**文件：** `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java`

所有无线电操作的应用程序端外观。关键点：
- 为类型化 Java 监听器维护 `CopyOnWriteArrayList<RadioListener>`。
- 内部创建一个 `IMcuRadioListener.Stub` AIDL 监听器（第 45 行），将每个 AIDL 回调分派给所有已注册的 Java `RadioListener` 实例。
- 调用 `McuClient.getServiceUnchecked()` 以通过 Binder 获取 `IMcuService` 代理。

常量定义：
- `BAND_FM = 0`、`BAND_AM = 1`
- `SEEK_STOP = 0`、`SEEK_AMS = 1`、`SEEK_FORWARD = 2`、`SEEK_BACKWARD = 3`、`SEEK_PTY = 4`、`SEEK_TA = 5`
- `SWITCH_TA = 0`、`SWITCH_AF = 1`、`SWITCH_STEREO = 2`、`SWITCH_LOCAL = 3`、`SWITCH_RDS = 4`、`SWITCH_CT = 5`

#### `RadioListener`
**文件：** `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/RadioListener.java`

应用程序端监听器接口，所有方法都有空默认值：
- `onFreqChanged(RadioState state)`
- `onPsPty(int pi, int pty, String ps)`
- `onRadioText(String rt)`
- `onTaPtyTips(int mode, int value)`
- `onWorkStatus(int status)`

---

### 11. 演示应用程序 UI（纯代码，无 XML 布局）

**文件：** `/Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/java/com/qsjnic/demo/radio/MainActivity.java`
**清单：** `/Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/AndroidManifest.xml`

演示完全以编程方式构建其 UI — 没有 XML 布局文件。组件如下：

#### 数据面板 UI
在 `onCreate()` 中（第 72-189 行）以编程方式构建：
- **`tvBand`** — 显示 "FM Radio" 或 "AM Radio"（`updateRadio()` 第 407 行）
- **`tvFreq`** — 大字（56sp）频率读数。FM 除以 100（例如 `8750 -> 87.5`），AM 除以 1000（`updateRadio()` 第 409 行）
- **`tvFreqMhz`** — 单位标签："MHz" 用于 FM，"KHz" 用于 AM
- **`tvSignal`** — 风格化芯片显示信号强度（例如 `Signal: 55`）
- **`tvStereo`** — 显示 "Stereo"（绿色）或 "Mono"（灰色），基于 `flagStereo`
- **`tvSeek`** — 显示："Stop"、"AMS"、"Fwd"、"Bwd"、"PTY" 或 "TA"
- **`tvAudioStatus`** — FM 调谐器音频直通状态

#### 数据如何流经 `updateRadio(RadioState s)`（第 405 行）
此方法在 `onFreqChanged` 回调中调用：
1.  `s.band` 映射到 "FM"/"AM"。
2.  `s.freq` 转换为 `double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0`，格式化为 `%.1f`。
3.  `s.signal` 显示为整数（0-255）。
4.  `s.flagStereo` 决定 "Stereo" 与 "Mono" 及颜色。
5.  `s.seekMode` 通过字符串数组映射到人类可读名称。
6.  它还会跟踪最强信号（第 421 行），以记录信号峰值。

#### 控制按钮
- **Band:** `setBand(BAND_FM)` / `setBand(BAND_AM)`
- **Frequency:** `setFrequency(8750)` 或 `setFrequency(9000)`
- **Seek:** `seekForward()` / `seekStop()`
- **Audio:** 启用 FM 调谐器音频直通（使用 `AudioRecord`，源为 `TYPE_FM_TUNER`，通过 `AudioDeviceInfo` 检测）
- **Other:** `setAntPower(true)`、`setFreqRange(...)`、`setSwitch(SWITCH_STEREO, 1)`、`setSwitch(SWITCH_RDS, 1)`、`setRegMute(false)`

#### 生命周期
- `onCreate`：初始化 `McuClient`、连接服务、添加 `RadioListener`、启动 FM 搜索 (93.4 MHz)、延迟启动音频直通。
- `onDestroy`：停止音频、移除监听器。
- `onResume`：如果服务未连接，则每 5 秒轮询重试一次。

---

### 12. 协议参考

**文件：** `/Users/admin/workspace/jyq/qsjnic/docs/MCU_ARM_Reference/20260513/Radio.xlsx` 和 `/Users/admin/workspace/jyq/qsjnic/release/mcu_full_test_for_vendor/protocol_reference/Radio.xlsx`

此外，纯文本协议摘要可在以下位置找到：
**文件：** `/Users/admin/workspace/jyq/qsjnic/docs/radio_strings.txt`

`radio_strings.txt` 文档精确记录了 `Radio.xlsx` 中包含的内容：5 个 M2A 命令和 7 个 A2M 命令的完整字节级布局，包括每个字节内的位字段分解。`RadioFeature.handleFreq()` 中 `RadioState` 的解析与该协议参考完全匹配。

---

### 13. 来自 `SDK_radio补充需求.md` 的先前差距修复

**文件：** `/Users/admin/workspace/jyq/qsjnic/docs/SDK_radio补充需求.md`

此文件记录了早期 SDK 版本中需要但丢失的 `RadioState` 字段：
- **添加的字段：** `valid`、`seekMode`、`pty`、`flagStereo`、`flagTa`、`flagTp`、`switchTa`、`switchAf`、`afBlink`、`bandSwitching` — 现在全部出现在 `RadioState.java` 中。
- **添加的回调：** `onTaPtyTips` — 现在出现在 `IMcuRadioListener.aidl`、`RadioListener.java`（SDK 层）和 `RadioFeature.java`（处理 cmd 0x04）中。
- **添加的搜索模式：** `SEEK_AMS`、`SEEK_PTY`、`SEEK_TA` — 现在全部在 `McuRadioClient.java` 中定义。
- **完成的 setSwitch 类型：** `SWITCH_TA` 到 `SWITCH_CT` — 全部在 `McuRadioClient.java` 中枚举。

---

### 完整的端到端数据流摘要

```
MCU 串行 UART (/dev/ttyS7 @ 460800 baud)
  |
  |  [原始字节：0xFF 0xAA ... 帧类型 0x06 ... 模块 0x05 ... cmd 0x01 ... 有效载荷]
  |
  v
C++ mcu_transport 层（mcu_protocol.h / mcu_transport.h）
  |  RX 线程 → 帧解析器 → jni_notify_cb()
  |  (从有效载荷中剥离前 2 字节：msg_type + cmd)
  v
jni_bridge.cpp:108 — env->CallStaticVoidMethod(FrameRouter.onMcuFrame, frameType, msgType, cmd, body)
  |
  v
FrameRouter.java:53 — onMcuFrame() [在本机 RX 线程上调用]
  |  — 克隆有效载荷字节
  |  — 发布到 FrameRouter 处理程序线程
  v
FrameRouter.java:61 — dispatch()
  |  — broadcastRawFrame() 到原始帧监听器
  |  — DomainBus.onFrame() → 迭代特征
  |  — handleRadio() [日志记录]
  v
RadioFeature.java:33 — onFrame() [由 DomainBus 调用]
  |  对 cmd 0x01..0x05 进行 switch 处理
  |  — 解析字节 → 更新 RadioState 字段
  |  — 广播到 typedListeners (AIDL) + frameListeners
  v
AIDL IPC（跨进程，通过 Binder）
  |  IMcuRadioListener.onFreqChanged(parcelable RadioState)
  |  IMcuRadioListener.onPsPty(pi, pty, ps)
  |  IMcuRadioListener.onRadioText(rt)
  |  IMcuRadioListener.onTaPtyTips(mode, value)
  |  IMcuRadioListener.onWorkStatus(status)
  v
McuRadioClient.java (SDK 层，客户端进程)
  |  AIDL 监听器存根 → 分派到 CopyOnWriteArrayList<RadioListener>
  v
RadioListener 接口
  |  onFreqChanged(RadioState state)
  |  onPsPty(pi, pty, ps)
  |  onRadioText(rt)
  v
MainActivity.java (演示应用程序 UI)
  |  updateRadio(RadioState s)
  |  — 将 freq/100 (FM) 或 freq/1000 (AM) 格式化为字符串
  |  — 设置 TextView 文本/颜色
  |  — 在 tvBand / tvFreq / tvFreqMhz / tvSignal / tvStereo / tvSeek 上显示
  |  onPsPty → log.i
  |  onRadioText → log.i
```

### 所有无线电相关文件的完整列表（排除构建/缓存目录）

**C++ 本机层：**
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/cpp/jni_bridge.cpp` — JNI 桥接（帧回调、OTA 进度）
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/cpp/mcu_protocol.h` — 协议原语（帧构建/解析、协议状态机）

**AIDL 接口：**
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl` — 包含无线电 A2M 命令
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuRadioListener.aidl` — 无线电推送回调接口
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/RadioState.aidl` — RadioState 的 Parcelable 声明
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuFrameListener.aidl` — 包含 onRadioStateChanged

**领域/合约 Java：**
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/contract/RadioState.java` — 主要的 13 字段数据模型
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/RadioFreq.java` — 精简的 3 字段 POJO
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java` — 命令常量（无线电 0x81-0x87, 0x01-0x05）
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java` — 模块 ID（无线电 = 0x05）
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java` — 帧反序列化器（parsePsPty、parseRadioText 等）
- `/Users/admin/workspace/jyq/qsjnic/contract/src/main/java/com/qsjnic/mcu/domain/Commands.java` — 命令有效载荷构建器（Commands.Radio.*）

**服务层：**
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/QsMcuSystemService.java` — 系统服务（初始化 + 串行重试 + 总线设置）
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/McuLink.java` — 本机链接外观（JNI 声明、TX、升级）
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java` — 帧路由器（本机 → Java 分派）
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java` — Binder 实现（将 IPC 调用路由到特征 + McuLink）
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/feature/Feature.java` — 特征接口
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/feature/DomainBus.java` — 按消息类型的特征调度程序
- `/Users/admin/workspace/jyq/qsjnic/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java` — **核心无线电处理程序**（解析 + 广播）
- `/Users/admin/workspace/jyq/qsjnic/service/src/test/java/com/qsjnic/mcu/service/feature/RadioFeatureTest.java` — 单元测试

**SDK 层：**
- `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java` — 单例 SDK 入口点
- `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java` — 无线电域外观（Java 监听器注册 + 命令封装）
- `/Users/admin/workspace/jyq/qsjnic/sdk/src/main/java/com/qsjnic/mcu/sdk/RadioListener.java` — 应用程序端监听器接口

**演示应用程序：**
- `/Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/java/com/qsjnic/demo/radio/MainActivity.java` — 演示 UI（纯编程式布局，无 XML）
- `/Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/AndroidManifest.xml` — 清单（MCU_ACCESS 权限 + 服务查询）

**文档：**
- `/Users/admin/workspace/jyq/qsjnic/docs/MCU_ARM_Reference/20260513/Radio.xlsx` — 协议参考
- `/Users/admin/workspace/jyq/qsjnic/docs/radio_strings.txt` — 纯文本协议文档（与 xlsx 相同的内容）
- `/Users/admin/workspace/jyq/qsjnic/docs/SDK_radio补充需求.md` — 先前的差距分析（最终版本中已解决）
- `/Users/admin/workspace/jyq/qsjnic/docs/demos/radio/radio_demo_beta3.png` — 演示 UI 截图

Prompt:
I need to understand how the radio demo app handles and displays MCU data. Please do a thorough search:

1. Find all Java/Kotlin files related to "radio" in the project at /Users/admin/workspace/jyq/qsjnic
2. Look for files that handle: FREQ, PS_PTY, RT frames, valid, signal, frequency display
3. Find any UI layout files (XML) related to radio display
4. Find any data model classes for radio frames
5. Look at the Radio.xlsx reference if it exists in the docs folder

The project is at /Users/admin/workspace/jyq/qsjnic. Focus on the service/ and app/ directories. Report file paths, key class names, and how the data flows from MCU serial → JNI → Java → UI.


Now I have a comprehensive understanding of the system. Let me analyze what the feedback document says and what the current demo app displays:

**Feedback document issues:**
1. FREQ frame: Byte12 (valid, bit7) and Byte14 (signal) are always 0
2. PS_PTY frame: All 9 bytes are 0 (no PS station name, no PTY)
3. RT frame: All 64 bytes are 0 (no Radio Text)

**Current demo app (MainActivity.java) already displays:**
- `tvFreq` - Frequency (FM/AM)
- `tvBand` - "FM Radio" / "AM Radio"
- `tvSignal` - Signal strength (from `s.signal`)
- `tvStereo` - Stereo/Mono indicator (from `s.flagStereo`)
- `tvSeek` - Seek mode
- `tvAudioStatus` - Audio passthrough status

**What's in RadioState but NOT displayed in the demo:**
- `valid` - The valid bit (critical for the issue!)
- `pty` - Program Type
- `flagTa` - Traffic Announcement
- `flagTp` - Traffic Program
- `switchTa` - TA switch status
- `switchAf` - AF switch status
- `afBlink` - AF blink
- `bandSwitching` - Band switching indicator

**What's received via callbacks but NOT displayed in the demo:**
- `onPsPty(pi, pty, ps)` - PS station name is only logged, not displayed
- `onRadioText(rt)` - Radio Text is only logged, not displayed
- `onTaPtyTips(mode, value)` - Only logged
- `onWorkStatus(status)` - Only logged

So the user wants to add display of these values that are relevant to the firmware issue:
1. **valid** - This is the most critical one, as it indicates whether a station is locked
2. **signal** - Already displayed but confirmed as 0
3. **PS (Program Service name)** - Currently only logged, need to display
4. **RT (Radio Text)** - Currently only logged, need to display
5. **PTY (Program Type)** - Currently in RadioState but not displayed
6. Maybe also raw hex data display would be useful for debugging

Now let me think about the approach:

The APK is already built. The user wants to see these values IN the test software. I need to:

1. Read the current MainActivity.java to understand the exact UI layout
2. Plan what UI changes are needed to display the additional fields

Let me read the MainActivity to understand the current UI layout and callback handling.


Excellent analysis. Now let me read the demo app's MainActivity to understand exactly what's currently displayed and how.


1	package com.qsjnic.demo.radio;
2
3	import android.app.Activity;
4	import android.content.pm.PackageManager;
5	import android.graphics.Color;
6	import android.graphics.Typeface;
7	import android.graphics.drawable.GradientDrawable;
8	import android.media.AudioAttributes;
9	import android.media.AudioDeviceInfo;
10	import android.media.AudioFormat;
11	import android.media.AudioManager;
12	import android.media.AudioRecord;
13	import android.media.AudioTrack;
14	import android.media.MediaRecorder;
15	import android.os.Build;
16	import android.os.Bundle;
17	import android.os.Handler;
18	import android.os.Looper;
19	import android.util.Log;
20	import android.view.Gravity;
21	import android.view.View;
22	import android.widget.Button;
23	import android.widget.LinearLayout;
24	import android.widget.ScrollView;
25	import android.widget.TextView;
26
27	import com.qsjnic.mcu.contract.RadioState;
28	import com.qsjnic.mcu.sdk.McuClient;
29	import com.qsjnic.mcu.sdk.McuRadioClient;
30	import com.qsjnic.mcu.sdk.RadioListener;
31
32	/**
33	 * Radio Demo with FM Tuner audio passthrough.
34	 */
35	public class MainActivity extends Activity {
36
37	    private static final String TAG = "RadioDemo";
38	    private static final int PERMISSION_REQ = 100;
39
40	    private final Handler ui = new Handler(Looper.getMainLooper());
41
42	    // Data panel
43	    private TextView tvBand, tvFreq, tvFreqMhz, tvSignal, tvStereo, tvSeek, tvAudioStatus;
44
45	    // Control buttons
46	    private TextView tvStatus;
47	    private Button btnFm, btnAm, btn875, btn900, btnSeekFw, btnSeekStop, btnPlay875;
48	    private Button btnAudioStart, btnAudioStop;
49
50	    private final RadioListener listener = new RadioListener() {
51	        @Override public void onFreqChanged(RadioState s) {
52	            ui.post(() -> updateRadio(s));
53	        }
54	        @Override public void onPsPty(int pi, int pty, String ps) {
55	            Log.i(TAG, "PS/PTY pi=" + pi + " pty=" + pty + " ps=" + ps);
56	        }
57	        @Override public void onRadioText(String rt) {
58	            Log.i(TAG, "RT=" + rt);
59	        }
60	        @Override public void onWorkStatus(int status) {
61	            Log.i(TAG, "WorkStatus=" + status);
62	        }
63	    };
64
65	    // ----- Audio passthrough -----
66	    private AudioRecord audioRecord;
67	    private AudioTrack audioTrack;
68	    private Thread audioThread;
69	    private volatile boolean audioRunning = false;
70
71	    @Override
72	    protected void onCreate(Bundle savedInstanceState) {
73	        super.onCreate(savedInstanceState);
74
75	        ScrollView scroll = new ScrollView(this);
76	        LinearLayout root = new LinearLayout(this);
77	        root.setOrientation(LinearLayout.VERTICAL);
78	        root.setPadding(32, 32, 32, 32);
79	        root.setBackgroundColor(Color.parseColor("#F5F5F5"));
80
81	        // ===== Title =====
82	        TextView title = new TextView(this);
83	        title.setText("Radio Tuner + Audio");
84	        title.setTextSize(24f);
85	        title.setTypeface(null, Typeface.BOLD);
86	        title.setTextColor(Color.parseColor("#333333"));
87	        title.setPadding(0, 0, 0, 16);
88	        root.addView(title);
89
90	        tvStatus = new TextView(this);
91	        tvStatus.setText("Service: connecting");
92	        tvStatus.setTextSize(14f);
93	        tvStatus.setTextColor(Color.parseColor("#555555"));
94	        tvStatus.setPadding(0, 0, 0, 16);
95	        root.addView(tvStatus);
96
97	        // ===== Frequency Display Card =====
98	        LinearLayout freqCard = card(root);
99	        tvBand = new TextView(this);
100	        tvBand.setText("--");
101	        tvBand.setTextSize(16f);
102	        tvBand.setTextColor(Color.parseColor("#888888"));
103	        tvBand.setGravity(Gravity.CENTER);
104	        freqCard.addView(tvBand);
105
106	        tvFreq = new TextView(this);
107	        tvFreq.setText("----");
108	        tvFreq.setTextSize(56f);
109	        tvFreq.setTypeface(null, Typeface.BOLD);
110	        tvFreq.setTextColor(Color.parseColor("#1976D2"));
111	        tvFreq.setGravity(Gravity.CENTER);
112	        freqCard.addView(tvFreq);
113
114	        tvFreqMhz = new TextView(this);
115	        tvFreqMhz.setText("MHz");
116	        tvFreqMhz.setTextSize(14f);
117	        tvFreqMhz.setTextColor(Color.parseColor("#888888"));
118	        tvFreqMhz.setGravity(Gravity.CENTER);
119	        freqCard.addView(tvFreqMhz);
120
121	        LinearLayout detailRow = new LinearLayout(this);
122	        detailRow.setOrientation(LinearLayout.HORIZONTAL);
123	        detailRow.setGravity(Gravity.CENTER);
124	        detailRow.setPadding(0, 16, 0, 0);
125	        freqCard.addView(detailRow);
126
127	        tvSignal = chip(detailRow, "Signal: --");
128	        tvStereo = chip(detailRow, "Stereo: --");
129	        tvSeek   = chip(detailRow, "Seek: --");
130
131	        // ===== Audio Status =====
132	        root.addView(sectionTitle("Tuner Audio"));
133	        LinearLayout audioCard = card(root);
134	        tvAudioStatus = new TextView(this);
135	        tvAudioStatus.setText("Audio: stopped");
136	        tvAudioStatus.setTextSize(14f);
137	        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
138	        tvAudioStatus.setPadding(0, 8, 0, 8);
139	        audioCard.addView(tvAudioStatus);
140
141	        LinearLayout audioRow = buttonRow(audioCard);
142	        btnAudioStart = btn(audioRow, "Start Audio", v -> startTunerAudio());
143	        btnAudioStop  = btn(audioRow, "Stop Audio",  v -> stopTunerAudio());
144
145	        // ===== Band Selection =====
146	        root.addView(sectionTitle("Band"));
147	        LinearLayout bandCard = card(root);
148	        LinearLayout bandRow = buttonRow(bandCard);
149	        btnFm = btn(bandRow, "FM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_FM));
150	        btnAm = btn(bandRow, "AM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_AM));
151
152	        // ===== Frequency Presets =====
153	        root.addView(sectionTitle("Frequency"));
154	        LinearLayout freqCtrlCard = card(root);
155	        LinearLayout freqRow1 = buttonRow(freqCtrlCard);
156	        btn875 = btn(freqRow1, "87.5 MHz", v -> McuClient.getInstance().radio().setFrequency(8750));
157	        btn900 = btn(freqRow1, "90.0 MHz", v -> McuClient.getInstance().radio().setFrequency(9000));
158
159	        // ===== Seek Control =====
160	        root.addView(sectionTitle("Seek"));
161	        LinearLayout seekCard = card(root);
162	        LinearLayout seekRow = buttonRow(seekCard);
163	        btnSeekFw   = btn(seekRow, "Forward",  v -> McuClient.getInstance().radio().seekForward());
164	        btnSeekStop = btn(seekRow, "Stop",     v -> McuClient.getInstance().radio().seekStop());
165
166	        // ===== Other =====
167	        root.addView(sectionTitle("Other"));
168	        LinearLayout otherCard = card(root);
169	        LinearLayout otherRow = buttonRow(otherCard);
170	        btnPlay875 = btn(otherRow, "Play 90.0", v -> playFm900());
171	        btn(otherRow, "Ant ON",  v -> McuClient.getInstance().radio().setAntPower(true));
172	        btn(otherRow, "Init Range", v -> McuClient.getInstance().radio().setFreqRange(0, 522, 1670, 8750, 10800, 9, 1));
173
174	        scroll.addView(root);
175	        setContentView(scroll);
176
177	        McuClient.getInstance().init(getApplicationContext());
178	        McuClient.getInstance().addReadyListener(() -> ui.post(() -> {
179	            tvStatus.setText("Service: connected, linkAlive=" + McuClient.getInstance().isLinkAlive());
180	            playFm900();
181	            ui.postDelayed(this::startTunerAudio, 1000);
182	        }));
183	        McuClient.getInstance().radio().addListener(listener);
184
185	        // Request audio permission
186	        if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
187	            requestPermissions(new String[]{android.Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ);
188	        }
189	    }
190
191	    // ---------- Audio Passthrough ----------
192
193	    private void startTunerAudio() {
194	        if (audioRunning) return;
195
196	        AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
197	        AudioDeviceInfo tunerDevice = null;
198	        for (AudioDeviceInfo dev : am.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
199	            Log.i(TAG, "Input device: type=" + dev.getType() + " name=" + dev.getProductName());
200	            if (dev.getType() == AudioDeviceInfo.TYPE_FM_TUNER) {
201	                tunerDevice = dev;
202	                break;
203	            }
204	        }
205
206	        if (tunerDevice == null) {
207	            tvAudioStatus.setText("Audio: FM Tuner device NOT FOUND");
208	            Log.e(TAG, "TYPE_FM_TUNER not found in input devices");
209	            return;
210	        }
211
212	        tvAudioStatus.setText("Audio: starting...");
213	        Log.i(TAG, "Found FM Tuner: " + tunerDevice.getProductName());
214
215	        final int sampleRate = 48000;
216	        final int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
217	        final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
218	        final int minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
219	        final int bufferSize = Math.max(minBufSize, 16384);
220	        // AUDIO_SOURCE_FM_TUNER = 1998 (from system/audio.h)
221	        final int AUDIO_SOURCE_FM_TUNER = 1998;
222
223	        try {
224	            // Use DEFAULT source + setPreferredDevice(tuner) approach
225	            audioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
226	                    sampleRate, channelConfig, audioFormat, bufferSize);
227
228	            // Try to set device on API 24+
229	            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && audioRecord.setPreferredDevice(tunerDevice)) {
230	                Log.i(TAG, "setPreferredDevice(tuner) succeeded");
231	            }
232
233	            audioTrack = new AudioTrack.Builder()
234	                    .setAudioAttributes(new AudioAttributes.Builder()
235	                            .setUsage(AudioAttributes.USAGE_MEDIA)
236	                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
237	                            .build())
238	                    .setAudioFormat(new AudioFormat.Builder()
239	                            .setSampleRate(sampleRate)
240	                            .setEncoding(audioFormat)
241	                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
242	                            .build())
243	                    .setBufferSizeInBytes(bufferSize)
244	                    .setTransferMode(AudioTrack.MODE_STREAM)
245	                    .build();
246
247	            audioRecord.startRecording();
248	            audioTrack.play();
249	            audioRunning = true;
250
251	            final int playbackBufSize = bufferSize;
252	            audioThread = new Thread(() -> {
253	                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
254	                byte[] buf = new byte[playbackBufSize];
255	                int totalRead = 0;
256	                long lastLog = System.currentTimeMillis();
257	                while (audioRunning) {
258	                    int read = audioRecord.read(buf, 0, buf.length);
259	                    if (read > 0) {
260	                        totalRead += read;
261	                        audioTrack.write(buf, 0, read);
262	                        // Log first non-zero sample every 3 seconds
263	                        long now = System.currentTimeMillis();
264	                        if (now - lastLog > 3000) {
265	                            short sample = (short) ((buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8));
266	                            Log.i(TAG, "Audio passthrough: read=" + read
267	                                    + " total=" + totalRead
268	                                    + " sample=" + sample
269	                                    + " trackState=" + audioTrack.getPlayState());
270	                            lastLog = now;
271	                        }
272	                    } else if (read < 0) {
273	                        Log.e(TAG, "AudioRecord read error: " + read);
274	                        break;
275	                    }
276	                }
277	            }, "TunerAudio");
278	            audioThread.setPriority(Thread.MAX_PRIORITY);
279	            audioThread.start();
280
281	            tvAudioStatus.setText("Audio: PLAYING (" + tunerDevice.getProductName() + ")");
282	            tvAudioStatus.setTextColor(Color.parseColor("#4CAF50"));
283
284	        } catch (Exception e) {
285	            Log.e(TAG, "startTunerAudio failed", e);
286	            tvAudioStatus.setText("Audio: FAILED - " + e.getMessage());
287	            tvAudioStatus.setTextColor(Color.parseColor("#F44336"));
288	            stopTunerAudio();
289	        }
290	    }
291
292	    private void stopTunerAudio() {
293	        audioRunning = false;
294	        if (audioThread != null) {
295	            try { audioThread.join(500); } catch (InterruptedException ignored) {}
296	            audioThread = null;
297	        }
298	        if (audioRecord != null) {
299	            try { audioRecord.stop(); } catch (Exception ignored) {}
300	            try { audioRecord.release(); } catch (Exception ignored) {}
301	            audioRecord = null;
302	        }
303	        if (audioTrack != null) {
304	            try { audioTrack.stop(); } catch (Exception ignored) {}
305	            try { audioTrack.release(); } catch (Exception ignored) {}
306	            audioTrack = null;
307	        }
308	        tvAudioStatus.setText("Audio: stopped");
309	        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
310	        Log.i(TAG, "Tuner audio stopped");
311	    }
312
313	    // ---------- UI helpers ----------
314
315	    private TextView sectionTitle(String text) {
316	        TextView t = new TextView(this);
317	        t.setText(text);
318	        t.setTextSize(14f);
319	        t.setTypeface(null, Typeface.BOLD);
320	        t.setTextColor(Color.parseColor("#666666"));
321	        t.setPadding(8, 24, 0, 8);
322	        return t;
323	    }
324
325	    private LinearLayout card(LinearLayout parent) {
326	        LinearLayout card = new LinearLayout(this);
327	        card.setOrientation(LinearLayout.VERTICAL);
328	        card.setPadding(24, 24, 24, 24);
329	        card.setBackgroundColor(Color.WHITE);
330	        GradientDrawable bg = new GradientDrawable();
331	        bg.setColor(Color.WHITE);
332	        bg.setCornerRadius(16f);
333	        card.setBackground(bg);
334	        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
335	                LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
336	        lp.setMargins(0, 0, 0, 16);
337	        card.setLayoutParams(lp);
338	        parent.addView(card);
339	        return card;
340	    }
341
342	    private TextView chip(LinearLayout parent, String text) {
343	        TextView t = new TextView(this);
344	        t.setText(text);
345	        t.setTextSize(12f);
346	        t.setTextColor(Color.parseColor("#555555"));
347	        t.setPadding(16, 8, 16, 8);
348	        GradientDrawable bg = new GradientDrawable();
349	        bg.setColor(Color.parseColor("#E8E8E8"));
350	        bg.setCornerRadius(20f);
351	        t.setBackground(bg);
352	        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
353	                LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
354	        lp.setMargins(8, 0, 8, 0);
355	        t.setLayoutParams(lp);
356	        parent.addView(t);
357	        return t;
358	    }
359
360	    private LinearLayout buttonRow(LinearLayout parent) {
361	        LinearLayout row = new LinearLayout(this);
362	        row.setOrientation(LinearLayout.HORIZONTAL);
363	        row.setPadding(0, 8, 0, 8);
364	        parent.addView(row);
365	        return row;
366	    }
367
368	    private Button btn(LinearLayout parent, String label, View.OnClickListener l) {
369	        Button b = new Button(this);
370	        b.setText(label);
371	        b.setOnClickListener(l);
372	        b.setAllCaps(false);
373	        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
374	        lp.setMargins(8, 0, 8, 0);
375	        b.setLayoutParams(lp);
376	        parent.addView(b);
377	        return b;
378	    }
379
380	    private void playFm900() {
381	        McuRadioClient radio = McuClient.getInstance().radio();
382	        int rcBand = radio.setBand(McuRadioClient.BAND_FM);
383	        int rcRange = radio.setFreqRange(0, 522, 1670, 8750, 10800, 9, 1);
384	        int rcAnt = radio.setAntPower(true);
385	        int rcStereo = radio.setSwitch(McuRadioClient.SWITCH_STEREO, 1);
386	        int rcRds = radio.setSwitch(McuRadioClient.SWITCH_RDS, 1);
387	        int rcMute = radio.setRegMute(false);  // 先解除静音
388	        int rcFreq = radio.setFrequency(9344);
389	        int rcStop = radio.seekStop();
390	        String msg = "Play 93.4 rc band=" + rcBand
391	                + " range=" + rcRange
392	                + " ant=" + rcAnt
393	                + " stereo=" + rcStereo
394	                + " rds=" + rcRds
395	                + " freq=" + rcFreq
396	                + " stop=" + rcStop
397	                + " muteOff=" + rcMute;
398	        Log.i(TAG, msg);
399	        tvStatus.setText(msg);
400	    }
401
402	    private int lastSignal = 0;
403	    private int strongFreq = 0;
404
405	    private void updateRadio(RadioState s) {
406	        String bandName = s.band == 0 ? "FM" : (s.band == 1 ? "AM" : "?");
407	        tvBand.setText(bandName + " Radio");
408
409	        double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0;
410	        tvFreq.setText(String.format("%.1f", mhz));
411	        tvFreqMhz.setText(s.band == 0 ? "MHz" : "KHz");
412
413	        tvSignal.setText("Signal: " + (s.signal >= 0 ? s.signal : "--"));
414	        tvStereo.setText(s.flagStereo == 1 ? "Stereo" : "Mono");
415	        tvStereo.setTextColor(s.flagStereo == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));
416
417	        String[] seekNames = {"Stop", "AMS", "Fwd", "Bwd", "PTY", "TA"};
418	        tvSeek.setText("Seek: " + (s.seekMode >= 0 && s.seekMode < seekNames.length ? seekNames[s.seekMode] : "--"));
419
420	        // Track strongest signal
421	        if (s.signal > lastSignal) {
422	            lastSignal = s.signal;
423	            strongFreq = s.freq;
424	            Log.i(TAG, "*** STRONG SIGNAL: " + s.freq + " signal=" + s.signal + " ***");
425	        }
426	    }
427
428	    @Override
429	    protected void onDestroy() {
430	        stopTunerAudio();
431	        McuClient.getInstance().radio().removeListener(listener);
432	        super.onDestroy();
433	    }
434
435	    @Override
436	    protected void onResume() {
437	        super.onResume();
438	        if (!McuClient.getInstance().isReady()) {
439	            android.widget.Toast.makeText(this, "Service 未连接，5s后重试", android.widget.Toast.LENGTH_SHORT).show();
440	            new Handler(Looper.getMainLooper()).postDelayed(this::onResume, 5000);
441	        }
442	    }
443	}
444

package com.qsjnic.demo.radio;

import android.app.Activity;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;

import com.qsjnic.mcu.contract.RadioState;
import com.qsjnic.mcu.sdk.McuClient;
import com.qsjnic.mcu.sdk.McuRadioClient;
import com.qsjnic.mcu.sdk.RadioListener;

/**
 * Radio Demo with FM Tuner audio passthrough.
 */
public class MainActivity extends Activity {

    private static final String TAG = "RadioDemo";
    private static final int PERMISSION_REQ = 100;

    private final Handler ui = new Handler(Looper.getMainLooper());

    // Data panel
    private TextView tvBand, tvFreq, tvFreqMhz, tvSignal, tvStereo, tvSeek, tvAudioStatus;

    // Control buttons
    private TextView tvStatus;
    private Button btnFm, btnAm, btn875, btn900, btnSeekFw, btnSeekStop, btnPlay875;
    private Button btnAudioStart, btnAudioStop;

    private final RadioListener listener = new RadioListener() {
        @Override public void onFreqChanged(RadioState s) {
            ui.post(() -> updateRadio(s));
        }
        @Override public void onPsPty(int pi, int pty, String ps) {
            Log.i(TAG, "PS/PTY pi=" + pi + " pty=" + pty + " ps=" + ps);
        }
        @Override public void onRadioText(String rt) {
            Log.i(TAG, "RT=" + rt);
        }
        @Override public void onWorkStatus(int status) {
            Log.i(TAG, "WorkStatus=" + status);
        }
    };

    // ----- Audio passthrough -----
    private AudioRecord audioRecord;
    private AudioTrack audioTrack;
    private Thread audioThread;
    private volatile boolean audioRunning = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScrollView scroll = new ScrollView(this);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(32, 32, 32, 32);
        root.setBackgroundColor(Color.parseColor("#F5F5F5"));

        // ===== Title =====
        TextView title = new TextView(this);
        title.setText("Radio Tuner + Audio");
        title.setTextSize(24f);
        title.setTypeface(null, Typeface.BOLD);
        title.setTextColor(Color.parseColor("#333333"));
        title.setPadding(0, 0, 0, 16);
        root.addView(title);

        tvStatus = new TextView(this);
        tvStatus.setText("Service: connecting");
        tvStatus.setTextSize(14f);
        tvStatus.setTextColor(Color.parseColor("#555555"));
        tvStatus.setPadding(0, 0, 0, 16);
        root.addView(tvStatus);

        // ===== Frequency Display Card =====
        LinearLayout freqCard = card(root);
        tvBand = new TextView(this);
        tvBand.setText("--");
        tvBand.setTextSize(16f);
        tvBand.setTextColor(Color.parseColor("#888888"));
        tvBand.setGravity(Gravity.CENTER);
        freqCard.addView(tvBand);

        tvFreq = new TextView(this);
        tvFreq.setText("----");
        tvFreq.setTextSize(56f);
        tvFreq.setTypeface(null, Typeface.BOLD);
        tvFreq.setTextColor(Color.parseColor("#1976D2"));
        tvFreq.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreq);

        tvFreqMhz = new TextView(this);
        tvFreqMhz.setText("MHz");
        tvFreqMhz.setTextSize(14f);
        tvFreqMhz.setTextColor(Color.parseColor("#888888"));
        tvFreqMhz.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreqMhz);

        LinearLayout detailRow = new LinearLayout(this);
        detailRow.setOrientation(LinearLayout.HORIZONTAL);
        detailRow.setGravity(Gravity.CENTER);
        detailRow.setPadding(0, 16, 0, 0);
        freqCard.addView(detailRow);

        tvSignal = chip(detailRow, "Signal: --");
        tvStereo = chip(detailRow, "Stereo: --");
        tvSeek   = chip(detailRow, "Seek: --");

        // ===== Audio Status =====
        root.addView(sectionTitle("Tuner Audio"));
        LinearLayout audioCard = card(root);
        tvAudioStatus = new TextView(this);
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextSize(14f);
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        tvAudioStatus.setPadding(0, 8, 0, 8);
        audioCard.addView(tvAudioStatus);

        LinearLayout audioRow = buttonRow(audioCard);
        btnAudioStart = btn(audioRow, "Start Audio", v -> startTunerAudio());
        btnAudioStop  = btn(audioRow, "Stop Audio",  v -> stopTunerAudio());

        // ===== Band Selection =====
        root.addView(sectionTitle("Band"));
        LinearLayout bandCard = card(root);
        LinearLayout bandRow = buttonRow(bandCard);
        btnFm = btn(bandRow, "FM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_FM));
        btnAm = btn(bandRow, "AM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_AM));

        // ===== Frequency Presets =====
        root.addView(sectionTitle("Frequency"));
        LinearLayout freqCtrlCard = card(root);
        LinearLayout freqRow1 = buttonRow(freqCtrlCard);
        btn875 = btn(freqRow1, "87.5 MHz", v -> McuClient.getInstance().radio().setFrequency(8750));
        btn900 = btn(freqRow1, "90.0 MHz", v -> McuClient.getInstance().radio().setFrequency(9000));

        // ===== Seek Control =====
        root.addView(sectionTitle("Seek"));
        LinearLayout seekCard = card(root);
        LinearLayout seekRow = buttonRow(seekCard);
        btnSeekFw   = btn(seekRow, "Forward",  v -> McuClient.getInstance().radio().seekForward());
        btnSeekStop = btn(seekRow, "Stop",     v -> McuClient.getInstance().radio().seekStop());

        // ===== Other =====
        root.addView(sectionTitle("Other"));
        LinearLayout otherCard = card(root);
        LinearLayout otherRow = buttonRow(otherCard);
        btnPlay875 = btn(otherRow, "Play 90.0", v -> playFm900());
        btn(otherRow, "Ant ON",  v -> McuClient.getInstance().radio().setAntPower(true));
        btn(otherRow, "Init Range", v -> McuClient.getInstance().radio().setFreqRange(0, 522, 1670, 8750, 10800, 9, 1));

        scroll.addView(root);
        setContentView(scroll);

        McuClient.getInstance().init(getApplicationContext());
        McuClient.getInstance().addReadyListener(() -> ui.post(() -> {
            tvStatus.setText("Service: connected, linkAlive=" + McuClient.getInstance().isLinkAlive());
            playFm900();
            ui.postDelayed(this::startTunerAudio, 1000);
        }));
        McuClient.getInstance().radio().addListener(listener);

        // Request audio permission
        if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[]{android.Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ);
        }
    }

    // ---------- Audio Passthrough ----------

    private void startTunerAudio() {
        if (audioRunning) return;

        AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
        AudioDeviceInfo tunerDevice = null;
        for (AudioDeviceInfo dev : am.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
            Log.i(TAG, "Input device: type=" + dev.getType() + " name=" + dev.getProductName());
            if (dev.getType() == AudioDeviceInfo.TYPE_FM_TUNER) {
                tunerDevice = dev;
                break;
            }
        }

        if (tunerDevice == null) {
            tvAudioStatus.setText("Audio: FM Tuner device NOT FOUND");
            Log.e(TAG, "TYPE_FM_TUNER not found in input devices");
            return;
        }

        tvAudioStatus.setText("Audio: starting...");
        Log.i(TAG, "Found FM Tuner: " + tunerDevice.getProductName());

        final int sampleRate = 48000;
        final int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
        final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
        final int minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
        final int bufferSize = Math.max(minBufSize, 16384);
        // AUDIO_SOURCE_FM_TUNER = 1998 (from system/audio.h)
        final int AUDIO_SOURCE_FM_TUNER = 1998;

        try {
            // Use DEFAULT source + setPreferredDevice(tuner) approach
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
                    sampleRate, channelConfig, audioFormat, bufferSize);

            // Try to set device on API 24+
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && audioRecord.setPreferredDevice(tunerDevice)) {
                Log.i(TAG, "setPreferredDevice(tuner) succeeded");
            }

            audioTrack = new AudioTrack.Builder()
                    .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_MEDIA)
                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                            .build())
                    .setAudioFormat(new AudioFormat.Builder()
                            .setSampleRate(sampleRate)
                            .setEncoding(audioFormat)
                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                            .build())
                    .setBufferSizeInBytes(bufferSize)
                    .setTransferMode(AudioTrack.MODE_STREAM)
                    .build();

            audioRecord.startRecording();
            audioTrack.play();
            audioRunning = true;

            final int playbackBufSize = bufferSize;
            audioThread = new Thread(() -> {
                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                byte[] buf = new byte[playbackBufSize];
                int totalRead = 0;
                long lastLog = System.currentTimeMillis();
                while (audioRunning) {
                    int read = audioRecord.read(buf, 0, buf.length);
                    if (read > 0) {
                        totalRead += read;
                        audioTrack.write(buf, 0, read);
                        // Log first non-zero sample every 3 seconds
                        long now = System.currentTimeMillis();
                        if (now - lastLog > 3000) {
                            short sample = (short) ((buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8));
                            Log.i(TAG, "Audio passthrough: read=" + read
                                    + " total=" + totalRead
                                    + " sample=" + sample
                                    + " trackState=" + audioTrack.getPlayState());
                            lastLog = now;
                        }
                    } else if (read < 0) {
                        Log.e(TAG, "AudioRecord read error: " + read);
                        break;
                    }
                }
            }, "TunerAudio");
            audioThread.setPriority(Thread.MAX_PRIORITY);
            audioThread.start();

            tvAudioStatus.setText("Audio: PLAYING (" + tunerDevice.getProductName() + ")");
            tvAudioStatus.setTextColor(Color.parseColor("#4CAF50"));

        } catch (Exception e) {
            Log.e(TAG, "startTunerAudio failed", e);
            tvAudioStatus.setText("Audio: FAILED - " + e.getMessage());
            tvAudioStatus.setTextColor(Color.parseColor("#F44336"));
            stopTunerAudio();
        }
    }

    private void stopTunerAudio() {
        audioRunning = false;
        if (audioThread != null) {
            try { audioThread.join(500); } catch (InterruptedException ignored) {}
            audioThread = null;
        }
        if (audioRecord != null) {
            try { audioRecord.stop(); } catch (Exception ignored) {}
            try { audioRecord.release(); } catch (Exception ignored) {}
            audioRecord = null;
        }
        if (audioTrack != null) {
            try { audioTrack.stop(); } catch (Exception ignored) {}
            try { audioTrack.release(); } catch (Exception ignored) {}
            audioTrack = null;
        }
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        Log.i(TAG, "Tuner audio stopped");
    }

    // ---------- UI helpers ----------

    private TextView sectionTitle(String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(14f);
        t.setTypeface(null, Typeface.BOLD);
        t.setTextColor(Color.parseColor("#666666"));
        t.setPadding(8, 24, 0, 8);
        return t;
    }

    private LinearLayout card(LinearLayout parent) {
        LinearLayout card = new LinearLayout(this);
        card.setOrientation(LinearLayout.VERTICAL);
        card.setPadding(24, 24, 24, 24);
        card.setBackgroundColor(Color.WHITE);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.WHITE);
        bg.setCornerRadius(16f);
        card.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(0, 0, 0, 16);
        card.setLayoutParams(lp);
        parent.addView(card);
        return card;
    }

    private TextView chip(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(12f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(16, 8, 16, 8);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.parseColor("#E8E8E8"));
        bg.setCornerRadius(20f);
        t.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(8, 0, 8, 0);
        t.setLayoutParams(lp);
        parent.addView(t);
        return t;
    }

    private LinearLayout buttonRow(LinearLayout parent) {
        LinearLayout row = new LinearLayout(this);
        row.setOrientation(LinearLayout.HORIZONTAL);
        row.setPadding(0, 8, 0, 8);
        parent.addView(row);
        return row;
    }

    private Button btn(LinearLayout parent, String label, View.OnClickListener l) {
        Button b = new Button(this);
        b.setText(label);
        b.setOnClickListener(l);
        b.setAllCaps(false);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
        lp.setMargins(8, 0, 8, 0);
        b.setLayoutParams(lp);
        parent.addView(b);
        return b;
    }

    private void playFm900() {
        McuRadioClient radio = McuClient.getInstance().radio();
        int rcBand = radio.setBand(McuRadioClient.BAND_FM);
        int rcRange = radio.setFreqRange(0, 522, 1670, 8750, 10800, 9, 1);
        int rcAnt = radio.setAntPower(true);
        int rcStereo = radio.setSwitch(McuRadioClient.SWITCH_STEREO, 1);
        int rcRds = radio.setSwitch(McuRadioClient.SWITCH_RDS, 1);
        int rcMute = radio.setRegMute(false);  // 先解除静音
        int rcFreq = radio.setFrequency(9344);
        int rcStop = radio.seekStop();
        String msg = "Play 93.4 rc band=" + rcBand
                + " range=" + rcRange
                + " ant=" + rcAnt
                + " stereo=" + rcStereo
                + " rds=" + rcRds
                + " freq=" + rcFreq
                + " stop=" + rcStop
                + " muteOff=" + rcMute;
        Log.i(TAG, msg);
        tvStatus.setText(msg);
    }

    private int lastSignal = 0;
    private int strongFreq = 0;

    private void updateRadio(RadioState s) {
        String bandName = s.band == 0 ? "FM" : (s.band == 1 ? "AM" : "?");
        tvBand.setText(bandName + " Radio");

        double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0;
        tvFreq.setText(String.format("%.1f", mhz));
        tvFreqMhz.setText(s.band == 0 ? "MHz" : "KHz");

        tvSignal.setText("Signal: " + (s.signal >= 0 ? s.signal : "--"));
        tvStereo.setText(s.flagStereo == 1 ? "Stereo" : "Mono");
        tvStereo.setTextColor(s.flagStereo == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        String[] seekNames = {"Stop", "AMS", "Fwd", "Bwd", "PTY", "TA"};
        tvSeek.setText("Seek: " + (s.seekMode >= 0 && s.seekMode < seekNames.length ? seekNames[s.seekMode] : "--"));

        // Track strongest signal
        if (s.signal > lastSignal) {
            lastSignal = s.signal;
            strongFreq = s.freq;
            Log.i(TAG, "*** STRONG SIGNAL: " + s.freq + " signal=" + s.signal + " ***");
        }
    }

    @Override
    protected void onDestroy() {
        stopTunerAudio();
        McuClient.getInstance().radio().removeListener(listener);
        super.onDestroy();
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (!McuClient.getInstance().isReady()) {
            android.widget.Toast.makeText(this, "Service 未连接，5s后重试", android.widget.Toast.LENGTH_SHORT).show();
            new Handler(Looper.getMainLooper()).postDelayed(this::onResume, 5000);
        }
    }
}


Now I have a complete picture. Let me analyze what needs to be done:

## Current state of the demo app

**Already displayed in UI (from RadioState via onFreqChanged):**
- `tvBand` - Band (FM/AM)
- `tvFreq` - Frequency (e.g., 87.5)
- `tvFreqMhz` - Unit (MHz/KHz)
- `tvSignal` - Signal strength
- `tvStereo` - Stereo/Mono
- `tvSeek` - Seek mode

**Already in RadioState but NOT displayed:**
- `valid` - **Critical for firmware issue debugging**
- `pty` - Program Type
- `flagTa`, `flagTp` - Traffic flags
- `switchTa`, `switchAf` - Switch status
- `afBlink` - AF blink
- `bandSwitching` - Band switching

**Received via callbacks but only logged, NOT displayed:**
- `onPsPty(pi, pty, ps)` - PS (Program Service name) and PTY **Critical**
- `onRadioText(rt)` - Radio Text **Critical**
- `onTaPtyTips(mode, value)` - TA/PTY tips
- `onWorkStatus(status)` - Work status

## What the feedback document says is problematic:
1. **valid** always 0 - need to display this prominently
2. **signal** always 0 - already displayed
3. **PS** (station name) empty - need to display this
4. **RT** (radio text) empty - need to display this
5. **PTY** (program type) - need to display this

## My proposed approach:

### UI additions needed:

1. **Add a "Debug Info" section** below the frequency card that shows the critical fields related to the firmware issue:
   - `valid` (with color coding: green=1, red=0)
   - `pty` (program type)
   - `flagTa`, `flagTp` (traffic flags)

2. **Add PS (Program Service) display** - show the station name that comes from `onPsPty`

3. **Add RT (Radio Text) display** - show the radio text from `onRadioText`

4. **Add raw hex display** - optionally show the raw bytes for each frame type (FREQ, PS_PTY, RT) so testers can immediately see if bytes are all zero

Actually, wait. The user said they want to display "相关数值" (relevant values) in the test software. Let me think about the most practical approach.

The feedback document's core complaint is:
- FREQ frame: valid=0, signal=0 (bytes 12 and 14)
- PS_PTY frame: all 9 bytes are 0
- RT frame: all 64 bytes are 0

So the key values to show are:
1. **valid** (bit 7 of byte 4 in FREQ payload) - currently parsed but not displayed
2. **signal** (byte 6 in FREQ payload) - already displayed
3. **PS** (from PS_PTY frame) - currently only logged
4. **RT** (from RT frame) - currently only logged
5. **PTY** (from PS_PTY frame and byte 5 in FREQ) - in RadioState but not displayed

I think the best approach is to:

1. **Add a "Data Monitor" section** to the demo UI that shows:
   - valid bit status (with visual indicator)
   - PS station name text
   - RT text (maybe scrolling)
   - PTY value
   - signal (already there but keep)

2. **Optionally add raw hex dump** of the latest received frames - this would be EXTREMELY useful for debugging. Show the hex bytes of the most recent FREQ/PS_PTY/RT frame right on screen.

Actually, looking at this from the firmware debugging perspective, the most useful thing would be to show:
- The raw hex bytes of each received frame
- The parsed values

This way testers can immediately see "ah, the PS_PTY frame is all zeros" without needing to pull logs.

Let me think about the layout:

```
Current layout:
┌─────────────────────────┐
│ Radio Tuner + Audio     │
│ Service: connected      │
├─────────────────────────┤
│      FM Radio           │
│       93.4              │
│        MHz              │
│ Signal:55  Stereo  Seek │
├─────────────────────────┤
│ Tuner Audio             │
│ [Start] [Stop]          │
├─────────────────────────┤
│ Band                    │
│ [FM] [AM]               │
│ ...                     │
└─────────────────────────┘
```

I want to add after the frequency card:

```
├─────────────────────────┤
│ Frame Monitor           │  ← NEW section
│ valid: ● OFF (0)        │  ← NEW: valid bit
│ PTY: 0                  │  ← NEW: program type
│ PS: [empty]             │  ← NEW: station name
│ RT: [empty]             │  ← NEW: radio text
│ TA: OFF  TP: OFF        │  ← NEW: traffic flags
│ FREQ hex: 26 D8 00...   │  ← NEW: raw hex
│ PS/PTY hex: 00 00...   │  ← NEW: raw hex
│ RT hex: 00 00...       │  ← NEW: raw hex
```

Actually, the raw hex might be too noisy for a demo app. Let me think about what's most practical...

The user's goal is to verify whether the MCU firmware is actually filling in these values. So the most important things are:

1. **valid** - is the tuner locking onto a station?
2. **PS** - is the station name being received?
3. **RT** - is radio text being received?
4. **signal** - is signal strength being reported? (already displayed)

And as a bonus for debugging: raw hex display would let testers see the actual bytes without needing serial port tools.

Let me present this plan to the user. Let me structure it clearly:

### Plan:

**A. Add fields to the existing frequency card (minimal change):**
- Add `valid` indicator next to Signal/Stereo/Seek chips
- Add `PTY` as a chip

**B. Add a new "RDS Info" card:**
- PS (station name) text display
- RT (radio text) text display
- These update from `onPsPty` and `onRadioText` callbacks

**C. Add a "Raw Frame" debug card (collapsible or toggleable):**
- Show latest FREQ frame hex bytes
- Show latest PS_PTY frame hex bytes
- Show latest RT frame hex bytes (maybe truncated)
- Update via a raw frame listener (IMcuFrameListener)

Actually, for the raw hex part, the demo currently uses `RadioListener` which gets parsed data. To get raw hex, we'd need to also register an `IMcuFrameListener`. But the simpler approach is to just display the parsed values that come through the existing callbacks.

Let me think about this differently. The user might also want the raw hex to be logged. But the user said "直接显示在我们的测试软件中" - display directly in the test software.

I think the most efficient approach is:

1. Add a new "RDS / Debug" card between the frequency card and the audio section
2. Show: valid, PTY, PS name, RT text, plus the traffic flags
3. Optionally show raw hex of each frame

Let me now think about whether raw hex is feasible. To get raw hex of frames, we'd need to listen to raw frames. Looking at the code:

- `IMcuFrameListener.onMcuFrame(frameType, msgType, cmd, byte[] body)` gives us raw bytes
- `McuClient` has `addFrameListener(IMcuFrameListener)`

So yes, we can register a frame listener to get raw bytes and display them.

But actually, looking more carefully at the feedback document, the key issue is about specific parsed values being 0. The raw hex is supplementary. Let me focus on what adds the most value:

**Essential additions:**
1. **valid** indicator - directly addresses the #1 issue in the feedback
2. **PS** display - directly addresses the #3 issue (PS all zeros)
3. **RT** display - directly addresses the #3 issue (RT all zeros)
4. **PTY** display - mentioned in the feedback

**Nice to have:**
5. Raw hex display for each frame type
6. Traffic flags (TA, TP)

Let me present the plan clearly and concisely to the user.

One more thing to consider - the `onPsPty` callback and `onRadioText` callback are separate from `onFreqChanged`. Currently `onPsPty` and `onRadioText` only do `Log.i()`. I need to add UI updates there too.

Also I need to store the PS and RT values as fields so they persist across `onFreqChanged` updates.

Let me now formulate my plan clearly.


Now I have a complete picture. Let me summarize my analysis and proposed approach.

---

## 分析

### 反馈文档中的核心问题

| 问题 | 对应字段 | 当前 demo 是否显示 |
|------|----------|-------------------|
| valid 始终为 0 | `RadioState.valid` (p[4] bit7) | **未显示** |
| signal 始终为 0 | `RadioState.signal` (p[6]) | 已显示 |
| PS 全为 0 | `onPsPty(pi, pty, ps)` 中的 ps | **仅 Log，未显示** |
| RT 全为 0 | `onRadioText(rt)` | **仅 Log，未显示** |
| PTY 相关 | `RadioState.pty` (p[5]) | **未显示** |

### 当前 UI 结构

```
┌─ Radio Tuner + Audio ─────────────────┐
│ Service: connected                    │
├─ Frequency Card ──────────────────────┤
│  FM Radio / 93.4 / MHz                │
│  [Signal:55] [Stereo] [Seek:Fwd]      │  ← 3 个 chip
├─ Tuner Audio ─────────────────────────┤
│  [Start Audio] [Stop Audio]           │
├─ Band ────────────────────────────────┤
│  [FM] [AM]                            │
├─ Frequency / Seek / Other ────────────┤
│  ...控制按钮...                        │
└───────────────────────────────────────┘
```

---

## 方案思路

在 Frequency Card 和 Tuner Audio 之间插入一个 **"Frame Monitor"调试卡片**，显示与固件问题直接相关的字段：

### 方案 A：纯解析值展示（推荐，改动最小）

新增一个卡片，展示：

**1. 关键标志位（从 RadioState 获取）：**
- `valid` —— 绿色 "● LOCKED" / 红色 "○ UNLOCK"，对应 p[4] bit7
- `pty` —— 节目类型数值 (0-31)
- `flagTp` / `flagTa` —— 交通广播标志
- `flagStereo` —— 已显示，此处可选

**2. RDS 文本（从 onPsPty / onRadioText 回调获取）：**
- PS（台名，最多 8 字节）
- RT（广播文本，最多 64 字节）

**3. 可选：原始 HEX 展示**

通过注册 `IMcuFrameListener` 获取原始 body 字节，直接以 hex 形式显示在界面上，这样测试人员无需拉日志就能看到原始数据。

### 方案 B：方案 A + 原始 HEX

在方案 A 的基础上，增加 FREQ / PS_PTY / RT 三帧的原始 hex 展示。需要通过 SDK 的 `addFrameListener` 注册一个额外的原始帧监听器。

---

### 我的建议

**推荐方案 A**，原因：
1. valid、PS、RT 是反馈文档中直接指出的问题字段，展示它们就能覆盖排查需求
2. 改动集中在 `MainActivity.java` 一个文件，添加约 60 行 UI 代码
3. 不需要额外注册 frame listener，利用已有的 `RadioListener` 回调即可

如果后续需要原始 hex 对比，再加也不迟。

你觉得这个方向可以吗？还是你更倾向于直接上方案 B（带 hex）？


方案b，要测试软件和代码先放置于/Users/admin/workspace/jyq/imgs/testapp这个文件夹下


The user wants:
1. Plan B - with raw HEX display
2. The test app and code should be placed in `/Users/admin/workspace/jyq/imgs/testapp`

Let me first check if that directory exists and understand the structure better. Then I'll plan the implementation.

Let me think about what needs to happen:

1. Create the test app in `/Users/admin/workspace/jyq/imgs/testapp`
2. It needs to be a standalone Android app that can display radio data
3. Need to include the SDK/library dependencies

Wait, let me think about this more carefully. The existing demo app is at `demos/radio-demo/` and depends on the SDK module (`sdk/`) and contract module (`contract/`). If I create a standalone app in `/Users/admin/workspace/jyq/imgs/testapp`, it needs to either:

a. Be part of the existing Gradle project (a new module)
b. Be a standalone project that includes the SDK as a dependency (AAR/JAR)
c. Copy the relevant source files

Actually, let me re-read the user's request. They said "测试软件和代码先放置于 /Users/admin/workspace/jyq/imgs/testapp". So they want both the APK and the source code there.

But wait - a standalone Android app needs a full Gradle project structure. The simplest approach would be to create a new demo module within the existing project, similar to `demos/radio-demo`, and then place a copy or reference in the imgs folder.

Actually, looking at the existing project structure, `demos/radio-demo` is already a standalone Android app module. The simplest approach would be to:

1. Create a new module `demos/radio-demo-v2` (or modify the existing one) and build it
2. Copy the built APK to `/Users/admin/workspace/jyq/imgs/testapp/`
3. Also copy the source code there

OR, even simpler: create a copy of the existing demo in the imgs folder with just the changed files and a build script.

Actually wait, let me think about what makes sense. The user wants:
- The APK placed in `/Users/admin/workspace/jyq/imgs/testapp/`
- The code also placed there

The most practical approach:
1. Modify the existing `demos/radio-demo` MainActivity.java (since it's part of the project and can build)
2. Build the APK
3. Copy APK to `/Users/admin/workspace/jyq/imgs/testapp/`
4. Copy the relevant source files there too

But actually, the user said "代码先放置于" - they want the code there first. Maybe they want me to create a standalone copy.

Let me think... Creating a fully standalone project would require either:
- A complete Gradle project with all dependencies
- Or modifying the existing demo module and copying artifacts

I think the sensible approach is:
1. Create the new source file(s) in `/Users/admin/workspace/jyq/imgs/testapp/`
2. But actually build from within the existing project (since it needs SDK, contract modules, etc.)
3. Copy the built APK to the testapp folder

Actually, let me reconsider. The simplest way to make this work is:
1. Modify the existing `demos/radio-demo` module's MainActivity.java with the new UI
2. Build the APK using the project's build system
3. Copy APK + modified source to `/Users/admin/workspace/jyq/imgs/testapp/`

But wait, the user might want a completely new module. Let me just go with modifying the existing demo and copying the output. That's the most practical approach.

Let me plan the implementation:

### Implementation Plan:

**1. Modify `MainActivity.java`:**
- Add new UI fields for RDS data display
- Add a "Frame Monitor" card with:
  - valid indicator (green/red)
  - PTY display
  - flagTp, flagTa display
  - PS (station name) from onPsPty callback
  - RT (radio text) from onRadioText callback
  - Raw hex display for FREQ/PS_PTY/RT frames (via IMcuFrameListener)
- Update onPsPty callback to update UI
- Update onRadioText callback to update UI
- Register a frame listener for raw hex

**2. Build APK**

**3. Copy to testapp folder**

Let me now enter plan mode and design the detailed implementation.

Actually, the user said "先不要写代码，请你先思考方案" earlier but now said "方案b" - so they've approved the approach. Let me now implement it.

Let me plan the code changes first, then implement.

For the raw frame hex, I need to register an `IMcuFrameListener`. Looking at the SDK:

In `McuClient.java`, there should be methods to add frame listeners. Let me check...

From the exploration, `McuClient` has `addFrameListener`. Let me look at the exact API.

Actually, let me also check how to import IMcuFrameListener properly.

Let me read the relevant SDK files to understand the API surface.


Let me first check the existing directory structure and understand the SDK API for raw frame listening.


Bash

ls -la /Users/admin/workspace/jyq/imgs/ 2>/dev/null; echo "---"; ls -la /Users/admin/workspace/jyq/imgs/testapp/ 2>/dev/null || echo "testapp does not exist yet"

Check imgs directory and testapp subdirectory


total 3408
drwxr-xr-x  12 admin  staff      384 May 29 10:05 .
drwxr-xr-x@ 14 admin  staff      448 May 28 16:48 ..
-rw-r--r--@  1 admin  staff     1840 May 28 20:03 MCU固件问题反馈.md
-rw-r--r--@  1 admin  staff   497170 May 28 16:55 Weixin Image_2026-05-28_165403_833.png
-rw-r--r--@  1 admin  staff  1200615 May 28 16:55 Weixin Image_2026-05-28_165509_304.png
-rw-r--r--@  1 admin  staff     2814 May 28 18:47 ai_context.md
-rw-r--r--@  1 admin  staff     3275 May 28 17:36 bug.md
-rw-r--r--@  1 admin  staff     5997 May 28 17:40 bug_investigation_plan.md
-rw-r--r--@  1 admin  staff     6266 May 28 18:46 bug_report.md
-rw-r--r--@  1 admin  staff     6875 May 28 20:40 full_test_report.md
-rw-r--r--@  1 admin  staff     1600 May 28 21:44 radio_demo_test_report.md
drwxr-xr-x@  2 admin  staff       64 May 29 10:05 testapp
---
total 0
drwxr-xr-x@  2 admin  staff   64 May 29 10:05 .
drwxr-xr-x  12 admin  staff  384 May 29 10:05 ..