lr-shuttle 0.2.1__tar.gz → 0.2.2__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 (36) hide show
  1. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/PKG-INFO +10 -4
  2. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/README.md +9 -3
  3. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/pyproject.toml +1 -1
  4. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/PKG-INFO +10 -4
  5. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/cli.py +521 -0
  6. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
  7. lr_shuttle-0.2.2/src/shuttle/prodtest.py +281 -0
  8. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_cli.py +699 -3
  9. lr_shuttle-0.2.2/tests/test_prodtest_helpers.py +147 -0
  10. lr_shuttle-0.2.1/src/shuttle/prodtest.py +0 -120
  11. lr_shuttle-0.2.1/tests/test_prodtest_helpers.py +0 -56
  12. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/setup.cfg +0 -0
  13. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/SOURCES.txt +0 -0
  14. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
  15. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
  16. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/requires.txt +0 -0
  17. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/lr_shuttle.egg-info/top_level.txt +0 -0
  18. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/constants.py +0 -0
  19. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/__init__.py +0 -0
  20. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/__init__.py +0 -0
  21. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/boot_app0.bin +0 -0
  22. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
  23. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
  24. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/firmware/esp32c5/manifest.json +0 -0
  25. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/flash.py +0 -0
  26. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/serial_client.py +0 -0
  27. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/src/shuttle/timo.py +0 -0
  28. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_cli_client.py +0 -0
  29. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_cli_edge.py +0 -0
  30. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_cli_seq.py +0 -0
  31. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_cli_utils.py +0 -0
  32. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_flash.py +0 -0
  33. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_prodtest_edge.py +0 -0
  34. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_serial_client.py +0 -0
  35. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/tests/test_timo.py +0 -0
  36. {lr_shuttle-0.2.1 → lr_shuttle-0.2.2}/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.2.1
3
+ Version: 0.2.2
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
@@ -247,6 +247,12 @@ Events continue to emit until you close the subscription or client, so you can a
247
247
 
248
248
  | Command | Description |
249
249
  | --- | --- |
250
- | `shuttle prodtest reset` | |
251
- | `shuttle prodtest ping` | |
252
- | `shuttle prodtest io-self-test` | |
250
+ | `shuttle prodtest reset` | Reset GPIO pins, IRQ pin and Radio |
251
+ | `shuttle prodtest ping` | Send '+' and expect '-' to verify SPI link |
252
+ | `shuttle prodtest io-self-test` | Perform GPIO self-test on pins given as argument |
253
+ | `shuttle prodtest antenna` | Select antenna |
254
+ | `shuttle prodtest continuous-tx` | Continuous transmitter test |
255
+ | `shuttle prodtest hw-device-id` | Read the 8-byte HW Device ID |
256
+ | `shuttle prodtest serial-number [--value HEX]` | Read or write the 8-byte serial number |
257
+ | `shuttle prodtest config [--value HEX]` | Read or write the 5-byte config payload |
258
+ | `shuttle prodtest erase-nvmc HW_ID` | Erase NVMC if the provided 8-byte HW ID matches |
@@ -221,6 +221,12 @@ Events continue to emit until you close the subscription or client, so you can a
221
221
 
222
222
  | Command | Description |
223
223
  | --- | --- |
224
- | `shuttle prodtest reset` | |
225
- | `shuttle prodtest ping` | |
226
- | `shuttle prodtest io-self-test` | |
224
+ | `shuttle prodtest reset` | Reset GPIO pins, IRQ pin and Radio |
225
+ | `shuttle prodtest ping` | Send '+' and expect '-' to verify SPI link |
226
+ | `shuttle prodtest io-self-test` | Perform GPIO self-test on pins given as argument |
227
+ | `shuttle prodtest antenna` | Select antenna |
228
+ | `shuttle prodtest continuous-tx` | Continuous transmitter test |
229
+ | `shuttle prodtest hw-device-id` | Read the 8-byte HW Device ID |
230
+ | `shuttle prodtest serial-number [--value HEX]` | Read or write the 8-byte serial number |
231
+ | `shuttle prodtest config [--value HEX]` | Read or write the 5-byte config payload |
232
+ | `shuttle prodtest erase-nvmc HW_ID` | Erase NVMC if the provided 8-byte HW ID matches |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-shuttle"
7
- version = "0.2.1"
7
+ version = "0.2.2"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -247,6 +247,12 @@ Events continue to emit until you close the subscription or client, so you can a
247
247
 
