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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.1
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=h8wElgPA-xN3MRUK3j_uBuWZs0hS_44bOc4WG_zYF_k,71365
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=V0wkbicAb-kqMPKsvvi7lQgcPvto7M8RbACU9pf7y8I,3595
5
- shuttle/serial_client.py,sha256=CnqWpC4CyxNXzsQQgRQsGwkDf19OoIEYvipu4kt2IQo,17392
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=ehmd3EEcns5cAXrIqjQDxCQJr9JrKEdGqvScv0utF8Q,378880
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.1.dist-info/METADATA,sha256=NtysDMhV36TKVUVNlho_2zogxpYTDKl_sZjgdjCVRjQ,13037
15
- lr_shuttle-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- lr_shuttle-0.2.1.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
- lr_shuttle-0.2.1.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
- lr_shuttle-0.2.1.dist-info/RECORD,,
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 or len(responses) < 2:
1554
+ if not responses:
1429
1555
  console.print("[red]Device returned no response[/]")
1430
1556
  raise typer.Exit(1)
1431
- rx2 = responses[1].get("rx", "")
1432
- if rx2 and isinstance(rx2, str):
1433
- rx_bytes = bytes.fromhex(rx2)
1434
- if rx_bytes and rx_bytes[0] == 0x2D: # ord('-')
1435
- console.print("[green]Ping successful: got '-' response[/]")
1436
- return
1437
- console.print(f"[red]Ping failed: expected '-' (0x2D), got: {rx2}[/]")
1438
- raise typer.Exit(1)
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 = 1_000_000
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(opcode: int, arguments: Iterable[int] | bytes = ()) -> dict:
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(_build_command_bytes(opcode, arguments))
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 command(RESET_OPCODE)
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": "leading",
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.Serial(port=port, baudrate=baudrate, timeout=timeout)
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: