From 27ae21bbead13b476440843178f26983282b5e64 Mon Sep 17 00:00:00 2001 From: zhangyisong Date: Thu, 20 Aug 2026 21:16:30 +0800 Subject: [PATCH] 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 --- CMakeLists.txt | 1 + Kconfig | 4 + VERSION | 5 + doc/UID编码规则.md | 156 +++++++ doc/升级操作指南.md | 286 ++++++------- doc/通讯协议.md | 27 ++ include/com.hpp | 71 ++- include/uid.hpp | 56 +++ keys/mykey.pem | 5 + prj.conf | 9 + .../__pycache__/smp_client.cpython-314.pyc | Bin 0 -> 23551 bytes scripts/__pycache__/test_gui.cpython-314.pyc | Bin 0 -> 62632 bytes scripts/requirements.txt | 1 + scripts/smp_client.py | 403 ++++++++++++++++++ scripts/test_gui.py | 345 ++++++++++++++- src/led.cpp | 16 + src/main.cpp | 4 +- src/uid.cpp | 165 +++++++ sysbuild.conf | 13 +- sysbuild/mcuboot.conf | 38 +- sysbuild/mcuboot.overlay | 11 +- 21 files changed, 1433 insertions(+), 183 deletions(-) create mode 100644 VERSION create mode 100644 doc/UID编码规则.md create mode 100644 include/uid.hpp create mode 100644 keys/mykey.pem create mode 100644 scripts/__pycache__/smp_client.cpython-314.pyc create mode 100644 scripts/__pycache__/test_gui.cpython-314.pyc create mode 100644 scripts/requirements.txt create mode 100644 scripts/smp_client.py create mode 100644 src/uid.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b76818b..913decf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ else() src/temp.cpp src/watdog.cpp src/com.cpp + src/uid.cpp ) zephyr_sources_ifdef(CONFIG_APP_DFU diff --git a/Kconfig b/Kconfig index 3ec892a..225f438 100644 --- a/Kconfig +++ b/Kconfig @@ -10,3 +10,7 @@ config SAMPLE_NTC config APP_DFU default y bool "Enable DFU" + +config APP_DEBUG_LED_STRIP + default n + bool "Enable debug LED strip" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..36fe7bb --- /dev/null +++ b/VERSION @@ -0,0 +1,5 @@ +VERSION_MAJOR = 0 +VERSION_MINOR = 0 +PATCHLEVEL = 19 +VERSION_TWEAK = 0 +EXTRAVERSION = diff --git a/doc/UID编码规则.md b/doc/UID编码规则.md new file mode 100644 index 0000000..c2e03aa --- /dev/null +++ b/doc/UID编码规则.md @@ -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) +- 生产年月:2608(2026 年 8 月,BCD 风格) +- 序列号:00001234 + +``` +设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号 +─────────┼─────────┼──────────────┼──────────────────────── + 0x01 │ 0x01 │ 26 08 00 00 │ 0x00 0x00 0x12 0x34 +``` + +UID(hex,10 字节):`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 +``` + +UID(hex,10 字节):`02 03 26 08 00 00 00 00 56 78` + +### 示例 3:DMS 第一代 + +- 设备类型:外置按摩头系列(0x03) +- 产品代次:第一代(0x01) +- 生产年月:2608 +- 序列号:AABBCCDD + +``` +设备类型 │ 产品代次 │ 生产年月 │ 唯一序列号 +─────────┼─────────┼──────────────┼──────────────────────── + 0x03 │ 0x01 │ 26 08 00 00 │ 0xAA 0xBB 0xCC 0xDD +``` + +UID(hex,10 字节):`03 01 26 08 00 00 AA BB CC DD` + +> 注:生产年月 4 字节的具体子字段分配(BCD/扩展信息)由生产系统定义,本工具按原始字节透传、透读。 + +--- + +## 5. 数据存储方式 + +UID 信息存储在设备 storage 分区(0x70000,64KB)起始处,记录格式: + +``` +[magic "UID1" 4B][ver 1B][uid 10B][crc16 2B][pad 7B] = 24 字节 +``` + +- 含 CRC16-Modbus 校验;记录损坏/未写入时自动回退 MCU 芯片 UID(12 字节)。 +- 写入命令:`0x07` 指令,见《通讯协议.md》。 + +--- + +## 6. 端口识别说明 + +按摩头插入端口后,设备通过以下方式识别: + +- **UID**:识别按摩头的类型和追溯信息 +- **端口号**:决定控制电路的分配(与 UID 无关) + +按摩头可自由插入任意端口,UID 不会因端口切换而改变。 + +--- + +## 7. 扩展预留 + +| 类型范围 | 用途 | +|---|---| +| 0x01 | 冲击波系列(已定义) | +| 0x02 | 光磁/热疗系列(已定义) | +| 0x03 | 外置按摩头系列(已定义) | +| 0x04 - 0x0F | 未来新增设备系列 | +| 0x10 - 0xEF | 客户定制设备 | +| 0xF0 - 0xFE | 工厂测试工具 | +| 0xFF | 无效/未编程(保留) | diff --git a/doc/升级操作指南.md b/doc/升级操作指南.md index 46b82e0..d2f671e 100644 --- a/doc/升级操作指南.md +++ b/doc/升级操作指南.md @@ -5,28 +5,36 @@ ### 1.1 安装依赖 ```bash -# 安装 imgtool(固件签名工具) +# GUI 工具必需:串口库 +pip install pyserial + +# 固件签名工具(imgtool 随 Zephyr/MCUboot 自带,也可单独安装) pip install imgtool -# 安装 mcumgr(固件上传工具) -# Linux/macOS -go install github.com/apache/mynewt-mcumgr-cli/mcumgr@latest -# 或从 https://github.com/apache/mynewt-mcumgr-cli/releases 下载 +# 可选:命令行升级(smpmgr)——GUI 已内置 SMP 客户端,不装也能升级 +pip install smpmgr ``` ### 1.2 生成签名密钥(量产阶段) ```bash -# 在项目根目录生成密钥对 -cd /home/issac-zys/code/zephyr_prj_template +# 在应用目录下生成密钥对 +cd /home/issac-zys/code/zephyr_prj_template/app/app_photomagnetic 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 中的密钥路径 -# SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/keys/my-signing-key.pem" +# 更新 sysbuild.conf 中的密钥路径与签名类型 +# 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/app_photomagnetic/zephyr/zephyr.signed.bin` — 签名后的应用固件 -- `build/zephyr/merged.hex` — 合并镜像(MCUboot + 应用) +- `build/mcuboot/zephyr/zephyr.hex` / `.bin` — MCUboot bootloader(0x08000000) +- `build/app_photomagnetic/zephyr/zephyr.signed.hex` / `.bin` — 签名后的应用固件(0x0800C000) -### 2.2 烧录(一次性) +> ⚠️ 注意:顶层 `west flash -d build` 只会烧应用镜像,不会烧 MCUboot!MCUboot 必须单独烧录。 + +### 2.2 烧录(一次性,需要调试器) ```bash -# 方式1: 使用 west flash(推荐) -west flash -d build +# ① 先烧 MCUboot 到 0x08000000(覆盖出厂 godtek bootloader,只需一次) +west flash -d build/mcuboot -# 方式2: 使用 J-Link 手动烧录 merged.hex -# J-Link Commander: -# loadfile build/zephyr/merged.hex -# reset +# ② 再烧签名应用到 slot0 (0x0800C000) +west flash -d build ``` +> 两条命令顺序不能反:没有 MCUboot 时,0x0800C000 的镜像头无法被引导,设备会完全无反应。 + ### 2.3 验证首次启动 串口调试口(usart3)应输出: @@ -82,135 +91,104 @@ west flash -d build ``` ┌─────────────────────────────────────────────────────────────┐ -│ 主板 │ +│ 主板 / 测试工具 │ │ 1. 发送升级命令 [7E E7][FF][01][01][CRC] │ -│ 2. 等待 200ms(小板重启) │ -│ 3. 切换到 mcumgr 模式 │ -│ 4. 发送 mcumgr 命令(3 秒内) │ +│ 2. 等待 300ms(小板重启) │ +│ 3. 通过 smpmgr 上传固件(3 秒内) │ +│ 4. 标记待测试 + 复位 │ │ 5. 等待新固件启动 │ -│ 6. 切回 0x7EE7 协议模式 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 光磁头(小板) │ │ 1. 收到升级命令 → 直接重启 │ -│ 2. MCUboot 启动 → 等待 mcumgr 命令(3 秒) │ -│ 3. 收到 mcumgr 命令 → 进入 serial recovery │ -│ 4. 接收固件 → 写入 slot1 │ -│ 5. 验证签名 → swap → 重启运行新固件 │ +│ 2. MCUboot 启动 → 等待 SMP 命令(3 秒) │ +│ 3. 收到 SMP 命令 → 进入 serial recovery │ +│ 4. 接收固件 → 直接写入 slot0(主槽,串口恢复固定写主槽) │ +│ 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: 发送升级命令 -使用串口工具发送升级命令到光磁头: - -``` -发送: 7E E7 FF 01 01 [CRC_L] [CRC_H] -``` - -或使用 Python 脚本: +使用串口工具或 Python 发送升级命令: ```python -import serial -import struct +import serial, struct, time -def send_upgrade_command(port='/dev/ttyUSB0'): +def send_upgrade(port='/dev/ttyUSB0'): cmd = bytes([0x7E, 0xE7, 0xFF, 0x01, 0x01]) - # 计算 CRC16(Modbus) crc = 0xFFFF - for b in cmd[2:]: # 从 CMD 开始计算 + for b in cmd[2:]: crc ^= b for _ in range(8): - if crc & 1: - crc = (crc >> 1) ^ 0xA001 - else: - crc >>= 1 + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 cmd += struct.pack(' 可选:仍可用命令行一键升级(需已安装 smpmgr): +> `smpmgr --port /dev/ttyUSB0 --timeout 10 upgrade build/app_photomagnetic/zephyr/zephyr.signed.bin` -# 确认新固件(可选,防止下次重启回滚) -mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \ - image confirm 9b4a...e7d1 -``` - -### 3.3 自动化升级脚本 +### 3.4 自动化升级脚本 ```bash #!/bin/bash @@ -219,7 +197,6 @@ mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \ PORT=${1:-/dev/ttyUSB0} FIRMWARE=${2:-build/app_photomagnetic/zephyr/zephyr.signed.bin} -BAUD=115200 echo "=== 光磁头固件升级 ===" echo "串口: $PORT" @@ -235,7 +212,7 @@ fi echo "1. 发送升级命令..." python3 -c " 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]) crc = 0xFFFF for b in cmd[2:]: @@ -251,70 +228,49 @@ with serial.Serial('$PORT', $BAUD, timeout=1) as ser: echo "2. 等待小板重启..." sleep 0.3 -# 上传固件 +# 上传固件(串口恢复直接写 slot0 主槽,上传完自动复位) echo "3. 上传固件..." -mcumgr --conntype=serial --connstring="dev=$PORT,baud=$BAUD" \ - image upload -e "$FIRMWARE" +smpmgr --port "$PORT" --timeout 10 upgrade "$FIRMWARE" if [ $? -ne 0 ]; then echo "错误: 固件上传失败" + echo "提示: 上传中断会擦掉 slot0 一半,但设备停在 bootloader 中不会变砖," + echo " 重新执行本脚本即可恢复(或 west flash -d build 重烧应用)。" exit 1 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 "=== 升级完成,等待新固件启动 ===" ``` --- -## 4. 回滚与恢复 +## 4. 恢复(串口恢复模式无 swap,没有回滚机制) -### 4.1 新固件未确认自动回滚 +> 串口恢复(serial recovery)是直接把镜像写入 slot0 主槽,固件即刻生效、 +> 不存在“待测试/回滚”的概念。旧版文档里的 swap/回滚描述来自应用内 mcumgr DFU, +> 本方案不适用。 -如果新固件有问题,重启后 MCUboot 会自动回滚到原固件: +### 4.1 上传中断/镜像损坏后的恢复 + +上传中断只会擦掉 slot0 一部分,MCUboot 仍然在运行并停留在 serial recovery 等待区, +**不需要调试器**即可恢复: ```bash -# 不执行 image confirm,直接重启 -mcumgr --conntype=serial --connstring="$MCUmgr_CONN" reset - -# MCUboot 检测到未确认 → 回滚到原固件 +# 直接重传即可(设备已在 bootloader 等待,无需再发升级命令) +smpmgr --port /dev/ttyUSB0 --timeout 10 upgrade \ + build/app_photomagnetic/zephyr/zephyr.signed.bin ``` -### 4.2 手动确认新固件 +或使用 GUI:再次点击 **Upgrade Firmware**(0xFF 命令对 MCUboot 无害,smpmgr 会自动连上)。 -如果新固件工作正常,确认后防止回滚: +### 4.2 强制恢复(需要调试器) + +如果连 MCUboot 都进不去(比如误烧了 boot 分区): ```bash -mcumgr --conntype=serial --connstring="$MCUmgr_CONN" \ - image confirm -``` - -### 4.3 强制恢复(需要调试器) - -如果升级失败导致设备无法启动: - -```bash -# 使用调试器强制烧录 +# 重新烧录 MCUboot + 应用 +west flash -d build/mcuboot 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 秒内发送命令 | 缩短重启到上传的间隔 | -| 签名验证失败 | 密钥不匹配 | 使用构建时的密钥签名 | -| 上传中断 | 串口断开/超时 | 重新上传(支持断点续传) | -| 设备无法启动 | 固件损坏 | 使用调试器恢复 | -| mcumgr 连接失败 | 波特率错误 | 确认使用 115200 | +| smpmgr 超时 | 上传未在 3 秒窗口内开始 | 先确认设备已进 bootloader(串口有 MCUboot 日志)再上传 | +| 上传中断 | 串口被其他工具/GUI 占用 | 关闭其他串口工具,重新上传(需从头传,不支持断点续传) | +| 上传后无法启动 | 上传中断擦坏了 slot0 | 设备停在 bootloader,直接重传或 `west flash -d build` 重烧应用 | +| 签名验证失败 | 密钥不匹配 | 用构建时相同的密钥(开发期 `root-ec-p256.pem`)签名 | +| smpmgr 连接失败 | 端口错误/被占用 | 确认端口号,关闭占用程序后再试 | --- ## 6. 注意事项 -1. **3 秒窗口**:主板必须在小板重启后 3 秒内发送 mcumgr 命令 -2. **波特率**:应用层协议和 mcumgr 都使用 115200 -3. **签名**:开发阶段使用默认密钥;量产必须替换 -4. **回滚**:不确认新固件 → 重启自动回滚 -5. **调试器**:首次烧录需要;后续升级不需要 -6. **断电保护**:升级中断电 → MCUboot 回滚,设备不会变砖 +1. **3 秒窗口**:小板重启后,MCUboot 等待 SMP 命令 3 秒;超时则正常启动应用 +2. **波特率**:应用层协议和 smpmgr 都使用 115200 +3. **签名**:开发阶段使用默认密钥 `root-ec-p256.pem`;量产必须替换(且必须用 ECDSA-P256,RSA 装不进 48KB 分区) +4. **升级期间勿占用串口**:上传过程中不要打开其他串口工具、不要让 GUI 重新 Connect,否则上传中断会擦坏 slot0 +5. **调试器**:首次烧录 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 官方工具,功能完整 | diff --git a/doc/通讯协议.md b/doc/通讯协议.md index e5f60ed..c011307 100644 --- a/doc/通讯协议.md +++ b/doc/通讯协议.md @@ -19,6 +19,28 @@ - 0x01:运行中 - 0x02:暂停中 - 0x03:故障中 + - 写UID:0x07指令(生产烧录自定义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分区(0x70000,64KB)起始处,记录格式 + `[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 sequenceDiagram @@ -45,4 +67,9 @@ Note over S: 系统上电 S -->> M: 回复 温度 数据 end + Note over M, S: 生产烧录 UID (0x07 指令) + M ->> S: 写入 12 字节 UID (0x07 指令帧) + S -->> M: 回复状态 (0x00 成功) + M ->> S: 请求 ID 验证 (0x01 指令帧) + S -->> M: 回复写入后的 UID ``` diff --git a/include/com.hpp b/include/com.hpp index f69af24..79788aa 100644 --- a/include/com.hpp +++ b/include/com.hpp @@ -5,7 +5,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -21,8 +24,9 @@ public: using RunningStateSignal = etl::signal; static auto Init() -> zpp::error { s_proto->SetRxCallbackTable(kRxCallbackTable); - auto size = hwinfo_get_device_id(buff, sizeof(buff)); - s_id_buff = etl::span(buff, size); + // 优先使用生产写入的自定义 UID,否则回退芯片 UID + Uid::Init(); + s_id_buff = Uid::Get(); return zpp::ok(); } static auto SendTemp(sensor_value val) -> void { @@ -32,14 +36,28 @@ public: static auto AddRunningState(RunningStateSignal::slot_type slot) -> bool { 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; + static auto AddLedColor(LedColorSignal::slot_type slot) -> bool { + return s_led_color_sig.connect(slot); + } +#endif private: enum Addr : uint8_t { R_TEMP = 0, R_GET_ID, W_RUNNING_STATE, + W_UID = 0x07, + R_VERSION = 0x06, + W_LED = 0x08, }; 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 { if (data.size() != 1) { return; @@ -50,6 +68,45 @@ private: printk("handle get id"); 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 /// 升级命令(0xFF):data[0]=0x01 进入升级模式,data[0]=0x02 查询状态 @@ -74,8 +131,11 @@ private: constexpr static std::pair kRxCallbackTable[] = { - {R_GET_ID, GetId}, - {W_RUNNING_STATE, RunningState}, + {R_GET_ID, GetId}, {W_RUNNING_STATE, RunningState}, + {R_VERSION, GetVersion}, {W_UID, WriteUid}, +#ifdef CONFIG_APP_DEBUG_LED_STRIP + {W_LED, SetLed}, +#endif #ifdef CONFIG_APP_DFU {0xFF, DfuCommand}, #endif @@ -83,9 +143,8 @@ private: inline static auto s_proto = (uart_com::SimpleProtocal *)DEVICE_DT_GET( DT_COMPAT_GET_ANY_STATUS_OKAY(uart_com_simple_protocal)); - inline static uint8_t buff[20]; + inline static etl::span s_id_buff; inline static auto s_heating_state = false; - inline static etl::span s_id_buff; }; } // namespace ther diff --git a/include/uid.hpp b/include/uid.hpp new file mode 100644 index 0000000..f8694bb --- /dev/null +++ b/include/uid.hpp @@ -0,0 +1,56 @@ +#ifndef __THER_UID_HPP__ +#define __THER_UID_HPP__ + +#include +#include +#include + +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; + /// 是否使用自定义存储 UID(否则为芯片 UID)。 + static auto IsCustom() -> bool; + /// 写入自定义 UID(必须 10 字节);空 span 表示清除记录(恢复芯片 UID)。 + /// 返回 0 成功,负数为错误码。 + static auto Write(etl::span 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 s_active{}; + inline static bool s_custom{false}; +}; + +} // namespace ther + +#endif // __THER_UID_HPP__ diff --git a/keys/mykey.pem b/keys/mykey.pem new file mode 100644 index 0000000..53ebc96 --- /dev/null +++ b/keys/mykey.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwlUCVXuz/+irsEXd +xU6IwCQCxnA42StQaD3Hd04QvnyhRANCAAQGpcUOBI1p35AGLNctUd50QL1OmeE3 +2fPWMOf9byIXeQFnRTVAJkSo067+GMP5onCHOvx76lREg5IxLRZilfxR +-----END PRIVATE KEY----- diff --git a/prj.conf b/prj.conf index 934bb5b..8f84beb 100644 --- a/prj.conf +++ b/prj.conf @@ -29,3 +29,12 @@ CONFIG_PWM=y # CONFIG_ADC_LOG_LEVEL_DBG=y # CONFIG_SENSOR_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 diff --git a/scripts/__pycache__/smp_client.cpython-314.pyc b/scripts/__pycache__/smp_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f27c49d564a084a2d24c24ad3888c25f868db89 GIT binary patch literal 23551 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 F{2yEJ#d81v literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/test_gui.cpython-314.pyc b/scripts/__pycache__/test_gui.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a4a89823afe58ce915508bd980295eb533c36f69 GIT binary patch literal 62632 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 BswV&d literal 0 HcmV?d00001 diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..d0dfd58 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +pyserial==3.5 diff --git a/scripts/smp_client.py b/scripts/smp_client.py new file mode 100644 index 0000000..15381bb --- /dev/null +++ b/scripts/smp_client.py @@ -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 diff --git a/scripts/test_gui.py b/scripts/test_gui.py index 85c454c..5a8d87a 100644 --- a/scripts/test_gui.py +++ b/scripts/test_gui.py @@ -9,8 +9,15 @@ from tkinter import ttk, scrolledtext, messagebox import threading import time import sys +import os +import struct +import base64 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: import serial import serial.tools.list_ports @@ -28,12 +35,39 @@ class Cmd(IntEnum): RUNNING_STATE = 0x02 W_HEATING = 0x03 R_HEATING = 0x04 + READ_VERSION = 0x06 + WRITE_UID = 0x07 + SET_LED = 0x08 # 调试用:灯带 RGB(仅调试固件支持) READ_POLE_NTC = 0x64 READ_HEATING_NTC = 0x65 HEATING_STATE_NAMES = {0: "OFF", 1: "ON"} 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) ─────────────────────────── def crc16_modbus(data: bytes) -> int: @@ -191,10 +225,52 @@ class SerialWorker: # ── 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: 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.title("Photomagnetic Communication Tool") + self.root.title(f"Photomagnetic Communication Tool v{self._version_str}") self.root.geometry("850x700") self.root.minsize(700, 550) @@ -215,16 +291,17 @@ class PhotomagneticGUI: self._port_var = tk.StringVar() 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) - ttk.Button(conn_frame, text="↻", width=3, - command=self._scan_ports).grid(row=0, column=2, padx=2) + self._scan_btn = ttk.Button(conn_frame, text="↻", width=3, + 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) 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", "115200", "230400", "460800"), 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.grid(row=0, column=5, padx=(12, 0)) @@ -234,6 +311,19 @@ class PhotomagneticGUI: 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_frame = ttk.Frame(self.root) 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.pack(fill=tk.X) - # Row 1: GET_ID + State + # Row 1: GET_ID + Version + State r1 = ttk.Frame(cmd_frame) r1.pack(fill=tk.X, pady=2) ttk.Button(r1, text="Get Device ID", width=14, 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)) self._state_combo = ttk.Combobox(r1, values=["Standby", "Running", "Pause", "Error"], state="readonly", width=10) @@ -306,6 +398,44 @@ class PhotomagneticGUI: self._loop_btn = ttk.Button(r5, text="▶ Loop GET_ID", width=14, command=self._toggle_loop) 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 ── disp_frame = ttk.LabelFrame(right, text="Live Values", padding=6) @@ -409,6 +539,14 @@ class PhotomagneticGUI: self.worker.send(Cmd.GET_ID) 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): if not self._check_connected(): return @@ -462,7 +600,168 @@ class PhotomagneticGUI: self.on_log(f"→ Raw: CMD=0x{cmd:02X} data={data.hex()}") 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 ────────────────────────────────────────── + + 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('= 100: + self._upgrade_last_pct = pct + self.root.after(0, self._append_log, f" Uploading... {offset}/{total} ({pct}%)") + def _toggle_monitor(self): if self.worker.monitoring: self._stop_monitor() @@ -524,6 +823,7 @@ class PhotomagneticGUI: # ── Frame handling (runs in main thread via after) ─── def _handle_frame(self, cmd: int, data: bytes): raw_hex = data.hex(" ") + print(f"[DEBUG] Frame received: cmd=0x{cmd:02X}, data={raw_hex}", flush=True) # ── 0x00 TEMP: 设备主动推送, 只更新 live value, 不输出 log ── if cmd == Cmd.TEMP: @@ -541,7 +841,25 @@ class PhotomagneticGUI: id_str = data.hex(" ") id_show = id_str[:47] + "..." if len(id_str) > 50 else id_str 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: state = data[0] if data else -1 @@ -577,6 +895,19 @@ class PhotomagneticGUI: t = temp_from_data(data) 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: self._append_log(f"📦 CMD=0x{cmd:02X} data={raw_hex}") diff --git a/src/led.cpp b/src/led.cpp index fee4edd..020c4a4 100644 --- a/src/led.cpp +++ b/src/led.cpp @@ -3,6 +3,9 @@ #include #include #include +#ifdef CONFIG_APP_DEBUG_LED_STRIP +#include // struct led_rgb +#endif using namespace ther; namespace { using McuState = Led; @@ -20,6 +23,19 @@ static auto Init() -> int { 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); using namespace std::chrono_literals; McuState::Flash(1s); diff --git a/src/main.cpp b/src/main.cpp index 4751dfa..4569543 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,10 @@ - +#include #include #include + auto main(void) -> int { 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) { k_sleep(K_SECONDS(1)); } diff --git a/src/uid.cpp b/src/uid.cpp new file mode 100644 index 0000000..2a8406c --- /dev/null +++ b/src/uid.cpp @@ -0,0 +1,165 @@ +#include + +#include +#include +#include +#include +#include + +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(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(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 { return s_active; } + +auto Uid::IsCustom() -> bool { return s_custom; } + +auto Uid::Write(etl::span 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(crc & 0xFF); + rec[kCrcOffset + 1] = static_cast(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(s_custom_uid, kUidLen); + s_custom = true; + printk("[uid] custom uid written\n"); + } else { + s_active = etl::span(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(rec[kCrcOffset] | (rec[kCrcOffset + 1] << 8)); + return Crc16Modbus(rec, kCrcOffset) == crc; +} + +} // namespace ther diff --git a/sysbuild.conf b/sysbuild.conf index ddfe93d..86c45c6 100644 --- a/sysbuild.conf +++ b/sysbuild.conf @@ -1,5 +1,14 @@ # 启用 MCUboot bootloader SB_CONFIG_BOOTLOADER_MCUBOOT=y -# 固件签名密钥(开发阶段用 MCUboot 自带密钥,量产替换为自生成密钥) -SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${ZEPHYR_BASE}/../bootloader/mcuboot/root-rsa-2048.pem" +# 把 MCUboot + 应用合并成一个 hex(方便一次性烧录/产线), +# 产物: build/merged_.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" diff --git a/sysbuild/mcuboot.conf b/sysbuild/mcuboot.conf index 6571ec8..e38ae98 100644 --- a/sysbuild/mcuboot.conf +++ b/sysbuild/mcuboot.conf @@ -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_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 # 等待 mcumgr 命令触发进入 serial recovery @@ -13,10 +24,27 @@ CONFIG_BOOT_SERIAL_WAIT_FOR_DFU_TIMEOUT=3000 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_MCUBOOT_LOG_LEVEL_WRN=y +# ── 裁掉板级 dts 引入、但 MCUboot 用不到的外设驱动 ── +# 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 diff --git a/sysbuild/mcuboot.overlay b/sysbuild/mcuboot.overlay index 2f4b06f..4aa0e5f 100644 --- a/sysbuild/mcuboot.overlay +++ b/sysbuild/mcuboot.overlay @@ -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>; + }; +};