diff --git a/.gitignore b/.gitignore index 36e7a39..976ceab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Firmware build and release output. /target/ /version/ +/output/ +/.venv-customer-fs/ # Local legacy-board packages retained when switching to the new-board branch. /artifacts/legacy-no-gnss/ diff --git a/audio_prompts/customer_fs/ai_active_zh.mp3 b/audio_prompts/customer_fs/ai_active_zh.mp3 new file mode 100644 index 0000000..88a7669 Binary files /dev/null and b/audio_prompts/customer_fs/ai_active_zh.mp3 differ diff --git a/audio_prompts/customer_fs/barge_in_cute.mp3 b/audio_prompts/customer_fs/barge_in_cute.mp3 new file mode 100644 index 0000000..c9cd9f7 Binary files /dev/null and b/audio_prompts/customer_fs/barge_in_cute.mp3 differ diff --git a/audio_prompts/customer_fs/device_sleep_mode_zh.mp3 b/audio_prompts/customer_fs/device_sleep_mode_zh.mp3 new file mode 100644 index 0000000..13b1723 Binary files /dev/null and b/audio_prompts/customer_fs/device_sleep_mode_zh.mp3 differ diff --git a/audio_prompts/customer_fs/device_wake_up_zh.mp3 b/audio_prompts/customer_fs/device_wake_up_zh.mp3 new file mode 100644 index 0000000..bac7deb Binary files /dev/null and b/audio_prompts/customer_fs/device_wake_up_zh.mp3 differ diff --git a/audio_prompts/customer_fs/have_rest_zh.mp3 b/audio_prompts/customer_fs/have_rest_zh.mp3 new file mode 100644 index 0000000..6e0bc87 Binary files /dev/null and b/audio_prompts/customer_fs/have_rest_zh.mp3 differ diff --git a/audio_prompts/customer_fs/low_power_off_zh.mp3 b/audio_prompts/customer_fs/low_power_off_zh.mp3 new file mode 100644 index 0000000..aa70eaa Binary files /dev/null and b/audio_prompts/customer_fs/low_power_off_zh.mp3 differ diff --git a/audio_prompts/customer_fs/low_power_zh.mp3 b/audio_prompts/customer_fs/low_power_zh.mp3 new file mode 100644 index 0000000..fee0d24 Binary files /dev/null and b/audio_prompts/customer_fs/low_power_zh.mp3 differ diff --git a/audio_prompts/customer_fs/network_check.mp3 b/audio_prompts/customer_fs/network_check.mp3 new file mode 100644 index 0000000..473697c Binary files /dev/null and b/audio_prompts/customer_fs/network_check.mp3 differ diff --git a/audio_prompts/customer_fs/network_connect_zh.mp3 b/audio_prompts/customer_fs/network_connect_zh.mp3 new file mode 100644 index 0000000..3b2b407 Binary files /dev/null and b/audio_prompts/customer_fs/network_connect_zh.mp3 differ diff --git a/audio_prompts/customer_fs/network_ok_zh.mp3 b/audio_prompts/customer_fs/network_ok_zh.mp3 new file mode 100644 index 0000000..a39c8e8 Binary files /dev/null and b/audio_prompts/customer_fs/network_ok_zh.mp3 differ diff --git a/audio_prompts/customer_fs/tts_error.mp3 b/audio_prompts/customer_fs/tts_error.mp3 new file mode 100644 index 0000000..7f0336a Binary files /dev/null and b/audio_prompts/customer_fs/tts_error.mp3 differ diff --git a/audio_prompts/customer_fs/upgrading.mp3 b/audio_prompts/customer_fs/upgrading.mp3 new file mode 100644 index 0000000..5cb2593 Binary files /dev/null and b/audio_prompts/customer_fs/upgrading.mp3 differ diff --git a/audio_prompts/customer_fs/volume_down_zh.mp3 b/audio_prompts/customer_fs/volume_down_zh.mp3 new file mode 100644 index 0000000..5d5bd1a Binary files /dev/null and b/audio_prompts/customer_fs/volume_down_zh.mp3 differ diff --git a/audio_prompts/customer_fs/volume_up_zh.mp3 b/audio_prompts/customer_fs/volume_up_zh.mp3 new file mode 100644 index 0000000..f6952bb Binary files /dev/null and b/audio_prompts/customer_fs/volume_up_zh.mp3 differ diff --git a/tools/customer_fs/README.md b/tools/customer_fs/README.md new file mode 100644 index 0000000..7912d3d --- /dev/null +++ b/tools/customer_fs/README.md @@ -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 块约束; +- 修改、删除或重命名业务音频前,必须同步检查代码中的文件名引用。 diff --git a/tools/customer_fs/build_macos.sh b/tools/customer_fs/build_macos.sh new file mode 100755 index 0000000..1af58c0 --- /dev/null +++ b/tools/customer_fs/build_macos.sh @@ -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" diff --git a/tools/customer_fs/build_windows.bat b/tools/customer_fs/build_windows.bat new file mode 100644 index 0000000..3ddee71 --- /dev/null +++ b/tools/customer_fs/build_windows.bat @@ -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 diff --git a/tools/customer_fs/generate_macos.py b/tools/customer_fs/generate_macos.py new file mode 100755 index 0000000..d86f975 --- /dev/null +++ b/tools/customer_fs/generate_macos.py @@ -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()) diff --git a/tools/customer_fs/requirements.txt b/tools/customer_fs/requirements.txt new file mode 100644 index 0000000..caebc13 --- /dev/null +++ b/tools/customer_fs/requirements.txt @@ -0,0 +1 @@ +littlefs-python==0.19.0 diff --git a/tools/customer_fs/verify_customer_fs.py b/tools/customer_fs/verify_customer_fs.py new file mode 100755 index 0000000..2df56df --- /dev/null +++ b/tools/customer_fs/verify_customer_fs.py @@ -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( + " 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())