diff --git a/build_rel.bat b/build_rel.bat index 96594d2..df3a3e0 100644 --- a/build_rel.bat +++ b/build_rel.bat @@ -1,9 +1,12 @@ @echo off set CURDIR=%~dp0 -for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddHHmmss"') do set appbuildtime=%%i +for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddHHmmss"') do set appbuildtime=%%i set appbuildrev=NULL set appbuildmodel=EG800AKCN_11LG set appbuildver=beta1 +set otalayoutid=eg800akcn_11lg-fs640-app2c2000 +set otabuildprofile=ota_ready_fs640 +set pythonexe=ql-cross-tool\win32\owtoolchain\Python\python.exe if /i not "%1" == "" ( set appbuildver=%1 @@ -18,14 +21,28 @@ echo #define CUSTOM_APP_BUILD_TIME "%appbuildtime%" >> ql-applicati echo #define CUSTOM_APP_BUILD_REV "%appbuildrev%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h echo #define CUSTOM_APP_BUILD_MODEL "%appbuildmodel%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h echo #define CUSTOM_APP_BUILD_VER "%appbuildver%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h +echo #define CUSTOM_APP_OTA_LAYOUT_ID "%otalayoutid%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h +echo #define CUSTOM_APP_OTA_BUILD_PROFILE "%otabuildprofile%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h echo #endif >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h +"%pythonexe%" tools\verify_ota_ready_baseline.py +if errorlevel 1 ( + echo "OTA-ready baseline source verification failed" + exit /b 1 +) + call build.bat project_name -p EG800AKCN_11LG -b NO_EXTFLASH -x XIP -c %appbuildmodel% call build.bat clean call build.bat app call build.bat kernel call build.bat firmware +"%pythonexe%" tools\verify_ota_ready_baseline.py --app-bin ql-application\threadx\build\app.bin +if errorlevel 1 ( + echo "OTA-ready baseline artifact verification failed" + exit /b 1 +) + if exist "target\%appbuildmodel%.zip" ( mkdir target\%appbuildmodel%\fota\ mkdir target\%appbuildmodel%\fota\a diff --git a/docs/OTA_READY_FACTORY_BASELINE.md b/docs/OTA_READY_FACTORY_BASELINE.md new file mode 100644 index 0000000..f224efe --- /dev/null +++ b/docs/OTA_READY_FACTORY_BASELINE.md @@ -0,0 +1,231 @@ +# Banban OTA-ready 工厂基线 + +## 1. 目标和边界 + +本基线用于尚未量产的 `EG800AKCN_11LG` 设备。目标是在不改变现有对话、音频、 +MQTT、NFC、GNSS、按键、休眠和 LED 业务流程的前提下,一次性固定后续本地暂存式 +OTA 所依赖的底层条件。 + +本基线完成: + +- 将 `customer_fs` 从 256 KiB 扩大到 640 KiB; +- 保持 `customer_app` 为 460 KiB; +- 将 APP 物理/XIP 地址固定为 `0x002C2000` / `0x802C2000`; +- 保留 SDK 已有的 `QUEC_APP_FULL_FOTA` 与 FULLOTA2 公共 API; +- 为设备和构建产物加入唯一布局身份; +- 在构建前后执行 fail-closed 校验。 + +本基线暂不完成: + +- 不把当前 `ql_fullfota_app_start()` 在线升级流程切换成 FULLOTA2; +- 不增加签名 manifest、SHA-256 下载校验、断点续传或后台灰度; +- 不提供 A/B APP 分区,也不承诺自动版本回滚; +- 不在线更新 kernel、分区表或 `customer_fs`。 + +这样安排是为了先冻结量产后难以改变的 Flash 布局,同时避免在生产前仓促替换业务 OTA +状态机。后续 FULLOTA2 逻辑只需通过 `customer_app` 版本迭代即可加入。 + +> 生产门禁:从本工厂基线开始量产,到 FULLOTA2 业务 APP 完成真机验收并正式发布之前, +> 后端必须暂停向生产设备下发 OTA 命令。当前 `ql_fullfota_app_start()` 入口仍是原有 +> FULLOTA1 流程,新的分区基线不会自动消除其覆盖下载风险。 + +## 2. 固定身份与 Flash 布局 + +设备布局身份: + +```text +eg800akcn_11lg-fs640-app2c2000 +``` + +构建 profile: + +```text +ota_ready_fs640 +``` + +4 MiB 内部 Flash 的尾部分区固定如下: + +| 分区 | 起始地址 | 结束地址 | 大小 | +| --- | ---: | ---: | ---: | +| reserved | `0x00287000` | `0x002C2000` | 236 KiB | +| customer_app | `0x002C2000` | `0x00335000` | 460 KiB | +| customer_fs | `0x00335000` | `0x003D5000` | 640 KiB | +| nvm | `0x003D5000` | `0x003F5000` | 128 KiB | +| erase_rd | `0x003F5000` | `0x003F7000` | 8 KiB | +| fota_param | `0x003F7000` | `0x003FA000` | 12 KiB | +| factory_a | `0x003FA000` | `0x00400000` | 24 KiB | + +`customer_app` 是单槽。FULLOTA2 能保护下载阶段:文件未下载完整、哈希错误或 FBF 校验 +失败时不会设置升级标志;但 updater 开始擦写 `customer_app` 后,没有旧 APP 槽可供回滚。 + +## 3. 本次修改 + +### 3.1 分区和链接地址 + +- `CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json`:`customer_fs = 640KiB`; +- `app_linkscript.ld`:ROM 起始地址改为 `0x802C2000`,最大 ROM 大小收紧为 `0x73000`; +- `customer_fs.bin`:从原 256 KiB LittleFS 中提取全部 14 个音频文件,以 4096 字节块、 + 160 个块重新生成 640 KiB LittleFS; +- 新文件系统镜像 SHA-256: + `19bd849dbcc6816f6dfffd1e585645a3474a2265f96a926b5dd073f0affb69f9`。 + +### 3.2 运行身份 + +`ota_baseline.h` 固定布局身份、APP/FS 地址和分区大小。应用启动时打印: + +```text +[OTA-BASELINE] layout=eg800akcn_11lg-fs640-app2c2000 ... slot=single +[OTA-BASELINE] U: total=... free=... fota2_reserve=65536 +``` + +该日志用于产线和联调确认设备没有混入旧的 fs256 基线,不改变任何业务状态。 + +### 3.3 构建保护 + +`build_rel.bat` 在构建前运行源码校验,在生成 APP 后运行容量校验。任何一项不满足都会中止: + +- `customer_fs` 必须为 640 KiB; +- APP 地址必须为 `0x002C2000`; +- LittleFS 必须为 4096 字节块、160 块且镜像哈希一致; +- kernel 必须继续定义 `QUEC_APP_FULL_FOTA`; +- FULLOTA2 所需的 SDK API 必须存在; +- `app.bin + 8192` 不得超过 460 KiB。 + +可独立执行: + +```powershell +ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baseline.py +ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baseline.py ` + --app-bin ql-application\threadx\build\app.bin +``` + +## 4. 构建和生产烧录 + +在 Windows SDK 构建环境中执行: + +```bat +build_rel.bat <生产版本号> +``` + +量产必须烧录该构建产生的完整 `*-fac.zip`,不能只烧 `customer_app`。完整工厂包需要同时 +携带新 `partition.bin`、新 `customer_fs.bin`、11LG kernel/system 和新地址 APP。 + +量产前冻结并记录: + +- 源码 Git commit; +- 工厂 ZIP 文件名、字节数和 SHA-256; +- `customer_fs.bin` SHA-256; +- `customer_app.bin` 字节数和 SHA-256; +- `partition.bin` 字节数和 SHA-256; +- 编译器、Aboot/arelease 和 FBFMake 版本; +- 首台、末台抽检设备串口中的 OTA baseline 日志。 + +不要把 `EG800AKCN_91LC` 的 `cp.bin`、工厂包或射频/GNSS 制品复制给本项目。两个项目同为 +ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。 + +## 5. 生产前测试 + +### 5.1 静态和产物测试 + +1. 运行 `verify_ota_ready_baseline.py`,结果必须通过。 +2. 解包工厂 ZIP,确认其中 `customer_fs.bin` 为 655360 字节且哈希一致。 +3. 使用 Aboot 工具检查 `partition.bin`,确认 APP/FS 地址与本文件一致。 +4. 确认 `customer_app.bin + 8192 <= 471040`。 +5. 确认 FBF 的目标物理地址是 `0x002C2000`,不能是旧地址 `0x00322000`。 + +### 5.2 全擦工厂烧录测试 + +至少准备 3 台板执行完整擦除和工厂烧录: + +1. 下载器无越界、校验或 partition 错误。 +2. USB 重新枚举,`AT`/`ATI` 正常。 +3. 串口打印正确 layout、地址和 640 KiB 文件系统容量。 +4. 14 个预置音频均可读取和播放。 +5. IMEI、SIM、驻网、数据连接和 GNSS 正常。 +6. MQTT 注册、命令收发、心跳和状态上报正常。 +7. 唤醒、对话、打断、录音、播放、音量和休眠正常。 +8. NFC、按键、LED、电池检测和低电关机正常。 +9. 连续冷启动、软重启和断电启动各不少于 20 次。 +10. 连续运行至少 8 小时,观察 watchdog、内存、文件系统和网络重连。 + +### 5.3 文件系统压力测试 + +1. 启动后记录 `ql_fs_size('U')` 和 `ql_fs_free_size('U')`。 +2. 写入一个与计划 FBF 等大的测试文件,关闭、重启并逐字节回读。 +3. 删除测试文件,确认空间恢复。 +4. 在写入 10%、50%、90% 时分别断电,重启后确认业务 APP 仍可运行并可清理残留文件。 +5. 验证正常业务文件占用后,仍满足 `FBF size + 64 KiB` 空闲门槛。 + +### 5.4 最小 FULLOTA2 冒烟测试 + +正式量产前至少做一次 A 到 B 的本地暂存升级。B 可以只修改版本号: + +1. 将 B 版 `customer_app.fbf` 顺序写入 `U:/FotaFile.bin`; +2. 调用 `ql_fota_fixed_internal_flash_upgrade(1)`; +3. 调用 `ql_fota_set_package_path()`、`ql_fota_init()`、`ql_fota_image_write()`、 + `ql_fota_image_flush()` 和 `ql_fota_image_verify()`; +4. 重启,确认 updater 安装 B,且 `U:/FotaFile.bin` 被正确处理; +5. 确认 NVM、设备身份、业务配置和预置音频未损坏; +6. 下载损坏、截断和旧地址 FBF,确认均不能设置有效升级标志。 + +## 6. 后续 FULLOTA2 业务实现 + +后续 APP 版本建议按以下顺序加入,不再改变本基线分区: + +1. 下载所有权收敛到单一 OTA task,其他任务只投递数据或事件。 +2. 下载前停止对话、录音、播放和非必要 Flash 写入,保持 watchdog 正常喂狗。 +3. 删除确认不再使用的旧暂存文件。 +4. 检查 `free >= manifest.size + 65536`。 +5. 从偏移 0 顺序写入 `U:/FotaFile.bin`;首版不要宣称支持断点续传。 +6. 同时计算 SHA-256,并在下载完成后重新打开本地文件复核大小和 SHA-256。 +7. 在设置升级标志前持久化 `PENDING_REBOOT` 状态。 +8. 只有 `ql_fota_image_verify()` 成功后才重启。 +9. 新 APP 启动后核对版本、release sequence 和 layout,再上报健康状态。 +10. 失败时 deinit、删除残留文件、恢复业务,并上报明确错误码。 + +建议使用两个带 CRC 和递增 generation 的状态文件,例如 `U:/ota_state.a`、 +`U:/ota_state.b`。这能抵抗状态文件写入中断,但不构成固件 A/B 回滚。 + +## 7. 后端联调契约 + +现有 OTA 命令仅有 URL 和版本不足以可靠激活 FULLOTA2。建议后端增加: + +```json +{ + "product": "EG800AKCN_11LG", + "layout_id": "eg800akcn_11lg-fs640-app2c2000", + "version": "1.9.0", + "release_seq": 10900, + "min_release_seq": 10800, + "url": "https://ota.example/releases/1.9.0/customer_app.fbf", + "size": 212992, + "sha256": "64-lowercase-hex", + "signature": "optional-first-stage" +} +``` + +设备校验顺序:产品、layout、版本序号、URL host、大小、SHA-256,最后才允许设置升级标志。 + +设备状态建议至少包含: + +- `ACCEPTED`:命令和布局校验通过; +- `DOWNLOADING`:带下载字节数或百分比; +- `VERIFIED`:本地文件 SHA-256 和 FBF 校验通过; +- `REBOOTING`:升级标志已设置; +- `UPDATED`:目标 APP 已启动并通过健康检查; +- `FAILED`:带稳定错误码和失败阶段。 + +联调测试矩阵必须覆盖:同版本、降级、错误 layout、大小不符、SHA 不符、404、超时、下载 +中断、下载阶段断电、verify 前断电、重启后无法确认,以及后台重复下发同一命令。 + +如果暂时没有动态 OTA 后端,也可以用“不可变 FBF 对象 + 签名 current.json”实现;但灰度、 +设备级进度和失败统计需要后台服务。 + +## 8. 发布和回退策略 + +- 新生产设备只允许使用 fs640 工厂包和 `0x002C2000` APP/FBF。 +- 禁止向 fs640 设备发布旧地址 `0x00322000` 的 FBF。 +- 如生产前验证失败,可回退源码分支和工厂包重新全擦烧录;不得混烧新旧布局制品。 +- 量产后若只是业务问题,可通过 APP OTA 修复;若分区或 kernel 基线有问题,需要返厂或有线 + 完整烧录,不能假定业务 APP OTA 能安全修改分区表。 +- 当前为单槽。发布新版本必须先小批量灰度,并保持可用的有线恢复工厂包。 diff --git a/ql-application/threadx/config/common/app_linkscript.ld b/ql-application/threadx/config/common/app_linkscript.ld index a727193..fe83dba 100644 --- a/ql-application/threadx/config/common/app_linkscript.ld +++ b/ql-application/threadx/config/common/app_linkscript.ld @@ -1,6 +1,6 @@ -__ROM_START_ADDR = 0x80322000; -__MAX_ROM_SIZE = 0x100000; +__ROM_START_ADDR = 0x802C2000; +__MAX_ROM_SIZE = 0x73000; __RAM_START_ADDR = 0x7e040800; __MAX_RAM_SIZE = 0x100000; diff --git a/ql-application/threadx/interface/custom_app/custom_app.c b/ql-application/threadx/interface/custom_app/custom_app.c index 1291ee9..e045ffd 100644 --- a/ql-application/threadx/interface/custom_app/custom_app.c +++ b/ql-application/threadx/interface/custom_app/custom_app.c @@ -29,6 +29,7 @@ #include "ql_log.h" #include "hal_api.h" #include "nfc.h" +#include "ota_baseline.h" #define APP_ENABLE_SLEEP @@ -146,6 +147,17 @@ static void custom_app_task(void * argv) hal_os_get_build_time(tempbuf); printf("BUILD TIME:%s\r\n",tempbuf); + printf("[OTA-BASELINE] layout=%s profile=%s app_phys=0x%08lX app_xip=0x%08lX app_size=%lu fs_size=%lu slot=single\r\n", + BANBAN_OTA_LAYOUT_ID, + BANBAN_OTA_BUILD_PROFILE, + (unsigned long)BANBAN_OTA_APP_PHYSICAL_ADDRESS, + (unsigned long)BANBAN_OTA_APP_XIP_ADDRESS, + (unsigned long)BANBAN_OTA_APP_PARTITION_SIZE, + (unsigned long)BANBAN_OTA_FS_PARTITION_SIZE); + printf("[OTA-BASELINE] U: total=%d free=%d fota2_reserve=%lu\r\n", + ql_fs_size('U'), + ql_fs_free_size('U'), + (unsigned long)BANBAN_OTA_STAGING_RESERVE_BYTES); //app_uartsh_init(); diff --git a/ql-application/threadx/interface/custom_app/inc/ota_baseline.h b/ql-application/threadx/interface/custom_app/inc/ota_baseline.h new file mode 100644 index 0000000..4c6f163 --- /dev/null +++ b/ql-application/threadx/interface/custom_app/inc/ota_baseline.h @@ -0,0 +1,21 @@ +#ifndef BANBAN_OTA_BASELINE_H +#define BANBAN_OTA_BASELINE_H + +#include +#include "build_macro.h" + +#define BANBAN_OTA_EXPECTED_LAYOUT_ID "eg800akcn_11lg-fs640-app2c2000" +#define BANBAN_OTA_LAYOUT_ID CUSTOM_APP_OTA_LAYOUT_ID +#define BANBAN_OTA_BUILD_PROFILE CUSTOM_APP_OTA_BUILD_PROFILE +#define BANBAN_OTA_APP_PHYSICAL_ADDRESS UINT32_C(0x002C2000) +#define BANBAN_OTA_APP_XIP_ADDRESS UINT32_C(0x802C2000) +#define BANBAN_OTA_APP_PARTITION_SIZE UINT32_C(0x00073000) +#define BANBAN_OTA_FS_PHYSICAL_ADDRESS UINT32_C(0x00335000) +#define BANBAN_OTA_FS_PARTITION_SIZE UINT32_C(0x000A0000) +#define BANBAN_OTA_STAGING_RESERVE_BYTES UINT32_C(0x00010000) + +#if !defined(CUSTOM_APP_OTA_LAYOUT_ID) || !defined(CUSTOM_APP_OTA_BUILD_PROFILE) +#error "Use build_rel.bat to build the OTA-ready production baseline" +#endif + +#endif diff --git a/ql-application/threadx/interface/custom_app/lib/hal/build_macro.h b/ql-application/threadx/interface/custom_app/lib/hal/build_macro.h index 6717201..d4865c5 100644 --- a/ql-application/threadx/interface/custom_app/lib/hal/build_macro.h +++ b/ql-application/threadx/interface/custom_app/lib/hal/build_macro.h @@ -4,4 +4,6 @@ #define CUSTOM_APP_BUILD_REV "NULL" #define CUSTOM_APP_BUILD_MODEL "EG800AKCN_11LG" #define CUSTOM_APP_BUILD_VER "1.8.0" +#define CUSTOM_APP_OTA_LAYOUT_ID "eg800akcn_11lg-fs640-app2c2000" +#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_fs640" #endif diff --git a/ql-config/config/EG800AKCN_11LG/customer_fs.bin b/ql-config/config/EG800AKCN_11LG/customer_fs.bin index 5066bf7..6093ff2 100644 --- a/ql-config/config/EG800AKCN_11LG/customer_fs.bin +++ b/ql-config/config/EG800AKCN_11LG/customer_fs.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c498e97811cc8e4f4732936cc26cd42fb37c9c9f4795d2cab9135dca2636c16 -size 262144 +oid sha256:19bd849dbcc6816f6dfffd1e585645a3474a2265f96a926b5dd073f0affb69f9 +size 655360 diff --git a/ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json b/ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json index 12ffbc5..1f8ed0f 100644 --- a/ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json +++ b/ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json @@ -56,7 +56,7 @@ { "name": "customer_fs", "format": "raw", - "size": "256KiB" + "size": "640KiB" }, { "name": "nvm", diff --git a/tools/verify_ota_ready_baseline.py b/tools/verify_ota_ready_baseline.py new file mode 100644 index 0000000..0093ac1 --- /dev/null +++ b/tools/verify_ota_ready_baseline.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fail-closed checks for the EG800AKCN_11LG OTA-ready factory baseline.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import struct +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LAYOUT = ROOT / "ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json" +LINKER = ROOT / "ql-application/threadx/config/common/app_linkscript.ld" +FS_IMAGE = ROOT / "ql-config/config/EG800AKCN_11LG/customer_fs.bin" +KERNEL_FLAGS = ROOT / "ql-kernel/threadx/config/common/default_dflags_1602.mk" +FOTA_HEADER = ROOT / "ql-application/threadx/common/include/ql_fota.h" +BASELINE_HEADER = ROOT / "ql-application/threadx/interface/custom_app/inc/ota_baseline.h" +BUILD_MACRO = ROOT / "ql-application/threadx/interface/custom_app/lib/hal/build_macro.h" + +FLASH_SIZE = 0x400000 +EXPECTED = { + "app_start": 0x002C2000, + "app_size": 0x00073000, + "fs_start": 0x00335000, + "fs_size": 0x000A0000, + "reserved_size": 0x0003B000, + "xip_start": 0x802C2000, +} +FBF_HEADER_ALLOWANCE = 8192 +EXPECTED_FS_SHA256 = "19bd849dbcc6816f6dfffd1e585645a3474a2265f96a926b5dd073f0affb69f9" + + +def fail(message: str) -> None: + raise SystemExit(f"OTA-ready baseline verification failed: {message}") + + +def parse_size(value: str) -> int: + if value == "-1": + return -1 + match = re.fullmatch(r"(\d+)(KiB|MiB)", value) + if not match: + fail(f"unsupported partition size {value!r}") + scale = 1024 if match.group(2) == "KiB" else 1024 * 1024 + return int(match.group(1)) * scale + + +def flattened_tail() -> list[tuple[str, int]]: + data = json.loads(LAYOUT.read_text(encoding="utf-8")) + qspi = next(item for item in data["partitions"] if item["name"] == "qspi") + result: list[tuple[str, int]] = [] + for item in qspi["partitions"]: + if item["name"] == "factory": + result.extend((child["name"], parse_size(child["size"])) for child in item["partitions"]) + elif item["name"] in {"reserved", "customer_app", "customer_fs", "nvm", "erase_rd", "fota_param"}: + result.append((item["name"], parse_size(item["size"]))) + return result + + +def verify_layout() -> None: + tail = flattened_tail() + sizes = dict(tail) + if sizes.get("customer_app") != EXPECTED["app_size"]: + fail("customer_app must be 460 KiB") + if sizes.get("customer_fs") != EXPECTED["fs_size"]: + fail("customer_fs must be 640 KiB") + + suffix = 0 + starts: dict[str, int] = {} + for name, size in reversed(tail): + if size < 0: + continue + suffix += size + starts[name] = FLASH_SIZE - suffix + + if starts.get("customer_app") != EXPECTED["app_start"]: + fail(f"customer_app starts at 0x{starts.get('customer_app', 0):08X}, expected 0x{EXPECTED['app_start']:08X}") + if starts.get("customer_fs") != EXPECTED["fs_start"]: + fail(f"customer_fs starts at 0x{starts.get('customer_fs', 0):08X}, expected 0x{EXPECTED['fs_start']:08X}") + + # The system image ends at 0x287000 for this accepted 4 MiB CRANELRH baseline. + reserved = EXPECTED["app_start"] - 0x00287000 + if reserved != EXPECTED["reserved_size"]: + fail("reserved partition calculation changed") + + +def verify_linker() -> None: + text = LINKER.read_text(encoding="utf-8") + rom = re.search(r"__ROM_START_ADDR\s*=\s*(0x[0-9A-Fa-f]+)", text) + maximum = re.search(r"__MAX_ROM_SIZE\s*=\s*(0x[0-9A-Fa-f]+)", text) + if not rom or int(rom.group(1), 16) != EXPECTED["xip_start"]: + fail("linker ROM address is not 0x802C2000") + if not maximum or int(maximum.group(1), 16) != EXPECTED["app_size"]: + fail("linker ROM limit is not the 460 KiB customer_app partition") + + +def verify_fs_image() -> None: + image = FS_IMAGE.read_bytes() + if len(image) != EXPECTED["fs_size"]: + fail(f"customer_fs.bin is {len(image)} bytes, expected {EXPECTED['fs_size']}") + digest = hashlib.sha256(image).hexdigest() + if digest != EXPECTED_FS_SHA256: + fail(f"customer_fs.bin SHA-256 is {digest}; expected {EXPECTED_FS_SHA256}") + magic_at = image.find(b"littlefs", 0, 64) + if magic_at < 0: + fail("customer_fs.bin has no LittleFS superblock") + # The global-state tag follows the magic; geometry starts 12 bytes later. + disk_version, block_size, block_count = struct.unpack_from("> 16 != 2: + fail(f"unsupported LittleFS disk version 0x{disk_version:08X}") + if block_size != 4096 or block_count != 160: + fail(f"LittleFS geometry is block_size={block_size}, block_count={block_count}; expected 4096/160") + + +def verify_fota_contract() -> None: + flags = KERNEL_FLAGS.read_text(encoding="utf-8", errors="replace") + if "-DQUEC_APP_FULL_FOTA" not in flags: + fail("kernel no longer enables QUEC_APP_FULL_FOTA") + api = FOTA_HEADER.read_text(encoding="utf-8", errors="replace") + for symbol in ( + "ql_fota_init", + "ql_fota_image_write", + "ql_fota_image_flush", + "ql_fota_image_verify", + "ql_fota_set_package_path", + "ql_fota_fixed_internal_flash_upgrade", + ): + if symbol not in api: + fail(f"SDK FULLOTA2 API {symbol} is missing") + identity = BASELINE_HEADER.read_text(encoding="utf-8") + if "eg800akcn_11lg-fs640-app2c2000" not in identity: + fail("application layout identity is missing") + generated = BUILD_MACRO.read_text(encoding="utf-8", errors="replace") + if '#define CUSTOM_APP_OTA_LAYOUT_ID "eg800akcn_11lg-fs640-app2c2000"' not in generated: + fail("generated build macro has the wrong OTA layout identity") + if '#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_fs640"' not in generated: + fail("generated build macro has the wrong OTA build profile") + + +def verify_app(path: Path) -> None: + if not path.is_file(): + fail(f"application binary does not exist: {path}") + size = path.stat().st_size + if size + FBF_HEADER_ALLOWANCE > EXPECTED["app_size"]: + fail( + f"app.bin ({size}) plus {FBF_HEADER_ALLOWANCE}-byte FBF allowance exceeds " + f"customer_app ({EXPECTED['app_size']})" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app-bin", type=Path, help="also enforce the application/FBF capacity gate") + args = parser.parse_args() + verify_layout() + verify_linker() + verify_fs_image() + verify_fota_contract() + if args.app_bin: + verify_app(args.app_bin if args.app_bin.is_absolute() else ROOT / args.app_bin) + print("OTA-ready baseline verified: fs640, app@0x002C2000, FULLOTA2 available") + return 0 + + +if __name__ == "__main__": + sys.exit(main())