Add UID management, LED strip debug, and MCUboot signing updates

- Add UID encoding/decoding with flash storage and CRC16 validation
- Add write UID (0x07) and LED strip color (0x08) protocol commands
- Add debug LED strip configuration option
- Switch MCUboot signing from RSA-2048 to ECDSA-P256
- Add SMP serial client for firmware upload over UART
- Add firmware version output on boot
- Update upgrade documentation with ECDSA key generation steps
This commit is contained in:
zhangyisong 2026-08-20 21:16:30 +08:00
parent 48644610b8
commit 27ae21bbea
21 changed files with 1433 additions and 183 deletions

View File

@ -22,6 +22,7 @@ else()
src/temp.cpp src/temp.cpp
src/watdog.cpp src/watdog.cpp
src/com.cpp src/com.cpp
src/uid.cpp
) )
zephyr_sources_ifdef(CONFIG_APP_DFU zephyr_sources_ifdef(CONFIG_APP_DFU

View File

@ -10,3 +10,7 @@ config SAMPLE_NTC
config APP_DFU config APP_DFU
default y default y
bool "Enable DFU" bool "Enable DFU"
config APP_DEBUG_LED_STRIP
default n
bool "Enable debug LED strip"

5
VERSION Normal file
View File

@ -0,0 +1,5 @@
VERSION_MAJOR = 0
VERSION_MINOR = 0
PATCHLEVEL = 19
VERSION_TWEAK = 0
EXTRAVERSION =

156
doc/UID编码规则.md Normal file
View File

@ -0,0 +1,156 @@
# 按摩头 UID 编码规则
## 1. 设备分类
| 类别 | 设备类型 | 说明 |
|---|---|---|
| 内置式 | 冲击波、光磁 | 由主板直接驱动,按摩头内无独立控制模块 |
| 外置式 | DMS、负压、捶打、揉捏、热敷、EMS 等 | 按摩头自带驱动模块,通过数据线与主板通信 |
> 通用设计所有按摩头均可插入设备的任意端口Port 0 或 Port 1端口号不影响 UID 编码。
---
## 2. UID 基本规则
每个按摩头拥有一个唯一的身份标识码UID用于识别设备类型和追溯生产信息。
### UID 组成(共 10 字节)
```
┌──────────┬──────────┬────────────┬─────────────────────┐
│ 设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号 │
│ (1字节) │ (1字节) │ (4字节) │ (4字节) │
└──────────┴──────────┴────────────┴─────────────────────┘
字节: [0] [1] [2..6) [6..10)
```
### 字段说明
| 字段 | 说明 | 示例 |
|---|---|---|
| 设备类型 | 区分按摩头的产品系列 | 冲击波、光磁、外置按摩头等 |
| 产品代次 | 同一系列内的产品迭代版本(硬件版本号) | 第一代、第二代、第三代 |
| 生产年月 | 同一代次的不同批次修订版本 | 第一批、第二批 |
| 唯一序列号 | 生产时分配的唯一编号 | 用于追溯具体产品 |
---
## 3. 设备类型编码
### 冲击波系列(类型 0x01
| 代次 | 设备名称 | UID 示例 |
|---|---|---|
| 第一代 | 标准冲击波手柄 | 01-01-XXXX-1234 |
| 第二代 | 高能型冲击波手柄 | 01-02-XXXX-1235 |
### 光磁/热疗系列(类型 0x02
| 代次 | 设备名称 | UID 示例 |
|---|---|---|
| 第一代 | 光磁手柄 | 02-01-XXXX-XXXX |
| 第二代 | 热疗手柄 | 02-02-XXXX-XXXX |
| 第三代 | 增强型光磁手柄 | 02-03-XXXX-XXXX |
### 外置按摩头系列(类型 0x03
| 代次 | 设备名称 | UID 示例 |
|---|---|---|
| 第一代 | 捶打按摩头 | 03-01-XXXX-XXXX |
| 第一代 | 揉捏按摩头 | 03-02-XXXX-XXXX |
| 第一代 | 热敷按摩头 | 03-03-XXXX-XXXX |
| 第一代 | 电磁按摩头EMS | 03-04-XXXX-XXXX |
| 第一代 | DMS 深层肌肉刺激仪 | 03-05-XXXX-XXXX |
| 第一代 | 负压吸力按摩头 | 03-06-XXXX-XXXX |
| — | 通用型(未分类) | 03-F0-XXXX-XXXX |
| — | 未知设备(自动探测) | 03-FF-XXXX-XXXX |
---
## 4. 编码示例
### 示例 1冲击波第一代
- 设备类型冲击波系列0x01
- 产品代次第一代0x01
- 生产年月26082026 年 8 月BCD 风格)
- 序列号00001234
```
设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号
─────────┼─────────┼──────────────┼────────────────────────
0x01 │ 0x01 │ 26 08 00 00 │ 0x00 0x00 0x12 0x34
```
UIDhex10 字节):`01 01 26 08 00 00 00 00 12 34`
### 示例 2光磁第三代
- 设备类型:光磁/热疗系列0x02
- 产品代次第三代0x03
- 生产年月2608
- 序列号00005678
```
设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号
─────────┼─────────┼──────────────┼────────────────────────
0x02 │ 0x03 │ 26 08 00 00 │ 0x00 0x00 0x56 0x78
```
UIDhex10 字节):`02 03 26 08 00 00 00 00 56 78`
### 示例 3DMS 第一代
- 设备类型外置按摩头系列0x03
- 产品代次第一代0x01
- 生产年月2608
- 序列号AABBCCDD
```
设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号
─────────┼─────────┼──────────────┼────────────────────────
0x03 │ 0x01 │ 26 08 00 00 │ 0xAA 0xBB 0xCC 0xDD
```
UIDhex10 字节):`03 01 26 08 00 00 AA BB CC DD`
> 注:生产年月 4 字节的具体子字段分配BCD/扩展信息)由生产系统定义,本工具按原始字节透传、透读。
---
## 5. 数据存储方式
UID 信息存储在设备 storage 分区0x7000064KB起始处记录格式
```
[magic "UID1" 4B][ver 1B][uid 10B][crc16 2B][pad 7B] = 24 字节
```
- 含 CRC16-Modbus 校验;记录损坏/未写入时自动回退 MCU 芯片 UID12 字节)。
- 写入命令:`0x07` 指令,见《通讯协议.md》。
---
## 6. 端口识别说明
按摩头插入端口后,设备通过以下方式识别:
- **UID**:识别按摩头的类型和追溯信息
- **端口号**:决定控制电路的分配(与 UID 无关)
按摩头可自由插入任意端口UID 不会因端口切换而改变。
---
## 7. 扩展预留
| 类型范围 | 用途 |
|---|---|
| 0x01 | 冲击波系列(已定义) |
| 0x02 | 光磁/热疗系列(已定义) |
| 0x03 | 外置按摩头系列(已定义) |
| 0x04 - 0x0F | 未来新增设备系列 |
| 0x10 - 0xEF | 客户定制设备 |
| 0xF0 - 0xFE | 工厂测试工具 |
| 0xFF | 无效/未编程(保留) |

View File

@ -5,28 +5,36 @@
### 1.1 安装依赖 ### 1.1 安装依赖
```bash ```bash
# 安装 imgtool(固件签名工具) # GUI 工具必需:串口库
pip install pyserial
# 固件签名工具(imgtool 随 Zephyr/MCUboot 自带,也可单独安装)
pip install imgtool pip install imgtool
# 安装 mcumgr(固件上传工具) # 可选:命令行升级(smpmgr)——GUI 已内置 SMP 客户端,不装也能升级
# Linux/macOS pip install smpmgr
go install github.com/apache/mynewt-mcumgr-cli/mcumgr@latest
# 或从 https://github.com/apache/mynewt-mcumgr-cli/releases 下载
``` ```
### 1.2 生成签名密钥(量产阶段) ### 1.2 生成签名密钥(量产阶段)
```bash ```bash
# 在项目根目录生成密钥对 # 在应用目录下生成密钥对
cd /home/issac-zys/code/zephyr_prj_template cd /home/issac-zys/code/zephyr_prj_template/app/app_photomagnetic
mkdir -p keys mkdir -p keys
imgtool keygen -k keys/my-signing-key.pem -t rsa-2048 imgtool keygen -k keys/mykey.pem -t ecdsa-p256
# 更新 sysbuild.conf 中的密钥路径 # 更新 sysbuild.conf 中的密钥路径与签名类型
# SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/keys/my-signing-key.pem" # SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
# SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/keys/mykey.pem"
``` ```
> 开发阶段可跳过此步,使用 MCUboot 自带的开发密钥。 > 开发阶段可跳过此步,使用 MCUboot 自带的开发密钥 `root-ec-p256.pem`
> ⚠️ **密钥算法必须与签名类型一致**:
> - `-t ecdsa-p256` 对应 `SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y`
> - `-t rsa-2048` 对应 `SB_CONFIG_BOOT_SIGNATURE_TYPE_RSA=y`
> 不匹配会报链接错误(如 `undefined reference to 'ecdsa_pub_key'`)。
> 本项目 boot 分区只有 48KB,建议用 ECDSA-P256(RSA 会把 MCUboot 撑到接近极限)。
> 更换密钥后必须**完整重建**(删 build 目录),否则签名/验签用的还是旧公钥。
--- ---
@ -44,22 +52,23 @@ west build -p auto -b dr2501a_g0b0ce/stm32g0b0xx \
``` ```
构建产物: 构建产物:
- `build/mcuboot/zephyr/zephyr.bin` — MCUboot bootloader - `build/mcuboot/zephyr/zephyr.hex` / `.bin` — MCUboot bootloader(0x08000000)
- `build/app_photomagnetic/zephyr/zephyr.signed.bin` — 签名后的应用固件 - `build/app_photomagnetic/zephyr/zephyr.signed.hex` / `.bin` — 签名后的应用固件(0x0800C000)
- `build/zephyr/merged.hex` — 合并镜像(MCUboot + 应用)
### 2.2 烧录(一次性) > ⚠️ 注意:顶层 `west flash -d build` 只会烧应用镜像,不会烧 MCUboot!MCUboot 必须单独烧录。
### 2.2 烧录(一次性,需要调试器)
```bash ```bash
# 方式1: 使用 west flash(推荐) # ① 先烧 MCUboot 到 0x08000000(覆盖出厂 godtek bootloader,只需一次)
west flash -d build west flash -d build/mcuboot
# 方式2: 使用 J-Link 手动烧录 merged.hex # ② 再烧签名应用到 slot0 (0x0800C000)
# J-Link Commander: west flash -d build
# loadfile build/zephyr/merged.hex
# reset
``` ```
> 两条命令顺序不能反:没有 MCUboot 时,0x0800C000 的镜像头无法被引导,设备会完全无反应。
### 2.3 验证首次启动 ### 2.3 验证首次启动
串口调试口(usart3)应输出: 串口调试口(usart3)应输出:
@ -82,135 +91,104 @@ west flash -d build
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ 主板 │ 主板 / 测试工具
│ 1. 发送升级命令 [7E E7][FF][01][01][CRC] │ │ 1. 发送升级命令 [7E E7][FF][01][01][CRC] │
│ 2. 等待 200ms(小板重启) │ │ 2. 等待 300ms(小板重启) │
│ 3. 切换到 mcumgr 模式 │ 3. 通过 smpmgr 上传固件(3 秒内)
│ 4. 发送 mcumgr 命令(3 秒内) │ 4. 标记待测试 + 复位
│ 5. 等待新固件启动 │ │ 5. 等待新固件启动 │
│ 6. 切回 0x7EE7 协议模式 │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ 光磁头(小板) │ │ 光磁头(小板) │
│ 1. 收到升级命令 → 直接重启 │ │ 1. 收到升级命令 → 直接重启 │
│ 2. MCUboot 启动 → 等待 mcumgr 命令(3 秒) │ 2. MCUboot 启动 → 等待 SMP 命令(3 秒)
│ 3. 收到 mcumgr 命令 → 进入 serial recovery │ 3. 收到 SMP 命令 → 进入 serial recovery
│ 4. 接收固件 → 写入 slot1 │ 4. 接收固件 → 直接写入 slot0(主槽,串口恢复固定写主槽)
│ 5. 验证签名 → swap → 重启运行新固件 │ 5. 复位 → MCUboot 校验签名 → 启动新固件
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
### 3.2 手动升级步骤(使用 mcumgr CLI) > ⚠️ **重要:串口恢复(serial recovery)是把镜像直接写入 slot0(主槽)**,
> 不是写 slot1 再 swap(那是应用内 mcumgr DFU 的做法)。因此:
> 1. **升级过程中绝不能抢占/打开串口**(比如用其他串口工具、或让本工具重新 Connect),
> 否则上传中断会把 slot0 擦到一半,设备无法启动;
> 2. 上传中断后设备不会变砖——MCUboot 仍在运行并停留在 serial recovery 等待区,
> 直接用 smpmgr 重新上传即可恢复(或 `west flash -d build` 重烧应用);
> 3. 设备异常停在 bootloader 时,再点一次升级按钮也能恢复(0xFF 命令对 MCUboot 无害,
> smpmgr 会自动连上当前等待中的 serial recovery)。
### 3.2 使用 smpmgr CLI 升级
#### 步骤 1: 发送升级命令 #### 步骤 1: 发送升级命令
使用串口工具发送升级命令到光磁头: 使用串口工具或 Python 发送升级命令:
```
发送: 7E E7 FF 01 01 [CRC_L] [CRC_H]
```
或使用 Python 脚本:
```python ```python
import serial import serial, struct, time
import struct
def send_upgrade_command(port='/dev/ttyUSB0'): def send_upgrade(port='/dev/ttyUSB0'):
cmd = bytes([0x7E, 0xE7, 0xFF, 0x01, 0x01]) cmd = bytes([0x7E, 0xE7, 0xFF, 0x01, 0x01])
# 计算 CRC16(Modbus)
crc = 0xFFFF crc = 0xFFFF
for b in cmd[2:]: # 从 CMD 开始计算 for b in cmd[2:]:
crc ^= b crc ^= b
for _ in range(8): for _ in range(8):
if crc & 1: crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
cmd += struct.pack('<H', crc) cmd += struct.pack('<H', crc)
with serial.Serial(port, 115200, timeout=1) as ser: with serial.Serial(port, 115200, timeout=1) as ser:
ser.write(cmd) ser.write(cmd)
print(f"Sent upgrade command: {cmd.hex()}") print(f"已发送升级命令: {cmd.hex()}")
time.sleep(0.3) # 等待重启
send_upgrade_command() send_upgrade('/dev/ttyUSB0')
``` ```
#### 步骤 2: 等待小板重启 #### 步骤 2: 使用 smpmgr 上传固件
等待 200ms,让小板完成重启。
#### 步骤 3: 上传固件
```bash ```bash
# 设置 mcumgr 连接 # 一键升级(串口恢复模式直接写 slot0 主槽,上传完自动复位,无需 test/confirm)
export MCUmgr_CONN="dev=/dev/ttyUSB0,baud=115200" # 必须在小板重启后 3 秒内执行;--timeout 10 给擦除/慢速上传留足超时
smpmgr --port /dev/ttyUSB0 --timeout 10 upgrade \
# 上传固件(必须在小板重启后 3 秒内执行) build/app_photomagnetic/zephyr/zephyr.signed.bin
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \
image upload -e build/app_photomagnetic/zephyr/zephyr.signed.bin
``` ```
上传过程输出: 也可以手动分步:
```
0 25.00 KiB [====] 100%
Done
```
#### 步骤 4: 查看镜像列表
```bash ```bash
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" image list # 上传固件到 slot0(主槽)
smpmgr --port /dev/ttyUSB0 image upload build/app_photomagnetic/zephyr/zephyr.signed.bin
# 复位,让 MCUboot 校验并启动新固件
smpmgr --port /dev/ttyUSB0 os reset
``` ```
输出示例: #### 步骤 3: 验证新固件
```
Split status: N/A (0)
Image number: 1
Slot 0: primary
Version: 1.0.0
Bootable: true
Flags: active confirmed
Hash: 5a8d...c3f2
Slot 1: secondary
Version: 1.1.0
Bootable: true
Flags:
Hash: 9b4a...e7d1
```
#### 步骤 5: 标记待测试
```bash ```bash
# 使用 slot1 的 hash 值 # 查看串口输出,确认新固件版本(协议口应输出新固件的 ID 帧)
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \ # 串口恢复直接写主槽、无 swap,固件已生效,无需 confirm。
image test 9b4a...e7d1
``` ```
#### 步骤 6: 复位触发 swap ### 3.3 使用 GUI 工具升级(推荐)
```bash 已集成到 `scripts/test_gui.py` 中,升级功能**进程内封装了 SMP 客户端**(`smp_client.py`),
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" reset 不再依赖外部 smpmgr/mcumgr 命令:
```
#### 步骤 7: 验证新固件 1. 打开 GUI 工具: `python scripts/test_gui.py`
2. 连接串口
3. 点击 **"Upgrade Firmware"** 按钮
4. 选择 `.signed.bin` 固件文件
5. 等待升级完成(GUI 会显示上传进度,自动复位并重连)
MCUboot 会自动 swap 并重启。新固件启动后: 升级期间 GUI 会锁定 Connect 按钮/端口选择,防止串口被抢占导致上传中断。
```bash > 可选:仍可用命令行一键升级(需已安装 smpmgr):
# 查看串口输出,确认新固件版本 > `smpmgr --port /dev/ttyUSB0 --timeout 10 upgrade build/app_photomagnetic/zephyr/zephyr.signed.bin`
# 协议口应输出新固件的 ID 帧
# 确认新固件(可选,防止下次重启回滚) ### 3.4 自动化升级脚本
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \
image confirm 9b4a...e7d1
```
### 3.3 自动化升级脚本
```bash ```bash
#!/bin/bash #!/bin/bash
@ -219,7 +197,6 @@ mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \
PORT=${1:-/dev/ttyUSB0} PORT=${1:-/dev/ttyUSB0}
FIRMWARE=${2:-build/app_photomagnetic/zephyr/zephyr.signed.bin} FIRMWARE=${2:-build/app_photomagnetic/zephyr/zephyr.signed.bin}
BAUD=115200
echo "=== 光磁头固件升级 ===" echo "=== 光磁头固件升级 ==="
echo "串口: $PORT" echo "串口: $PORT"
@ -235,7 +212,7 @@ fi
echo "1. 发送升级命令..." echo "1. 发送升级命令..."
python3 -c " python3 -c "
import serial, struct, time import serial, struct, time
with serial.Serial('$PORT', $BAUD, timeout=1) as ser: with serial.Serial('$PORT', 115200, timeout=1) as ser:
cmd = bytes([0x7E, 0xE7, 0xFF, 0x01, 0x01]) cmd = bytes([0x7E, 0xE7, 0xFF, 0x01, 0x01])
crc = 0xFFFF crc = 0xFFFF
for b in cmd[2:]: for b in cmd[2:]:
@ -251,70 +228,49 @@ with serial.Serial('$PORT', $BAUD, timeout=1) as ser:
echo "2. 等待小板重启..." echo "2. 等待小板重启..."
sleep 0.3 sleep 0.3
# 上传固件 # 上传固件(串口恢复直接写 slot0 主槽,上传完自动复位)
echo "3. 上传固件..." echo "3. 上传固件..."
mcumgr --conntype=serial --connstring="dev=$PORT,baud=$BAUD" \ smpmgr --port "$PORT" --timeout 10 upgrade "$FIRMWARE"
image upload -e "$FIRMWARE"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "错误: 固件上传失败" echo "错误: 固件上传失败"
echo "提示: 上传中断会擦掉 slot0 一半,但设备停在 bootloader 中不会变砖,"
echo " 重新执行本脚本即可恢复(或 west flash -d build 重烧应用)。"
exit 1 exit 1
fi fi
# 获取新固件 hash
HASH=$(mcumgr --conntype=serial --connstring="dev=$PORT,baud=$BAUD" \
image list | grep -A5 "Slot 1:" | grep "Hash:" | awk '{print $2}')
echo "4. 新固件 hash: $HASH"
# 标记待测试
echo "5. 标记待测试..."
mcumgr --conntype=serial --connstring="dev=$PORT,baud=$BAUD" \
image test "$HASH"
# 复位
echo "6. 复位,触发 swap..."
mcumgr --conntype=serial --connstring="dev=$PORT,baud=$BAUD" reset
echo "=== 升级完成,等待新固件启动 ===" echo "=== 升级完成,等待新固件启动 ==="
``` ```
--- ---
## 4. 回滚与恢复 ## 4. 恢复(串口恢复模式无 swap,没有回滚机制)
### 4.1 新固件未确认自动回滚 > 串口恢复(serial recovery)是直接把镜像写入 slot0 主槽,固件即刻生效、
> 不存在“待测试/回滚”的概念。旧版文档里的 swap/回滚描述来自应用内 mcumgr DFU,
> 本方案不适用。
如果新固件有问题,重启后 MCUboot 会自动回滚到原固件: ### 4.1 上传中断/镜像损坏后的恢复
上传中断只会擦掉 slot0 一部分,MCUboot 仍然在运行并停留在 serial recovery 等待区,
**不需要调试器**即可恢复:
```bash ```bash
# 不执行 image confirm,直接重启 # 直接重传即可(设备已在 bootloader 等待,无需再发升级命令)
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" reset smpmgr --port /dev/ttyUSB0 --timeout 10 upgrade \
build/app_photomagnetic/zephyr/zephyr.signed.bin
# MCUboot 检测到未确认 → 回滚到原固件
``` ```
### 4.2 手动确认新固件 或使用 GUI:再次点击 **Upgrade Firmware**(0xFF 命令对 MCUboot 无害,smpmgr 会自动连上)。
如果新固件工作正常,确认后防止回滚: ### 4.2 强制恢复(需要调试器)
如果连 MCUboot 都进不去(比如误烧了 boot 分区):
```bash ```bash
mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \ # 重新烧录 MCUboot + 应用
image confirm <hash> west flash -d build/mcuboot
```
### 4.3 强制恢复(需要调试器)
如果升级失败导致设备无法启动:
```bash
# 使用调试器强制烧录
west flash -d build west flash -d build
# 或烧录 merged.hex
# J-Link Commander:
# loadfile build/zephyr/merged.hex
# reset
``` ```
--- ---
@ -323,19 +279,29 @@ west flash -d build
| 现象 | 原因 | 解决方案 | | 现象 | 原因 | 解决方案 |
|---|---|---| |---|---|---|
| mcumgr 超时 | 主板未在 3 秒内发送命令 | 缩短重启到上传的间隔 | | smpmgr 超时 | 上传未在 3 秒窗口内开始 | 先确认设备已进 bootloader(串口有 MCUboot 日志)再上传 |
| 签名验证失败 | 密钥不匹配 | 使用构建时的密钥签名 | | 上传中断 | 串口被其他工具/GUI 占用 | 关闭其他串口工具,重新上传(需从头传,不支持断点续传) |
| 上传中断 | 串口断开/超时 | 重新上传(支持断点续传) | | 上传后无法启动 | 上传中断擦坏了 slot0 | 设备停在 bootloader,直接重传或 `west flash -d build` 重烧应用 |
| 设备无法启动 | 固件损坏 | 使用调试器恢复 | | 签名验证失败 | 密钥不匹配 | 用构建时相同的密钥(开发期 `root-ec-p256.pem`)签名 |
| mcumgr 连接失败 | 波特率错误 | 确认使用 115200 | | smpmgr 连接失败 | 端口错误/被占用 | 确认端口号,关闭占用程序后再试 |
--- ---
## 6. 注意事项 ## 6. 注意事项
1. **3 秒窗口**:主板必须在小板重启后 3 秒内发送 mcumgr 命令 1. **3 秒窗口**:小板重启后,MCUboot 等待 SMP 命令 3 秒;超时则正常启动应用
2. **波特率**:应用层协议和 mcumgr 都使用 115200 2. **波特率**:应用层协议和 smpmgr 都使用 115200
3. **签名**:开发阶段使用默认密钥;量产必须替换 3. **签名**:开发阶段使用默认密钥 `root-ec-p256.pem`;量产必须替换(且必须用 ECDSA-P256,RSA 装不进 48KB 分区)
4. **回滚**:不确认新固件 → 重启自动回滚 4. **升级期间勿占用串口**:上传过程中不要打开其他串口工具、不要让 GUI 重新 Connect,否则上传中断会擦坏 slot0
5. **调试器**:首次烧录需要;后续升级不需要 5. **调试器**:首次烧录 MCUboot 需要;后续升级不需要
6. **断电保护**:升级中断电 → MCUboot 回滚,设备不会变砖 6. **断电保护**:升级中断电 → MCUboot 仍在运行,停在 serial recovery 等待区,重新上传即可恢复,设备不会变砖
---
## 7. 工具对比
| 工具 | 安装方式 | 优点 |
|---|---|---|
| **GUI 工具(推荐)** | 集成在 `scripts/test_gui.py`,内置 SMP 客户端 | 图形界面,一键升级,无需额外依赖(smpmgr/mcumgr 均可不要) |
| **smpmgr** | `pip install smpmgr` | 命令行,支持 Serial/BLE/UDP,适合脚本化 |
| **mcumgr CLI** | Go 编译/下载二进制 | Zephyr 官方工具,功能完整 |

