Agent 代码生成与 EV-MUX 闭环测试

[English]

本指南是 AI Agent 生成 ESP-VISION 应用代码时的强制性约束。必须将目标环境视为在 ESP32 微控制器上运行的 ESP-VISION MicroPython v1.28.0,而不是运行 CPython 的 PC。只能使用本知识包中存在、已为所选开发板启用,或已由仓库示例验证的 API;不得假定设备上存在桌面端标准库模块、pip 包、原生 wheel、操作系统服务、子进程或 PC 文件系统。

依赖规则

默认 ESP-VISION 固件不能从 requirements.txt 动态安装依赖,也没有包含设备端 mip。生成的目标端代码不得调用 pip、解析 requirements.txt、调用 mip.install(),也不得静默依赖未验证的第三方包。如果知识包中没有必需模块,应改用已支持的模块重写,或明确说明运行前必须通过主机工具复制到 /lib、预编译为兼容的 .mpy、冻结到定制固件,或实现为原生模块。

主机端工具与目标端代码属于两个不同环境。PC 工具可以使用 pyserial 等 CPython 包,但必须将这类代码标记为“仅主机端”,且不能在 ESP-VISION 脚本中导入这些包。

All-in-One 输出

除非用户明确要求软件包或多文件架构,否则应返回一个可独立运行的设备端 main.py。将已支持的 import、常量、初始化、应用逻辑、主循环或有界测试、异常处理和资源释放都放在该文件中,避免要求用户再手工创建辅助模块。模型和其他二进制资产可以保持为独立文件,但必须列出它们的精确设备路径和部署前置条件。

在进入无限产品循环前,优先生成有界且可观测的验证代码。只有在摄像头、模型、存储、网络或用户要求的其他操作真正成功后,才打印唯一成功标记。下面的目标端冒烟测试是 all-in-one MicroPython 脚本,只有在成功拍摄一帧后才打印 EVTEST:OK

import gc
import sensor


def main():
    print("EVTEST:START")
    sensor.reset()
    try:
        sensor.set_pixformat(sensor.RGB565)
        sensor.set_framesize(sensor.QQVGA)
        sensor.skip_frames(time=500)
        image = sensor.snapshot()
        width = image.width()
        height = image.height()
    finally:
        sensor.shutdown()
    print("EVTEST:OK {}x{} heap={}".format(
        width, height, gc.mem_free()))


main()

USB-OTG CDC 与 USJ 传输约定

ESP-VISION 在 USB-OTG CDC(sink cdc)和 USB-Serial-JTAG(sink usj)上承载相同的 EV-MUX 协议。每次测试只打开一个设备端口。以 DTR 有效状态打开 CDC 端口后,CDC 会成为自动路由;否则支持的开发板会回退到 USJ。测试时不要同时打开两个端口,也不要保留其他串口监视器连接。USB 波特率不属于协议,但主机串口 API 仍需一个占位波特率。

每个帧由 0x1eEVMUX/1 h=<metadata_len> p=<payload_len> c=<crc32>\r\n、精确 h 字节的 UTF-8 JSON metadata、精确 p 字节的 payload 以及最后的 0x1f 组成。metadata 和 payload 边界由长度而不是分隔符决定。设备帧包含 payload CRC32;主机命令可发送零 CRC,但建议仍计算 CRC。接收端必须在 0x1e 上重新同步,验证长度与 CRC,然后按 metadata.channel 分发每个帧,不能按 USB 端口或到达顺序推断帧类型。

最小闭环是:打开所选端口;交换 hellocapabilities;验证 firmware.id == "esp-vision" 及协商的 evMuxVersion;查询 transport.state;停止当前脚本以进入 REPL;通过 script.write 上传一个完整的设备脚本;通过 script.run 启动;并必须在 repl.stdout 上看到期望标记。script.run 响应只表示脚本已入队,不能单独作为应用测试成功的依据。

