V1.00.21: UART OTA channel verified + mode-switch handshake + loss-tolerant lockstep

- UART binary mode switch mechanism: enter handshake marker '[ota] binary
  mode ON', explicit exit via OTA_ABORT (immediate), 10s idle fallback
  (was 3s, must exceed the host retry window); host resync by CR probe
- fix: otaIdleMs not re-armed on mode entry - every second 'ota' entry was
  kicked out by an instant idle timeout (leftover count from prior session)
- lossy-link tolerance: device drops partial frames after a 100ms byte gap
  so host resends re-align; OTA session is now single-owner (second BEGIN
  on another channel gets BAD_STATE)
- ble_ota_update.py: wait for the enter marker instead of blind sleeps,
  per-frame resend (2s x3), BAD_STATE+expected-offset treated as implicit
  ack for duplicate DATA/BEGIN (resync after ack loss), stage-labeled
  timeouts with raw-rx dump
- verified on hardware: UART OTA full pass (55.7KB/41s, 4-5 lost frames
  auto-recovered) + BLE OTA regression pass; docs: dev log 44,
  ble_protocol.md 6.6 rework, AGENTS.md sync
This commit is contained in:
evan.liu 2026-09-04 20:19:00 +08:00
parent 520838ce65
commit efc7bd129f
27 changed files with 11561 additions and 11352 deletions

View File

