lr-shuttle 0.1.1__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/PKG-INFO +10 -2
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/README.md +8 -1
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/pyproject.toml +9 -2
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/PKG-INFO +10 -2
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/SOURCES.txt +9 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/requires.txt +1 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/shuttle/cli.py +109 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/__init__.py +5 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/__init__.py +1 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/boot_app0.bin +0 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
- lr_shuttle-0.2.1/src/shuttle/firmware/esp32c5/manifest.json +17 -0
- lr_shuttle-0.2.1/src/shuttle/flash.py +136 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/shuttle/serial_client.py +9 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_cli.py +139 -0
- lr_shuttle-0.2.1/tests/test_flash.py +92 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_serial_client.py +15 -11
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/setup.cfg +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/lr_shuttle.egg-info/top_level.txt +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/shuttle/constants.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/shuttle/prodtest.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/src/shuttle/timo.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_cli_client.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_cli_edge.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_cli_seq.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_cli_utils.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_prodtest_edge.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_prodtest_helpers.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_timo.py +0 -0
- {lr_shuttle-0.1.1 → lr_shuttle-0.2.1}/tests/test_timo_write.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -14,6 +14,7 @@ Requires-Dist: rich>=13.7
|
|
|
14
14
|
Requires-Dist: pydantic>=2.8
|
|
15
15
|
Requires-Dist: typer>=0.12
|
|
16
16
|
Requires-Dist: pyserial>=3.5
|
|
17
|
+
Requires-Dist: esptool>=4.7
|
|
17
18
|
Provides-Extra: dev
|
|
18
19
|
Requires-Dist: build>=1.2.1; extra == "dev"
|
|
19
20
|
Requires-Dist: twine>=5.1.1; extra == "dev"
|
|
@@ -25,7 +26,7 @@ Requires-Dist: pytest-cov; extra == "dev"
|
|
|
25
26
|
|
|
26
27
|
# Shuttle
|
|
27
28
|
|
|
28
|
-
`shuttle` is a Typer-based command-line interface & python library for interacting with the
|
|
29
|
+
`shuttle` is a Typer-based command-line interface & python library for interacting with the ESP32-C5 devboard over its NDJSON serial protocol. The tool is packaged as `lr-shuttle` for PyPI distribution and exposes high-level helpers for common workflows such as probing firmware info, querying protocol metadata, and issuing TiMo SPI sequences.
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
## Installation
|
|
@@ -55,6 +56,7 @@ make -C host dev
|
|
|
55
56
|
| `shuttle spi-cfg [options]` | Queries or updates the devboard SPI defaults (wraps the `spi.cfg` protocol command). |
|
|
56
57
|
| `shuttle uart-cfg [options]` | Queries or updates the devboard UART defaults (wraps the `uart.cfg` protocol command). |
|
|
57
58
|
| `shuttle uart-tx [payload]` | Transmits bytes over the devboard UART (wraps the `uart.tx` protocol command). |
|
|
59
|
+
| `shuttle flash --port /dev/ttyUSB0` | Programs the ESP32-C5 devboard using the bundled firmware artifacts. |
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
### SPI Configuration Workflow
|
|
@@ -77,6 +79,12 @@ make -C host dev
|
|
|
77
79
|
- Use `--uart-port` if a future firmware exposes multiple UART instances; otherwise the option can be omitted and the default device UART is used.
|
|
78
80
|
- Responses echo the number of bytes accepted by the firmware, matching the `n` field returned by `uart.tx`.
|
|
79
81
|
|
|
82
|
+
### Flashing Bundled Firmware
|
|
83
|
+
|
|
84
|
+
- `shuttle flash --port /dev/ttyUSB0` invokes `esptool.py` under the hood and writes the bundled ESP32-C5 firmware (bootloader, partitions, and application images) to the selected device. Pass `--erase-first` to issue a chip erase before programming.
|
|
85
|
+
- Firmware bundles live under `shuttle/firmware/<board>` inside the Python package. Run `make -f Makefile.arduino arduino-python` from the repo root after compiling with Arduino CLI to refresh the packaged binaries and `manifest.json` for distribution builds. The helper also copies `boot_app0.bin` from the ESP-IDF core (needed for the USB CDC-on-boot option) so the CLI uses the same flashing layout as `arduino-cli upload`.
|
|
86
|
+
- Use `--board <name>` if additional bundles are added; the command enumerates available bundles automatically and validates the provided identifier.
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
### Sequence Integrity Checks
|
|
82
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Shuttle
|
|
2
2
|
|
|
3
|
-
`shuttle` is a Typer-based command-line interface & python library for interacting with the
|
|
3
|
+
`shuttle` is a Typer-based command-line interface & python library for interacting with the ESP32-C5 devboard over its NDJSON serial protocol. The tool is packaged as `lr-shuttle` for PyPI distribution and exposes high-level helpers for common workflows such as probing firmware info, querying protocol metadata, and issuing TiMo SPI sequences.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
## Installation
|
|
@@ -30,6 +30,7 @@ make -C host dev
|
|
|
30
30
|
| `shuttle spi-cfg [options]` | Queries or updates the devboard SPI defaults (wraps the `spi.cfg` protocol command). |
|
|
31
31
|
| `shuttle uart-cfg [options]` | Queries or updates the devboard UART defaults (wraps the `uart.cfg` protocol command). |
|
|
32
32
|
| `shuttle uart-tx [payload]` | Transmits bytes over the devboard UART (wraps the `uart.tx` protocol command). |
|
|
33
|
+
| `shuttle flash --port /dev/ttyUSB0` | Programs the ESP32-C5 devboard using the bundled firmware artifacts. |
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
### SPI Configuration Workflow
|
|
@@ -52,6 +53,12 @@ make -C host dev
|
|
|
52
53
|
- Use `--uart-port` if a future firmware exposes multiple UART instances; otherwise the option can be omitted and the default device UART is used.
|
|
53
54
|
- Responses echo the number of bytes accepted by the firmware, matching the `n` field returned by `uart.tx`.
|
|
54
55
|
|
|
56
|
+
### Flashing Bundled Firmware
|
|
57
|
+
|
|
58
|
+
- `shuttle flash --port /dev/ttyUSB0` invokes `esptool.py` under the hood and writes the bundled ESP32-C5 firmware (bootloader, partitions, and application images) to the selected device. Pass `--erase-first` to issue a chip erase before programming.
|
|
59
|
+
- Firmware bundles live under `shuttle/firmware/<board>` inside the Python package. Run `make -f Makefile.arduino arduino-python` from the repo root after compiling with Arduino CLI to refresh the packaged binaries and `manifest.json` for distribution builds. The helper also copies `boot_app0.bin` from the ESP-IDF core (needed for the USB CDC-on-boot option) so the CLI uses the same flashing layout as `arduino-cli upload`.
|
|
60
|
+
- Use `--board <name>` if additional bundles are added; the command enumerates available bundles automatically and validates the provided identifier.
|
|
61
|
+
|
|
55
62
|
|
|
56
63
|
### Sequence Integrity Checks
|
|
57
64
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lr-shuttle"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "CLI and Python client for host-side of json based serial communication with embedded device bridge."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
|
15
15
|
"pydantic>=2.8",
|
|
16
16
|
"typer>=0.12",
|
|
17
17
|
"pyserial>=3.5",
|
|
18
|
+
"esptool>=4.7",
|
|
18
19
|
]
|
|
19
20
|
keywords = ["CLI"]
|
|
20
21
|
classifiers = [
|
|
@@ -34,4 +35,10 @@ package-dir = {"" = "src"}
|
|
|
34
35
|
where = ["src"]
|
|
35
36
|
|
|
36
37
|
[project.optional-dependencies]
|
|
37
|
-
dev = ["build>=1.2.1", "twine>=5.1.1", "wheel", "pytest>=8.4.2", "black>=25.9.0", "pytest-html", "pytest-cov"]
|
|
38
|
+
dev = ["build>=1.2.1", "twine>=5.1.1", "wheel", "pytest>=8.4.2", "black>=25.9.0", "pytest-html", "pytest-cov"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
"shuttle" = [
|
|
42
|
+
"firmware/esp32c5/*.bin",
|
|
43
|
+
"firmware/esp32c5/manifest.json",
|
|
44
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -14,6 +14,7 @@ Requires-Dist: rich>=13.7
|
|
|
14
14
|
Requires-Dist: pydantic>=2.8
|
|
15
15
|
Requires-Dist: typer>=0.12
|
|
16
16
|
Requires-Dist: pyserial>=3.5
|
|
17
|
+
Requires-Dist: esptool>=4.7
|
|
17
18
|
Provides-Extra: dev
|
|
18
19
|
Requires-Dist: build>=1.2.1; extra == "dev"
|
|
19
20
|
Requires-Dist: twine>=5.1.1; extra == "dev"
|
|
@@ -25,7 +26,7 @@ Requires-Dist: pytest-cov; extra == "dev"
|
|
|
25
26
|
|
|
26
27
|
# Shuttle
|
|
27
28
|
|
|
28
|
-
`shuttle` is a Typer-based command-line interface & python library for interacting with the
|
|
29
|
+
`shuttle` is a Typer-based command-line interface & python library for interacting with the ESP32-C5 devboard over its NDJSON serial protocol. The tool is packaged as `lr-shuttle` for PyPI distribution and exposes high-level helpers for common workflows such as probing firmware info, querying protocol metadata, and issuing TiMo SPI sequences.
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
## Installation
|
|
@@ -55,6 +56,7 @@ make -C host dev
|
|
|
55
56
|
| `shuttle spi-cfg [options]` | Queries or updates the devboard SPI defaults (wraps the `spi.cfg` protocol command). |
|
|
56
57
|
| `shuttle uart-cfg [options]` | Queries or updates the devboard UART defaults (wraps the `uart.cfg` protocol command). |
|
|
57
58
|
| `shuttle uart-tx [payload]` | Transmits bytes over the devboard UART (wraps the `uart.tx` protocol command). |
|
|
59
|
+
| `shuttle flash --port /dev/ttyUSB0` | Programs the ESP32-C5 devboard using the bundled firmware artifacts. |
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
### SPI Configuration Workflow
|
|
@@ -77,6 +79,12 @@ make -C host dev
|
|
|
77
79
|
- Use `--uart-port` if a future firmware exposes multiple UART instances; otherwise the option can be omitted and the default device UART is used.
|
|
78
80
|
- Responses echo the number of bytes accepted by the firmware, matching the `n` field returned by `uart.tx`.
|
|
79
81
|
|
|
82
|
+
### Flashing Bundled Firmware
|
|
83
|
+
|
|
84
|
+
- `shuttle flash --port /dev/ttyUSB0` invokes `esptool.py` under the hood and writes the bundled ESP32-C5 firmware (bootloader, partitions, and application images) to the selected device. Pass `--erase-first` to issue a chip erase before programming.
|
|
85
|
+
- Firmware bundles live under `shuttle/firmware/<board>` inside the Python package. Run `make -f Makefile.arduino arduino-python` from the repo root after compiling with Arduino CLI to refresh the packaged binaries and `manifest.json` for distribution builds. The helper also copies `boot_app0.bin` from the ESP-IDF core (needed for the USB CDC-on-boot option) so the CLI uses the same flashing layout as `arduino-cli upload`.
|
|
86
|
+
- Use `--board <name>` if additional bundles are added; the command enumerates available bundles automatically and validates the provided identifier.
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
### Sequence Integrity Checks
|
|
82
90
|
|
|
@@ -8,14 +8,23 @@ src/lr_shuttle.egg-info/requires.txt
|
|
|
8
8
|
src/lr_shuttle.egg-info/top_level.txt
|
|
9
9
|
src/shuttle/cli.py
|
|
10
10
|
src/shuttle/constants.py
|
|
11
|
+
src/shuttle/flash.py
|
|
11
12
|
src/shuttle/prodtest.py
|
|
12
13
|
src/shuttle/serial_client.py
|
|
13
14
|
src/shuttle/timo.py
|
|
15
|
+
src/shuttle/firmware/__init__.py
|
|
16
|
+
src/shuttle/firmware/esp32c5/__init__.py
|
|
17
|
+
src/shuttle/firmware/esp32c5/boot_app0.bin
|
|
18
|
+
src/shuttle/firmware/esp32c5/devboard.ino.bin
|
|
19
|
+
src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin
|
|
20
|
+
src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin
|
|
21
|
+
src/shuttle/firmware/esp32c5/manifest.json
|
|
14
22
|
tests/test_cli.py
|
|
15
23
|
tests/test_cli_client.py
|
|
16
24
|
tests/test_cli_edge.py
|
|
17
25
|
tests/test_cli_seq.py
|
|
18
26
|
tests/test_cli_utils.py
|
|
27
|
+
tests/test_flash.py
|
|
19
28
|
tests/test_prodtest_edge.py
|
|
20
29
|
tests/test_prodtest_helpers.py
|
|
21
30
|
tests/test_serial_client.py
|
|
@@ -17,6 +17,7 @@ from rich.panel import Panel
|
|
|
17
17
|
from rich.pretty import Pretty
|
|
18
18
|
from rich.table import Table
|
|
19
19
|
|
|
20
|
+
from . import flash as flash_module
|
|
20
21
|
from . import prodtest, timo
|
|
21
22
|
from .constants import (
|
|
22
23
|
DEFAULT_BAUD,
|
|
@@ -24,6 +25,7 @@ from .constants import (
|
|
|
24
25
|
SPI_CHOICE_FIELDS,
|
|
25
26
|
UART_PARITY_ALIASES,
|
|
26
27
|
)
|
|
28
|
+
from .firmware import DEFAULT_BOARD
|
|
27
29
|
from .serial_client import (
|
|
28
30
|
NDJSONSerialClient,
|
|
29
31
|
SerialLogger,
|
|
@@ -1975,6 +1977,113 @@ def uart_rx_command(
|
|
|
1975
1977
|
console.print("[yellow]No uart.rx events observed[/]")
|
|
1976
1978
|
|
|
1977
1979
|
|
|
1980
|
+
@app.command("power")
|
|
1981
|
+
def power_command(
|
|
1982
|
+
ctx: typer.Context,
|
|
1983
|
+
port: Optional[str] = typer.Option(
|
|
1984
|
+
None,
|
|
1985
|
+
"--port",
|
|
1986
|
+
envvar="SHUTTLE_PORT",
|
|
1987
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1988
|
+
),
|
|
1989
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1990
|
+
timeout: float = typer.Option(
|
|
1991
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1992
|
+
),
|
|
1993
|
+
enable: Optional[bool] = typer.Option(
|
|
1994
|
+
None,
|
|
1995
|
+
"--enable/--disable",
|
|
1996
|
+
help="Enable or disable the downstream power rail",
|
|
1997
|
+
),
|
|
1998
|
+
):
|
|
1999
|
+
"""Query or toggle the downstream power rail."""
|
|
2000
|
+
|
|
2001
|
+
resources = _ctx_resources(ctx)
|
|
2002
|
+
resolved_port = _require_port(port)
|
|
2003
|
+
if enable is None:
|
|
2004
|
+
action = "Querying"
|
|
2005
|
+
label = "power.state"
|
|
2006
|
+
method_name = "power_state"
|
|
2007
|
+
elif enable:
|
|
2008
|
+
action = "Enabling"
|
|
2009
|
+
label = "power.enable"
|
|
2010
|
+
method_name = "power_enable"
|
|
2011
|
+
else:
|
|
2012
|
+
action = "Disabling"
|
|
2013
|
+
label = "power.disable"
|
|
2014
|
+
method_name = "power_disable"
|
|
2015
|
+
|
|
2016
|
+
with spinner(f"{action} power over {resolved_port}"):
|
|
2017
|
+
try:
|
|
2018
|
+
with NDJSONSerialClient(
|
|
2019
|
+
resolved_port,
|
|
2020
|
+
baudrate=baudrate,
|
|
2021
|
+
timeout=timeout,
|
|
2022
|
+
logger=resources.get("logger"),
|
|
2023
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
2024
|
+
) as client:
|
|
2025
|
+
method = getattr(client, method_name)
|
|
2026
|
+
response = method()
|
|
2027
|
+
except ShuttleSerialError as exc:
|
|
2028
|
+
console.print(f"[red]{exc}[/]")
|
|
2029
|
+
raise typer.Exit(1) from exc
|
|
2030
|
+
|
|
2031
|
+
_render_payload_response(label, response)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
@app.command("flash")
|
|
2035
|
+
def flash_command(
|
|
2036
|
+
ctx: typer.Context,
|
|
2037
|
+
port: Optional[str] = typer.Option(
|
|
2038
|
+
None,
|
|
2039
|
+
"--port",
|
|
2040
|
+
envvar="SHUTTLE_PORT",
|
|
2041
|
+
help="Serial port connected to the ESP32-C5 devboard",
|
|
2042
|
+
),
|
|
2043
|
+
baudrate: int = typer.Option(
|
|
2044
|
+
DEFAULT_BAUD,
|
|
2045
|
+
"--baud",
|
|
2046
|
+
help="Serial baud used for the ROM bootloader",
|
|
2047
|
+
),
|
|
2048
|
+
board: str = typer.Option(
|
|
2049
|
+
DEFAULT_BOARD,
|
|
2050
|
+
"--board",
|
|
2051
|
+
help="Firmware bundle to flash",
|
|
2052
|
+
),
|
|
2053
|
+
erase_first: bool = typer.Option(
|
|
2054
|
+
False,
|
|
2055
|
+
"--erase-first/--no-erase-first",
|
|
2056
|
+
help="Erase the entire flash before writing",
|
|
2057
|
+
),
|
|
2058
|
+
):
|
|
2059
|
+
"""Flash the bundled firmware image to the devboard."""
|
|
2060
|
+
|
|
2061
|
+
resolved_port = _require_port(port)
|
|
2062
|
+
available_boards = flash_module.list_available_boards()
|
|
2063
|
+
available = ", ".join(available_boards)
|
|
2064
|
+
if board not in available_boards:
|
|
2065
|
+
raise typer.BadParameter(
|
|
2066
|
+
f"Unknown firmware bundle '{board}'. Available: {available}"
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
with spinner(f"Flashing {board} firmware to {resolved_port}"):
|
|
2070
|
+
try:
|
|
2071
|
+
manifest = flash_module.flash_firmware(
|
|
2072
|
+
port=resolved_port,
|
|
2073
|
+
baudrate=baudrate,
|
|
2074
|
+
board=board,
|
|
2075
|
+
erase_first=erase_first,
|
|
2076
|
+
)
|
|
2077
|
+
except flash_module.FirmwareFlashError as exc:
|
|
2078
|
+
console.print(f"[red]{exc}[/]")
|
|
2079
|
+
raise typer.Exit(1) from exc
|
|
2080
|
+
|
|
2081
|
+
label = str(manifest.get("label", board))
|
|
2082
|
+
console.print(
|
|
2083
|
+
f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
|
|
1978
2087
|
@app.command("get-info")
|
|
1979
2088
|
def get_info(
|
|
1980
2089
|
ctx: typer.Context,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ESP32-C5 devboard firmware bundle."""
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"board": "esp32c5",
|
|
3
|
+
"label": "ESP32-C5 Devboard",
|
|
4
|
+
"chip": "esp32c5",
|
|
5
|
+
"flash-mode": "keep",
|
|
6
|
+
"flash-freq": "keep",
|
|
7
|
+
"flash-size": "keep",
|
|
8
|
+
"before": "default-reset",
|
|
9
|
+
"after": "hard-reset",
|
|
10
|
+
"compress": true,
|
|
11
|
+
"segments": [
|
|
12
|
+
{"offset": "0x2000", "file": "devboard.ino.bootloader.bin"},
|
|
13
|
+
{"offset": "0x8000", "file": "devboard.ino.partitions.bin"},
|
|
14
|
+
{"offset": "0xe000", "file": "boot_app0.bin"},
|
|
15
|
+
{"offset": "0x10000", "file": "devboard.ino.bin"}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Helpers for flashing embedded firmware bundles to the devboard."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from contextlib import ExitStack
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
import esptool
|
|
14
|
+
|
|
15
|
+
from .firmware import DEFAULT_BOARD
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FirmwareFlashError(RuntimeError):
|
|
19
|
+
"""Raised when preparing or flashing bundled firmware fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_available_boards() -> List[str]:
|
|
23
|
+
"""Return the set of firmware bundles shipped with the package."""
|
|
24
|
+
|
|
25
|
+
firmware_pkg = resources.files("shuttle.firmware")
|
|
26
|
+
return sorted(child.name for child in firmware_pkg.iterdir() if child.is_dir())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_firmware_manifest(board: str) -> Tuple[Dict[str, object], str]:
|
|
30
|
+
"""Load the manifest for the requested firmware bundle."""
|
|
31
|
+
|
|
32
|
+
package = f"shuttle.firmware.{board}"
|
|
33
|
+
try:
|
|
34
|
+
manifest_path = resources.files(package) / "manifest.json"
|
|
35
|
+
except ModuleNotFoundError as exc:
|
|
36
|
+
raise FirmwareFlashError(f"Unknown firmware target '{board}'") from exc
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
40
|
+
except FileNotFoundError as exc:
|
|
41
|
+
raise FirmwareFlashError(f"Firmware manifest missing for '{board}'") from exc
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
raise FirmwareFlashError(f"Invalid manifest for '{board}': {exc}") from exc
|
|
44
|
+
|
|
45
|
+
if not manifest.get("segments"):
|
|
46
|
+
raise FirmwareFlashError(f"Firmware manifest for '{board}' defines no segments")
|
|
47
|
+
manifest.setdefault("label", board)
|
|
48
|
+
manifest.setdefault("chip", board)
|
|
49
|
+
manifest.setdefault("compress", True)
|
|
50
|
+
return manifest, package
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_esptool(args: Sequence[str]) -> None:
|
|
54
|
+
try:
|
|
55
|
+
esptool.main(list(args))
|
|
56
|
+
except SystemExit as exc:
|
|
57
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
58
|
+
if code:
|
|
59
|
+
raise FirmwareFlashError(f"esptool exited with status {code}") from exc
|
|
60
|
+
except Exception as exc: # pragma: no cover - esptool internal errors
|
|
61
|
+
raise FirmwareFlashError(str(exc)) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def flash_firmware(
|
|
65
|
+
*,
|
|
66
|
+
port: str,
|
|
67
|
+
baudrate: int,
|
|
68
|
+
board: str = DEFAULT_BOARD,
|
|
69
|
+
erase_first: bool = False,
|
|
70
|
+
) -> Dict[str, object]:
|
|
71
|
+
"""Flash the bundled firmware to the specified serial port."""
|
|
72
|
+
|
|
73
|
+
manifest, package = load_firmware_manifest(board)
|
|
74
|
+
base_args: List[str] = [
|
|
75
|
+
"--chip",
|
|
76
|
+
str(manifest.get("chip", board)),
|
|
77
|
+
"--port",
|
|
78
|
+
port,
|
|
79
|
+
"--baud",
|
|
80
|
+
str(baudrate),
|
|
81
|
+
"--before",
|
|
82
|
+
str(manifest.get("before", "default-reset")),
|
|
83
|
+
"--after",
|
|
84
|
+
str(manifest.get("after", "hard-reset")),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
with ExitStack() as stack:
|
|
88
|
+
resolved_segments: List[Tuple[str, Path]] = []
|
|
89
|
+
for segment in manifest["segments"]:
|
|
90
|
+
offset = segment.get("offset")
|
|
91
|
+
file_name = segment.get("file")
|
|
92
|
+
if not offset or not file_name:
|
|
93
|
+
raise FirmwareFlashError(
|
|
94
|
+
"Manifest segment entries require 'offset' and 'file'"
|
|
95
|
+
)
|
|
96
|
+
traversable = resources.files(package) / file_name
|
|
97
|
+
try:
|
|
98
|
+
file_path = stack.enter_context(resources.as_file(traversable))
|
|
99
|
+
except FileNotFoundError as exc:
|
|
100
|
+
raise FirmwareFlashError(
|
|
101
|
+
f"Missing firmware artifact: {file_name}"
|
|
102
|
+
) from exc
|
|
103
|
+
resolved_segments.append((str(offset), Path(file_path)))
|
|
104
|
+
|
|
105
|
+
if erase_first:
|
|
106
|
+
_run_esptool(base_args + ["erase-flash"])
|
|
107
|
+
|
|
108
|
+
write_args: List[str] = base_args + ["write-flash"]
|
|
109
|
+
option_keys = [
|
|
110
|
+
("flash-mode", manifest.get("flash-mode") or manifest.get("flash_mode")),
|
|
111
|
+
("flash-freq", manifest.get("flash-freq") or manifest.get("flash_freq")),
|
|
112
|
+
("flash-size", manifest.get("flash-size") or manifest.get("flash_size")),
|
|
113
|
+
]
|
|
114
|
+
for option, value in option_keys:
|
|
115
|
+
if value:
|
|
116
|
+
write_args += [f"--{option}", str(value)]
|
|
117
|
+
|
|
118
|
+
if manifest.get("compress", True):
|
|
119
|
+
write_args.append("--compress")
|
|
120
|
+
else:
|
|
121
|
+
write_args.append("--no-compress")
|
|
122
|
+
|
|
123
|
+
for offset, artifact_path in resolved_segments:
|
|
124
|
+
write_args += [offset, str(artifact_path)]
|
|
125
|
+
|
|
126
|
+
_run_esptool(write_args)
|
|
127
|
+
|
|
128
|
+
return manifest
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = [
|
|
132
|
+
"FirmwareFlashError",
|
|
133
|
+
"flash_firmware",
|
|
134
|
+
"list_available_boards",
|
|
135
|
+
"load_firmware_manifest",
|
|
136
|
+
]
|
|
@@ -324,6 +324,15 @@ class NDJSONSerialClient:
|
|
|
324
324
|
def ping(self) -> Dict[str, Any]:
|
|
325
325
|
return self._command("ping", {})
|
|
326
326
|
|
|
327
|
+
def power_state(self) -> Dict[str, Any]:
|
|
328
|
+
return self._command("power.state", {})
|
|
329
|
+
|
|
330
|
+
def power_enable(self) -> Dict[str, Any]:
|
|
331
|
+
return self._command("power.enable", {})
|
|
332
|
+
|
|
333
|
+
def power_disable(self) -> Dict[str, Any]:
|
|
334
|
+
return self._command("power.disable", {})
|
|
335
|
+
|
|
327
336
|
def spi_cfg(self, spi: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
328
337
|
payload: Dict[str, Any] = {}
|
|
329
338
|
if spi:
|
|
@@ -1641,6 +1641,65 @@ def test_uart_tx_stdin(monkeypatch):
|
|
|
1641
1641
|
assert record.data == "4f4b0a"
|
|
1642
1642
|
|
|
1643
1643
|
|
|
1644
|
+
def test_flash_command_invokes_helper(monkeypatch, recorded_console):
|
|
1645
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
1646
|
+
record = SimpleNamespace(args=None)
|
|
1647
|
+
|
|
1648
|
+
monkeypatch.setattr(
|
|
1649
|
+
cli_module.flash_module,
|
|
1650
|
+
"list_available_boards",
|
|
1651
|
+
lambda: ["esp32c5"],
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
def fake_flash(**kwargs):
|
|
1655
|
+
record.args = kwargs
|
|
1656
|
+
return {"label": "ESP32-C5"}
|
|
1657
|
+
|
|
1658
|
+
monkeypatch.setattr(cli_module.flash_module, "flash_firmware", fake_flash)
|
|
1659
|
+
|
|
1660
|
+
runner = CliRunner()
|
|
1661
|
+
result = runner.invoke(
|
|
1662
|
+
app,
|
|
1663
|
+
[
|
|
1664
|
+
"flash",
|
|
1665
|
+
"--port",
|
|
1666
|
+
"/dev/ttyUSB0",
|
|
1667
|
+
"--board",
|
|
1668
|
+
"esp32c5",
|
|
1669
|
+
"--erase-first",
|
|
1670
|
+
],
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
assert result.exit_code == 0
|
|
1674
|
+
assert record.args == {
|
|
1675
|
+
"port": "/dev/ttyUSB0",
|
|
1676
|
+
"baudrate": cli_module.DEFAULT_BAUD,
|
|
1677
|
+
"board": "esp32c5",
|
|
1678
|
+
"erase_first": True,
|
|
1679
|
+
}
|
|
1680
|
+
assert "flashed" in recorded_console.getvalue().lower()
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def test_flash_command_handles_failure(monkeypatch):
|
|
1684
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
1685
|
+
monkeypatch.setattr(
|
|
1686
|
+
cli_module.flash_module,
|
|
1687
|
+
"list_available_boards",
|
|
1688
|
+
lambda: ["esp32c5"],
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
def boom(**_kwargs):
|
|
1692
|
+
raise cli_module.flash_module.FirmwareFlashError("boom")
|
|
1693
|
+
|
|
1694
|
+
monkeypatch.setattr(cli_module.flash_module, "flash_firmware", boom)
|
|
1695
|
+
|
|
1696
|
+
runner = CliRunner()
|
|
1697
|
+
result = runner.invoke(app, ["flash", "--port", "/dev/ttyUSB0"])
|
|
1698
|
+
|
|
1699
|
+
assert result.exit_code == 1
|
|
1700
|
+
assert "boom" in result.stdout.lower()
|
|
1701
|
+
|
|
1702
|
+
|
|
1644
1703
|
def test_uart_sub_queries_device(monkeypatch, recorded_console):
|
|
1645
1704
|
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
1646
1705
|
record = SimpleNamespace(payloads=[])
|
|
@@ -1959,6 +2018,86 @@ def test_uart_rx_no_events_message(monkeypatch, recorded_console):
|
|
|
1959
2018
|
assert "No uart.rx events observed" in recorded_console.getvalue()
|
|
1960
2019
|
|
|
1961
2020
|
|
|
2021
|
+
def test_power_command_queries_state(monkeypatch, recorded_console):
|
|
2022
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
2023
|
+
record = SimpleNamespace(calls=[])
|
|
2024
|
+
|
|
2025
|
+
class PowerClient:
|
|
2026
|
+
def __init__(self, *args, **kwargs):
|
|
2027
|
+
pass
|
|
2028
|
+
|
|
2029
|
+
def __enter__(self):
|
|
2030
|
+
return self
|
|
2031
|
+
|
|
2032
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
2033
|
+
return False
|
|
2034
|
+
|
|
2035
|
+
def power_state(self):
|
|
2036
|
+
record.calls.append("power_state")
|
|
2037
|
+
return {
|
|
2038
|
+
"type": "resp",
|
|
2039
|
+
"id": 1,
|
|
2040
|
+
"ok": True,
|
|
2041
|
+
"power": {"enabled": False},
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", PowerClient)
|
|
2045
|
+
runner = CliRunner()
|
|
2046
|
+
result = runner.invoke(app, ["power", "--port", "/dev/ttyUSB0"])
|
|
2047
|
+
|
|
2048
|
+
assert result.exit_code == 0
|
|
2049
|
+
assert record.calls == ["power_state"]
|
|
2050
|
+
assert "power.state payload" in recorded_console.getvalue()
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
@pytest.mark.parametrize(
|
|
2054
|
+
"flag, expected_call, panel_label",
|
|
2055
|
+
[
|
|
2056
|
+
("--enable", "power_enable", "power.enable payload"),
|
|
2057
|
+
("--disable", "power_disable", "power.disable payload"),
|
|
2058
|
+
],
|
|
2059
|
+
)
|
|
2060
|
+
def test_power_command_toggles(monkeypatch, recorded_console, flag, expected_call, panel_label):
|
|
2061
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
2062
|
+
record = SimpleNamespace(calls=[])
|
|
2063
|
+
|
|
2064
|
+
class PowerClient:
|
|
2065
|
+
def __init__(self, *args, **kwargs):
|
|
2066
|
+
pass
|
|
2067
|
+
|
|
2068
|
+
def __enter__(self):
|
|
2069
|
+
return self
|
|
2070
|
+
|
|
2071
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
2072
|
+
return False
|
|
2073
|
+
|
|
2074
|
+
def power_enable(self):
|
|
2075
|
+
record.calls.append("power_enable")
|
|
2076
|
+
return {
|
|
2077
|
+
"type": "resp",
|
|
2078
|
+
"id": 2,
|
|
2079
|
+
"ok": True,
|
|
2080
|
+
"power": {"enabled": True},
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
def power_disable(self):
|
|
2084
|
+
record.calls.append("power_disable")
|
|
2085
|
+
return {
|
|
2086
|
+
"type": "resp",
|
|
2087
|
+
"id": 3,
|
|
2088
|
+
"ok": True,
|
|
2089
|
+
"power": {"enabled": False},
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", PowerClient)
|
|
2093
|
+
runner = CliRunner()
|
|
2094
|
+
result = runner.invoke(app, ["power", "--port", "/dev/ttyUSB0", flag])
|
|
2095
|
+
|
|
2096
|
+
assert result.exit_code == 0
|
|
2097
|
+
assert record.calls == [expected_call]
|
|
2098
|
+
assert panel_label in recorded_console.getvalue()
|
|
2099
|
+
|
|
2100
|
+
|
|
1962
2101
|
def test_render_uart_event_handles_invalid_payload(monkeypatch):
|
|
1963
2102
|
buffer = io.StringIO()
|
|
1964
2103
|
console = Console(file=buffer, force_terminal=False, width=80)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from shuttle import flash
|
|
7
|
+
from shuttle.firmware import DEFAULT_BOARD
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_list_available_boards_includes_default():
|
|
11
|
+
assert DEFAULT_BOARD in flash.list_available_boards()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_load_firmware_manifest_returns_segments():
|
|
15
|
+
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
16
|
+
assert manifest["segments"]
|
|
17
|
+
assert package.endswith("esp32c5")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_flash_firmware_invokes_esptool(monkeypatch):
|
|
21
|
+
calls = []
|
|
22
|
+
|
|
23
|
+
def fake_run(args):
|
|
24
|
+
calls.append(args)
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
27
|
+
|
|
28
|
+
manifest = flash.flash_firmware(
|
|
29
|
+
port="/dev/ttyUSB0",
|
|
30
|
+
baudrate=921600,
|
|
31
|
+
board="esp32c5",
|
|
32
|
+
erase_first=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert manifest["label"]
|
|
36
|
+
assert len(calls) == 2 # erase + write
|
|
37
|
+
erase_args, write_args = calls
|
|
38
|
+
assert erase_args[:4] == ["--chip", manifest["chip"], "--port", "/dev/ttyUSB0"]
|
|
39
|
+
assert "write-flash" in write_args
|
|
40
|
+
assert any("devboard.ino.bin" in arg for arg in write_args)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_flash_firmware_unknown_board():
|
|
44
|
+
with pytest.raises(flash.FirmwareFlashError):
|
|
45
|
+
flash.flash_firmware(port="/dev/null", baudrate=115200, board="does-not-exist")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_flash_firmware_handles_no_compress(monkeypatch):
|
|
49
|
+
calls = []
|
|
50
|
+
|
|
51
|
+
def fake_run(args):
|
|
52
|
+
calls.append(args)
|
|
53
|
+
|
|
54
|
+
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
55
|
+
custom_manifest = dict(manifest)
|
|
56
|
+
custom_manifest["compress"] = False
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
59
|
+
monkeypatch.setattr(
|
|
60
|
+
flash,
|
|
61
|
+
"load_firmware_manifest",
|
|
62
|
+
lambda _board: (custom_manifest, package),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600)
|
|
66
|
+
|
|
67
|
+
assert len(calls) == 1
|
|
68
|
+
assert "--no-compress" in calls[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_run_esptool_success_with_exit_zero(monkeypatch):
|
|
72
|
+
recorded = []
|
|
73
|
+
|
|
74
|
+
def fake_main(args):
|
|
75
|
+
recorded.append(list(args))
|
|
76
|
+
raise SystemExit(0)
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
79
|
+
|
|
80
|
+
flash._run_esptool(["ping"])
|
|
81
|
+
|
|
82
|
+
assert recorded[0] == ["ping"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_run_esptool_failure(monkeypatch):
|
|
86
|
+
def fake_main(_args):
|
|
87
|
+
raise SystemExit(2)
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
90
|
+
|
|
91
|
+
with pytest.raises(flash.FirmwareFlashError):
|
|
92
|
+
flash._run_esptool(["bad"])
|
|
@@ -73,17 +73,21 @@ def test_ndjson_serial_client_payload_builders():
|
|
|
73
73
|
client.spi_disable()
|
|
74
74
|
client.uart_sub({"enable": True, "gap_ms": 5})
|
|
75
75
|
client.uart_sub()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
assert calls
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
client.power_state()
|
|
77
|
+
client.power_enable()
|
|
78
|
+
client.power_disable()
|
|
79
|
+
|
|
80
|
+
assert calls == [
|
|
81
|
+
("spi.cfg", {"spi": {"hz": 123}}),
|
|
82
|
+
("uart.cfg", {"uart": {"baudrate": 9600}}),
|
|
83
|
+
("spi.enable", {}),
|
|
84
|
+
("spi.disable", {}),
|
|
85
|
+
("uart.sub", {"uart": {"sub": {"enable": True, "gap_ms": 5}}}),
|
|
86
|
+
("uart.sub", {}),
|
|
87
|
+
("power.state", {}),
|
|
88
|
+
("power.enable", {}),
|
|
89
|
+
("power.disable", {}),
|
|
90
|
+
]
|
|
87
91
|
|
|
88
92
|
|
|
89
93
|
def test_record_sequence_no_tracker(monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|