lr-shuttle 0.2.1__py3-none-any.whl → 0.2.3__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.
Potentially problematic release.
This version of lr-shuttle might be problematic. Click here for more details.
- {lr_shuttle-0.2.1.dist-info → lr_shuttle-0.2.3.dist-info}/METADATA +10 -4
- {lr_shuttle-0.2.1.dist-info → lr_shuttle-0.2.3.dist-info}/RECORD +9 -9
- shuttle/cli.py +710 -10
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/prodtest.py +166 -5
- shuttle/serial_client.py +9 -1
- {lr_shuttle-0.2.1.dist-info → lr_shuttle-0.2.3.dist-info}/WHEEL +0 -0
- {lr_shuttle-0.2.1.dist-info → lr_shuttle-0.2.3.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.1.dist-info → lr_shuttle-0.2.3.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.2.
|
|
3
|
+
Version: 0.2.3
|
|
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 |
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
shuttle/cli.py,sha256=
|
|
1
|
+
shuttle/cli.py,sha256=i75UW_r_v2AbVTMA1X4u_LnVf3fQz5Xc00IjbxNZwN8,93515
|
|
2
2
|
shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
|
|
3
3
|
shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
|
|
4
|
-
shuttle/prodtest.py,sha256=
|
|
5
|
-
shuttle/serial_client.py,sha256=
|
|
4
|
+
shuttle/prodtest.py,sha256=1q-1dZYtrWBpI_e0jPgROVGbb_42Y0q0DIxDoo4MWUk,8020
|
|
5
|
+
shuttle/serial_client.py,sha256=WjGanUAL16qw2RZcCjHjYMKHsk-B6zY3cMeS0gPtPHE,17650
|
|
6
6
|
shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
|
|
7
7
|
shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
|
|
8
8
|
shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
|
|
9
9
|
shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
|
|
10
|
-
shuttle/firmware/esp32c5/devboard.ino.bin,sha256=
|
|
10
|
+
shuttle/firmware/esp32c5/devboard.ino.bin,sha256=eCkSHtcBBbLRvHQmkvXR1jRoMMqMTMEXaXMv8GMIsD8,1098256
|
|
11
11
|
shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
|
|
12
12
|
shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
|
|
13
13
|
shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
|
|
14
|
-
lr_shuttle-0.2.
|
|
15
|
-
lr_shuttle-0.2.
|
|
16
|
-
lr_shuttle-0.2.
|
|
17
|
-
lr_shuttle-0.2.
|
|
18
|
-
lr_shuttle-0.2.
|
|
14
|
+
lr_shuttle-0.2.3.dist-info/METADATA,sha256=_hp5-NmIdtKB2wZIQww4oPpXCe3mDHqevScN3G9uj4Y,13611
|
|
15
|
+
lr_shuttle-0.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
lr_shuttle-0.2.3.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
|
|
17
|
+
lr_shuttle-0.2.3.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
|
|
18
|
+
lr_shuttle-0.2.3.dist-info/RECORD,,
|
shuttle/cli.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
5
7
|
import string
|
|
6
8
|
import sys
|
|
7
9
|
import time
|
|
@@ -45,6 +47,70 @@ app.add_typer(
|
|
|
45
47
|
|
|
46
48
|
console = Console()
|
|
47
49
|
UART_RX_POLL_INTERVAL = 0.25
|
|
50
|
+
PRODTEST_ANTENNA_CHOICES = {
|
|
51
|
+
"internal": 0,
|
|
52
|
+
"external": 1,
|
|
53
|
+
}
|
|
54
|
+
PRODTEST_TX_POWER_LEVELS = [
|
|
55
|
+
{
|
|
56
|
+
"value": 0,
|
|
57
|
+
"label": "-30 dBm",
|
|
58
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg30dBm",
|
|
59
|
+
"aliases": ["neg30", "neg30dbm", "-30", "-30dbm"],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"value": 1,
|
|
63
|
+
"label": "-20 dBm",
|
|
64
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg20dBm",
|
|
65
|
+
"aliases": ["neg20", "neg20dbm", "-20", "-20dbm"],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"value": 2,
|
|
69
|
+
"label": "-16 dBm",
|
|
70
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg16dBm",
|
|
71
|
+
"aliases": ["neg16", "neg16dbm", "-16", "-16dbm"],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"value": 3,
|
|
75
|
+
"label": "-12 dBm",
|
|
76
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg12dBm",
|
|
77
|
+
"aliases": ["neg12", "neg12dbm", "-12", "-12dbm"],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"value": 4,
|
|
81
|
+
"label": "-8 dBm",
|
|
82
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg8dBm",
|
|
83
|
+
"aliases": ["neg8", "neg8dbm", "-8", "-8dbm"],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"value": 5,
|
|
87
|
+
"label": "-4 dBm",
|
|
88
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Neg4dBm",
|
|
89
|
+
"aliases": ["neg4", "neg4dbm", "-4", "-4dbm"],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"value": 6,
|
|
93
|
+
"label": "0 dBm",
|
|
94
|
+
"macro": "RADIO_TXPOWER_TXPOWER_0dBm",
|
|
95
|
+
"aliases": ["0dbm", "0db", "0d", "zero"],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"value": 7,
|
|
99
|
+
"label": "+4 dBm",
|
|
100
|
+
"macro": "RADIO_TXPOWER_TXPOWER_Pos4dBm",
|
|
101
|
+
"aliases": ["pos4", "pos4dbm", "+4", "+4dbm"],
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
PRODTEST_TX_POWER_META = {entry["value"]: entry for entry in PRODTEST_TX_POWER_LEVELS}
|
|
105
|
+
PRODTEST_TX_POWER_ALIASES: Dict[str, int] = {}
|
|
106
|
+
for entry in PRODTEST_TX_POWER_LEVELS:
|
|
107
|
+
for alias in entry["aliases"]:
|
|
108
|
+
PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
|
|
109
|
+
PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
|
|
110
|
+
PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
|
|
111
|
+
|
|
112
|
+
_HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
|
|
113
|
+
_IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
|
|
48
114
|
|
|
49
115
|
# Backwards-compatible aliases for tests and external callers
|
|
50
116
|
_SerialLogger = SerialLogger
|
|
@@ -79,6 +145,26 @@ def _normalize_choice(value: Optional[str], *, name: str) -> Optional[str]:
|
|
|
79
145
|
return normalized
|
|
80
146
|
|
|
81
147
|
|
|
148
|
+
def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
|
|
149
|
+
trimmed = value.strip().lower()
|
|
150
|
+
if not trimmed:
|
|
151
|
+
raise typer.BadParameter("Power level cannot be empty")
|
|
152
|
+
resolved = PRODTEST_TX_POWER_ALIASES.get(trimmed)
|
|
153
|
+
if resolved is None:
|
|
154
|
+
try:
|
|
155
|
+
parsed = int(trimmed, 0)
|
|
156
|
+
except ValueError:
|
|
157
|
+
parsed = None
|
|
158
|
+
if parsed is not None and parsed in PRODTEST_TX_POWER_META:
|
|
159
|
+
resolved = parsed
|
|
160
|
+
if resolved is None:
|
|
161
|
+
allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
|
|
162
|
+
raise typer.BadParameter(
|
|
163
|
+
f"Power must be one of: {allowed} or an index 0-7"
|
|
164
|
+
)
|
|
165
|
+
return resolved, PRODTEST_TX_POWER_META[resolved]
|
|
166
|
+
|
|
167
|
+
|
|
82
168
|
def _normalize_uart_parity(value: Optional[str]) -> Optional[str]:
|
|
83
169
|
if value is None:
|
|
84
170
|
return None
|
|
@@ -170,9 +256,24 @@ def _resolve_uart_payload(
|
|
|
170
256
|
return payload_bytes.hex(), len(payload_bytes)
|
|
171
257
|
|
|
172
258
|
|
|
259
|
+
def _normalize_port(port: str) -> str:
|
|
260
|
+
trimmed = port.strip()
|
|
261
|
+
if not trimmed:
|
|
262
|
+
raise typer.BadParameter(
|
|
263
|
+
"Serial port is required (use --port or SHUTTLE_PORT)"
|
|
264
|
+
)
|
|
265
|
+
if "://" in trimmed:
|
|
266
|
+
return trimmed
|
|
267
|
+
if trimmed.startswith("/") or trimmed.startswith("\\"):
|
|
268
|
+
return trimmed
|
|
269
|
+
if _HOST_PORT_PATTERN.match(trimmed) or _IPV6_HOST_PORT_PATTERN.match(trimmed):
|
|
270
|
+
return f"socket://{trimmed}"
|
|
271
|
+
return trimmed
|
|
272
|
+
|
|
273
|
+
|
|
173
274
|
def _require_port(port: Optional[str]) -> str:
|
|
174
275
|
if port:
|
|
175
|
-
return port
|
|
276
|
+
return _normalize_port(port)
|
|
176
277
|
raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
|
|
177
278
|
|
|
178
279
|
|
|
@@ -188,6 +289,16 @@ def _parse_int_option(value: str, *, name: str) -> int:
|
|
|
188
289
|
return parsed
|
|
189
290
|
|
|
190
291
|
|
|
292
|
+
def _parse_ipv4(value: Optional[str], *, name: str) -> Optional[str]:
|
|
293
|
+
if value is None:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
ipaddress.IPv4Address(value)
|
|
297
|
+
except ipaddress.AddressValueError as exc:
|
|
298
|
+
raise typer.BadParameter(f"{name} must be a valid IPv4 address") from exc
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
|
|
191
302
|
def _parse_prodtest_mask(value: str) -> bytes:
|
|
192
303
|
try:
|
|
193
304
|
return prodtest.mask_from_hex(value)
|
|
@@ -195,6 +306,21 @@ def _parse_prodtest_mask(value: str) -> bytes:
|
|
|
195
306
|
raise typer.BadParameter(str(exc)) from exc
|
|
196
307
|
|
|
197
308
|
|
|
309
|
+
def _parse_fixed_hex(value: str, *, name: str, length: int) -> bytes:
|
|
310
|
+
cleaned = value.strip()
|
|
311
|
+
if cleaned.startswith(("0x", "0X")):
|
|
312
|
+
cleaned = cleaned[2:]
|
|
313
|
+
cleaned = cleaned.replace("_", "").replace(" ", "")
|
|
314
|
+
if len(cleaned) != length * 2:
|
|
315
|
+
raise typer.BadParameter(
|
|
316
|
+
f"{name} must be {length * 2} hex characters ({length} bytes)"
|
|
317
|
+
)
|
|
318
|
+
try:
|
|
319
|
+
return bytes.fromhex(cleaned)
|
|
320
|
+
except ValueError as exc:
|
|
321
|
+
raise typer.BadParameter(f"{name} must be valid hex") from exc
|
|
322
|
+
|
|
323
|
+
|
|
198
324
|
def _format_hex(hex_str: str) -> str:
|
|
199
325
|
if not hex_str:
|
|
200
326
|
return "—"
|
|
@@ -1425,17 +1551,474 @@ def prodtest_ping(
|
|
|
1425
1551
|
logger=resources.get("logger"),
|
|
1426
1552
|
seq_tracker=resources.get("seq_tracker"),
|
|
1427
1553
|
)
|
|
1428
|
-
if not responses
|
|
1554
|
+
if not responses:
|
|
1429
1555
|
console.print("[red]Device returned no response[/]")
|
|
1430
1556
|
raise typer.Exit(1)
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1557
|
+
|
|
1558
|
+
failed_idx = next(
|
|
1559
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1560
|
+
)
|
|
1561
|
+
if failed_idx is not None:
|
|
1562
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1563
|
+
_render_spi_response(
|
|
1564
|
+
f"prodtest ping ({phase})",
|
|
1565
|
+
responses[failed_idx],
|
|
1566
|
+
command_label=f"spi.xfer (prodtest {phase})",
|
|
1567
|
+
)
|
|
1568
|
+
raise typer.Exit(1)
|
|
1569
|
+
|
|
1570
|
+
if len(responses) != len(sequence):
|
|
1571
|
+
console.print("[red]Prodtest command halted before completing all SPI phases[/]")
|
|
1572
|
+
raise typer.Exit(1)
|
|
1573
|
+
|
|
1574
|
+
command_response, payload_response = responses
|
|
1575
|
+
_render_spi_response(
|
|
1576
|
+
"prodtest ping (command)",
|
|
1577
|
+
command_response,
|
|
1578
|
+
command_label="spi.xfer (prodtest command)",
|
|
1579
|
+
)
|
|
1580
|
+
_render_spi_response(
|
|
1581
|
+
"prodtest ping (payload)",
|
|
1582
|
+
payload_response,
|
|
1583
|
+
command_label="spi.xfer (prodtest payload)",
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
rx_bytes = _decode_hex_response(
|
|
1587
|
+
payload_response, label="prodtest ping (payload)"
|
|
1588
|
+
)
|
|
1589
|
+
if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
|
|
1590
|
+
console.print(
|
|
1591
|
+
"[red]Ping failed: expected '-' (0x2D), got: "
|
|
1592
|
+
f"{_format_hex(payload_response.get('rx', ''))}[/]"
|
|
1593
|
+
)
|
|
1594
|
+
raise typer.Exit(1)
|
|
1595
|
+
|
|
1596
|
+
console.print("[green]Ping successful: got '-' response[/]")
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
@prodtest_app.command("antenna")
|
|
1600
|
+
def prodtest_antenna(
|
|
1601
|
+
ctx: typer.Context,
|
|
1602
|
+
antenna: str = typer.Argument(
|
|
1603
|
+
...,
|
|
1604
|
+
metavar="ANTENNA",
|
|
1605
|
+
help="Antenna to select (internal/external)",
|
|
1606
|
+
),
|
|
1607
|
+
port: Optional[str] = typer.Option(
|
|
1608
|
+
None,
|
|
1609
|
+
"--port",
|
|
1610
|
+
envvar="SHUTTLE_PORT",
|
|
1611
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1612
|
+
),
|
|
1613
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1614
|
+
timeout: float = typer.Option(
|
|
1615
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1616
|
+
),
|
|
1617
|
+
):
|
|
1618
|
+
"""Select the active prodtest antenna (opcode 'a')."""
|
|
1619
|
+
|
|
1620
|
+
resources = _ctx_resources(ctx)
|
|
1621
|
+
normalized = antenna.strip().lower()
|
|
1622
|
+
try:
|
|
1623
|
+
antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
|
|
1624
|
+
except KeyError as exc:
|
|
1625
|
+
allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
|
|
1626
|
+
raise typer.BadParameter(
|
|
1627
|
+
f"Antenna must be one of: {allowed}"
|
|
1628
|
+
) from exc
|
|
1629
|
+
|
|
1630
|
+
sequence = [prodtest.select_antenna(antenna_value)]
|
|
1631
|
+
responses = _execute_timo_sequence(
|
|
1632
|
+
port=port,
|
|
1633
|
+
baudrate=baudrate,
|
|
1634
|
+
timeout=timeout,
|
|
1635
|
+
sequence=sequence,
|
|
1636
|
+
spinner_label=f"Selecting prodtest antenna ({normalized})",
|
|
1637
|
+
logger=resources.get("logger"),
|
|
1638
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
if not responses:
|
|
1642
|
+
console.print("[red]Device returned no response[/]")
|
|
1643
|
+
raise typer.Exit(1)
|
|
1644
|
+
|
|
1645
|
+
response = responses[0]
|
|
1646
|
+
_render_spi_response(
|
|
1647
|
+
"prodtest antenna",
|
|
1648
|
+
response,
|
|
1649
|
+
command_label="spi.xfer (prodtest)",
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
if not response.get("ok"):
|
|
1653
|
+
raise typer.Exit(1)
|
|
1654
|
+
|
|
1655
|
+
console.print(f"[green]Antenna set to {normalized}")
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
@prodtest_app.command("continuous-tx")
|
|
1659
|
+
def prodtest_continuous_tx(
|
|
1660
|
+
ctx: typer.Context,
|
|
1661
|
+
channel: int = typer.Argument(
|
|
1662
|
+
...,
|
|
1663
|
+
metavar="CHANNEL",
|
|
1664
|
+
min=0,
|
|
1665
|
+
max=100,
|
|
1666
|
+
help="NRF_RADIO->FREQUENCY channel index (0=2400 MHz, 100=2500 MHz)",
|
|
1667
|
+
),
|
|
1668
|
+
power: str = typer.Argument(
|
|
1669
|
+
...,
|
|
1670
|
+
metavar="POWER",
|
|
1671
|
+
help=(
|
|
1672
|
+
"Output power alias (neg30, neg20, neg16, neg12, neg8, neg4, 0, pos4) "
|
|
1673
|
+
"or numeric index 0-7"
|
|
1674
|
+
),
|
|
1675
|
+
),
|
|
1676
|
+
port: Optional[str] = typer.Option(
|
|
1677
|
+
None,
|
|
1678
|
+
"--port",
|
|
1679
|
+
envvar="SHUTTLE_PORT",
|
|
1680
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1681
|
+
),
|
|
1682
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1683
|
+
timeout: float = typer.Option(
|
|
1684
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1685
|
+
),
|
|
1686
|
+
):
|
|
1687
|
+
"""Run the prodtest continuous transmitter test (opcode 'o')."""
|
|
1688
|
+
|
|
1689
|
+
resources = _ctx_resources(ctx)
|
|
1690
|
+
power_value, power_meta = _resolve_prodtest_power_choice(power)
|
|
1691
|
+
freq_mhz = 2400 + channel
|
|
1692
|
+
sequence = [prodtest.continuous_transmitter(channel, power_value)]
|
|
1693
|
+
responses = _execute_timo_sequence(
|
|
1694
|
+
port=port,
|
|
1695
|
+
baudrate=baudrate,
|
|
1696
|
+
timeout=timeout,
|
|
1697
|
+
sequence=sequence,
|
|
1698
|
+
spinner_label=(
|
|
1699
|
+
f"Enabling continuous TX (ch={channel}, power={power_meta['label']})"
|
|
1700
|
+
),
|
|
1701
|
+
logger=resources.get("logger"),
|
|
1702
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
if not responses:
|
|
1706
|
+
console.print("[red]Device returned no response[/]")
|
|
1707
|
+
raise typer.Exit(1)
|
|
1708
|
+
|
|
1709
|
+
response = responses[0]
|
|
1710
|
+
_render_spi_response(
|
|
1711
|
+
"prodtest continuous-tx",
|
|
1712
|
+
response,
|
|
1713
|
+
command_label="spi.xfer (prodtest)",
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
if not response.get("ok"):
|
|
1717
|
+
raise typer.Exit(1)
|
|
1718
|
+
|
|
1719
|
+
console.print(
|
|
1720
|
+
f"[green]Continuous transmitter enabled[/] channel={channel} ({freq_mhz} MHz) "
|
|
1721
|
+
f"power={power_meta['label']} ({power_meta['macro']})"
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
@prodtest_app.command("hw-device-id")
|
|
1726
|
+
def prodtest_hw_device_id(
|
|
1727
|
+
ctx: typer.Context,
|
|
1728
|
+
port: Optional[str] = typer.Option(
|
|
1729
|
+
None,
|
|
1730
|
+
"--port",
|
|
1731
|
+
envvar="SHUTTLE_PORT",
|
|
1732
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1733
|
+
),
|
|
1734
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1735
|
+
timeout: float = typer.Option(
|
|
1736
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1737
|
+
),
|
|
1738
|
+
):
|
|
1739
|
+
"""Read the prodtest HW Device ID (opcode 'I')."""
|
|
1740
|
+
|
|
1741
|
+
resources = _ctx_resources(ctx)
|
|
1742
|
+
sequence = prodtest.hw_device_id_sequence()
|
|
1743
|
+
responses = _execute_timo_sequence(
|
|
1744
|
+
port=port,
|
|
1745
|
+
baudrate=baudrate,
|
|
1746
|
+
timeout=timeout,
|
|
1747
|
+
sequence=sequence,
|
|
1748
|
+
spinner_label="Reading prodtest HW Device ID",
|
|
1749
|
+
logger=resources.get("logger"),
|
|
1750
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
if not responses:
|
|
1754
|
+
console.print("[red]Device returned no response[/]")
|
|
1755
|
+
raise typer.Exit(1)
|
|
1756
|
+
|
|
1757
|
+
failed_idx = next(
|
|
1758
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1759
|
+
)
|
|
1760
|
+
if failed_idx is not None:
|
|
1761
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1762
|
+
_render_spi_response(
|
|
1763
|
+
f"prodtest hw-device-id ({phase})",
|
|
1764
|
+
responses[failed_idx],
|
|
1765
|
+
command_label=f"spi.xfer (prodtest {phase})",
|
|
1766
|
+
)
|
|
1767
|
+
raise typer.Exit(1)
|
|
1768
|
+
|
|
1769
|
+
if len(responses) != len(sequence):
|
|
1770
|
+
console.print("[red]Prodtest command halted before completing all SPI phases[/]")
|
|
1771
|
+
raise typer.Exit(1)
|
|
1772
|
+
|
|
1773
|
+
result_response = responses[-1]
|
|
1774
|
+
_render_spi_response(
|
|
1775
|
+
"prodtest hw-device-id",
|
|
1776
|
+
result_response,
|
|
1777
|
+
command_label="spi.xfer (prodtest payload)",
|
|
1778
|
+
)
|
|
1779
|
+
|
|
1780
|
+
rx_bytes = _decode_hex_response(result_response, label="prodtest hw-device-id")
|
|
1781
|
+
if len(rx_bytes) < prodtest.HW_DEVICE_ID_RESULT_LEN:
|
|
1782
|
+
console.print("[red]Prodtest HW Device ID response shorter than expected[/]")
|
|
1783
|
+
raise typer.Exit(1)
|
|
1784
|
+
|
|
1785
|
+
hw_id = rx_bytes[-prodtest.HW_DEVICE_ID_RESULT_LEN :]
|
|
1786
|
+
console.print(f"HW Device ID: {_format_hex(hw_id.hex())}")
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
@prodtest_app.command("serial-number")
|
|
1790
|
+
def prodtest_serial_number(
|
|
1791
|
+
ctx: typer.Context,
|
|
1792
|
+
value: Optional[str] = typer.Option(
|
|
1793
|
+
None,
|
|
1794
|
+
"--value",
|
|
1795
|
+
"-v",
|
|
1796
|
+
help="Hex-encoded 8-byte serial number to write (omit to read)",
|
|
1797
|
+
),
|
|
1798
|
+
port: Optional[str] = typer.Option(
|
|
1799
|
+
None,
|
|
1800
|
+
"--port",
|
|
1801
|
+
envvar="SHUTTLE_PORT",
|
|
1802
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1803
|
+
),
|
|
1804
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1805
|
+
timeout: float = typer.Option(
|
|
1806
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1807
|
+
),
|
|
1808
|
+
):
|
|
1809
|
+
"""Read or write the prodtest serial number (opcode 'x'/'X')."""
|
|
1810
|
+
|
|
1811
|
+
resources = _ctx_resources(ctx)
|
|
1812
|
+
if value is None:
|
|
1813
|
+
sequence = prodtest.serial_number_read_sequence()
|
|
1814
|
+
spinner_label = "Reading prodtest serial number"
|
|
1815
|
+
else:
|
|
1816
|
+
serial_bytes = _parse_fixed_hex(
|
|
1817
|
+
value, name="--value", length=prodtest.SERIAL_NUMBER_LEN
|
|
1818
|
+
)
|
|
1819
|
+
sequence = [prodtest.serial_number_write(serial_bytes)]
|
|
1820
|
+
spinner_label = "Writing prodtest serial number"
|
|
1821
|
+
|
|
1822
|
+
responses = _execute_timo_sequence(
|
|
1823
|
+
port=port,
|
|
1824
|
+
baudrate=baudrate,
|
|
1825
|
+
timeout=timeout,
|
|
1826
|
+
sequence=sequence,
|
|
1827
|
+
spinner_label=spinner_label,
|
|
1828
|
+
logger=resources.get("logger"),
|
|
1829
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
if not responses:
|
|
1833
|
+
console.print("[red]Device returned no response[/]")
|
|
1834
|
+
raise typer.Exit(1)
|
|
1835
|
+
|
|
1836
|
+
failed_idx = next(
|
|
1837
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1838
|
+
)
|
|
1839
|
+
if failed_idx is not None:
|
|
1840
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1841
|
+
_render_spi_response(
|
|
1842
|
+
f"prodtest serial-number ({phase})",
|
|
1843
|
+
responses[failed_idx],
|
|
1844
|
+
command_label=f"spi.xfer (prodtest {phase})",
|
|
1845
|
+
)
|
|
1846
|
+
raise typer.Exit(1)
|
|
1847
|
+
|
|
1848
|
+
if len(responses) != len(sequence):
|
|
1849
|
+
console.print(
|
|
1850
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
1851
|
+
)
|
|
1852
|
+
raise typer.Exit(1)
|
|
1853
|
+
|
|
1854
|
+
if value is None:
|
|
1855
|
+
result_response = responses[-1]
|
|
1856
|
+
_render_spi_response(
|
|
1857
|
+
"prodtest serial-number",
|
|
1858
|
+
result_response,
|
|
1859
|
+
command_label="spi.xfer (prodtest payload)",
|
|
1860
|
+
)
|
|
1861
|
+
rx_bytes = _decode_hex_response(
|
|
1862
|
+
result_response, label="prodtest serial-number"
|
|
1863
|
+
)
|
|
1864
|
+
if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
|
|
1865
|
+
console.print("[red]Prodtest serial-number response shorter than expected[/]")
|
|
1866
|
+
raise typer.Exit(1)
|
|
1867
|
+
serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
|
|
1868
|
+
console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
|
|
1869
|
+
else:
|
|
1870
|
+
_render_spi_response(
|
|
1871
|
+
"prodtest serial-number",
|
|
1872
|
+
responses[0],
|
|
1873
|
+
command_label="spi.xfer (prodtest command)",
|
|
1874
|
+
)
|
|
1875
|
+
console.print(
|
|
1876
|
+
f"[green]Serial number updated[/] value={_format_hex(serial_bytes.hex())}"
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
@prodtest_app.command("config")
|
|
1881
|
+
def prodtest_config(
|
|
1882
|
+
ctx: typer.Context,
|
|
1883
|
+
value: Optional[str] = typer.Option(
|
|
1884
|
+
None,
|
|
1885
|
+
"--value",
|
|
1886
|
+
"-v",
|
|
1887
|
+
help="Hex-encoded 5-byte config payload to write (omit to read)",
|
|
1888
|
+
),
|
|
1889
|
+
port: Optional[str] = typer.Option(
|
|
1890
|
+
None,
|
|
1891
|
+
"--port",
|
|
1892
|
+
envvar="SHUTTLE_PORT",
|
|
1893
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1894
|
+
),
|
|
1895
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1896
|
+
timeout: float = typer.Option(
|
|
1897
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1898
|
+
),
|
|
1899
|
+
):
|
|
1900
|
+
"""Read or write the prodtest config (opcode 'r'/'R')."""
|
|
1901
|
+
|
|
1902
|
+
resources = _ctx_resources(ctx)
|
|
1903
|
+
if value is None:
|
|
1904
|
+
sequence = prodtest.config_read_sequence()
|
|
1905
|
+
spinner_label = "Reading prodtest config"
|
|
1906
|
+
else:
|
|
1907
|
+
config_bytes = _parse_fixed_hex(
|
|
1908
|
+
value, name="--value", length=prodtest.CONFIG_WRITE_LEN
|
|
1909
|
+
)
|
|
1910
|
+
sequence = [prodtest.config_write(config_bytes)]
|
|
1911
|
+
spinner_label = "Writing prodtest config"
|
|
1912
|
+
|
|
1913
|
+
responses = _execute_timo_sequence(
|
|
1914
|
+
port=port,
|
|
1915
|
+
baudrate=baudrate,
|
|
1916
|
+
timeout=timeout,
|
|
1917
|
+
sequence=sequence,
|
|
1918
|
+
spinner_label=spinner_label,
|
|
1919
|
+
logger=resources.get("logger"),
|
|
1920
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
if not responses:
|
|
1924
|
+
console.print("[red]Device returned no response[/]")
|
|
1925
|
+
raise typer.Exit(1)
|
|
1926
|
+
|
|
1927
|
+
failed_idx = next(
|
|
1928
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1929
|
+
)
|
|
1930
|
+
if failed_idx is not None:
|
|
1931
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1932
|
+
_render_spi_response(
|
|
1933
|
+
f"prodtest config ({phase})",
|
|
1934
|
+
responses[failed_idx],
|
|
1935
|
+
command_label=f"spi.xfer (prodtest {phase})",
|
|
1936
|
+
)
|
|
1937
|
+
raise typer.Exit(1)
|
|
1938
|
+
|
|
1939
|
+
if len(responses) != len(sequence):
|
|
1940
|
+
console.print(
|
|
1941
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
1942
|
+
)
|
|
1943
|
+
raise typer.Exit(1)
|
|
1944
|
+
|
|
1945
|
+
if value is None:
|
|
1946
|
+
result_response = responses[-1]
|
|
1947
|
+
_render_spi_response(
|
|
1948
|
+
"prodtest config",
|
|
1949
|
+
result_response,
|
|
1950
|
+
command_label="spi.xfer (prodtest payload)",
|
|
1951
|
+
)
|
|
1952
|
+
rx_bytes = _decode_hex_response(result_response, label="prodtest config")
|
|
1953
|
+
if len(rx_bytes) < prodtest.CONFIG_RESULT_LEN:
|
|
1954
|
+
console.print("[red]Prodtest config response shorter than expected[/]")
|
|
1955
|
+
raise typer.Exit(1)
|
|
1956
|
+
config_bytes = rx_bytes[-prodtest.CONFIG_RESULT_LEN :]
|
|
1957
|
+
console.print(f"Config: {_format_hex(config_bytes.hex())}")
|
|
1958
|
+
else:
|
|
1959
|
+
_render_spi_response(
|
|
1960
|
+
"prodtest config",
|
|
1961
|
+
responses[0],
|
|
1962
|
+
command_label="spi.xfer (prodtest command)",
|
|
1963
|
+
)
|
|
1964
|
+
console.print(
|
|
1965
|
+
f"[green]Config updated[/] value={_format_hex(config_bytes.hex())}"
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
@prodtest_app.command("erase-nvmc")
|
|
1970
|
+
def prodtest_erase_nvmc(
|
|
1971
|
+
ctx: typer.Context,
|
|
1972
|
+
hw_device_id: str = typer.Argument(
|
|
1973
|
+
...,
|
|
1974
|
+
metavar="HW_ID",
|
|
1975
|
+
help="Hex-encoded 8-byte HW Device ID (must match device to erase)",
|
|
1976
|
+
),
|
|
1977
|
+
port: Optional[str] = typer.Option(
|
|
1978
|
+
None,
|
|
1979
|
+
"--port",
|
|
1980
|
+
envvar="SHUTTLE_PORT",
|
|
1981
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1982
|
+
),
|
|
1983
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1984
|
+
timeout: float = typer.Option(
|
|
1985
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1986
|
+
),
|
|
1987
|
+
):
|
|
1988
|
+
"""Erase NVMC if the provided HW Device ID matches (opcode 'e')."""
|
|
1989
|
+
|
|
1990
|
+
resources = _ctx_resources(ctx)
|
|
1991
|
+
hw_id_bytes = _parse_fixed_hex(
|
|
1992
|
+
hw_device_id, name="HW_ID", length=prodtest.ERASE_NVMC_LEN
|
|
1993
|
+
)
|
|
1994
|
+
sequence = [prodtest.erase_nvmc(hw_id_bytes)]
|
|
1995
|
+
responses = _execute_timo_sequence(
|
|
1996
|
+
port=port,
|
|
1997
|
+
baudrate=baudrate,
|
|
1998
|
+
timeout=timeout,
|
|
1999
|
+
sequence=sequence,
|
|
2000
|
+
spinner_label="Erasing NVMC (if HW ID matches)",
|
|
2001
|
+
logger=resources.get("logger"),
|
|
2002
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
if not responses:
|
|
2006
|
+
console.print("[red]Device returned no response[/]")
|
|
2007
|
+
raise typer.Exit(1)
|
|
2008
|
+
|
|
2009
|
+
response = responses[0]
|
|
2010
|
+
_render_spi_response(
|
|
2011
|
+
"prodtest erase-nvmc",
|
|
2012
|
+
response,
|
|
2013
|
+
command_label="spi.xfer (prodtest)",
|
|
2014
|
+
)
|
|
2015
|
+
|
|
2016
|
+
if not response.get("ok"):
|
|
2017
|
+
raise typer.Exit(1)
|
|
2018
|
+
|
|
2019
|
+
console.print(
|
|
2020
|
+
f"[green]erase-nvmc completed[/] hw-id={_format_hex(hw_id_bytes.hex())}"
|
|
2021
|
+
)
|
|
1439
2022
|
|
|
1440
2023
|
|
|
1441
2024
|
@prodtest_app.command("io-self-test")
|
|
@@ -1807,6 +2390,123 @@ def uart_sub_command(
|
|
|
1807
2390
|
_render_payload_response("uart.sub", response)
|
|
1808
2391
|
|
|
1809
2392
|
|
|
2393
|
+
@app.command("wifi-cfg")
|
|
2394
|
+
def wifi_cfg_command(
|
|
2395
|
+
ctx: typer.Context,
|
|
2396
|
+
port: Optional[str] = typer.Option(
|
|
2397
|
+
None,
|
|
2398
|
+
"--port",
|
|
2399
|
+
envvar="SHUTTLE_PORT",
|
|
2400
|
+
help="Serial port or host:port (e.g., /dev/ttyUSB0 or 192.168.1.10:5000)",
|
|
2401
|
+
),
|
|
2402
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
2403
|
+
timeout: float = typer.Option(
|
|
2404
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
2405
|
+
),
|
|
2406
|
+
ssid: Optional[str] = typer.Option(
|
|
2407
|
+
None,
|
|
2408
|
+
"--ssid",
|
|
2409
|
+
help="Set the station SSID",
|
|
2410
|
+
show_default=False,
|
|
2411
|
+
),
|
|
2412
|
+
psk: Optional[str] = typer.Option(
|
|
2413
|
+
None,
|
|
2414
|
+
"--psk",
|
|
2415
|
+
help="Set the WPA/WPA2/WPA3 passphrase",
|
|
2416
|
+
show_default=False,
|
|
2417
|
+
),
|
|
2418
|
+
dhcp: Optional[bool] = typer.Option(
|
|
2419
|
+
None,
|
|
2420
|
+
"--dhcp/--static",
|
|
2421
|
+
help="Enable DHCP or force static IPv4 addressing",
|
|
2422
|
+
),
|
|
2423
|
+
ip_addr: Optional[str] = typer.Option(
|
|
2424
|
+
None,
|
|
2425
|
+
"--ip",
|
|
2426
|
+
help="Static IPv4 address (requires --static or other static fields)",
|
|
2427
|
+
show_default=False,
|
|
2428
|
+
),
|
|
2429
|
+
netmask: Optional[str] = typer.Option(
|
|
2430
|
+
None,
|
|
2431
|
+
"--netmask",
|
|
2432
|
+
help="Static subnet mask (e.g., 255.255.255.0)",
|
|
2433
|
+
show_default=False,
|
|
2434
|
+
),
|
|
2435
|
+
gateway: Optional[str] = typer.Option(
|
|
2436
|
+
None,
|
|
2437
|
+
"--gateway",
|
|
2438
|
+
help="Static default gateway IPv4 address",
|
|
2439
|
+
show_default=False,
|
|
2440
|
+
),
|
|
2441
|
+
dns: Optional[str] = typer.Option(
|
|
2442
|
+
None,
|
|
2443
|
+
"--dns",
|
|
2444
|
+
help="Primary DNS server IPv4 address",
|
|
2445
|
+
show_default=False,
|
|
2446
|
+
),
|
|
2447
|
+
dns_alt: Optional[str] = typer.Option(
|
|
2448
|
+
None,
|
|
2449
|
+
"--dns-alt",
|
|
2450
|
+
help="Secondary DNS server IPv4 address",
|
|
2451
|
+
show_default=False,
|
|
2452
|
+
),
|
|
2453
|
+
):
|
|
2454
|
+
"""Query or update Wi-Fi credentials and network settings."""
|
|
2455
|
+
|
|
2456
|
+
resources = _ctx_resources(ctx)
|
|
2457
|
+
wifi_payload: Dict[str, Any] = {}
|
|
2458
|
+
if ssid is not None:
|
|
2459
|
+
wifi_payload["ssid"] = ssid
|
|
2460
|
+
if psk is not None:
|
|
2461
|
+
wifi_payload["psk"] = psk
|
|
2462
|
+
if dhcp is not None:
|
|
2463
|
+
wifi_payload["dhcp"] = dhcp
|
|
2464
|
+
|
|
2465
|
+
network_payload: Dict[str, Any] = {}
|
|
2466
|
+
parsed_ip = _parse_ipv4(ip_addr, name="--ip")
|
|
2467
|
+
parsed_mask = _parse_ipv4(netmask, name="--netmask")
|
|
2468
|
+
parsed_gateway = _parse_ipv4(gateway, name="--gateway")
|
|
2469
|
+
parsed_dns_primary = _parse_ipv4(dns, name="--dns")
|
|
2470
|
+
parsed_dns_secondary = _parse_ipv4(dns_alt, name="--dns-alt")
|
|
2471
|
+
|
|
2472
|
+
if parsed_ip is not None:
|
|
2473
|
+
network_payload["ip"] = parsed_ip
|
|
2474
|
+
if parsed_mask is not None:
|
|
2475
|
+
network_payload["netmask"] = parsed_mask
|
|
2476
|
+
if parsed_gateway is not None:
|
|
2477
|
+
network_payload["gateway"] = parsed_gateway
|
|
2478
|
+
|
|
2479
|
+
dns_entries = [entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry]
|
|
2480
|
+
if dns_entries:
|
|
2481
|
+
network_payload["dns"] = dns_entries
|
|
2482
|
+
|
|
2483
|
+
if network_payload:
|
|
2484
|
+
if wifi_payload.get("dhcp") is True:
|
|
2485
|
+
raise typer.BadParameter(
|
|
2486
|
+
"Static network options cannot be combined with --dhcp"
|
|
2487
|
+
)
|
|
2488
|
+
wifi_payload.setdefault("dhcp", False)
|
|
2489
|
+
wifi_payload["network"] = network_payload
|
|
2490
|
+
|
|
2491
|
+
resolved_port = _require_port(port)
|
|
2492
|
+
action = "Updating" if wifi_payload else "Querying"
|
|
2493
|
+
with spinner(f"{action} wifi.cfg over {resolved_port}"):
|
|
2494
|
+
try:
|
|
2495
|
+
with NDJSONSerialClient(
|
|
2496
|
+
resolved_port,
|
|
2497
|
+
baudrate=baudrate,
|
|
2498
|
+
timeout=timeout,
|
|
2499
|
+
logger=resources.get("logger"),
|
|
2500
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
2501
|
+
) as client:
|
|
2502
|
+
response = client.wifi_cfg(wifi_payload if wifi_payload else None)
|
|
2503
|
+
except ShuttleSerialError as exc:
|
|
2504
|
+
console.print(f"[red]{exc}[/]")
|
|
2505
|
+
raise typer.Exit(1) from exc
|
|
2506
|
+
|
|
2507
|
+
_render_payload_response("wifi.cfg", response)
|
|
2508
|
+
|
|
2509
|
+
|
|
1810
2510
|
@app.command("uart-tx")
|
|
1811
2511
|
def uart_tx_command(
|
|
1812
2512
|
ctx: typer.Context,
|
|
Binary file
|
shuttle/prodtest.py
CHANGED
|
@@ -10,10 +10,32 @@ from . import timo
|
|
|
10
10
|
|
|
11
11
|
RESET_OPCODE = ord("?")
|
|
12
12
|
PLUS_OPCODE = ord("+")
|
|
13
|
+
ANTENNA_OPCODE = ord("a")
|
|
14
|
+
CONTINUOUS_TX_OPCODE = ord("o")
|
|
15
|
+
HW_DEVICE_ID_OPCODE = ord("I")
|
|
16
|
+
SERIAL_NUMBER_READ_OPCODE = ord("x")
|
|
17
|
+
SERIAL_NUMBER_WRITE_OPCODE = ord("X")
|
|
18
|
+
CONFIG_READ_OPCODE = ord("r")
|
|
19
|
+
CONFIG_WRITE_OPCODE = ord("R")
|
|
20
|
+
ERASE_NVMC_OPCODE = ord("e")
|
|
13
21
|
IO_SELF_TEST_OPCODE = ord("T")
|
|
14
22
|
IO_SELF_TEST_MASK_LEN = 8
|
|
15
23
|
IO_SELF_TEST_DUMMY_BYTE = 0xFF
|
|
16
|
-
IO_SELF_TEST_IRQ_TIMEOUT_US =
|
|
24
|
+
IO_SELF_TEST_IRQ_TIMEOUT_US = 500_000
|
|
25
|
+
RESET_IRQ_TIMEOUT_US = 200_000
|
|
26
|
+
CONTINUOUS_TX_IRQ_TIMEOUT_US = 100_000
|
|
27
|
+
HW_DEVICE_ID_RESULT_LEN = 8
|
|
28
|
+
HW_DEVICE_ID_DUMMY_BYTE = 0xFF
|
|
29
|
+
HW_DEVICE_ID_IRQ_TIMEOUT_US = 100_000
|
|
30
|
+
SERIAL_NUMBER_LEN = 8
|
|
31
|
+
SERIAL_NUMBER_DUMMY_BYTE = 0xFF
|
|
32
|
+
SERIAL_NUMBER_IRQ_TIMEOUT_US = 100_000
|
|
33
|
+
CONFIG_RESULT_LEN = 6
|
|
34
|
+
CONFIG_WRITE_LEN = 5
|
|
35
|
+
CONFIG_DUMMY_BYTE = 0xFF
|
|
36
|
+
CONFIG_IRQ_TIMEOUT_US = 100_000
|
|
37
|
+
ERASE_NVMC_LEN = 8
|
|
38
|
+
ERASE_NVMC_IRQ_TIMEOUT_US = 500_000
|
|
17
39
|
|
|
18
40
|
|
|
19
41
|
def _ensure_byte(value: int) -> int:
|
|
@@ -32,16 +54,31 @@ def _build_command_bytes(opcode: int, arguments: Iterable[int] | bytes = ()) ->
|
|
|
32
54
|
return bytes([opcode]) + payload
|
|
33
55
|
|
|
34
56
|
|
|
35
|
-
def command(
|
|
57
|
+
def command(
|
|
58
|
+
opcode: int,
|
|
59
|
+
arguments: Iterable[int] | bytes = (),
|
|
60
|
+
*,
|
|
61
|
+
params: dict | None = None,
|
|
62
|
+
) -> dict:
|
|
36
63
|
"""Build an NDJSON-ready spi.xfer payload for a prodtest command."""
|
|
37
64
|
|
|
38
|
-
return timo.command_payload(
|
|
65
|
+
return timo.command_payload(
|
|
66
|
+
_build_command_bytes(opcode, arguments), params=params
|
|
67
|
+
)
|
|
39
68
|
|
|
40
69
|
|
|
41
70
|
def reset() -> dict:
|
|
42
71
|
"""Return the prodtest reset command (single '?' byte)."""
|
|
43
72
|
|
|
44
|
-
return
|
|
73
|
+
return timo.command_payload(
|
|
74
|
+
bytes([RESET_OPCODE]),
|
|
75
|
+
params={
|
|
76
|
+
"wait_irq": {
|
|
77
|
+
"edge": "leading",
|
|
78
|
+
"timeout_us": RESET_IRQ_TIMEOUT_US,
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
)
|
|
45
82
|
|
|
46
83
|
|
|
47
84
|
def reset_transfer() -> dict:
|
|
@@ -50,6 +87,130 @@ def reset_transfer() -> dict:
|
|
|
50
87
|
return reset()
|
|
51
88
|
|
|
52
89
|
|
|
90
|
+
def select_antenna(selection: int) -> dict:
|
|
91
|
+
"""Return the prodtest antenna command for the given antenna index."""
|
|
92
|
+
|
|
93
|
+
return command(ANTENNA_OPCODE, [selection])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def continuous_transmitter(channel: int, power_level: int) -> dict:
|
|
97
|
+
"""Return the prodtest continuous transmitter command (opcode 'o')."""
|
|
98
|
+
|
|
99
|
+
return command(
|
|
100
|
+
CONTINUOUS_TX_OPCODE,
|
|
101
|
+
[channel, power_level],
|
|
102
|
+
params={
|
|
103
|
+
"wait_irq": {
|
|
104
|
+
"edge": "trailing",
|
|
105
|
+
"timeout_us": CONTINUOUS_TX_IRQ_TIMEOUT_US,
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def hw_device_id_sequence() -> Sequence[dict]:
|
|
112
|
+
"""Return the SPI frames required to read the HW Device ID (opcode 'I')."""
|
|
113
|
+
|
|
114
|
+
command = timo.command_payload(
|
|
115
|
+
bytes([HW_DEVICE_ID_OPCODE]),
|
|
116
|
+
params={
|
|
117
|
+
"wait_irq": {
|
|
118
|
+
"edge": "trailing",
|
|
119
|
+
"timeout_us": HW_DEVICE_ID_IRQ_TIMEOUT_US,
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
readback = timo.command_payload(
|
|
124
|
+
bytes([HW_DEVICE_ID_DUMMY_BYTE] * HW_DEVICE_ID_RESULT_LEN)
|
|
125
|
+
)
|
|
126
|
+
return (command, readback)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def serial_number_read_sequence() -> Sequence[dict]:
|
|
130
|
+
"""Return the SPI frames required to read the prodtest serial number (opcode 'x')."""
|
|
131
|
+
|
|
132
|
+
command = timo.command_payload(
|
|
133
|
+
bytes([SERIAL_NUMBER_READ_OPCODE]),
|
|
134
|
+
params={
|
|
135
|
+
"wait_irq": {
|
|
136
|
+
"edge": "trailing",
|
|
137
|
+
"timeout_us": SERIAL_NUMBER_IRQ_TIMEOUT_US,
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
readback = timo.command_payload(
|
|
142
|
+
bytes([SERIAL_NUMBER_DUMMY_BYTE] * SERIAL_NUMBER_LEN)
|
|
143
|
+
)
|
|
144
|
+
return (command, readback)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def serial_number_write(serial_bytes: bytes) -> dict:
|
|
148
|
+
"""Build the prodtest serial-number write command (opcode 'X')."""
|
|
149
|
+
|
|
150
|
+
if len(serial_bytes) != SERIAL_NUMBER_LEN:
|
|
151
|
+
raise ValueError("Serial number must be exactly 8 bytes")
|
|
152
|
+
return command(
|
|
153
|
+
SERIAL_NUMBER_WRITE_OPCODE,
|
|
154
|
+
serial_bytes,
|
|
155
|
+
params={
|
|
156
|
+
"wait_irq": {
|
|
157
|
+
"edge": "trailing",
|
|
158
|
+
"timeout_us": SERIAL_NUMBER_IRQ_TIMEOUT_US,
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def config_read_sequence() -> Sequence[dict]:
|
|
165
|
+
"""Return the SPI frames required to read the prodtest config (opcode 'r')."""
|
|
166
|
+
|
|
167
|
+
command = timo.command_payload(
|
|
168
|
+
bytes([CONFIG_READ_OPCODE]),
|
|
169
|
+
params={
|
|
170
|
+
"wait_irq": {
|
|
171
|
+
"edge": "trailing",
|
|
172
|
+
"timeout_us": CONFIG_IRQ_TIMEOUT_US,
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
readback = timo.command_payload(bytes([CONFIG_DUMMY_BYTE] * CONFIG_RESULT_LEN))
|
|
177
|
+
return (command, readback)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def config_write(config_bytes: bytes) -> dict:
|
|
181
|
+
"""Build the prodtest config write command (opcode 'R')."""
|
|
182
|
+
|
|
183
|
+
if len(config_bytes) != CONFIG_WRITE_LEN:
|
|
184
|
+
raise ValueError("Config payload must be exactly 5 bytes")
|
|
185
|
+
return command(
|
|
186
|
+
CONFIG_WRITE_OPCODE,
|
|
187
|
+
config_bytes,
|
|
188
|
+
params={
|
|
189
|
+
"wait_irq": {
|
|
190
|
+
"edge": "trailing",
|
|
191
|
+
"timeout_us": CONFIG_IRQ_TIMEOUT_US,
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def erase_nvmc(hw_id: bytes) -> dict:
|
|
198
|
+
"""Build the prodtest erase command (opcode 'e')."""
|
|
199
|
+
|
|
200
|
+
if len(hw_id) != ERASE_NVMC_LEN:
|
|
201
|
+
raise ValueError("Erase requires an 8-byte HW Device ID argument")
|
|
202
|
+
return command(
|
|
203
|
+
ERASE_NVMC_OPCODE,
|
|
204
|
+
hw_id,
|
|
205
|
+
params={
|
|
206
|
+
"wait_irq": {
|
|
207
|
+
"edge": "trailing",
|
|
208
|
+
"timeout_us": ERASE_NVMC_IRQ_TIMEOUT_US,
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
53
214
|
def ping_sequence() -> Sequence[dict]:
|
|
54
215
|
"""Return the two SPI frames for the prodtest ping action ('+' then dummy)."""
|
|
55
216
|
# First transfer: send '+' (PLUS_OPCODE), expect response (should be ignored)
|
|
@@ -72,7 +233,7 @@ def io_self_test(mask: bytes) -> Sequence[dict]:
|
|
|
72
233
|
command,
|
|
73
234
|
params={
|
|
74
235
|
"wait_irq": {
|
|
75
|
-
"edge": "
|
|
236
|
+
"edge": "trailing",
|
|
76
237
|
"timeout_us": IO_SELF_TEST_IRQ_TIMEOUT_US,
|
|
77
238
|
}
|
|
78
239
|
},
|
shuttle/serial_client.py
CHANGED
|
@@ -228,7 +228,9 @@ class NDJSONSerialClient:
|
|
|
228
228
|
seq_tracker: Optional[SequenceTracker] = None,
|
|
229
229
|
):
|
|
230
230
|
try:
|
|
231
|
-
self._serial = serial.
|
|
231
|
+
self._serial = serial.serial_for_url(
|
|
232
|
+
url=port, baudrate=baudrate, timeout=timeout
|
|
233
|
+
)
|
|
232
234
|
except SerialException as exc: # pragma: no cover - hardware specific
|
|
233
235
|
raise ShuttleSerialError(f"Unable to open {port}: {exc}") from exc
|
|
234
236
|
self._serial.reset_input_buffer()
|
|
@@ -345,6 +347,12 @@ class NDJSONSerialClient:
|
|
|
345
347
|
payload["uart"] = uart
|
|
346
348
|
return self._command("uart.cfg", payload)
|
|
347
349
|
|
|
350
|
+
def wifi_cfg(self, wifi: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
351
|
+
payload: Dict[str, Any] = {}
|
|
352
|
+
if wifi:
|
|
353
|
+
payload["wifi"] = wifi
|
|
354
|
+
return self._command("wifi.cfg", payload)
|
|
355
|
+
|
|
348
356
|
def uart_tx(self, data: str, port: Optional[int] = None) -> Dict[str, Any]:
|
|
349
357
|
payload: Dict[str, Any] = {"data": data}
|
|
350
358
|
if port is not None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|