feat: add cross-platform customer fs generator
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
# customer_fs.bin 双系统生成工具
|
||||
|
||||
本目录提供 `EG800AKCN_11LG` 项目的 `customer_fs.bin` 自动生成和校验工具,支持
|
||||
Windows 与 macOS。
|
||||
|
||||
当前 `customer_fs` 分区大小为 576 KiB,设备 kernel 只支持 LittleFS 磁盘格式
|
||||
`0x00020000`(disk 2.0)。所有生成入口都会在输出前执行兼容性检查,避免再次生成
|
||||
kernel 无法挂载的 disk 2.1 镜像。
|
||||
|
||||
## 1. 源文件目录
|
||||
|
||||
文件系统的源文件统一存放在:
|
||||
|
||||
```text
|
||||
audio_prompts/customer_fs/
|
||||
```
|
||||
|
||||
当前目录中的 14 个音频文件,全部从已经过实机验证、能够正常挂载的 Windows
|
||||
`customer_fs.bin` 中提取,并经过重新打包和逐文件回读验证。
|
||||
|
||||
设备代码从 `U:/` 根目录访问文件,因此所有文件必须直接放在上述目录中。例如:
|
||||
|
||||
```text
|
||||
正确:audio_prompts/customer_fs/network_connect_zh.mp3
|
||||
错误:audio_prompts/customer_fs/subdir/network_connect_zh.mp3
|
||||
```
|
||||
|
||||
生成工具会拒绝隐藏文件和子目录,避免把 `.DS_Store` 等无关文件打进正式镜像。
|
||||
|
||||
## 2. 如何增删改文件
|
||||
|
||||
生成工具采用“根据源目录全量重建”的方式,不是在旧 bin 上追加内容。
|
||||
|
||||
- 添加:把新文件直接放进 `audio_prompts/customer_fs/`;
|
||||
- 替换:用新文件覆盖同名文件;
|
||||
- 编辑:使用音频工具修改原文件并保存到原位置;
|
||||
- 删除:从源目录删除文件,下一次生成的 bin 中将不再包含该文件;
|
||||
- 改名:直接修改源文件名,但必须同步确认业务代码使用的新文件名。
|
||||
|
||||
部分音频文件名已经写入业务代码。删除或改名后,如果代码仍然播放原文件,串口会打印:
|
||||
|
||||
```text
|
||||
tips file missing
|
||||
```
|
||||
|
||||
因此,生成工具允许删减文件,但删减前仍需确认业务代码不再使用它。
|
||||
|
||||
## 3. Windows 一键生成
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bat
|
||||
tools\customer_fs\build_windows.bat
|
||||
```
|
||||
|
||||
Windows 版本使用项目自带并已通过实机验证的工具:
|
||||
|
||||
```text
|
||||
ql-cross-tool\win32\mklfs-v2.exe
|
||||
```
|
||||
|
||||
同时使用 SDK 自带的 Python 执行兼容性校验,不需要另外安装 Python 依赖。
|
||||
|
||||
也可以指定自定义源目录和输出文件:
|
||||
|
||||
```bat
|
||||
tools\customer_fs\build_windows.bat 源目录 输出文件
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```bat
|
||||
tools\customer_fs\build_windows.bat ^
|
||||
audio_prompts\customer_fs ^
|
||||
output\customer_fs\customer_fs.bin
|
||||
```
|
||||
|
||||
## 4. macOS 一键生成
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
./tools/customer_fs/build_macos.sh
|
||||
```
|
||||
|
||||
首次运行时,脚本会自动创建:
|
||||
|
||||
```text
|
||||
.venv-customer-fs
|
||||
```
|
||||
|
||||
并安装项目固定的依赖版本:
|
||||
|
||||
```text
|
||||
littlefs-python==0.19.0
|
||||
```
|
||||
|
||||
新版 `littlefs-python` 默认会生成 disk 2.1 镜像,不能直接使用它自带的命令行生成。
|
||||
本项目的 `generate_macos.py` 会显式设置:
|
||||
|
||||
```text
|
||||
disk_version = 0x00020000
|
||||
```
|
||||
|
||||
从而生成当前设备 kernel 可以识别的 disk 2.0 镜像。
|
||||
|
||||
也可以指定自定义源目录和输出文件:
|
||||
|
||||
```bash
|
||||
./tools/customer_fs/build_macos.sh 源目录 输出文件
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
./tools/customer_fs/build_macos.sh \
|
||||
audio_prompts/customer_fs \
|
||||
output/customer_fs/customer_fs.bin
|
||||
```
|
||||
|
||||
## 5. 默认输出位置
|
||||
|
||||
Windows 和 macOS 默认都输出到:
|
||||
|
||||
```text
|
||||
output/customer_fs/customer_fs.bin
|
||||
```
|
||||
|
||||
生成时会先写入临时文件:
|
||||
|
||||
```text
|
||||
customer_fs.bin.tmp
|
||||
```
|
||||
|
||||
只有生成和全部校验成功后,临时文件才会替换正式输出。生成或校验失败时,已有正式输出
|
||||
不会被覆盖。
|
||||
|
||||
## 6. 自动门禁检查
|
||||
|
||||
每次生成都会检查:
|
||||
|
||||
| 检查项 | 要求 |
|
||||
| --- | ---: |
|
||||
| 镜像大小 | 589824 字节 |
|
||||
| LittleFS 磁盘格式 | `0x00020000` |
|
||||
| Block Size | 4096 字节 |
|
||||
| Block Count | 144 |
|
||||
| 第一个 superblock | 必须存在且参数正确 |
|
||||
| 第二个 superblock | 必须存在且参数正确 |
|
||||
| 源目录 | 不能为空 |
|
||||
| 源文件层级 | 只能是根目录文件 |
|
||||
| 隐藏文件 | 不允许 |
|
||||
| 文件名 | 必须能在生成镜像中找到 |
|
||||
|
||||
也可以单独校验一个镜像:
|
||||
|
||||
```bash
|
||||
python3 tools/customer_fs/verify_customer_fs.py \
|
||||
--image output/customer_fs/customer_fs.bin \
|
||||
--source audio_prompts/customer_fs
|
||||
```
|
||||
|
||||
校验成功会输出镜像大小、LittleFS 版本、块参数、源文件数量和 SHA-256。
|
||||
|
||||
## 7. 放入正式固件工程
|
||||
|
||||
生成并校验成功后,需要显式复制到 SDK 打包位置。
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
cp output/customer_fs/customer_fs.bin \
|
||||
ql-config/config/EG800AKCN_11LG/customer_fs.bin
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```bat
|
||||
copy /Y output\customer_fs\customer_fs.bin ^
|
||||
ql-config\config\EG800AKCN_11LG\customer_fs.bin
|
||||
```
|
||||
|
||||
替换前建议备份原文件并记录新镜像的 SHA-256。
|
||||
|
||||
`customer_fs.bin` 不是当前 APP-only OTA 的升级内容。修改该镜像后,需要重新生成并烧录
|
||||
包含分区表、kernel、customer_app 和 customer_fs 的完整工厂包,不能只下发 APP OTA。
|
||||
|
||||
## 8. 实机验收
|
||||
|
||||
Windows 生成工具已经过实机验证。macOS 工具已经通过结构校验、重新挂载和逐文件回读,
|
||||
首次用于正式版本前仍应至少烧录一台设备验收。
|
||||
|
||||
启动日志应直接出现:
|
||||
|
||||
```text
|
||||
[U] mount successfully
|
||||
```
|
||||
|
||||
不能出现:
|
||||
|
||||
```text
|
||||
[U] mount failed (-22), will format fs partition
|
||||
```
|
||||
|
||||
还需要逐一验证镜像中的音频能够通过 `U:/文件名` 正常读取和播放,并记录本次发布使用的
|
||||
`customer_fs.bin` SHA-256。
|
||||
|
||||
## 9. 注意事项
|
||||
|
||||
- 不要用十六进制工具把 disk 2.1 镜像的版本字段直接改成 disk 2.0;
|
||||
- 不要直接使用 `python -m littlefs create`,它当前默认生成 disk 2.1;
|
||||
- 不要把 `.venv-customer-fs`、`output` 或 `.DS_Store` 提交到仓库;
|
||||
- Windows 和 macOS 生成镜像的整体 SHA-256 可能不同,这是块分配顺序不同造成的;
|
||||
- 两个平台产物必须满足相同的 disk 2.0、4096 字节块和 144 块约束;
|
||||
- 修改、删除或重命名业务音频前,必须同步检查代码中的文件名引用。
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||
SOURCE_DIR=${1:-"$ROOT_DIR/audio_prompts/customer_fs"}
|
||||
OUTPUT_IMAGE=${2:-"$ROOT_DIR/output/customer_fs/customer_fs.bin"}
|
||||
VENV_DIR=${CUSTOMER_FS_VENV:-"$ROOT_DIR/.venv-customer-fs"}
|
||||
PYTHON_BOOTSTRAP=${PYTHON3:-python3}
|
||||
TEMP_IMAGE="${OUTPUT_IMAGE}.tmp"
|
||||
|
||||
cleanup() {
|
||||
rm -f -- "$TEMP_IMAGE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ ! -x "$VENV_DIR/bin/python" ]; then
|
||||
"$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if ! "$VENV_DIR/bin/python" -c 'import littlefs; raise SystemExit(0 if littlefs.__version__ == "0.19.0" else 1)' 2>/dev/null; then
|
||||
"$VENV_DIR/bin/python" -m pip install --requirement "$SCRIPT_DIR/requirements.txt"
|
||||
fi
|
||||
|
||||
mkdir -p -- "$(dirname -- "$OUTPUT_IMAGE")"
|
||||
rm -f -- "$TEMP_IMAGE"
|
||||
|
||||
"$VENV_DIR/bin/python" "$SCRIPT_DIR/generate_macos.py" \
|
||||
--source "$SOURCE_DIR" \
|
||||
--output "$TEMP_IMAGE"
|
||||
|
||||
"$VENV_DIR/bin/python" "$SCRIPT_DIR/verify_customer_fs.py" \
|
||||
--image "$TEMP_IMAGE" \
|
||||
--source "$SOURCE_DIR"
|
||||
|
||||
mv -f -- "$TEMP_IMAGE" "$OUTPUT_IMAGE"
|
||||
trap - EXIT INT TERM
|
||||
printf 'customer_fs ready: %s\n' "$OUTPUT_IMAGE"
|
||||
@@ -0,0 +1,56 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
for %%I in ("%SCRIPT_DIR%..\..") do set "ROOT_DIR=%%~fI"
|
||||
|
||||
if "%~1"=="" (
|
||||
set "SOURCE_DIR=%ROOT_DIR%\audio_prompts\customer_fs"
|
||||
) else (
|
||||
set "SOURCE_DIR=%~f1"
|
||||
)
|
||||
|
||||
if "%~2"=="" (
|
||||
set "OUTPUT_IMAGE=%ROOT_DIR%\output\customer_fs\customer_fs.bin"
|
||||
) else (
|
||||
set "OUTPUT_IMAGE=%~f2"
|
||||
)
|
||||
|
||||
set "TEMP_IMAGE=%OUTPUT_IMAGE%.tmp"
|
||||
set "MKLFS=%ROOT_DIR%\ql-cross-tool\win32\mklfs-v2.exe"
|
||||
set "PYTHON=%ROOT_DIR%\ql-cross-tool\win32\owtoolchain\Python\python.exe"
|
||||
|
||||
if not exist "%MKLFS%" (
|
||||
echo customer_fs generation failed: mklfs-v2.exe not found at "%MKLFS%"
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%PYTHON%" (
|
||||
echo customer_fs generation failed: bundled Python not found at "%PYTHON%"
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%SOURCE_DIR%\" (
|
||||
echo customer_fs generation failed: source directory not found at "%SOURCE_DIR%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%I in ("%OUTPUT_IMAGE%") do if not exist "%%~dpI" mkdir "%%~dpI"
|
||||
if exist "%TEMP_IMAGE%" del /q "%TEMP_IMAGE%"
|
||||
|
||||
"%MKLFS%" -c "%SOURCE_DIR%" -s 589824 -i "%TEMP_IMAGE%"
|
||||
if errorlevel 1 goto :failed
|
||||
|
||||
"%PYTHON%" "%SCRIPT_DIR%verify_customer_fs.py" ^
|
||||
--image "%TEMP_IMAGE%" ^
|
||||
--source "%SOURCE_DIR%"
|
||||
if errorlevel 1 goto :failed
|
||||
|
||||
move /Y "%TEMP_IMAGE%" "%OUTPUT_IMAGE%" >nul
|
||||
if errorlevel 1 goto :failed
|
||||
|
||||
echo customer_fs ready: "%OUTPUT_IMAGE%"
|
||||
exit /b 0
|
||||
|
||||
:failed
|
||||
if exist "%TEMP_IMAGE%" del /q "%TEMP_IMAGE%"
|
||||
echo customer_fs generation failed; existing output was not changed.
|
||||
exit /b 1
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a disk-2.0 LittleFS customer_fs image on macOS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from littlefs import LittleFS, UserContext
|
||||
|
||||
|
||||
IMAGE_SIZE = 589824
|
||||
BLOCK_SIZE = 4096
|
||||
BLOCK_COUNT = 144
|
||||
DISK_VERSION = 0x00020000
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SystemExit(f"customer_fs generation failed: {message}")
|
||||
|
||||
|
||||
def collect_source_files(source: Path) -> list[Path]:
|
||||
if not source.is_dir():
|
||||
fail(f"source directory does not exist: {source}")
|
||||
entries = sorted(source.iterdir(), key=lambda path: path.name.encode("utf-8"))
|
||||
if not entries:
|
||||
fail(f"source directory is empty: {source}")
|
||||
invalid = [entry.name for entry in entries if not entry.is_file() or entry.name.startswith(".")]
|
||||
if invalid:
|
||||
fail(
|
||||
"source must contain only visible root-level files; invalid entries: "
|
||||
+ ", ".join(invalid)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
files = collect_source_files(args.source)
|
||||
context = UserContext(buffsize=IMAGE_SIZE)
|
||||
fs = LittleFS(
|
||||
context=context,
|
||||
mount=False,
|
||||
block_size=BLOCK_SIZE,
|
||||
block_count=BLOCK_COUNT,
|
||||
read_size=16,
|
||||
prog_size=16,
|
||||
cache_size=256,
|
||||
lookahead_size=32,
|
||||
block_cycles=500,
|
||||
disk_version=DISK_VERSION,
|
||||
)
|
||||
fs.format()
|
||||
fs.mount()
|
||||
try:
|
||||
for source_file in files:
|
||||
with source_file.open("rb") as source_handle:
|
||||
with fs.open("/" + source_file.name, "wb") as image_handle:
|
||||
while chunk := source_handle.read(64 * 1024):
|
||||
image_handle.write(chunk)
|
||||
finally:
|
||||
fs.unmount()
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_bytes(context.buffer)
|
||||
print(f"generated {args.output} from {len(files)} files")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
littlefs-python==0.19.0
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a customer_fs LittleFS image for the EG800AKCN_11LG kernel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_SIZE = 589824
|
||||
DEFAULT_DISK_VERSION = 0x00020000
|
||||
DEFAULT_BLOCK_SIZE = 4096
|
||||
DEFAULT_BLOCK_COUNT = 144
|
||||
SUPERBLOCK_MAGIC_OFFSETS = (8, DEFAULT_BLOCK_SIZE + 8)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SystemExit(f"customer_fs verification failed: {message}")
|
||||
|
||||
|
||||
def source_files(source: Path) -> list[Path]:
|
||||
if not source.is_dir():
|
||||
fail(f"source directory does not exist: {source}")
|
||||
|
||||
entries = sorted(source.iterdir(), key=lambda path: path.name.encode("utf-8"))
|
||||
if not entries:
|
||||
fail(f"source directory is empty: {source}")
|
||||
|
||||
invalid = [entry.name for entry in entries if not entry.is_file() or entry.name.startswith(".")]
|
||||
if invalid:
|
||||
fail(
|
||||
"source must contain only visible root-level files; invalid entries: "
|
||||
+ ", ".join(invalid)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def verify_superblock(
|
||||
image: bytes,
|
||||
magic_offset: int,
|
||||
disk_version: int,
|
||||
block_size: int,
|
||||
block_count: int,
|
||||
) -> None:
|
||||
if image[magic_offset : magic_offset + 8] != b"littlefs":
|
||||
fail(f"LittleFS magic is missing at 0x{magic_offset:X}")
|
||||
|
||||
actual_version, actual_block_size, actual_block_count = struct.unpack_from(
|
||||
"<III", image, magic_offset + 12
|
||||
)
|
||||
if actual_version != disk_version:
|
||||
fail(
|
||||
f"disk version at 0x{magic_offset:X} is 0x{actual_version:08X}; "
|
||||
f"expected 0x{disk_version:08X}"
|
||||
)
|
||||
if actual_block_size != block_size:
|
||||
fail(
|
||||
f"block size at 0x{magic_offset:X} is {actual_block_size}; "
|
||||
f"expected {block_size}"
|
||||
)
|
||||
if actual_block_count != block_count:
|
||||
fail(
|
||||
f"block count at 0x{magic_offset:X} is {actual_block_count}; "
|
||||
f"expected {block_count}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--image", required=True, type=Path)
|
||||
parser.add_argument("--source", type=Path, help="also check root-level source filenames")
|
||||
parser.add_argument("--size", type=int, default=DEFAULT_SIZE)
|
||||
parser.add_argument(
|
||||
"--disk-version", type=lambda value: int(value, 0), default=DEFAULT_DISK_VERSION
|
||||
)
|
||||
parser.add_argument("--block-size", type=int, default=DEFAULT_BLOCK_SIZE)
|
||||
parser.add_argument("--block-count", type=int, default=DEFAULT_BLOCK_COUNT)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.image.is_file():
|
||||
fail(f"image does not exist: {args.image}")
|
||||
image = args.image.read_bytes()
|
||||
if len(image) != args.size:
|
||||
fail(f"image size is {len(image)}; expected {args.size}")
|
||||
if args.block_size * args.block_count != args.size:
|
||||
fail("block size multiplied by block count does not equal image size")
|
||||
|
||||
for magic_offset in (8, args.block_size + 8):
|
||||
verify_superblock(
|
||||
image,
|
||||
magic_offset,
|
||||
args.disk_version,
|
||||
args.block_size,
|
||||
args.block_count,
|
||||
)
|
||||
|
||||
files: list[Path] = []
|
||||
if args.source:
|
||||
files = source_files(args.source)
|
||||
missing = [path.name for path in files if path.name.encode("utf-8") not in image]
|
||||
if missing:
|
||||
fail("source filenames missing from image: " + ", ".join(missing))
|
||||
|
||||
digest = hashlib.sha256(image).hexdigest()
|
||||
print("customer_fs verified")
|
||||
print(f" image: {args.image}")
|
||||
print(f" size: {len(image)}")
|
||||
print(f" disk_version: 0x{args.disk_version:08X}")
|
||||
print(f" block_size: {args.block_size}")
|
||||
print(f" block_count: {args.block_count}")
|
||||
if files:
|
||||
print(f" source_files: {len(files)}")
|
||||
print(f" sha256: {digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user