View File

@ -19,6 +19,28 @@
- 0x01运行中 - 0x01运行中
- 0x02暂停中 - 0x02暂停中
- 0x03故障中 - 0x03故障中
- 写UID0x07指令生产烧录自定义UID替代MCU芯片UID
- 主板发 0x[7ee7][07][0a][uid 10字节][crc16]
- 将10字节UID写入小板storage分区flash掉电不丢失
- 写入后0x01读ID指令返回的是该UID
- UID编码规则见《UID编码规则.md》
`[设备类型 1B][产品代次 1B][生产年月 4B][序列号 4B]`
- 主板发 0x[7ee7][07][00][crc16]
- 清除自定义UID恢复使用MCU芯片UID
- 小板回 0x[7ee7][07][01][状态][crc16]
- 状态值定义:
- 0x00成功
- 0x01写入失败flash擦除/写入/校验错误)
- 0x02长度错误UID必须为10字节或0字节=清除)
- 说明UID在storage分区0x7000064KB起始处记录格式
`[magic "UID1" 4B][ver 1B][uid 10B][crc16 2B][pad 7B]`
含CRC16-Modbus校验损坏/未写入时自动回退芯片UID
- 灯带调色(调试)0x08指令仅调试固件 CONFIG_APP_DEBUG_LED_STRIP=y 时存在,
生产固件不编译此命令)
- 主板发 0x[7ee7][08][03][R][G][B][crc16]
- 灯带整体设为该 RGB 颜色R/G/B 各 0~255
- [0,0,0] 即熄灭
- 用于开发/产线检查灯光颜色是否满足需求
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@ -45,4 +67,9 @@ Note over S: 系统上电
S -->> M: 回复 温度 数据 S -->> M: 回复 温度 数据
end end
Note over M, S: 生产烧录 UID (0x07 指令)
M ->> S: 写入 12 字节 UID (0x07 指令帧)
S -->> M: 回复状态 (0x00 成功)
M ->> S: 请求 ID 验证 (0x01 指令帧)
S -->> M: 回复写入后的 UID
``` ```

View File

