lr-shuttle 0.1.1__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.1.1
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 SPI-to-USB 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
+ `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
 
@@ -0,0 +1,18 @@
1
+ shuttle/cli.py,sha256=h8wElgPA-xN3MRUK3j_uBuWZs0hS_44bOc4WG_zYF_k,71365
2
+ shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
+ shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
+ shuttle/prodtest.py,sha256=V0wkbicAb-kqMPKsvvi7lQgcPvto7M8RbACU9pf7y8I,3595
5
+ shuttle/serial_client.py,sha256=CnqWpC4CyxNXzsQQgRQsGwkDf19OoIEYvipu4kt2IQo,17392
6
+ shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
7
+ shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
8
+ shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
9
+ shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
10
+ shuttle/firmware/esp32c5/devboard.ino.bin,sha256=ehmd3EEcns5cAXrIqjQDxCQJr9JrKEdGqvScv0utF8Q,378880
11
+ shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
12
+ shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
13
+ shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
14
+ lr_shuttle-0.2.1.dist-info/METADATA,sha256=NtysDMhV36TKVUVNlho_2zogxpYTDKl_sZjgdjCVRjQ,13037
15
+ lr_shuttle-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ lr_shuttle-0.2.1.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
+ lr_shuttle-0.2.1.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
+ lr_shuttle-0.2.1.dist-info/RECORD,,
shuttle/cli.py CHANGED
@@ -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,5 @@
1
+ """Embedded firmware artifacts bundled with the Shuttle CLI."""
2
+
3
+ DEFAULT_BOARD = "esp32c5"
4
+
5
+ __all__ = ["DEFAULT_BOARD"]
@@ -0,0 +1 @@
1
+ """ESP32-C5 devboard firmware bundle."""
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
+ }
shuttle/flash.py ADDED
@@ -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
+ ]
shuttle/serial_client.py CHANGED
@@ -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:
@@ -1,10 +0,0 @@
1
- shuttle/cli.py,sha256=UvaRuBKVdM0JsGC-Gqh8tYauMslouAlF6QuZTXGHjTM,68046
2
- shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
- shuttle/prodtest.py,sha256=V0wkbicAb-kqMPKsvvi7lQgcPvto7M8RbACU9pf7y8I,3595
4
- shuttle/serial_client.py,sha256=B4ci-Ox-ig65H0xO32FgJHfTG0Ievg6VjGfQ_PSZjWE,17104
5
- shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
6
- lr_shuttle-0.1.1.dist-info/METADATA,sha256=HYaly9JaR-hKo_S_SzPzJc0LgCoLkh4o-Mar-oRvxlE,12040
7
- lr_shuttle-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- lr_shuttle-0.1.1.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
9
- lr_shuttle-0.1.1.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
10
- lr_shuttle-0.1.1.dist-info/RECORD,,