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 0000000..9f27c49 Binary files /dev/null and b/scripts/__pycache__/smp_client.cpython-314.pyc differ diff --git a/scripts/__pycache__/test_gui.cpython-314.pyc b/scripts/__pycache__/test_gui.cpython-314.pyc new file mode 100644 index 0000000..a4a8982 Binary files /dev/null and b/scripts/__pycache__/test_gui.cpython-314.pyc differ 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>; + }; +};