lr-shuttle 0.1.0__tar.gz → 0.2.0__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.

Potentially problematic release.


This version of lr-shuttle might be problematic. Click here for more details.

Files changed (34) hide show
  1. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/PKG-INFO +11 -3
  2. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/README.md +9 -2
  3. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/pyproject.toml +9 -2
  4. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/PKG-INFO +11 -3
  5. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/SOURCES.txt +9 -0
  6. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/requires.txt +1 -0
  7. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/shuttle/cli.py +276 -0
  8. lr_shuttle-0.2.0/src/shuttle/firmware/__init__.py +5 -0
  9. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/__init__.py +1 -0
  10. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/boot_app0.bin +0 -0
  11. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
  12. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
  13. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
  14. lr_shuttle-0.2.0/src/shuttle/firmware/esp32c5/manifest.json +17 -0
  15. lr_shuttle-0.2.0/src/shuttle/flash.py +132 -0
  16. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/shuttle/serial_client.py +6 -0
  17. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_cli.py +391 -0
  18. lr_shuttle-0.2.0/tests/test_flash.py +92 -0
  19. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_serial_client.py +6 -0
  20. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/setup.cfg +0 -0
  21. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
  22. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
  23. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/lr_shuttle.egg-info/top_level.txt +0 -0
  24. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/shuttle/constants.py +0 -0
  25. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/shuttle/prodtest.py +0 -0
  26. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/src/shuttle/timo.py +0 -0
  27. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_cli_client.py +0 -0
  28. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_cli_edge.py +0 -0
  29. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_cli_seq.py +0 -0
  30. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_cli_utils.py +0 -0
  31. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_prodtest_edge.py +0 -0
  32. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_prodtest_helpers.py +0 -0
  33. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/tests/test_timo.py +0 -0
  34. {lr_shuttle-0.1.0 → lr_shuttle-0.2.0}/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.1.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 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,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.
@@ -1,6 +1,6 @@
1
1
  # Shuttle
2
2
 
3
- `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.
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,10 +53,16 @@ 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
 
58
- Every device message carries a monotonically increasing `seq` counter. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
65
+ 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:
59
66
 
60
67
  - During a command, any gap in response/event sequence numbers raises a `ShuttleSerialError`, helping you catch dropped frames immediately.
61
68
  - 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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-shuttle"
7
- version = "0.1.0"
7
+ version = "0.2.0"
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.1.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 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,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.
@@ -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
@@ -2,6 +2,7 @@ rich>=13.7
2
2
  pydantic>=2.8
3
3
  typer>=0.12
4
4
  pyserial>=3.5
5
+ esptool>=4.7
5
6
 
6
7
  [dev]
7
8
  build>=1.2.1
@@ -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,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."""
@@ -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,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
+ ]
@@ -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()
@@ -240,6 +240,7 @@ def test_timo_write_reg_reports_parse_error(monkeypatch):
240
240
  # -*- coding: utf-8 -*-
241
241
 
242
242
  import io
243
+ from concurrent.futures import TimeoutError as FutureTimeout
243
244
  from contextlib import contextmanager
244
245
  from types import SimpleNamespace
245
246
 
@@ -1640,6 +1641,396 @@ def test_uart_tx_stdin(monkeypatch):
1640
1641
  assert record.data == "4f4b0a"
1641
1642
 
1642
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
+
1703
+ def test_uart_sub_queries_device(monkeypatch, recorded_console):
1704
+ monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
1705
+ record = SimpleNamespace(payloads=[])
1706
+
1707
+ class UartClient:
1708
+ def __init__(self, *args, **kwargs):
1709
+ pass
1710
+
1711
+ def __enter__(self):
1712
+ return self
1713
+
1714
+ def __exit__(self, exc_type, exc, exc_tb):
1715
+ return False
1716
+
1717
+ def uart_sub(self, sub=None):
1718
+ record.payloads.append(sub)
1719
+ return {
1720
+ "type": "resp",
1721
+ "id": 1,
1722
+ "ok": True,
1723
+ "uart": {"subscription": {"enabled": False}},
1724
+ }
1725
+
1726
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", UartClient)
1727
+
1728
+ runner = CliRunner()
1729
+ result = runner.invoke(app, ["uart-sub", "--port", "/dev/ttyUSB0"])
1730
+
1731
+ assert result.exit_code == 0
1732
+ assert record.payloads == [None]
1733
+ assert "uart.sub payload" in recorded_console.getvalue()
1734
+
1735
+
1736
+ def test_uart_sub_updates_fields(monkeypatch):
1737
+ monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
1738
+ record = SimpleNamespace(payload=None)
1739
+
1740
+ class UartClient:
1741
+ def __init__(self, *args, **kwargs):
1742
+ pass
1743
+
1744
+ def __enter__(self):
1745
+ return self
1746
+
1747
+ def __exit__(self, exc_type, exc, exc_tb):
1748
+ return False
1749
+
1750
+ def uart_sub(self, sub=None):
1751
+ record.payload = sub
1752
+ return {"type": "resp", "id": 1, "ok": True}
1753
+
1754
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", UartClient)
1755
+
1756
+ runner = CliRunner()
1757
+ result = runner.invoke(
1758
+ app,
1759
+ [
1760
+ "uart-sub",
1761
+ "--port",
1762
+ "/dev/ttyUSB0",
1763
+ "--enable",
1764
+ "--gap-ms",
1765
+ "10",
1766
+ "--buf",
1767
+ "32",
1768
+ ],
1769
+ )
1770
+
1771
+ assert result.exit_code == 0
1772
+ assert record.payload == {"enable": True, "gap_ms": 10, "buf": 32}
1773
+
1774
+
1775
+ def test_uart_rx_waits_for_single_event(monkeypatch, recorded_console):
1776
+ record = SimpleNamespace(uart_sub_calls=[])
1777
+
1778
+ class Listener:
1779
+ def __init__(self):
1780
+ self.returned = False
1781
+
1782
+ def next(self, timeout=None):
1783
+ assert timeout is None
1784
+ if self.returned:
1785
+ raise AssertionError("listener queried more than once")
1786
+ self.returned = True
1787
+ return {"data": "4142", "seq": 1, "n": 2, "port": 0}
1788
+
1789
+ class RxClient:
1790
+ def __init__(self, *args, **kwargs):
1791
+ pass
1792
+
1793
+ def __enter__(self):
1794
+ return self
1795
+
1796
+ def __exit__(self, exc_type, exc, exc_tb):
1797
+ return False
1798
+
1799
+ def register_event_listener(self, name):
1800
+ assert name == "uart.rx"
1801
+ return Listener()
1802
+
1803
+ def uart_sub(self, payload=None):
1804
+ record.uart_sub_calls.append(payload)
1805
+ return {"type": "resp", "id": 2, "ok": True}
1806
+
1807
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
1808
+
1809
+ runner = CliRunner()
1810
+ result = runner.invoke(app, ["uart-rx", "--port", "/dev/ttyUSB0"])
1811
+
1812
+ assert result.exit_code == 0
1813
+ assert record.uart_sub_calls == [{"enable": True}]
1814
+ output = recorded_console.getvalue()
1815
+ assert "uart.rx" in output
1816
+ assert "41 42" in output
1817
+
1818
+
1819
+ def test_uart_rx_duration_mode(monkeypatch, recorded_console):
1820
+ class DurationListener:
1821
+ def __init__(self):
1822
+ self.calls = 0
1823
+
1824
+ def next(self, timeout=None):
1825
+ if self.calls < 2:
1826
+ self.calls += 1
1827
+ return {
1828
+ "data": f"0{self.calls}",
1829
+ "seq": self.calls,
1830
+ "n": 1,
1831
+ "port": 0,
1832
+ }
1833
+ raise FutureTimeout()
1834
+
1835
+ listener = DurationListener()
1836
+
1837
+ class RxClient:
1838
+ def __init__(self, *args, **kwargs):
1839
+ pass
1840
+
1841
+ def __enter__(self):
1842
+ return self
1843
+
1844
+ def __exit__(self, exc_type, exc, exc_tb):
1845
+ return False
1846
+
1847
+ def register_event_listener(self, name):
1848
+ assert name == "uart.rx"
1849
+ return listener
1850
+
1851
+ def uart_sub(self, payload=None): # pragma: no cover - disabled via CLI flag
1852
+ return {"type": "resp", "id": 3, "ok": True}
1853
+
1854
+ fake_time = SimpleNamespace(current=0.0)
1855
+
1856
+ def fake_monotonic():
1857
+ value = fake_time.current
1858
+ fake_time.current += 0.3
1859
+ return value
1860
+
1861
+ monkeypatch.setattr(cli_module, "time", SimpleNamespace(monotonic=fake_monotonic))
1862
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
1863
+
1864
+ runner = CliRunner()
1865
+ result = runner.invoke(
1866
+ app,
1867
+ [
1868
+ "uart-rx",
1869
+ "--port",
1870
+ "/dev/ttyUSB0",
1871
+ "--duration",
1872
+ "0.6",
1873
+ "--no-ensure-subscription",
1874
+ ],
1875
+ )
1876
+
1877
+ assert result.exit_code == 0
1878
+ output = recorded_console.getvalue()
1879
+ assert output.count("uart.rx") == 2
1880
+
1881
+
1882
+ def test_uart_rx_duration_conflict():
1883
+ runner = CliRunner()
1884
+ result = runner.invoke(
1885
+ app,
1886
+ [
1887
+ "uart-rx",
1888
+ "--port",
1889
+ "/dev/ttyUSB0",
1890
+ "--duration",
1891
+ "1",
1892
+ "--forever",
1893
+ ],
1894
+ )
1895
+
1896
+ assert result.exit_code != 0
1897
+ combined = (result.stdout + result.stderr).lower()
1898
+ assert "cannot" in combined
1899
+
1900
+
1901
+ def test_uart_rx_gap_requires_ensure():
1902
+ runner = CliRunner()
1903
+ result = runner.invoke(
1904
+ app,
1905
+ [
1906
+ "uart-rx",
1907
+ "--port",
1908
+ "/dev/ttyUSB0",
1909
+ "--gap-ms",
1910
+ "5",
1911
+ "--no-ensure-subscription",
1912
+ ],
1913
+ )
1914
+
1915
+ assert result.exit_code != 0
1916
+ combined = (result.stdout + result.stderr).lower()
1917
+ assert "require" in combined
1918
+
1919
+
1920
+ def test_uart_rx_applies_gap_and_buf(monkeypatch, recorded_console):
1921
+ record = SimpleNamespace(uart_sub_calls=[])
1922
+
1923
+ class Listener:
1924
+ def __init__(self):
1925
+ self.called = False
1926
+
1927
+ def next(self, timeout=None):
1928
+ if self.called:
1929
+ raise AssertionError("listener should only be polled once")
1930
+ self.called = True
1931
+ return {"data": "aa", "seq": 1, "n": 1, "port": 0}
1932
+
1933
+ class RxClient:
1934
+ def __init__(self, *args, **kwargs):
1935
+ pass
1936
+
1937
+ def __enter__(self):
1938
+ return self
1939
+
1940
+ def __exit__(self, exc_type, exc, exc_tb):
1941
+ return False
1942
+
1943
+ def register_event_listener(self, name):
1944
+ assert name == "uart.rx"
1945
+ return Listener()
1946
+
1947
+ def uart_sub(self, payload=None):
1948
+ record.uart_sub_calls.append(payload)
1949
+ return {"type": "resp", "id": 9, "ok": True}
1950
+
1951
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
1952
+
1953
+ runner = CliRunner()
1954
+ result = runner.invoke(
1955
+ app,
1956
+ [
1957
+ "uart-rx",
1958
+ "--port",
1959
+ "/dev/ttyUSB0",
1960
+ "--gap-ms",
1961
+ "15",
1962
+ "--buf",
1963
+ "8",
1964
+ ],
1965
+ )
1966
+
1967
+ assert result.exit_code == 0
1968
+ assert record.uart_sub_calls == [{"enable": True, "gap_ms": 15, "buf": 8}]
1969
+ assert "aa" in recorded_console.getvalue().lower()
1970
+
1971
+
1972
+ def test_uart_rx_no_events_message(monkeypatch, recorded_console):
1973
+ class SilentListener:
1974
+ def next(self, timeout=None):
1975
+ raise FutureTimeout()
1976
+
1977
+ class RxClient:
1978
+ def __init__(self, *args, **kwargs):
1979
+ pass
1980
+
1981
+ def __enter__(self):
1982
+ return self
1983
+
1984
+ def __exit__(self, exc_type, exc, exc_tb):
1985
+ return False
1986
+
1987
+ def register_event_listener(self, name):
1988
+ assert name == "uart.rx"
1989
+ return SilentListener()
1990
+
1991
+ def uart_sub(self, payload=None): # pragma: no cover - disabled via CLI flag
1992
+ return {"type": "resp", "id": 10, "ok": True}
1993
+
1994
+ fake_time = SimpleNamespace(current=0.0)
1995
+
1996
+ def fake_monotonic():
1997
+ value = fake_time.current
1998
+ fake_time.current += 0.2
1999
+ return value
2000
+
2001
+ monkeypatch.setattr(cli_module, "time", SimpleNamespace(monotonic=fake_monotonic))
2002
+ monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
2003
+
2004
+ runner = CliRunner()
2005
+ result = runner.invoke(
2006
+ app,
2007
+ [
2008
+ "uart-rx",
2009
+ "--port",
2010
+ "/dev/ttyUSB0",
2011
+ "--duration",
2012
+ "0.4",
2013
+ "--no-ensure-subscription",
2014
+ ],
2015
+ )
2016
+
2017
+ assert result.exit_code == 0
2018
+ assert "No uart.rx events observed" in recorded_console.getvalue()
2019
+
2020
+
2021
+ def test_render_uart_event_handles_invalid_payload(monkeypatch):
2022
+ buffer = io.StringIO()
2023
+ console = Console(file=buffer, force_terminal=False, width=80)
2024
+ monkeypatch.setattr(cli_module, "console", console)
2025
+
2026
+ cli_module._render_uart_event({"seq": 1})
2027
+ cli_module._render_uart_event({"data": "zz"})
2028
+
2029
+ output = buffer.getvalue()
2030
+ assert "missing data" in output.lower()
2031
+ assert "not valid hex" in output.lower()
2032
+
2033
+
1643
2034
  def test_ndjson_serial_client_handles_event_and_response(monkeypatch, recorded_console):
1644
2035
  monkeypatch.setattr(serial_client_module.secrets, "randbits", lambda bits: 1)
1645
2036
  stub = _install_serial_stub(
@@ -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"])
@@ -71,6 +71,8 @@ def test_ndjson_serial_client_payload_builders():
71
71
  client.uart_cfg({"baudrate": 9600})
72
72
  client.spi_enable()
73
73
  client.spi_disable()
74
+ client.uart_sub({"enable": True, "gap_ms": 5})
75
+ client.uart_sub()
74
76
 
75
77
  assert calls[0][0] == "spi.cfg"
76
78
  assert calls[0][1]["spi"]["hz"] == 123
@@ -78,6 +80,10 @@ def test_ndjson_serial_client_payload_builders():
78
80
  assert calls[1][1]["uart"]["baudrate"] == 9600
79
81
  assert calls[2][0] == "spi.enable"
80
82
  assert calls[3][0] == "spi.disable"
83
+ assert calls[4][0] == "uart.sub"
84
+ assert calls[4][1]["uart"]["sub"] == {"enable": True, "gap_ms": 5}
85
+ assert calls[5][0] == "uart.sub"
86
+ assert calls[5][1] == {}
81
87
 
82
88
 
83
89
  def test_record_sequence_no_tracker(monkeypatch):
File without changes