@ -5,14 +5,14 @@
本仓库是 **mothercup(母乳杯)** 产品的完整代码仓库,包含三大部分: 本仓库是 **mothercup(母乳杯)** 产品的完整代码仓库,包含三大部分:
1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB): 1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB):
- **`mcm-ddc-ble/`** — 唯一活跃的应用固件工程(APP,链接在 APP1 bank `0x01008000`/112KB)。以 SDK rdtss 例程为蓝本,集成 FreeRTOS、UART CLI、BLE 自定义 GATT 服务(CLI 透传 / 设备信息查询 / BLE OTA 双 bank 直写升级)、bootsetting 与 APP_DATA 参数区结构化读写命令。当前版本 **V1.00.20**(`mcm-ddc-ble/inc/app_version.h`)。历史上曾有 `mcm-ddc-04/` 主工程,**已删除**,其功能已全部并入本工程。 - **`mcm-ddc-ble/`** — 唯一活跃的应用固件工程(APP,链接在 APP1 bank `0x01008000`/112KB)。以 SDK rdtss 例程为蓝本,集成 FreeRTOS、UART CLI、BLE 自定义 GATT 服务(CLI 透传 / 设备信息查询 / BLE OTA 双 bank 直写升级)、bootsetting 与 APP_DATA 参数区结构化读写命令。当前版本 **V1.00.21**(`mcm-ddc-ble/inc/app_version.h`)。历史上曾有 `mcm-ddc-04/` 主工程,**已删除**,其功能已全部并入本工程。
- **`Boot/`** — 自写精简 bootloader(链接在 `0x01000000`/16KB):校验 bootsetting 记录自身完整性(magic + 结构体 CRC32)后按 active bank 的 `start_address` 直接跳转;**不校验镜像 CRC(CRC 校验在 OTA 升级过程中完成)**;记录无效或地址越界回退 APP1。 - **`Boot/`** — 自写精简 bootloader(链接在 `0x01000000`/16KB):校验 bootsetting 记录自身完整性(magic + 结构体 CRC32)后按 active bank 的 `start_address` 直接跳转;**不校验镜像 CRC(CRC 校验在 OTA 升级过程中完成)**;记录无效或地址越界回退 APP1。
2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速等)、CLI 终端、OTA 升级页(当前停用,见下)。另有登录/注册/用户信息等云端业务页面。 2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速等)、CLI 终端、OTA 升级页(当前停用,见下)。另有登录/注册/用户信息等云端业务页面。
3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。 3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。
关键文档: 关键文档:
- `docs/开发日志.md` — 固件全程开发日志(43 节,含所有踩坑根因与设计决策,排查问题先查这里) - `docs/开发日志.md` — 固件全程开发日志(44 节,含所有踩坑根因与设计决策,排查问题先查这里)
- `docs/ble_protocol.md` — CAIIC BLE 通信协议 V1.1(帧格式/GATT UUID/三类业务流程/维护命令/手机端开发指南) - `docs/ble_protocol.md` — CAIIC BLE 通信协议 V1.1(帧格式/GATT UUID/三类业务流程/维护命令/手机端开发指南)
- `SmartAssiter/docs/修改记录_*.md` — App 侧每次改造的详细记录 - `SmartAssiter/docs/修改记录_*.md` — App 侧每次改造的详细记录
@ -95,7 +95,7 @@ mcm-ddc-ble/
"D:\Keil_v5\UV4\UV4.exe" -b caiic_boot.uvprojx -j0 -o build.log # Boot 增量构建 "D:\Keil_v5\UV4\UV4.exe" -b caiic_boot.uvprojx -j0 -o build.log # Boot 增量构建
``` ```
要求保持 **0 Error(s), 0 Warning(s)**。当前基线(V1.00.20):`Code=48840 RO-data=4868 RW-data=2076 ZI-data=29116`。 要求保持 **0 Error(s), 0 Warning(s)**。当前基线(V1.00.21):`Code=48872 RO-data=4980 RW-data=2076 ZI-data=29116`。
**固件版本号约定(重要)**:每次修改固件代码必须递增 `mcm-ddc-ble/inc/app_version.h` 中的 `APP_FW_VERSION` 与 `APP_FW_VERSION_NUM`(格式 `0x00MMmmpp`,通常补丁位 +1),用于识别板上实际运行的固件;工程名与出包文件名保持不变。 **固件版本号约定(重要)**:每次修改固件代码必须递增 `mcm-ddc-ble/inc/app_version.h` 中的 `APP_FW_VERSION` 与 `APP_FW_VERSION_NUM`(格式 `0x00MMmmpp`,通常补丁位 +1),用于识别板上实际运行的固件;工程名与出包文件名保持不变。
@ -152,7 +152,7 @@ mcm-ddc-ble/
- `make_package.bat` / `flash_package.bat` — 一键出包 / NSpyocd 整片烧录(脚本会自动找 Python312 全路径,本机 `python` 可能是商店占位 stub) - `make_package.bat` / `flash_package.bat` — 一键出包 / NSpyocd 整片烧录(脚本会自动找 Python312 全路径,本机 `python` 可能是商店占位 stub)
- `make_dual_package.bat` — 双 APP 整包(Boot+bootsetting 双 bank+APP_DATA+APP1+APP2,APP2 升一版链接 0x01024000),用于 appsw 切换测试 - `make_dual_package.bat` — 双 APP 整包(Boot+bootsetting 双 bank+APP_DATA+APP1+APP2,APP2 升一版链接 0x01024000),用于 appsw 切换测试
- `ble_cli_test.py` + `ble_cli.bat` — PC 端 bleak 蓝牙 CLI 测试客户端(帧协议模式 + `-rw` 读写特征模式,交互模式 `-i`,venv 在 `tools/.venv-ble`,首次运行 bat 自动创建) - `ble_cli_test.py` + `ble_cli.bat` — PC 端 bleak 蓝牙 CLI 测试客户端(帧协议模式 + `-rw` 读写特征模式,交互模式 `-i`,venv 在 `tools/.venv-ble`,首次运行 bat 自动创建)
- `ble_ota_update.py` + `ble_ota.bat` — BLE OTA 升级器(解析单文件升级包 `mothercup_ble_ota.bin`、按 CUR_BANK 选对侧 bank 载荷、经 OTA 特征 `...e0005` 逐帧锁步、复位检测+重连校验;`--uart COMx` 走串口二进制模式)。**BLE 通道已实测通过(§42);UART 通道待验证(串口线接触问题,待恢复后测)** - `ble_ota_update.py` + `ble_ota.bat` — BLE OTA 升级器(解析单文件升级包 `mothercup_ble_ota.bin`、按 CUR_BANK 选对侧 bank 载荷、经 OTA 特征 `...e0005` 逐帧锁步、复位检测+重连校验;`--uart COMx` 走串口二进制模式:握手标记进入/ABORT 退出/丢帧锁步重传再同步)。**BLE 与 UART 双通道均已实测通过(§42/§44)**
- `ble_temp_watch.py` — 温度监测小工具 - `ble_temp_watch.py` — 温度监测小工具
## 实时架构约定(固件) ## 实时架构约定(固件)

View File