下面的仅主机端 CPython 脚本对 USB-OTG CDC 或 USJ 实现完整闭环。在 PC 上执行 python -m pip install pyserial 安装 pyserial,将下面内容保存为一个文件,关闭其他串口工具,然后使用所选设备端口运行,例如 python evmux_smoke.py /dev/ttyACM0。要上传的 MicroPython 程序已嵌入主机脚本,无需第二个源文件。

#!/usr/bin/env python3
import argparse
import base64
import json
import re
import sys
import time
import zlib

import serial


DEVICE_SCRIPT = """\
import gc
import sensor

def main():
    print("EVTEST:START")
    sensor.reset()
    try:
        sensor.set_pixformat(sensor.RGB565)
        sensor.set_framesize(sensor.QQVGA)
        sensor.skip_frames(time=500)
        image = sensor.snapshot()
        width = image.width()
        height = image.height()
    finally:
        sensor.shutdown()
    print("EVTEST:OK {}x{} heap={}".format(
        width, height, gc.mem_free()))

main()
"""

SOF = b"\x1e"
EOF = b"\x1f"
HEADER_RE = re.compile(
    rb"EVMUX/1 h=([0-9]+) p=([0-9]+) c=([0-9A-Fa-f]{8})\r\n")
MAX_METADATA = 64 * 1024
MAX_PAYLOAD = 16 * 1024 * 1024
WRITE_CHUNK = 768


class EvMux:
    def __init__(self, port):
        self.port = port
        self.next_seq = 1
        self.text_tail = bytearray()

    def _read_exact(self, size, deadline):
        data = bytearray()
        while len(data) < size:
            if time.monotonic() >= deadline:
                raise TimeoutError("timed out while reading EV-MUX frame")
            part = self.port.read(size - len(data))
            if part:
                data.extend(part)
        return bytes(data)

    def read_frame(self, deadline):
        while time.monotonic() < deadline:
            if self.port.read(1) != SOF:
                continue

            header = bytearray()
            while len(header) < 80:
                char = self._read_exact(1, deadline)
                header.extend(char)
                if char == b"\n":
                    break
            match = HEADER_RE.fullmatch(bytes(header))
            if match is None:
                continue

            metadata_len = int(match.group(1))
            payload_len = int(match.group(2))
            expected_crc = int(match.group(3), 16)
            if not 0 < metadata_len <= MAX_METADATA:
                raise RuntimeError("invalid metadata length: {}".format(metadata_len))
            if not 0 <= payload_len <= MAX_PAYLOAD:
                raise RuntimeError("invalid payload length: {}".format(payload_len))

            metadata_bytes = self._read_exact(metadata_len, deadline)
            payload = self._read_exact(payload_len, deadline)
            if self._read_exact(1, deadline) != EOF:
                raise RuntimeError("invalid EV-MUX EOF")
            actual_crc = zlib.crc32(payload) & 0xFFFFFFFF
            if expected_crc and actual_crc != expected_crc:
                raise RuntimeError("EV-MUX payload CRC mismatch")
            metadata = json.loads(metadata_bytes.decode("utf-8"))
            return metadata, payload
        raise TimeoutError("timed out waiting for EV-MUX frame")

    def send_request(self, channel, method, body):
        seq = self.next_seq
        self.next_seq += 1
        payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
        metadata = {
            "sid": "debug" if channel == "debug.rpc" else "user",
            "seq": seq,
            "channel": channel,
            "type": "req",
            "method": method,
            "encoding": "json",
            "ts_ms": int(time.monotonic() * 1000),
        }
        metadata_bytes = json.dumps(
            metadata, separators=(",", ":")).encode("utf-8")
        crc = zlib.crc32(payload) & 0xFFFFFFFF
        header = "EVMUX/1 h={} p={} c={:08X}\r\n".format(
            len(metadata_bytes), len(payload), crc).encode("ascii")
        self.port.write(SOF + header + metadata_bytes + payload + EOF)
        self.port.flush()
        return seq

    def _observe(self, metadata, payload):
        channel = metadata.get("channel")
        if channel in ("repl.stdout", "repl.stderr", "log.idf"):
            self.text_tail.extend(payload)
            del self.text_tail[:-8192]
            text = payload.decode("utf-8", errors="replace")
            sys.stdout.write("[{}] {}".format(channel, text))
            sys.stdout.flush()

    def rpc(self, channel, method, body, timeout=10):
        seq = self.send_request(channel, method, body)
        deadline = time.monotonic() + timeout
        while True:
            metadata, payload = self.read_frame(deadline)
            self._observe(metadata, payload)
            if (metadata.get("channel") != channel
                    or metadata.get("type") != "rsp"
                    or metadata.get("seq") != seq):
                continue
            if metadata.get("encoding") != "json":
                raise RuntimeError("unexpected binary RPC response")
            result = json.loads(payload.decode("utf-8"))
            if not result.get("ok"):
                raise RuntimeError("{} failed: {}".format(method, result))
            return result

    def wait_for_text(self, marker, timeout=15):
        deadline = time.monotonic() + timeout
        while marker not in self.text_tail:
            metadata, payload = self.read_frame(deadline)
            self._observe(metadata, payload)
        return bytes(self.text_tail)