@ -5,7 +5,10 @@
#include <etl/delegate.h> #include <etl/delegate.h>
#include <etl/signal.h> #include <etl/signal.h>
#include <led_strip_indicator/led_strip_indicator.hpp> #include <led_strip_indicator/led_strip_indicator.hpp>
#include <stdio.h>
#include <uart_com/simple_protocal.hpp> #include <uart_com/simple_protocal.hpp>
#include <uid.hpp>
#include <zephyr/app_version.h>
#include <zephyr/drivers/hwinfo.h> #include <zephyr/drivers/hwinfo.h>
#include <zephyr/drivers/sensor.h> #include <zephyr/drivers/sensor.h>
#include <zpp/driver.hpp> #include <zpp/driver.hpp>
@ -21,8 +24,9 @@ public:
using RunningStateSignal = etl::signal<void(HostStatus), 10>; using RunningStateSignal = etl::signal<void(HostStatus), 10>;
static auto Init() -> zpp::error { static auto Init() -> zpp::error {
s_proto->SetRxCallbackTable(kRxCallbackTable); s_proto->SetRxCallbackTable(kRxCallbackTable);
auto size = hwinfo_get_device_id(buff, sizeof(buff)); // 优先使用生产写入的自定义 UID,否则回退芯片 UID
s_id_buff = etl::span<uint8_t>(buff, size); Uid::Init();
s_id_buff = Uid::Get();
return zpp::ok(); return zpp::ok();
} }
static auto SendTemp(sensor_value val) -> void { static auto SendTemp(sensor_value val) -> void {
@ -32,14 +36,28 @@ public:
static auto AddRunningState(RunningStateSignal::slot_type slot) -> bool { static auto AddRunningState(RunningStateSignal::slot_type slot) -> bool {
return s_running_state_sig.connect(slot); return s_running_state_sig.connect(slot);
} }
#ifdef CONFIG_APP_DEBUG_LED_STRIP
/// 调试灯带控制(0x08):仅调试固件(CONFIG_APP_DEBUG_LED_STRIP=y)支持,
/// 生产固件不编译此命令。订阅后收到命令回调 (r, g, b)。
using LedColorSignal = etl::signal<void(uint8_t, uint8_t, uint8_t), 4>;
static auto AddLedColor(LedColorSignal::slot_type slot) -> bool {
return s_led_color_sig.connect(slot);
}
#endif
private: private:
enum Addr : uint8_t { enum Addr : uint8_t {
R_TEMP = 0, R_TEMP = 0,
R_GET_ID, R_GET_ID,
W_RUNNING_STATE, W_RUNNING_STATE,
W_UID = 0x07,
R_VERSION = 0x06,
W_LED = 0x08,
}; };
inline static RunningStateSignal s_running_state_sig{}; inline static RunningStateSignal s_running_state_sig{};
#ifdef CONFIG_APP_DEBUG_LED_STRIP
inline static LedColorSignal s_led_color_sig{};
#endif
static auto RunningState(uart_com::DataType data) -> void { static auto RunningState(uart_com::DataType data) -> void {
if (data.size() != 1) { if (data.size() != 1) {
return; return;
@ -50,6 +68,45 @@ private:
printk("handle get id"); printk("handle get id");
s_proto->Send(R_GET_ID, uart_com::DataType(s_id_buff)); s_proto->Send(R_GET_ID, uart_com::DataType(s_id_buff));
}; };
static auto GetVersion(uart_com::DataType data) -> void {
// Send version string: major.minor.patchlevel
static char ver_buf[16];
snprintf(ver_buf, sizeof(ver_buf), "%d.%d.%d", APP_VERSION_MAJOR,
APP_VERSION_MINOR, APP_PATCHLEVEL);
printk("[com] GetVersion: %s\n", ver_buf);
s_proto->Send(R_VERSION, uart_com::DataType((const uint8_t *)ver_buf,
strlen(ver_buf)));
};
/// 写 UID(0x07):data 为 10 字节生产 UID → 写入 flash;data 为空 →
/// 清除记录(恢复芯片 UID)。 回 ACK:0x00=成功 0x01=写入失败 0x02=长度错误
static auto WriteUid(uart_com::DataType data) -> void {
uint8_t status = 0x02;
if (data.size() == Uid::kUidLen) {
status = (Uid::Write(data) == 0) ? 0x00 : 0x01;
} else if (data.empty()) {
status = (Uid::Write({}) == 0) ? 0x00 : 0x01;
}
if (status == 0x00) {
// 立即生效:后续 GET_ID(0x01)返回新 UID,无需等重启
s_id_buff = Uid::Get();
}
printk("[com] WriteUid len=%zu status=0x%02x\n", data.size(), status);
s_proto->Send(W_UID, uart_com::DataType(&status, 1));
};
#ifdef CONFIG_APP_DEBUG_LED_STRIP
/// 调试灯带(0x08):data = [R][G][B] → 灯带整体改色,用于开发/产线检查
/// 灯光颜色是否满足需求。(0,0,0) 即熄灭。生产固件不含此命令。
static auto SetLed(uart_com::DataType data) -> void {
if (data.size() != 3) {
printk("[com] SetLed: expect 3 bytes (R G B), got %zu\n", data.size());
return;
}
printk("[com] SetLed R=%u G=%u B=%u\n", data[0], data[1], data[2]);
s_led_color_sig(data[0], data[1], data[2]);
}
#endif
#ifdef CONFIG_APP_DFU #ifdef CONFIG_APP_DFU
/// 升级命令(0xFF):data[0]=0x01 进入升级模式,data[0]=0x02 查询状态 /// 升级命令(0xFF):data[0]=0x01 进入升级模式,data[0]=0x02 查询状态
@ -74,8 +131,11 @@ private:
constexpr static std::pair<const uint8_t, constexpr static std::pair<const uint8_t,
uart_com::SimpleProtocal::CallbackType> uart_com::SimpleProtocal::CallbackType>
kRxCallbackTable[] = { kRxCallbackTable[] = {
{R_GET_ID, GetId}, {R_GET_ID, GetId}, {W_RUNNING_STATE, RunningState},
{W_RUNNING_STATE, RunningState}, {R_VERSION, GetVersion}, {W_UID, WriteUid},
#ifdef CONFIG_APP_DEBUG_LED_STRIP
{W_LED, SetLed},
#endif
#ifdef CONFIG_APP_DFU #ifdef CONFIG_APP_DFU
{0xFF, DfuCommand}, {0xFF, DfuCommand},
#endif #endif
@ -83,9 +143,8 @@ private:
inline static auto s_proto = (uart_com::SimpleProtocal *)DEVICE_DT_GET( inline static auto s_proto = (uart_com::SimpleProtocal *)DEVICE_DT_GET(
DT_COMPAT_GET_ANY_STATUS_OKAY(uart_com_simple_protocal)); DT_COMPAT_GET_ANY_STATUS_OKAY(uart_com_simple_protocal));
inline static uint8_t buff[20]; inline static etl::span<const uint8_t> s_id_buff;
inline static auto s_heating_state = false; inline static auto s_heating_state = false;
inline static etl::span<uint8_t> s_id_buff;
}; };
} // namespace ther } // namespace ther

56
include/uid.hpp Normal file
View File

@ -0,0 +1,56 @@
#ifndef __THER_UID_HPP__
#define __THER_UID_HPP__
#include <etl/span.h>
#include <stddef.h>
#include <stdint.h>
namespace ther {
/// 生产 UID 存储:优先使用写入 storage 分区的自定义 UID,否则回退 MCU 芯片 UID。
///
/// UID 共 10 字节(生产编码,见 doc/UID编码规则.md)
/// [0] 设备类型
/// [1] 产品代次
/// [2..6) 生产年月(4 字节)
/// [6..10) 唯一序列号(4 字节)
///
/// 存储记录(24 字节,位于 storage 分区起始,与 8 字节写对齐)
/// [0..4] magic "UID1"
/// [4] ver 0x02
/// [5..15) uid 10 字节
/// [15..17) crc16 CRC16-Modbus([0..15)),LSB 在前
/// [17..24) pad 0xFF
class Uid {
public:
static constexpr size_t kUidLen = 10; ///< 生产 UID 长度
static constexpr size_t kChipUidLen =
12; ///< MCU 芯片 UID 长度(STM32G0 96bit)
static constexpr size_t kRecordLen = 24;
/// 上电读取:存在有效存储记录则使用之,否则回退芯片 UID。
static auto Init() -> void;
/// 当前生效的 UID(10 字节)。
static auto Get() -> etl::span<const uint8_t>;
/// 是否使用自定义存储 UID(否则为芯片 UID)。
static auto IsCustom() -> bool;
/// 写入自定义 UID(必须 10 字节);空 span 表示清除记录(恢复芯片 UID)。
/// 返回 0 成功,负数为错误码。
static auto Write(etl::span<const uint8_t> uid) -> int;
private:
static auto LoadRecord() -> void;
static auto RecordValid(const uint8_t *rec) -> bool;
static auto EraseRecord() -> int;
static auto SaveRecord(const uint8_t *rec) -> int;
inline static uint8_t s_chip_uid[kChipUidLen]{};
inline static size_t s_chip_uid_len = 0;
inline static uint8_t s_custom_uid[kUidLen]{};
inline static etl::span<const uint8_t> s_active{};
inline static bool s_custom{false};
};
} // namespace ther
#endif // __THER_UID_HPP__

5
keys/mykey.pem Normal file
View File

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwlUCVXuz/+irsEXd
xU6IwCQCxnA42StQaD3Hd04QvnyhRANCAAQGpcUOBI1p35AGLNctUd50QL1OmeE3
2fPWMOf9byIXeQFnRTVAJkSo067+GMP5onCHOvx76lREg5IxLRZilfxR
-----END PRIVATE KEY-----

View File

@ -29,3 +29,12 @@ CONFIG_PWM=y
# CONFIG_ADC_LOG_LEVEL_DBG=y # CONFIG_ADC_LOG_LEVEL_DBG=y
# CONFIG_SENSOR_LOG_LEVEL_DBG=y # CONFIG_SENSOR_LOG_LEVEL_DBG=y
# CONFIG_SPI_LOG_LEVEL_DBG=y # CONFIG_SPI_LOG_LEVEL_DBG=y
# ── MCUboot 引导支持(关键!)──
CONFIG_BOOTLOADER_MCUBOOT=y
# ── 镜像管理(补全依赖链,Nordic 教程 Step 5.2)──
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_STREAM_FLASH=y
CONFIG_IMG_MANAGER=y

Binary file not shown.

Binary file not shown.

1
scripts/requirements.txt Normal file
View File

@ -0,0 +1 @@
pyserial==3.5

403
scripts/smp_client.py Normal file
View File

@ -0,0 +1,403 @@
"""
SMP serial client for MCUboot serial recovery (in-process, no external tools).
Implements the mcumgr-over-UART transport used by MCUboot's boot_serial:
- frame: [0x06 0x09 | base64(payload) | 0x0a] (first frame)
[0x04 0x14 | base64(payload) | 0x0a] (continuation frames)
- payload: [len:2 BE][SMP packet:8B hdr + CBOR][crc16-ccitt:2 BE]
- CRC16-CCITT (poly 0x1021, init 0), appended BIG-endian (matches MCUboot,
which uses htons + crc16_ccitt(0, ...) and checks for a zero result).
MCUboot serial recovery always writes uploaded images to slot 0 (primary),
so no test/confirm marking is needed - upload then reset is the whole flow.
"""
import struct
import base64
import hashlib
import time
import sys
import serial
# mcumgr over UART framing constants (match MCUboot boot_serial)
SMP_HEADER = b"\x06\x09"
SMP_FRAG_HEADER = b"\x04\x14"
SMP_NEWLINE = b"\x0a"
FRAME_MTU = 124 # MCUboot BOOT_SERIAL_FRAME_MTU: 127 - 2 - 1
class SMPError(Exception):
"""SMP communication error."""
def crc16_ccitt(data: bytes, crc: int = 0) -> int:
"""CRC16-CCITT (poly 0x1021, init 0), same as Zephyr crc16_ccitt."""
for byte in data:
crc ^= byte << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc
# ── CBOR encoding (requests) ───────────────────────────────────
def cbor_uint(val: int) -> bytes:
if val < 24:
return bytes([val])
elif val < 256:
return bytes([0x18, val])
elif val < 65536:
return bytes([0x19, val >> 8, val & 0xFF])
else:
return bytes([0x1a, (val >> 24) & 0xFF, (val >> 16) & 0xFF,
(val >> 8) & 0xFF, val & 0xFF])
def cbor_bytes(data: bytes) -> bytes:
n = len(data)
if n < 24:
return bytes([0x40 + n]) + data
elif n < 256:
return bytes([0x58, n]) + data
else:
return bytes([0x59, n >> 8, n & 0xFF]) + data
def cbor_str(s: str) -> bytes:
b = s.encode('utf-8')
n = len(b)
if n < 24:
return bytes([0x60 + n]) + b
elif n < 256:
return bytes([0x78, n]) + b
else:
return bytes([0x79, n >> 8, n & 0xFF]) + b
# ── CBOR decoding (responses) ──────────────────────────────────
def _cbor_decode(data: bytes, off: int = 0):
"""Decode one CBOR item. Returns (value, new_offset).
Supports unsigned ints, byte/str strings, maps and arrays - enough
for MCUboot boot_serial responses ({rc, off} maps and image lists).
"""
if off >= len(data):
raise SMPError("CBOR: truncated data")
b = data[off]
off += 1
if b <= 0x17:
return b, off
if b == 0x18:
return data[off], off + 1
if b == 0x19:
return struct.unpack('>H', data[off:off + 2])[0], off + 2
if b == 0x1a:
return struct.unpack('>I', data[off:off + 4])[0], off + 4
if 0x40 <= b <= 0x5b: # byte string
if b <= 0x57:
n = b - 0x40
elif b == 0x58:
n = data[off]; off += 1
elif b == 0x59:
n = struct.unpack('>H', data[off:off + 2])[0]; off += 2
else:
n = struct.unpack('>I', data[off:off + 4])[0]; off += 4
return data[off:off + n], off + n
if 0x60 <= b <= 0x7b: # text string
if b <= 0x77:
n = b - 0x60
elif b == 0x78:
n = data[off]; off += 1
elif b == 0x79:
n = struct.unpack('>H', data[off:off + 2])[0]; off += 2
else:
n = struct.unpack('>I', data[off:off + 4])[0]; off += 4
return data[off:off + n].decode('utf-8', 'replace'), off + n
if 0x80 <= b <= 0x9b: # array
if b <= 0x97:
n = b - 0x80
elif b == 0x98:
n = data[off]; off += 1
elif b == 0x99:
n = struct.unpack('>H', data[off:off + 2])[0]; off += 2
else:
n = struct.unpack('>I', data[off:off + 4])[0]; off += 4
out = []
for _ in range(n):
v, off = _cbor_decode(data, off)
out.append(v)
return out, off
if 0xa0 <= b <= 0xbb: # map
if b <= 0xb7:
n = b - 0xa0
elif b == 0xb8:
n = data[off]; off += 1
elif b == 0xb9:
n = struct.unpack('>H', data[off:off + 2])[0]; off += 2
else:
n = struct.unpack('>I', data[off:off + 4])[0]; off += 4
out = {}
for _ in range(n):
k, off = _cbor_decode(data, off)
v, off = _cbor_decode(data, off)
out[k] = v
return out, off
raise SMPError(f"CBOR: unsupported type byte 0x{b:02x}")
class SMPClient:
"""Minimal, verified SMP serial client for MCUboot serial recovery."""
OP_READ = 0
OP_READ_RSP = 1
OP_WRITE = 2
OP_WRITE_RSP = 3
GROUP_OS = 0
GROUP_IMAGE = 1
IMAGE_STATE_READ = 0
IMAGE_UPLOAD = 1
IMAGE_STATE_WRITE = 2
OS_RESET = 5
def __init__(self, port: str, baudrate: int = 115200, log=None):
self.port = port
self.baudrate = baudrate
self.ser: serial.Serial | None = None
self._log = log or (lambda m: print(f"[SMP] {m}", file=sys.stderr))
# ── connection ──────────────────────────────────────────
def open(self):
self.ser = serial.Serial(self.port, self.baudrate, timeout=0.1)
# 清掉设备刚重启时可能残留的字节
time.sleep(0.2)
self.ser.reset_input_buffer()
self._log(f"opened {self.port} @ {self.baudrate}")
def close(self):
if self.ser:
try:
self.ser.close()
except Exception:
pass
self.ser = None
# ── packet / frame building ─────────────────────────────
def _build_smp_packet(self, op: int, group: int, cmd: int,
payload: bytes = b"", seq: int = 0) -> bytes:
"""SMP packet: 8B header + CBOR payload + 2B BE CRC, wrapped in
[len:2 BE] transport prefix."""
hdr = bytearray(8)
hdr[0] = op
hdr[1] = 0 # flags
hdr[2:4] = struct.pack('>H', len(payload) + 8)
hdr[4:6] = struct.pack('>H', group)
hdr[6] = seq
hdr[7] = cmd
packet = bytes(hdr) + payload
crc = crc16_ccitt(packet)
return struct.pack('>H', len(packet) + 2) + packet + struct.pack('>H', crc)
def _send_frame(self, data: bytes):
"""Base64-encode and send as mcumgr UART frame(s) with fragmentation."""
encoded = base64.b64encode(data)
offset = 0
first = True
while offset < len(encoded):
chunk = encoded[offset:offset + FRAME_MTU]
header = SMP_HEADER if first else SMP_FRAG_HEADER
first = False
self.ser.write(header + chunk + SMP_NEWLINE)
offset += len(chunk)
self.ser.flush()
# ── response receiving ──────────────────────────────────
def _recv_packet(self, timeout: float = 10.0) -> bytes:
"""Read one complete decoded SMP packet (transport payload without
the 2-byte length prefix / 2-byte CRC)."""
start = time.time()
buf = bytearray()
while time.time() - start < timeout:
if self.ser and self.ser.in_waiting:
buf.extend(self.ser.read(self.ser.in_waiting))
while b'\n' in buf:
nl = buf.index(b'\n')
line = bytes(buf[:nl])
del buf[:nl + 1]
if not line:
continue
if line[:2] not in (SMP_HEADER, SMP_FRAG_HEADER):
self._log(f"skip non-SMP line: {line[:16].hex()}")
continue
encoded = line[2:]
try:
decoded = base64.b64decode(encoded, validate=True)
except Exception as e:
self._log(f"base64 decode failed: {e}")
continue
if len(decoded) < 12: # 2 len + 8 hdr + 2 crc
self._log("decoded too short")
continue
total_len = struct.unpack('>H', decoded[:2])[0]
if len(decoded) < 2 + total_len:
self._log(f"short packet: {len(decoded)} < {2 + total_len}")
continue
packet = decoded[2:2 + total_len - 2]
wire_crc = struct.unpack('>H', decoded[2 + total_len - 2:2 + total_len])[0]
if crc16_ccitt(packet) != wire_crc:
self._log("CRC mismatch")
continue
return packet
time.sleep(0.01)
raise SMPError(f"timeout waiting for SMP response ({timeout}s)")
def _request(self, op: int, group: int, cmd: int, payload: bytes = b"",
seq: int = 0, timeout: float = 10.0):
"""Send a request and return the response header + payload."""
self._send_frame(self._build_smp_packet(op, group, cmd, payload, seq))
resp = self._recv_packet(timeout)
if len(resp) < 8:
raise SMPError("response too short")
r_op = resp[0]
r_group = struct.unpack('>H', resp[4:6])[0]
r_seq = resp[6]
r_cmd = resp[7]
r_payload = resp[8:]
if r_op != (self.OP_READ_RSP if op == self.OP_READ else self.OP_WRITE_RSP):
self._log(f"unexpected op {r_op} for request op {op}")
return r_group, r_op, r_seq, r_cmd, r_payload
@staticmethod
def _rc(payload: bytes) -> int:
"""Extract 'rc' from a CBOR map response; missing means 0 (OK)."""
if not payload:
return 0
try:
val, _ = _cbor_decode(payload)
if isinstance(val, dict):
return int(val.get('rc', 0))
except SMPError:
pass
return 0
@staticmethod
def _off(payload: bytes):
"""Extract 'off' from a CBOR map response, or None."""
if not payload:
return None
try:
val, _ = _cbor_decode(payload)
if isinstance(val, dict) and 'off' in val:
return int(val['off'])
except SMPError:
pass
return None
# ── image upload (serial recovery writes slot 0 directly) ──
def upload_image(self, filepath: str, chunk_size: int = 512,
progress_callback=None) -> bool:
"""Upload a signed image to the device. MCUboot serial recovery writes
it to slot 0 (primary); after this call the caller should reset().
chunk_size MCUboot BOOT_SERIAL_MAX_RECEIVE_SIZE(1024)限制:
base64 行长不能超过 1024(本配置),所以块大小取 512 留足余量
progress_callback(offset, total) is called in the calling thread.
"""
with open(filepath, 'rb') as f:
image = f.read()
image_size = len(image)
sha = hashlib.sha256(image).digest()
offset = 0
seq = 0
while offset < image_size:
chunk = image[offset:offset + chunk_size]
payload = bytearray()
payload.append(0xa3) # map: off, data, len
payload.extend(cbor_str("off"))
payload.extend(cbor_uint(offset))
payload.extend(cbor_str("data"))
payload.extend(cbor_bytes(chunk))
payload.extend(cbor_str("len"))
payload.extend(cbor_uint(image_size))
if offset == 0:
payload[0] = 0xa4 # add sha
payload.extend(cbor_str("sha"))
payload.extend(cbor_bytes(sha))
_, _, _, _, r_payload = self._request(
self.OP_WRITE, self.GROUP_IMAGE, self.IMAGE_UPLOAD,
bytes(payload), seq=seq, timeout=20.0)
rc = self._rc(r_payload)
if rc != 0:
self._log(f"upload rejected at offset {offset}: rc={rc}")
return False
resp_off = self._off(r_payload)
if resp_off is None:
resp_off = offset + len(chunk)
if resp_off <= offset:
# MCUboot wants a retransmission from resp_off; go back.
offset = resp_off
else:
offset = resp_off
seq = (seq + 1) & 0xFF
if progress_callback:
progress_callback(min(offset, image_size), image_size)
return offset >= image_size
def image_state_write(self, hash_hex: str, confirm: bool = False) -> bool:
"""Mark an image for test/confirm swap (only needed for slot1 uploads;
serial recovery writes slot0 directly so this is normally unused)."""
payload = bytearray()
payload.append(0xa2)
payload.extend(cbor_str("hash"))
payload.extend(cbor_bytes(bytes.fromhex(hash_hex)))
payload.extend(cbor_str("confirm"))
payload.extend(cbor_uint(1 if confirm else 0))
_, _, _, _, r_payload = self._request(
self.OP_WRITE, self.GROUP_IMAGE, self.IMAGE_STATE_WRITE,
bytes(payload), timeout=10.0)
return self._rc(r_payload) == 0
def image_state_read(self) -> list:
"""Read image list. Returns list of dicts (slot, version, flags, hash)."""
_, _, _, _, r_payload = self._request(
self.OP_READ, self.GROUP_IMAGE, self.IMAGE_STATE_READ, timeout=10.0)
if self._rc(r_payload) != 0:
raise SMPError(f"image state read failed rc={self._rc(r_payload)}")
val, _ = _cbor_decode(r_payload)
images = []
if isinstance(val, dict) and 'images' in val and isinstance(val['images'], list):
for entry in val['images']:
if not isinstance(entry, dict):
continue
images.append({
'slot': int(entry.get('slot', -1)),
'version': entry.get('version', '?'),
'flags': entry.get('flags', 0),
'hash': entry.get('hash', b''),
})
return images
def reset(self, timeout: float = 5.0) -> bool:
"""Reset the device (MCUboot replies, then reboots)."""
self._send_frame(self._build_smp_packet(
self.OP_WRITE, self.GROUP_OS, self.OS_RESET))
# MCUboot bs_reset replies with {rc:0} then reboots; read it.
try:
resp = self._recv_packet(timeout)
r_payload = resp[8:] if len(resp) >= 8 else b""
return self._rc(r_payload) == 0
except SMPError:
# Some builds reset without replying; treat as success.
return True

View File

@ -9,8 +9,15 @@ from tkinter import ttk, scrolledtext, messagebox
import threading import threading
import time import time
import sys import sys
import os
import struct
import base64
from enum import IntEnum from enum import IntEnum
# Add scripts directory to path for smp_client import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from smp_client import SMPClient
try: try:
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
@ -28,12 +35,39 @@ class Cmd(IntEnum):
RUNNING_STATE = 0x02 RUNNING_STATE = 0x02
W_HEATING = 0x03 W_HEATING = 0x03
R_HEATING = 0x04 R_HEATING = 0x04
READ_VERSION = 0x06
WRITE_UID = 0x07
SET_LED = 0x08 # 调试用:灯带 RGB(仅调试固件支持)
READ_POLE_NTC = 0x64 READ_POLE_NTC = 0x64
READ_HEATING_NTC = 0x65 READ_HEATING_NTC = 0x65
HEATING_STATE_NAMES = {0: "OFF", 1: "ON"} HEATING_STATE_NAMES = {0: "OFF", 1: "ON"}
STATE_NAMES = {0: "Standby", 1: "Running", 2: "Pause", 3: "Error"} STATE_NAMES = {0: "Standby", 1: "Running", 2: "Pause", 3: "Error"}
# UID 编码规则(与 doc/UID编码规则.md 一致)
UID_TYPE_NAMES = {
0x01: "冲击波",
0x02: "光磁/热疗",
0x03: "外置按摩头",
0xF0: "通用(未分类)",
0xFF: "未知",
}
UID_DEV_NAMES = {
(0x01, 0x01): "标准冲击波手柄",
(0x01, 0x02): "高能型冲击波手柄",
(0x02, 0x01): "光磁手柄",
(0x02, 0x02): "热疗手柄",
(0x02, 0x03): "增强型光磁手柄",
(0x03, 0x01): "捶打按摩头",
(0x03, 0x02): "揉捏按摩头",
(0x03, 0x03): "热敷按摩头",
(0x03, 0x04): "EMS 电磁按摩头",
(0x03, 0x05): "DMS 深层肌肉刺激仪",
(0x03, 0x06): "负压吸力按摩头",
(0x03, 0xF0): "通用按摩头(未分类)",
(0x03, 0xFF): "未知设备(自动探测)",
}
# ── Protocol helpers (from test.py) ─────────────────────────── # ── Protocol helpers (from test.py) ───────────────────────────
def crc16_modbus(data: bytes) -> int: def crc16_modbus(data: bytes) -> int:
@ -191,10 +225,52 @@ class SerialWorker:
# ── GUI Application ─────────────────────────────────────────── # ── GUI Application ───────────────────────────────────────────
def read_version_file(filepath):
"""Read Zephyr VERSION file and return version dict"""
version = {'major': 0, 'minor': 0, 'patchlevel': 0, 'tweak': 0, 'extraversion': ''}
try:
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if '=' not in line or line.startswith('#'):
continue
key, val = line.split('=', 1)
key = key.strip()
val = val.strip().strip('"').strip("'")
if key == 'VERSION_MAJOR':
version['major'] = int(val) if val else 0
elif key == 'VERSION_MINOR':
version['minor'] = int(val) if val else 0
elif key == 'PATCHLEVEL':
version['patchlevel'] = int(val) if val else 0
elif key == 'VERSION_TWEAK':
version['tweak'] = int(val) if val else 0
elif key == 'EXTRAVERSION':
version['extraversion'] = val
except (FileNotFoundError, ValueError):
pass
return version
def format_version(version):
"""Format version dict to string like '0.0.9'"""
v = f"{version['major']}.{version['minor']}.{version['patchlevel']}"
if version['tweak']:
v += f"+{version['tweak']}"
if version['extraversion']:
v += f"-{version['extraversion']}"
return v
class PhotomagneticGUI: class PhotomagneticGUI:
def __init__(self): def __init__(self):
# Read version from VERSION file
version_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'VERSION')
self._version = read_version_file(version_path)
self._version_str = format_version(self._version)
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("Photomagnetic Communication Tool") self.root.title(f"Photomagnetic Communication Tool v{self._version_str}")
self.root.geometry("850x700") self.root.geometry("850x700")
self.root.minsize(700, 550) self.root.minsize(700, 550)
@ -215,16 +291,17 @@ class PhotomagneticGUI:
self._port_var = tk.StringVar() self._port_var = tk.StringVar()
self._port_combo = ttk.Combobox(conn_frame, textvariable=self._port_var, width=25) self._port_combo = ttk.Combobox(conn_frame, textvariable=self._port_var, width=25)
self._port_combo.grid(row=0, column=1, padx=4, sticky=tk.W) self._port_combo.grid(row=0, column=1, padx=4, sticky=tk.W)
ttk.Button(conn_frame, text="", width=3, self._scan_btn = ttk.Button(conn_frame, text="", width=3,
command=self._scan_ports).grid(row=0, column=2, padx=2) command=self._scan_ports)
self._scan_btn.grid(row=0, column=2, padx=2)
ttk.Label(conn_frame, text="Baud:").grid(row=0, column=3, padx=(12, 0), sticky=tk.W) ttk.Label(conn_frame, text="Baud:").grid(row=0, column=3, padx=(12, 0), sticky=tk.W)
self._baud_var = tk.StringVar(value="115200") self._baud_var = tk.StringVar(value="115200")
baud_combo = ttk.Combobox(conn_frame, textvariable=self._baud_var, self._baud_combo = ttk.Combobox(conn_frame, textvariable=self._baud_var,
values=("9600", "19200", "38400", "57600", values=("9600", "19200", "38400", "57600",
"115200", "230400", "460800"), "115200", "230400", "460800"),
width=10) width=10)
baud_combo.grid(row=0, column=4, padx=4, sticky=tk.W) self._baud_combo.grid(row=0, column=4, padx=4, sticky=tk.W)
self._connect_btn = ttk.Button(conn_frame, text="Connect", command=self._toggle_connect) self._connect_btn = ttk.Button(conn_frame, text="Connect", command=self._toggle_connect)
self._connect_btn.grid(row=0, column=5, padx=(12, 0)) self._connect_btn.grid(row=0, column=5, padx=(12, 0))
@ -234,6 +311,19 @@ class PhotomagneticGUI:
conn_frame.columnconfigure(6, weight=1) conn_frame.columnconfigure(6, weight=1)
# Version display
version_frame = ttk.Frame(self.root)
version_frame.pack(fill=tk.X, padx=8, pady=0)
ttk.Label(version_frame, text="Tool Version:",
font=("Consolas", 9), foreground="#666").pack(side=tk.LEFT)
ttk.Label(version_frame, text=f"v{self._version_str}",
font=("Consolas", 9, "bold"), foreground="#333").pack(side=tk.LEFT, padx=(2, 12))
ttk.Label(version_frame, text="Firmware Version:",
font=("Consolas", 9), foreground="#666").pack(side=tk.LEFT)
self._fw_version_lbl = ttk.Label(version_frame, text="N/A",
font=("Consolas", 9, "bold"), foreground="#333")
self._fw_version_lbl.pack(side=tk.LEFT, padx=2)
# ── Main content: left (controls) + right (display) ── # ── Main content: left (controls) + right (display) ──
main_frame = ttk.Frame(self.root) main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=2) main_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=2)
@ -248,11 +338,13 @@ class PhotomagneticGUI:
cmd_frame = ttk.LabelFrame(left, text="Commands", padding=8) cmd_frame = ttk.LabelFrame(left, text="Commands", padding=8)
cmd_frame.pack(fill=tk.X) cmd_frame.pack(fill=tk.X)
# Row 1: GET_ID + State # Row 1: GET_ID + Version + State
r1 = ttk.Frame(cmd_frame) r1 = ttk.Frame(cmd_frame)
r1.pack(fill=tk.X, pady=2) r1.pack(fill=tk.X, pady=2)
ttk.Button(r1, text="Get Device ID", width=14, ttk.Button(r1, text="Get Device ID", width=14,
command=self._cmd_get_id).pack(side=tk.LEFT) command=self._cmd_get_id).pack(side=tk.LEFT)
ttk.Button(r1, text="Read Version", width=12,
command=self._cmd_read_version).pack(side=tk.LEFT, padx=4)
ttk.Label(r1, text="State:").pack(side=tk.LEFT, padx=(10, 2)) ttk.Label(r1, text="State:").pack(side=tk.LEFT, padx=(10, 2))
self._state_combo = ttk.Combobox(r1, values=["Standby", "Running", "Pause", "Error"], self._state_combo = ttk.Combobox(r1, values=["Standby", "Running", "Pause", "Error"],
state="readonly", width=10) state="readonly", width=10)
@ -306,6 +398,44 @@ class PhotomagneticGUI:
self._loop_btn = ttk.Button(r5, text="▶ Loop GET_ID", width=14, self._loop_btn = ttk.Button(r5, text="▶ Loop GET_ID", width=14,
command=self._toggle_loop) command=self._toggle_loop)
self._loop_btn.pack(side=tk.LEFT) self._loop_btn.pack(side=tk.LEFT)
ttk.Separator(r5, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8)
self._upgrade_btn = ttk.Button(r5, text="⬆ Upgrade Firmware", width=18,
command=self._cmd_upgrade)
self._upgrade_btn.pack(side=tk.LEFT, padx=4)
# Row 6: UID 写入(生产烧录自定义 UID,10 字节编码)
r6 = ttk.Frame(cmd_frame)
r6.pack(fill=tk.X, pady=2)
ttk.Label(r6, text="UID(10B):").pack(side=tk.LEFT)
self._uid_var = tk.StringVar()
uid_entry = ttk.Entry(r6, textvariable=self._uid_var, width=24)
uid_entry.pack(side=tk.LEFT, padx=2)
ttk.Button(r6, text="Write UID", width=10,
command=self._cmd_write_uid).pack(side=tk.LEFT, padx=2)
ttk.Button(r6, text="Use Chip UID", width=12,
command=self._cmd_clear_uid).pack(side=tk.LEFT, padx=2)
# Row 7: LED 调色调试(需调试固件 CONFIG_APP_DEBUG_LED_STRIP=y,
# 生产固件无 0x08 命令)
r7 = ttk.Frame(cmd_frame)
r7.pack(fill=tk.X, pady=2)
ttk.Label(r7, text="LED:").pack(side=tk.LEFT)
self._led_r_var = tk.IntVar(value=255)
self._led_g_var = tk.IntVar(value=255)
self._led_b_var = tk.IntVar(value=255)
# 允许键盘直接输入(0~255),上下箭头仍可用
vcmd = (self.root.register(self._validate_led_channel), "%P")
for var in (self._led_r_var, self._led_g_var, self._led_b_var):
tk.Spinbox(r7, from_=0, to=255, textvariable=var, width=4,
validate="key", validatecommand=vcmd).pack(
side=tk.LEFT, padx=1)
self._led_swatch = tk.Label(r7, text=" ", bg="#FFFFFF", width=3,
relief=tk.SUNKEN)
self._led_swatch.pack(side=tk.LEFT, padx=4)
ttk.Button(r7, text="Set", width=4,
command=self._cmd_set_led).pack(side=tk.LEFT, padx=2)
ttk.Button(r7, text="Off", width=4,
command=self._cmd_led_off).pack(side=tk.LEFT, padx=2)
# ── Right top: Live display ── # ── Right top: Live display ──
disp_frame = ttk.LabelFrame(right, text="Live Values", padding=6) disp_frame = ttk.LabelFrame(right, text="Live Values", padding=6)
@ -409,6 +539,14 @@ class PhotomagneticGUI:
self.worker.send(Cmd.GET_ID) self.worker.send(Cmd.GET_ID)
self.on_log("→ GET_ID sent") self.on_log("→ GET_ID sent")
def _cmd_read_version(self):
"""Request firmware version from device"""
if not self._check_connected():
return
self.worker.send(Cmd.READ_VERSION)
self.on_log("→ VERSION request sent (0x06)")
print(f"[DEBUG] VERSION request sent: cmd=0x{Cmd.READ_VERSION:02X}", flush=True)
def _cmd_set_state(self): def _cmd_set_state(self):
if not self._check_connected(): if not self._check_connected():
return return
@ -462,7 +600,168 @@ class PhotomagneticGUI:
self.on_log(f"→ Raw: CMD=0x{cmd:02X} data={data.hex()}") self.on_log(f"→ Raw: CMD=0x{cmd:02X} data={data.hex()}")
self._raw_var.set("") self._raw_var.set("")
# ── UID ────────────────────────────────────────────────
def _cmd_write_uid(self):
"""写生产 UID(10 字节 = 20 个 hex 字符,编码见 doc/UID编码规则.md)"""
if not self._check_connected():
return
hex_str = self._uid_var.get().strip().replace(" ", "")
if not hex_str:
self.on_log("✗ UID 为空")
return
try:
uid = bytes.fromhex(hex_str)
except ValueError:
self.on_log("✗ UID 不是合法 hex(20 个 hex 字符 = 10 字节)")
return
if len(uid) != 10:
self.on_log(f"✗ UID 长度错误: {len(uid)} 字节,应为 10 字节")
return
self.worker.send(Cmd.WRITE_UID, uid)
self.on_log(f"→ Write UID: {uid.hex(' ').upper()} (10B)")
def _cmd_clear_uid(self):
"""清除自定义 UID,恢复芯片 UID"""
if not self._check_connected():
return
self.worker.send(Cmd.WRITE_UID, b"")
self.on_log("→ Clear UID (restore chip UID)")
self._uid_var.set("")
# ── LED 调色调试 ────────────────────────────────────────
def _validate_led_channel(self, new_text: str) -> bool:
"""RGB 输入框键盘校验:允许空串(编辑中)或 0~255 的整数"""
if new_text == "":
return True
return (new_text.isascii() and new_text.isdigit()
and int(new_text) <= 255)
def _cmd_set_led(self):
"""调试用:灯带整体设为 RGB 颜色(0x08,需 CONFIG_APP_DEBUG_LED_STRIP=y) """
if not self._check_connected():
return
try:
r, g, b = (self._led_r_var.get(), self._led_g_var.get(),
self._led_b_var.get())
except tk.TclError:
r = g = b = 0 # 输入框为空(编辑中)时按 0 处理
r, g, b = max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b))
self.worker.send(Cmd.SET_LED, bytes([r, g, b]))
self._led_swatch.configure(bg=f"#{r:02X}{g:02X}{b:02X}")
self.on_log(f"→ Set LED RGB({r},{g},{b}) #{r:02X}{g:02X}{b:02X}")
def _cmd_led_off(self):
"""调试用:熄灭灯带"""
if not self._check_connected():
return
self.worker.send(Cmd.SET_LED, b"\x00\x00\x00")
self._led_swatch.configure(bg="#000000")
self.on_log("→ LED off")
# ── Monitor ────────────────────────────────────────── # ── Monitor ──────────────────────────────────────────
def _cmd_upgrade(self):
"""Upgrade firmware: send upgrade cmd -> wait reboot -> SMP upload in-process"""
if not self.worker.is_open:
self._append_log("Please connect serial port first")
return
from tkinter import filedialog
firmware = filedialog.askopenfilename(
title="Select firmware file",
filetypes=[("Signed Binary", "*.signed.bin"), ("Binary", "*.bin"), ("All", "*.*")],
initialdir=os.path.join(os.getcwd(), "build", "app_photomagnetic", "zephyr")
)
if not firmware:
return
self._append_log(f"Upgrade start: {os.path.basename(firmware)}")
self._upgrade_btn.configure(state=tk.DISABLED)
# 升级期间锁定连接相关控件:禁止用户中途 Connect/换端口,否则会抢占
# 串口导致 smpmgr 上传中断、slot0 被擦除一半 → 设备变砖。
for w in (self._connect_btn, self._port_combo, self._baud_combo, self._scan_btn):
w.configure(state=tk.DISABLED)
def upgrade_thread():
try:
port = self._port_var.get()
# Step 1: Close GUI serial connection first(释放协议串口)
self._append_log("Preparing serial port...")
self._disconnect()
# Step 2: Send upgrade command using separate serial connection
self._append_log("-> Sending upgrade command...")
body = bytes([0xFF, 0x01, 0x01])
crc = crc16_modbus(body)
upgrade_frame = HEADER + body + struct.pack('<H', crc)
try:
with serial.Serial(port, 115200, timeout=0.1) as s:
s.write(upgrade_frame)
self._append_log(f" Sent: {upgrade_frame.hex()}")
except Exception as e:
self._append_log(f"Failed to send upgrade command: {e}")
return
# Step 3: Wait for device to reboot into MCUboot
self._append_log("Waiting for device to enter bootloader...")
time.sleep(1.2)
# Step 4: 进程内 SMP 上传(不再调用外部 smpmgr 命令)
# MCUboot serial recovery 固定把镜像写到 slot0(主槽),
# 上传完 reset 即可,无需 test/confirm。
from smp_client import SMPClient
client = SMPClient(port, 115200, log=lambda m: None)
try:
client.open()
self._append_log("Connected to bootloader, uploading...")
self._upgrade_last_pct = -1
ok = client.upload_image(firmware,
progress_callback=self._upgrade_progress)
if not ok:
self._append_log(
"⚠ 上传失败。设备可能已停在 bootloader,重新点升级即可重传;"
"或 west flash -d build 重烧应用")
return
self._append_log("Upload complete (100%)")
self._append_log("Resetting device...")
client.reset()
time.sleep(1)
finally:
client.close()
self._append_log("Upgrade complete! Device will reboot with new firmware")
# Step 6: Wait for device to boot new firmware
self._append_log("Waiting for device to boot...")
time.sleep(4)
# Step 7: Reconnect
self._connect()
except Exception as e:
self._append_log(f"Upgrade failed: {e}")
finally:
def _restore():
self._upgrade_btn.configure(state=tk.NORMAL)
for w in (self._connect_btn, self._port_combo,
self._baud_combo, self._scan_btn):
w.configure(state=tk.NORMAL)
self.root.after(0, _restore)
threading.Thread(target=upgrade_thread, daemon=True).start()
def _upgrade_progress(self, offset: int, total: int):
"""SMP upload progress callback (runs in upgrade thread)."""
pct = offset * 100 // total if total else 0
if pct // 10 != getattr(self, '_upgrade_last_pct', -1) // 10 or pct >= 100:
self._upgrade_last_pct = pct
self.root.after(0, self._append_log, f" Uploading... {offset}/{total} ({pct}%)")
def _toggle_monitor(self): def _toggle_monitor(self):
if self.worker.monitoring: if self.worker.monitoring:
self._stop_monitor() self._stop_monitor()
@ -524,6 +823,7 @@ class PhotomagneticGUI:
# ── Frame handling (runs in main thread via after) ─── # ── Frame handling (runs in main thread via after) ───
def _handle_frame(self, cmd: int, data: bytes): def _handle_frame(self, cmd: int, data: bytes):
raw_hex = data.hex(" ") raw_hex = data.hex(" ")
print(f"[DEBUG] Frame received: cmd=0x{cmd:02X}, data={raw_hex}", flush=True)
# ── 0x00 TEMP: 设备主动推送, 只更新 live value, 不输出 log ── # ── 0x00 TEMP: 设备主动推送, 只更新 live value, 不输出 log ──
if cmd == Cmd.TEMP: if cmd == Cmd.TEMP:
@ -541,7 +841,25 @@ class PhotomagneticGUI:
id_str = data.hex(" ") id_str = data.hex(" ")
id_show = id_str[:47] + "..." if len(id_str) > 50 else id_str id_show = id_str[:47] + "..." if len(id_str) > 50 else id_str
self._disp_labels["dev_id"].configure(text=id_show) self._disp_labels["dev_id"].configure(text=id_show)
self._append_log(f"🆔 Device ID ({len(data)}B): {raw_hex}") if len(data) == 10:
# 生产 UID:[设备类型][产品代次][生产年月 4B][序列号 4B]
dev_type, gen = data[0], data[1]
ym = data[2:6].hex()
serial = data[6:10].hex()
tname = UID_TYPE_NAMES.get(dev_type, f"0x{dev_type:02X}")
dev = UID_DEV_NAMES.get((dev_type, gen))
desc = f"{tname}·代{gen}" + (f" ({dev})" if dev else "")
self._append_log(
f"🆔 Device ID (10B UID): {raw_hex} | {desc} | "
f"年月={ym} 序列号={serial}")
else:
self._append_log(f"🆔 Device ID ({len(data)}B): {raw_hex}")
elif cmd == Cmd.READ_VERSION:
# 固件回复: ASCII 字符串,如 b"0.0.14"
ver = data.decode("utf-8", "replace").strip("\x00 \r\n")
self._fw_version_lbl.configure(text=ver if ver else "?")
self._append_log(f" Firmware version: {ver if ver else '(empty)'}")
elif cmd == Cmd.RUNNING_STATE: elif cmd == Cmd.RUNNING_STATE:
state = data[0] if data else -1 state = data[0] if data else -1
@ -577,6 +895,19 @@ class PhotomagneticGUI:
t = temp_from_data(data) t = temp_from_data(data)
self._append_log(f"🔥 Pad NTC: {t:.2f}°C ({raw_hex})") self._append_log(f"🔥 Pad NTC: {t:.2f}°C ({raw_hex})")
elif cmd == Cmd.WRITE_UID:
status = data[0] if data else 0xFF
if status == 0x00:
self._append_log("✓ UID 写入成功")
# 刷新 ID 显示
self.worker.send(Cmd.GET_ID)
elif status == 0x01:
self._append_log("✗ UID 写入失败(flash 错误)")
elif status == 0x02:
self._append_log("✗ UID 长度错误(需 12 字节或空=清除)")
else:
self._append_log(f"📦 UID ACK: status=0x{status:02X}")
else: else:
self._append_log(f"📦 CMD=0x{cmd:02X} data={raw_hex}") self._append_log(f"📦 CMD=0x{cmd:02X} data={raw_hex}")

View File

@ -3,6 +3,9 @@
#include <led.hpp> #include <led.hpp>
#include <led_strip_indicator/led_strip_indicator.hpp> #include <led_strip_indicator/led_strip_indicator.hpp>
#include <zephyr/init.h> #include <zephyr/init.h>
#ifdef CONFIG_APP_DEBUG_LED_STRIP
#include <zephyr/drivers/led_strip.h> // struct led_rgb
#endif
using namespace ther; using namespace ther;
namespace { namespace {
using McuState = Led<LED_DT_SPEC_GET(DT_NODELABEL(led_mcu_state))>; using McuState = Led<LED_DT_SPEC_GET(DT_NODELABEL(led_mcu_state))>;
@ -20,6 +23,19 @@ static auto Init() -> int {
Inf::Off(); Inf::Off();
} }
}); });
#ifdef CONFIG_APP_DEBUG_LED_STRIP
// 调试灯带控制:生产固件不编译此段
Com::AddLedColor([](uint8_t r, uint8_t g, uint8_t b) {
led_rgb color{};
color.r = r;
color.g = g;
color.b = b;
const auto ret = indicator->ChangeColor(color);
if (!ret) {
printk("[%s] ChangeColor failed: %d\n", MODULE, (int)ret.code_id());
}
});
#endif
indicator->Status(Com::HostStatus::STANDBY); indicator->Status(Com::HostStatus::STANDBY);
using namespace std::chrono_literals; using namespace std::chrono_literals;
McuState::Flash(1s); McuState::Flash(1s);