@ -149,7 +149,21 @@ OTA 会话(§6 的 BEGIN/DATA/END/ABORT → OTA_RSP)跑在 0xCA 帧上,帧
| 通道 | 下行(主机→设备) | 上行(设备→主机) | | 通道 | 下行(主机→设备) | 上行(设备→主机) |
|---|---|---| |---|---|---|
| **BLE OTA 特征** `...e0005` | 每次"写带响应"携带一帧(≤ att_mtu−3) | 写完**读同一特征**取回 OTA_RSP 帧;读应答可能先于设备处理完成返回空,**读需轮询至非空**(擦扇区时数十 ms) | | **BLE OTA 特征** `...e0005` | 每次"写带响应"携带一帧(≤ att_mtu−3) | 写完**读同一特征**取回 OTA_RSP 帧;读应答可能先于设备处理完成返回空,**读需轮询至非空**(擦扇区时数十 ms) |
| **UART 串口**(115200 8N1) | CLI 先发文本命令 `ota` 进入二进制帧模式,随后原始帧字节流 | OTA_RSP 帧直接从 TX 发出;3s 无帧自动退出回 CLI | | **UART 串口**(115200 8N1) | CLI 先发文本命令 `ota`,**等到设备回标记行 `[ota] binary mode ON` 再发帧**(握手,防帧被当文本命令吃掉),随后原始帧字节流 | OTA_RSP 帧直接从 TX 发出;**OTA_ABORT 立即退出**回 CLI,10s 无帧兜底退出 |
UART 模式切换与容错(V1.00.21 起):
- **进入握手**:`ota` 命令的标记行是 go-ahead;标记之前设备仍在文本 CLI,
提前发二进制帧会被行编辑器吃掉(帧内 0x0D 甚至会触发垃圾命令执行)。
- **退出三路**:END 成功 → 复位;OTA_ABORT → 回完 ack 立即回文本 CLI;
10s 空闲兜底(host 失联自救)。host 再同步:发 `\r`,CLI 态立即回
`caiic->`;二进制态无响应,等兜底超时退出。
- **丢帧恢复(锁步重传)**:任一帧的 ack 超时(PC 参考实现 2s)就直接
重发同一帧,最多 3 次。重复 DATA 会被设备回 `BAD_STATE + 期望 offset`
——若该 offset 恰好等于本帧发完后的下一 offset,说明设备早已收到、只是
ack 丢了,**视为 ack 继续**;重复 BEGIN 同理视为会话已建立。设备侧对
100ms 字节间断的半帧自动丢弃重组器状态,保证重发帧能重新对齐;
会话全程只允许一个通道占用(他通道 BEGIN 回 BAD_STATE)。
| BLE 旧帧协议通道(e0001/e0002 notify) | 0xCA 帧写入 e0001 | notify 上行(V1.00.19 修复 CRC 后可用,见开发日志 §42) | | BLE 旧帧协议通道(e0001/e0002 notify) | 0xCA 帧写入 e0001 | notify 上行(V1.00.19 修复 CRC 后可用,见开发日志 §42) |
锁步规则:每发一帧必须等到对应 OTA_RSP 再发下一帧(ack 里 offset = 锁步规则:每发一帧必须等到对应 OTA_RSP 再发下一帧(ack 里 offset =

View File

@ -1092,3 +1092,43 @@ e0005 读回缓冲,**主机还没发起 ATT 读**,200ms 后设备已复位
实测:`ble_ota.bat` 完整 OTA 后**直接收到 END ack**(输出 `OTA_END done`, 实测:`ble_ota.bat` 完整 OTA 后**直接收到 END ack**(输出 `OTA_END done`,
不再出现 ack lost),设备复位进 APP2,重连核对 CUR_BANK 翻转 + 版本正确, 不再出现 ack lost),设备复位进 APP2,重连核对 CUR_BANK 翻转 + 版本正确,
PASS。基线:Code=48840 RO-data=4868 RW-data=2076 ZI-data=29116。 PASS。基线:Code=48840 RO-data=4868 RW-data=2076 ZI-data=29116。
## 44. UART OTA 通道调通:模式切换机制 + 锁步重传容错(2026-09-04,V1.00.21)
UART OTA 首轮实测失败("no OTA_RSP on UART"),排查过程与最终设计如下。
**模式切换机制(来回切换的完整答案)**:串口是 CLI 文本通道,切二进制
需要明确的状态机,否则主机与设备模式错位、二进制帧被文本行编辑器吃掉
(帧内 0x0D 会触发执行垃圾命令)。定案(ble_protocol.md §6.6):
- 进入握手:`ota` 命令回标记行 `[ota] binary mode ON`,主机必须等到标记
再发帧(此前 PC 工具靠固定 sleep 猜,时序上不可靠);
- 退出三路:END 成功→复位(V1.00.20 延迟复位保证 ack 送达);OTA_ABORT→
回完 ack 立即回 CLI(新增,`app_ota_chan_rx_uart()` 返回值驱动前端退出);
10s 空闲兜底(原 3s 太短,必须大于主机重传窗口);
- 再同步:主机随时发 `\r`,CLI 态立即回提示符;二进制态无响应,等兜底退出;
- 会话占用保护:OTA 会话全局唯一,他通道 BEGIN 回 BAD_STATE(此前会踩状态)。
**实测抓到的两个真 bug**:
1. `otaIdleMs` 进入二进制模式时未清零——第一次 `ota` 正常,**第二次进入
必在 100ms 内被"空闲超时"踢出**(上一段会话残留的计数值 ≥ 阈值)。
表现为帧字节走文本路径被逐字符回显(只有可打印字符回显,这是定位
突破口)。修复:每次进入 while(s_otaMode) 前清零。
2. 丢整帧:约 1~3% 的 233B 数据帧在主机 write() 成功后设备侧一个字节都
收不到(设备 3s 空闲退出、CLI 仍活为证),随机发生——**物理链路接触
不良**(用户已预警过串口线问题)。协议必须容忍:PC 工具实现锁步重传
(ack 2s 超时直接重发同帧,≤3 次);利用协议已有的 offset 报告语义做
再同步——重复 DATA 回 BAD_STATE+期望 offset,若等于本帧结束位置说明
设备早收到只是 ack 丢了,视为 ack 继续;重复 BEGIN 视为会话已建立。
设备侧配套:100ms 字节间断自动丢弃重组器半帧状态,保证重发帧重新对齐。
**调试教训**:排查时在逐字节路径加 `printf("[rx %02X]")` 观测,结果打印
本身(轮询 TX ~500µs/字节)把 RX 消费拖慢 6 倍,64 深队列在 ~80 字节处
溢出丢尾——**观测手段本身制造了新的丢帧**,误以为 CRC/协议问题。高速
链路排查禁用逐字节打印。
实测(release 固件):UART OTA 全程 PASS(55.7KB/41s,途中 4~5 次丢帧
全部自动重传恢复,END ack 正常,复位进 APP2 复核通过);BLE OTA 回归
PASS(APP1,30s)。基线:Code=48872 RO-data=4980 RW-data=2076
ZI-data=29116。遗留:串口物理链路建议用户更换/重插,协议层已能自愈。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -49,8 +49,10 @@ void app_ota_chan_rx(const uint8_t* data, uint16_t len, app_ota_rsp_sink_fn sink
void app_ota_chan_rx_ble(const uint8_t* data, uint16_t len); void app_ota_chan_rx_ble(const uint8_t* data, uint16_t len);
const uint8_t* app_ota_chan_rsp_ble(uint16_t* out_len); const uint8_t* app_ota_chan_rsp_ble(uint16_t* out_len);
/** UART binary mode (CLI "ota"): one received byte in; RSP frames on TX. */ /** UART binary mode (CLI "ota"): one received byte in; RSP frames on TX.
void app_ota_chan_rx_uart(uint8_t ch); * @return non-zero when the host sent OTA_ABORT - the frontend should
* leave binary mode and return to the text CLI immediately. */
uint8_t app_ota_chan_rx_uart(uint8_t ch);
/** Drop the reassembler state (channel idle timeout / link loss). */ /** Drop the reassembler state (channel idle timeout / link loss). */
void app_ota_chan_idle(void); void app_ota_chan_idle(void);

View File

@ -9,12 +9,12 @@
#define __APP_VERSION_H__ #define __APP_VERSION_H__
#ifndef APP_FW_VERSION #ifndef APP_FW_VERSION
#define APP_FW_VERSION "V1.00.20" #define APP_FW_VERSION "V1.00.21"
#endif #endif
/* Numeric form used by the BLE info query / OTA records: 0x00MMmmpp */ /* Numeric form used by the BLE info query / OTA records: 0x00MMmmpp */
#ifndef APP_FW_VERSION_NUM #ifndef APP_FW_VERSION_NUM
#define APP_FW_VERSION_NUM 0x00010014u #define APP_FW_VERSION_NUM 0x00010015u
#endif #endif
#endif /* __APP_VERSION_H__ */ #endif /* __APP_VERSION_H__ */

View File

@ -26,8 +26,10 @@
#define CLI_PROMPT "caiic->" #define CLI_PROMPT "caiic->"
/* UART OTA binary mode: raw 0xCA frames in, framed OTA_RSP out (no echo). /* UART OTA binary mode: raw 0xCA frames in, framed OTA_RSP out (no echo).
* Auto-exit after this much inactivity (ms). */ * Auto-exit after this much inactivity (ms). Must exceed the host's worst-
#define OTA_MODE_IDLE_MS 3000u * case retry window (ble_ota_update.py: 3 tries x 2 s) so a lost frame on a
* flaky link does not drop the session before the host resends. */
#define OTA_MODE_IDLE_MS 10000u
#define OTA_MODE_POLL_MS 100u #define OTA_MODE_POLL_MS 100u
static uint8_t s_otaMode; static uint8_t s_otaMode;
@ -224,7 +226,7 @@ static uint16_t cli_tab_complete(char* line, uint16_t len)
* @brief CLI task: receive bytes, edit the line, dispatch on terminator. * @brief CLI task: receive bytes, edit the line, dispatch on terminator.
* TAB completes the command name, UP/DOWN arrows walk the history. * TAB completes the command name, UP/DOWN arrows walk the history.
* In OTA binary mode (CLI "ota"), bytes feed the OTA frame channel * In OTA binary mode (CLI "ota"), bytes feed the OTA frame channel
* raw - no echo/editing; the mode exits on 3s of inactivity (or on * raw - no echo/editing; the mode exits on 10s of inactivity (or on
* device reset after a successful OTA_END). * device reset after a successful OTA_END).
*/ */
static void CliTask(void* argument) static void CliTask(void* argument)
@ -243,16 +245,29 @@ static void CliTask(void* argument)
for (;;) for (;;)
{ {
/* OTA binary frame mode: raw bytes to the OTA channel */ /* OTA binary frame mode: raw bytes to the OTA channel.
* otaIdleMs is (re)armed on every entry - a leftover count from a
* previous session must not trip an instant idle timeout. */
otaIdleMs = 0;
while (s_otaMode) while (s_otaMode)
{ {
if (bsp_usart_read_byte(&ch, OTA_MODE_POLL_MS) != 0) if (bsp_usart_read_byte(&ch, OTA_MODE_POLL_MS) != 0)
{ {
app_ota_chan_rx_uart(ch); if (app_ota_chan_rx_uart(ch) != 0)
{
/* OTA_ABORT: explicit exit back to the text CLI */
s_otaMode = 0;
bsp_usart_tx_lock();
cli_uart_write("\r\n[ota] aborted, back to CLI\r\n" CLI_PROMPT);
bsp_usart_tx_unlock();
}
otaIdleMs = 0; otaIdleMs = 0;
continue; continue;
} }
otaIdleMs += OTA_MODE_POLL_MS; otaIdleMs += OTA_MODE_POLL_MS;
/* A byte gap this long mid-frame means the rest was lost on the
* wire: drop the partial frame so a host resend re-syncs */
app_ota_chan_idle();
if (otaIdleMs >= OTA_MODE_IDLE_MS) if (otaIdleMs >= OTA_MODE_IDLE_MS)
{ {
s_otaMode = 0; s_otaMode = 0;

View File

@ -212,6 +212,14 @@ static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len)
crc = ota_rd32(p + 4); crc = ota_rd32(p + 4);
version = ota_rd32(p + 8); version = ota_rd32(p + 8);
/* One session at a time across all channels: a second BEGIN (e.g. BLE
* while a UART session runs) is refused instead of corrupting state */
if (s_active)
{
ota_respond(seq, BLE_FRAME_OTA_BEGIN, BLE_OTA_ST_BAD_STATE, s_offset);
return;
}
/* Pick the opposite bank as the update target */ /* Pick the opposite bank as the update target */
self = app_ota_current_bank_base(); self = app_ota_current_bank_base();
if (self == CAIIC_APP1_BASE) if (self == CAIIC_APP1_BASE)
@ -485,6 +493,10 @@ void app_ota_reset_poll(void)
} }
} }
static void ota_uart_sink(const uint8_t* frame, uint16_t len);
static uint8_t s_uart_exit_req; /* OTA_ABORT on UART: drop back to CLI now */
void app_ota_handle_frame(uint8_t type, uint8_t seq, const uint8_t* payload, uint16_t len) void app_ota_handle_frame(uint8_t type, uint8_t seq, const uint8_t* payload, uint16_t len)
{ {
switch (type) switch (type)
@ -501,6 +513,13 @@ void app_ota_handle_frame(uint8_t type, uint8_t seq, const uint8_t* payload, uin
case BLE_FRAME_OTA_ABORT: case BLE_FRAME_OTA_ABORT:
app_ota_abort(); app_ota_abort();
ota_respond(seq, BLE_FRAME_OTA_ABORT, BLE_OTA_ST_OK, s_offset); ota_respond(seq, BLE_FRAME_OTA_ABORT, BLE_OTA_ST_OK, s_offset);
/* Explicit exit from UART binary mode (ack already on the wire):
* the CLI frontend returns to text mode immediately instead of
* waiting out the 3 s idle timeout */
if (s_rsp_sink == ota_uart_sink)
{
s_uart_exit_req = 1;
}
break; break;
default: default:
break; break;
@ -594,6 +613,9 @@ void app_ota_chan_rx(const uint8_t* data, uint16_t len, app_ota_rsp_sink_fn sink
app_ota_handle_frame(s_chanBuf[1], s_chanBuf[2], app_ota_handle_frame(s_chanBuf[1], s_chanBuf[2],
&s_chanBuf[5], payLen); &s_chanBuf[5], payLen);
} }
/* CRC mismatch: silently drop; the host's lockstep
* resend recovers (its resync relies on our offset
* report in the next ack) */
s_chanState = CHAN_RX_WAIT_SOF; s_chanState = CHAN_RX_WAIT_SOF;
s_chanHave = 0; s_chanHave = 0;
} }
@ -650,9 +672,14 @@ static void ota_uart_sink(const uint8_t* frame, uint16_t len)
s_rsp_consumed = 1; s_rsp_consumed = 1;
} }
void app_ota_chan_rx_uart(uint8_t ch) uint8_t app_ota_chan_rx_uart(uint8_t ch)
{ {
uint8_t exit_req;
app_ota_chan_rx(&ch, 1, ota_uart_sink); app_ota_chan_rx(&ch, 1, ota_uart_sink);
exit_req = s_uart_exit_req;
s_uart_exit_req = 0;
return exit_req;
} }
/** /**

View File

@ -307,17 +307,20 @@ static void CmdUartInfo(int argc, char* argv[])
/** /**
* @brief "ota": switch the UART frontend into OTA binary frame mode * @brief "ota": switch the UART frontend into OTA binary frame mode
* (0xCA OTA frames on the raw stream, framed RSP on TX, 3s idle * (0xCA OTA frames on the raw stream, framed RSP on TX).
* timeout back to CLI). The BLE OTA channel (...e0005) is always * Handshake: the "[ota] binary mode ON" marker line is the go-ahead -
* available and does not need this command. * the host must wait for it before sending frames. Exits: OTA_ABORT
* frame (immediate), 3 s idle timeout (fallback), or device reset
* after a successful OTA_END. The BLE OTA channel (...e0005) is
* always available and does not need this command.
*/ */
static void CmdOta(int argc, char* argv[]) static void CmdOta(int argc, char* argv[])
{ {
(void)argc; (void)argc;
(void)argv; (void)argv;
cli_write("ota: UART switches to binary frame mode (3s idle timeout)\r\n"); cli_write("[ota] binary mode ON - send 0xCA OTA frames now\r\n"
cli_write("send 0xCA OTA frames now (ble_protocol.md section 6.6)\r\n"); "(ble_protocol.md section 6.6; OTA_ABORT or 3s idle exits)\r\n");
app_cli_ota_mode_enter(); app_cli_ota_mode_enter();
} }

View File

@ -10,7 +10,8 @@ Two channels:
write-with-response, the framed OTA_RSP is read back from write-with-response, the framed OTA_RSP is read back from
the same characteristic (lockstep, no notify needed). the same characteristic (lockstep, no notify needed).
UART (--uart): e.g. --uart COM4 - the CLI command "ota" switches the UART (--uart): e.g. --uart COM4 - the CLI command "ota" switches the
serial link to binary frame mode; same frames on the wire. serial link to binary frame mode (marker-confirmed
handshake; OTA_ABORT exits); same frames on the wire.
Both channels select the payload by CUR_BANK (info item 0x07, read-only Both channels select the payload by CUR_BANK (info item 0x07, read-only
characteristic ...e0004 / CLI "devinfo"): OTA always writes the INACTIVE characteristic ...e0004 / CLI "devinfo"): OTA always writes the INACTIVE
@ -28,6 +29,7 @@ import asyncio
import os import os
import struct import struct
import sys import sys
import time
import zlib import zlib
NAME_PREFIX = "CAIIC-MCM-20260902" NAME_PREFIX = "CAIIC-MCM-20260902"
@ -43,9 +45,13 @@ TYPE_OTA_RSP = 0x1F
COMBO_MAGIC = 0xCA10BA11 COMBO_MAGIC = 0xCA10BA11
COMBO_HDR_LEN = 52 COMBO_HDR_LEN = 52
RSP_TIMEOUT_S = 5.0 RSP_TIMEOUT_S = 2.0 # one lockstep round trip (flash erase adds ~50ms)
XFER_TRIES = 3 # resend a frame whose ack was lost (flaky links)
REBOOT_WAIT_S = 20.0 REBOOT_WAIT_S = 20.0
ST_OK = 0
ST_BAD_STATE = 2
_seq = [0] _seq = [0]
@ -137,33 +143,84 @@ class BleTransport:
class UartTransport: class UartTransport:
"""UART binary mode: frames on the wire, RSP frames come back on RX.""" """UART binary mode: frames on the wire, RSP frames come back on RX.
Mode switching handshake (firmware V1.00.21+):
text CLI --"ota"--> binary frame mode; the device prints the marker
"[ota] binary mode ON" as the go-ahead. Exits: OTA_ABORT frame
(immediate), 3 s idle timeout (fallback), reset after OTA_END.
Re-sync: a bare "\\r" gets an immediate "caiic->" prompt in CLI mode;
in binary mode it is silently dropped and the 3 s idle timeout brings
the CLI back on its own."""
MARKER_ON = b"[ota] binary mode ON"
PROMPT = b"caiic->"
def __init__(self, port, baud=115200): def __init__(self, port, baud=115200):
import serial import serial
self.ser = serial.Serial(port, baud, timeout=0.1) self.ser = serial.Serial(port, baud, timeout=0.1)
self.dec = FrameDecoder() self.dec = FrameDecoder()
def _read_until(self, markers, timeout_s):
"""Read text until any marker appears or the timeout expires.
Runs in a worker thread - no asyncio loop time here."""
buf = b""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
buf += self.ser.read(self.ser.in_waiting or 1)
if any(m in buf for m in markers):
break
return buf
async def _resync_cli(self):
"""Make sure the device sits at the text CLI (probe with CR; if a
previous run left it in binary mode, wait out the 3 s idle exit)."""
self.ser.reset_input_buffer()
self.ser.write(b"\r")
text = await asyncio.to_thread(self._read_until, [self.PROMPT], 0.8)
if self.PROMPT in text:
return True
# maybe stuck in binary mode: idle timeout exits within ~3.5 s
text = await asyncio.to_thread(self._read_until, [self.PROMPT], 3.5)
return self.PROMPT in text
async def enter_ota_mode(self): async def enter_ota_mode(self):
"""Text CLI first: devinfo tells us the running bank ("cur bank: """Text CLI first: devinfo tells us the running bank ("cur bank:
APPn"); then "ota" switches the frontend to binary frame mode.""" APPn"); then "ota" switches the frontend to binary frame mode,
confirmed by the marker line (no blind sleeps)."""
if not await self._resync_cli():
print("warning: no CLI prompt on UART (check wiring/baud)")
self.ser.write(b"devinfo\r") self.ser.write(b"devinfo\r")
await asyncio.sleep(0.5) text = await asyncio.to_thread(self._read_until, [b"cur bank:", self.PROMPT], 1.5)
text = self.ser.read(self.ser.in_waiting or 1).decode("ascii", "replace")
bank = 0 bank = 0
for line in text.splitlines(): for line in text.decode("ascii", "replace").splitlines():
if "cur bank:" in line: if "cur bank:" in line:
bank = 2 if "APP2" in line else 1 bank = 2 if "APP2" in line else 1
self.ser.write(b"ota\r") self.ser.write(b"ota\r")
await asyncio.sleep(0.4) text = await asyncio.to_thread(self._read_until, [self.MARKER_ON], 2.0)
self.ser.read(self.ser.in_waiting or 1) # drain echo/notice text if self.MARKER_ON not in text:
raise RuntimeError("ota mode handshake failed (marker not seen; "
"firmware >= V1.00.21 required; got: %r)"
% text[-120:])
self.ser.reset_input_buffer() # drop trailing notice text
return bank return bank
async def leave_ota_mode(self):
"""Explicit exit: OTA_ABORT makes the device drop back to the CLI
immediately (also the failure-path cleanup)."""
try:
await self.xfer(encode_frame(TYPE_OTA_ABORT, b""))
except Exception:
pass
await asyncio.to_thread(self._read_until, [self.PROMPT], 1.0)
async def xfer(self, frame): async def xfer(self, frame):
self.ser.write(frame) self.ser.write(frame)
raw = bytearray()
deadline = asyncio.get_event_loop().time() + RSP_TIMEOUT_S deadline = asyncio.get_event_loop().time() + RSP_TIMEOUT_S
while True: while True:
data = await asyncio.to_thread(self.ser.read, 256) data = await asyncio.to_thread(self.ser.read, 256)
raw += data
for ftype, seq, payload in self.dec.feed(data): for ftype, seq, payload in self.dec.feed(data):
if ftype == TYPE_OTA_RSP: if ftype == TYPE_OTA_RSP:
# hand back a raw re-encoded frame (uniform with BLE) # hand back a raw re-encoded frame (uniform with BLE)
@ -172,7 +229,8 @@ class UartTransport:
crc = crc16_ccitt(body) crc = crc16_ccitt(body)
return bytes([0xCA]) + body + bytes([crc & 0xFF, crc >> 8]) return bytes([0xCA]) + body + bytes([crc & 0xFF, crc >> 8])
if asyncio.get_event_loop().time() > deadline: if asyncio.get_event_loop().time() > deadline:
raise asyncio.TimeoutError("no OTA_RSP on UART") raise asyncio.TimeoutError("no OTA_RSP on UART (raw rx: %s)"
% bytes(raw).hex(" "))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -227,18 +285,35 @@ def decode_tlv(data):
async def ota_session(xfer, chunk, blob, version, target): async def ota_session(xfer, chunk, blob, version, target):
"""Lockstep OTA: BEGIN -> DATA* -> END, every frame acked by OTA_RSP.""" """Lockstep OTA: BEGIN -> DATA* -> END, every frame acked by OTA_RSP."""
async def call(ftype, payload): async def call(ftype, payload, ack_off=None):
stage = {TYPE_OTA_BEGIN: "BEGIN", TYPE_OTA_DATA: "DATA", stage = {TYPE_OTA_BEGIN: "BEGIN", TYPE_OTA_DATA: "DATA",
TYPE_OTA_END: "END", TYPE_OTA_ABORT: "ABORT"}.get(ftype, "?") TYPE_OTA_END: "END", TYPE_OTA_ABORT: "ABORT"}.get(ftype, "?")
# transport errors (TimeoutError/OSError) propagate unwrapped so the # A lost frame (flaky wiring) is recovered by resending: the device
# END handler can tell "ack lost due to reboot" apart from rejection # re-acks a duplicate BEGIN as BAD_STATE (session already open) and a
# duplicate DATA as BAD_STATE carrying the offset it already expects.
for attempt in range(1, XFER_TRIES + 1):
try:
rsp = await xfer(encode_frame(ftype, payload)) rsp = await xfer(encode_frame(ftype, payload))
except asyncio.TimeoutError:
if attempt < XFER_TRIES:
print(" %s: ack timeout, resending (attempt %d/%d)"
% (stage, attempt + 1, XFER_TRIES))
continue
raise asyncio.TimeoutError("%s: no OTA_RSP after %d tries"
% (stage, XFER_TRIES))
frames = FrameDecoder().feed(rsp) frames = FrameDecoder().feed(rsp)
if len(frames) != 1 or frames[0][0] != TYPE_OTA_RSP: if len(frames) != 1 or frames[0][0] != TYPE_OTA_RSP:
raise RuntimeError("%s: bad RSP frame: %s" % (stage, rsp.hex(" "))) raise RuntimeError("%s: bad RSP frame: %s" % (stage, rsp.hex(" ")))
echo, status, offset = parse_rsp(frames[0][2]) echo, status, offset = parse_rsp(frames[0][2])
if echo != ftype: if echo != ftype:
raise RuntimeError("%s: RSP echo 0x%02X != 0x%02X" % (stage, echo, ftype)) raise RuntimeError("%s: RSP echo 0x%02X != 0x%02X" % (stage, echo, ftype))
if ftype == TYPE_OTA_BEGIN and status == ST_BAD_STATE:
print(" BEGIN: session already open on device, resynced")
return ST_OK, offset
if ftype == TYPE_OTA_DATA and status == ST_BAD_STATE \
and ack_off is not None and offset == ack_off:
print(" DATA: chunk already stored (ack was lost), resynced")
return ST_OK, offset
return status, offset return status, offset
status, _ = await call(TYPE_OTA_BEGIN, status, _ = await call(TYPE_OTA_BEGIN,
@ -251,7 +326,8 @@ async def ota_session(xfer, chunk, blob, version, target):
while sent < len(blob): while sent < len(blob):
n = min(chunk, len(blob) - sent) n = min(chunk, len(blob) - sent)
status, next_off = await call(TYPE_OTA_DATA, status, next_off = await call(TYPE_OTA_DATA,
struct.pack("<I", sent) + blob[sent:sent + n]) struct.pack("<I", sent) + blob[sent:sent + n],
ack_off=sent + n)
if status != 0: if status != 0:
raise RuntimeError("OTA_DATA rejected at %d, status=%d (device " raise RuntimeError("OTA_DATA rejected at %d, status=%d (device "
"expects offset %d)" % (sent, status, next_off)) "expects offset %d)" % (sent, status, next_off))
@ -380,10 +456,7 @@ async def run_uart(port, pkg_path):
await ota_session(transport.xfer, 222, blob, version, target) await ota_session(transport.xfer, 222, blob, version, target)
except Exception as exc: except Exception as exc:
print("FAIL: %s" % exc) print("FAIL: %s" % exc)
try: await transport.leave_ota_mode() # ABORT: device returns to the CLI
await transport.xfer(encode_frame(TYPE_OTA_ABORT, b""))
except Exception:
pass
return 1 return 1
print("device resets into the new bank; rerun with the serial CLI " print("device resets into the new bank; rerun with the serial CLI "
"'devinfo' to confirm (cur bank / version).") "'devinfo' to confirm (cur bank / version).")

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,7 +1,7 @@
{ {
"file": "caiic_ble_dual_ota.bin", "file": "caiic_ble_dual_ota.bin",
"target_bank": 1, "target_bank": 1,
"size": 55264, "size": 55408,
"crc32": "0x5A24EBF6", "crc32": "0x71598E94",
"version": 65556 "version": 65557
} }

View File

@ -1,7 +1,7 @@
{ {
"file": "caiic_ble_dual_ota_app2.bin", "file": "caiic_ble_dual_ota_app2.bin",
"target_bank": 2, "target_bank": 2,
"size": 55564, "size": 55708,
"crc32": "0x97ED5D57", "crc32": "0xDAAEED74",
"version": 65556 "version": 65557
} }

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,7 +1,7 @@
{ {
"file": "mothercup_ble_prod_ota.bin", "file": "mothercup_ble_prod_ota.bin",
"target_bank": 1, "target_bank": 1,
"size": 55264, "size": 55408,
"crc32": "0x5A24EBF6", "crc32": "0x71598E94",
"version": 65556 "version": 65557
} }

View File

@ -1,7 +1,7 @@
{ {
"file": "mothercup_ble_prod_ota_app2.bin", "file": "mothercup_ble_prod_ota_app2.bin",
"target_bank": 2, "target_bank": 2,
"size": 55564, "size": 55708,
"crc32": "0x97ED5D57", "crc32": "0xDAAEED74",
"version": 65556 "version": 65557
} }