248
248
  | Command | Description |
249
249
  | --- | --- |
250
- | `shuttle prodtest reset` | |
251
- | `shuttle prodtest ping` | |
252
- | `shuttle prodtest io-self-test` | |
250
+ | `shuttle prodtest reset` | Reset GPIO pins, IRQ pin and Radio |
251
+ | `shuttle prodtest ping` | Send '+' and expect '-' to verify SPI link |
252
+ | `shuttle prodtest io-self-test` | Perform GPIO self-test on pins given as argument |
253
+ | `shuttle prodtest antenna` | Select antenna |
254
+ | `shuttle prodtest continuous-tx` | Continuous transmitter test |
255
+ | `shuttle prodtest hw-device-id` | Read the 8-byte HW Device ID |
256
+ | `shuttle prodtest serial-number [--value HEX]` | Read or write the 8-byte serial number |
257
+ | `shuttle prodtest config [--value HEX]` | Read or write the 5-byte config payload |
258
+ | `shuttle prodtest erase-nvmc HW_ID` | Erase NVMC if the provided 8-byte HW ID matches |
@@ -45,6 +45,67 @@ app.add_typer(
45
45
 
46
46
  console = Console()
47
47
  UART_RX_POLL_INTERVAL = 0.25
48
+ PRODTEST_ANTENNA_CHOICES = {
49
+ "internal": 0,
50
+ "external": 1,
51
+ }
52
+ PRODTEST_TX_POWER_LEVELS = [
53
+ {
54
+ "value": 0,
55
+ "label": "-30 dBm",
56
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg30dBm",
57
+ "aliases": ["neg30", "neg30dbm", "-30", "-30dbm"],
58
+ },
59
+ {
60
+ "value": 1,
61
+ "label": "-20 dBm",
62
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg20dBm",
63
+ "aliases": ["neg20", "neg20dbm", "-20", "-20dbm"],
64
+ },
65
+ {
66
+ "value": 2,
67
+ "label": "-16 dBm",
68
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg16dBm",
69
+ "aliases": ["neg16", "neg16dbm", "-16", "-16dbm"],
70
+ },
71
+ {
72
+ "value": 3,
73
+ "label": "-12 dBm",
74
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg12dBm",
75
+ "aliases": ["neg12", "neg12dbm", "-12", "-12dbm"],
76
+ },
77
+ {
78
+ "value": 4,
79
+ "label": "-8 dBm",
80
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg8dBm",
81
+ "aliases": ["neg8", "neg8dbm", "-8", "-8dbm"],
82
+ },
83
+ {
84
+ "value": 5,
85
+ "label": "-4 dBm",
86
+ "macro": "RADIO_TXPOWER_TXPOWER_Neg4dBm",
87
+ "aliases": ["neg4", "neg4dbm", "-4", "-4dbm"],
88
+ },
89
+ {
90
+ "value": 6,
91
+ "label": "0 dBm",
92
+ "macro": "RADIO_TXPOWER_TXPOWER_0dBm",
93
+ "aliases": ["0dbm", "0db", "0d", "zero"],
94
+ },
95
+ {
96
+ "value": 7,
97
+ "label": "+4 dBm",
98
+ "macro": "RADIO_TXPOWER_TXPOWER_Pos4dBm",
99
+ "aliases": ["pos4", "pos4dbm", "+4", "+4dbm"],
100
+ },
101
+ ]
102
+ PRODTEST_TX_POWER_META = {entry["value"]: entry for entry in PRODTEST_TX_POWER_LEVELS}
103
+ PRODTEST_TX_POWER_ALIASES: Dict[str, int] = {}
104
+ for entry in PRODTEST_TX_POWER_LEVELS:
105
+ for alias in entry["aliases"]:
106
+ PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
107
+ PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
108
+ PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
48
109
 
49
110
  # Backwards-compatible aliases for tests and external callers
50
111
  _SerialLogger = SerialLogger
@@ -79,6 +140,26 @@ def _normalize_choice(value: Optional[str], *, name: str) -> Optional[str]:
79
140
  return normalized
80
141
 
81
142
 
143
+ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
144
+ trimmed = value.strip().lower()
145
+ if not trimmed:
146
+ raise typer.BadParameter("Power level cannot be empty")
147
+ resolved = PRODTEST_TX_POWER_ALIASES.get(trimmed)
148
+ if resolved is None:
149
+ try:
150
+ parsed = int(trimmed, 0)
151
+ except ValueError:
152
+ parsed = None
153
+ if parsed is not None and parsed in PRODTEST_TX_POWER_META:
154
+ resolved = parsed
155
+ if resolved is None:
156
+ allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
157
+ raise typer.BadParameter(
158
+ f"Power must be one of: {allowed} or an index 0-7"
159
+ )
160
+ return resolved, PRODTEST_TX_POWER_META[resolved]
161
+
162
+
82
163
  def _normalize_uart_parity(value: Optional[str]) -> Optional[str]:
83
164
  if value is None:
84
165
  return None
@@ -195,6 +276,21 @@ def _parse_prodtest_mask(value: str) -> bytes:
195
276
  raise typer.BadParameter(str(exc)) from exc
196
277
 
197
278
 
279
+ def _parse_fixed_hex(value: str, *, name: str, length: int) -> bytes:
280
+ cleaned = value.strip()
281
+ if cleaned.startswith(("0x", "0X")):
282
+ cleaned = cleaned[2:]
283
+ cleaned = cleaned.replace("_", "").replace(" ", "")
284
+ if len(cleaned) != length * 2:
285
+ raise typer.BadParameter(
286
+ f"{name} must be {length * 2} hex characters ({length} bytes)"
287
+ )
288
+ try:
289
+ return bytes.fromhex(cleaned)
290
+ except ValueError as exc:
291
+ raise typer.BadParameter(f"{name} must be valid hex") from exc
292
+
293
+
198
294
  def _format_hex(hex_str: str) -> str:
199
295
  if not hex_str:
200
296
  return "—"
@@ -1438,6 +1534,431 @@ def prodtest_ping(
1438
1534
  raise typer.Exit(1)
1439
1535
 
1440
1536
 
1537
+ @prodtest_app.command("antenna")
1538
+ def prodtest_antenna(
1539
+ ctx: typer.Context,
1540
+ antenna: str = typer.Argument(
1541
+ ...,
1542
+ metavar="ANTENNA",
1543
+ help="Antenna to select (internal/external)",
1544
+ ),
1545
+ port: Optional[str] = typer.Option(
1546
+ None,
1547
+ "--port",
1548
+ envvar="SHUTTLE_PORT",
1549
+ help="Serial port (e.g., /dev/ttyACM0)",
1550
+ ),
1551
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1552
+ timeout: float = typer.Option(
1553
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1554
+ ),
1555
+ ):
1556
+ """Select the active prodtest antenna (opcode 'a')."""
1557
+
1558
+ resources = _ctx_resources(ctx)
1559
+ normalized = antenna.strip().lower()
1560
+ try:
1561
+ antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
1562
+ except KeyError as exc:
1563
+ allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
1564
+ raise typer.BadParameter(
1565
+ f"Antenna must be one of: {allowed}"
1566
+ ) from exc
1567
+
1568
+ sequence = [prodtest.select_antenna(antenna_value)]
1569
+ responses = _execute_timo_sequence(
1570
+ port=port,
1571
+ baudrate=baudrate,
1572
+ timeout=timeout,
1573
+ sequence=sequence,
1574
+ spinner_label=f"Selecting prodtest antenna ({normalized})",
1575
+ logger=resources.get("logger"),
1576
+ seq_tracker=resources.get("seq_tracker"),
1577
+ )
1578
+
1579
+ if not responses:
1580
+ console.print("[red]Device returned no response[/]")
1581
+ raise typer.Exit(1)
1582
+
1583
+ response = responses[0]
1584
+ _render_spi_response(
1585
+ "prodtest antenna",
1586
+ response,
1587
+ command_label="spi.xfer (prodtest)",
1588
+ )
1589
+
1590
+ if not response.get("ok"):
1591
+ raise typer.Exit(1)
1592
+
1593
+ console.print(f"[green]Antenna set to {normalized}")
1594
+
1595
+
1596
+ @prodtest_app.command("continuous-tx")
1597
+ def prodtest_continuous_tx(
1598
+ ctx: typer.Context,
1599
+ channel: int = typer.Argument(
1600
+ ...,
1601
+ metavar="CHANNEL",
1602
+ min=0,
1603
+ max=100,
1604
+ help="NRF_RADIO->FREQUENCY channel index (0=2400 MHz, 100=2500 MHz)",
1605
+ ),
1606
+ power: str = typer.Argument(
1607
+ ...,
1608
+ metavar="POWER",
1609
+ help=(
1610
+ "Output power alias (neg30, neg20, neg16, neg12, neg8, neg4, 0, pos4) "
1611
+ "or numeric index 0-7"
1612
+ ),
1613
+ ),
1614
+ port: Optional[str] = typer.Option(
1615
+ None,
1616
+ "--port",
1617
+ envvar="SHUTTLE_PORT",
1618
+ help="Serial port (e.g., /dev/ttyACM0)",
1619
+ ),
1620
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1621
+ timeout: float = typer.Option(
1622
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1623
+ ),
1624
+ ):
1625
+ """Run the prodtest continuous transmitter test (opcode 'o')."""
1626
+
1627
+ resources = _ctx_resources(ctx)
1628
+ power_value, power_meta = _resolve_prodtest_power_choice(power)
1629
+ freq_mhz = 2400 + channel
1630
+ sequence = [prodtest.continuous_transmitter(channel, power_value)]
1631
+ responses = _execute_timo_sequence(
1632
+ port=port,
1633
+ baudrate=baudrate,
1634
+ timeout=timeout,
1635
+ sequence=sequence,
1636
+ spinner_label=(
1637
+ f"Enabling continuous TX (ch={channel}, power={power_meta['label']})"
1638
+ ),
1639
+ logger=resources.get("logger"),
1640
+ seq_tracker=resources.get("seq_tracker"),
1641
+ )
1642
+
1643
+ if not responses:
1644
+ console.print("[red]Device returned no response[/]")
1645
+ raise typer.Exit(1)
1646
+
1647
+ response = responses[0]
1648
+ _render_spi_response(
1649
+ "prodtest continuous-tx",
1650
+ response,
1651
+ command_label="spi.xfer (prodtest)",
1652
+ )
1653
+
1654
+ if not response.get("ok"):
1655
+ raise typer.Exit(1)
1656
+
1657
+ console.print(
1658
+ f"[green]Continuous transmitter enabled[/] channel={channel} ({freq_mhz} MHz) "
1659
+ f"power={power_meta['label']} ({power_meta['macro']})"
1660
+ )
1661
+
1662
+
1663
+ @prodtest_app.command("hw-device-id")
1664
+ def prodtest_hw_device_id(
1665
+ ctx: typer.Context,
1666
+ port: Optional[str] = typer.Option(
1667
+ None,
1668
+ "--port",
1669
+ envvar="SHUTTLE_PORT",
1670
+ help="Serial port (e.g., /dev/ttyACM0)",
1671
+ ),
1672
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1673
+ timeout: float = typer.Option(
1674
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1675
+ ),
1676
+ ):
1677
+ """Read the prodtest HW Device ID (opcode 'I')."""
1678
+
1679
+ resources = _ctx_resources(ctx)
1680
+ sequence = prodtest.hw_device_id_sequence()
1681
+ responses = _execute_timo_sequence(
1682
+ port=port,
1683
+ baudrate=baudrate,
1684
+ timeout=timeout,
1685
+ sequence=sequence,
1686
+ spinner_label="Reading prodtest HW Device ID",
1687
+ logger=resources.get("logger"),
1688
+ seq_tracker=resources.get("seq_tracker"),
1689
+ )
1690
+
1691
+ if not responses:
1692
+ console.print("[red]Device returned no response[/]")
1693
+ raise typer.Exit(1)
1694
+
1695
+ failed_idx = next(
1696
+ (idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
1697
+ )
1698
+ if failed_idx is not None:
1699
+ phase = "command" if failed_idx == 0 else "payload"
1700
+ _render_spi_response(
1701
+ f"prodtest hw-device-id ({phase})",
1702
+ responses[failed_idx],
1703
+ command_label=f"spi.xfer (prodtest {phase})",
1704
+ )
1705
+ raise typer.Exit(1)
1706
+
1707
+ if len(responses) != len(sequence):
1708
+ console.print("[red]Prodtest command halted before completing all SPI phases[/]")
1709
+ raise typer.Exit(1)
1710
+
1711
+ result_response = responses[-1]
1712
+ _render_spi_response(
1713
+ "prodtest hw-device-id",
1714
+ result_response,
1715
+ command_label="spi.xfer (prodtest payload)",
1716
+ )
1717
+
1718
+ rx_bytes = _decode_hex_response(result_response, label="prodtest hw-device-id")
1719
+ if len(rx_bytes) < prodtest.HW_DEVICE_ID_RESULT_LEN:
1720
+ console.print("[red]Prodtest HW Device ID response shorter than expected[/]")
1721
+ raise typer.Exit(1)
1722
+
1723
+ hw_id = rx_bytes[-prodtest.HW_DEVICE_ID_RESULT_LEN :]
1724
+ console.print(f"HW Device ID: {_format_hex(hw_id.hex())}")
1725
+
1726
+
1727
+ @prodtest_app.command("serial-number")
1728
+ def prodtest_serial_number(
1729
+ ctx: typer.Context,
1730
+ value: Optional[str] = typer.Option(
1731
+ None,
1732
+ "--value",
1733
+ "-v",
1734
+ help="Hex-encoded 8-byte serial number to write (omit to read)",
1735
+ ),
1736
+ port: Optional[str] = typer.Option(
1737
+ None,
1738
+ "--port",
1739
+ envvar="SHUTTLE_PORT",
1740
+ help="Serial port (e.g., /dev/ttyACM0)",
1741
+ ),
1742
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1743
+ timeout: float = typer.Option(
1744
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1745
+ ),
1746
+ ):
1747
+ """Read or write the prodtest serial number (opcode 'x'/'X')."""
1748
+
1749
+ resources = _ctx_resources(ctx)
1750
+ if value is None:
1751
+ sequence = prodtest.serial_number_read_sequence()
1752
+ spinner_label = "Reading prodtest serial number"
1753
+ else:
1754
+ serial_bytes = _parse_fixed_hex(
1755
+ value, name="--value", length=prodtest.SERIAL_NUMBER_LEN
1756
+ )
1757
+ sequence = [prodtest.serial_number_write(serial_bytes)]
1758
+ spinner_label = "Writing prodtest serial number"
1759
+
1760
+ responses = _execute_timo_sequence(
1761
+ port=port,
1762
+ baudrate=baudrate,
1763
+ timeout=timeout,
1764
+ sequence=sequence,
1765
+ spinner_label=spinner_label,
1766
+ logger=resources.get("logger"),
1767
+ seq_tracker=resources.get("seq_tracker"),
1768
+ )
1769
+
1770
+ if not responses:
1771
+ console.print("[red]Device returned no response[/]")
1772
+ raise typer.Exit(1)
1773
+
1774
+ failed_idx = next(
1775
+ (idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
1776
+ )
1777
+ if failed_idx is not None:
1778
+ phase = "command" if failed_idx == 0 else "payload"
1779
+ _render_spi_response(
1780
+ f"prodtest serial-number ({phase})",
1781
+ responses[failed_idx],
1782
+ command_label=f"spi.xfer (prodtest {phase})",
1783
+ )
1784
+ raise typer.Exit(1)
1785
+
1786
+ if len(responses) != len(sequence):
1787
+ console.print(
1788
+ "[red]Prodtest command halted before completing all SPI phases[/]"
1789
+ )
1790
+ raise typer.Exit(1)
1791
+
1792
+ if value is None:
1793
+ result_response = responses[-1]
1794
+ _render_spi_response(
1795
+ "prodtest serial-number",
1796
+ result_response,
1797
+ command_label="spi.xfer (prodtest payload)",
1798
+ )
1799
+ rx_bytes = _decode_hex_response(
1800
+ result_response, label="prodtest serial-number"
1801
+ )
1802
+ if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
1803
+ console.print("[red]Prodtest serial-number response shorter than expected[/]")
1804
+ raise typer.Exit(1)
1805
+ serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
1806
+ console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
1807
+ else:
1808
+ _render_spi_response(
1809
+ "prodtest serial-number",
1810
+ responses[0],
1811
+ command_label="spi.xfer (prodtest command)",
1812
+ )
1813
+ console.print(
1814
+ f"[green]Serial number updated[/] value={_format_hex(serial_bytes.hex())}"
1815
+ )
1816
+
1817
+
1818
+ @prodtest_app.command("config")
1819
+ def prodtest_config(
1820
+ ctx: typer.Context,
1821
+ value: Optional[str] = typer.Option(
1822
+ None,
1823
+ "--value",
1824
+ "-v",
1825
+ help="Hex-encoded 5-byte config payload to write (omit to read)",
1826
+ ),
1827
+ port: Optional[str] = typer.Option(
1828
+ None,
1829
+ "--port",
1830
+ envvar="SHUTTLE_PORT",
1831
+ help="Serial port (e.g., /dev/ttyACM0)",
1832
+ ),
1833
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1834
+ timeout: float = typer.Option(
1835
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1836
+ ),
1837
+ ):
1838
+ """Read or write the prodtest config (opcode 'r'/'R')."""
1839
+
1840
+ resources = _ctx_resources(ctx)
1841
+ if value is None:
1842
+ sequence = prodtest.config_read_sequence()
1843
+ spinner_label = "Reading prodtest config"
1844
+ else:
1845
+ config_bytes = _parse_fixed_hex(
1846
+ value, name="--value", length=prodtest.CONFIG_WRITE_LEN
1847
+ )
1848
+ sequence = [prodtest.config_write(config_bytes)]
1849
+ spinner_label = "Writing prodtest config"
1850
+
1851
+ responses = _execute_timo_sequence(
1852
+ port=port,
1853
+ baudrate=baudrate,
1854
+ timeout=timeout,
1855
+ sequence=sequence,
1856
+ spinner_label=spinner_label,
1857
+ logger=resources.get("logger"),
1858
+ seq_tracker=resources.get("seq_tracker"),
1859
+ )
1860
+
1861
+ if not responses:
1862
+ console.print("[red]Device returned no response[/]")
1863
+ raise typer.Exit(1)
1864
+
1865
+ failed_idx = next(
1866
+ (idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
1867
+ )
1868
+ if failed_idx is not None:
1869
+ phase = "command" if failed_idx == 0 else "payload"
1870
+ _render_spi_response(
1871
+ f"prodtest config ({phase})",
1872
+ responses[failed_idx],
1873
+ command_label=f"spi.xfer (prodtest {phase})",
1874
+ )
1875
+ raise typer.Exit(1)
1876
+
1877
+ if len(responses) != len(sequence):
1878
+ console.print(
1879
+ "[red]Prodtest command halted before completing all SPI phases[/]"
1880
+ )
1881
+ raise typer.Exit(1)
1882
+
1883
+ if value is None:
1884
+ result_response = responses[-1]
1885
+ _render_spi_response(
1886
+ "prodtest config",
1887
+ result_response,
1888
+ command_label="spi.xfer (prodtest payload)",
1889
+ )
1890
+ rx_bytes = _decode_hex_response(result_response, label="prodtest config")
1891
+ if len(rx_bytes) < prodtest.CONFIG_RESULT_LEN:
1892
+ console.print("[red]Prodtest config response shorter than expected[/]")
1893
+ raise typer.Exit(1)
1894
+ config_bytes = rx_bytes[-prodtest.CONFIG_RESULT_LEN :]
1895
+ console.print(f"Config: {_format_hex(config_bytes.hex())}")
1896
+ else:
1897
+ _render_spi_response(
1898
+ "prodtest config",
1899
+ responses[0],
1900
+ command_label="spi.xfer (prodtest command)",
1901
+ )
1902
+ console.print(
1903
+ f"[green]Config updated[/] value={_format_hex(config_bytes.hex())}"
1904
+ )
1905
+
1906
+
1907
+ @prodtest_app.command("erase-nvmc")
1908
+ def prodtest_erase_nvmc(
1909
+ ctx: typer.Context,
1910
+ hw_device_id: str = typer.Argument(
1911
+ ...,
1912
+ metavar="HW_ID",
1913
+ help="Hex-encoded 8-byte HW Device ID (must match device to erase)",
1914
+ ),
1915
+ port: Optional[str] = typer.Option(
1916
+ None,
1917
+ "--port",
1918
+ envvar="SHUTTLE_PORT",
1919
+ help="Serial port (e.g., /dev/ttyACM0)",
1920
+ ),
1921
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1922
+ timeout: float = typer.Option(
1923
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1924
+ ),
1925
+ ):
1926
+ """Erase NVMC if the provided HW Device ID matches (opcode 'e')."""
1927
+
1928
+ resources = _ctx_resources(ctx)
1929
+ hw_id_bytes = _parse_fixed_hex(
1930
+ hw_device_id, name="HW_ID", length=prodtest.ERASE_NVMC_LEN
1931
+ )
1932
+ sequence = [prodtest.erase_nvmc(hw_id_bytes)]
1933
+ responses = _execute_timo_sequence(
1934
+ port=port,
1935
+ baudrate=baudrate,
1936
+ timeout=timeout,
1937
+ sequence=sequence,
1938
+ spinner_label="Erasing NVMC (if HW ID matches)",
1939
+ logger=resources.get("logger"),
1940
+ seq_tracker=resources.get("seq_tracker"),
1941
+ )
1942
+
1943
+ if not responses:
1944
+ console.print("[red]Device returned no response[/]")
1945
+ raise typer.Exit(1)
1946
+
1947
+ response = responses[0]
1948
+ _render_spi_response(
1949
+ "prodtest erase-nvmc",
1950
+ response,
1951
+ command_label="spi.xfer (prodtest)",
1952
+ )
1953
+
1954
+ if not response.get("ok"):
1955
+ raise typer.Exit(1)
1956
+
1957
+ console.print(
1958
+ f"[green]erase-nvmc completed[/] hw-id={_format_hex(hw_id_bytes.hex())}"
1959
+ )
1960
+
1961
+
1441
1962
  @prodtest_app.command("io-self-test")
1442
1963
  def prodtest_io_self_test(
1443
1964
  ctx: typer.Context,