Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 进入。
Expand Down
50 changes: 42 additions & 8 deletions scripts/provision_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import re
import runpy
import shlex
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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}$")
Expand Down Expand Up @@ -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="跳过烧录应用分区")
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions scripts/requirements-hardware.txt
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions tests/python/test_provision_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading