lr-shuttle 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -1,18 +1,18 @@
1
- shuttle/cli.py,sha256=eMJEEmHG6Ar3vQsrQmCbPLY0pHUJg6gUyUDCaW0KNGM,87851
1
+ shuttle/cli.py,sha256=lou_JvWNoMXxoyXyz5MSIyAUZEUAe0CmXaBnl_gqLMY,93507
2
2
  shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
3
  shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
- shuttle/prodtest.py,sha256=1q-1dZYtrWBpI_e0jPgROVGbb_42Y0q0DIxDoo4MWUk,8020
5
- shuttle/serial_client.py,sha256=CnqWpC4CyxNXzsQQgRQsGwkDf19OoIEYvipu4kt2IQo,17392
4
+ shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
5
+ shuttle/serial_client.py,sha256=bUpTs6MmJkpYBgtNYZZ0EYaybkLlrM7MlhWxHLQPh3U,18185
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=vH-IBzbJCpSKgCLDl6snXqe2d5PFJJ3RDVQq41b3iKg,378880
10
+ shuttle/firmware/esp32c5/devboard.ino.bin,sha256=CxWJQYlbL7zu9nkUzBQ0PQQET4XHZKFoxMzm9tZonqk,1099040
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.2.dist-info/METADATA,sha256=eS1NFB_YNkvsq7eT4akMzyUij0ormtqHHtagAMghonA,13611
15
- lr_shuttle-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- lr_shuttle-0.2.2.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
- lr_shuttle-0.2.2.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
- lr_shuttle-0.2.2.dist-info/RECORD,,
14
+ lr_shuttle-0.2.4.dist-info/METADATA,sha256=EEgzPGFjeZ3L04elKT8VcUkiRjm0CyUn9dAN5Cq4_IQ,13611
15
+ lr_shuttle-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ lr_shuttle-0.2.4.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
+ lr_shuttle-0.2.4.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
+ lr_shuttle-0.2.4.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
@@ -105,7 +107,12 @@ for entry in PRODTEST_TX_POWER_LEVELS:
105
107
  for alias in entry["aliases"]:
106
108
  PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
107
109
  PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
108
- PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
110
+ PRODTEST_TX_POWER_CANONICAL = [
111
+ entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS
112
+ ]
113
+
114
+ _HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
115
+ _IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
109
116
 
110
117
  # Backwards-compatible aliases for tests and external callers
111
118
  _SerialLogger = SerialLogger
@@ -154,9 +161,7 @@ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
154
161
  resolved = parsed
155
162
  if resolved is None:
156
163
  allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
157
- raise typer.BadParameter(
158
- f"Power must be one of: {allowed} or an index 0-7"
159
- )
164
+ raise typer.BadParameter(f"Power must be one of: {allowed} or an index 0-7")
160
165
  return resolved, PRODTEST_TX_POWER_META[resolved]
161
166
 
162
167
 
@@ -251,9 +256,22 @@ def _resolve_uart_payload(
251
256
  return payload_bytes.hex(), len(payload_bytes)
252
257
 
253
258
 
259
+ def _normalize_port(port: str) -> str:
260
+ trimmed = port.strip()
261
+ if not trimmed:
262
+ raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
263
+ if "://" in trimmed:
264
+ return trimmed
265
+ if trimmed.startswith("/") or trimmed.startswith("\\"):
266
+ return trimmed
267
+ if _HOST_PORT_PATTERN.match(trimmed) or _IPV6_HOST_PORT_PATTERN.match(trimmed):
268
+ return f"socket://{trimmed}"
269
+ return trimmed
270
+
271
+
254
272
  def _require_port(port: Optional[str]) -> str:
255
273
  if port:
256
- return port
274
+ return _normalize_port(port)
257
275
  raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
258
276
 
259
277
 
@@ -269,6 +287,16 @@ def _parse_int_option(value: str, *, name: str) -> int:
269
287
  return parsed
270
288
 
271
289
 
290
+ def _parse_ipv4(value: Optional[str], *, name: str) -> Optional[str]:
291
+ if value is None:
292
+ return None
293
+ try:
294
+ ipaddress.IPv4Address(value)
295
+ except ipaddress.AddressValueError as exc:
296
+ raise typer.BadParameter(f"{name} must be a valid IPv4 address") from exc
297
+ return value
298
+
299
+
272
300
  def _parse_prodtest_mask(value: str) -> bytes:
273
301
  try:
274
302
  return prodtest.mask_from_hex(value)
@@ -1521,17 +1549,49 @@ def prodtest_ping(
1521
1549
  logger=resources.get("logger"),
1522
1550
  seq_tracker=resources.get("seq_tracker"),
1523
1551
  )
1524
- if not responses or len(responses) < 2:
1552
+ if not responses:
1525
1553
  console.print("[red]Device returned no response[/]")
1526
1554
  raise typer.Exit(1)
1527
- rx2 = responses[1].get("rx", "")
1528
- if rx2 and isinstance(rx2, str):
1529
- rx_bytes = bytes.fromhex(rx2)
1530
- if rx_bytes and rx_bytes[0] == 0x2D: # ord('-')
1531
- console.print("[green]Ping successful: got '-' response[/]")
1532
- return
1533
- console.print(f"[red]Ping failed: expected '-' (0x2D), got: {rx2}[/]")
1534
- raise typer.Exit(1)
1555
+
1556
+ failed_idx = next(
1557
+ (idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
1558
+ )
1559
+ if failed_idx is not None:
1560
+ phase = "command" if failed_idx == 0 else "payload"
1561
+ _render_spi_response(
1562
+ f"prodtest ping ({phase})",
1563
+ responses[failed_idx],
1564
+ command_label=f"spi.xfer (prodtest {phase})",
1565
+ )
1566
+ raise typer.Exit(1)
1567
+
1568
+ if len(responses) != len(sequence):
1569
+ console.print(
1570
+ "[red]Prodtest command halted before completing all SPI phases[/]"
1571
+ )
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(payload_response, label="prodtest ping (payload)")
1587
+ if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
1588
+ console.print(
1589
+ "[red]Ping failed: expected '-' (0x2D), got: "
1590
+ f"{_format_hex(payload_response.get('rx', ''))}[/]"
1591
+ )
1592
+ raise typer.Exit(1)
1593
+
1594
+ console.print("[green]Ping successful: got '-' response[/]")
1535
1595
 
1536
1596
 
1537
1597
  @prodtest_app.command("antenna")
@@ -1561,9 +1621,7 @@ def prodtest_antenna(
1561
1621
  antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
1562
1622
  except KeyError as exc:
1563
1623
  allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
1564
- raise typer.BadParameter(
1565
- f"Antenna must be one of: {allowed}"
1566
- ) from exc
1624
+ raise typer.BadParameter(f"Antenna must be one of: {allowed}") from exc
1567
1625
 
1568
1626
  sequence = [prodtest.select_antenna(antenna_value)]
1569
1627
  responses = _execute_timo_sequence(
@@ -1705,7 +1763,9 @@ def prodtest_hw_device_id(
1705
1763
  raise typer.Exit(1)
1706
1764
 
1707
1765
  if len(responses) != len(sequence):
1708
- console.print("[red]Prodtest command halted before completing all SPI phases[/]")
1766
+ console.print(
1767
+ "[red]Prodtest command halted before completing all SPI phases[/]"
1768
+ )
1709
1769
  raise typer.Exit(1)
1710
1770
 
1711
1771
  result_response = responses[-1]
@@ -1796,11 +1856,11 @@ def prodtest_serial_number(
1796
1856
  result_response,
1797
1857
  command_label="spi.xfer (prodtest payload)",
1798
1858
  )
1799
- rx_bytes = _decode_hex_response(
1800
- result_response, label="prodtest serial-number"
1801
- )
1859
+ rx_bytes = _decode_hex_response(result_response, label="prodtest serial-number")
1802
1860
  if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
1803
- console.print("[red]Prodtest serial-number response shorter than expected[/]")
1861
+ console.print(
1862
+ "[red]Prodtest serial-number response shorter than expected[/]"
1863
+ )
1804
1864
  raise typer.Exit(1)
1805
1865
  serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
1806
1866
  console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
@@ -2328,6 +2388,125 @@ def uart_sub_command(
2328
2388
  _render_payload_response("uart.sub", response)
2329
2389
 
2330
2390
 
2391
+ @app.command("wifi-cfg")
2392
+ def wifi_cfg_command(
2393
+ ctx: typer.Context,
2394
+ port: Optional[str] = typer.Option(
2395
+ None,
2396
+ "--port",
2397
+ envvar="SHUTTLE_PORT",
2398
+ help="Serial port or host:port (e.g., /dev/ttyUSB0 or 192.168.1.10:5000)",
2399
+ ),
2400
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
2401
+ timeout: float = typer.Option(
2402
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
2403
+ ),
2404
+ ssid: Optional[str] = typer.Option(
2405
+ None,
2406
+ "--ssid",
2407
+ help="Set the station SSID",
2408
+ show_default=False,
2409
+ ),
2410
+ psk: Optional[str] = typer.Option(
2411
+ None,
2412
+ "--psk",
2413
+ help="Set the WPA/WPA2/WPA3 passphrase",
2414
+ show_default=False,
2415
+ ),
2416
+ dhcp: Optional[bool] = typer.Option(
2417
+ None,
2418
+ "--dhcp/--static",
2419
+ help="Enable DHCP or force static IPv4 addressing",
2420
+ ),
2421
+ ip_addr: Optional[str] = typer.Option(
2422
+ None,
2423
+ "--ip",
2424
+ help="Static IPv4 address (requires --static or other static fields)",
2425
+ show_default=False,
2426
+ ),
2427
+ netmask: Optional[str] = typer.Option(
2428
+ None,
2429
+ "--netmask",
2430
+ help="Static subnet mask (e.g., 255.255.255.0)",
2431
+ show_default=False,
2432
+ ),
2433
+ gateway: Optional[str] = typer.Option(
2434
+ None,
2435
+ "--gateway",
2436
+ help="Static default gateway IPv4 address",
2437
+ show_default=False,
2438
+ ),
2439
+ dns: Optional[str] = typer.Option(
2440
+ None,
2441
+ "--dns",
2442
+ help="Primary DNS server IPv4 address",
2443
+ show_default=False,
2444
+ ),
2445
+ dns_alt: Optional[str] = typer.Option(
2446
+ None,
2447
+ "--dns-alt",
2448
+ help="Secondary DNS server IPv4 address",
2449
+ show_default=False,
2450
+ ),
2451
+ ):
2452
+ """Query or update Wi-Fi credentials and network settings."""
2453
+
2454
+ resources = _ctx_resources(ctx)
2455
+ wifi_payload: Dict[str, Any] = {}
2456
+ if ssid is not None:
2457
+ wifi_payload["ssid"] = ssid
2458
+ if psk is not None:
2459
+ wifi_payload["psk"] = psk
2460
+ if dhcp is not None:
2461
+ wifi_payload["dhcp"] = dhcp
2462
+
2463
+ network_payload: Dict[str, Any] = {}
2464
+ parsed_ip = _parse_ipv4(ip_addr, name="--ip")
2465
+ parsed_mask = _parse_ipv4(netmask, name="--netmask")
2466
+ parsed_gateway = _parse_ipv4(gateway, name="--gateway")
2467
+ parsed_dns_primary = _parse_ipv4(dns, name="--dns")
2468
+ parsed_dns_secondary = _parse_ipv4(dns_alt, name="--dns-alt")
2469
+
2470
+ if parsed_ip is not None:
2471
+ network_payload["ip"] = parsed_ip
2472
+ if parsed_mask is not None:
2473
+ network_payload["netmask"] = parsed_mask
2474
+ if parsed_gateway is not None:
2475
+ network_payload["gateway"] = parsed_gateway
2476
+
2477
+ dns_entries = [
2478
+ entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry
2479
+ ]
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
+
2331
2510
  @app.command("uart-tx")
2332
2511
  def uart_tx_command(
2333
2512
  ctx: typer.Context,
Binary file
shuttle/prodtest.py CHANGED
@@ -62,9 +62,7 @@ def command(
62
62
  ) -> dict:
63
63
  """Build an NDJSON-ready spi.xfer payload for a prodtest command."""
64
64
 
65
- return timo.command_payload(
66
- _build_command_bytes(opcode, arguments), params=params
67
- )
65
+ return timo.command_payload(_build_command_bytes(opcode, arguments), params=params)
68
66
 
69
67
 
70
68
  def reset() -> dict:
shuttle/serial_client.py CHANGED
@@ -228,9 +228,24 @@ 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,
233
+ baudrate=baudrate,
234
+ timeout=timeout,
235
+ do_not_open=True,
236
+ )
237
+ except SerialException as exc: # pragma: no cover - hardware specific
238
+ raise ShuttleSerialError(f"Unable to initialize {port}: {exc}") from exc
239
+
240
+ try:
241
+ if getattr(self._serial, "open", None) is not None:
242
+ if not getattr(self._serial, "is_open", False):
243
+ self._serial.open()
232
244
  except SerialException as exc: # pragma: no cover - hardware specific
233
245
  raise ShuttleSerialError(f"Unable to open {port}: {exc}") from exc
246
+ except AttributeError:
247
+ # Test stubs without an open() method are already "connected"
248
+ pass
234
249
  self._serial.reset_input_buffer()
235
250
  self._lock = threading.Lock()
236
251
  self._pending: Dict[int, CommandFuture] = {}
@@ -345,6 +360,12 @@ class NDJSONSerialClient:
345
360
  payload["uart"] = uart
346
361
  return self._command("uart.cfg", payload)
347
362
 
363
+ def wifi_cfg(self, wifi: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
364
+ payload: Dict[str, Any] = {}
365
+ if wifi:
366
+ payload["wifi"] = wifi
367
+ return self._command("wifi.cfg", payload)
368
+
348
369
  def uart_tx(self, data: str, port: Optional[int] = None) -> Dict[str, Any]:
349
370
  payload: Dict[str, Any] = {"data": data}
350
371
  if port is not None: