lr-shuttle 0.2.2__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.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
@@ -1,18 +1,18 @@
1
- shuttle/cli.py,sha256=eMJEEmHG6Ar3vQsrQmCbPLY0pHUJg6gUyUDCaW0KNGM,87851
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
4
  shuttle/prodtest.py,sha256=1q-1dZYtrWBpI_e0jPgROVGbb_42Y0q0DIxDoo4MWUk,8020
5
- shuttle/serial_client.py,sha256=CnqWpC4CyxNXzsQQgRQsGwkDf19OoIEYvipu4kt2IQo,17392
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=vH-IBzbJCpSKgCLDl6snXqe2d5PFJJ3RDVQq41b3iKg,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.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.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
@@ -107,6 +109,9 @@ for entry in PRODTEST_TX_POWER_LEVELS:
107
109
  PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
108
110
  PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
109
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+$")
114
+
110
115
  # Backwards-compatible aliases for tests and external callers
111
116
  _SerialLogger = SerialLogger
112
117
  _SequenceTracker = SequenceTracker
@@ -251,9 +256,24 @@ 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(
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
+
254
274
  def _require_port(port: Optional[str]) -> str:
255
275
  if port:
256
- return port
276
+ return _normalize_port(port)
257
277
  raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
258
278
 
259
279
 
@@ -269,6 +289,16 @@ def _parse_int_option(value: str, *, name: str) -> int:
269
289
  return parsed
270
290
 
271
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
+
272
302
  def _parse_prodtest_mask(value: str) -> bytes:
273
303
  try:
274
304
  return prodtest.mask_from_hex(value)
@@ -1521,17 +1551,49 @@ def prodtest_ping(
1521
1551
  logger=resources.get("logger"),
1522
1552
  seq_tracker=resources.get("seq_tracker"),
1523
1553
  )
1524
- if not responses or len(responses) < 2:
1554
+ if not responses:
1525
1555
  console.print("[red]Device returned no response[/]")
1526
1556
  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)
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[/]")
1535
1597
 
1536
1598
 
1537
1599
  @prodtest_app.command("antenna")
@@ -2328,6 +2390,123 @@ def uart_sub_command(
2328
2390
  _render_payload_response("uart.sub", response)
2329
2391
 
2330
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
+
2331
2510
  @app.command("uart-tx")
2332
2511
  def uart_tx_command(
2333
2512
  ctx: typer.Context,
Binary file
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: