tests(prodtest): introduce prodtest device tests
What changed, and why it matters
This commit only adds automated tests and test-support tooling for Trezor's production-test firmware mode. It does not change the firmware itself, introduce new device commands, or alter how secrets are handled. There is no user-facing security change.
No security action required. Treat as normal test-infrastructure code review; ensure the new test harness does not accidentally ship internal test helpers as public API.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff is a pure test-suite addition: a new tests/prodtest_tests/ pytest suite, a core/prodtest_emu.py wrapper, and supporting trezorlib._internal modules (prodtest_client, prodtest_emulator, prodtest_transport) that speak the existing prodtest VCP/UDP CLI. The tests exercise existing prodtest commands (display, touch, haptic, OTP, Tropic, CRC, etc.) against an emulator. No prodtest firmware code is modified; no new commands are implemented on the device side.
Changed components
tests/prodtest_tests/core/prodtest_emu.pypython/src/trezorlib/_internal/prodtest_client.pypython/src/trezorlib/_internal/prodtest_emulator.pypython/src/trezorlib/_internal/prodtest_transport.pycore/MakefileInspect captured patch +2329 / −0
diff --git a/core/Makefile b/core/Makefile
index 3d4efc06..90f84a4b 100644
--- a/core/Makefile
+++ b/core/Makefile
@@ -8,6 +8,7 @@
test_emu_click test_emu_click_ui test_emu_click_ui_multicore \
test_emu_persistence test_emu_persistence_ui test_emu_upgrade \
test_emu_ui test_emu_ui_multicore test_emu_ui_record test_emu_ui_record_multicore \
+ test_emu_prodtest \
pylint mypy typecheck pyright clippy \
audit_rust vet_rust \
templates templates_check translations translations_check \
@@ -46,6 +47,8 @@ EMU_LOG_FILE ?= $(TESTPATH)/trezor.log
EMU_TEST_ARGS = --disable-animation --headless --output=$(EMU_LOG_FILE) --temporary-profile
EMU_TEST = $(EMU) $(EMU_TEST_ARGS) -c
+PRODTEST_EMU = $(CURDIR)/prodtest_emu.py
+
JUNIT_XML ?= $(TESTPATH)/junit.xml
PYTEST = pytest --junitxml=$(JUNIT_XML)
TREZOR_FIDO2_UDP_PORT = 21326
@@ -188,6 +191,9 @@ test_emu_ui_record_multicore: ## quickly record all screens
make test_emu_ui_multicore || echo "All errors are recorded in fixtures.json"
../tests/update_fixtures.py local --remove-missing
+test_emu_prodtest: ## run prodtest device tests
+ $(PRODTEST_EMU) $(PYTEST) $(TESTPATH)/prodtest_tests $(TESTOPTS)
+
pylint: ## run pylint on application sources and tests
pylint -E $(shell find src tests -name *.py)
diff --git a/core/prodtest_emu.py b/core/prodtest_emu.py
new file mode 100755
index 00000000..b157e44c
--- /dev/null
+++ b/core/prodtest_emu.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""Start the prodtest emulator, run a command, then stop it.
+
+Usage:
+
+ ./prodtest_emu.py pytest ../tests/prodtest_tests
+ TREZOR_MODEL=t3w1 ./prodtest_emu.py pytest ../tests/prodtest_tests
+"""
+
+import argparse
+import os
+import signal
+import subprocess
+import sys
+import tempfile
+
+from trezorlib._internal.prodtest_emulator import get_prodtest_emulator
+
+parser = argparse.ArgumentParser(add_help=False)
+parser.add_argument("-m", "--model", default=os.environ.get("TREZOR_MODEL"))
+args, remaining = parser.parse_known_args()
+
+if not args.model:
+ print("No model specified. Use -m <model> or set TREZOR_MODEL.", file=sys.stderr)
+ sys.exit(1)
+
+with tempfile.TemporaryDirectory(prefix="prodtest_emu_") as profile_dir:
+ emulator = get_prodtest_emulator(model=args.model, profile_dir=profile_dir)
+ with emulator:
+ emulator.start()
+ process = subprocess.Popen(remaining)
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+ returncode = process.wait()
+
+sys.exit(returncode)
diff --git a/python/src/trezorlib/_internal/prodtest_client.py b/python/src/trezorlib/_internal/prodtest_client.py
new file mode 100644
index 00000000..c329f004
--- /dev/null
+++ b/python/src/trezorlib/_internal/prodtest_client.py
@@ -0,0 +1,300 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import logging
+import typing as t
+import zlib
+from functools import cached_property
+
+from ..models import TrezorModel, by_internal_name
+
+if t.TYPE_CHECKING:
+ from .prodtest_transport import VcpUdpTransport
+
+LOG = logging.getLogger(__name__)
+
+_MAX_ATTEMPTS_TO_GET_COMMANDS = 15
+_HELP_COMMAND_TIMEOUT: float = 2
+
+
+class ResponseNotOkError(Exception):
+ def __init__(self, response: ProdtestResponse | None = None, *args: t.Any) -> None:
+ self.response = response
+ super().__init__(*args)
+
+
+class CrcMismatchError(Exception):
+ pass
+
+
+class Cmd:
+ BACKUP_RAM_ERASE = "backup-ram-erase"
+ BACKUP_RAM_LIST = "backup-ram-list"
+ BACKUP_RAM_READ = "backup-ram-read"
+ BACKUP_RAM_WRITE = "backup-ram-write"
+ BOARDLOADER_UPDATE = "boardloader-update"
+ BOARDLOADER_VERSION = "boardloader-version"
+ BUTTON_TEST = "button-test"
+ CRC_DISABLE = "crc-disable"
+ CRC_ENABLE = "crc-enable"
+ CRC_STATUS = "crc-status"
+ DISPLAY_BARS = "display-bars"
+ DISPLAY_BORDER = "display-border"
+ DISPLAY_SET_BACKLIGHT = "display-set-backlight"
+ DISPLAY_TEXT = "display-text"
+ GET_CPUID = "get-cpuid"
+ HAPTIC_TEST = "haptic-test"
+ HELP = "help"
+ HW_REVISION = "hw-revision"
+ LOG_FILTER = "log-filter"
+ MANUFACTURING_LOCK_READ = "manufacturing-lock-read"
+ MANUFACTURING_LOCK_WRITE = "manufacturing-lock-write"
+ OTP_BATCH_READ = "otp-batch-read"
+ OTP_BATCH_WRITE = "otp-batch-write"
+ OTP_DEVICE_SN_READ = "otp-device-sn-read"
+ OTP_DEVICE_SN_WRITE = "otp-device-sn-write"
+ OTP_VARIANT_READ = "otp-variant-read"
+ OTP_VARIANT_WRITE = "otp-variant-write"
+ PING = "ping"
+ PRODTEST_HOMESCREEN = "prodtest-homescreen"
+ PRODTEST_MEM_READ = "prodtest-mem-read"
+ PRODTEST_MEM_WRITE = "prodtest-mem-write"
+ PRODTEST_MODEL = "prodtest-model"
+ PRODTEST_UPTIME = "prodtest-uptime"
+ PRODTEST_VERSION = "prodtest-version"
+ PRODTEST_WIPE = "prodtest-wipe"
+ REBOOT = "reboot"
+ REBOOT_TO_BOOTLOADER = "reboot-to-bootloader"
+ RGBLED_EFFECT_START = "rgbled-effect-start"
+ RGBLED_EFFECT_STOP = "rgbled-effect-stop"
+ RGBLED_SET = "rgbled-set"
+ SBU_SET = "sbu-set"
+ SDCARD_TEST = "sdcard-test"
+ SECURE_CHANNEL_HANDSHAKE_1 = "secure-channel-handshake-1"
+ SECURE_CHANNEL_HANDSHAKE_2 = "secure-channel-handshake-2"
+ TAMPER_READ = "tamper-read"
+ TELEMETRY_READ = "telemetry-read"
+ TELEMETRY_RESET = "telemetry-reset"
+ TOUCH_DRAW = "touch-draw"
+ TOUCH_TEST = "touch-test"
+ TOUCH_TEST_CUSTOM = "touch-test-custom"
+ TOUCH_TEST_IDLE = "touch-test-idle"
+ TOUCH_TEST_POWER = "touch-test-power"
+ TOUCH_TEST_SENSITIVITY = "touch-test-sensitivity"
+ TOUCH_VERSION = "touch-version"
+ TROPIC_BENCHMARK = "tropic-benchmark"
+ TROPIC_CERTDEV_READ = "tropic-certdev-read"
+ TROPIC_CERTDEV_WRITE = "tropic-certdev-write"
+ TROPIC_CERTFIDO_READ = "tropic-certfido-read"
+ TROPIC_CERTFIDO_WRITE = "tropic-certfido-write"
+ TROPIC_CERTTROPIC_READ = "tropic-certtropic-read"
+ TROPIC_ERASE_ALL_SLOTS = "tropic-erase-all-slots"
+ TROPIC_GET_ACCESS_CREDENTIAL = "tropic-get-access-credential"
+ TROPIC_GET_CHIP_ID = "tropic-get-chip-id"
+ TROPIC_GET_FIDO_MASKING_KEY = "tropic-get-fido-masking-key"
+ TROPIC_GET_RISCV_FW_VERSION = "tropic-get-riscv-fw-version"
+ TROPIC_GET_SPECT_FW_VERSION = "tropic-get-spect-fw-version"
+ TROPIC_HANDSHAKE = "tropic-handshake"
+ TROPIC_KEYFIDO_READ = "tropic-keyfido-read"
+ TROPIC_LOCK = "tropic-lock"
+ TROPIC_LOCK_CHECK = "tropic-lock-check"
+ TROPIC_PAIR = "tropic-pair"
+ TROPIC_READ_CONFIGS = "tropic-read-configs"
+ TROPIC_READ_SENSORS = "tropic-read-sensors"
+ TROPIC_SEND_COMMAND = "tropic-send-command"
+ TROPIC_SET_SENSORS = "tropic-set-sensors"
+ TROPIC_STRESS_INIT = "tropic-stress-init"
+ TROPIC_STRESS_MAC_AND_DESTROY = "tropic-stress-mac-and-destroy"
+ TROPIC_STRESS_SESSION = "tropic-stress-session"
+ TROPIC_STRESS_TEST = "tropic-stress-test"
+ TROPIC_TEST_COUNTER = "tropic-test-counter"
+ TROPIC_TEST_MAC_AND_DESTROY = "tropic-test-mac-and-destroy"
+ TROPIC_TEST_RMEM = "tropic-test-rmem"
+ TROPIC_TEST_RNG = "tropic-test-rng"
+ TROPIC_TEST_SIGN = "tropic-test-sign"
+ TROPIC_TESTS_CLEANUP = "tropic-tests-cleanup"
+ TROPIC_UPDATE_FW = "tropic-update-fw"
+ UNIT_TEST_LIST = "unit-test-list"
+ UNIT_TEST_RUN = "unit-test-run"
+
+
+class ProdtestCommand:
+ def __init__(self, name: str, *args: str) -> None:
+ self.name = name
+ self._payload = " ".join((self.name, *args))
+
+ def get(self, crc_enabled: bool = False) -> str:
+ if not crc_enabled:
+ return self._payload
+ crc = zlib.crc32(self._payload.encode())
+ return f"checked-{self._payload} {crc:08X}"
+
+
+class ProdtestResponse:
+ def __init__(
+ self,
+ is_ok: bool,
+ args: str,
+ progress: list[str] | None = None,
+ traces: list[str] | None = None,
+ ) -> None:
+ self.is_ok = is_ok
+ self.args = args
+ self.progress = progress
+ self.traces = traces
+
+ @property
+ def error_code(self) -> int:
+ """Numeric error code of a failed response.
+
+ The device's CLI formats error args as `<code> "message"`, so this is
+ only meaningful when `is_ok` is False.
+ """
+ return int(self.args.split()[0])
+
+
+class ProdtestClient:
+ DEFAULT_TIMEOUT: float = 30
+
+ def __init__(self, transport: VcpUdpTransport) -> None:
+ self.transport = transport
+ self.crc_enabled: bool = False
+
+ @cached_property
+ def model(self) -> TrezorModel:
+ """Query and cache the model reported by the device.
+
+ Raises ValueError if the response is not a recognised internal model name.
+ """
+ resp = self.command_ok(ProdtestCommand(Cmd.PRODTEST_MODEL))
+ model = by_internal_name(resp.args.upper())
+ if model is None:
+ raise ValueError(
+ f"prodtest-model returned unknown model name: {resp.args!r}"
+ )
+ return model
+
+ @cached_property
+ def available_commands(self) -> set[str]:
+ """Command names parsed from the device's ``help`` output (cached)."""
+ for attempt in range(_MAX_ATTEMPTS_TO_GET_COMMANDS):
+ try:
+ resp = self.command_ok(
+ ProdtestCommand(Cmd.HELP), timeout=_HELP_COMMAND_TIMEOUT
+ )
+ break
+ except (ResponseNotOkError, TimeoutError) as e:
+ LOG.warning(
+ "Failed to obtain the list of available commands. Attempt %d/%d: %s",
+ attempt,
+ _MAX_ATTEMPTS_TO_GET_COMMANDS,
+ e,
+ )
+ resp = None
+ if resp is None:
+ raise Exception("Exceeded max number of retries.")
+ # Each trace line is " <name> - <info>"; extract the command name.
+ assert resp.traces is not None
+ return {
+ line.split()[0]
+ for line in resp.traces
+ if line.strip() and not line.startswith("Available")
+ }
+
+ def command(
+ self, cmd: ProdtestCommand, timeout: float = DEFAULT_TIMEOUT
+ ) -> ProdtestResponse:
+
+ LOG.debug(f"Command: {cmd.get(self.crc_enabled)}\n")
+ self.transport.writeline(cmd.get(self.crc_enabled))
+
+ progress_lines: list[str] = []
+ trace_lines: list[str] = []
+
+ while True:
+ line = self._strip_crc(self.transport.readline(timeout))
+ LOG.debug(line)
+ if line.startswith("OK"):
+ ok_args = line[2:].strip()
+ self._update_crc_state(cmd, ok_args)
+ return ProdtestResponse(
+ is_ok=True,
+ args=ok_args,
+ progress=progress_lines,
+ traces=trace_lines,
+ )
+ elif line.startswith("ERROR"):
+ err_args = line[5:].strip()
+ return ProdtestResponse(
+ is_ok=False,
+ args=err_args,
+ progress=progress_lines,
+ traces=trace_lines,
+ )
+ elif line.startswith("PROGRESS"):
+ progress_lines.append(line[8:].strip())
+ elif line.startswith("#"):
+ trace_lines.append(line[1:].strip())
+ else:
+ LOG.warning("Unexpected line from prodtest: %s", line)
+
+ def command_ok(
+ self, cmd: ProdtestCommand, timeout: float = DEFAULT_TIMEOUT
+ ) -> ProdtestResponse:
+ response = self.command(cmd, timeout=timeout)
+ if not response.is_ok:
+ raise ResponseNotOkError(response, f"{cmd.name!r} failed: {response.args}")
+ return response
+
+ def close(self) -> None:
+ self.transport.close()
+
+ def _strip_crc(self, line: str) -> str:
+ """Verify and remove the trailing CRC suffix from a response line.
+
+ When CRC is active the device appends a space and an 8-digit hex CRC-32
+ (computed over the preceding line content) to every response line. If no
+ CRC is expected the line is returned unchanged.
+ """
+ if not self.crc_enabled:
+ return line
+
+ payload, sep, crc_str = line.rpartition(" ")
+ if not sep or len(crc_str) != 8:
+ raise CrcMismatchError(f"Missing CRC suffix in response: {line!r}")
+ try:
+ received = int(crc_str, 16)
+ except ValueError:
+ raise CrcMismatchError(f"Malformed CRC suffix in response: {line!r}")
+
+ expected = zlib.crc32(payload.encode())
+ if received != expected:
+ raise CrcMismatchError(
+ f"CRC mismatch in response {line!r}: "
+ f"expected {expected:08X}, got {crc_str.upper()}"
+ )
+ return payload
+
+ def _update_crc_state(self, cmd: ProdtestCommand, ok_args: str) -> None:
+ """Keep crc_enabled in sync with device state."""
+ if cmd.name == Cmd.CRC_ENABLE:
+ self.crc_enabled = True
+ elif cmd.name == Cmd.CRC_DISABLE:
+ self.crc_enabled = False
+ elif cmd.name == Cmd.CRC_STATUS:
+ self.crc_enabled = ok_args == "1"
diff --git a/python/src/trezorlib/_internal/prodtest_emulator.py b/python/src/trezorlib/_internal/prodtest_emulator.py
new file mode 100644
index 00000000..542cb844
--- /dev/null
+++ b/python/src/trezorlib/_internal/prodtest_emulator.py
@@ -0,0 +1,220 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import logging
+import socket
+import time
+import typing as t
+from pathlib import Path
+
+from . import emulator
+from .emulator import Emulator
+
+LOG = logging.getLogger(__name__)
+
+ROOT = Path(__file__).resolve().parents[4]
+
+# Same as USB_IFACE_BASE_PORT in core/embed/io/usb/usb_config.c
+DEFAULT_UDP_BASE_PORT = 21324
+# VCP is the 4th interface (offset 3) in the USB configuration
+VCP_PORT_OFFSET = 3
+
+
+class ProdtestEmulator(Emulator):
+ """Manages a running prodtest emulator process.
+
+ Extends trezorlib's ``Emulator`` base, overriding ``make_env()`` (adds
+ TREZOR_UDP_PORT), ``_wait_until_ready()`` (probes the VCP ``ping`` instead of
+ the UDP handshake), and ``start()`` (no debug link). Construct via the
+ :func:`get_prodtest_emulator` factory.
+ """
+
+ STORAGE_FILENAME = "trezor.flash"
+
+ def __init__(
+ self,
+ executable: Path,
+ profile_dir: str,
+ *,
+ port: int | None = None,
+ headless: bool = True,
+ tropic_model_port: int | None = None,
+ **kwargs: t.Any,
+ ) -> None:
+ super().__init__(
+ executable=executable,
+ profile_dir=profile_dir,
+ headless=headless,
+ debug=False,
+ auto_interact=False,
+ **kwargs,
+ )
+ if port is not None:
+ self.port = port
+
+ # The Tropic model is managed externally (see the ``tropic_prodtest``
+ # fixture); we only point the emulator at its port, like CoreEmulator.
+ self.tropic_model_port = tropic_model_port
+
+ @property
+ def vcp_port(self) -> int:
+ """The UDP port for the VCP text interface."""
+ return self.port + VCP_PORT_OFFSET
+
+ def make_env(self) -> dict[str, str]:
+ env = super().make_env()
+ env.update(
+ TREZOR_PROFILE_DIR=str(self.profile_dir),
+ TREZOR_PROFILE=str(self.profile_dir),
+ TREZOR_UDP_PORT=str(self.port),
+ )
+ if self.headless:
+ env["SDL_VIDEODRIVER"] = "dummy"
+ if self.tropic_model_port is not None:
+ env["TROPIC_MODEL_PORT"] = str(self.tropic_model_port)
+ return env
+
+ def _wait_until_ready(self, timeout: float = 30) -> None:
+ """
+ Wait for the prodtest emulator to accept VCP commands.
+
+ The firmware emulator checks readiness via UdpTransport.is_ready(),
+ which sends ``PINGPING`` to the *wire* port and expects ``PONGPONG``.
+
+ The prodtest emulator does not use the wire protocol at all — its
+ only interface is the VCP text CLI. So we probe the VCP UDP port
+ with a text ``ping`` command and wait for any response (``OK ...``).
+ """
+ assert self.process is not None, "Emulator not started"
+ LOG.info("Waiting for prodtest emulator (VCP port %d)...", self.vcp_port)
+
+ start = time.monotonic()
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.settimeout(0.5)
+
+ try:
+ while True:
+ if self.process.poll() is not None:
+ raise RuntimeError(
+ f"Emulator exited prematurely (code {self.process.returncode})"
+ )
+
+ try:
+ sock.sendto(b"ping\r", ("127.0.0.1", self.vcp_port))
+ data, _ = sock.recvfrom(4096)
+ # Any response means the VCP is alive
+ if data:
+ break
+ except socket.timeout:
+ pass
+
+ elapsed = time.monotonic() - start
+ if elapsed >= timeout:
+ raise TimeoutError(f"Prodtest emulator not ready after {timeout}s")
+ time.sleep(0.1)
+ finally:
+ sock.close()
+
+ LOG.info("Prodtest emulator ready after %.1fs", time.monotonic() - start)
+
+ def start(self) -> None:
+ """
+ Start the prodtest emulator.
+
+ Overrides the base Emulator.start() to skip TrezorTestContext creation,
+ since prodtest doesn't speak the Trezor wire protocol and has no
+ debug link.
+ """
+ if self.process and self.process.poll() is None:
+ return # already running
+
+ self.process = self._launch_process()
+ # Register for atexit cleanup (via base class's _RUNNING_PIDS)
+
+ emulator._RUNNING_PIDS.add(self.process)
+
+ try:
+ self._wait_until_ready()
+ except TimeoutError:
+ LOG.warning("Prodtest emulator did not come up in time")
+ self.process.kill()
+ raise
+
+ (self.profile_dir / "trezor.pid").write_text(str(self.process.pid) + "\n")
+ (self.profile_dir / "trezor.port").write_text(str(self.port) + "\n")
+
+ # stop() is inherited from Emulator
+
+
+def get_prodtest_emulator(
+ model: str | None = None,
+ *,
+ profile_dir: str,
+ port: int | None = None,
+ headless: bool = True,
+ tropic_model_port: int | None = None,
+) -> ProdtestEmulator:
+ """Locate a prebuilt binary and return a ready-to-start ProdtestEmulator.
+
+ The binary must already exist (built via ``xtask build prodtest``); otherwise
+ ``FileNotFoundError`` is raised. As with the ``Emulator`` base, *profile_dir*
+ is caller-owned (e.g. a ``tempfile.TemporaryDirectory``). Pass
+ *tropic_model_port* to target an externally-started Tropic ``model_server``.
+
+ Usage::
+
+ with tempfile.TemporaryDirectory() as profile_dir:
+ with get_prodtest_emulator("t3w1", profile_dir=profile_dir) as emu:
+ emu.start()
+ client = ProdtestClient(transport=VcpUdpTransport(port=emu.vcp_port))
+ """
+ return ProdtestEmulator(
+ executable=_find_prodtest_emulator(model),
+ profile_dir=profile_dir,
+ port=port or DEFAULT_UDP_BASE_PORT,
+ headless=headless,
+ tropic_model_port=tropic_model_port,
+ )
+
+
+def _find_prodtest_emulator(model: str | None) -> Path:
+ """Locate the prodtest emulator binary.
+
+ If *model* is None, use the ``latest`` symlink (``xtask build prodtest
+ --latest``), mirroring the convention in ``tests/emulators.py``.
+ """
+ artifacts = ROOT / "core" / "build-xtask" / "artifacts"
+
+ if model is None:
+ path = artifacts / "latest" / "prodtest-emu"
+ if path.exists():
+ return path
+ raise FileNotFoundError(
+ "No model specified and no 'latest' prodtest emulator found at "
+ f"{path}. Build one with: xtask build prodtest -e -m trezor_model"
+ )
+
+ path = artifacts / model.upper() / "prodtest-emu"
+ if path.exists():
+ return path
+
+ raise FileNotFoundError(
+ f"Prodtest emulator binary not found for model {model}. "
+ f"Expected path: {path}. "
+ f"Build it with: xtask build prodtest -m {model.lower()} -e"
+ )
diff --git a/python/src/trezorlib/_internal/prodtest_transport.py b/python/src/trezorlib/_internal/prodtest_transport.py
new file mode 100644
index 00000000..d0401a9d
--- /dev/null
+++ b/python/src/trezorlib/_internal/prodtest_transport.py
@@ -0,0 +1,62 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import logging
+import socket
+import time
+
+LOG = logging.getLogger(__name__)
+
+
+class VcpUdpTransport:
+ CHUNK_SIZE = 64
+
+ def __init__(self, port: int, host: str = "127.0.0.1") -> None:
+ self.port = port
+ self.host = host
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self.socket.settimeout(1.0)
+ self._buf = b""
+
+ def readline(self, timeout: float) -> str:
+ deadline = time.monotonic() + timeout
+ while True:
+ # Check buffer first
+ if b"\n" in self._buf:
+ line, self._buf = self._buf.split(b"\n", 1)
+ return line.decode("utf-8", errors="replace").strip()
+
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError()
+
+ self.socket.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = self.socket.recvfrom(4096)
+ self._buf += data
+ except socket.timeout:
+ continue
+
+ def writeline(self, line: str) -> None:
+ data = (line + "\r").encode("utf-8")
+ for i in range(0, len(data), self.CHUNK_SIZE):
+ self.socket.sendto(data[i : i + self.CHUNK_SIZE], (self.host, self.port))
+ LOG.debug(">>> %s", line)
+
+ def close(self) -> None:
+ self.socket.close()
diff --git a/tests/prodtest_tests/__init__.py b/tests/prodtest_tests/__init__.py
new file mode 100644
index 00000000..f1df8052
--- /dev/null
+++ b/tests/prodtest_tests/__init__.py
@@ -0,0 +1,75 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import (
+ ProdtestClient,
+ ProdtestCommand,
+ ProdtestResponse,
+ ResponseNotOkError,
+)
+
+# CLI framework codes — core/embed/rtl/inc/rtl/cli.h
+CLI_ERROR_INVALID_CMD = 10
+CLI_ERROR_INVALID_ARG = 11
+CLI_ERROR_INVALID_CRC = 14
+
+# Prodtest command codes — core/embed/projects/prodtest/prodtest_error_codes.h
+PRODTEST_ERR_BACKUP_RAM_KEY_NOT_FOUND = 1014
+PRODTEST_ERR_OTP_EMPTY = 10012
+PRODTEST_ERR_TROPIC_UPDATE_WRONG_REVISION = 20075
+PRODTEST_ERR_TROPIC_TEST_RNG_REPEAT = 20152
+
+
+def assert_command_fails(
+ client: ProdtestClient, command: ProdtestCommand
+) -> ProdtestResponse:
+ """Run a command expected to fail and return its error response.
+
+ Wraps the common negative-path boilerplate: the command must raise
+ ResponseNotOkError carrying a response, which is returned so the caller can
+ assert on error_code / args.
+ """
+ with pytest.raises(ResponseNotOkError) as exc_info:
+ client.command_ok(command)
+ response = exc_info.value.response
+ assert response is not None
+ return response
+
+
+def assert_hexdata(response: ProdtestResponse, num_bytes: int | None = None) -> bytes:
+ """Assert the response args are `cli_ok_hexdata` output and return the bytes.
+
+ The device formats binary payloads as an even-length run of uppercase hex
+ digits (see `cli_ok_hexdata`). This checks the args are valid, non-empty hex
+ and — when *num_bytes* is given — of the expected length, then returns the
+ decoded bytes for any further, non-fragile structural checks. It deliberately
+ does not compare the value itself, which is device-specific.
+ """
+ args = response.args
+ assert args, "expected hex data, got empty response"
+ try:
+ data = bytes.fromhex(args)
+ except ValueError:
+ raise AssertionError(f"response is not valid hex data: {args!r}")
+ if num_bytes is not None:
+ assert (
+ len(data) == num_bytes
+ ), f"expected {num_bytes} bytes of hex data, got {len(data)}: {args!r}"
+ return data
diff --git a/tests/prodtest_tests/conftest.py b/tests/prodtest_tests/conftest.py
new file mode 100644
index 00000000..eefa95a7
--- /dev/null
+++ b/tests/prodtest_tests/conftest.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import os
+import shutil
+import socket
+import tempfile
+import typing as t
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+
+from trezorlib._internal.emulator import TropicModel
+from trezorlib._internal.prodtest_client import ProdtestClient
+from trezorlib._internal.prodtest_emulator import get_prodtest_emulator
+from trezorlib._internal.prodtest_transport import VcpUdpTransport
+
+from ..emulators import delete_profile
+from .tropic_utils import DEFAULT_TROPIC_MODEL_CONFIGFILE, TropicProdtest, TropicSession
+
+# UDP base port for the dedicated Tropic test emulator. Tropic tests run one
+# at a time and tear their emulator down, so a fixed base is safe.
+_TROPIC_EMULATOR_UDP_BASE_PORT = 31324
+
+
+def pytest_addoption(parser: pytest.Parser) -> None:
+ parser.addoption(
+ "--prodtest-model",
+ action="store",
+ default=os.environ.get("TREZOR_MODEL"),
+ help="Prodtest model to run tests against (e.g. t3w1, t3t1). "
+ "Can also be set via the TREZOR_MODEL environment variable. "
+ "If neither is given, the 'latest' emulator build is used.",
+ )
+
+
+def pytest_configure(config: pytest.Config) -> None:
+ config.addinivalue_line(
+ "markers",
+ "requires_command(*names): skip the test unless the device reports all "
+ "the given commands in its 'help' listing. Use for model-specific "
+ "commands (touch, RGB LED, telemetry, ...).",
+ )
+
+
+@pytest.fixture(scope="session")
+def is_emulator(client: ProdtestClient) -> bool:
+ """Whether the device under test is an emulator rather than real hardware."""
+ return isinstance(client.transport, VcpUdpTransport)
+
+
+@pytest.fixture(autouse=True)
+def skip_if_command_unavailable(
+ request: pytest.FixtureRequest, client: ProdtestClient
+) -> None:
+ """Skip a test whose ``requires_command`` marker names an absent command.
+
+ Model-specific commands are only compiled into some builds. A test declares
+ what it needs with ``@pytest.mark.requires_command(Cmd.TOUCH_VERSION)`` and
+ is skipped wherever the device doesn't offer it.
+ """
+ marker = request.node.get_closest_marker("requires_command")
+ if marker is None:
+ return
+
+ missing = set(marker.args) - client.available_commands
+ if missing:
+ pytest.skip(f"command(s) not available on this device: {sorted(missing)}")
+
+
+@pytest.fixture
+def tropic_prodtest(
+ request: pytest.FixtureRequest,
+) -> t.Iterator[TropicProdtest]:
+ """Factory for a dedicated prodtest emulator with its own Tropic model.
+
+ Each call starts a fresh ``model_server`` seeded with a config (the
+ device-test default, or *tropic_model_configfile* if given), starts a
+ prodtest emulator pointed at it, yields a :class:`TropicSession`, and tears
+ both down on exit. Following the emulator refactor, the model is a standalone
+ process (like ``CoreEmulator`` + the device-test ``tropic_model_port``
+ fixture): the emulator only receives its TCP port.
+
+ The model dumps its final state on shutdown (SIGINT), so inspect it *after*
+ the ``with`` block::
+
+ def test_pair(tropic_prodtest):
+ with tropic_prodtest() as tp:
+ tp.client.command_ok(ProdtestCommand(Cmd.TROPIC_PAIR))
+ assert tp.state().pairing_key_state(0) == "written"
+
+ This is separate from the shared session emulator used by the other tests:
+ inspecting Tropic state requires stopping the model, which a shared,
+ session-scoped emulator cannot offer.
+ """
+ model = request.config.getoption("prodtest_model") or None
+ created_dirs: list[str] = []
+
+ @contextmanager
+ def _factory(
+ *, tropic_model_configfile: str | Path | None = None
+ ) -> t.Iterator[TropicSession]:
+ config = Path(tropic_model_configfile or DEFAULT_TROPIC_MODEL_CONFIGFILE)
+ model_dir = tempfile.mkdtemp(prefix="prodtest_tropic_model_")
+ created_dirs.append(model_dir)
+ # Let the OS pick a free TCP port for the Tropic model.
+ with socket.socket() as s:
+ s.bind(("", 0))
+ tropic_model_port = s.getsockname()[1]
+ tropic_model = TropicModel(
+ profile_dir=model_dir,
+ configfile=config,
+ port=tropic_model_port,
+ )
+ tropic_model.start()
+ try:
+ emu_dir = tempfile.mkdtemp(prefix="prodtest_emu_")
+ created_dirs.append(emu_dir)
+ emu = get_prodtest_emulator(
+ model=model,
+ profile_dir=emu_dir,
+ port=_TROPIC_EMULATOR_UDP_BASE_PORT,
+ tropic_model_port=tropic_model.port,
+ )
+ emu.start()
+ client = ProdtestClient(transport=VcpUdpTransport(port=emu.vcp_port))
+ session = TropicSession(client, tropic_model)
+ try:
+ yield session
+ finally:
+ client.close()
+ emu.stop()
+ finally:
+ # Stop the model after the emulator so it flushes its final state.
+ tropic_model.stop()
+
+ try:
+ yield _factory
+ finally:
+ for directory in created_dirs:
+ shutil.rmtree(directory, ignore_errors=True)
+
+
+@pytest.fixture(scope="session")
+def client(
+ request: pytest.FixtureRequest,
+) -> t.Generator[ProdtestClient, None, None]:
+ """Yield a client connected to the shared prodtest emulator.
+
+ Session-scoped: a single client (and the single wrapper-started emulator it
+ talks to) is shared by every test in the run, so tests that mutate device
+ state must restore it. Tropic tests instead use ``tropic_prodtest``, which
+ gives each test its own emulator.
+
+ The model is resolved in order:
+ 1. --prodtest-model CLI option
+ 2. TREZOR_MODEL environment variable
+ 3. 'latest' symlink under core/build-xtask/artifacts/latest/prodtest-emu
+ """
+ model = request.config.getoption("prodtest_model") or None
+
+ # NOTE: the emulator process is started by the `core/prodtest_emu.py` wrapper
+ # (or `make test_emu_prodtest`), not here. This object is only used to derive
+ # `vcp_port`. Tropic tests run their own emulator (`tropic_prodtest`).
+ with tempfile.TemporaryDirectory(
+ prefix="prodtest_emu_", delete=delete_profile()
+ ) as profile_dir:
+ emu = get_prodtest_emulator(model=model, profile_dir=profile_dir)
+ with emu:
+ client = ProdtestClient(transport=VcpUdpTransport(port=emu.vcp_port))
+ # Eagerly resolve the model so any unknown-model error surfaces at startup.
+ _ = client.model
+ yield client
+ client.close()
diff --git a/tests/prodtest_tests/pytest.ini b/tests/prodtest_tests/pytest.ini
new file mode 100644
index 00000000..78bd2e57
--- /dev/null
+++ b/tests/prodtest_tests/pytest.ini
@@ -0,0 +1,5 @@
+# Isolates prodtest tests from the firmware test suite.
+# confcutdir prevents pytest from loading tests/conftest.py
+
+[pytest]
+addopts = -rfEs --strict-markers --confcutdir=. --random-order -v
diff --git a/tests/prodtest_tests/test_backup_ram.py b/tests/prodtest_tests/test_backup_ram.py
new file mode 100644
index 00000000..88c1d8cc
--- /dev/null
+++ b/tests/prodtest_tests/test_backup_ram.py
@@ -0,0 +1,45 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import PRODTEST_ERR_BACKUP_RAM_KEY_NOT_FOUND, assert_command_fails
+
+# A key that is never provisioned, so a read of it always misses.
+_UNUSED_KEY = "42"
+
+
+@pytest.mark.requires_command(Cmd.BACKUP_RAM_LIST)
+def test_backup_ram_list(client: ProdtestClient) -> None:
+ """backup-ram-list should initialize backup RAM and succeed (even if empty)."""
+ client.command_ok(ProdtestCommand(Cmd.BACKUP_RAM_LIST))
+
+
+@pytest.mark.requires_command(Cmd.BACKUP_RAM_READ)
+def test_backup_ram_read_missing_key(client: ProdtestClient) -> None:
+ """Reading an unprovisioned key reports the dedicated 'key not found' error.
+
+ A write→read round-trip cannot be exercised on the emulator: its
+ backup_ram_write() is a no-op stub, so we only cover the read/miss path here.
+ """
+ resp = assert_command_fails(
+ client, ProdtestCommand(Cmd.BACKUP_RAM_READ, _UNUSED_KEY)
+ )
+ assert resp.error_code == PRODTEST_ERR_BACKUP_RAM_KEY_NOT_FOUND
diff --git a/tests/prodtest_tests/test_cli.py b/tests/prodtest_tests/test_cli.py
new file mode 100644
index 00000000..27056d02
--- /dev/null
+++ b/tests/prodtest_tests/test_cli.py
@@ -0,0 +1,29 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+"""Tests for the CLI framework behaviour shared by all commands."""
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_CMD, assert_command_fails
+
+
+def test_unknown_command_rejected(client: ProdtestClient) -> None:
+ """An unrecognized command name is rejected with INVALID_CMD."""
+ resp = assert_command_fails(client, ProdtestCommand("no-such-command"))
+ assert resp.error_code == CLI_ERROR_INVALID_CMD
diff --git a/tests/prodtest_tests/test_crc.py b/tests/prodtest_tests/test_crc.py
new file mode 100644
index 00000000..7252082a
--- /dev/null
+++ b/tests/prodtest_tests/test_crc.py
@@ -0,0 +1,55 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_CRC
+
+
+def test_crc_status_returns_0_or_1(client: ProdtestClient) -> None:
+ """crc-status should return '0' or '1'."""
+ resp = client.command_ok(ProdtestCommand(Cmd.CRC_STATUS))
+ assert resp.args in ("0", "1")
+
+
+def test_crc_enable_disable_cycle(client: ProdtestClient) -> None:
+ """Enabling then disabling CRC should leave it disabled."""
+ try:
+ client.command_ok(ProdtestCommand(Cmd.CRC_ENABLE))
+ assert client.crc_enabled
+ assert client.command_ok(ProdtestCommand(Cmd.CRC_STATUS)).args == "1"
+
+ client.command_ok(ProdtestCommand(Cmd.CRC_DISABLE))
+ assert not client.crc_enabled
+ assert client.command_ok(ProdtestCommand(Cmd.CRC_STATUS)).args == "0"
+ finally:
+ if client.crc_enabled:
+ client.command_ok(ProdtestCommand(Cmd.CRC_DISABLE))
+
+
+def test_crc_rejects_bad_checksum(client: ProdtestClient) -> None:
+ """A `checked-` command with a wrong CRC is rejected with INVALID_CRC.
+
+ The per-command `checked-` prefix validates a CRC without enabling global
+ CRC, so no device state lingers. The rejection response carries no CRC
+ suffix, so we read it directly off the transport rather than via command().
+ """
+ client.transport.writeline("checked-ping deadbeef")
+ line = client.transport.readline(client.DEFAULT_TIMEOUT)
+ assert line.startswith("ERROR"), line
+ assert int(line.split()[1]) == CLI_ERROR_INVALID_CRC
diff --git a/tests/prodtest_tests/test_display.py b/tests/prodtest_tests/test_display.py
new file mode 100644
index 00000000..0bf34b3b
--- /dev/null
+++ b/tests/prodtest_tests/test_display.py
@@ -0,0 +1,62 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+def test_display_border(client: ProdtestClient) -> None:
+ """display-border should succeed with no arguments."""
+ client.command_ok(ProdtestCommand(Cmd.DISPLAY_BORDER))
+
+
+def test_display_text(client: ProdtestClient) -> None:
+ """display-text should succeed rendering the given text."""
+ client.command_ok(ProdtestCommand(Cmd.DISPLAY_TEXT, "prodtest"))
+
+
+def test_display_bars(client: ProdtestClient) -> None:
+ """display-bars should succeed rendering a valid RGBW color pattern."""
+ client.command_ok(ProdtestCommand(Cmd.DISPLAY_BARS, "RGBW"))
+
+
+def test_display_bars_warns_on_invalid_color(client: ProdtestClient) -> None:
+ """display-bars accepts an invalid pattern but warns about it via a trace.
+
+ The command is lenient — it still returns OK — but emits a diagnostic trace
+ when the pattern contains characters outside RGBW/rgbw.
+ """
+ resp = client.command_ok(ProdtestCommand(Cmd.DISPLAY_BARS, "X"))
+ assert resp.traces is not None
+ assert any("Not valid color pattern" in line for line in resp.traces)
+
+
+def test_display_set_backlight(client: ProdtestClient) -> None:
+ """display-set-backlight should accept a level in the 0-255 range."""
+ client.command_ok(ProdtestCommand(Cmd.DISPLAY_SET_BACKLIGHT, "128"))
+
+
+def test_display_set_backlight_rejects_out_of_range(
+ client: ProdtestClient,
+) -> None:
+ """A backlight level above 255 should be rejected."""
+ resp = assert_command_fails(
+ client, ProdtestCommand(Cmd.DISPLAY_SET_BACKLIGHT, "256")
+ )
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_getcpuid.py b/tests/prodtest_tests/test_getcpuid.py
new file mode 100644
index 00000000..3801a7eb
--- /dev/null
+++ b/tests/prodtest_tests/test_getcpuid.py
@@ -0,0 +1,35 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import re
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_cpuid_is_hex(client: ProdtestClient) -> None:
+ """get-cpuid should return a non-empty, whole-byte hex string."""
+ resp = client.command_ok(ProdtestCommand(Cmd.GET_CPUID))
+ assert re.fullmatch(r"[0-9A-Fa-f]+", resp.args), f"Not a hex string: {resp.args}"
+ assert len(resp.args) % 2 == 0, f"Not a whole number of bytes: {resp.args}"
+
+
+def test_cpuid_is_stable(client: ProdtestClient) -> None:
+ """get-cpuid should return the same value on repeated calls."""
+ resp1 = client.command_ok(ProdtestCommand(Cmd.GET_CPUID))
+ resp2 = client.command_ok(ProdtestCommand(Cmd.GET_CPUID))
+ assert resp1.args == resp2.args
diff --git a/tests/prodtest_tests/test_haptic.py b/tests/prodtest_tests/test_haptic.py
new file mode 100644
index 00000000..1a8cefad
--- /dev/null
+++ b/tests/prodtest_tests/test_haptic.py
@@ -0,0 +1,42 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+@pytest.mark.requires_command(Cmd.HAPTIC_TEST)
+@pytest.mark.parametrize("amplitude", ["", "0", "50", "100"])
+def test_haptic_test_runs(client: ProdtestClient, amplitude: str) -> None:
+ """haptic-test should run a short feedback pulse and succeed."""
+ client.command_ok(ProdtestCommand(Cmd.HAPTIC_TEST, "1", amplitude))
+
+
+@pytest.mark.requires_command(Cmd.HAPTIC_TEST)
+@pytest.mark.parametrize("amplitude", ["-50", "-1", "101", "10000"])
+def test_haptic_test_rejects_bad_amplitude(
+ client: ProdtestClient, amplitude: str
+) -> None:
+ """haptic-test should reject an amplitude below 0 / above 100."""
+ resp = assert_command_fails(
+ client, ProdtestCommand(Cmd.HAPTIC_TEST, "1", amplitude)
+ )
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_help.py b/tests/prodtest_tests/test_help.py
new file mode 100644
index 00000000..e92c3a0a
--- /dev/null
+++ b/tests/prodtest_tests/test_help.py
@@ -0,0 +1,141 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient
+
+# Commands present on every supported model.
+_COMMON_COMMANDS = {
+ Cmd.BOARDLOADER_UPDATE,
+ Cmd.BOARDLOADER_VERSION,
+ Cmd.CRC_DISABLE,
+ Cmd.CRC_ENABLE,
+ Cmd.CRC_STATUS,
+ Cmd.DISPLAY_BARS,
+ Cmd.DISPLAY_BORDER,
+ Cmd.DISPLAY_SET_BACKLIGHT,
+ Cmd.DISPLAY_TEXT,
+ Cmd.GET_CPUID,
+ Cmd.HELP,
+ Cmd.HW_REVISION,
+ Cmd.LOG_FILTER,
+ Cmd.MANUFACTURING_LOCK_READ,
+ Cmd.OTP_BATCH_READ,
+ Cmd.OTP_BATCH_WRITE,
+ Cmd.OTP_DEVICE_SN_READ,
+ Cmd.OTP_DEVICE_SN_WRITE,
+ Cmd.OTP_VARIANT_READ,
+ Cmd.OTP_VARIANT_WRITE,
+ Cmd.PING,
+ Cmd.PRODTEST_HOMESCREEN,
+ Cmd.PRODTEST_MODEL,
+ Cmd.PRODTEST_MEM_READ,
+ Cmd.PRODTEST_MEM_WRITE,
+ Cmd.PRODTEST_UPTIME,
+ Cmd.PRODTEST_VERSION,
+ Cmd.PRODTEST_WIPE,
+ Cmd.REBOOT,
+ Cmd.REBOOT_TO_BOOTLOADER,
+ Cmd.SBU_SET,
+ Cmd.SECURE_CHANNEL_HANDSHAKE_1,
+ Cmd.SECURE_CHANNEL_HANDSHAKE_2,
+ Cmd.TAMPER_READ,
+ Cmd.UNIT_TEST_LIST,
+ Cmd.UNIT_TEST_RUN,
+}
+
+_TOUCH_COMMANDS = {
+ Cmd.HAPTIC_TEST,
+ Cmd.TOUCH_DRAW,
+ Cmd.TOUCH_TEST,
+ Cmd.TOUCH_TEST_CUSTOM,
+ Cmd.TOUCH_TEST_IDLE,
+ Cmd.TOUCH_TEST_POWER,
+ Cmd.TOUCH_TEST_SENSITIVITY,
+ Cmd.TOUCH_VERSION,
+}
+
+_TROPIC_COMMANDS = {
+ Cmd.TROPIC_BENCHMARK,
+ Cmd.TROPIC_CERTDEV_READ,
+ Cmd.TROPIC_CERTDEV_WRITE,
+ Cmd.TROPIC_CERTFIDO_READ,
+ Cmd.TROPIC_CERTFIDO_WRITE,
+ Cmd.TROPIC_CERTTROPIC_READ,
+ Cmd.TROPIC_ERASE_ALL_SLOTS,
+ Cmd.TROPIC_GET_ACCESS_CREDENTIAL,
+ Cmd.TROPIC_GET_CHIP_ID,
+ Cmd.TROPIC_GET_FIDO_MASKING_KEY,
+ Cmd.TROPIC_GET_RISCV_FW_VERSION,
+ Cmd.TROPIC_GET_SPECT_FW_VERSION,
+ Cmd.TROPIC_HANDSHAKE,
+ Cmd.TROPIC_KEYFIDO_READ,
+ Cmd.TROPIC_LOCK,
+ Cmd.TROPIC_LOCK_CHECK,
+ Cmd.TROPIC_PAIR,
+ Cmd.TROPIC_READ_CONFIGS,
+ Cmd.TROPIC_READ_SENSORS,
+ Cmd.TROPIC_SEND_COMMAND,
+ Cmd.TROPIC_SET_SENSORS,
+ Cmd.TROPIC_STRESS_INIT,
+ Cmd.TROPIC_STRESS_MAC_AND_DESTROY,
+ Cmd.TROPIC_STRESS_SESSION,
+ Cmd.TROPIC_STRESS_TEST,
+ Cmd.TROPIC_TEST_COUNTER,
+ Cmd.TROPIC_TEST_MAC_AND_DESTROY,
+ Cmd.TROPIC_TEST_RMEM,
+ Cmd.TROPIC_TEST_RNG,
+ Cmd.TROPIC_TEST_SIGN,
+ Cmd.TROPIC_TESTS_CLEANUP,
+ Cmd.TROPIC_UPDATE_FW,
+}
+
+# Per-model extra commands on top of _COMMON_COMMANDS.
+_MODEL_EXTRA_COMMANDS: dict[str, set[str]] = {
+ "t3w1": _TOUCH_COMMANDS
+ | _TROPIC_COMMANDS
+ | {
+ Cmd.BACKUP_RAM_ERASE,
+ Cmd.BACKUP_RAM_LIST,
+ Cmd.BACKUP_RAM_READ,
+ Cmd.BACKUP_RAM_WRITE,
+ Cmd.BUTTON_TEST,
+ Cmd.MANUFACTURING_LOCK_WRITE,
+ Cmd.RGBLED_EFFECT_START,
+ Cmd.RGBLED_EFFECT_STOP,
+ Cmd.RGBLED_SET,
+ Cmd.TELEMETRY_READ,
+ Cmd.TELEMETRY_RESET,
+ },
+}
+
+
+def _expected_commands(model_name: str) -> set[str]:
+ """Commands ``help`` should list for the given internal model name."""
+ extras = _MODEL_EXTRA_COMMANDS.get(model_name.lower(), set())
+ return _COMMON_COMMANDS | extras
+
+
+def test_help_lists_expected_commands(client: ProdtestClient) -> None:
+ """help should list exactly the expected commands for the model."""
+ expected_commands = _expected_commands(client.model.internal_name)
+ available_commands = client.available_commands
+ missing = expected_commands - available_commands
+ unexpected = available_commands - expected_commands
+
+ assert not missing, f"Commands missing from help output: {missing}"
+ assert not unexpected, f"Unexpected commands in help output: {unexpected}"
diff --git a/tests/prodtest_tests/test_homescreen.py b/tests/prodtest_tests/test_homescreen.py
new file mode 100644
index 00000000..214089e0
--- /dev/null
+++ b/tests/prodtest_tests/test_homescreen.py
@@ -0,0 +1,34 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+def test_show_homescreen(client: ProdtestClient) -> None:
+ """prodtest-homescreen should render the homescreen and succeed."""
+ client.command_ok(ProdtestCommand(Cmd.PRODTEST_HOMESCREEN))
+
+
+def test_homescreen_rejects_args(client: ProdtestClient) -> None:
+ """prodtest-homescreen takes no arguments."""
+ resp = assert_command_fails(
+ client, ProdtestCommand(Cmd.PRODTEST_HOMESCREEN, "extra")
+ )
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_hw_revision.py b/tests/prodtest_tests/test_hw_revision.py
new file mode 100644
index 00000000..eac2770f
--- /dev/null
+++ b/tests/prodtest_tests/test_hw_revision.py
@@ -0,0 +1,34 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+def test_hw_revision_is_byte(client: ProdtestClient) -> None:
+ """hw-revision should return an integer in the 0-255 range."""
+ resp = client.command_ok(ProdtestCommand(Cmd.HW_REVISION))
+ assert resp.args.isdigit()
+ assert 0 <= int(resp.args) <= 255
+
+
+def test_hw_revision_rejects_args(client: ProdtestClient) -> None:
+ """hw-revision takes no arguments."""
+ resp = assert_command_fails(client, ProdtestCommand(Cmd.HW_REVISION, "extra"))
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_log_filter.py b/tests/prodtest_tests/test_log_filter.py
new file mode 100644
index 00000000..baa03f02
--- /dev/null
+++ b/tests/prodtest_tests/test_log_filter.py
@@ -0,0 +1,32 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+def test_log_filter_set(client: ProdtestClient) -> None:
+ """log-filter should accept a filter string."""
+ client.command_ok(ProdtestCommand(Cmd.LOG_FILTER, "*"))
+
+
+def test_log_filter_requires_argument(client: ProdtestClient) -> None:
+ """log-filter with no filter string should be rejected."""
+ resp = assert_command_fails(client, ProdtestCommand(Cmd.LOG_FILTER))
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_manufacturing_lock.py b/tests/prodtest_tests/test_manufacturing_lock.py
new file mode 100644
index 00000000..b81b0f8c
--- /dev/null
+++ b/tests/prodtest_tests/test_manufacturing_lock.py
@@ -0,0 +1,25 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_manufacturing_lock_read(client: ProdtestClient) -> None:
+ """manufacturing-lock-read should report either 'locked' or 'unlocked'."""
+ resp = client.command_ok(ProdtestCommand(Cmd.MANUFACTURING_LOCK_READ))
+ assert resp.args in ("locked", "unlocked")
diff --git a/tests/prodtest_tests/test_mem_buffer.py b/tests/prodtest_tests/test_mem_buffer.py
new file mode 100644
index 00000000..4e5c8e84
--- /dev/null
+++ b/tests/prodtest_tests/test_mem_buffer.py
@@ -0,0 +1,28 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_write_and_read_back(client: ProdtestClient) -> None:
+ """Data written with mem-write should be returned verbatim by mem-read."""
+ hexdata = "DEADBEEF01020304"
+ client.command_ok(ProdtestCommand(Cmd.PRODTEST_MEM_WRITE, hexdata))
+
+ resp = client.command_ok(ProdtestCommand(Cmd.PRODTEST_MEM_READ))
+ assert resp.args.upper() == hexdata.upper()
diff --git a/tests/prodtest_tests/test_otp.py b/tests/prodtest_tests/test_otp.py
new file mode 100644
index 00000000..1b89f810
--- /dev/null
+++ b/tests/prodtest_tests/test_otp.py
@@ -0,0 +1,52 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import (
+ Cmd,
+ ProdtestClient,
+ ProdtestCommand,
+ ResponseNotOkError,
+)
+
+from . import PRODTEST_ERR_OTP_EMPTY
+
+
+def test_otp_variant_read(client: ProdtestClient) -> None:
+ """otp-variant-read returns the block as a list of byte values (0-255)."""
+ resp = client.command_ok(ProdtestCommand(Cmd.OTP_VARIANT_READ))
+ values = resp.args.split()
+ assert values, "expected at least one byte value"
+ assert all(v.isdigit() and 0 <= int(v) <= 255 for v in values)
+
+
+@pytest.mark.parametrize("cmd", [Cmd.OTP_BATCH_READ, Cmd.OTP_DEVICE_SN_READ])
+def test_otp_text_block_read(client: ProdtestClient, cmd: str) -> None:
+ """A text OTP block is either populated (OK) or reported empty.
+
+ On an unprovisioned (emulator) device the block is blank, so the command
+ fails with the dedicated 'OTP block is empty' error rather than a generic
+ read failure.
+ """
+ try:
+ resp = client.command_ok(ProdtestCommand(cmd))
+ assert resp.args
+ except ResponseNotOkError as exc:
+ assert exc.response
+ assert exc.response.error_code == PRODTEST_ERR_OTP_EMPTY
diff --git a/tests/prodtest_tests/test_ping.py b/tests/prodtest_tests/test_ping.py
new file mode 100644
index 00000000..5db08bc5
--- /dev/null
+++ b/tests/prodtest_tests/test_ping.py
@@ -0,0 +1,41 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_ping_no_args(client: ProdtestClient) -> None:
+ """ping with no arguments should return OK with empty args."""
+ resp = client.command_ok(ProdtestCommand(Cmd.PING))
+ assert resp.args == ""
+
+
+def test_ping_with_text(client: ProdtestClient) -> None:
+ """ping should echo back the provided text."""
+ resp = client.command_ok(ProdtestCommand(Cmd.PING, "hello"))
+ assert resp.args == "hello"
+
+
+def test_ping_with_long_text(client: ProdtestClient) -> None:
+ """ping echoes text up to the CLI line buffer, truncating anything beyond."""
+ long_text = 512 * "longtext"
+ resp = client.command_ok(ProdtestCommand(Cmd.PING, long_text))
+ assert resp.args == long_text
+ too_long_text = long_text + "A"
+ resp = client.command_ok(ProdtestCommand(Cmd.PING, too_long_text))
+ assert resp.args == long_text
diff --git a/tests/prodtest_tests/test_rgbled.py b/tests/prodtest_tests/test_rgbled.py
new file mode 100644
index 00000000..94a9e4ef
--- /dev/null
+++ b/tests/prodtest_tests/test_rgbled.py
@@ -0,0 +1,76 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import typing as t
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+@pytest.fixture(autouse=True)
+def _restore_rgbled(client: ProdtestClient) -> t.Iterator[None]:
+ """Leave the RGB LED as it was on entry (off) after each test.
+
+ These tests share the session emulator, and — with tests running in random
+ order — must not leave the LED lit for whatever runs next. The prodtest main
+ loop turns the LED off and disables automatic control once its start-up
+ animation finishes, so ``off`` is the state tests see on entry. There is no
+ command to read the current color, so we reset to off (0, 0, 0) to match it.
+ """
+ yield
+ if Cmd.RGBLED_SET in client.available_commands:
+ client.command_ok(ProdtestCommand(Cmd.RGBLED_SET, "0", "0", "0"))
+
+
+@pytest.mark.requires_command(Cmd.RGBLED_SET)
+@pytest.mark.parametrize(
+ ("r", "g", "b"),
+ [
+ ("0", "255", "0"),
+ ("0", "0", "255"),
+ ("255", "0", "0"),
+ ("255", "255", "255"),
+ ("15", "55", "129"),
+ ],
+)
+def test_rgbled_set(client: ProdtestClient, r: str, g: str, b: str) -> None:
+ """rgbled-set should accept an R/G/B triple in the 0-255 range."""
+ client.command_ok(ProdtestCommand(Cmd.RGBLED_SET, r, g, b))
+
+
+@pytest.mark.parametrize(
+ ("r", "g", "b"),
+ [("0", "256", "0"), ("-1", "0", "0"), ("-15", "55", "129")],
+)
+@pytest.mark.requires_command(Cmd.RGBLED_SET)
+def test_rgbled_set_rejects_out_of_range(
+ client: ProdtestClient, r: str, g: str, b: str
+) -> None:
+ """rgbled-set should reject a channel value outside the 0-255 range."""
+ resp = assert_command_fails(client, ProdtestCommand(Cmd.RGBLED_SET, r, g, b))
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
+
+
+@pytest.mark.requires_command(Cmd.RGBLED_EFFECT_START, Cmd.RGBLED_EFFECT_STOP)
+def test_rgbled_effect_start_stop(client: ProdtestClient) -> None:
+ """An RGB LED effect should start and stop successfully."""
+ client.command_ok(ProdtestCommand(Cmd.RGBLED_EFFECT_START, "0"))
+ client.command_ok(ProdtestCommand(Cmd.RGBLED_EFFECT_STOP))
diff --git a/tests/prodtest_tests/test_sbu.py b/tests/prodtest_tests/test_sbu.py
new file mode 100644
index 00000000..db3692ab
--- /dev/null
+++ b/tests/prodtest_tests/test_sbu.py
@@ -0,0 +1,36 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+from . import CLI_ERROR_INVALID_ARG, assert_command_fails
+
+
+@pytest.mark.parametrize("sbu1,sbu2", [("0", "0"), ("1", "0"), ("0", "1"), ("1", "1")])
+def test_sbu_set_levels(client: ProdtestClient, sbu1: str, sbu2: str) -> None:
+ """sbu-set should accept any combination of logical levels 0/1."""
+ resp = client.command_ok(ProdtestCommand(Cmd.SBU_SET, sbu1, sbu2))
+ assert resp.args == ""
+
+
+def test_sbu_set_rejects_invalid_level(client: ProdtestClient) -> None:
+ """sbu-set should reject levels other than 0 or 1."""
+ resp = assert_command_fails(client, ProdtestCommand(Cmd.SBU_SET, "2", "0"))
+ assert resp.error_code == CLI_ERROR_INVALID_ARG
diff --git a/tests/prodtest_tests/test_tamper.py b/tests/prodtest_tests/test_tamper.py
new file mode 100644
index 00000000..f4e90343
--- /dev/null
+++ b/tests/prodtest_tests/test_tamper.py
@@ -0,0 +1,26 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_tamper_read_is_integer(client: ProdtestClient) -> None:
+ """tamper-read should initialize the driver and return a byte status (0-255)."""
+ resp = client.command_ok(ProdtestCommand(Cmd.TAMPER_READ))
+ assert resp.args.isdigit()
+ assert 0 <= int(resp.args) <= 255
diff --git a/tests/prodtest_tests/test_telemetry.py b/tests/prodtest_tests/test_telemetry.py
new file mode 100644
index 00000000..ae01377c
--- /dev/null
+++ b/tests/prodtest_tests/test_telemetry.py
@@ -0,0 +1,41 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+@pytest.mark.requires_command(Cmd.TELEMETRY_READ)
+def test_telemetry_read(client: ProdtestClient) -> None:
+ """telemetry-read returns 'min_temp max_temp battery_errors battery_cycles'.
+
+ Format is `%d %d 0x%02X %d`: the temperatures and cycle count are (possibly
+ negative) integers, and the battery-error field is a hex bitmask.
+ """
+ resp = client.command_ok(ProdtestCommand(Cmd.TELEMETRY_READ))
+ fields = resp.args.split()
+ assert len(fields) == 4, f"unexpected telemetry format: {resp.args!r}"
+
+ min_temp, max_temp, battery_errors, battery_cycles = fields
+ int(min_temp) # raises if not an integer
+ int(max_temp)
+ int(battery_cycles)
+ assert re.fullmatch(r"0x[0-9A-Fa-f]+", battery_errors), battery_errors
diff --git a/tests/prodtest_tests/test_touch.py b/tests/prodtest_tests/test_touch.py
new file mode 100644
index 00000000..e4c1c960
--- /dev/null
+++ b/tests/prodtest_tests/test_touch.py
@@ -0,0 +1,31 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+# Interactive touch commands (touch-test, touch-draw, ...) block waiting for
+# physical input and are not exercised here; touch-version is a passive read.
+
+
+@pytest.mark.requires_command(Cmd.TOUCH_VERSION)
+def test_touch_version_is_integer(client: ProdtestClient) -> None:
+ """touch-version should initialize the controller and return its version."""
+ resp = client.command_ok(ProdtestCommand(Cmd.TOUCH_VERSION))
+ assert resp.args.isdigit()
diff --git a/tests/prodtest_tests/test_tropic.py b/tests/prodtest_tests/test_tropic.py
new file mode 100644
index 00000000..6743a28b
--- /dev/null
+++ b/tests/prodtest_tests/test_tropic.py
@@ -0,0 +1,289 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+"""Prodtest ``tropic-*`` command tests.
+
+These tests run against a dedicated prodtest emulator with its own Tropic model
+(the ``tropic_prodtest`` fixture), rather than the shared session emulator. The
+fixture starts the Tropic model automatically — nothing has to be launched by
+hand. The general pattern is:
+
+ 1. start the emulator + Tropic model (entering the context manager),
+ 2. issue ``tropic-*`` commands via ``session.client``,
+ 3. leave the context so the Tropic model flushes its final state, then
+ 4. (optionally) assert on ``session.state()``.
+
+The tests seed the Tropic model with the default device-test config
+(``tests/tropic_model/config.yml``), which represents an already-paired device.
+Commands that need a fresh/unpaired chip (``tropic-pair``) or device-specific
+certificates are covered only where they succeed; some are exercised through
+their error paths instead (e.g. the TRNG, which the model drives from a
+constant, and firmware update against a mismatched chip revision).
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestCommand
+
+from . import (
+ PRODTEST_ERR_TROPIC_TEST_RNG_REPEAT,
+ PRODTEST_ERR_TROPIC_UPDATE_WRONG_REVISION,
+ assert_command_fails,
+ assert_hexdata,
+)
+from .tropic_utils import (
+ DEFAULT_TROPIC_MODEL_CONFIGFILE,
+ TropicModelState,
+ TropicProdtest,
+)
+
+# Fixed response sizes reported by libtropic (vendor/libtropic/include).
+_CHIP_ID_SIZE = 128 # TR01_L2_GET_INFO_CHIP_ID_SIZE
+_FW_VERSION_SIZE = 4 # TR01_L2_GET_INFO_{RISCV,SPECT}_FW_SIZE
+
+# ECC-key and user-data slots populated by the default Tropic model config,
+# used to check what ``tropic-erase-all-slots`` clears.
+_ECC_KEY_SLOT = 0
+_USER_DATA_SLOTS = (3, 4, 6)
+
+# The distribution-version slot ``lock`` writes, and its always-erased backup.
+_DISTRIBUTION_VERSION_SLOT = 6
+_BACKUP_DISTRIBUTION_VERSION_SLOT = 7
+
+# Arbitrary non-default sensors config: 8 hex digits = big-endian uint32.
+_SENSORS_CONFIG_VALUE = "0000000F"
+
+# Reversible-config keys that ``lock`` is expected to change.
+_LOCK_CHANGED_R_CONFIG_KEYS = {"cfg_start_up"}
+
+
+# --- info / read-only commands ---------------------------------------------
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_GET_CHIP_ID)
+def test_tropic_get_chip_id(tropic_prodtest: TropicProdtest) -> None:
+ """A chip-ID query returns a well-formed, fixed-size chip ID."""
+ with tropic_prodtest() as session:
+ resp = session.client.command_ok(ProdtestCommand(Cmd.TROPIC_GET_CHIP_ID))
+
+ # Structural check only: the value is device-specific, but its length is not.
+ assert_hexdata(resp, _CHIP_ID_SIZE)
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_GET_RISCV_FW_VERSION)
+def test_tropic_get_riscv_fw_version(tropic_prodtest: TropicProdtest) -> None:
+ """The RISC-V firmware version is a well-formed, fixed-size value."""
+ with tropic_prodtest() as session:
+ resp = session.client.command_ok(
+ ProdtestCommand(Cmd.TROPIC_GET_RISCV_FW_VERSION)
+ )
+
+ assert_hexdata(resp, _FW_VERSION_SIZE)
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_GET_SPECT_FW_VERSION)
+def test_tropic_get_spect_fw_version(tropic_prodtest: TropicProdtest) -> None:
+ """The SPECT firmware version is a well-formed, fixed-size value."""
+ with tropic_prodtest() as session:
+ resp = session.client.command_ok(
+ ProdtestCommand(Cmd.TROPIC_GET_SPECT_FW_VERSION)
+ )
+
+ assert_hexdata(resp, _FW_VERSION_SIZE)
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_LOCK_CHECK)
+def test_tropic_lock_check(tropic_prodtest: TropicProdtest, is_emulator: bool) -> None:
+ """``lock-check`` reports a yes/no answer.
+
+ On the emulator this is always ``NO``: ``lock-check`` returns ``NO`` as soon
+ as the MCU has no stored Tropic public key, which is the case for a freshly
+ started emulator — the pairing process was never run against it. On real
+ hardware either answer is valid depending on provisioning, so we only check
+ the shape there.
+ """
+ with tropic_prodtest() as session:
+ resp = session.client.command_ok(ProdtestCommand(Cmd.TROPIC_LOCK_CHECK))
+
+ assert resp.args in ("YES", "NO")
+ if is_emulator:
+ assert resp.args == "NO"
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_READ_CONFIGS)
+def test_tropic_read_configs(tropic_prodtest: TropicProdtest) -> None:
+ """Reading the whole I/R configuration over a privileged session succeeds."""
+ with tropic_prodtest() as session:
+ session.client.command_ok(ProdtestCommand(Cmd.TROPIC_READ_CONFIGS))
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_READ_SENSORS)
+def test_tropic_read_sensors(
+ tropic_prodtest: TropicProdtest, is_emulator: bool
+) -> None:
+ """Reading the sensors config returns a 32-bit value as ``0x`` + 8 hex."""
+ with tropic_prodtest() as session:
+ resp = session.client.command_ok(ProdtestCommand(Cmd.TROPIC_READ_SENSORS))
+
+ # Structural: "0x%08X" of a uint32 -> parseable and in range on any device.
+ assert resp.args.startswith("0x"), f"unexpected sensors format: {resp.args!r}"
+ value = int(resp.args, 16)
+ assert 0 <= value <= 0xFFFFFFFF
+ if is_emulator:
+ # The freshly seeded model reports the default all-enabled value.
+ assert value == 0x00000000
+
+
+# --- self-test / diagnostic commands ---------------------------------------
+
+# These exercise the Tropic over a session and clean up after themselves; each
+# runs against its own fresh model, so we only assert they complete successfully.
+_SELF_TEST_COMMANDS = [
+ pytest.param(command, marks=pytest.mark.requires_command(command))
+ for command in (
+ Cmd.TROPIC_BENCHMARK,
+ Cmd.TROPIC_STRESS_INIT,
+ Cmd.TROPIC_STRESS_SESSION,
+ Cmd.TROPIC_STRESS_MAC_AND_DESTROY,
+ Cmd.TROPIC_STRESS_TEST,
+ Cmd.TROPIC_TEST_MAC_AND_DESTROY,
+ Cmd.TROPIC_TEST_RMEM,
+ Cmd.TROPIC_TEST_SIGN,
+ Cmd.TROPIC_TESTS_CLEANUP,
+ )
+]
+
+
+@pytest.mark.parametrize("command", _SELF_TEST_COMMANDS)
+def test_tropic_self_tests(tropic_prodtest: TropicProdtest, command: str) -> None:
+ """Each self-test/diagnostic command runs to completion on the model."""
+ with tropic_prodtest() as session:
+ session.client.command_ok(ProdtestCommand(command))
+
+
+# --- state-changing commands (inspected via the model output) --------------
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_ERASE_ALL_SLOTS)
+def test_tropic_erase_all_slots(tropic_prodtest: TropicProdtest) -> None:
+ """``erase-all-slots`` clears ECC keys and data slots, keeps pairing keys."""
+ with tropic_prodtest() as session:
+ session.client.command_ok(ProdtestCommand(Cmd.TROPIC_ERASE_ALL_SLOTS))
+
+ state = session.state()
+
+ # Pairing keys are explicitly preserved.
+ assert state.pairing_key_state(1) == "written"
+ assert state.pairing_key_state(2) == "written"
+
+ # ECC keys and user-data slots seeded by the config are gone.
+ assert not state.ecc_key_is_present(_ECC_KEY_SLOT)
+ for slot in _USER_DATA_SLOTS:
+ assert state.slot_is_erased(slot), f"slot {slot} not erased"
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_SET_SENSORS)
+def test_tropic_set_sensors(tropic_prodtest: TropicProdtest) -> None:
+ """``set-sensors`` writes the requested value into the R-config."""
+ with tropic_prodtest() as session:
+ session.client.command_ok(
+ ProdtestCommand(Cmd.TROPIC_SET_SENSORS, _SENSORS_CONFIG_VALUE)
+ )
+
+ state = session.state()
+ assert state.r_config.get("cfg_sensors") == int(_SENSORS_CONFIG_VALUE, 16)
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_TEST_COUNTER)
+def test_tropic_test_counter(tropic_prodtest: TropicProdtest) -> None:
+ """``test-counter`` initializes the monotonic counters on the model."""
+ with tropic_prodtest() as session:
+ session.client.command_ok(ProdtestCommand(Cmd.TROPIC_TEST_COUNTER))
+
+ state = session.state()
+ assert state.mcounters, "no monotonic counters were initialized"
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_LOCK)
+def test_tropic_lock(tropic_prodtest: TropicProdtest) -> None:
+ """``tropic-lock`` writes the expected config and distribution version.
+
+ ``lock`` is irreversible, but each Tropic test runs against its own
+ throwaway model, so locking it is safe. It rewrites the reversible config to
+ the expected "locked" values, writes the distribution version into its slot,
+ erases the backup slot, and leaves the pairing keys untouched. We capture a
+ fresh (unlocked) model as a baseline to show the reversible config actually
+ changed.
+ """
+ baseline = TropicModelState.from_file(DEFAULT_TROPIC_MODEL_CONFIGFILE)
+
+ with tropic_prodtest(
+ tropic_model_configfile=DEFAULT_TROPIC_MODEL_CONFIGFILE
+ ) as session:
+ session.client.command_ok(ProdtestCommand(Cmd.TROPIC_LOCK))
+ locked = session.state()
+
+ # Locking rewrites the reversible config to the expected locked values.
+ # Reporting the full diff on failure.
+ r_config_diff = {
+ key: (baseline.r_config.get(key), locked.r_config.get(key))
+ for key in baseline.r_config.keys() | locked.r_config.keys()
+ if baseline.r_config.get(key) != locked.r_config.get(key)
+ }
+ assert (
+ set(r_config_diff) == _LOCK_CHANGED_R_CONFIG_KEYS
+ ), f"unexpected r_config changes: {r_config_diff}"
+
+ assert locked.i_config == baseline.i_config
+
+ # The distribution version is written (4-byte big-endian) and its backup
+ # slot is left erased.
+ version = locked.slot_value(_DISTRIBUTION_VERSION_SLOT)
+ assert version is not None and len(version) == 4
+ assert locked.slot_is_erased(_BACKUP_DISTRIBUTION_VERSION_SLOT)
+
+ # Pairing keys survive the lock.
+ assert locked.pairing_key_state(1) == "written"
+ assert locked.pairing_key_state(2) == "written"
+
+
+# --- error paths (specific prodtest error codes) ---------------------------
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_TEST_RNG)
+def test_tropic_test_rng_rejects_constant_rng(tropic_prodtest: TropicProdtest) -> None:
+ """``test-rng``'s sanity check flags the model's constant TRNG output."""
+ with tropic_prodtest() as session:
+ resp = assert_command_fails(
+ session.client, ProdtestCommand(Cmd.TROPIC_TEST_RNG)
+ )
+
+ assert resp.error_code == PRODTEST_ERR_TROPIC_TEST_RNG_REPEAT
+
+
+@pytest.mark.requires_command(Cmd.TROPIC_UPDATE_FW)
+def test_tropic_update_fw_rejects_wrong_revision(
+ tropic_prodtest: TropicProdtest,
+) -> None:
+ """``update-fw`` refuses the model's mismatched chip silicon revision."""
+ with tropic_prodtest() as session:
+ resp = assert_command_fails(
+ session.client, ProdtestCommand(Cmd.TROPIC_UPDATE_FW)
+ )
+
+ assert resp.error_code == PRODTEST_ERR_TROPIC_UPDATE_WRONG_REVISION
diff --git a/tests/prodtest_tests/test_unit_test.py b/tests/prodtest_tests/test_unit_test.py
new file mode 100644
index 00000000..9cfac6da
--- /dev/null
+++ b/tests/prodtest_tests/test_unit_test.py
@@ -0,0 +1,37 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_unit_test_list(client: ProdtestClient) -> None:
+ """unit-test-list should succeed and emit a listing header trace."""
+ resp = client.command_ok(ProdtestCommand(Cmd.UNIT_TEST_LIST))
+ assert resp.traces is not None
+ assert any("registered unit tests" in line for line in resp.traces)
+
+
+def test_unit_test_run(client: ProdtestClient) -> None:
+ """unit-test-run should run all registered on-device unit tests and pass.
+
+ The command returns OK only if every unit test passes; a failure surfaces
+ here as a non-OK response.
+ """
+ resp = client.command_ok(ProdtestCommand(Cmd.UNIT_TEST_RUN))
+ assert resp.traces is not None
+ assert not any("FAILED" in line for line in resp.traces)
diff --git a/tests/prodtest_tests/test_uptime.py b/tests/prodtest_tests/test_uptime.py
new file mode 100644
index 00000000..8cafd956
--- /dev/null
+++ b/tests/prodtest_tests/test_uptime.py
@@ -0,0 +1,32 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_uptime_is_nonnegative_integer(client: ProdtestClient) -> None:
+ """prodtest-uptime should return a non-negative integer number of milliseconds."""
+ resp = client.command_ok(ProdtestCommand(Cmd.PRODTEST_UPTIME))
+ assert resp.args.isdigit()
+
+
+def test_uptime_is_monotonic(client: ProdtestClient) -> None:
+ """prodtest-uptime should not decrease between consecutive calls."""
+ first = int(client.command_ok(ProdtestCommand(Cmd.PRODTEST_UPTIME)).args)
+ second = int(client.command_ok(ProdtestCommand(Cmd.PRODTEST_UPTIME)).args)
+ assert second >= first
diff --git a/tests/prodtest_tests/test_version.py b/tests/prodtest_tests/test_version.py
new file mode 100644
index 00000000..0357dbc8
--- /dev/null
+++ b/tests/prodtest_tests/test_version.py
@@ -0,0 +1,42 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import re
+
+from trezorlib._internal.prodtest_client import Cmd, ProdtestClient, ProdtestCommand
+
+
+def test_prodtest_version_format(client: ProdtestClient) -> None:
+ """prodtest-version should return a version in major.minor.patch.build format."""
+ resp = client.command_ok(ProdtestCommand(Cmd.PRODTEST_VERSION))
+ assert re.fullmatch(
+ r"\d+\.\d+\.\d+\.\d+", resp.args
+ ), f"Unexpected version format: {resp.args}"
+
+
+def test_boardloader_version_format(client: ProdtestClient) -> None:
+ """boardloader-version should return a version in major.minor.patch format."""
+ resp = client.command_ok(ProdtestCommand(Cmd.BOARDLOADER_VERSION))
+ assert re.fullmatch(
+ r"\d+\.\d+\.\d+", resp.args
+ ), f"Unexpected version format: {resp.args}"
+
+
+def test_prodtest_model(client: ProdtestClient) -> None:
+ """prodtest-model should return an internal model name."""
+ assert client.model is not None
diff --git a/tests/prodtest_tests/tropic_utils.py b/tests/prodtest_tests/tropic_utils.py
new file mode 100644
index 00000000..409548d4
--- /dev/null
+++ b/tests/prodtest_tests/tropic_utils.py
@@ -0,0 +1,187 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+"""Helpers for inspecting the Tropic model state after a prodtest run.
+
+The `model_server` (Tropic model) dumps its final state to a YAML file when it
+shuts down (see `trezorlib._internal.emulator.TropicModel.stop`, which sends
+SIGINT so the model's `atexit` save handler runs). This module wraps that YAML
+in a small read-only view so tests can assert on pairing keys, config words,
+memory slots and ECC keys without re-parsing the raw structure everywhere.
+"""
+
+from __future__ import annotations
+
+import typing as t
+from pathlib import Path
+
+import yaml
+
+from trezorlib._internal.emulator import TropicModel
+from trezorlib._internal.prodtest_client import ProdtestClient
+
+ROOT = Path(__file__).resolve().parent.parent.parent
+DEFAULT_TROPIC_MODEL_CONFIGFILE = ROOT / "tests" / "tropic_model" / "config.yml"
+
+
+# TODO: remove once ts-tvl ships the plain-int-key dump from
+# https://github.com/tropicsquare/ts-tvl/pull/14 — then `from_file` can go back
+# to a plain `yaml.safe_load` (and this loader + `_construct_apply` can go).
+class _TropicYamlLoader(yaml.SafeLoader):
+ """SafeLoader that tolerates the model's Python-tagged enum keys.
+
+ Some commands (e.g. `tropic-lock`) make the model serialize slot indices
+ as `!!python/object/apply:tvl.api.l3_api.SlotEnum [N]` instead of a plain
+ integer. The stock `SafeLoader` refuses those tags. We map any such
+ `apply` node back to its single argument (the integer), so pairing-key
+ slots keyed by `SlotEnum(N)` read back identically to the `N` used
+ elsewhere. Subclassing keeps this off the global `SafeLoader`.
+ """
+
+
+def _construct_apply(
+ loader: yaml.SafeLoader, _tag_suffix: str, node: yaml.Node
+) -> t.Any:
+ # The SlotEnum form is `apply:...SlotEnum [N]` — a one-element arg sequence.
+ if isinstance(node, yaml.SequenceNode):
+ args = loader.construct_sequence(node, deep=True)
+ elif isinstance(node, yaml.MappingNode):
+ # General apply mapping form (`{args: [...], ...}`).
+ args = loader.construct_mapping(node, deep=True).get("args", [])
+ else:
+ args = []
+ return args[0] if len(args) == 1 else tuple(args)
+
+
+_TropicYamlLoader.add_multi_constructor(
+ "tag:yaml.org,2002:python/object/apply:", _construct_apply
+)
+
+
+class TropicModelState:
+ """Read-only view over a Tropic model config-output YAML file.
+
+ The structure mirrors `tests/tropic_model/config.yml`:
+
+ - `i_config` / `r_config`: dicts of `cfg_*` config words (ints)
+ - `i_pairing_keys`: slot index -> {`state`, `value`}
+ - `r_user_data`: slot index -> {`value`, `free`}
+ - `r_ecc_keys`: slot index -> {`a`, `s`, `prefix`, `origin`}
+ """
+
+ def __init__(self, raw: dict[str, t.Any]) -> None:
+ self.raw = raw
+
+ @classmethod
+ def from_file(cls, path: Path | str) -> "TropicModelState":
+ path = Path(path)
+ assert path.exists(), (
+ f"Tropic model output file was not generated: {path}. "
+ "Did the Tropic model receive SIGINT on shutdown?"
+ )
+ return cls(yaml.load(path.read_text(), Loader=_TropicYamlLoader) or {})
+
+ @property
+ def i_config(self) -> dict[str, int]:
+ return self.raw.get("i_config", {})
+
+ @property
+ def r_config(self) -> dict[str, int]:
+ return self.raw.get("r_config", {})
+
+ @property
+ def chip_id(self) -> bytes | None:
+ return self.raw.get("chip_id")
+
+ def pairing_key(self, slot: int) -> dict[str, t.Any] | None:
+ return (self.raw.get("i_pairing_keys") or {}).get(slot)
+
+ def pairing_key_state(self, slot: int) -> str | None:
+ key = self.pairing_key(slot)
+ return key.get("state") if key else None
+
+ def pairing_key_value(self, slot: int) -> bytes | None:
+ key = self.pairing_key(slot)
+ return key.get("value") if key else None
+
+ def slot(self, slot: int) -> dict[str, t.Any] | None:
+ return (self.raw.get("r_user_data") or {}).get(slot)
+
+ def slot_value(self, slot: int) -> bytes | None:
+ entry = self.slot(slot)
+ return entry.get("value") if entry else None
+
+ def slot_is_erased(self, slot: int) -> bool:
+ """True if the slot is absent, explicitly free, empty or all-0xFF."""
+ entry = self.slot(slot)
+ if not entry:
+ return True
+ if entry.get("free") is True:
+ return True
+ value = entry.get("value")
+ if value in (None, b""):
+ return True
+ return all(byte == 0xFF for byte in value)
+
+ def ecc_key(self, slot: int) -> dict[str, t.Any] | None:
+ return (self.raw.get("r_ecc_keys") or {}).get(slot)
+
+ def ecc_key_is_present(self, slot: int) -> bool:
+ return self.ecc_key(slot) is not None
+
+ @property
+ def mcounters(self) -> dict[int, dict[str, t.Any]]:
+ return self.raw.get("r_mcounters") or {}
+
+ def mcounter(self, slot: int) -> dict[str, t.Any] | None:
+ return self.mcounters.get(slot)
+
+
+class TropicSession:
+ """A prodtest client together with the Tropic model backing its emulator.
+
+ Exposes the prodtest `client` for issuing `tropic-*` commands and,
+ once the surrounding context manager has closed (so the Tropic model has
+ flushed its state), `state` for inspecting the resulting model config.
+ """
+
+ def __init__(self, client: ProdtestClient, tropic_model: TropicModel) -> None:
+ self.client = client
+ self.tropic_model = tropic_model
+
+ def state(self) -> TropicModelState:
+ """Parse the Tropic model output YAML.
+
+ Only meaningful after the `tropic_prodtest` context manager has exited,
+ because the model writes the file on shutdown.
+ """
+ return TropicModelState.from_file(self.tropic_model.configfile_output)
+
+
+class TropicProdtest(t.Protocol):
+ """Type of the `tropic_prodtest` fixture: a factory of `TropicSession`.
+
+ Annotate the fixture parameter with this so `with tropic_prodtest() as
+ session` infers `session: TropicSession`::
+
+ def test_x(tropic_prodtest: TropicProdtest) -> None:
+ with tropic_prodtest() as session:
+ ...
+ """
+
+ def __call__(
+ self, *, tropic_model_configfile: str | Path | None = None
+ ) -> t.ContextManager[TropicSession]: ...
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.