From acd2c5359210f29b1478a660c27e425aa255c94f Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Thu, 3 Sep 2026 12:12:18 +0200 Subject: [PATCH] feat(ridesx): Qualcomm QDL firmware flashing driver Add jumpstarter-driver-ridesx with full QDL-based firmware flashing for Qualcomm SA8775P and SA8650P (QAM8650P) platforms. Key features: - Manifest-driven flash workflows (QDL, fastboot, mode switching) - Streaming tar extraction with magic-byte compression detection - Cached and ephemeral flash modes with per-URL cache namespacing - Firmware identification via concurrent serial port scanning - Board revision filtering for CDT image selection (driver config) - Rich live progress panel with download/step tracking - Retry support with automatic device mode recovery - TAC/EPM serial command sequences with persistent connections Includes SoC profiles, example manifests, CLI commands (flash, id, check), comprehensive test suite (116 tests), and documentation. --- .../reference/package-apis/drivers/index.md | 2 +- .../adapters/pexpect.py | 5 +- .../jumpstarter-driver-ridesx/.gitignore | 5 + .../jumpstarter-driver-ridesx/README.md | 202 +++++++- .../examples/exporter-platform.yaml | 37 ++ .../jumpstarter_driver_ridesx/client.py | 2 - .../jumpstarter_driver_ridesx/driver.py | 27 +- .../jumpstarter_driver_ridesx/qdl/__init__.py | 1 + .../qdl/cache_test.py | 106 ++++ .../jumpstarter_driver_ridesx/qdl/client.py | 379 ++++++++++++++ .../qdl/client_test.py | 175 +++++++ .../jumpstarter_driver_ridesx/qdl/driver.py | 486 ++++++++++++++++++ .../qdl/driver_test.py | 102 ++++ .../qdl/examples/manifests/cs4.yaml | 61 +++ .../qdl/examples/manifests/cs5.yaml | 61 +++ .../qdl/examples/manifests/es13.yaml | 75 +++ .../qdl/examples/manifests/es21.yaml | 72 +++ .../qdl/examples/manifests/es22.yaml | 72 +++ .../jumpstarter_driver_ridesx/qdl/executor.py | 397 ++++++++++++++ .../qdl/executor_test.py | 213 ++++++++ .../qdl/firmware_id.py | 264 ++++++++++ .../jumpstarter_driver_ridesx/qdl/schema.py | 144 ++++++ .../qdl/schema_test.py | 87 ++++ .../qdl/soc_profiles.py | 144 ++++++ .../jumpstarter_driver_ridesx/tac.py | 81 +++ .../jumpstarter_driver_ridesx/tac_test.py | 18 + .../jumpstarter-driver-ridesx/pyproject.toml | 6 +- .../jumpstarter/client/__init__.py | 20 +- .../jumpstarter/jumpstarter/client/flasher.py | 157 +++++- .../jumpstarter/client/flasher_test.py | 300 ++++++++++- .../jumpstarter/driver/__init__.py | 4 +- .../jumpstarter/jumpstarter/driver/base.py | 4 +- .../jumpstarter/jumpstarter/driver/flasher.py | 32 ++ .../jumpstarter/driver/flasher_test.py | 29 +- .../jumpstarter/streams/aiohttp.py | 13 +- .../jumpstarter/jumpstarter/streams/common.py | 4 + python/uv.lock | 220 ++++---- typos.toml | 3 + 38 files changed, 3863 insertions(+), 147 deletions(-) create mode 100644 python/packages/jumpstarter-driver-ridesx/.gitignore create mode 100644 python/packages/jumpstarter-driver-ridesx/examples/exporter-platform.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/__init__.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/cache_test.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client_test.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs4.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs5.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es13.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es21.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es22.yaml create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/firmware_id.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema_test.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/soc_profiles.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac.py create mode 100644 python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac_test.py diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 574ff123a..12c244e61 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -79,7 +79,7 @@ Drivers for flashing firmware and programming devices: - {doc}`Probe-RS ` (`jumpstarter-driver-probe-rs`) - Debug probe support - {doc}`ST-LINK MSD ` (`jumpstarter-driver-stlink-msd`) - ST-LINK mass storage flasher for STM32 - {doc}`U-Boot ` (`jumpstarter-driver-uboot`) - Universal Bootloader interface -- {doc}`RideSX ` (`jumpstarter-driver-ridesx`) - Flashing and power management for Qualcomm RideSX +- {doc}`RideSX ` (`jumpstarter-driver-ridesx`) - RideSX fastboot flashing, QDL platform updates, and power management for Qualcomm automotive SoCs ### Emulation diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/pexpect.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/pexpect.py index 1edd1be9f..c292ba24a 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/pexpect.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/pexpect.py @@ -16,4 +16,7 @@ def PexpectAdapter(*, client: DriverClient, method: str = "connect"): try: yield fdspawn(sock) finally: - sock.close() + try: + sock.close() + except OSError: + pass # fd already closed by fdspawn diff --git a/python/packages/jumpstarter-driver-ridesx/.gitignore b/python/packages/jumpstarter-driver-ridesx/.gitignore new file mode 100644 index 000000000..73102fafe --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ diff --git a/python/packages/jumpstarter-driver-ridesx/README.md b/python/packages/jumpstarter-driver-ridesx/README.md index 8cdd5948f..a8e5dce46 100644 --- a/python/packages/jumpstarter-driver-ridesx/README.md +++ b/python/packages/jumpstarter-driver-ridesx/README.md @@ -1,8 +1,11 @@ # RideSX Driver -`jumpstarter-driver-ridesx` provides functionality for Qualcomm RideSX devices, -supporting fastboot flashing operations and power control through serial communication. -It includes automatic compression handling (`.gz`, `.gzip`, `.xz`), built-in storage +`jumpstarter-driver-ridesx` provides functionality for Qualcomm automotive platforms: + +- **RideSX** fastboot partition flashing +- **QDL platform flashing** (`QualcommFlasher`) for full firmware/bootloader updates on SA8775P, SA8650P, and related SoCs + +RideSX support includes automatic compression handling (`.gz`, `.gzip`, `.xz`), built-in storage for firmware images with upload/download capabilities, and direct access to the underlying serial interface for custom commands. @@ -19,6 +22,8 @@ automotive-image-builder build --target ridesx4 --export aboot.simg --mode packa $ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-ridesx ``` +The QDL platform flasher (`QualcommFlasher`) is included in this package. The exporter host must provide `qdl` and `fastboot`. + ## Configuration The RideSX driver supports two main components: @@ -154,3 +159,194 @@ power_client.cycle(wait=5) # Wait 5 seconds between off/on .. autoclass:: jumpstarter_driver_ridesx.client.RideSXPowerClient() :members: on, off, cycle, rescue, serial ``` + +## QDL platform flashing (`QualcommFlasher`) + +Manifest-driven QDL/fastboot flashing for vendor firmware packages (ES13, ES21, ES22, CS4, CS5, …). +See `examples/exporter-platform.yaml` and reference manifests in +`jumpstarter_driver_ridesx/qdl/examples/manifests/`. + +**driver**: `jumpstarter_driver_ridesx.qdl.driver.QualcommFlasher` + +TAC serial handles power on/off and mode switching (EDL/fastboot). Export as `firmware` with +`tac`, `serial`, and `sail` children for identification. + +### Example exporter configuration + +```yaml +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: jumpstarter-lab + name: qualcomm-sa8775p +endpoint: +token: +export: + firmware: + type: "jumpstarter_driver_ridesx.qdl.driver.QualcommFlasher" + config: + soc_type: sa8775p + work_dir: /var/lib/jumpstarter/qualcomm + board_revision: v3 + power_cycle_delay: 2.0 + children: + tac: + ref: tac + serial: + ref: serial + sail: + ref: sail + tac: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyACM0" + baudrate: 115200 + serial: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyUSB1" + baudrate: 115200 + sail: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyUSB2" + baudrate: 115200 +``` + +### Config parameters + +| Parameter | Description | Type | Required | Default | +| -------------------- | ---------------------------------------------------- | ----- | -------- | ------------------------------ | +| soc_type | SoC profile (`sa8775p`, `sa8540p1`, `sa8540p2`) | str | no | sa8775p | +| work_dir | Base directory for firmware extraction | str | no | /var/lib/jumpstarter/qualcomm | +| board_revision | Board revision for CDT image selection (`v1`–`v4`) | str | no | | +| qdl_timeout | Timeout for QDL subprocess steps (seconds) | int | no | 1800 | +| fastboot_timeout | Timeout for fastboot subprocess steps (seconds) | int | no | 600 | +| power_cycle_delay | Delay between power off/on (seconds) | float | no | 2.0 | +| tac_command_timeout | Timeout for TAC command acknowledgement (seconds) | float | no | 10.0 | + +### Required children + +| Child | Description | Required for flash | Required for `id` | +| ------ | -------------------------------------- | ------------------ | ----------------- | +| tac | TAC serial for power and mode control | Yes | Yes | +| serial | Main boot serial console | No | Yes | +| sail | SAIL boot serial console | No | Yes | + +### CLI + +Both the firmware archive and `--manifest` accept local paths or `http://` / `https://` URLs. +Firmware URLs are downloaded on the exporter. Manifest URLs are fetched by the client. + +```bash +# Flash firmware (manifest auto-discovered from archive) +j firmware flash https://example.com/firmware/sx4-r00021.1a.tar.xz + +# Flash with explicit manifest +j firmware flash https://example.com/firmware/sx4-r00021.1a.tar.xz --manifest ./es22.yaml + +# Cache firmware on the exporter for faster re-flashing +j firmware flash ./sx4-r00021.1a.tar.xz --cached + +# Force re-download when cache is incomplete or corrupted +j firmware flash ./sx4-r00021.1a.tar.xz --cached --force-download + +# Identify running firmware +j firmware id -v + +# Check firmware matches expected variant +j firmware check ES22 --hypervisor prod --sail-fw-version 1.3.0 + +# Boot into specific modes +j firmware boot-to-edl +j firmware boot-to-fastboot +``` + +Use `--cached` to keep extracted firmware on the exporter and reuse it on subsequent +flashes. Each source URL gets its own cache directory (namespaced by a hash of the URL) +under `work_dir/`, so different firmware archives never overwrite each other. + +If a download is interrupted, the cache may be left in an incomplete state. Use +`--force-download` with `--cached` to clear the existing cache and re-download. + +Archives may embed `jumpstarter_manifest.yaml`. See `examples/exporter-platform.yaml` for +exporter configuration and the QDL module for manifest schema details. + +### Board revision and CDT flash + +Fastboot flash operations in the manifest can include a `revision` field to conditionally +flash based on the board hardware revision. The board revision is set via the +`board_revision` field in the exporter driver config. + +When a flash operation has `revision` set and no board revision is configured, the +flash command fails with an error. + +### Manifest example + +ABL and CDT flashing are regular fastboot steps in the manifest, giving full control over +ordering, retries, and device mode switching: + +```yaml +name: "SA8650P CS4 Firmware" +data: + folder: "r00010.1" +steps: + - set_mode: edl + check_dmesg: "qcserial" + - sleep: 5 + - name: "UFS provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + - sleep: 10 + - set_mode: edl + check_dmesg: "qcserial" + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + - sleep: 30 + - set_mode: fastboot + check_dmesg: "Product: Android" + - name: "Flash ABL" + fastboot: + flash: + - partition: abl_a + file: qam8650p_abl_signed.elf + - partition: abl_b + file: qam8650p_abl_signed.elf + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true +``` + +The `revision` field on flash operations filters by board revision — only the matching +entry is flashed, the rest are skipped. The `continue: true` on the last fastboot step +tells the device to boot after flashing. + +### Requirements on exporter host + +- `qdl` (Qualcomm download tool) +- `fastboot` +- USB access to the DUT in EDL/fastboot modes +- TAC serial device for mode switching diff --git a/python/packages/jumpstarter-driver-ridesx/examples/exporter-platform.yaml b/python/packages/jumpstarter-driver-ridesx/examples/exporter-platform.yaml new file mode 100644 index 000000000..35188df6d --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/examples/exporter-platform.yaml @@ -0,0 +1,37 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: jumpstarter-lab + name: qualcomm-sa8775p +endpoint: +token: +export: + firmware: + type: "jumpstarter_driver_ridesx.qdl.driver.QualcommFlasher" + config: + soc_type: sa8775p + work_dir: /var/lib/jumpstarter/qualcomm + board_revision: v3 + power_cycle_delay: 2.0 + children: + tac: + ref: tac + serial: + ref: serial + sail: + ref: sail + tac: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyACM0" + baudrate: 115200 + serial: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyUSB1" + baudrate: 115200 + sail: + type: "jumpstarter_driver_pyserial.driver.PySerial" + config: + url: "/dev/ttyUSB2" + baudrate: 115200 diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py index 9545dcd92..8e427cbf9 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py @@ -16,8 +16,6 @@ from jumpstarter.client.decorators import driver_click_group from jumpstarter.common.exceptions import JumpstarterException -PROMPT = "CMD >> " - class RideSXFlashError(JumpstarterException): """User-facing flash operation failure.""" diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py index 01ffe67f2..8111db7e2 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py @@ -8,6 +8,7 @@ from jumpstarter_driver_opendal.driver import Opendal +from .tac import PROMPT, send_power_commands_sequence from jumpstarter.common.exceptions import ConfigurationError from jumpstarter.common.fls import get_fls_binary from jumpstarter.driver import Driver, export @@ -394,7 +395,7 @@ async def boot_to_fastboot(self): chunk = await stream.receive() data += chunk self.logger.debug(f"Command {command} acknowledged with 'ok'") - prompt = b"CMD >> " + prompt = PROMPT while prompt not in data: chunk = await stream.receive() data += chunk @@ -429,7 +430,7 @@ async def on(self): ("usbDevicePower 1", 0), ("gpio vbusdis1 0", 0.03), ] - await _send_power_commands_sequence(self.children["serial"], self.logger, commands) + await send_power_commands_sequence(self.children["serial"], self.logger, commands) @export async def off(self): @@ -440,7 +441,7 @@ async def off(self): ("usbDevicePower 1", 0), ("devicePower 0", 0.5), ] - await _send_power_commands_sequence(self.children["serial"], self.logger, commands) + await send_power_commands_sequence(self.children["serial"], self.logger, commands) @export async def cycle(self, delay: float = 2): @@ -454,23 +455,3 @@ async def cycle(self, delay: float = 2): async def rescue(self): """Rescue mode - not implemented for RideSX""" raise NotImplementedError("Rescue mode not available for RideSX") - - -async def _send_power_command(serial, logger, command: str): - """Send a power command to the device via serial""" - async with serial.connect() as stream: - logger.info(f"Executing power command: {command}") - await stream.send(f"{command}\r".encode()) - data = b"" - while b"ok" not in data: - chunk = await stream.receive() - data += chunk - logger.debug(f"Command {command} acknowledged with 'ok'") - - -async def _send_power_commands_sequence(serial, logger, commands): - """Send a sequence of power commands with delays""" - for command, delay in commands: - await _send_power_command(serial, logger, command) - if delay > 0: - await asyncio.sleep(delay) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/__init__.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/__init__.py new file mode 100644 index 000000000..377ab516f --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/__init__.py @@ -0,0 +1 @@ +"""QDL platform firmware flashing for Qualcomm automotive SoCs.""" diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/cache_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/cache_test.py new file mode 100644 index 000000000..1c24f7b9a --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/cache_test.py @@ -0,0 +1,106 @@ +import io +import tarfile +from pathlib import Path + +import yaml + +from jumpstarter_driver_ridesx.qdl.driver import QualcommFlasher +from jumpstarter_driver_ridesx.qdl.schema import ( + FirmwareData, + FirmwareManifest, + find_embedded_manifest, + load_firmware_manifest, +) + + +def test_cache_is_valid(tmp_path): + driver = QualcommFlasher.__new__(QualcommFlasher) + driver.work_dir = str(tmp_path) + manifest = load_firmware_manifest(Path(__file__).parent / "examples" / "manifests" / "es22.yaml") + firmware_root = driver._firmware_root(manifest) + assert not driver._cache_is_valid(firmware_root) + + firmware_root.mkdir(parents=True) + (firmware_root / "ufs").mkdir() + assert driver._cache_is_valid(firmware_root) + + +def test_cache_is_valid_empty_directory(tmp_path): + driver = QualcommFlasher.__new__(QualcommFlasher) + driver.work_dir = str(tmp_path) + manifest = FirmwareManifest(name="test", data=FirmwareData(folder="empty"), steps=[]) + firmware_root = driver._firmware_root(manifest) + firmware_root.mkdir(parents=True) + assert not driver._cache_is_valid(firmware_root) + + +def test_find_embedded_manifest(tmp_path): + nested = tmp_path / "release" + nested.mkdir() + (nested / "jumpstarter_manifest.yaml").write_text( + yaml.safe_dump( + { + "name": "embedded", + "data": {"folder": "release"}, + "steps": [], + } + ), + encoding="utf-8", + ) + + manifest_path = find_embedded_manifest(tmp_path) + assert manifest_path is not None + assert manifest_path.name == "jumpstarter_manifest.yaml" + assert load_firmware_manifest(manifest_path).name == "embedded" + + +def test_find_embedded_manifest_ignores_manifest_yaml(tmp_path): + nested = tmp_path / "release" + nested.mkdir() + (nested / "manifest.yaml").write_text( + yaml.safe_dump( + { + "name": "legacy", + "data": {"folder": "release"}, + "steps": [], + } + ), + encoding="utf-8", + ) + + assert find_embedded_manifest(tmp_path) is None + + +def test_resolve_manifest_from_embedded_jumpstarter_manifest(tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + manifest = { + "name": "embedded", + "data": {"folder": "r00002.2a_AWE"}, + "steps": [{"sleep": 1}], + } + (work_dir / "jumpstarter_manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + + driver = QualcommFlasher.__new__(QualcommFlasher) + resolved = driver._resolve_manifest(None, work_dir) + assert resolved.name == "embedded" + + +def test_load_manifest_from_archive_reads_jumpstarter_manifest(tmp_path): + manifest = { + "name": "archive embedded", + "data": {"folder": "r00002.2a_AWE"}, + "steps": [], + } + archive_path = tmp_path / "firmware.tar" + with tarfile.open(archive_path, "w") as archive: + payload = yaml.safe_dump(manifest).encode("utf-8") + info = tarfile.TarInfo(name="jumpstarter_manifest.yaml") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + + driver = QualcommFlasher.__new__(QualcommFlasher) + loaded = driver._load_manifest_from_archive(archive_path) + assert loaded is not None + assert loaded.name == "archive embedded" + assert loaded.data.folder == "r00002.2a_AWE" diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py new file mode 100644 index 000000000..6469684cd --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py @@ -0,0 +1,379 @@ +"""Qualcomm driver clients.""" + +from __future__ import annotations + +import hashlib +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +import click +import yaml +from jumpstarter_driver_composite.client import CompositeClient +from rich.console import Console, Group +from rich.live import Live +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + TransferSpeedColumn, +) +from rich.text import Text + +from .firmware_id import collect_version_info, format_version_report +from .schema import load_firmware_manifest_from_mapping +from jumpstarter.client.decorators import driver_click_group +from jumpstarter.client.flasher import ( + FlashPhase, + FlashStatus, + StreamingFlasherClient, + _http_url_adapter, + _local_file_adapter, + _parse_path, +) + + +def _load_manifest_source(source: str) -> dict[str, Any]: + if source.startswith(("http://", "https://")): + with urlopen(source) as response: + raw = yaml.safe_load(response.read().decode("utf-8")) + else: + raw = yaml.safe_load(Path(source).read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise click.ClickException("Manifest must be a YAML mapping") + manifest = load_firmware_manifest_from_mapping(raw) + return manifest.model_dump(mode="json") + + +def _manifest_data_from_source(manifest: Any | None) -> dict[str, Any] | None: + if manifest is None: + return None + if isinstance(manifest, dict): + return manifest + if isinstance(manifest, str): + return _load_manifest_source(manifest) + return load_firmware_manifest_from_mapping(manifest).model_dump(mode="json") + + + +class _FlashPanel: + """Live-updating panel for firmware flash progress.""" + + def __init__(self, console: Console) -> None: + self._console = console + self._overall = Progress( + SpinnerColumn(), + TextColumn("{task.description}"), + BarColumn(), + TaskProgressColumn(), + TimeElapsedColumn(), + console=console, + ) + self._download = Progress( + TextColumn(" "), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TimeElapsedColumn(), + console=console, + ) + self._overall_task = self._overall.add_task("[bold]Flashing firmware", total=None) + self._download_task = self._download.add_task("download", total=None, visible=False) + self._status_text = Text() + self._log_lines: list[Text] = [] + self._live = Live(self._build(), console=console, refresh_per_second=8) + + def _build(self) -> Group: + parts: list[Any] = list(self._log_lines) + parts.append(self._overall) + if self._download.tasks[self._download_task].visible: + parts.append(self._download) + if self._status_text: + parts.append(self._status_text) + return Group(*parts) + + def _log(self, text: Text) -> None: + self._log_lines.append(text) + + def _refresh(self) -> None: + self._live.update(self._build()) + + def start(self) -> None: + self._live.start() + + def stop(self) -> None: + self._live.stop() + + def update(self, status: FlashStatus) -> None: + if status.phase == FlashPhase.DOWNLOAD: + self._handle_download(status) + elif status.phase == FlashPhase.EXTRACT: + self._hide_download() + self._status_text = Text.assemble((" Extracting ", "yellow"), "firmware archive...") + elif status.phase == FlashPhase.CACHE: + self._hide_download() + self._status_text = Text.assemble((" Cache ", "cyan"), status.message) + elif status.phase == FlashPhase.STEP: + self._handle_step(status) + elif status.phase == FlashPhase.COMPLETE: + self._handle_complete(status) + self._refresh() + + def _handle_download(self, status: FlashStatus) -> None: + task = self._download.tasks[self._download_task] + if not task.visible: + self._download.update(self._download_task, visible=True) + self._overall.update(self._overall_task, description="[bold blue]Downloading and extracting firmware") + if status.bytes_total and task.total is None: + self._download.update(self._download_task, total=status.bytes_total) + if status.bytes_transferred is not None: + self._download.update(self._download_task, completed=status.bytes_transferred) + + def _hide_download(self) -> None: + if self._download.tasks[self._download_task].visible: + self._download.update(self._download_task, visible=False) + self._log(Text.assemble((" \u2714 ", "green"), "Download complete")) + + def _handle_step(self, status: FlashStatus) -> None: + self._hide_download() + if status.step_index is not None and status.total_steps is not None: + self._overall.update( + self._overall_task, + description="[bold]Flashing firmware", + completed=status.step_index, + total=status.total_steps, + ) + if status.message.startswith("Completed ") or status.message.startswith("Completed ABL"): + self._log(Text.assemble((" \u2714 ", "green"), status.message.removeprefix("Completed "))) + self._status_text = Text() + else: + label = status.message.removeprefix("Running ") + self._status_text = Text.assemble((" \u25b6 ", "blue"), label) + + def _handle_complete(self, status: FlashStatus) -> None: + self._overall.update(self._overall_task, description="[bold green]Done", completed=100, total=100) + self._status_text = Text() + self._log(Text.assemble(("\u2714 ", "bold green"), status.message)) + + +def _check_firmware(versions, variant, **expected_fields) -> list[str]: + """Compare detected firmware against expected values, return mismatches.""" + mismatches: list[str] = [] + + detected = versions.firmware_variant or "unknown" + if detected.upper() != variant.upper(): + mismatches.append(f"variant: expected {variant}, got {detected}") + + field_sources = { + "sail_fw_version": versions.sail_versions, + "qc_image_version": versions.main_versions, + "uefi_version": versions.main_versions, + "hypervisor": versions.main_versions, + "abl_build": versions.main_versions, + "cdt_platform_id": versions.main_versions, + } + for field_name, expected in expected_fields.items(): + if expected is None: + continue + source = field_sources.get(field_name, versions.main_versions) + actual = source.get(field_name) + if expected != actual: + mismatches.append(f"{field_name}: expected {expected!r}, got {actual!r}") + + return mismatches + + +def _register_check_command(group, client: QualcommFlasherClient) -> None: + @group.command() + @click.argument("variant") + @click.option("--sail-fw-version", type=str, help="Expected SAIL firmware version (e.g. 1.3.0)") + @click.option("--qc-image-version", type=str, help="Expected QC image version string") + @click.option("--uefi-version", type=str, help="Expected UEFI version string") + @click.option("--hypervisor", type=str, help="Expected hypervisor mode (prod, perf, debug)") + @click.option("--abl-build", type=str, help="Expected ABL build info string") + @click.option("--cdt-platform-id", type=str, help="Expected CDT platform ID") + def check(variant, sail_fw_version, qc_image_version, uefi_version, hypervisor, abl_build, cdt_platform_id): + """Check that firmware matches VARIANT and optional details. + + Exits 0 if all specified fields match, 1 if any mismatch. + """ + versions = client.identify() + mismatches = _check_firmware( + versions, variant, + sail_fw_version=sail_fw_version, + qc_image_version=qc_image_version, + uefi_version=uefi_version, + hypervisor=hypervisor, + abl_build=abl_build, + cdt_platform_id=cdt_platform_id, + ) + if mismatches: + click.echo("FAIL: firmware mismatch", err=True) + for m in mismatches: + click.echo(f" {m}", err=True) + click.echo("", err=True) + click.echo(format_version_report(versions), err=True) + from click.exceptions import Exit + + raise Exit(1) + + click.echo(f"OK: firmware matches {variant}") + + +def _flash_rich(client: QualcommFlasherClient, file, *, manifest, cached, force_download) -> None: + """Render flash progress using a live-updating rich panel.""" + console = Console(stderr=True) + panel = _FlashPanel(console) + panel.start() + try: + for status in client.flash_stream( + file, manifest=manifest, cached=cached, + force_download=force_download, + ): + panel.update(status) + finally: + panel.stop() + + +@dataclass(kw_only=True) +class QualcommFlasherClient(StreamingFlasherClient, CompositeClient): + """Client for Qualcomm QDL firmware flashing and identification.""" + + def _iter_flash_status( + self, + *, + handle: Any, + manifest: dict[str, Any] | None, + cached: bool, + source_id: str | None = None, + force_download: bool = False, + source_filename: str | None = None, + ): + for value in self.streamingcall( + "flash", handle, manifest, cached, source_id, force_download, source_filename, + ): + status = FlashStatus.model_validate(value) + yield status + if status.phase == FlashPhase.ERROR: + raise RuntimeError(status.message) + + def flash_stream( + self, + path, + *, + manifest: Any | None = None, + cached: bool = False, + force_download: bool = False, + compression=None, + ): + manifest_data = _manifest_data_from_source(manifest) + source_id = hashlib.sha256(str(path).encode()).hexdigest()[:12] + + local_path, url = _parse_path(path) + source_filename = str(path) + if url is not None: + with _http_url_adapter(client=self, url=url, mode="rb") as handle: + yield from self._iter_flash_status( + handle=handle, manifest=manifest_data, cached=cached, + source_id=source_id, + force_download=force_download, + source_filename=source_filename, + ) + else: + with _local_file_adapter(client=self, path=local_path, mode="rb", compression=compression) as handle: + yield from self._iter_flash_status( + handle=handle, manifest=manifest_data, cached=cached, + source_id=source_id, + force_download=force_download, + source_filename=source_filename, + ) + + def identify(self, *, verbose: bool = False, capture_dir: str | None = None): + for child in ("serial", "sail"): + if child not in self.children: + raise click.ClickException( + f"'{child}' child is required for firmware identification; add it to the exporter config" + ) + with self.serial.pexpect() as serial, self.sail.pexpect() as sail: + return collect_version_info( + serial, + sail, + lambda: self.call("power_cycle"), + verbose=verbose, + capture_dir=capture_dir, + ) + + def cli(self): + @driver_click_group(self) + def base(): + """Qualcomm firmware flasher""" + pass + + @base.command() + @click.argument("file", metavar="FILE|URL") + @click.option( + "--manifest", + type=str, + help=( + "Manifest YAML file or http(s) URL. " + "Optional when the archive contains jumpstarter_manifest.yaml." + ), + ) + @click.option( + "--cached", + is_flag=True, + help=( + "Reuse previously downloaded firmware if present on the exporter " + "and keep extracted files on disk after flashing." + ), + ) + @click.option( + "--force-download", + is_flag=True, + help="Force re-download even if cached firmware exists. Use with --cached.", + ) + def flash(file, manifest, cached, force_download): + """Flash firmware using QDL from a local path or http(s) URL""" + use_rich = sys.stderr.isatty() + try: + if use_rich: + _flash_rich( + self, file, manifest=manifest, cached=cached, + force_download=force_download, + ) + else: + for status in self.flash_stream( + file, manifest=manifest, cached=cached, + force_download=force_download, + ): + click.echo(self.render_flash_status(status)) + except (RuntimeError, ValueError, FileNotFoundError) as exc: + raise click.ClickException(str(exc)) from exc + + @base.command() + @click.option("-v", "--verbose", is_flag=True, help="Print progress messages") + @click.option("--capture", type=click.Path(), help="Directory to save raw serial logs") + def id(verbose, capture): + """Identify firmware variant and version strings""" + versions = self.identify(verbose=verbose, capture_dir=capture) + click.echo(format_version_report(versions)) + + _register_check_command(base, self) + + @base.command("boot-to-edl") + def boot_to_edl(): + """Boot the device into EDL mode""" + self.call("boot_to_edl") + + @base.command("boot-to-fastboot") + def boot_to_fastboot(): + """Boot the device into fastboot mode""" + self.call("boot_to_fastboot") + + return base diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client_test.py new file mode 100644 index 000000000..a89bc8601 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client_test.py @@ -0,0 +1,175 @@ +from unittest.mock import MagicMock, patch + +from jumpstarter_driver_ridesx.qdl.client import ( + QualcommFlasherClient, + _check_firmware, + _load_manifest_source, +) +from jumpstarter_driver_ridesx.qdl.firmware_id import VersionInfo + +MANIFEST_YAML = """ +name: "SA8775P ES22 AWE Firmware" +data: + folder: "r00002.2a_AWE" +steps: + - sleep: 1 +""" + + +def test_load_manifest_source_from_https_url(): + response = MagicMock() + response.read.return_value = MANIFEST_YAML.encode("utf-8") + response.__enter__.return_value = response + + with patch("jumpstarter_driver_ridesx.qdl.client.urlopen", return_value=response) as urlopen: + manifest = _load_manifest_source("https://example.com/manifests/es22.yaml") + + urlopen.assert_called_once_with("https://example.com/manifests/es22.yaml") + assert manifest["name"] == "SA8775P ES22 AWE Firmware" + assert manifest["data"]["folder"] == "r00002.2a_AWE" + + +def test_iter_flash_status_coerces_float_to_int(): + """Protobuf struct_pb2.Value stores all numbers as doubles, so integer + fields like bytes_transferred arrive as floats after gRPC round-tripping. + model_validate must accept them without strict mode.""" + client = QualcommFlasherClient.__new__(QualcommFlasherClient) + client.streamingcall = MagicMock( + return_value=iter( + [ + { + "phase": "download", + "message": "Received 450019 bytes", + "bytes_transferred": 450019.0, + "bytes_total": 2000000.0, + }, + {"phase": "complete", "message": "done"}, + ] + ) + ) + results = list(client._iter_flash_status(handle=None, manifest=None, cached=False)) + assert len(results) == 2 + assert results[0].bytes_transferred == 450019 + assert isinstance(results[0].bytes_transferred, int) + + +def test_flash_stream_uses_http_adapter_for_firmware_url(): + client = MagicMock() + client.flash_stream = QualcommFlasherClient.flash_stream.__get__(client, QualcommFlasherClient) + client._iter_flash_status = MagicMock(return_value=iter([])) + + with patch("jumpstarter_driver_ridesx.qdl.client._http_url_adapter") as http_adapter: + http_adapter.return_value.__enter__.return_value = {"url": "https://example.com/fw.tar.xz"} + http_adapter.return_value.__exit__.return_value = None + + list( + client.flash_stream( + "https://example.com/firmware/sx4-r00021.1a.tar.xz", + manifest={"name": "test", "data": {"folder": "fw"}, "steps": []}, + ) + ) + + http_adapter.assert_called_once_with( + client=client, + url="https://example.com/firmware/sx4-r00021.1a.tar.xz", + mode="rb", + ) + client._iter_flash_status.assert_called_once() + + +def test_flash_stream_uses_local_adapter_for_file_path(): + client = MagicMock() + client.flash_stream = QualcommFlasherClient.flash_stream.__get__(client, QualcommFlasherClient) + client._iter_flash_status = MagicMock(return_value=iter([])) + + with ( + patch("jumpstarter_driver_ridesx.qdl.client._local_file_adapter") as local_adapter, + patch("jumpstarter_driver_ridesx.qdl.client._http_url_adapter") as http_adapter, + ): + local_adapter.return_value.__enter__.return_value = "local-handle" + local_adapter.return_value.__exit__.return_value = None + + list(client.flash_stream("./firmware.tar.xz")) + + local_adapter.assert_called_once() + http_adapter.assert_not_called() + + +def test_flash_stream_loads_manifest_from_https_url(): + client = MagicMock() + client.flash_stream = QualcommFlasherClient.flash_stream.__get__(client, QualcommFlasherClient) + client._iter_flash_status = MagicMock(return_value=iter([])) + + response = MagicMock() + response.read.return_value = MANIFEST_YAML.encode("utf-8") + response.__enter__.return_value = response + + with ( + patch("jumpstarter_driver_ridesx.qdl.client.urlopen", return_value=response), + patch("jumpstarter_driver_ridesx.qdl.client._http_url_adapter") as http_adapter, + ): + http_adapter.return_value.__enter__.return_value = {"url": "https://example.com/fw.tar.xz"} + http_adapter.return_value.__exit__.return_value = None + + list( + client.flash_stream( + "https://example.com/fw.tar.xz", + manifest="https://example.com/es22.yaml", + ) + ) + + client._iter_flash_status.assert_called_once() + assert client._iter_flash_status.call_args.kwargs["manifest"]["name"] == "SA8775P ES22 AWE Firmware" + + +def _make_version_info(**overrides): + defaults = { + "firmware_variant": "ES22", + "sail_versions": {"sail_fw_version": "1.3.0", "platform_type": "RIDE_SX"}, + "main_versions": { + "qc_image_version": "BOOT.MXF.1.2-00541-LEMANS-1", + "uefi_version": "6.0.250710.BOOT.MXF.1.2-00541-LEMANS-1", + "hypervisor": "prod", + "abl_build": "Mar 3 2026 13:24:15", + "cdt_platform_id": "37", + }, + } + defaults.update(overrides) + return VersionInfo(**defaults) + + +def test_check_firmware_all_match(): + versions = _make_version_info() + mismatches = _check_firmware(versions, "ES22", hypervisor="prod", sail_fw_version="1.3.0") + assert mismatches == [] + + +def test_check_firmware_variant_mismatch(): + versions = _make_version_info() + mismatches = _check_firmware(versions, "CS4") + assert len(mismatches) == 1 + assert "variant" in mismatches[0] + + +def test_check_firmware_variant_case_insensitive(): + versions = _make_version_info() + assert _check_firmware(versions, "es22") == [] + + +def test_check_firmware_field_mismatch(): + versions = _make_version_info() + mismatches = _check_firmware(versions, "ES22", hypervisor="debug", cdt_platform_id="99") + assert len(mismatches) == 2 + assert any("hypervisor" in m for m in mismatches) + assert any("cdt_platform_id" in m for m in mismatches) + + +def test_check_firmware_none_fields_ignored(): + versions = _make_version_info() + mismatches = _check_firmware( + versions, "ES22", + sail_fw_version=None, qc_image_version=None, hypervisor=None, + ) + assert mismatches == [] + + diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py new file mode 100644 index 000000000..48001a4bd --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py @@ -0,0 +1,486 @@ +"""Qualcomm QDL flasher and firmware identification drivers.""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from ..tac import send_tac_sequence +from .executor import execute_manifest, resolve_firmware_root +from .schema import ( + EMBEDDED_MANIFEST_NAMES, + FirmwareManifest, + find_embedded_manifest, + load_firmware_manifest, + load_firmware_manifest_from_mapping, +) +from .soc_profiles import SoCType, get_soc_profile +from jumpstarter.client.flasher import FlashPhase, FlashStatus +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver import Driver, export +from jumpstarter.driver.flasher import StreamingFlasherInterface +from jumpstarter.streams.progress import ProgressAttribute + +logger = logging.getLogger(__name__) + + +@dataclass +class _FlashContext: + work_dir: Path | None = None + firmware_root: Path | None = None + manifest: FirmwareManifest | None = None + temp_work_dir: Path | None = None + cache_dir: Path | None = None + + +@dataclass(kw_only=True) +class QualcommFlasher(StreamingFlasherInterface, Driver): + """Qualcomm firmware flasher and identification driver using QDL and fastboot.""" + soc_type: SoCType = "sa8775p" + work_dir: str = field(default="/var/lib/jumpstarter/qualcomm") + qdl_timeout: int = field(default=30 * 60) + fastboot_timeout: int = field(default=10 * 60) + board_revision: str | None = field( + default=None, + metadata={"help": "Optional board revision (v1, v2, v3, v4) for CDT image selection"}, + ) + power_cycle_delay: float = field( + default=2.0, + metadata={"help": "Delay between power off and on when identifying firmware (seconds)"}, + ) + tac_command_timeout: float = field( + default=10.0, + metadata={"help": "Timeout for each TAC serial command acknowledgement (seconds)"}, + ) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + if "tac" not in self.children: + raise ConfigurationError("'tac' child is required for QualcommFlasher") + Path(self.work_dir).mkdir(parents=True, exist_ok=True) + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_ridesx.qdl.client.QualcommFlasherClient" + + @property + def profile(self): + return get_soc_profile(self.soc_type) + + async def _set_mode(self, mode: str, check_dmesg: str | None = None) -> None: + from .executor import set_device_mode + + await set_device_mode( + tac=self.children["tac"], + profile=self.profile, + mode=mode, + check=check_dmesg, + tac_timeout=self.tac_command_timeout, + ) + + @export + async def boot_to_edl(self) -> None: + await self._set_mode("edl") + + @export + async def boot_to_fastboot(self) -> None: + await self._set_mode("fastboot") + + @export + async def power_cycle(self) -> None: + tac = self.children["tac"] + await send_tac_sequence(tac, self.profile.power_off_commands, timeout=self.tac_command_timeout) + await asyncio.sleep(self.power_cycle_delay) + await send_tac_sequence(tac, self.profile.power_on_commands, timeout=self.tac_command_timeout) + + @staticmethod + def _validate_member(member: tarfile.TarInfo, extract_root: Path) -> None: + """Validate a tar member for path traversal and special files (Python <3.12).""" + if member.name.startswith("/"): + raise tarfile.ExtractError(f"Blocked absolute path in archive: {member.name}") + if member.isdev() or member.issym() or member.islnk(): + raise tarfile.ExtractError(f"Blocked special file in archive: {member.name}") + destination = extract_root.resolve() + target = (destination / member.name).resolve() + try: + target.relative_to(destination) + except ValueError as exc: + raise tarfile.ExtractError( + f"Blocked path traversal in archive: {member.name}" + ) from exc + + @staticmethod + def _safe_extractall(archive: tarfile.TarFile, extract_root: Path) -> None: + if sys.version_info >= (3, 12): + archive.extractall(path=extract_root, filter="data") + return + destination = extract_root.resolve() + for member in archive.getmembers(): + QualcommFlasher._validate_member(member, destination) + archive.extractall(path=extract_root) + + @staticmethod + def _ensure_firmware_root(work_dir: Path, manifest: FirmwareManifest) -> None: + """Ensure firmware files end up under ``work_dir/``. + + After extraction, if the expected folder already exists, the tarball + was already structured correctly. Otherwise the tarball has files at + the root — move them into the folder so ``resolve_firmware_root`` + finds them. + """ + firmware_root = work_dir / manifest.data.folder + if firmware_root.is_dir(): + logger.debug("firmware root %s already exists after extraction", firmware_root) + return + logger.info("Wrapping extracted files into %s", firmware_root) + firmware_root.mkdir(parents=True, exist_ok=True) + for entry in work_dir.iterdir(): + if entry == firmware_root: + continue + entry.rename(firmware_root / entry.name) + + def _extract_archive(self, archive_path: Path, work_dir: Path, manifest: FirmwareManifest | None) -> None: + with tarfile.open(archive_path, mode="r:*") as archive: + self._safe_extractall(archive, work_dir) + if manifest: + self._ensure_firmware_root(work_dir, manifest) + + def _load_manifest_from_archive(self, archive_path: Path) -> FirmwareManifest | None: + with tarfile.open(archive_path, mode="r:*") as archive: + for filename in EMBEDDED_MANIFEST_NAMES: + for member in archive.getmembers(): + if not member.isfile() or Path(member.name).name != filename: + continue + extracted = archive.extractfile(member) + if extracted is None: + continue + raw = yaml.safe_load(extracted.read().decode("utf-8")) + if isinstance(raw, dict): + return load_firmware_manifest_from_mapping(raw) + return None + + def _resolve_manifest(self, manifest_data: dict | None, work_dir: Path) -> FirmwareManifest: + if manifest_data: + return load_firmware_manifest_from_mapping(manifest_data) + embedded = find_embedded_manifest(work_dir) + if embedded is not None: + return load_firmware_manifest(embedded) + raise FileNotFoundError( + "No manifest provided and jumpstarter_manifest.yaml not found in extracted firmware" + ) + + def _firmware_root(self, manifest: FirmwareManifest) -> Path: + return resolve_firmware_root(Path(self.work_dir), manifest) + + def _cache_is_valid(self, firmware_root: Path) -> bool: + return firmware_root.is_dir() and any(firmware_root.iterdir()) + + def _cache_work_dir(self, source_id: str | None) -> Path: + """Return the cache directory for a given source, namespaced by source_id.""" + base = Path(self.work_dir) + if source_id: + return base / source_id + return base + + async def _prepare_cached_flash( + self, + source: Any, + manifest_data: dict | None, + ctx: _FlashContext, + *, + source_id: str | None = None, + force_download: bool = False, + source_filename: str | None = None, + ) -> AsyncGenerator[FlashStatus, None]: + work_dir = self._cache_work_dir(source_id) + work_dir.mkdir(parents=True, exist_ok=True) + + manifest = load_firmware_manifest_from_mapping(manifest_data) if manifest_data else None + + if force_download: + # Remove existing cache to force a fresh download. + if manifest is not None: + firmware_root = resolve_firmware_root(work_dir, manifest) + if firmware_root.exists(): + logger.info("Force download: removing cached firmware at %s", firmware_root) + shutil.rmtree(firmware_root, ignore_errors=True) + else: + # Without a manifest we don't know the folder name, clear the whole work_dir. + logger.info("Force download: clearing cache at %s", work_dir) + shutil.rmtree(work_dir, ignore_errors=True) + work_dir.mkdir(parents=True, exist_ok=True) + + # When a manifest is provided we can check the cache immediately. + if manifest is not None: + firmware_root = resolve_firmware_root(work_dir, manifest) + if self._cache_is_valid(firmware_root): + ctx.work_dir = work_dir + ctx.firmware_root = firmware_root + ctx.manifest = manifest + ctx.cache_dir = firmware_root + yield FlashStatus( + phase=FlashPhase.CACHE, + message=f"Using cached firmware at {firmware_root}", + ) + return + + # Need to download — either to populate the cache or to discover + # the embedded manifest. + if source is None: + if manifest is not None: + firmware_root = resolve_firmware_root(work_dir, manifest) + raise FileNotFoundError( + f"No cached firmware at {firmware_root}; provide a firmware archive to populate the cache" + ) + raise ValueError("firmware source is required when no manifest is provided") + + async for status in self._download_and_extract(source, work_dir, manifest, source_filename=source_filename): + yield status + + # If no manifest was provided, discover it from the extracted archive. + if manifest is None: + manifest = self._resolve_manifest(None, work_dir) + + firmware_root = resolve_firmware_root(work_dir, manifest) + ctx.work_dir = work_dir + ctx.firmware_root = firmware_root + ctx.manifest = manifest + ctx.cache_dir = firmware_root + yield FlashStatus( + phase=FlashPhase.CACHE, + message=f"Cached firmware at {firmware_root}", + ) + + async def _prepare_ephemeral_flash( + self, + source: Any, + manifest_data: dict | None, + ctx: _FlashContext, + source_filename: str | None = None, + ) -> AsyncGenerator[FlashStatus, None]: + if source is None: + raise ValueError("firmware source is required") + temp_work_dir = Path(tempfile.mkdtemp(prefix="qcom-flash-", dir=self.work_dir)) + ctx.temp_work_dir = temp_work_dir + ctx.work_dir = temp_work_dir + manifest_hint = load_firmware_manifest_from_mapping(manifest_data) if manifest_data else None + async for status in self._download_and_extract( + source, temp_work_dir, manifest_hint, source_filename=source_filename, + ): + yield status + ctx.manifest = self._resolve_manifest(manifest_data, temp_work_dir) + ctx.firmware_root = resolve_firmware_root(temp_work_dir, ctx.manifest) + + @staticmethod + def _build_tar_cmd(extract_root: Path, decompress_flag: str | None) -> list[str]: + """Build the tar command with the appropriate decompression flag.""" + if decompress_flag and decompress_flag.startswith("--"): + return ["tar", decompress_flag, "-xf", "-", "-C", str(extract_root)] + elif decompress_flag: + return ["tar", f"-x{decompress_flag.lstrip('-')}f", "-", "-C", str(extract_root)] + return ["tar", "-xf", "-", "-C", str(extract_root)] + + @staticmethod + def _detect_compression(header: bytes) -> str | None: + """Detect compression format from the first bytes of a stream. + + Returns the tar flag (-J, -z, -j, --zstd) or None for plain tar. + This is more reliable than filename-based detection since files + may have misleading extensions. + """ + if header[:6] == b"\xfd7zXZ\x00": # xz magic + return "-J" + if header[:2] == b"\x1f\x8b": # gzip magic + return "-z" + if header[:3] == b"BZh": # bzip2 magic + return "-j" + if header[:4] == b"\x28\xb5\x2f\xfd": # zstd magic + return "--zstd" + return None + + @staticmethod + def _start_tar(header: bytes, extract_root: Path) -> subprocess.Popen: + """Start a tar subprocess with compression detected from magic bytes.""" + decompress_flag = QualcommFlasher._detect_compression(header) + tar_cmd = QualcommFlasher._build_tar_cmd(extract_root, decompress_flag) + logger.info("Running: %s", " ".join(tar_cmd)) + return subprocess.Popen( + tar_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + @staticmethod + def _finish_tar(tar_proc: subprocess.Popen, chunk_count: int, bytes_received: int) -> None: + """Wait for tar to finish and raise on failure.""" + _, stderr_bytes = tar_proc.communicate() + logger.info( + "tar finished: rc=%s, chunks=%d, bytes=%d, stderr=%s", + tar_proc.returncode, chunk_count, bytes_received, + stderr_bytes.decode().strip() or "(empty)", + ) + if tar_proc.returncode != 0: + raise RuntimeError( + f"tar extraction failed (rc={tar_proc.returncode}): {stderr_bytes.decode().strip()}" + ) + + class _StreamToTarResult: + bytes_received: int = 0 + + @staticmethod + async def _write_to_tar(proc: subprocess.Popen, data: bytes) -> bool: + """Write data to tar stdin; returns False on BrokenPipeError.""" + assert proc.stdin is not None, "tar process must be started with stdin=PIPE" + try: + await asyncio.to_thread(proc.stdin.write, data) + return True + except BrokenPipeError: + return False + + async def _stream_to_tar( + self, + res: Any, + extract_root: Path, + bytes_total: int | None, + result: _StreamToTarResult, + ) -> AsyncGenerator[FlashStatus, None]: + """Pipe a resource stream into tar, detecting compression from magic bytes.""" + tar_proc: subprocess.Popen | None = None + write_buf = bytearray() + WRITE_THRESHOLD = 1024 * 1024 # 1 MB + last_update = time.monotonic() + try: + async for chunk in res: + write_buf.extend(chunk) + result.bytes_received += len(chunk) + if tar_proc is None: + tar_proc = self._start_tar(bytes(write_buf[:6]), extract_root) + if len(write_buf) >= WRITE_THRESHOLD: + if not await self._write_to_tar(tar_proc, bytes(write_buf)): + break + write_buf.clear() + now = time.monotonic() + if now - last_update >= 0.5: + last_update = now + yield FlashStatus( + phase=FlashPhase.DOWNLOAD, + message="Downloading and extracting firmware", + bytes_transferred=result.bytes_received, + bytes_total=bytes_total, + ) + if write_buf and tar_proc is not None: + await self._write_to_tar(tar_proc, bytes(write_buf)) + finally: + if tar_proc is not None: + try: + tar_proc.stdin.close() + except BrokenPipeError: + pass + + if tar_proc is None: + raise RuntimeError("No data received from firmware source") + self._finish_tar(tar_proc, result.bytes_received // WRITE_THRESHOLD, result.bytes_received) + + async def _download_and_extract( + self, + source: Any, + work_dir: Path, + manifest: FirmwareManifest | None, + source_filename: str | None = None, + ) -> AsyncGenerator[FlashStatus, None]: + yield FlashStatus(phase=FlashPhase.DOWNLOAD, message="Downloading and extracting firmware", progress=0.0) + logger.info("Streaming firmware archive to %s", work_dir) + bytes_received = 0 + + extract_root = work_dir + + async with self.resource(source) as res: + bytes_total = int(res.extra(ProgressAttribute.total)) if res.extra(ProgressAttribute.total, None) else None + result = self._StreamToTarResult() + async for status in self._stream_to_tar(res, extract_root, bytes_total, result): + yield status + bytes_received = result.bytes_received + + logger.info("Download and extraction complete: %d bytes received", bytes_received) + if manifest: + self._ensure_firmware_root(work_dir, manifest) + yield FlashStatus( + phase=FlashPhase.DOWNLOAD, + message="Download and extraction complete", + bytes_transferred=bytes_received, + bytes_total=bytes_total, + ) + + async def _run_manifest_flash( + self, + manifest: FirmwareManifest, + firmware_root: Path, + ) -> AsyncGenerator[FlashStatus, None]: + async for status in execute_manifest( + manifest, + tac=self.children["tac"], + profile=self.profile, + firmware_root=firmware_root, + qdl_timeout=self.qdl_timeout, + fastboot_timeout=self.fastboot_timeout, + tac_timeout=self.tac_command_timeout, + board_revision=self.board_revision, + ): + yield status + + yield FlashStatus(phase=FlashPhase.COMPLETE, message=f"Firmware update complete: {manifest.name}") + + @export + async def flash( + self, + source: Any, + manifest_data: dict | None = None, + cached: bool = False, + source_id: str | None = None, + force_download: bool = False, + source_filename: str | None = None, + ) -> AsyncGenerator[FlashStatus, None]: + logger.info( + "flash() called: cached=%s, source_id=%r, force_download=%s, manifest_data=%s, source_filename=%r", + cached, source_id, force_download, bool(manifest_data), source_filename, + ) + ctx = _FlashContext() + try: + if cached: + async for status in self._prepare_cached_flash( + source, manifest_data, ctx, + source_id=source_id, force_download=force_download, + source_filename=source_filename, + ): + yield status + else: + async for status in self._prepare_ephemeral_flash( + source, manifest_data, ctx, source_filename=source_filename, + ): + yield status + + assert ctx.manifest is not None + assert ctx.firmware_root is not None + async for status in self._run_manifest_flash( + ctx.manifest, ctx.firmware_root, + ): + yield status + except Exception as exc: + yield FlashStatus(phase=FlashPhase.ERROR, message=str(exc)) + if ctx.cache_dir is not None and not self._cache_is_valid(ctx.cache_dir): + shutil.rmtree(ctx.cache_dir, ignore_errors=True) + return + finally: + if ctx.temp_work_dir is not None: + shutil.rmtree(ctx.temp_work_dir, ignore_errors=True) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py new file mode 100644 index 000000000..597e08603 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py @@ -0,0 +1,102 @@ +import tarfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from jumpstarter_driver_ridesx.qdl.driver import QualcommFlasher +from jumpstarter_driver_ridesx.qdl.executor import execute_manifest +from jumpstarter_driver_ridesx.qdl.schema import ( + SleepStep, + load_firmware_manifest, +) +from jumpstarter_driver_ridesx.qdl.soc_profiles import SA8775P + +from jumpstarter.client.flasher import FlashPhase +from jumpstarter.common.exceptions import ConfigurationError + + +@pytest.mark.asyncio +async def test_execute_manifest_sleep_step(): + manifest = load_firmware_manifest(Path(__file__).parent / "examples" / "manifests" / "es22.yaml") + manifest = manifest.model_copy(update={"steps": [SleepStep(sleep=0)]}) + tac = MagicMock() + statuses = [ + status + async for status in execute_manifest( + manifest, + tac=tac, + profile=SA8775P, + firmware_root=Path("/tmp/firmware"), + qdl_timeout=1, + fastboot_timeout=1, + tac_timeout=1, + ) + ] + assert statuses[0].phase == FlashPhase.STEP + assert statuses[0].step_name == "sleep 0s" + + +def test_qualcomm_flasher_requires_tac_child(): + with pytest.raises(ConfigurationError, match="tac"): + QualcommFlasher(children={}) + + +@pytest.mark.asyncio +async def test_qualcomm_flasher_power_cycle(tmp_path): + mock_tac = MagicMock() + stream = AsyncMock() + stream.__aenter__ = AsyncMock(return_value=stream) + stream.__aexit__ = AsyncMock(return_value=None) + stream.receive = AsyncMock(return_value=b"ok") + mock_tac.connect.return_value = stream + + driver = QualcommFlasher(children={"tac": mock_tac}, work_dir=str(tmp_path)) + await driver.power_cycle() + assert stream.send.await_count > 0 + + +@pytest.mark.asyncio +async def test_qualcomm_flasher_boot_to_edl(tmp_path): + mock_tac = MagicMock() + stream = AsyncMock() + stream.__aenter__ = AsyncMock(return_value=stream) + stream.__aexit__ = AsyncMock(return_value=None) + stream.receive = AsyncMock(return_value=b"ok") + mock_tac.connect.return_value = stream + + driver = QualcommFlasher(children={"tac": mock_tac}, work_dir=str(tmp_path)) + await driver.boot_to_edl() + assert stream.send.await_count > 0 + + +@pytest.mark.parametrize( + "header, expected", + [ + (b"\xfd7zXZ\x00", "-J"), # xz + (b"\x1f\x8b\x08\x00\x00\x00", "-z"), # gzip + (b"BZh91AY&SY", "-j"), # bzip2 + (b"\x28\xb5\x2f\xfd\x04\x00", "--zstd"), # zstd + (b"\x00\x00\x00\x00\x00\x00", None), # unknown / plain tar + ], +) +def test_detect_compression(header, expected): + assert QualcommFlasher._detect_compression(header) == expected + + +def test_safe_extractall_rejects_path_traversal(tmp_path): + archive_path = tmp_path / "bad.tar" + extract_root = tmp_path / "extract" + extract_root.mkdir() + with tarfile.open(archive_path, "w") as archive: + info = tarfile.TarInfo(name="../escape.txt") + info.size = 4 + import io + + archive.addfile(info, io.BytesIO(b"evil")) + + with tarfile.open(archive_path, "r") as archive: + # Python 3.12+ filter="data" raises OutsideDestinationError (a FilterError), + # while our 3.11 fallback raises ExtractError. Both are subclasses of TarError. + with pytest.raises(tarfile.TarError): + QualcommFlasher._safe_extractall(archive, extract_root) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs4.yaml b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs4.yaml new file mode 100644 index 000000000..308d146aa --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs4.yaml @@ -0,0 +1,61 @@ +name: "SA8650P CS4 Firmware" +description: "CS4 (8650 ADAS) QDL flash workflow" +data: + folder: "r00010.1" +steps: + - set_mode: edl + check_dmesg: "qcserial" + + - sleep: 5 + + - name: "UFS provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + + - sleep: 10 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + + - sleep: 30 + + - set_mode: fastboot + check_dmesg: "Product: Android" + + - name: "Flash ABL" + fastboot: + flash: + - partition: abl_a + file: qam8650p_abl_signed.elf + - partition: abl_b + file: qam8650p_abl_signed.elf + + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs5.yaml b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs5.yaml new file mode 100644 index 000000000..165babe85 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/cs5.yaml @@ -0,0 +1,61 @@ +name: "SA8650P CS5 Firmware" +description: "CS5 (8650 ADAS) QDL flash workflow" +data: + folder: "r00011.1" +steps: + - set_mode: edl + check_dmesg: "qcserial" + + - sleep: 5 + + - name: "UFS provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + + - sleep: 10 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + + - sleep: 30 + + - set_mode: fastboot + check_dmesg: "Product: Android" + + - name: "Flash ABL" + fastboot: + flash: + - partition: abl_a + file: qam8650p_abl_signed.elf + - partition: abl_b + file: qam8650p_abl_signed.elf + + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es13.yaml b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es13.yaml new file mode 100644 index 000000000..fc5f8a2c3 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es13.yaml @@ -0,0 +1,75 @@ +name: "SA8775P ES13 Firmware" +description: "ES13 firmware update package (QDL)" +data: + folder: "r00022.2a" +steps: + - set_mode: edl + check_dmesg: "qcserial" + + - name: "UFS Provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + + - sleep: 5 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + + - sleep: 10 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash SPINOR" + retry_mode: edl + qdl: + storage: spinor + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + + - sleep: 30 + + - set_mode: fastboot + check_dmesg: "Product: Android" + + - name: "Flash perf hypervisor" + fastboot: + erase: + - hyp_a + - hyp_b + flash: + - partition: hyp_a + file: ufs/hypvmperformance.mbn + - partition: hyp_b + file: ufs/hypvmperformance.mbn + + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es21.yaml b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es21.yaml new file mode 100644 index 000000000..3450e6885 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es21.yaml @@ -0,0 +1,72 @@ +name: "SA8775P ES21 Firmware" +description: "ES21 firmware update package (QDL)" +data: + folder: "r00001.1" +steps: + - set_mode: edl + check_dmesg: "qcserial" + + - name: "UFS Provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + + - sleep: 5 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + + - sleep: 10 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash SPINOR" + retry_mode: edl + qdl: + storage: spinor + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + + - sleep: 30 + + - set_mode: fastboot + check_dmesg: "Product: Android" + + - name: "Flash ABL" + fastboot: + flash: + - partition: abl_a + file: sa8775p_es21_abl_signed.elf + - partition: abl_b + file: sa8775p_es21_abl_signed.elf + + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es22.yaml b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es22.yaml new file mode 100644 index 000000000..9922ffda7 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/examples/manifests/es22.yaml @@ -0,0 +1,72 @@ +name: "SA8775P ES22 AWE Firmware" +description: "ES22 AWE firmware update package (QDL)" +data: + folder: "r00002.2a_AWE" +steps: + - set_mode: edl + check_dmesg: "qcserial" + + - name: "UFS Provisioning" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - provision_default.xml + + - sleep: 5 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash UFS" + retry_mode: edl + qdl: + storage: ufs + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + - "patch*.xml" + + - sleep: 10 + + - set_mode: edl + check_dmesg: "qcserial" + + - name: "Flash SPINOR" + retry_mode: edl + qdl: + storage: spinor + programmer: prog_firehose_ddr.elf + files: + - "rawprogram*.xml" + + - sleep: 30 + + - set_mode: fastboot + check_dmesg: "Product: Android" + + - name: "Flash ABL" + fastboot: + flash: + - partition: abl_a + file: sa8775p_es21_abl_signed.elf + - partition: abl_b + file: sa8775p_es21_abl_signed.elf + + - name: "Flash CDT" + fastboot: + flash: + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v1 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.1.0.bin + revision: v2 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v3 + - partition: cdt + file: ufs/LEMANSAU_QAM_1.2.0.bin + revision: v4 + continue: true diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor.py new file mode 100644 index 000000000..958559448 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor.py @@ -0,0 +1,397 @@ +"""Manifest step execution helpers for Qualcomm QDL flashing.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import logging +import subprocess +from pathlib import Path + +from ..tac import send_tac_sequence +from .schema import ( + FastbootFlashOp, + FastbootStep, + FirmwareManifest, + QdlStep, + SetModeStep, + SleepStep, + Step, + normalize_revision, +) +from .soc_profiles import SoCProfile +from jumpstarter.client.flasher import FlashPhase, FlashStatus + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class StepResult: + """Collected stdout/stderr from one or more subprocess calls in a step.""" + + stdout: str = "" + stderr: str = "" + +RETRY_MODE_DMESG = { + "edl": "qcserial", + "fastboot": "Product: Android", +} + + +def read_dmesg() -> str: + result = subprocess.run(["dmesg"], capture_output=True, text=True, check=False) + return result.stdout + + +def check_dmesg(expected: str, *, baseline: str | None = None, tail_lines: int = 200) -> None: + """Check kernel ring buffer for an expected marker. + + When *baseline* is provided, only lines **not** in the baseline are + inspected (diff-based check) and the function raises if the marker + is not found among the new lines. This prevents false positives + from stale markers left by previous operations. + + Without a baseline the last *tail_lines* lines are searched. On + systems with very high dmesg throughput (USB enumeration storms, + etc.) the default 200 lines may scroll past the marker; increase + *tail_lines* if this becomes an issue. + """ + output = read_dmesg() + if baseline is not None: + baseline_lines = set(baseline.splitlines()) + for line in output.splitlines(): + if line not in baseline_lines and expected in line: + return + raise RuntimeError(f"Expected dmesg marker '{expected}' not found in new kernel log entries") + tail = "\n".join(output.splitlines()[-tail_lines:]) + if expected in tail: + return + raise RuntimeError(f"Expected dmesg marker '{expected}' not found in recent kernel log") + + +def fix_provision_default_xml(workdir: Path) -> None: + provision = workdir / "provision_default.xml" + if not provision.exists(): + return + content = provision.read_text(encoding="utf-8", errors="replace") + if content.lstrip().startswith(" tuple[list[str], Path]: + config = step.qdl + workdir_name = config.workdir or config.storage + workdir = firmware_root / workdir_name + programmer = workdir / config.programmer + cmd = ["qdl", "-s", config.storage, str(programmer)] + for pattern in config.files: + if any(char in pattern for char in "*?[]"): + matches = sorted(workdir.glob(pattern)) + if not matches: + raise FileNotFoundError(f"No QDL files matched pattern '{pattern}' in {workdir}") + cmd.extend(str(path) for path in matches) + else: + path = workdir / pattern + if not path.exists(): + raise FileNotFoundError(f"QDL file not found: {path}") + cmd.append(str(path)) + return cmd, workdir + + +def run_qdl_step(step: QdlStep, firmware_root: Path, *, timeout: int) -> subprocess.CompletedProcess[str]: + if any("provision_default.xml" in pattern for pattern in step.qdl.files): + fix_provision_default_xml(firmware_root / (step.qdl.workdir or step.qdl.storage)) + cmd, workdir = build_qdl_command(step, firmware_root) + logger.info("Running QDL: %s (cwd=%s)", " ".join(cmd), workdir) + result = subprocess.run( + cmd, + cwd=workdir, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + logger.info("QDL finished: returncode=%d", result.returncode) + return result + + +def _should_flash(op: FastbootFlashOp, board_revision: str | None) -> bool: + """Check whether a flash operation should execute based on board revision.""" + if op.revision is None: + return True + if board_revision is None: + raise ValueError( + f"Board revision is required to flash {op.partition} " + f"(revision={op.revision}). " + f"Use --board-revision or set board_revision in the driver config." + ) + return normalize_revision(op.revision) == normalize_revision(board_revision) + + +def _run_fastboot_flash( + operations: list[FastbootFlashOp], + firmware_root: Path, + *, + timeout: int, + board_revision: str | None, + collected: StepResult, +) -> None: + for operation in operations: + if not _should_flash(operation, board_revision): + logger.info( + "Skipping flash %s (revision %s, board is %s)", + operation.partition, operation.revision, board_revision, + ) + continue + image_path = firmware_root / operation.file + if not image_path.exists(): + raise FileNotFoundError(f"Fastboot image not found: {image_path}") + logger.info("fastboot flash %s %s", operation.partition, image_path.name) + result = subprocess.run( + ["fastboot", "flash", operation.partition, str(image_path)], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + collected.stdout += result.stdout + collected.stderr += result.stderr + if result.returncode != 0: + raise RuntimeError( + f"fastboot flash {operation.partition} failed: {result.stderr or result.stdout}" + ) + + +def run_fastboot_step( + step: FastbootStep, + firmware_root: Path, + *, + timeout: int, + board_revision: str | None = None, +) -> StepResult: + config = step.fastboot + collected = StepResult() + + if config.erase: + for partition in config.erase: + logger.info("fastboot erase %s", partition) + result = subprocess.run( + ["fastboot", "erase", partition], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + collected.stdout += result.stdout + collected.stderr += result.stderr + if result.returncode != 0: + raise RuntimeError(f"fastboot erase {partition} failed: {result.stderr or result.stdout}") + + if config.flash: + _run_fastboot_flash( + config.flash, firmware_root, + timeout=timeout, board_revision=board_revision, collected=collected, + ) + + if config.continue_: + logger.info("fastboot continue") + result = subprocess.run( + ["fastboot", "continue"], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + collected.stdout += result.stdout + collected.stderr += result.stderr + if result.returncode != 0: + raise RuntimeError(f"fastboot continue failed: {result.stderr or result.stdout}") + + return collected + + +async def _poll_dmesg( + marker: str, + baseline: str, + *, + timeout: float, + interval: float, + mode: str, +) -> None: + """Poll dmesg for *marker* among lines added after *baseline*.""" + logger.info("Waiting for device in %s mode (marker: %s)", mode, marker) + elapsed = 0.0 + while True: + await asyncio.sleep(interval) + elapsed += interval + try: + check_dmesg(marker, baseline=baseline) + logger.info("Device entered %s mode (%.0fs)", mode, elapsed) + return + except RuntimeError: + if elapsed >= timeout: + logger.warning("Device did not enter %s mode after %.0fs", mode, elapsed) + raise + + +async def set_device_mode( + *, + tac, + profile: SoCProfile, + mode: str, + check: str | None, + tac_timeout: float, + check_timeout: float = 30.0, + check_interval: float = 2.0, +) -> None: + logger.info("Entering %s mode", mode) + baseline = read_dmesg() if check else None + if mode == "edl": + await send_tac_sequence(tac, profile.edl_commands, timeout=tac_timeout) + elif mode == "fastboot": + await send_tac_sequence(tac, profile.fastboot_commands, timeout=tac_timeout) + else: + raise ValueError(f"Unsupported mode: {mode}") + if check and baseline is not None: + await _poll_dmesg( + check, baseline, timeout=check_timeout, interval=check_interval, + mode=mode, + ) + + +def step_label(step: Step) -> str: + if step.name: + return step.name + if isinstance(step, SetModeStep): + return f"set_mode {step.set_mode}" + if isinstance(step, SleepStep): + return f"sleep {step.sleep}s" + if isinstance(step, QdlStep): + return f"qdl {step.qdl.storage}" + if isinstance(step, FastbootStep): + return "fastboot" + return "step" + + +async def execute_step( + step: Step, + *, + tac, + profile: SoCProfile, + firmware_root: Path, + qdl_timeout: int, + fastboot_timeout: int, + tac_timeout: float, + board_revision: str | None = None, +) -> StepResult | None: + if isinstance(step, SetModeStep): + await set_device_mode( + tac=tac, + profile=profile, + mode=step.set_mode, + check=step.check_dmesg, + tac_timeout=tac_timeout, + ) + return None + if isinstance(step, SleepStep): + await asyncio.sleep(step.sleep) + return None + if isinstance(step, QdlStep): + result = await asyncio.to_thread(run_qdl_step, step, firmware_root, timeout=qdl_timeout) + if result.returncode != 0: + raise RuntimeError(f"QDL failed ({result.returncode}): {result.stderr or result.stdout}") + return StepResult(stdout=result.stdout, stderr=result.stderr) + if isinstance(step, FastbootStep): + return await asyncio.to_thread( + run_fastboot_step, step, firmware_root, timeout=fastboot_timeout, + board_revision=board_revision, + ) + raise TypeError(f"Unsupported step type: {type(step)!r}") + + +async def execute_manifest( + manifest: FirmwareManifest, + *, + tac, + profile: SoCProfile, + firmware_root: Path, + qdl_timeout: int, + fastboot_timeout: int, + tac_timeout: float, + board_revision: str | None = None, + max_attempts: int = 3, +): + total_steps = len(manifest.steps) + for index, step in enumerate(manifest.steps, start=1): + label = step_label(step) + yield FlashStatus( + phase=FlashPhase.STEP, + message=f"Running {label}", + step_index=index, + total_steps=total_steps, + step_name=label, + ) + step_result: StepResult | None = None + for attempt in range(1, max_attempts + 1): + try: + step_result = await execute_step( + step, + tac=tac, + profile=profile, + firmware_root=firmware_root, + qdl_timeout=qdl_timeout, + fastboot_timeout=fastboot_timeout, + tac_timeout=tac_timeout, + board_revision=board_revision, + ) + break + except Exception as exc: + if attempt >= max_attempts: + raise RuntimeError(f"Step '{label}' failed after {max_attempts} attempts: {exc}") from exc + if not step.retry_mode: + raise + yield FlashStatus( + phase=FlashPhase.STEP, + message=f"Retrying {label} in {step.retry_mode} mode (attempt {attempt + 1}/{max_attempts})", + step_index=index, + total_steps=total_steps, + step_name=label, + ) + retry_check = RETRY_MODE_DMESG.get(step.retry_mode) + if step.retry_mode and retry_check is None: + logger.warning( + "No dmesg verification configured for retry mode '%s'; " + "skipping post-retry device check", + step.retry_mode, + ) + await set_device_mode( + tac=tac, + profile=profile, + mode=step.retry_mode, + check=retry_check, + tac_timeout=tac_timeout, + ) + if step_result: + yield FlashStatus( + phase=FlashPhase.STEP, + message=f"Completed {label}", + step_index=index, + total_steps=total_steps, + step_name=label, + stdout=step_result.stdout or None, + stderr=step_result.stderr or None, + ) + + +def resolve_firmware_root(work_dir: Path, manifest: FirmwareManifest) -> Path: + return work_dir / manifest.data.folder diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py new file mode 100644 index 000000000..075d8294b --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py @@ -0,0 +1,213 @@ +import subprocess +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from jumpstarter_driver_ridesx.qdl.executor import ( + build_qdl_command, + check_dmesg, + fix_provision_default_xml, + run_fastboot_step, + set_device_mode, +) +from jumpstarter_driver_ridesx.qdl.firmware_id import identify_firmware_variant +from jumpstarter_driver_ridesx.qdl.schema import FastbootConfig, FastbootFlashOp, FastbootStep, QdlConfig, QdlStep +from jumpstarter_driver_ridesx.qdl.soc_profiles import SA8775P + + +def test_identify_firmware_variant_known_es22(): + assert identify_firmware_variant("BOOT.MXF.1.2-00541-LEMANS-1") == "ES22" + + +def test_identify_firmware_variant_cs4_cs5_disambiguation(): + assert identify_firmware_variant("BOOT.MXF.1.2-00568-LEMANS-2", rm_version="55198a39") == "CS4" + assert identify_firmware_variant("BOOT.MXF.1.2-00568-LEMANS-2", rm_version="ba2475df") == "CS5" + assert identify_firmware_variant("BOOT.MXF.1.2-00568-LEMANS-2") == "CS4/CS5" + + +def test_build_qdl_command_expands_globs(tmp_path): + ufs_dir = tmp_path / "ufs" + ufs_dir.mkdir() + programmer = ufs_dir / "prog_firehose_ddr.elf" + programmer.write_bytes(b"elf") + (ufs_dir / "rawprogram0.xml").write_text("", encoding="utf-8") + (ufs_dir / "patch0.xml").write_text("", encoding="utf-8") + + step = QdlStep( + qdl=QdlConfig( + storage="ufs", + programmer="prog_firehose_ddr.elf", + files=["rawprogram*.xml", "patch*.xml"], + ) + ) + cmd, workdir = build_qdl_command(step, tmp_path) + assert workdir == ufs_dir + assert cmd[:4] == ["qdl", "-s", "ufs", str(programmer)] + assert str(ufs_dir / "rawprogram0.xml") in cmd + assert str(ufs_dir / "patch0.xml") in cmd + + +def test_fix_provision_default_xml_strips_invalid_header(tmp_path): + ufs_dir = tmp_path / "ufs" + ufs_dir.mkdir() + provision = ufs_dir / "provision_default.xml" + provision.write_text( + "\n".join( + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + '', + ] + ), + encoding="utf-8", + ) + fix_provision_default_xml(ufs_dir) + content = provision.read_text(encoding="utf-8") + assert content.startswith(" str: + return "".join(self._chunks) + + +PatternSpec = tuple[str, str, int] +"""(regex, key, group) tuple for serial pattern matching.""" + +ValidateCallback = Callable[[str, str], bool] | None +"""Optional ``(key, value) -> bool`` callback; return False to reject a match.""" + + +def _scan_serial_patterns( + serial, + patterns: list[PatternSpec], + *, + timeout: int = 30, + min_found: int = 0, + log_buffer=None, + label: str = "version", + validate: ValidateCallback = None, +) -> dict[str, str]: + """Drive a pexpect session looking for *patterns*, returning matched values. + + Args: + serial: A pexpect spawn-like object. + patterns: List of ``(regex, key, capture_group)`` tuples. + timeout: Overall wall-clock timeout in seconds. + min_found: Stop early on timeout/EOF once at least this many + distinct keys have been captured. + log_buffer: Optional writable object attached to + ``serial.logfile_read``. + label: Human-readable label used in warning messages. + validate: Optional callback ``(key, value) -> bool``. When it + returns ``False`` the captured value is silently discarded. + """ + serial.logfile_read = log_buffer + version_info: dict[str, str] = {} + found_patterns: set[str] = set() + start_time = time.time() + + while time.time() - start_time < timeout and len(found_patterns) < len(patterns): + try: + expect_patterns = [p for p, k, _ in patterns if k not in found_patterns] + if not expect_patterns: + break + index = serial.expect(expect_patterns, timeout=5) + _process_match(serial, patterns, found_patterns, version_info, index, validate) + except (pexpect.TIMEOUT, pexpect.EOF): + if len(found_patterns) >= min_found: + break + except Exception: + logger.warning("Unexpected error during %s extraction", label, exc_info=True) + if len(found_patterns) >= min_found: + break + return version_info + + +def _process_match( + serial, + patterns: list[PatternSpec], + found: set[str], + results: dict[str, str], + matched_index: int, + validate: ValidateCallback, +) -> None: + """Record the captured value for the pattern that matched at *matched_index*.""" + pattern_idx = 0 + for _regex, key, group in patterns: + if key not in found: + if pattern_idx == matched_index and serial.match: + value = serial.match.group(group) + if isinstance(value, bytes): + value = value.decode("utf-8", errors="ignore") + value = value.strip() + if validate and not validate(key, value): + return + results[key] = value + found.add(key) + return + pattern_idx += 1 + + +def _validate_main_version(key: str, value: str) -> bool: + """Reject spurious UEFI version matches that are too short or lack BOOT.""" + if key == "uefi_version" and (len(value) < 10 or ("BOOT" not in value and len(value) < 20)): + return False + return True + + +def extract_sail_version(sail, timeout=60, log_buffer=None) -> dict[str, str]: + return _scan_serial_patterns( + sail, + patterns=[ + (r"FW Version:\s*([\d.]+)", "sail_fw_version", 1), + (r"(?:Info:\s+)?Platform type:\s*(\S+)", "platform_type", 1), + (r"(?:Info:\s+)?SOC1 Board ID corresponds to SOC Type:\s*([^\r\n]+)[\r\n]", "soc_type", 1), + (r"(?:Info:\s+)?SIP1 Board ID corresponds to SIP Type:\s*([^\r\n]+)[\r\n]", "sip_type", 1), + (r"Hypervisor\s+cold\s+boot[^:]*:\s*gunyah-[^\s]+\s+(perf|debug|prod)", "hypervisor", 1), + (r"Hypervisor.*?(perf|debug)", "hypervisor", 1), + ], + timeout=timeout, + min_found=2, + log_buffer=log_buffer, + label="SAIL version", + ) + + +def extract_main_version(serial, timeout=60, log_buffer=None) -> dict[str, str]: + return _scan_serial_patterns( + serial, + patterns=[ + (r"QC_IMAGE_VERSION_STRING=([^\r\n]+)[\r\n]", "qc_image_version", 1), + (r"IMAGE_VARIANT_STRING=([^\r\n]+)[\r\n]", "image_variant", 1), + (r"OEM_IMAGE_VERSION_STRING=([^\r\n]+)[\r\n]", "oem_image_version", 1), + (r"UEFI Ver\s+:\s*([\d.]+\.BOOT\.[^\r\n]+)[\r\n]", "uefi_version", 1), + (r"UEFI Ver\s*:\s*([\d.]+\.BOOT\.[^\r\n]+)[\r\n]", "uefi_version", 1), + (r"UEFI Ver\s+:\s*([^\r\n]+)[\r\n]", "uefi_version", 1), + (r"UEFI Ver\s*:\s*([^\r\n]+)[\r\n]", "uefi_version", 1), + (r"SBL1 BUILD @ ([^\r\n]+)[\r\n]", "sbl1_build", 1), + (r"Chip Name\s*:\s*([^\r\n]+)[\r\n]", "chip_name", 1), + (r"Chip Ver\s*:\s*([^\r\n]+)[\r\n]", "chip_version", 1), + (r"Loader Build Info:\s*([^\r\n]+)[\r\n]", "abl_build", 1), + (r"CDT Version:\d+,Platform ID:(\d+),Major ID:(\d+),Minor ID:(\d+),Subtype:(\d+)", "cdt_platform_id", 1), + (r"\[RM\]Resource Manager version:\s*(\S+)", "rm_version", 1), + (r"Hypervisor\s+cold\s+boot[^:]*:\s*gunyah-[^\s]+\s+(perf|debug|prod)", "hypervisor", 1), + (r"Hypervisor.*?(perf|debug|prod)", "hypervisor", 1), + ], + timeout=timeout, + min_found=8, + log_buffer=log_buffer, + label="main version", + validate=_validate_main_version, + ) + + +def identify_firmware_variant(qc_image_version: str | None, rm_version: str | None = None) -> str | None: + if not qc_image_version: + return None + + known_variants = { + "BOOT.MXF.1.2-00527-LEMANS-1": "ES21", + "BOOT.MXF.1.2-00541-LEMANS-1": "ES22", + "BOOT.MXF.1.2-00369-LEMANS-3": "ES14", + "BOOT.MXF.1.2-00347-LEMANS-2": "ES13", + "BOOT.MXF.1.2-00333-LEMANS-1": "ES12", + } + if qc_image_version in known_variants: + return known_variants[qc_image_version] + + if qc_image_version == "BOOT.MXF.1.2-00568-LEMANS-2": + rm_version_map = { + "55198a39": "CS4", + "ba2475df": "CS5", + } + if rm_version and rm_version in rm_version_map: + return rm_version_map[rm_version] + return "CS4/CS5" + + return "unknown" + + +def collect_version_info(serial, sail, power_cycle_callable, *, verbose=False, capture_dir=None) -> VersionInfo: + result = VersionInfo() + sail_capture = _SerialCapture() + main_capture = _SerialCapture() + + with sail: + with serial: + if verbose: + logger.info("Power cycling device...") + power_cycle_callable() + if verbose: + logger.info("Collecting version information...") + # Run both scans concurrently: SAIL output appears early in + # boot while main serial output spans SBL1 through UEFI/ABL. + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + sail_future = pool.submit(extract_sail_version, sail, timeout=60, log_buffer=sail_capture) + main_future = pool.submit(extract_main_version, serial, timeout=60, log_buffer=main_capture) + result.sail_versions = sail_future.result() + result.main_versions = main_future.result() + if "hypervisor" in result.sail_versions and "hypervisor" not in result.main_versions: + result.main_versions["hypervisor"] = result.sail_versions["hypervisor"] + qc_image_version = result.main_versions.get("qc_image_version") + rm_version = result.main_versions.get("rm_version") + if qc_image_version: + result.firmware_variant = identify_firmware_variant(qc_image_version, rm_version=rm_version) + + result.sail_raw = sail_capture.getvalue() + result.main_raw = main_capture.getvalue() + result.raw_output = result.sail_raw + result.main_raw + + if capture_dir: + capture_path = Path(capture_dir) + capture_path.mkdir(parents=True, exist_ok=True) + (capture_path / "sail.log").write_text(result.sail_raw, encoding="utf-8") + (capture_path / "main.log").write_text(result.main_raw, encoding="utf-8") + + return result + + +def format_version_report(versions: VersionInfo) -> str: + lines = ["=" * 60, "VERSION INFORMATION SUMMARY", "=" * 60] + if versions.sail_versions: + lines.append("\nSAIL Port:") + for key, value in versions.sail_versions.items(): + lines.append(f" {key}: {value}") + if versions.main_versions: + lines.append("\nMAIN/SERIAL Port:") + for key, value in versions.main_versions.items(): + lines.append(f" {key}: {value}") + lines.extend(["", "=" * 60, "FIRMWARE VARIANT IDENTIFICATION", "=" * 60]) + qc_image_version = versions.main_versions.get("qc_image_version") + if versions.firmware_variant: + lines.append(f"\n Firmware Variant: {versions.firmware_variant}") + lines.append(f" QC Image Version: {qc_image_version}") + else: + lines.append(f"\n QC Image Version: {qc_image_version or 'Not found'}") + lines.append(" Firmware Variant: Could not be identified") + uefi_version = versions.main_versions.get("uefi_version") + hypervisor = versions.main_versions.get("hypervisor") + lines.append(f" UEFI Version: {uefi_version or 'Not found'}") + lines.append(f" Hypervisor: {hypervisor or 'Not found'}") + lines.append("\n" + "=" * 60) + return "\n".join(lines) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py new file mode 100644 index 000000000..f0657e78c --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py @@ -0,0 +1,144 @@ +"""Schema definitions for Qualcomm firmware manifest YAML files.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Literal, Union + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +EMBEDDED_MANIFEST_NAMES = ("jumpstarter_manifest.yaml",) + + +class StepBase(BaseModel): + name: str | None = Field(None, description="Optional human-readable name for this step") + retry_mode: Literal["edl", "fastboot"] | None = Field( + None, description="Mode to retry in if the step fails" + ) + + +class SetModeStep(StepBase): + set_mode: Literal["edl", "fastboot"] + check_dmesg: str | None = Field(None, description="Expected dmesg output to verify the mode change") + + +class SleepStep(StepBase): + sleep: int = Field(..., description="Sleep duration in seconds") + + +class QdlConfig(BaseModel): + storage: Literal["ufs", "spinor"] + programmer: str = Field(..., description="Firehose programmer ELF relative to the storage workdir") + files: list[str] = Field(..., description="XML or glob patterns relative to the storage workdir") + workdir: str | None = Field( + None, + description="Directory relative to firmware root. Defaults to the storage name (ufs/spinor).", + ) + + +class QdlStep(StepBase): + qdl: QdlConfig + + +class FastbootFlashOp(BaseModel): + partition: str + file: str + revision: str | None = Field( + None, + description="Only flash when board revision matches (e.g. v3). " + "Requires --board-revision or board_revision in the driver config.", + ) + + +class FastbootConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + flash: list[FastbootFlashOp] | None = None + erase: list[str] | None = None + continue_: bool | None = Field(None, alias="continue") + + +class FastbootStep(StepBase): + fastboot: FastbootConfig + + +Step = Annotated[ + Union[SetModeStep, SleepStep, QdlStep, FastbootStep], + Field(discriminator=None), +] + + +class FirmwareData(BaseModel): + folder: str = Field(..., description="Folder containing the firmware files after extraction") + + +class FirmwareManifest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + description: str | None = None + data: FirmwareData + steps: list[Step] + + +def _parse_step(data: dict) -> Step: + if "set_mode" in data: + return SetModeStep.model_validate(data) + if "sleep" in data: + return SleepStep.model_validate(data) + if "qdl" in data: + return QdlStep.model_validate(data) + if "fastboot" in data: + return FastbootStep.model_validate(data) + raise ValidationError.from_exception_data( + "Step", + [{"type": "value_error", "loc": (), "msg": f"Unknown step type: {sorted(data)}"}], + ) + + +def load_firmware_manifest(yaml_path: Path) -> FirmwareManifest: + if not yaml_path.exists(): + raise FileNotFoundError(f"Firmware manifest not found: {yaml_path}") + + with yaml_path.open("r", encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if not isinstance(raw, dict): + raise ValidationError.from_exception_data( + "FirmwareManifest", + [{"type": "value_error", "loc": (), "msg": "Manifest root must be a mapping"}], + ) + + steps_raw = raw.get("steps", []) + parsed_steps = [_parse_step(step) for step in steps_raw] + payload = {**raw, "steps": parsed_steps} + return FirmwareManifest.model_validate(payload) + + +def load_firmware_manifest_from_mapping(data: dict) -> FirmwareManifest: + steps_raw = data.get("steps", []) + parsed_steps = [_parse_step(step) for step in steps_raw] + payload = {**data, "steps": parsed_steps} + return FirmwareManifest.model_validate(payload) + + +def find_embedded_manifest(work_dir: Path) -> Path | None: + """Search ``work_dir`` recursively for a manifest file. + + Returns the first match found (sorted alphabetically) or ``None``. + When multiple manifests exist in the tree, the alphabetically first + path wins. Firmware archives are expected to contain at most one + manifest, so this is typically unambiguous. + """ + for filename in EMBEDDED_MANIFEST_NAMES: + matches = sorted(work_dir.rglob(filename)) + if matches: + return matches[0] + return None + + +def normalize_revision(board_revision: str) -> str: + """Normalize a board revision string for comparison (lowercase, 'v' prefix).""" + revision = board_revision.strip().lower() + return revision if revision.startswith("v") else f"v{revision}" diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema_test.py new file mode 100644 index 000000000..1b99446b4 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema_test.py @@ -0,0 +1,87 @@ +from pathlib import Path + +import pytest + +from jumpstarter_driver_ridesx.qdl.schema import ( + FastbootFlashOp, + FastbootStep, + QdlStep, + SetModeStep, + SleepStep, + load_firmware_manifest, + normalize_revision, +) +from jumpstarter_driver_ridesx.qdl.soc_profiles import SA8775P, get_soc_profile + +MANIFEST_DIR = Path(__file__).parent / "examples" / "manifests" + + +@pytest.mark.parametrize( + "manifest_name", + ["cs4.yaml", "cs5.yaml", "es13.yaml", "es21.yaml", "es22.yaml"], +) +def test_example_manifests_validate(manifest_name): + manifest = load_firmware_manifest(MANIFEST_DIR / manifest_name) + assert manifest.name + assert manifest.steps + assert any(isinstance(step, (SetModeStep, QdlStep, FastbootStep, SleepStep)) for step in manifest.steps) + + +def test_es22_manifest_contains_qdl_steps(): + manifest = load_firmware_manifest(MANIFEST_DIR / "es22.yaml") + qdl_steps = [step for step in manifest.steps if isinstance(step, QdlStep)] + assert len(qdl_steps) == 3 + assert qdl_steps[0].qdl.storage == "ufs" + assert qdl_steps[2].qdl.storage == "spinor" + + +def test_es13_manifest_contains_fastboot_steps(): + manifest = load_firmware_manifest(MANIFEST_DIR / "es13.yaml") + fastboot_steps = [step for step in manifest.steps if isinstance(step, FastbootStep)] + # hypervisor step + CDT step + assert len(fastboot_steps) == 2 + # Last fastboot step has continue: true + assert fastboot_steps[-1].fastboot.continue_ is True + + +def test_cs4_manifest_has_abl_and_cdt_fastboot_steps(): + manifest = load_firmware_manifest(MANIFEST_DIR / "cs4.yaml") + fastboot_steps = [step for step in manifest.steps if isinstance(step, FastbootStep)] + assert len(fastboot_steps) == 2 + # ABL step flashes to abl_a and abl_b + abl_step = fastboot_steps[0] + assert abl_step.fastboot.flash is not None + partitions = [op.partition for op in abl_step.fastboot.flash] + assert "abl_a" in partitions + assert "abl_b" in partitions + # CDT step has revision-filtered ops + cdt_step = fastboot_steps[1] + assert cdt_step.fastboot.flash is not None + revisions = [op.revision for op in cdt_step.fastboot.flash if op.revision] + assert "v1" in revisions + assert "v4" in revisions + assert cdt_step.fastboot.continue_ is True + + +def test_soc_profiles_match_reference_sequences(): + profile = get_soc_profile("sa8775p") + assert profile.power_on_commands[0][0] == "devicePower 1" + assert profile.edl_commands[3][0] == "pin 1 31" + assert profile is SA8775P + + +def test_fastboot_flash_op_with_revision(): + op = FastbootFlashOp(partition="cdt", file="cdt.bin", revision="v3") + assert op.revision == "v3" + + +def test_fastboot_flash_op_without_revision(): + op = FastbootFlashOp(partition="abl_a", file="abl.elf") + assert op.revision is None + + +def test_normalize_revision_adds_v_prefix(): + assert normalize_revision("3") == "v3" + assert normalize_revision("V3") == "v3" + assert normalize_revision("v3") == "v3" + assert normalize_revision(" v3 ") == "v3" diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/soc_profiles.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/soc_profiles.py new file mode 100644 index 000000000..7f5634fd4 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/soc_profiles.py @@ -0,0 +1,144 @@ +"""TAC GPIO command sequences for Qualcomm SoC profiles.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +SoCType = Literal["sa8775p", "sa8540p1", "sa8540p2"] + +CommandDelay = tuple[str, float] + + +@dataclass(frozen=True) +class SoCProfile: + name: SoCType + edl_commands: list[CommandDelay] + fastboot_commands: list[CommandDelay] + power_on_commands: list[CommandDelay] + power_off_commands: list[CommandDelay] + + +SA8775P = SoCProfile( + name="sa8775p", + power_off_commands=[ + ("gpio vbusdis1 0", 0), + ("usbDevicePower 1", 0), + ("devicePower 0", 0.5), + ], + power_on_commands=[ + ("devicePower 1", 0.9), + ("usbDevicePower 1", 0), + ("gpio vbusdis1 0", 0.03), + ], + edl_commands=[ + ("devicePower 0", 0), + ("usbDevicePower 1", 0), + ("gpio vbusdis1 0", 0), + ("pin 1 31", 0.5), + ("devicePower 1", 0.9), + ("usbDevicePower 1", 0), + ("gpio vbusdis1 0", 0.03), + ("ttl outputBit 1 1", 0.8), + ("ttl outputBit 1 0", 0.5), + ("pin 0 31", 0.5), + ], + fastboot_commands=[ + ("devicePower 0", 0), + ("usbDevicePower 1", 0), + ("gpio vbusdis1 0", 0), + ("ttl outputBit 1 0", 0), + ("gpio volup 0", 0), + ("ttl outputBit 2 1", 0), + ("ttl outputBit 4 0", 0.5), + ("devicePower 1", 0.9), + ("usbDevicePower 1", 0), + ("gpio vbusdis1 0", 0.03), + ("ttl outputBit 1 1", 0.8), + ("ttl outputBit 1 0", 8), + ("ttl outputBit 2 0", 0.5), + ], +) + +SA8540P1 = SoCProfile( + name="sa8540p1", + power_on_commands=[ + ("gpio start 0", 1.0), + ], + power_off_commands=[ + ("gpio start 1", 0.5), + ], + edl_commands=[ + ("gpio start 0", 0), + ("usbDevicePower 0", 0), + ("gpio volup 0", 0), + ("ttl outputBit 2 0", 0), + ("gpio start 0", 0), + ("ttl outputBit 4 1", 0.5), + ("gpio start 1", 0), + ("gpio start 0", 2.0), + ("ttl outputBit 4 0", 3.0), + ], + fastboot_commands=[ + ("gpio start 0", 0), + ("usbDevicePower 0", 0), + ("ttl outputBit 1 0", 0), + ("gpio volup 0", 0), + ("ttl outputBit 2 0", 0), + ("gpio resetsec 0", 0), + ("ttl outputBit 4 0", 0), + ("gpio pshold 0", 0), + ("ttl outputBit 2 1", 0), + ("gpio start 1", 0.8), + ("gpio start 0", 0.3), + ("ttl outputBit 2 0", 7.0), + ], +) + +SA8540P2 = SoCProfile( + name="sa8540p2", + power_on_commands=[ + ("gpio pshold 0", 1.0), + ], + power_off_commands=[ + ("gpio pshold 1", 0.5), + ], + edl_commands=[ + ("gpio start 0", 0), + ("usbDevicePower 0", 0), + ("gpio volup 0", 0), + ("ttl outputBit 2 0", 0), + ("gpio pshold 0", 0), + ("gpio swdwnldsec 1", 0.5), + ("gpio pshold 1", 0), + ("gpio pshold 0", 2.0), + ("gpio swdwnldsec 0", 3.0), + ], + fastboot_commands=[ + ("gpio start 0", 0), + ("usbDevicePower 0", 0), + ("ttl outputBit 1 0", 0), + ("gpio volup 0", 0), + ("ttl outputBit 2 0", 0), + ("gpio resetsec 0", 0), + ("ttl outputBit 4 0", 0), + ("gpio resetsec 1", 0), + ("gpio pshold 1", 0.8), + ("gpio pshold 0", 0.3), + ("gpio resetsec 0", 7.0), + ], +) + +SOC_PROFILES: dict[SoCType, SoCProfile] = { + "sa8775p": SA8775P, + "sa8540p1": SA8540P1, + "sa8540p2": SA8540P2, +} + + +def get_soc_profile(soc_type: str) -> SoCProfile: + normalized = soc_type.lower() + if normalized not in SOC_PROFILES: + supported = ", ".join(sorted(SOC_PROFILES)) + raise ValueError(f"Unsupported soc_type '{soc_type}'. Supported values: {supported}") + return SOC_PROFILES[normalized] diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac.py new file mode 100644 index 000000000..e545a3119 --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac.py @@ -0,0 +1,81 @@ +"""Shared TAC/EPM serial helpers for RideSX drivers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence + +CommandDelay = tuple[str, float] +PROMPT = b"CMD >> " +DEFAULT_TAC_COMMAND_TIMEOUT = 10.0 + + +async def _send_power_command_on_stream( + stream, logger, command: str, *, timeout: float +) -> None: + """Send a single power command on an already-open stream and wait for ok.""" + logger.info(f"Executing power command: {command}") + await stream.send(f"{command}\r".encode()) + data = b"" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while b"ok" not in data: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError(f"Power command '{command}' timed out after {timeout}s") + chunk = await asyncio.wait_for(stream.receive(), timeout=remaining) + data += chunk + logger.debug(f"Command {command} acknowledged with 'ok'") + + +async def send_power_command( + serial, logger, command: str, *, timeout: float = DEFAULT_TAC_COMMAND_TIMEOUT +) -> None: + """Send a power command and wait for ok.""" + async with serial.connect() as stream: + await _send_power_command_on_stream(stream, logger, command, timeout=timeout) + + +async def send_power_commands_sequence( + serial, logger, commands: Sequence[CommandDelay], *, timeout: float = DEFAULT_TAC_COMMAND_TIMEOUT +) -> None: + """Send a sequence of power commands with delays, using a single connection.""" + async with serial.connect() as stream: + for command, delay in commands: + await _send_power_command_on_stream(stream, logger, command, timeout=timeout) + if delay > 0: + await asyncio.sleep(delay) + + +async def _send_tac_command_on_stream(stream, command: str, *, timeout: float) -> None: + """Send a single TAC command on an already-open stream and wait for ok.""" + await stream.send(f"{command}\r".encode()) + data = b"" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while b"ok" not in data: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError(f"TAC command '{command}' timed out after {timeout}s") + chunk = await asyncio.wait_for(stream.receive(), timeout=remaining) + data += chunk + + +async def send_tac_command(tac, command: str, *, timeout: float = DEFAULT_TAC_COMMAND_TIMEOUT) -> None: + """Send a TAC command and wait for ok.""" + async with tac.connect() as stream: + await _send_tac_command_on_stream(stream, command, timeout=timeout) + + +async def send_tac_sequence( + tac, + commands: Sequence[CommandDelay], + *, + timeout: float = DEFAULT_TAC_COMMAND_TIMEOUT, +) -> None: + """Send a sequence of TAC commands with delays, using a single connection.""" + async with tac.connect() as stream: + for command, delay in commands: + await _send_tac_command_on_stream(stream, command, timeout=timeout) + if delay > 0: + await asyncio.sleep(delay) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac_test.py new file mode 100644 index 000000000..f234252df --- /dev/null +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/tac_test.py @@ -0,0 +1,18 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from jumpstarter_driver_ridesx.tac import send_tac_command + + +@pytest.mark.asyncio +async def test_send_tac_command_times_out(): + tac = MagicMock() + stream = AsyncMock() + stream.__aenter__ = AsyncMock(return_value=stream) + stream.__aexit__ = AsyncMock(return_value=None) + stream.receive = AsyncMock(return_value=b"waiting") + tac.connect.return_value = stream + + with pytest.raises(TimeoutError, match="devicePower"): + await send_tac_command(tac, "devicePower 0", timeout=0.01) diff --git a/python/packages/jumpstarter-driver-ridesx/pyproject.toml b/python/packages/jumpstarter-driver-ridesx/pyproject.toml index 1bda732f3..5928947a4 100644 --- a/python/packages/jumpstarter-driver-ridesx/pyproject.toml +++ b/python/packages/jumpstarter-driver-ridesx/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "jumpstarter-driver-ridesx" dynamic = ["version", "urls"] -description = "Jumpstarter driver for Qualcomm RideSX" +description = "Jumpstarter driver for Qualcomm RideSX and QDL platform flashing" readme = "README.md" license = "Apache-2.0" authors = [{ name = "Benny Zlotnik", email = "bzlotnik@redhat.com" }] @@ -12,11 +12,15 @@ dependencies = [ "jumpstarter-driver-opendal", "jumpstarter-driver-power", "jumpstarter-driver-pyserial", + "pyyaml>=6.0.2", + "pydantic>=2.0", + "pexpect>=4.9", ] [project.entry-points."jumpstarter.drivers"] RideSXDriver = "jumpstarter_driver_ridesx.driver:RideSXDriver" RideSXPowerDriver = "jumpstarter_driver_ridesx.driver:RideSXPowerDriver" +QualcommFlasher = "jumpstarter_driver_ridesx.qdl.driver:QualcommFlasher" [tool.hatch.version] source = "vcs" diff --git a/python/packages/jumpstarter/jumpstarter/client/__init__.py b/python/packages/jumpstarter/jumpstarter/client/__init__.py index 62dbf689c..d0faca150 100644 --- a/python/packages/jumpstarter/jumpstarter/client/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/client/__init__.py @@ -1,6 +1,22 @@ from .base import DriverClient from .client import client_from_path -from .flasher import FlasherClient, FlasherClientInterface +from .flasher import ( + FlasherClient, + FlasherClientInterface, + FlashStatus, + StreamingFlasherClient, + StreamingFlasherClientInterface, +) from .lease import DirectLease, Lease -__all__ = ["DriverClient", "DirectLease", "FlasherClient", "FlasherClientInterface", "client_from_path", "Lease"] +__all__ = [ + "DriverClient", + "DirectLease", + "FlashStatus", + "FlasherClient", + "FlasherClientInterface", + "StreamingFlasherClient", + "StreamingFlasherClientInterface", + "client_from_path", + "Lease", +] diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher.py b/python/packages/jumpstarter/jumpstarter/client/flasher.py index 3e64ad9a3..11ab19c71 100644 --- a/python/packages/jumpstarter/jumpstarter/client/flasher.py +++ b/python/packages/jumpstarter/jumpstarter/client/flasher.py @@ -2,7 +2,7 @@ import warnings from abc import ABCMeta, abstractmethod -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator from contextlib import asynccontextmanager from dataclasses import dataclass, field from os import PathLike @@ -12,6 +12,7 @@ import click from anyio import BrokenResourceError, EndOfStream from anyio.abc import ObjectStream +from pydantic import BaseModel from jumpstarter.client import DriverClient from jumpstarter.client.adapters import blocking @@ -23,6 +24,43 @@ PathBuf = str | PathLike +class FlashPhase: + """Standard phase constants for ``FlashStatus`` updates.""" + + DOWNLOAD = "download" + """Firmware archive is being received / downloaded.""" + + EXTRACT = "extract" + """Archive is being extracted to disk.""" + + STEP = "step" + """A manifest step (QDL, fastboot, mode switch, etc.) is running.""" + + CACHE = "cache" + """Cached firmware is being reused or has been stored.""" + + COMPLETE = "complete" + """The flash operation finished successfully.""" + + ERROR = "error" + """The flash operation failed.""" + + +class FlashStatus(BaseModel): + """Progress update emitted during a streaming flash operation.""" + + phase: str + message: str + step_index: int | None = None + total_steps: int | None = None + step_name: str | None = None + progress: float | None = None + bytes_transferred: int | None = None + bytes_total: int | None = None + stdout: str | None = None + stderr: str | None = None + + @dataclass(kw_only=True) class _AsyncIteratorStream(ObjectStream[bytes]): """Wraps an async iterator as an ObjectStream for resource_async.""" @@ -288,3 +326,120 @@ def dump( else: with _local_file_adapter(client=self, path=local_path, mode="wb", compression=compression) as handle: return self.call("dump", handle, target) + + +class StreamingFlasherClientInterface(FlasherClientInterface): + @abstractmethod + def flash_stream( + self, + path: PathBuf, + *, + manifest: Any | None = None, + compression: Compression | None = None, + ) -> Generator[FlashStatus, None, None]: + """Flash image to DUT, yielding progress updates.""" + ... + + +class StreamingFlasherClient(FlasherClient, StreamingFlasherClientInterface): + def _iter_flash_status( + self, + *, + handle: Any, + manifest: Any | None, + ) -> Generator[FlashStatus, None, None]: + for value in self.streamingcall("flash", handle, manifest): + status = FlashStatus.model_validate(value) + yield status + if status.phase == FlashPhase.ERROR: + raise RuntimeError(status.message) + + def flash_stream( + self, + path: PathBuf, + *, + manifest: Any | None = None, + compression: Compression | None = None, + ) -> Generator[FlashStatus, None, None]: + local_path, url = _parse_path(path) + + if url is not None: + if compression is not None: + warnings.warn( + "compression parameter is ignored for HTTP URLs", + stacklevel=2, + ) + with _http_url_adapter(client=self, url=url, mode="rb") as handle: + yield from self._iter_flash_status(handle=handle, manifest=manifest) + else: + with _local_file_adapter(client=self, path=local_path, mode="rb", compression=compression) as handle: + yield from self._iter_flash_status(handle=handle, manifest=manifest) + + def flash( + self, + path: PathBuf | dict[str, PathBuf], + *, + target: str | None = None, + compression: Compression | None = None, + ) -> FlashStatus: + if isinstance(path, dict): + from jumpstarter.common.exceptions import ArgumentError + + raise ArgumentError("StreamingFlasherClient does not support multi-target flash mappings") + + if target is not None: + from jumpstarter.common.exceptions import ArgumentError + + raise ArgumentError("'target' parameter is not supported by StreamingFlasherClient") + + last: FlashStatus | None = None + for status in self.flash_stream(path, compression=compression): + last = status + if last is None: + raise RuntimeError("flash completed without status updates") + if last.phase != FlashPhase.COMPLETE: + raise RuntimeError(last.message or "flash did not complete successfully") + return last + + @staticmethod + def render_flash_status(status: FlashStatus, *, verbose: bool = False) -> str: + parts = [status.phase.upper(), status.message] + if status.step_index is not None and status.total_steps is not None: + parts.append(f"step {status.step_index}/{status.total_steps}") + if status.step_name: + parts.append(status.step_name) + if status.progress is not None: + parts.append(f"{status.progress * 100:.1f}%") + if status.bytes_transferred is not None and status.bytes_total is not None: + parts.append(f"{status.bytes_transferred}/{status.bytes_total} bytes") + result = " | ".join(parts) + if verbose: + if status.stdout: + result += f"\n [stdout] {status.stdout.rstrip()}" + if status.stderr: + result += f"\n [stderr] {status.stderr.rstrip()}" + return result + + def cli(self) -> click.Group: + @driver_click_group(self) + def base(): + """Streaming flasher interface""" + pass + + @base.command() + @click.argument("file") + @click.option("--compression", type=click.Choice(Compression, case_sensitive=False)) + def flash(file, compression): + """Flash image to DUT with progress updates""" + for status in self.flash_stream(file, compression=compression): + click.echo(self.render_flash_status(status)) + + @base.command() + @click.argument("file") + @click.option("--target", type=str) + @click.option("--compression", type=click.Choice(Compression, case_sensitive=False)) + def dump(file, target, compression): + """Dump image from DUT to file""" + super().dump(file, target=target, compression=compression) + + return base diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher_test.py b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py index ca1b43fa7..bcbf25777 100644 --- a/python/packages/jumpstarter/jumpstarter/client/flasher_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py @@ -4,7 +4,7 @@ import click.testing import pytest -from jumpstarter.client.flasher import _parse_path +from jumpstarter.client.flasher import FlashPhase, FlashStatus, StreamingFlasherClient, _parse_path class TestParsePath: @@ -489,3 +489,301 @@ def test_dump_with_file_only(self): result = runner.invoke(group, ["dump", "/tmp/output.bin"]) assert result.exit_code == 0 client.dump.assert_called_once_with("/tmp/output.bin", target=None, compression=None) + + +class TestFlashPhase: + """Tests for FlashPhase constants.""" + + def test_standard_phases(self): + assert FlashPhase.DOWNLOAD == "download" + assert FlashPhase.EXTRACT == "extract" + assert FlashPhase.STEP == "step" + assert FlashPhase.CACHE == "cache" + assert FlashPhase.COMPLETE == "complete" + assert FlashPhase.ERROR == "error" + + def test_phases_are_strings(self): + """FlashPhase values should be usable directly as FlashStatus phase.""" + status = FlashStatus(phase=FlashPhase.STEP, message="test") + assert status.phase == "step" + + +class TestFlashStatusFields: + """Tests for FlashStatus stdout/stderr fields.""" + + def test_stdout_stderr_default_none(self): + status = FlashStatus(phase=FlashPhase.STEP, message="test") + assert status.stdout is None + assert status.stderr is None + + def test_stdout_stderr_set(self): + status = FlashStatus( + phase=FlashPhase.STEP, + message="test", + stdout="output line", + stderr="warning line", + ) + assert status.stdout == "output line" + assert status.stderr == "warning line" + + +class TestRenderFlashStatus: + """Tests for StreamingFlasherClient.render_flash_status.""" + + def test_basic_render(self): + status = FlashStatus(phase=FlashPhase.STEP, message="Running qdl ufs") + result = StreamingFlasherClient.render_flash_status(status) + assert "STEP" in result + assert "Running qdl ufs" in result + + def test_render_with_step_info(self): + status = FlashStatus( + phase=FlashPhase.STEP, + message="Running", + step_index=2, + total_steps=5, + step_name="Flash UFS", + ) + result = StreamingFlasherClient.render_flash_status(status) + assert "step 2/5" in result + assert "Flash UFS" in result + + def test_render_with_progress(self): + status = FlashStatus(phase=FlashPhase.DOWNLOAD, message="Downloading", progress=0.5) + result = StreamingFlasherClient.render_flash_status(status) + assert "50.0%" in result + + def test_render_with_bytes(self): + status = FlashStatus( + phase=FlashPhase.DOWNLOAD, + message="Receiving", + bytes_transferred=1024, + bytes_total=4096, + ) + result = StreamingFlasherClient.render_flash_status(status) + assert "1024/4096 bytes" in result + + def test_render_verbose_with_stdout(self): + status = FlashStatus( + phase=FlashPhase.STEP, + message="Completed", + stdout="OKAY [ 0.123s]\n", + ) + result = StreamingFlasherClient.render_flash_status(status, verbose=True) + assert "[stdout]" in result + assert "OKAY" in result + + def test_render_verbose_with_stderr(self): + status = FlashStatus( + phase=FlashPhase.STEP, + message="Completed", + stderr="Warning: slow device\n", + ) + result = StreamingFlasherClient.render_flash_status(status, verbose=True) + assert "[stderr]" in result + assert "slow device" in result + + def test_render_not_verbose_hides_stdout_stderr(self): + status = FlashStatus( + phase=FlashPhase.STEP, + message="Completed", + stdout="hidden output", + stderr="hidden error", + ) + result = StreamingFlasherClient.render_flash_status(status, verbose=False) + assert "[stdout]" not in result + assert "[stderr]" not in result + + def test_render_verbose_no_output(self): + status = FlashStatus(phase=FlashPhase.STEP, message="Completed") + result = StreamingFlasherClient.render_flash_status(status, verbose=True) + assert "[stdout]" not in result + assert "[stderr]" not in result + + +class TestStreamingFlasherClient: + """Tests for StreamingFlasherClient methods using mocks.""" + + def _make_client(self): + client = object.__new__(StreamingFlasherClient) + client.description = None + client.methods_description = {} + return client + + def test_iter_flash_status_yields_statuses(self): + client = self._make_client() + statuses_data = [ + {"phase": "step", "message": "running"}, + {"phase": "complete", "message": "done"}, + ] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + results = list(client._iter_flash_status(handle=None, manifest=None)) + assert len(results) == 2 + assert results[0].phase == FlashPhase.STEP + assert results[1].phase == FlashPhase.COMPLETE + + def test_iter_flash_status_coerces_float_to_int(self): + """Protobuf struct_pb2.Value stores all numbers as doubles, so integer + fields like bytes_transferred arrive as floats after round-tripping + through gRPC. model_validate must accept them without strict mode.""" + client = self._make_client() + statuses_data = [ + { + "phase": "download", + "message": "Received 480285 bytes", + "bytes_transferred": 480285.0, + "bytes_total": 1000000.0, + }, + {"phase": "complete", "message": "done"}, + ] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + results = list(client._iter_flash_status(handle=None, manifest=None)) + assert len(results) == 2 + assert results[0].bytes_transferred == 480285 + assert isinstance(results[0].bytes_transferred, int) + assert results[0].bytes_total == 1000000 + assert isinstance(results[0].bytes_total, int) + + def test_iter_flash_status_raises_on_error(self): + client = self._make_client() + statuses_data = [ + {"phase": "step", "message": "running"}, + {"phase": "error", "message": "something failed"}, + ] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + with pytest.raises(RuntimeError, match="something failed"): + list(client._iter_flash_status(handle=None, manifest=None)) + + def test_flash_stream_local_file(self, tmp_path): + client = self._make_client() + test_file = tmp_path / "firmware.bin" + test_file.touch() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="local_handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [{"phase": "complete", "message": "done"}] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): + results = list(client.flash_stream(str(test_file))) + assert len(results) == 1 + assert results[0].phase == FlashPhase.COMPLETE + + def test_flash_stream_http_url(self): + client = self._make_client() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="http_handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [{"phase": "complete", "message": "done"}] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + with patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_adapter): + results = list(client.flash_stream("https://example.com/firmware.bin")) + assert len(results) == 1 + + def test_flash_stream_http_url_compression_warns(self): + client = self._make_client() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="http_handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [{"phase": "complete", "message": "done"}] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + with patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_adapter): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + list(client.flash_stream("https://example.com/fw.bin", compression="zstd")) + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) + + def test_flash_returns_last_status(self, tmp_path): + client = self._make_client() + test_file = tmp_path / "firmware.bin" + test_file.touch() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [ + {"phase": "step", "message": "running"}, + {"phase": "complete", "message": "done"}, + ] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): + result = client.flash(str(test_file)) + assert result.phase == FlashPhase.COMPLETE + + def test_flash_raises_on_incomplete(self, tmp_path): + client = self._make_client() + test_file = tmp_path / "firmware.bin" + test_file.touch() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [{"phase": "step", "message": ""}] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): + with pytest.raises(RuntimeError, match="flash did not complete"): + client.flash(str(test_file)) + + def test_flash_raises_on_no_statuses(self, tmp_path): + client = self._make_client() + test_file = tmp_path / "firmware.bin" + test_file.touch() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + client.streamingcall = MagicMock(return_value=iter([])) + + with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): + with pytest.raises(RuntimeError, match="without status updates"): + client.flash(str(test_file)) + + def test_flash_dict_raises_argument_error(self): + from jumpstarter.common.exceptions import ArgumentError + + client = self._make_client() + with pytest.raises(ArgumentError, match="does not support multi-target"): + client.flash({"boot": "/tmp/boot.bin"}) + + def test_flash_with_target_raises_argument_error(self, tmp_path): + from jumpstarter.common.exceptions import ArgumentError + + client = self._make_client() + test_file = tmp_path / "firmware.bin" + test_file.touch() + with pytest.raises(ArgumentError, match="not supported"): + client.flash(str(test_file), target="boot") + + def test_cli_flash_command(self, tmp_path): + client = self._make_client() + + mock_adapter = MagicMock() + mock_adapter.__enter__ = MagicMock(return_value="handle") + mock_adapter.__exit__ = MagicMock(return_value=False) + + statuses_data = [{"phase": "complete", "message": "done"}] + client.streamingcall = MagicMock(return_value=iter(statuses_data)) + + test_file = tmp_path / "firmware.bin" + test_file.touch() + + with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): + group = client.cli() + runner = click.testing.CliRunner() + result = runner.invoke(group, ["flash", str(test_file)]) + assert result.exit_code == 0 + assert "COMPLETE" in result.output diff --git a/python/packages/jumpstarter/jumpstarter/driver/__init__.py b/python/packages/jumpstarter/jumpstarter/driver/__init__.py index ac7400106..8704afe9c 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/driver/__init__.py @@ -1,5 +1,5 @@ from .base import Driver from .decorators import export, exportstream -from .flasher import FlasherInterface +from .flasher import FlasherInterface, StreamingFlasherInterface -__all__ = ["Driver", "FlasherInterface", "export", "exportstream"] +__all__ = ["Driver", "FlasherInterface", "StreamingFlasherInterface", "export", "exportstream"] diff --git a/python/packages/jumpstarter/jumpstarter/driver/base.py b/python/packages/jumpstarter/jumpstarter/driver/base.py index 275bc0db8..0cad0c3e6 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/base.py +++ b/python/packages/jumpstarter/jumpstarter/driver/base.py @@ -409,7 +409,9 @@ async def _presigned_get( timeout=timeout, allow_redirects=False, ) as resp: if resp.status not in (301, 302, 303, 307, 308): - async with AiohttpStreamReaderStream(reader=resp.content) as stream: + async with AiohttpStreamReaderStream( + reader=resp.content, content_length=resp.content_length, + ) as stream: yield ProgressStream(stream=stream, logging=True) return location = resp.headers.get("Location", "") diff --git a/python/packages/jumpstarter/jumpstarter/driver/flasher.py b/python/packages/jumpstarter/jumpstarter/driver/flasher.py index d711b38e0..946633dc9 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/flasher.py +++ b/python/packages/jumpstarter/jumpstarter/driver/flasher.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod +from collections.abc import AsyncGenerator from typing import Any @@ -16,3 +17,34 @@ def flash(self, source: Any, target: str | None = None) -> None: ... @abstractmethod def dump(self, target: Any, partition: str | None = None) -> None: ... + + +class StreamingFlasherInterface(metaclass=ABCMeta): + """Interface for flash drivers that report progress via an async stream. + + Drivers extending this interface yield ``FlashStatus`` updates during + the flash process, enabling real-time progress reporting to the client. + """ + + driver_type = "storage" + + @classmethod + def client(cls) -> str: + return "jumpstarter.client.flasher.StreamingFlasherClient" + + @abstractmethod + async def flash( + self, source: Any, manifest: Any | None = None + ) -> AsyncGenerator[Any, None]: + """Flash firmware to the device, yielding progress updates. + + Args: + source: Firmware source (file handle, presigned URL, etc.). + manifest: Optional manifest data for the firmware. + + Yields: + ``FlashStatus`` instances describing progress. + """ + ... + # Make this a valid async generator for type checkers. + yield # pragma: no cover diff --git a/python/packages/jumpstarter/jumpstarter/driver/flasher_test.py b/python/packages/jumpstarter/jumpstarter/driver/flasher_test.py index f866e6ff6..90ed83b51 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/flasher_test.py +++ b/python/packages/jumpstarter/jumpstarter/driver/flasher_test.py @@ -1,6 +1,6 @@ import pytest -from jumpstarter.driver.flasher import FlasherInterface +from jumpstarter.driver.flasher import FlasherInterface, StreamingFlasherInterface class TestFlasherInterfaceABC: @@ -37,3 +37,30 @@ def dump(self, target, partition=None): def test_client_returns_expected_path(self): assert FlasherInterface.client() == "jumpstarter.client.flasher.FlasherClient" + + +class TestStreamingFlasherInterfaceABC: + def test_cannot_instantiate_directly(self): + with pytest.raises(TypeError, match="abstract method"): + StreamingFlasherInterface() + + def test_subclass_without_flash_raises(self): + class Incomplete(StreamingFlasherInterface): + pass + + with pytest.raises(TypeError, match="abstract method"): + Incomplete() + + def test_complete_subclass_can_be_instantiated(self): + class Complete(StreamingFlasherInterface): + async def flash(self, source, manifest=None): + yield # pragma: no cover + + instance = Complete() + assert instance is not None + + def test_client_returns_expected_path(self): + assert StreamingFlasherInterface.client() == "jumpstarter.client.flasher.StreamingFlasherClient" + + def test_driver_type_is_storage(self): + assert StreamingFlasherInterface.driver_type == "storage" diff --git a/python/packages/jumpstarter/jumpstarter/streams/aiohttp.py b/python/packages/jumpstarter/jumpstarter/streams/aiohttp.py index ec01f862c..88a48392a 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/aiohttp.py +++ b/python/packages/jumpstarter/jumpstarter/streams/aiohttp.py @@ -1,13 +1,24 @@ -from dataclasses import dataclass +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any from aiohttp import ClientError, StreamReader from anyio import BrokenResourceError, EndOfStream from anyio.abc import ObjectStream +from jumpstarter.streams.progress import ProgressAttribute + @dataclass(frozen=True, kw_only=True, slots=True) class AiohttpStreamReaderStream(ObjectStream[bytes]): reader: StreamReader + content_length: int | None = field(default=None) + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + if self.content_length is not None and self.content_length > 0: + return {ProgressAttribute.total: lambda: float(self.content_length)} + return {} async def send(self, item: bytes): raise BrokenResourceError diff --git a/python/packages/jumpstarter/jumpstarter/streams/common.py b/python/packages/jumpstarter/jumpstarter/streams/common.py index 90a643106..44639822f 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/common.py +++ b/python/packages/jumpstarter/jumpstarter/streams/common.py @@ -57,6 +57,10 @@ async def copy_stream( if isinstance(e.__cause__, BrokenPipeError): # BrokenPipeError (EPIPE) = writing to a closed pipe during normal teardown logger.debug("stream copy interrupted (%s): %s", type(e).__name__, e) + elif isinstance(e, BrokenResourceError) and e.__cause__ is None: + # anyio raises BrokenResourceError(from None) when the underlying + # TCP socket is closed by the peer (e.g. pexpect/fdspawn teardown). + logger.debug("stream copy interrupted (%s): %s", type(e).__name__, e) else: logger.warning("stream copy interrupted (%s): %s", type(e).__name__, e) if e.__cause__ is not None: diff --git a/python/uv.lock b/python/uv.lock index 3aa339d5a..80892b0e7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -277,7 +277,7 @@ version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argon2-cffi-bindings", version = "21.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "argon2-cffi-bindings", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "argon2-cffi-bindings", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } wheels = [ @@ -294,7 +294,7 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911, upload-time = "2021-12-01T08:52:55.68Z" } wheels = [ @@ -320,7 +320,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } wheels = [ @@ -1163,7 +1163,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } wheels = [ @@ -1212,7 +1212,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } wheels = [ @@ -1441,11 +1441,11 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "werkzeug" }, + { name = "blinker", marker = "python_full_version < '3.12'" }, + { name = "click", marker = "python_full_version < '3.12'" }, + { name = "itsdangerous", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "werkzeug", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824, upload-time = "2024-11-13T18:24:38.127Z" } wheels = [ @@ -1465,12 +1465,12 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, + { name = "blinker", marker = "python_full_version >= '3.12'" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "itsdangerous", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "markupsafe", marker = "python_full_version >= '3.12'" }, + { name = "werkzeug", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } wheels = [ @@ -1731,8 +1731,8 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "hpack" }, - { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "hpack", marker = "python_full_version < '3.12'" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593, upload-time = "2021-10-05T18:27:47.18Z" } wheels = [ @@ -1752,8 +1752,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "hpack" }, - { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "hpack", marker = "python_full_version >= '3.12'" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -1834,8 +1834,8 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "certifi" }, - { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385, upload-time = "2025-04-11T14:42:46.661Z" } wheels = [ @@ -1855,8 +1855,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "certifi" }, - { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -3334,6 +3334,9 @@ dependencies = [ { name = "jumpstarter-driver-opendal" }, { name = "jumpstarter-driver-power" }, { name = "jumpstarter-driver-pyserial" }, + { name = "pexpect" }, + { name = "pydantic" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -3350,6 +3353,9 @@ requires-dist = [ { name = "jumpstarter-driver-opendal", editable = "packages/jumpstarter-driver-opendal" }, { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, { name = "jumpstarter-driver-pyserial", editable = "packages/jumpstarter-driver-pyserial" }, + { name = "pexpect", specifier = ">=4.9" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, ] [package.metadata.requires-dev] @@ -4301,7 +4307,7 @@ name = "libusb-package" version = "1.0.26.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-resources" }, + { name = "importlib-resources", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/01/34/fd042950325366cb8f01e1873cd87f8aa99e773586377a8e670d6127dc8e/libusb_package-1.0.26.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:ce3711a82a2b4b9936a6268c9cbad794c34ae3b1b2c5cc9aeced8340db678668", size = 63859, upload-time = "2026-06-19T11:04:23.822Z" }, @@ -4525,31 +4531,31 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "aioquic" }, - { name = "asgiref", version = "3.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "brotli", version = "1.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "certifi" }, - { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" } }, - { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" } }, - { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "kaitaistruct", version = "0.10", source = { registry = "https://pypi.org/simple" } }, - { name = "ldap3" }, - { name = "mitmproxy-rs", version = "0.10.7", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "passlib" }, - { name = "publicsuffix2" }, - { name = "pydivert", marker = "sys_platform == 'win32'" }, - { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing", version = "3.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyperclip", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, - { name = "ruamel-yaml", version = "0.18.6", source = { registry = "https://pypi.org/simple" } }, - { name = "sortedcontainers" }, - { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "urwid", version = "2.6.16", source = { registry = "https://pypi.org/simple" } }, - { name = "wsproto" }, - { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" } }, + { name = "aioquic", marker = "python_full_version < '3.12'" }, + { name = "asgiref", version = "3.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "brotli", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "kaitaistruct", version = "0.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ldap3", marker = "python_full_version < '3.12'" }, + { name = "mitmproxy-rs", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "passlib", marker = "python_full_version < '3.12'" }, + { name = "publicsuffix2", marker = "python_full_version < '3.12'" }, + { name = "pydivert", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyparsing", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyperclip", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ruamel-yaml", version = "0.18.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.12'" }, + { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "urwid", version = "2.6.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "wsproto", marker = "python_full_version < '3.12'" }, + { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/66/88/5f503d5dd63aa8e0e6d788380e8e8b5d172b682eb5770da625bf70a5f0a7/mitmproxy-11.0.2-py3-none-any.whl", hash = "sha256:95db7b57b21320a0c76e59e1d6644daaa431291cdf89419608301424651199b4", size = 1658730, upload-time = "2024-12-05T09:38:10.269Z" }, @@ -4568,33 +4574,33 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "aioquic" }, - { name = "argon2-cffi" }, - { name = "asgiref", version = "3.10.0", source = { registry = "https://pypi.org/simple" } }, - { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "certifi" }, - { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" } }, - { name = "flask", version = "3.1.2", source = { registry = "https://pypi.org/simple" } }, - { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" } }, - { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kaitaistruct", version = "0.11", source = { registry = "https://pypi.org/simple" } }, - { name = "ldap3" }, - { name = "mitmproxy-rs", version = "0.12.9", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" } }, - { name = "publicsuffix2" }, - { name = "pydivert", marker = "sys_platform == 'win32'" }, - { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing", version = "3.2.5", source = { registry = "https://pypi.org/simple" } }, - { name = "pyperclip", version = "1.11.0", source = { registry = "https://pypi.org/simple" } }, - { name = "ruamel-yaml", version = "0.18.16", source = { registry = "https://pypi.org/simple" } }, - { name = "sortedcontainers" }, - { name = "tornado", version = "6.5.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "urwid", version = "3.0.3", source = { registry = "https://pypi.org/simple" } }, - { name = "wsproto" }, - { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" } }, + { name = "aioquic", marker = "python_full_version >= '3.12'" }, + { name = "argon2-cffi", marker = "python_full_version >= '3.12'" }, + { name = "asgiref", version = "3.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "flask", version = "3.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "kaitaistruct", version = "0.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ldap3", marker = "python_full_version >= '3.12'" }, + { name = "mitmproxy-rs", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "publicsuffix2", marker = "python_full_version >= '3.12'" }, + { name = "pydivert", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyparsing", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyperclip", version = "1.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ruamel-yaml", version = "0.18.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, + { name = "tornado", version = "6.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "urwid", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "wsproto", marker = "python_full_version >= '3.12'" }, + { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/54/d4/2acc254beec19403269652ead42735c98baf6d56d060ef9dfe34256bda22/mitmproxy-12.2.1-py3-none-any.whl", hash = "sha256:7a508cc9fb906253eb26460d99b3572bf5a7b4a185ab62534379ac1915677dd2", size = 1650400, upload-time = "2025-11-24T19:01:11.712Z" }, @@ -4643,8 +4649,8 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "mitmproxy-macos", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, - { name = "mitmproxy-windows", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, + { name = "mitmproxy-macos", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and os_name == 'nt'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/64/114311494f8fb689343ce348b7f046bbc67a88247ffc655dc4c3440286fb/mitmproxy_rs-0.10.7.tar.gz", hash = "sha256:0959a540766403222464472b64122ac8ccbca66b5f019154496b98e62482277f", size = 1183834, upload-time = "2024-10-28T11:56:39.622Z" } wheels = [ @@ -4667,9 +4673,9 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "mitmproxy-linux", marker = "sys_platform == 'linux'" }, - { name = "mitmproxy-macos", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, - { name = "mitmproxy-windows", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, + { name = "mitmproxy-linux", marker = "python_full_version >= '3.12' and sys_platform == 'linux'" }, + { name = "mitmproxy-macos", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and os_name == 'nt'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" } wheels = [ @@ -5247,7 +5253,7 @@ name = "prompt-toolkit" version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth" }, + { name = "wcwidth", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ @@ -5502,7 +5508,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } wheels = [ @@ -5522,7 +5528,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760, upload-time = "2026-08-13T04:26:11.021Z" } wheels = [ @@ -5624,7 +5630,7 @@ name = "pyobjc-framework-cocoa" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" } wheels = [ @@ -5641,8 +5647,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/b2/ad9e8516cd73611a3a8f8ff2d7d51b917115f3f7f9e7a9760d5fc4e9dd6b/pyobjc_framework_corebluetooth-12.0.tar.gz", hash = "sha256:61ae2a56c3dcb8b7307d833e7d913bd7c063d11a1ea931158facceb38aae21d3", size = 33587, upload-time = "2025-10-21T08:31:18.036Z" } wheels = [ @@ -5659,8 +5665,8 @@ name = "pyobjc-framework-libdispatch" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/7e/251ea268ce5a341586c963de758c7ff6dea681c98a1fb6da87f6d0004bd3/pyobjc_framework_libdispatch-12.0.tar.gz", hash = "sha256:2ef31c02670c377d9e2875e74053087b1d96b240d2fc8721cc4c665c05394b3a", size = 38599, upload-time = "2025-10-21T08:34:38.878Z" } wheels = [ @@ -5682,7 +5688,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" } }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944, upload-time = "2024-11-27T20:43:12.755Z" } wheels = [ @@ -5702,8 +5708,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } wheels = [ @@ -6383,7 +6389,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.12' and platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/81/4dfc17eb6ebb1aac314a3eb863c1325b907863a1b8b1382cdffcb6ac0ed9/ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b", size = 143362, upload-time = "2024-02-07T06:47:20.283Z" } wheels = [ @@ -6403,7 +6409,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/c7/ee630b29e04a672ecfc9b63227c87fd7a37eb67c1bf30fe95376437f897c/ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a", size = 147269, upload-time = "2025-10-22T17:54:02.346Z" } wheels = [ @@ -7131,8 +7137,8 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "typing-extensions" }, - { name = "wcwidth" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "wcwidth", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179, upload-time = "2024-10-15T16:07:24.297Z" } wheels = [ @@ -7152,7 +7158,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "wcwidth" }, + { name = "wcwidth", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/d3/09683323e2290732a39dc92ca5031d5e5ddda56f8d236f885a400535b29a/urwid-3.0.3.tar.gz", hash = "sha256:300804dd568cda5aa1c5b204227bd0cfe7a62cef2d00987c5eb2e4e64294ed9b", size = 855817, upload-time = "2025-09-15T10:26:17.089Z" } wheels = [ @@ -7348,7 +7354,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -7371,7 +7377,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -7394,7 +7400,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -7417,7 +7423,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -7440,7 +7446,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -7463,7 +7469,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -7486,7 +7492,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -7509,7 +7515,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ @@ -7694,7 +7700,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, + { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation == 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } wheels = [ diff --git a/typos.toml b/typos.toml index 19a832654..a6c6aa2ba 100644 --- a/typos.toml +++ b/typos.toml @@ -25,6 +25,9 @@ mosquitto = "mosquitto" # ser is short for "serialize" in variable names like ser_json_timedelta ser = "ser" +# abl is a Qualcomm bootloader component (Application Boot Loader) +abl = "abl" + # iif/oif are standard nftables keywords (input/output interface) iif = "iif" oif = "oif"