def upload_script(mux, path, source):
    content = source.encode("utf-8")
    for offset in range(0, len(content), WRITE_CHUNK):
        chunk = content[offset:offset + WRITE_CHUNK]
        result = mux.rpc("user.rpc", "script.write", {
            "path": path,
            "mode": "overwrite",
            "encoding": "utf-8",
            "contentBase64": base64.b64encode(chunk).decode("ascii"),
            "offset": offset,
            "totalBytes": len(content),
        })
        expected_complete = offset + len(chunk) == len(content)
        if result.get("complete") != expected_complete:
            raise RuntimeError("unexpected script.write completion state")


def main():
    parser = argparse.ArgumentParser(
        description="ESP-VISION EV-MUX USB/USJ closed-loop smoke test")
    parser.add_argument("port", help="USB-OTG CDC or USB-Serial-JTAG port")
    args = parser.parse_args()

    with serial.Serial(
            args.port, baudrate=115200, timeout=0.2, write_timeout=5) as port:
        try:
            port.dtr = True
        except (OSError, serial.SerialException):
            pass
        port.reset_input_buffer()
        time.sleep(0.1)
        mux = EvMux(port)

        hello = mux.rpc("user.rpc", "hello", {})
        if hello.get("firmware", {}).get("id") != "esp-vision":
            raise RuntimeError("connected device is not ESP-VISION")

        capabilities = mux.rpc("user.rpc", "capabilities", {})
        version = capabilities.get("protocol", {}).get("evMuxVersion")
        if version != 3:
            raise RuntimeError("unsupported EV-MUX version: {}".format(version))
        required = {"user.rpc", "debug.rpc", "repl.stdout"}
        if not required.issubset(set(capabilities.get("channels", []))):
            raise RuntimeError("required EV-MUX channels are unavailable")

        state = mux.rpc("debug.rpc", "transport.state", {})
        print("routes: user={user[route]} debug={debug[route]}".format(**state))

        mux.rpc("user.rpc", "device.control", {"action": "stop"})
        mux.wait_for_text(b">>>", timeout=10)

        path = "/evmux_smoke.py"
        upload_script(mux, path, DEVICE_SCRIPT)
        mux.rpc("user.rpc", "script.run", {"path": path})
        mux.wait_for_text(b"EVTEST:OK", timeout=20)
        print("\nPASS: EV-MUX upload, execution, camera capture, and stdout verified")


if __name__ == "__main__":
    main()

该测试会将超时、帧格式错误、路由拒绝、RPC 失败响应、未收到摄像头标记或协议版本不支持都视为失败。在产品集成中应保留相同的帧处理和序列关联规则,等待 RPC 时继续读取无关事件,显式处理 VM_BUSYVM_TIMEOUT,并在观测到预期设备端输出或结果之前绝不报告成功。