diff --git a/README.md b/README.md index 2229e24b..2f261faa 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,17 @@ pnpm --dir services/im-gateway test ```bash source /path/to/esp-idf-v6.0.2/export.sh +python3 -m pip install -r scripts/requirements-hardware.txt python3 scripts/prepare_sqlite.py python3 scripts/firmware.py build esp32s3-storage-dev python3 scripts/firmware.py package esp32s3-storage-dev ``` +SparkBot 的 `scripts/provision_device.py` 会按 `IDF_PATH`、本机 +`~/esp-idf-v6.0.2`、`~/esp-idf`、`~/esp/esp-idf` 的顺序自动加载有效的 +ESP-IDF 环境;也可以用 `--idf-dir` 显式指定。硬件测试依赖统一记录在 +[`scripts/requirements-hardware.txt`](./scripts/requirements-hardware.txt)。 + ## 模块边界 VoiceLife 使用 ESP-IDF 组件化模块单体。核心代码使用 C++,外部 SDK、网络库、平台格式和板卡驱动只能通过 Port 或 Adapter 进入。 diff --git a/scripts/provision_device.py b/scripts/provision_device.py index c057f083..115e9951 100644 --- a/scripts/provision_device.py +++ b/scripts/provision_device.py @@ -17,6 +17,7 @@ import os import re import runpy +import shlex import subprocess import sys import tempfile @@ -46,6 +47,15 @@ # 与真机烧录一致的 esptool 固定参数。 FLASH_SETTINGS = ("--flash-mode", "dio", "--flash-size", "16MB", "--flash-freq", "80m") +# CI and the SparkBot board are validated with ESP-IDF 6.0.2. Keep the +# machine-specific paths in one ordered fallback list, while allowing an +# explicitly selected IDF_PATH to take precedence. +DEFAULT_IDF_RELATIVE_DIRS = ( + Path("esp-idf-v6.0.2"), + Path("esp-idf"), + Path("esp") / "esp-idf", +) + UUID_V4_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") DEVICE_ID_PATTERN = re.compile(r"^[!-~]{1,128}$") TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$") @@ -75,7 +85,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "--gateway-origin", help="Gateway HTTPS origin(缺省 PROVISION_GATEWAY_ORIGIN 或 WECHAT_ACTION_UI_BASE_URL)" ) parser.add_argument("--user-id", help="内部 userId;缺省继承服务器上最新 active 设备的所有者") - parser.add_argument("--idf-dir", help="ESP-IDF 安装目录(缺省 ~/esp/esp-idf)") + parser.add_argument("--idf-dir", help="ESP-IDF 安装目录(缺省自动选择 IDF_PATH 或 ESP-IDF 6.0.2)") parser.add_argument("--force", action="store_true", help="provisioning 时覆盖板子已有 IM 配置(VLI2)") parser.add_argument("--skip-build", action="store_true", help="跳过固件构建") parser.add_argument("--skip-flash", action="store_true", help="跳过烧录应用分区") @@ -171,14 +181,38 @@ def validate_credential(data: dict, expected_user_id: str | None = None, *, requ return data -def idf_bootstrap(idf_dir: str | None = None) -> str: - """返回加载 ESP-IDF 环境的 bash 前缀。""" +def resolve_idf_dir( + idf_dir: str | None = None, + *, + environ: dict[str, str] | None = None, + home: Path | None = None, +) -> Path | None: + """Resolve an ESP-IDF installation that can build the SparkBot profile.""" + environment = os.environ if environ is None else environ + home_directory = Path.home() if home is None else home if idf_dir: - return f"source {idf_dir}/export.sh &&" - home = Path.home() - if (home / "esp" / "esp-idf" / "export.sh").is_file(): - return f"source {home}/esp/esp-idf/export.sh &&" - return "" + candidate = Path(idf_dir).expanduser() + if not (candidate / "export.sh").is_file() or not (candidate / "tools" / "idf.py").is_file(): + raise ValueError(f"ESP-IDF 安装无效:{candidate}") + return candidate + + candidates: list[Path] = [] + configured = environment.get("IDF_PATH") + if configured: + candidates.append(Path(configured).expanduser()) + candidates.extend(home_directory / relative for relative in DEFAULT_IDF_RELATIVE_DIRS) + for candidate in candidates: + if (candidate / "export.sh").is_file() and (candidate / "tools" / "idf.py").is_file(): + return candidate + return None + + +def idf_bootstrap(idf_dir: str | None = None) -> str: + """Return a shell prefix that loads the selected ESP-IDF environment.""" + resolved = resolve_idf_dir(idf_dir) + if resolved is None: + return "" + return f"source {shlex.quote(str(resolved / 'export.sh'))} &&" def build_command(board: str, idf_dir: str | None = None) -> str: diff --git a/scripts/requirements-hardware.txt b/scripts/requirements-hardware.txt new file mode 100644 index 00000000..27577de6 --- /dev/null +++ b/scripts/requirements-hardware.txt @@ -0,0 +1,8 @@ +# Host-side hardware and end-to-end test tools for SparkBot. +pyserial==3.5 +esptool==5.3.1 +dashscope>=1.27,<2 +websockets>=17,<18 +pytest>=9,<10 +pytest-cov>=7,<8 +ruff==0.12.7 diff --git a/tests/python/test_provision_device.py b/tests/python/test_provision_device.py index b0542862..b9e42254 100644 --- a/tests/python/test_provision_device.py +++ b/tests/python/test_provision_device.py @@ -65,6 +65,39 @@ def test_rejects_non_https_origin(self) -> None: class EnvironmentConfigTest(unittest.TestCase): + def test_resolve_idf_dir_prefers_explicit_directory(self) -> None: + root = Path("/tmp/provision-device-idf-explicit") + (root / "tools").mkdir(parents=True, exist_ok=True) + (root / "export.sh").touch() + (root / "tools" / "idf.py").touch() + try: + self.assertEqual(provision_device.resolve_idf_dir(str(root)), root) + finally: + (root / "tools" / "idf.py").unlink(missing_ok=True) + (root / "export.sh").unlink(missing_ok=True) + (root / "tools").rmdir() + root.rmdir() + + def test_resolve_idf_dir_uses_idf_path_before_home_fallbacks(self) -> None: + configured = Path("/tmp/provision-device-idf-configured") + (configured / "tools").mkdir(parents=True, exist_ok=True) + (configured / "export.sh").touch() + (configured / "tools" / "idf.py").touch() + try: + self.assertEqual( + provision_device.resolve_idf_dir(environ={"IDF_PATH": str(configured)}, home=Path("/tmp/empty-home")), + configured, + ) + finally: + (configured / "tools" / "idf.py").unlink(missing_ok=True) + (configured / "export.sh").unlink(missing_ok=True) + (configured / "tools").rmdir() + configured.rmdir() + + def test_resolve_idf_dir_rejects_invalid_explicit_directory(self) -> None: + with self.assertRaises(ValueError): + provision_device.resolve_idf_dir("/tmp/not-an-idf") + def test_load_dotenv_parses_key_values(self) -> None: env_file = Path("/tmp/provision-device-test.env") env_file.write_text(