diff --git a/pytest-embedded-espemu/README.md b/pytest-embedded-espemu/README.md index f7b357f8..59acbadf 100644 --- a/pytest-embedded-espemu/README.md +++ b/pytest-embedded-espemu/README.md @@ -2,7 +2,7 @@ A `pytest-embedded` service for running tests on [esp-emu](https://github.com/espressif/esp-emulator), Espressif's lightweight emulator for ESP RISC-V series SoCs, without real hardware. -Supported targets: `esp32c3`, `esp32c6`, `esp32h2`, `esp32p4`, `esp32s31`. +Supported targets: `esp32c3`, `esp32c5`, `esp32c6`, `esp32h2`, `esp32p4`, `esp32s31`. #### Service Activation diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py b/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py index 63a3279c..386fdb19 100644 --- a/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py +++ b/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py @@ -5,15 +5,18 @@ from pytest_embedded.utils import lazy_load DEFAULT_IMAGE_FN = 'espemu_image.bin' +ENCRYPTED_IMAGE_FN = f'encrypted_{DEFAULT_IMAGE_FN}' from .dut import EspEmuDut # noqa from .espemu import EspEmu # noqa +from .serial import EspEmuSerial # noqa __getattr__ = lazy_load( importlib.import_module(__name__), { 'EspEmu': EspEmu, 'EspEmuDut': EspEmuDut, + 'EspEmuSerial': EspEmuSerial, }, { 'EspEmuApp': '.app', # requires idf @@ -23,6 +26,7 @@ __all__ = [ 'DEFAULT_IMAGE_FN', + 'ENCRYPTED_IMAGE_FN', 'EspEmu', 'EspEmuApp', 'EspEmuDut', diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/app.py b/pytest-embedded-espemu/pytest_embedded_espemu/app.py index c1d230be..b597ed90 100644 --- a/pytest-embedded-espemu/pytest_embedded_espemu/app.py +++ b/pytest-embedded-espemu/pytest_embedded_espemu/app.py @@ -6,7 +6,7 @@ from pytest_embedded.log import MessageQueue, live_print_call from pytest_embedded_idf.app import IdfApp -from . import DEFAULT_IMAGE_FN +from . import DEFAULT_IMAGE_FN, ENCRYPTED_IMAGE_FN class EspEmuApp(IdfApp): @@ -22,6 +22,8 @@ def __init__( msg_queue: MessageQueue, espemu_image_path: str | None = None, skip_regenerate_image: bool | None = False, + encrypt: bool | None = False, + keyfile: str | None = None, **kwargs, ): self._q = msg_queue @@ -30,9 +32,39 @@ def __init__( self.image_path = espemu_image_path or os.path.join(self.binary_path, DEFAULT_IMAGE_FN) self.skip_regenerate_image = skip_regenerate_image + self.encrypt = encrypt + self.keyfile = keyfile + + if self.encrypt: + self.encrypted_image_path = os.path.join(self.binary_path, ENCRYPTED_IMAGE_FN) self.create_image() + def _write_encrypted_image(self, seek: int = 0) -> None: + """ + Encrypt the merged image with the flash encryption key, as the ROM would + have written it. + """ + with contextlib.redirect_stdout(self._q): + live_print_call( + [ + sys.executable, + '-m', + 'espsecure', + 'encrypt-flash-data', + # every target the emulator supports encrypts flash with + # XTS-AES, not the scheme the original esp32 uses + '--aes-xts', + '--keyfile', + self.keyfile, + '--output', + self.encrypted_image_path, + '--address', + str(seek), + self.image_path, + ], + ) + def create_image(self) -> None: """ Create the image, if it doesn't exist. @@ -65,3 +97,8 @@ def create_image(self) -> None: ], cwd=self.binary_path, ) + + if self.encrypt: + if self.keyfile is None or not os.path.exists(self.keyfile): + raise ValueError("Flash Encryption key file doesn't exist") + self._write_encrypted_image() diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/dut.py b/pytest-embedded-espemu/pytest_embedded_espemu/dut.py index 7ba0e3e1..0ef4a7a1 100644 --- a/pytest-embedded-espemu/pytest_embedded_espemu/dut.py +++ b/pytest-embedded-espemu/pytest_embedded_espemu/dut.py @@ -3,6 +3,7 @@ from pytest_embedded.dut import Dut from .espemu import EspEmu +from .serial import EspEmuSerial class EspEmuDut(Dut): @@ -11,11 +12,13 @@ class EspEmuDut(Dut): Attributes: target (str): target chip type, taken from the app + serial (EspEmuSerial): `EspEmuSerial` instance """ def __init__( self, espemu: EspEmu, + serial: EspEmuSerial, **kwargs, ) -> None: self.espemu = espemu @@ -27,6 +30,11 @@ def __init__( # target is known here as well; keep it optional for other app classes. self.target: str | None = getattr(self.app, 'target', None) + # `IdfSerial` is what ESP-IDF tests call for device state — a reset, + # an erase, an eFuse burn. Give them something that answers, so a test + # that needs one reports which operation it needed. + self.serial = serial + self._hard_reset_func = self.espemu._hard_reset def write(self, s: AnyStr) -> None: diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py b/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py index 0b973884..24166149 100644 --- a/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py +++ b/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py @@ -1,4 +1,10 @@ +import functools +import logging +import os import shlex +import socket +import subprocess +import time import typing as t from pytest_embedded.log import DuplicateStdoutPopen @@ -7,6 +13,33 @@ from .app import EspEmuApp +def send_control_command(port: int | None, command: str, timeout: float = 30) -> str: + """Send one command to an esp-emu control channel and return its reply.""" + if port is None: + raise NotImplementedError( + 'this esp-emu build has no --control-tcp channel; a newer emulator is needed for device-state operations' + ) + # The emulator binds the channel while it is starting, so a test whose + # first action is a device-state operation can arrive before it is + # listening. Retry until the deadline rather than fail the case. + deadline = time.time() + timeout + while True: + try: + sock = socket.create_connection(('127.0.0.1', port), timeout=timeout) + break + except ConnectionRefusedError: + if time.time() >= deadline: + raise + time.sleep(0.1) + with sock: + sock.sendall(f'{command}\n'.encode()) + with sock.makefile('rb') as reader: + reply = reader.readline().decode().strip() + if reply.startswith('err:'): + raise RuntimeError(f'esp-emu rejected {command!r}: {reply}') + return reply + + class EspEmu(DuplicateStdoutPopen): """ esp-emu class (https://github.com/espressif/esp-emulator) @@ -20,7 +53,17 @@ class EspEmu(DuplicateStdoutPopen): ESPEMU_PROG_PATH = 'esp-emu' - SUPPORTED_TARGETS: t.ClassVar[tuple] = ('esp32c3', 'esp32c6', 'esp32h2', 'esp32p4', 'esp32s31') + SUPPORTED_TARGETS: t.ClassVar[tuple] = ('esp32c3', 'esp32c5', 'esp32c6', 'esp32h2', 'esp32p4', 'esp32s31') + + # esp-emu reads and writes a QEMU compatible eFuse image of this size. A + # blank one means "nothing burned", which is the state a test starts from + # unless it is given an image of its own. + EFUSE_IMAGE_SIZE = 336 + + # Boot to the ROM loader instead of the firmware, so esptool and espefuse + # can drive the emulator over the UART socket. There are no modem control + # lines on a socket, so the strap has to be set at launch. + DOWNLOAD_MODE_STRAP = '0x02' def __init__( self, @@ -28,6 +71,8 @@ def __init__( espemu_prog_path: str | None = None, espemu_cli_args: str | None = None, espemu_extra_args: str | None = None, + espemu_efuse_path: str | None = None, + espemu_control_port: int | None = None, app: t.Optional['EspEmuApp'] = None, **kwargs, ): @@ -37,6 +82,9 @@ def __init__( espemu_prog_path: esp-emu program path espemu_cli_args: esp-emu CLI arguments espemu_extra_args: esp-emu CLI extra arguments, will be appended to `espemu_cli_args` + espemu_efuse_path: eFuse image the emulator reads at start and writes back on exit + espemu_control_port: port to serve the control channel on, from `pick_control_port`; + `None` runs without one app: `EspEmuApp` instance, used to detect the target chip """ self.app = app @@ -55,22 +103,175 @@ def __init__( espemu_prog_path = espemu_prog_path or self.ESPEMU_PROG_PATH + self.espemu_prog_path = espemu_prog_path + self.image_path = image_path + self.target = target + self.efuse_path = espemu_efuse_path + + efuse_args = [] + if self.efuse_path: + self._create_efuse_image(self.efuse_path) + logging.debug('The eFuse image will be saved to: %s', self.efuse_path) + efuse_args = ['--efuse', self.efuse_path] + self.control_port = espemu_control_port + control_args = [] + if self.control_port is not None: + control_args = ['--control-tcp', f'127.0.0.1:{self.control_port}'] + cmd = [ espemu_prog_path, '--chip', target, '--firmware', image_path, + *efuse_args, + *control_args, *shlex.split(espemu_cli_args or ''), *shlex.split(espemu_extra_args or ''), ] super().__init__(cmd=cmd, **kwargs) + @classmethod + def _create_efuse_image(cls, path: str) -> None: + """Create a blank eFuse image, keeping one that already exists.""" + if os.path.exists(path): + return + + with open(path, 'wb') as f: + f.write(b'\x00' * cls.EFUSE_IMAGE_SIZE) + + def execute_efuse_command(self, command: str) -> None: + """ + Run an espefuse command against the emulator. + + A second emulator instance is started in download mode with its UART on + a socket, since the running one is booted into the firmware and a socket + carries no reset lines. The instance writes the eFuse image back on + exit, and the running emulator then reads it back over the control + channel, which resets it -- so the burn takes effect on the machine + under test as well as on the next start. + + Args: + command: espefuse command line, e.g. "burn-custom-mac 00:11:22:33:44:55" + """ + import espefuse + + if not self.efuse_path: + raise ValueError('No eFuse image set. Please use --espemu-efuse-path') + + available_port = self._free_port() + + child = subprocess.Popen( + [ + self.espemu_prog_path, + '--chip', + self.target, + '--firmware', + self.image_path, + '--efuse', + self.efuse_path, + '--strap-mode', + self.DOWNLOAD_MODE_STRAP, + '--uart-tcp', + f'127.0.0.1:{available_port}', + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + self._wait_for_port(available_port) + args = [arg for arg in shlex.split(command) if arg != '--do-not-confirm'] + # espefuse registers a click result callback on its module-level + # group every time it is invoked, chaining onto the one already + # there. A suite that burns in a loop -- ESP-IDF's secure boot does + # so 1150 times -- overflows the stack somewhere past 950 calls. + # The callback only acts when one invocation carries several burn + # commands, which is never the case here. + espefuse.cli._result_callback = None + espefuse.main( + [ + '--port', + f'socket://127.0.0.1:{available_port}', + '--chip', + self.target, + '--do-not-confirm', + *args, + ] + ) + finally: + child.terminate() + child.wait(timeout=10) + + # The child wrote the burned image out as it exited, but this emulator + # still holds the eFuses it read at start, so a later reset would not + # see the burn. Read the new image into the running machine, which + # resets it -- the same thing a burn needs on hardware to take effect. + # A build without the command keeps the older behaviour: the burn is in + # the image for the next start, just not in the machine already up. + if self.control_port is not None and self.supports_efuse_load: + self.control_command(f'efuse-load {self.efuse_path}') + + @staticmethod + def _wait_for_port(port: int, timeout: float = 30) -> None: + """Wait until the emulator accepts connections on its UART socket.""" + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + if s.connect_ex(('127.0.0.1', port)) == 0: + return + time.sleep(0.1) + + raise TimeoutError(f'esp-emu did not open its UART socket on port {port} within {timeout}s') + + @staticmethod + def _free_port() -> int: + """An unused local port for one of the emulator's sockets.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return int(s.getsockname()[1]) + + @classmethod + def pick_control_port(cls, prog_path: str | None = None) -> int | None: + """ + A free port for the control channel, or `None` if this build has none. + + A chip reset has no in-band form — on hardware it is a DTR/RTS toggle + into EN, and pyserial's `socket://` handler ignores modem control + lines — so it goes over esp-emu's control channel. Older binaries do + not have the flag, and passing an unknown one makes them exit, so ask + first. Chosen before either the emulator or its serial exists, so both + can be handed the same port. + """ + if '--control-tcp' not in cls._help_text(prog_path or cls.ESPEMU_PROG_PATH): + return None + return cls._free_port() + + @functools.cached_property + def supports_efuse_load(self) -> bool: + """Whether this build takes `efuse-load`, which arrived after the channel did.""" + return 'efuse-load' in self._help_text(self.espemu_prog_path) + + @classmethod + def _help_text(cls, prog_path: str) -> str: + """This esp-emu build's help, which is what says which flags and + control-channel commands it has. Empty if it cannot be run.""" + try: + out = subprocess.run([prog_path, '--help'], capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError): + return '' + return out.stdout + out.stderr + + def control_command(self, command: str, timeout: float = 30) -> str: + """Send one command to this emulator's control channel and return its reply.""" + return send_control_command(self.control_port, command, timeout) + def _hard_reset(self): """ - esp-emu has no reset API. Raising `NotImplementedError` makes - `IdfUnityDutMixin` fall back to re-triggering the Unity test menu - with a newline instead of resetting the target. + Reset the emulated chip, the way a DTR/RTS toggle does on hardware. + + The emulator process keeps running, so the dut's output stream, its + expect history and the log all continue across the reset. """ - raise NotImplementedError('esp-emu does not support resetting; relaunch the emulator instead') + self.control_command('reset') diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/serial.py b/pytest-embedded-espemu/pytest_embedded_espemu/serial.py new file mode 100644 index 00000000..5f842ae4 --- /dev/null +++ b/pytest-embedded-espemu/pytest_embedded_espemu/serial.py @@ -0,0 +1,82 @@ +import logging +import typing as t + +from .espemu import send_control_command + +if t.TYPE_CHECKING: + from pytest_embedded_idf.app import IdfApp + + +class EspEmuSerial: + """ + The device-state half of an emulated dut. + + ESP-IDF tests reach the chip through two channels: the console stream + (``dut.expect``) and a control channel (``dut.serial``) that resets it, + erases flash and burns eFuses. The second one is esptool-backed on + hardware, and an emulator has no serial port behind it, so the operations + that are expressible go over esp-emu's control channel instead. + + Reset and the flash erase and write operations go over the channel and act + on the running machine, so the firmware sees them without a reload. The + operations that need esptool itself — ``flash()``, ``bootloader_flash()`` + and the partition-writing helpers ESP-IDF's esp_tee tests subclass in — + still raise, and the test reports the operation it needed. + """ + + def __init__(self, control_port: int | None = None, app: t.Optional['IdfApp'] = None) -> None: + """ + Args: + control_port: the emulator's control channel, the same port `EspEmu` serves; + `None` for a build without one + app: `IdfApp` instance, whose partition table `erase_partition` reads + """ + self.control_port = control_port + self.app = app + + @property + def port(self) -> str: + """The control channel's address, in the place a port name would be.""" + if self.control_port is None: + return 'espemu' + return f'espemu://127.0.0.1:{self.control_port}' + + def hard_reset(self) -> None: + """Reset the chip, the way a DTR/RTS toggle does on hardware.""" + logging.debug('hard resetting the emulated chip') + send_control_command(self.control_port, 'reset') + + def erase_flash(self) -> None: + """Erase the whole flash, as ``esptool erase-flash`` does.""" + logging.info('erasing the emulated flash') + send_control_command(self.control_port, 'erase-flash') + + def erase_region(self, offset: int, size: int) -> None: + """Erase one flash region, as ``esptool erase-region`` does.""" + logging.info('erasing %#x bytes of emulated flash at %#x', size, offset) + send_control_command(self.control_port, f'erase-region {offset:#x} {size:#x}') + + def erase_partition(self, partition_name: str) -> None: + """Erase one partition, looked up in the app's partition table.""" + table = getattr(self.app, 'partition_table', None) + if not table: + raise ValueError('Partition table not parsed.') + if partition_name not in table: + raise ValueError(f'partition {partition_name} not found in the partition table') + entry = table[partition_name] + self.erase_region(entry['offset'], entry['size']) + + def write_flash_no_enc(self, offset: int, file_path: str) -> None: + """Write one file into flash at ``offset``, without encrypting it.""" + logging.info('writing %s into emulated flash at %#x', file_path, offset) + send_control_command(self.control_port, f'write-region {offset:#x} {file_path}') + + def close(self) -> None: + """Nothing to close: the control channel is opened per command.""" + + def __getattr__(self, name: str) -> t.Any: + raise NotImplementedError( + f'esp-emu cannot do dut.serial.{name}() yet: it needs esptool ' + f'itself, driving the emulator in download mode with its UART on ' + f'a socket.' + ) diff --git a/pytest-embedded-espemu/tests/test_espemu.py b/pytest-embedded-espemu/tests/test_espemu.py index 260ef321..45a477b5 100644 --- a/pytest-embedded-espemu/tests/test_espemu.py +++ b/pytest-embedded-espemu/tests/test_espemu.py @@ -33,6 +33,36 @@ def test_pexpect_by_espemu(dut): result.assert_outcomes(passed=1) +@espemu_bin_required +def test_espemu_write_efuse(testdir): + efuse_path = os.path.join(testdir.tmpdir, 'efuse.bin') + + testdir.makepyfile(f""" + def test_espemu_write_efuse(dut): + dut.espemu.execute_efuse_command('burn-custom-mac 00:11:22:33:44:55') + + with open({efuse_path!r}, 'rb') as f: + content = f.read() + + # the emulator writes the eFuse image back when it exits, so the + # burned MAC is there for the next run + assert len(content) == 336 + assert bytes.fromhex('001122334455') in content + """) + + result = testdir.runpytest( + '-s', + '--embedded-services', + 'idf,espemu', + '--app-path', + os.path.join(testdir.tmpdir, 'hello_world_esp32c3'), + '--espemu-efuse-path', + efuse_path, + ) + + result.assert_outcomes(passed=1) + + @espemu_bin_required def test_multi_count_espemu(testdir): testdir.makepyfile(""" diff --git a/pytest-embedded-espemu/tests/test_serial_fixture_check.py b/pytest-embedded-espemu/tests/test_serial_fixture_check.py new file mode 100644 index 00000000..9537fa0e --- /dev/null +++ b/pytest-embedded-espemu/tests/test_serial_fixture_check.py @@ -0,0 +1,34 @@ +import os +import shutil + +import pytest + +espemu_bin_required = pytest.mark.skipif(shutil.which('esp-emu') is None, reason='no esp-emu') + + +@espemu_bin_required +def test_serial_is_the_fixture(testdir): + testdir.makepyfile(""" + from pytest_embedded_espemu import EspEmuSerial + + def test_serial_is_the_fixture(dut, serial): + assert isinstance(serial, EspEmuSerial) + assert dut.serial is serial + + # the serial is built from the port alone, the one the emulator serves + assert serial.control_port is not None + assert serial.control_port == dut.espemu.control_port + + # and that is enough to drive it: the reset is answered, not refused + serial.hard_reset() + """) + + result = testdir.runpytest( + '-s', + '--embedded-services', + 'idf,espemu', + '--app-path', + os.path.join(testdir.tmpdir, 'hello_world_esp32c3'), + ) + + result.assert_outcomes(passed=1) diff --git a/pytest-embedded/pytest_embedded/dut_factory.py b/pytest-embedded/pytest_embedded/dut_factory.py index 37e273d1..757e2e13 100644 --- a/pytest-embedded/pytest_embedded/dut_factory.py +++ b/pytest-embedded/pytest_embedded/dut_factory.py @@ -13,7 +13,7 @@ from pathlib import Path if t.TYPE_CHECKING: - from pytest_embedded_espemu import EspEmu + from pytest_embedded_espemu import EspEmu, EspEmuSerial from pytest_embedded_idf import LinuxSerial from pytest_embedded_idf.dut import IdfDut from pytest_embedded_jtag import Gdb, OpenOcd @@ -162,6 +162,7 @@ def _fixture_classes_and_options_fn( espemu_prog_path, espemu_cli_args, espemu_extra_args, + espemu_efuse_path, wokwi_diagram, wokwi_usb_serial_jtag, skip_regenerate_image, @@ -180,6 +181,15 @@ def _fixture_classes_and_options_fn( mixins: dict[str, list[type]] = defaultdict(list) kwargs: dict[str, dict[str, t.Any]] = defaultdict(dict) + # The emulator and its serial are two fixtures talking over one control + # channel, so the port is settled here, before either exists, and each is + # handed it. + espemu_control_port = None + if 'espemu' in _services: + from pytest_embedded_espemu import EspEmu + + espemu_control_port = EspEmu.pick_control_port(espemu_prog_path) + for fixture in FIXTURES_SERVICES.keys(): if fixture == 'app': kwargs['app'] = {'app_path': app_path, 'build_dir': build_dir} @@ -210,6 +220,8 @@ def _fixture_classes_and_options_fn( 'part_tool': part_tool, 'espemu_image_path': espemu_image_path, 'skip_regenerate_image': skip_regenerate_image, + 'encrypt': encrypt, + 'keyfile': keyfile, } ) else: @@ -289,6 +301,14 @@ def _fixture_classes_and_options_fn( from pytest_embedded_serial_esp import EspSerial classes[fixture] = EspSerial + elif 'espemu' in _services: + from pytest_embedded_espemu import EspEmuSerial + + classes[fixture] = EspEmuSerial + kwargs[fixture] = { + 'control_port': espemu_control_port, + 'app': None, + } elif 'serial' in _services or 'jtag' in _services: from pytest_embedded_serial.serial import Serial @@ -351,6 +371,7 @@ def _fixture_classes_and_options_fn( if 'espemu' in _services: from pytest_embedded_espemu import ( DEFAULT_IMAGE_FN, + ENCRYPTED_IMAGE_FN, EspEmu, ) @@ -358,10 +379,14 @@ def _fixture_classes_and_options_fn( kwargs[fixture] = { 'msg_queue': msg_queue, 'espemu_image_path': espemu_image_path - or os.path.join(app_path or '', build_dir or 'build', DEFAULT_IMAGE_FN), + or os.path.join( + app_path or '', build_dir or 'build', ENCRYPTED_IMAGE_FN if encrypt else DEFAULT_IMAGE_FN + ), 'espemu_prog_path': espemu_prog_path, 'espemu_cli_args': espemu_cli_args, 'espemu_extra_args': espemu_extra_args, + 'espemu_efuse_path': espemu_efuse_path, + 'espemu_control_port': espemu_control_port, 'app': None, 'meta': _meta, } @@ -423,6 +448,7 @@ def _fixture_classes_and_options_fn( kwargs[fixture].update( { 'espemu': None, + 'serial': None, } ) elif 'qemu' in _services: @@ -510,7 +536,7 @@ def app_fn(_fixture_classes_and_options: ClassCliOptions) -> App: return cls(**_drop_none_kwargs(kwargs)) -def serial_gn(_fixture_classes_and_options, msg_queue, app) -> t.Union['Serial', 'LinuxSerial'] | None: +def serial_gn(_fixture_classes_and_options, msg_queue, app) -> t.Union['Serial', 'LinuxSerial'] | 'EspEmuSerial' | None: if hasattr(app, 'target') and app.target == 'linux': from pytest_embedded_idf import LinuxSerial @@ -764,6 +790,7 @@ def create( espemu_prog_path: str | None = None, espemu_cli_args: str | None = None, espemu_extra_args: str | None = None, + espemu_efuse_path: str | None = None, wokwi_diagram: str | None = None, wokwi_usb_serial_jtag: bool | None = None, skip_regenerate_image: bool | None = None, @@ -816,6 +843,7 @@ def create( espemu_prog_path: esp-emu program path. espemu_cli_args: esp-emu CLI arguments. espemu_extra_args: Additional esp-emu arguments. + espemu_efuse_path: esp-emu eFuse image path. wokwi_diagram: Wokwi diagram path. wokwi_usb_serial_jtag: Use USB Serial JTAG instead of UART for Wokwi serial communication. skip_regenerate_image: Skip image regeneration flag. @@ -892,6 +920,7 @@ def create( 'espemu_prog_path': espemu_prog_path, 'espemu_cli_args': espemu_cli_args, 'espemu_extra_args': espemu_extra_args, + 'espemu_efuse_path': espemu_efuse_path, 'wokwi_diagram': wokwi_diagram, 'wokwi_usb_serial_jtag': wokwi_usb_serial_jtag, 'skip_regenerate_image': skip_regenerate_image, diff --git a/pytest-embedded/pytest_embedded/plugin.py b/pytest-embedded/pytest_embedded/plugin.py index 77717099..063a899e 100644 --- a/pytest-embedded/pytest_embedded/plugin.py +++ b/pytest-embedded/pytest_embedded/plugin.py @@ -320,6 +320,11 @@ def pytest_addoption(parser: pytest.Parser): '--espemu-cli-args', help='esp-emu cli default arguments. (Default: None)', ) + espemu_group.addoption( + '--espemu-efuse-path', + help='esp-emu eFuse image path. The emulator reads it at start and writes it back on exit. ' + 'A blank image is created when the file does not exist. (Default: None)', + ) espemu_group.addoption( '--espemu-extra-args', help='esp-emu cli extra arguments, will append to the argument list. (Default: None)', @@ -1113,6 +1118,13 @@ def espemu_cli_args(request: FixtureRequest) -> str | None: return _request_param_or_config_option_or_default(request, 'espemu_cli_args', None) +@pytest.fixture +@multi_dut_argument +def espemu_efuse_path(request: FixtureRequest) -> str | None: + """Enable parametrization for the same cli option""" + return _request_param_or_config_option_or_default(request, 'espemu_efuse_path', None) + + @pytest.fixture @multi_dut_argument def espemu_extra_args(request: FixtureRequest) -> str | None: @@ -1220,6 +1232,7 @@ def parametrize_fixtures( espemu_prog_path, espemu_cli_args, espemu_extra_args, + espemu_efuse_path, wokwi_diagram, wokwi_usb_serial_jtag, skip_regenerate_image, diff --git a/pytest-embedded/tests/test_base.py b/pytest-embedded/tests/test_base.py index 361baaec..a76dfdec 100644 --- a/pytest-embedded/tests/test_base.py +++ b/pytest-embedded/tests/test_base.py @@ -35,7 +35,7 @@ def _classes(request): ('idf,serial', {'IdfApp', 'Serial', 'SerialDut'}), ('idf,esp', {'IdfApp', 'IdfSerial', 'IdfDut'}), ('idf,qemu', {'QemuApp', 'Qemu', 'QemuDut'}), - ('idf,espemu', {'EspEmuApp', 'EspEmu', 'EspEmuDut'}), + ('idf,espemu', {'EspEmuApp', 'EspEmu', 'EspEmuSerial', 'EspEmuDut'}), ('arduino,esp', {'ArduinoApp', 'ArduinoSerial', 'SerialDut'}), ], indirect=True