View File

@ -1,8 +1,10 @@
#include <zephyr/app_version.h>
#include <zephyr/device.h> #include <zephyr/device.h>
#include <zephyr/kernel.h> #include <zephyr/kernel.h>
auto main(void) -> int { auto main(void) -> int {
printk("[main] init on %s\n", CONFIG_BOARD); printk("[main] init on %s\n", CONFIG_BOARD);
printk("[main] firmware version: %d.%d.%d\n", APP_VERSION_MAJOR, APP_VERSION_MINOR, APP_PATCHLEVEL);
while (1) { while (1) {
k_sleep(K_SECONDS(1)); k_sleep(K_SECONDS(1));
} }

165
src/uid.cpp Normal file
View File

@ -0,0 +1,165 @@
#include <uid.hpp>
#include <string.h>
#include <zephyr/drivers/flash.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/storage/flash_map.h>
#include <zephyr/sys/printk.h>
namespace ther {
namespace {
constexpr uint8_t kMagic[4] = {'U', 'I', 'D', '1'};
constexpr uint8_t kVersion = 0x02;
constexpr size_t kCrcOffset = 15; // CRC16 覆盖 [0, 15) = magic+ver+uid(10B)
auto Crc16Modbus(const uint8_t *data, size_t len) -> uint16_t {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (int b = 0; b < 8; ++b) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
}
return crc;
}
/// 打开 storage 分区;失败返回 nullptr。
auto OpenArea(const flash_area **area) -> bool {
const int id = PARTITION_ID(storage_partition);
if (flash_area_open(id, area) != 0) {
printk("[uid] open storage partition failed\n");
return false;
}
if (!device_is_ready(flash_area_get_device(*area))) {
printk("[uid] flash device not ready\n");
flash_area_close(*area);
return false;
}
return true;
}
/// 分区起始(offset 0)所在 flash 页的大小;erase 需整页对齐。
///
/// 注意:不要用 flash_area_get_sectors(..., count=1) 来取——count 的语义是
/// “数组容量”,分区有多个页时会返回 -ENOMEM(见 flash_map_layout.c)。
auto FirstPageSize(const flash_area *area) -> uint32_t {
struct flash_pages_info info{};
if (flash_get_page_info_by_offs(flash_area_get_device(area), area->fa_off,
&info) != 0) {
return 0;
}
return info.size;
}
} // namespace
auto Uid::Init() -> void {
// 芯片 UID 作为兜底(保持 12 字节,不做截断)
s_chip_uid_len = hwinfo_get_device_id(s_chip_uid, sizeof(s_chip_uid));
s_active = etl::span<const uint8_t>(s_chip_uid, s_chip_uid_len);
s_custom = false;
const flash_area *area = nullptr;
if (!OpenArea(&area)) {
return;
}
uint8_t rec[kRecordLen];
const int rc = flash_area_read(area, 0, rec, sizeof(rec));
flash_area_close(area);
if (rc != 0) {
printk("[uid] read failed, use chip uid\n");
return;
}
if (RecordValid(rec)) {
memcpy(s_custom_uid, &rec[5], kUidLen);
s_active = etl::span<const uint8_t>(s_custom_uid, kUidLen);
s_custom = true;
printk("[uid] custom uid active\n");
} else {
printk("[uid] no valid record, use chip uid\n");
}
}
auto Uid::Get() -> etl::span<const uint8_t> { return s_active; }
auto Uid::IsCustom() -> bool { return s_custom; }
auto Uid::Write(etl::span<const uint8_t> uid) -> int {
if (!uid.empty() && uid.size() != kUidLen) {
return -EINVAL;
}
// 组装记录;空 span 表示清除(记录全 0xFF = 擦除态)
uint8_t rec[kRecordLen];
memset(rec, 0xFF, sizeof(rec));
if (!uid.empty()) {
memcpy(rec, kMagic, sizeof(kMagic));
rec[4] = kVersion;
memcpy(&rec[5], uid.data(), kUidLen);
const uint16_t crc = Crc16Modbus(rec, kCrcOffset);
rec[kCrcOffset] = static_cast<uint8_t>(crc & 0xFF);
rec[kCrcOffset + 1] = static_cast<uint8_t>(crc >> 8);
}
const flash_area *area = nullptr;
if (!OpenArea(&area)) {
return -ENODEV;
}
// 记录位于分区起始,只需擦除 offset 0 所在的 flash 页(整页对齐)
const uint32_t page_size = FirstPageSize(area);
if (page_size == 0) {
flash_area_close(area);
return -EIO;
}
int rc = flash_area_erase(area, 0, page_size);
if (rc != 0) {
printk("[uid] erase failed rc=%d\n", rc);
flash_area_close(area);
return rc;
}
// kRecordLen 与 STM32G0 写粒度(8B)对齐
rc = flash_area_write(area, 0, rec, kRecordLen);
if (rc != 0) {
printk("[uid] write failed rc=%d\n", rc);
flash_area_close(area);
return rc;
}
// 回读校验
uint8_t rd[kRecordLen];
if (flash_area_read(area, 0, rd, sizeof(rd)) != 0 ||
memcmp(rd, rec, sizeof(rd)) != 0) {
printk("[uid] verify failed\n");
flash_area_close(area);
return -EIO;
}
flash_area_close(area);
// 更新生效 UID
if (!uid.empty()) {
memcpy(s_custom_uid, uid.data(), kUidLen);
s_active = etl::span<const uint8_t>(s_custom_uid, kUidLen);
s_custom = true;
printk("[uid] custom uid written\n");
} else {
s_active = etl::span<const uint8_t>(s_chip_uid, s_chip_uid_len);
s_custom = false;
printk("[uid] custom uid cleared, use chip uid\n");
}
return 0;
}
auto Uid::RecordValid(const uint8_t *rec) -> bool {
if (memcmp(rec, kMagic, sizeof(kMagic)) != 0) {
return false;
}
if (rec[4] != kVersion) {
return false;
}
const uint16_t crc =
static_cast<uint16_t>(rec[kCrcOffset] | (rec[kCrcOffset + 1] << 8));
return Crc16Modbus(rec, kCrcOffset) == crc;
}
} // namespace ther

View File

@ -1,5 +1,14 @@
# 启用 MCUboot bootloader # 启用 MCUboot bootloader
SB_CONFIG_BOOTLOADER_MCUBOOT=y SB_CONFIG_BOOTLOADER_MCUBOOT=y
# 固件签名密钥(开发阶段用 MCUboot 自带密钥,量产替换为自生成密钥) # 把 MCUboot + 应用合并成一个 hex(方便一次性烧录/产线),
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${ZEPHYR_BASE}/../bootloader/mcuboot/root-rsa-2048.pem" # 产物: build/merged_<board>.hex(如 merged_dr2501a_g0b0ce_stm32g0b0xx.hex)
SB_CONFIG_MERGED_HEX_FILES=y
# 固件签名密钥(开发阶段使用默认密钥)
# 注意:用 ECDSA-P256 而不是 RSA-2048 —— RSA 会把 MCUboot 撑到 ~57KB,
# 超过 48KB 的 boot 分区;ECDSA 验证代码小很多,MCUboot 能装进 48KB。
# 签名类型必须在这里用 SB_CONFIG_BOOT_SIGNATURE_TYPE_* 指定,
# 写 mcuboot.conf 里的 CONFIG_BOOT_SIGNATURE_TYPE_* 会被 sysbuild 强制覆盖。
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/keys/mykey.pem"

View File

@ -1,8 +1,19 @@
# ── Serial Recovery 配置 ── # ── MCUboot 日志 ──
# 裁掉多余驱动后有 ~14KB 余量,恢复 INF 日志方便排障(usart3 调试口可见)
CONFIG_LOG=y
CONFIG_MCUBOOT_LOG_LEVEL_INF=y
# ── Serial Recovery 配置(Nordic 教程 Step 3.1)──
CONFIG_MCUBOOT_SERIAL=y CONFIG_MCUBOOT_SERIAL=y
CONFIG_BOOT_SERIAL_UART=y CONFIG_BOOT_SERIAL_UART=y
# 禁用 GPIO 按钮检测 # 禁用 UART console,避免与 Serial Recovery 争用 UART(Nordic 教程 Step 3.2)
# 注意:教程里 console 和 serial recovery 共用同一个 UART 才需要禁。
# 本板 console 在 usart3(调试口)、serial recovery 在 usart1(协议口),
# 两个 UART 互不冲突,打开 console 才能看到 MCUboot 的启动日志,便于排障。
CONFIG_UART_CONSOLE=y
# 禁用 GPIO 按钮检测(我们通过升级命令触发,不需要按钮)
CONFIG_BOOT_SERIAL_ENTRANCE_GPIO=n CONFIG_BOOT_SERIAL_ENTRANCE_GPIO=n
# 等待 mcumgr 命令触发进入 serial recovery # 等待 mcumgr 命令触发进入 serial recovery
@ -13,10 +24,27 @@ CONFIG_BOOT_SERIAL_WAIT_FOR_DFU_TIMEOUT=3000
CONFIG_BOOT_WATCHDOG_FEED=y CONFIG_BOOT_WATCHDOG_FEED=y
# ── 签名验证 ── # ── 签名验证 ──
CONFIG_BOOT_SIGNATURE_TYPE_RSA=y # 签名类型由 sysbuild.conf 的 SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256 决定
# (写在这里的 CONFIG_BOOT_SIGNATURE_TYPE_* 会被 sysbuild 强制覆盖,无效)
# ── 优化 ── # ── 优化 ──
CONFIG_BOOT_ERASE_PROGRESSIVELY=y CONFIG_BOOT_ERASE_PROGRESSIVELY=y
# ── 日志 ── # ── 裁掉板级 dts 引入、但 MCUboot 用不到的外设驱动 ──
CONFIG_MCUBOOT_LOG_LEVEL_WRN=y # MCUboot 只需要 UART(serial recovery)+ GPIO(pinctrl)+ FLASH + WATCHDOG(喂狗),
# 其余驱动(ADC/SPI/PWM/传感器/灯带/协议栈等)全部关掉,把体积压进 48KB boot 分区。
CONFIG_ADC=n
CONFIG_SPI=n
CONFIG_PWM=n
CONFIG_SENSOR=n
CONFIG_LED=n
CONFIG_LED_STRIP=n
CONFIG_LED_STRIP_INDICATOR=n
CONFIG_RTC=n
CONFIG_HWINFO=n
CONFIG_INPUT=n
CONFIG_GODTEK_TEMP_UART=n
CONFIG_UART_COM_PROTOCAL=n
CONFIG_UART_COM_SIMPLE_PROTOCAL=n
CONFIG_UART_COM_NORMAL_PROTOCAL=n
CONFIG_UART_COM_MODBUS_BRIDGE_PROTOCAL=n

View File

@ -1,2 +1,9 @@
/* MCUboot serial recovery 使用 zephyr,uart-mcumgr (已在板级 dts 指定为 &usart1) / {
* zephyr,console 保持 &usart3 (调试口),两者不能指向同一设备 */ chosen {
/* MCUboot 从 boot_partition 启动(Nordic 教程 Step 2.5)
* Serial Recovery 使用板级 dts 中的 zephyr,uart-mcumgr = &usart1
* console 使用 zephyr,console = &usart3(调试口)
*/
zephyr,code-partition = <&boot_partition>;
};
};