lr-shuttle 0.1.0__py3-none-any.whl → 0.2.0__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.
- {lr_shuttle-0.1.0.dist-info → lr_shuttle-0.2.0.dist-info}/METADATA +11 -3
- lr_shuttle-0.2.0.dist-info/RECORD +18 -0
- shuttle/cli.py +276 -0
- shuttle/firmware/__init__.py +5 -0
- shuttle/firmware/esp32c5/__init__.py +1 -0
- shuttle/firmware/esp32c5/boot_app0.bin +0 -0
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
- shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
- shuttle/firmware/esp32c5/manifest.json +17 -0
- shuttle/flash.py +132 -0
- shuttle/serial_client.py +6 -0
- lr_shuttle-0.1.0.dist-info/RECORD +0 -10
- {lr_shuttle-0.1.0.dist-info → lr_shuttle-0.2.0.dist-info}/WHEEL +0 -0
- {lr_shuttle-0.1.0.dist-info → lr_shuttle-0.2.0.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.1.0.dist-info → lr_shuttle-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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,10 +79,16 @@ 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
|
|
|
83
|
-
Every device message carries a monotonically increasing `seq` counter. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
91
|
+
Every device message carries a monotonically increasing `seq` counter emitted by the firmware transport itself. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
84
92
|
|
|
85
93
|
- During a command, any gap in response/event sequence numbers raises a `ShuttleSerialError`, helping you catch dropped frames immediately.
|
|
86
94
|
- Pass `--seq-meta /path/to/seq.meta` to persist the last observed sequence number. Subsequent Shuttle runs expect the very next `seq` value; if a gap is detected (for example because the device dropped messages while Shuttle was offline), the CLI exits with an error detailing the missing value.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
shuttle/cli.py,sha256=SxTNlqIfGviNQDloIUIVE2IffggkvVKne-GoN9VJJfA,69716
|
|
2
|
+
shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
|
|
3
|
+
shuttle/flash.py,sha256=a2KMECO4aVFuWtP0zrT7cELQgQuyDoSXNM1EBk1Wd-U,4416
|
|
4
|
+
shuttle/prodtest.py,sha256=V0wkbicAb-kqMPKsvvi7lQgcPvto7M8RbACU9pf7y8I,3595
|
|
5
|
+
shuttle/serial_client.py,sha256=B4ci-Ox-ig65H0xO32FgJHfTG0Ievg6VjGfQ_PSZjWE,17104
|
|
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=xrS7cn4mllTXPtpBqpgjuA9863shD1TISgFkHfxMIGk,377792
|
|
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.0.dist-info/METADATA,sha256=DeZ2s75NvqbuHDa-4077ItWJuvLWEbSEvk5byWEa_mw,13037
|
|
15
|
+
lr_shuttle-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
lr_shuttle-0.2.0.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
|
|
17
|
+
lr_shuttle-0.2.0.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
|
|
18
|
+
lr_shuttle-0.2.0.dist-info/RECORD,,
|
shuttle/cli.py
CHANGED
|
@@ -4,17 +4,20 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import string
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from contextlib import contextmanager
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
10
11
|
|
|
11
12
|
import atexit
|
|
13
|
+
from concurrent.futures import TimeoutError as FutureTimeout
|
|
12
14
|
import typer
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
from rich.panel import Panel
|
|
15
17
|
from rich.pretty import Pretty
|
|
16
18
|
from rich.table import Table
|
|
17
19
|
|
|
20
|
+
from . import flash as flash_module
|
|
18
21
|
from . import prodtest, timo
|
|
19
22
|
from .constants import (
|
|
20
23
|
DEFAULT_BAUD,
|
|
@@ -22,6 +25,7 @@ from .constants import (
|
|
|
22
25
|
SPI_CHOICE_FIELDS,
|
|
23
26
|
UART_PARITY_ALIASES,
|
|
24
27
|
)
|
|
28
|
+
from .firmware import DEFAULT_BOARD
|
|
25
29
|
from .serial_client import (
|
|
26
30
|
NDJSONSerialClient,
|
|
27
31
|
SerialLogger,
|
|
@@ -40,6 +44,7 @@ app.add_typer(
|
|
|
40
44
|
)
|
|
41
45
|
|
|
42
46
|
console = Console()
|
|
47
|
+
UART_RX_POLL_INTERVAL = 0.25
|
|
43
48
|
|
|
44
49
|
# Backwards-compatible aliases for tests and external callers
|
|
45
50
|
_SerialLogger = SerialLogger
|
|
@@ -369,6 +374,74 @@ def _render_ping_response(response: Dict[str, Any]) -> None:
|
|
|
369
374
|
_render_payload_response("ping", response)
|
|
370
375
|
|
|
371
376
|
|
|
377
|
+
def _render_uart_event(event: Dict[str, Any]) -> None:
|
|
378
|
+
data_hex = event.get("data")
|
|
379
|
+
if not isinstance(data_hex, str):
|
|
380
|
+
console.print("[yellow]uart.rx event missing data payload[/]")
|
|
381
|
+
return
|
|
382
|
+
try:
|
|
383
|
+
payload = bytes.fromhex(data_hex)
|
|
384
|
+
except ValueError:
|
|
385
|
+
console.print("[red]uart.rx event payload is not valid hex[/]")
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
seq = event.get("seq", "?")
|
|
389
|
+
port = event.get("port", 0)
|
|
390
|
+
n_field = event.get("n")
|
|
391
|
+
byte_count = n_field if isinstance(n_field, int) else len(payload)
|
|
392
|
+
preview_limit = 64
|
|
393
|
+
ascii_preview = "".join(
|
|
394
|
+
chr(b) if 32 <= b < 127 else "." for b in payload[:preview_limit]
|
|
395
|
+
)
|
|
396
|
+
if len(payload) > preview_limit:
|
|
397
|
+
ascii_preview += " ..."
|
|
398
|
+
|
|
399
|
+
console.print(f"[green]uart.rx[/] seq={seq} port={port} bytes={byte_count}")
|
|
400
|
+
console.print(f" hex : {_format_hex(data_hex)}")
|
|
401
|
+
if payload:
|
|
402
|
+
console.print(
|
|
403
|
+
f" ascii: {ascii_preview if ascii_preview else '(non-printable)'}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _consume_uart_events(
|
|
408
|
+
listener,
|
|
409
|
+
*,
|
|
410
|
+
duration: Optional[float],
|
|
411
|
+
forever: bool,
|
|
412
|
+
) -> int:
|
|
413
|
+
events_seen = 0
|
|
414
|
+
start = time.monotonic()
|
|
415
|
+
deadline = start + duration if duration is not None else None
|
|
416
|
+
|
|
417
|
+
while True:
|
|
418
|
+
if deadline is not None:
|
|
419
|
+
remaining = deadline - time.monotonic()
|
|
420
|
+
if remaining <= 0:
|
|
421
|
+
break
|
|
422
|
+
timeout_value = (
|
|
423
|
+
remaining
|
|
424
|
+
if remaining < UART_RX_POLL_INTERVAL
|
|
425
|
+
else UART_RX_POLL_INTERVAL
|
|
426
|
+
)
|
|
427
|
+
elif forever:
|
|
428
|
+
timeout_value = UART_RX_POLL_INTERVAL
|
|
429
|
+
else:
|
|
430
|
+
timeout_value = None
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
event = listener.next(timeout=timeout_value)
|
|
434
|
+
except FutureTimeout:
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
_render_uart_event(event)
|
|
438
|
+
events_seen += 1
|
|
439
|
+
if duration is None and not forever:
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
return events_seen
|
|
443
|
+
|
|
444
|
+
|
|
372
445
|
@app.callback()
|
|
373
446
|
def main(
|
|
374
447
|
ctx: typer.Context,
|
|
@@ -1671,6 +1744,69 @@ def uart_cfg_command(
|
|
|
1671
1744
|
_render_payload_response("uart.cfg", response)
|
|
1672
1745
|
|
|
1673
1746
|
|
|
1747
|
+
@app.command("uart-sub")
|
|
1748
|
+
def uart_sub_command(
|
|
1749
|
+
ctx: typer.Context,
|
|
1750
|
+
port: Optional[str] = typer.Option(
|
|
1751
|
+
None,
|
|
1752
|
+
"--port",
|
|
1753
|
+
envvar="SHUTTLE_PORT",
|
|
1754
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1755
|
+
),
|
|
1756
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1757
|
+
timeout: float = typer.Option(
|
|
1758
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1759
|
+
),
|
|
1760
|
+
enable: Optional[bool] = typer.Option(
|
|
1761
|
+
None,
|
|
1762
|
+
"--enable/--disable",
|
|
1763
|
+
help="Enable or disable uart.rx event emission",
|
|
1764
|
+
),
|
|
1765
|
+
gap_ms: Optional[int] = typer.Option(
|
|
1766
|
+
None,
|
|
1767
|
+
"--gap-ms",
|
|
1768
|
+
min=0,
|
|
1769
|
+
max=1000,
|
|
1770
|
+
help="Milliseconds of idle before emitting buffered bytes",
|
|
1771
|
+
),
|
|
1772
|
+
buf: Optional[int] = typer.Option(
|
|
1773
|
+
None,
|
|
1774
|
+
"--buf",
|
|
1775
|
+
min=1,
|
|
1776
|
+
max=1024,
|
|
1777
|
+
help="Emit an event once this many bytes are buffered",
|
|
1778
|
+
),
|
|
1779
|
+
):
|
|
1780
|
+
"""Query or update uart.rx subscription settings."""
|
|
1781
|
+
|
|
1782
|
+
resources = _ctx_resources(ctx)
|
|
1783
|
+
sub_payload: Dict[str, Any] = {}
|
|
1784
|
+
if enable is not None:
|
|
1785
|
+
sub_payload["enable"] = enable
|
|
1786
|
+
if gap_ms is not None:
|
|
1787
|
+
sub_payload["gap_ms"] = gap_ms
|
|
1788
|
+
if buf is not None:
|
|
1789
|
+
sub_payload["buf"] = buf
|
|
1790
|
+
|
|
1791
|
+
resolved_port = _require_port(port)
|
|
1792
|
+
action = "Updating" if sub_payload else "Querying"
|
|
1793
|
+
with spinner(f"{action} uart.sub over {resolved_port}"):
|
|
1794
|
+
try:
|
|
1795
|
+
with NDJSONSerialClient(
|
|
1796
|
+
resolved_port,
|
|
1797
|
+
baudrate=baudrate,
|
|
1798
|
+
timeout=timeout,
|
|
1799
|
+
logger=resources.get("logger"),
|
|
1800
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1801
|
+
) as client:
|
|
1802
|
+
response = client.uart_sub(sub_payload if sub_payload else None)
|
|
1803
|
+
except ShuttleSerialError as exc:
|
|
1804
|
+
console.print(f"[red]{exc}[/]")
|
|
1805
|
+
raise typer.Exit(1) from exc
|
|
1806
|
+
|
|
1807
|
+
_render_payload_response("uart.sub", response)
|
|
1808
|
+
|
|
1809
|
+
|
|
1674
1810
|
@app.command("uart-tx")
|
|
1675
1811
|
def uart_tx_command(
|
|
1676
1812
|
ctx: typer.Context,
|
|
@@ -1754,6 +1890,146 @@ def uart_tx_command(
|
|
|
1754
1890
|
_render_payload_response("uart.tx", response)
|
|
1755
1891
|
|
|
1756
1892
|
|
|
1893
|
+
@app.command("uart-rx")
|
|
1894
|
+
def uart_rx_command(
|
|
1895
|
+
ctx: typer.Context,
|
|
1896
|
+
port: Optional[str] = typer.Option(
|
|
1897
|
+
None,
|
|
1898
|
+
"--port",
|
|
1899
|
+
envvar="SHUTTLE_PORT",
|
|
1900
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1901
|
+
),
|
|
1902
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1903
|
+
timeout: float = typer.Option(
|
|
1904
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1905
|
+
),
|
|
1906
|
+
duration: Optional[float] = typer.Option(
|
|
1907
|
+
None,
|
|
1908
|
+
"--duration",
|
|
1909
|
+
min=0.0,
|
|
1910
|
+
help="Listen for uart.rx events for N seconds before exiting",
|
|
1911
|
+
),
|
|
1912
|
+
forever: bool = typer.Option(
|
|
1913
|
+
False,
|
|
1914
|
+
"--forever",
|
|
1915
|
+
help="Stream uart.rx events until interrupted",
|
|
1916
|
+
),
|
|
1917
|
+
ensure_subscription: bool = typer.Option(
|
|
1918
|
+
True,
|
|
1919
|
+
"--ensure-subscription/--no-ensure-subscription",
|
|
1920
|
+
help="Call uart.sub --enable before listening",
|
|
1921
|
+
),
|
|
1922
|
+
gap_ms: Optional[int] = typer.Option(
|
|
1923
|
+
None,
|
|
1924
|
+
"--gap-ms",
|
|
1925
|
+
min=0,
|
|
1926
|
+
max=1000,
|
|
1927
|
+
help="Override gap_ms while ensuring the subscription",
|
|
1928
|
+
),
|
|
1929
|
+
buf: Optional[int] = typer.Option(
|
|
1930
|
+
None,
|
|
1931
|
+
"--buf",
|
|
1932
|
+
min=1,
|
|
1933
|
+
max=1024,
|
|
1934
|
+
help="Override buf while ensuring the subscription",
|
|
1935
|
+
),
|
|
1936
|
+
):
|
|
1937
|
+
"""Stream uart.rx events emitted by the firmware."""
|
|
1938
|
+
|
|
1939
|
+
if forever and duration is not None:
|
|
1940
|
+
raise typer.BadParameter("--duration cannot be combined with --forever")
|
|
1941
|
+
|
|
1942
|
+
if (gap_ms is not None or buf is not None) and not ensure_subscription:
|
|
1943
|
+
raise typer.BadParameter("--gap-ms/--buf require --ensure-subscription")
|
|
1944
|
+
|
|
1945
|
+
resources = _ctx_resources(ctx)
|
|
1946
|
+
resolved_port = _require_port(port)
|
|
1947
|
+
console.print(f"Listening for uart.rx events on {resolved_port}...")
|
|
1948
|
+
|
|
1949
|
+
events_seen = 0
|
|
1950
|
+
try:
|
|
1951
|
+
with NDJSONSerialClient(
|
|
1952
|
+
resolved_port,
|
|
1953
|
+
baudrate=baudrate,
|
|
1954
|
+
timeout=timeout,
|
|
1955
|
+
logger=resources.get("logger"),
|
|
1956
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1957
|
+
) as client:
|
|
1958
|
+
if ensure_subscription:
|
|
1959
|
+
sub_payload: Dict[str, Any] = {"enable": True}
|
|
1960
|
+
if gap_ms is not None:
|
|
1961
|
+
sub_payload["gap_ms"] = gap_ms
|
|
1962
|
+
if buf is not None:
|
|
1963
|
+
sub_payload["buf"] = buf
|
|
1964
|
+
client.uart_sub(sub_payload)
|
|
1965
|
+
listener = client.register_event_listener("uart.rx")
|
|
1966
|
+
events_seen = _consume_uart_events(
|
|
1967
|
+
listener, duration=duration, forever=forever
|
|
1968
|
+
)
|
|
1969
|
+
except ShuttleSerialError as exc:
|
|
1970
|
+
console.print(f"[red]{exc}[/]")
|
|
1971
|
+
raise typer.Exit(1) from exc
|
|
1972
|
+
except KeyboardInterrupt:
|
|
1973
|
+
console.print("\n[yellow]Interrupted by user[/]")
|
|
1974
|
+
raise typer.Exit(1)
|
|
1975
|
+
|
|
1976
|
+
if not events_seen:
|
|
1977
|
+
console.print("[yellow]No uart.rx events observed[/]")
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
@app.command("flash")
|
|
1981
|
+
def flash_command(
|
|
1982
|
+
ctx: typer.Context,
|
|
1983
|
+
port: Optional[str] = typer.Option(
|
|
1984
|
+
None,
|
|
1985
|
+
"--port",
|
|
1986
|
+
envvar="SHUTTLE_PORT",
|
|
1987
|
+
help="Serial port connected to the ESP32-C5 devboard",
|
|
1988
|
+
),
|
|
1989
|
+
baudrate: int = typer.Option(
|
|
1990
|
+
DEFAULT_BAUD,
|
|
1991
|
+
"--baud",
|
|
1992
|
+
help="Serial baud used for the ROM bootloader",
|
|
1993
|
+
),
|
|
1994
|
+
board: str = typer.Option(
|
|
1995
|
+
DEFAULT_BOARD,
|
|
1996
|
+
"--board",
|
|
1997
|
+
help="Firmware bundle to flash",
|
|
1998
|
+
),
|
|
1999
|
+
erase_first: bool = typer.Option(
|
|
2000
|
+
False,
|
|
2001
|
+
"--erase-first/--no-erase-first",
|
|
2002
|
+
help="Erase the entire flash before writing",
|
|
2003
|
+
),
|
|
2004
|
+
):
|
|
2005
|
+
"""Flash the bundled firmware image to the devboard."""
|
|
2006
|
+
|
|
2007
|
+
resolved_port = _require_port(port)
|
|
2008
|
+
available_boards = flash_module.list_available_boards()
|
|
2009
|
+
available = ", ".join(available_boards)
|
|
2010
|
+
if board not in available_boards:
|
|
2011
|
+
raise typer.BadParameter(
|
|
2012
|
+
f"Unknown firmware bundle '{board}'. Available: {available}"
|
|
2013
|
+
)
|
|
2014
|
+
|
|
2015
|
+
with spinner(f"Flashing {board} firmware to {resolved_port}"):
|
|
2016
|
+
try:
|
|
2017
|
+
manifest = flash_module.flash_firmware(
|
|
2018
|
+
port=resolved_port,
|
|
2019
|
+
baudrate=baudrate,
|
|
2020
|
+
board=board,
|
|
2021
|
+
erase_first=erase_first,
|
|
2022
|
+
)
|
|
2023
|
+
except flash_module.FirmwareFlashError as exc:
|
|
2024
|
+
console.print(f"[red]{exc}[/]")
|
|
2025
|
+
raise typer.Exit(1) from exc
|
|
2026
|
+
|
|
2027
|
+
label = str(manifest.get("label", board))
|
|
2028
|
+
console.print(
|
|
2029
|
+
f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
|
|
1757
2033
|
@app.command("get-info")
|
|
1758
2034
|
def get_info(
|
|
1759
2035
|
ctx: typer.Context,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ESP32-C5 devboard firmware bundle."""
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|
shuttle/flash.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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("Manifest segment entries require 'offset' and 'file'")
|
|
94
|
+
traversable = resources.files(package) / file_name
|
|
95
|
+
try:
|
|
96
|
+
file_path = stack.enter_context(resources.as_file(traversable))
|
|
97
|
+
except FileNotFoundError as exc:
|
|
98
|
+
raise FirmwareFlashError(f"Missing firmware artifact: {file_name}") from exc
|
|
99
|
+
resolved_segments.append((str(offset), Path(file_path)))
|
|
100
|
+
|
|
101
|
+
if erase_first:
|
|
102
|
+
_run_esptool(base_args + ["erase-flash"])
|
|
103
|
+
|
|
104
|
+
write_args: List[str] = base_args + ["write-flash"]
|
|
105
|
+
option_keys = [
|
|
106
|
+
("flash-mode", manifest.get("flash-mode") or manifest.get("flash_mode")),
|
|
107
|
+
("flash-freq", manifest.get("flash-freq") or manifest.get("flash_freq")),
|
|
108
|
+
("flash-size", manifest.get("flash-size") or manifest.get("flash_size")),
|
|
109
|
+
]
|
|
110
|
+
for option, value in option_keys:
|
|
111
|
+
if value:
|
|
112
|
+
write_args += [f"--{option}", str(value)]
|
|
113
|
+
|
|
114
|
+
if manifest.get("compress", True):
|
|
115
|
+
write_args.append("--compress")
|
|
116
|
+
else:
|
|
117
|
+
write_args.append("--no-compress")
|
|
118
|
+
|
|
119
|
+
for offset, artifact_path in resolved_segments:
|
|
120
|
+
write_args += [offset, str(artifact_path)]
|
|
121
|
+
|
|
122
|
+
_run_esptool(write_args)
|
|
123
|
+
|
|
124
|
+
return manifest
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = [
|
|
128
|
+
"FirmwareFlashError",
|
|
129
|
+
"flash_firmware",
|
|
130
|
+
"list_available_boards",
|
|
131
|
+
"load_firmware_manifest",
|
|
132
|
+
]
|
shuttle/serial_client.py
CHANGED
|
@@ -342,6 +342,12 @@ class NDJSONSerialClient:
|
|
|
342
342
|
payload["port"] = port
|
|
343
343
|
return self._command("uart.tx", payload)
|
|
344
344
|
|
|
345
|
+
def uart_sub(self, sub: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
346
|
+
payload: Dict[str, Any] = {}
|
|
347
|
+
if sub:
|
|
348
|
+
payload["uart"] = {"sub": sub}
|
|
349
|
+
return self._command("uart.sub", payload)
|
|
350
|
+
|
|
345
351
|
def _command(self, op: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
346
352
|
future = self.send_command(op, params)
|
|
347
353
|
return future.result()
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
shuttle/cli.py,sha256=KKNThf4eG6oOmqR__tw6Yqx-Y62epztbKje3WfNWGy0,61201
|
|
2
|
-
shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
|
|
3
|
-
shuttle/prodtest.py,sha256=V0wkbicAb-kqMPKsvvi7lQgcPvto7M8RbACU9pf7y8I,3595
|
|
4
|
-
shuttle/serial_client.py,sha256=YdZFM13KrOKHGjqW0R25jdfaf4i_O1OtaPDSzVWm5c8,16877
|
|
5
|
-
shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
|
|
6
|
-
lr_shuttle-0.1.0.dist-info/METADATA,sha256=TvjPypziHRV3HhEwQXJOMRFgBr8fWm4C5FmDGYcSuC8,11999
|
|
7
|
-
lr_shuttle-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
lr_shuttle-0.1.0.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
|
|
9
|
-
lr_shuttle-0.1.0.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
|
|
10
|
-
lr_shuttle-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|