lr-shuttle 0.2.2__py3-none-any.whl → 0.2.12__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.
shuttle/cli.py CHANGED
@@ -2,10 +2,13 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  from __future__ import annotations
4
4
 
5
+ import io
6
+ import ipaddress
7
+ import re
5
8
  import string
6
9
  import sys
7
10
  import time
8
- from contextlib import contextmanager
11
+ from contextlib import contextmanager, nullcontext
9
12
  from pathlib import Path
10
13
  from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
11
14
 
@@ -105,7 +108,12 @@ for entry in PRODTEST_TX_POWER_LEVELS:
105
108
  for alias in entry["aliases"]:
106
109
  PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
107
110
  PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
108
- PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
111
+ PRODTEST_TX_POWER_CANONICAL = [
112
+ entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS
113
+ ]
114
+
115
+ _HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
116
+ _IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
109
117
 
110
118
  # Backwards-compatible aliases for tests and external callers
111
119
  _SerialLogger = SerialLogger
@@ -116,6 +124,66 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
116
124
  return ctx.obj or {}
117
125
 
118
126
 
127
+ @contextmanager
128
+ def _sys_error_reporter(client) -> None:
129
+ setter = getattr(client, "set_event_callback", None)
130
+ if setter is None:
131
+ yield
132
+ return
133
+ setter(_handle_sys_error_event)
134
+ try:
135
+ yield
136
+ finally:
137
+ setter(None)
138
+
139
+
140
+ def _handle_sys_error_event(event: Dict[str, Any]) -> None:
141
+ if event.get("ev") != "sys.error":
142
+ return
143
+ code = event.get("code", "?")
144
+ msg = event.get("msg", "")
145
+ seq = event.get("seq")
146
+ parts = [f"Device sys.error ({code})"]
147
+ if seq is not None:
148
+ parts.append(f"seq={seq}")
149
+ if msg:
150
+ parts.append(f"- {msg}")
151
+ console.print(f"[red]{' '.join(parts)}[/]")
152
+
153
+
154
+ def _flush_client_input(client) -> None:
155
+ """Best-effort drain any unread serial noise before issuing commands."""
156
+
157
+ flush = getattr(client, "flush_input_and_log", None)
158
+ if flush is None:
159
+ return
160
+ try:
161
+ flush()
162
+ except Exception:
163
+ # Flushing is opportunistic; failures should not leak into CLI flows.
164
+ pass
165
+
166
+
167
+ @contextmanager
168
+ def _open_serial_client(
169
+ resolved_port: str,
170
+ *,
171
+ baudrate: int,
172
+ timeout: float,
173
+ logger: Optional[SerialLogger],
174
+ seq_tracker: Optional[SequenceTracker],
175
+ ):
176
+ with NDJSONSerialClient(
177
+ resolved_port,
178
+ baudrate=baudrate,
179
+ timeout=timeout,
180
+ logger=logger,
181
+ seq_tracker=seq_tracker,
182
+ ) as client:
183
+ with _sys_error_reporter(client):
184
+ yield client
185
+
186
+
119
187
  @contextmanager
120
188
  def spinner(message: str, enabled: bool = True):
121
189
  """Show a Rich spinner while the body executes."""
@@ -154,9 +222,7 @@ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
154
222
  resolved = parsed
155
223
  if resolved is None:
156
224
  allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
157
- raise typer.BadParameter(
158
- f"Power must be one of: {allowed} or an index 0-7"
159
- )
225
+ raise typer.BadParameter(f"Power must be one of: {allowed} or an index 0-7")
160
226
  return resolved, PRODTEST_TX_POWER_META[resolved]
161
227
 
162
228
 
@@ -251,9 +317,22 @@ def _resolve_uart_payload(
251
317
  return payload_bytes.hex(), len(payload_bytes)
252
318
 
253
319
 
320
+ def _normalize_port(port: str) -> str:
321
+ trimmed = port.strip()
322
+ if not trimmed:
323
+ raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
324
+ if "://" in trimmed:
325
+ return trimmed
326
+ if trimmed.startswith("/") or trimmed.startswith("\\"):
327
+ return trimmed
328
+ if _HOST_PORT_PATTERN.match(trimmed) or _IPV6_HOST_PORT_PATTERN.match(trimmed):
329
+ return f"socket://{trimmed}"
330
+ return trimmed
331
+
332
+
254
333
  def _require_port(port: Optional[str]) -> str:
255
334
  if port:
256
- return port
335
+ return _normalize_port(port)
257
336
  raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
258
337
 
259
338
 
@@ -269,6 +348,16 @@ def _parse_int_option(value: str, *, name: str) -> int:
269
348
  return parsed
270
349
 
271
350
 
351
+ def _parse_ipv4(value: Optional[str], *, name: str) -> Optional[str]:
352
+ if value is None:
353
+ return None
354
+ try:
355
+ ipaddress.IPv4Address(value)
356
+ except ipaddress.AddressValueError as exc:
357
+ raise typer.BadParameter(f"{name} must be a valid IPv4 address") from exc
358
+ return value
359
+
360
+
272
361
  def _parse_prodtest_mask(value: str) -> bytes:
273
362
  try:
274
363
  return prodtest.mask_from_hex(value)
@@ -409,6 +498,267 @@ def _render_read_dmx_result(result, rx_frames):
409
498
  )
410
499
 
411
500
 
501
+ class FirmwareUpdateError(RuntimeError):
502
+ """Raised when TiMo firmware update prerequisites or transfers fail."""
503
+
504
+
505
+ FW_UPDATE_SPI_LIMIT_HZ = 2_000_000
506
+ FW_UPDATE_BOOT_DELAY_S = 1.75
507
+ FW_UPDATE_IRQ_RETRIES = 5
508
+ FW_UPDATE_IRQ_RETRY_DELAY_S = 0.25
509
+
510
+
511
+ def _run_timo_sequence_with_client(
512
+ client,
513
+ sequence: Sequence[Dict[str, Any]],
514
+ *,
515
+ label: str,
516
+ ) -> List[Dict[str, Any]]:
517
+ responses: List[Dict[str, Any]] = []
518
+ for idx, transfer in enumerate(sequence):
519
+ response = client.spi_xfer(**transfer)
520
+ responses.append(response)
521
+ if not response.get("ok"):
522
+ phase = "command" if idx == 0 else "payload"
523
+ err = response.get("err", {})
524
+ details = f"code={err.get('code')} msg={err.get('msg')}" if err else ""
525
+ raise FirmwareUpdateError(
526
+ f"{label} failed during {phase} phase {details}".strip()
527
+ )
528
+ return responses
529
+
530
+
531
+ def _write_reg_checked(client, address: int, data: bytes) -> timo.WriteRegisterResult:
532
+ label = f"write-reg 0x{address:02X}"
533
+ for attempt in range(1, FW_UPDATE_IRQ_RETRIES + 1):
534
+ responses = _run_timo_sequence_with_client(
535
+ client,
536
+ timo.write_reg_sequence(address, data),
537
+ label=label,
538
+ )
539
+ rx_frames = [resp.get("rx", "") for resp in responses]
540
+ try:
541
+ parsed = timo.parse_write_reg_response(address, data, rx_frames)
542
+ except ValueError as exc: # pragma: no cover - defensive
543
+ raise FirmwareUpdateError(
544
+ f"Unable to parse {label} response: {exc}"
545
+ ) from exc
546
+ needs_retry = timo.requires_restart(
547
+ parsed.irq_flags_command
548
+ ) or timo.requires_restart(parsed.irq_flags_payload)
549
+ if not needs_retry:
550
+ return parsed
551
+ if attempt < FW_UPDATE_IRQ_RETRIES:
552
+ console.print(
553
+ f"[yellow]{label} attempt {attempt}/{FW_UPDATE_IRQ_RETRIES} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
554
+ )
555
+ time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
556
+ raise FirmwareUpdateError(
557
+ f"{label} kept reporting IRQ bit7 after {FW_UPDATE_IRQ_RETRIES} attempts"
558
+ )
559
+
560
+
561
+ def _read_reg_checked(
562
+ client,
563
+ address: int,
564
+ length: int,
565
+ *,
566
+ label: str,
567
+ wait_irq: timo.WaitIrqOption = None,
568
+ retries: int = FW_UPDATE_IRQ_RETRIES,
569
+ ) -> timo.ReadRegisterResult:
570
+ max_attempts = max(1, retries)
571
+ for attempt in range(1, max_attempts + 1):
572
+ responses = _run_timo_sequence_with_client(
573
+ client,
574
+ timo.read_reg_sequence(address, length, wait_irq=wait_irq),
575
+ label=label,
576
+ )
577
+ rx_frames = [resp.get("rx", "") for resp in responses]
578
+ try:
579
+ parsed = timo.parse_read_reg_response(address, length, rx_frames)
580
+ except ValueError as exc:
581
+ raise FirmwareUpdateError(
582
+ f"Unable to parse {label} response: {exc}"
583
+ ) from exc
584
+ needs_retry = timo.requires_restart(
585
+ parsed.irq_flags_command
586
+ ) or timo.requires_restart(parsed.irq_flags_payload)
587
+ if not needs_retry:
588
+ return parsed
589
+ if attempt < max_attempts and max_attempts > 1:
590
+ console.print(
591
+ f"[yellow]{label} attempt {attempt}/{max_attempts} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
592
+ )
593
+ time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
594
+ raise FirmwareUpdateError(
595
+ f"{label} kept reporting IRQ bit7 after {max_attempts} attempts"
596
+ )
597
+
598
+
599
+ def _ensure_spi_ready_for_update(client, *, max_frame_bytes: int) -> Dict[str, Any]:
600
+ info = client.get_info()
601
+ spi_caps = info.get("spi_caps") or {}
602
+ max_transfer = spi_caps.get("max_transfer_bytes")
603
+ if not isinstance(max_transfer, int) or max_transfer < max_frame_bytes:
604
+ raise FirmwareUpdateError(
605
+ "Device SPI transport cannot send the required firmware block size "
606
+ f"(needs {max_frame_bytes} bytes, reports {max_transfer}). Update the devboard firmware."
607
+ )
608
+ cfg_resp = client.spi_cfg()
609
+ spi_cfg = cfg_resp.get("spi") or {}
610
+ hz = spi_cfg.get("hz") or spi_caps.get("default_hz")
611
+ if isinstance(hz, str):
612
+ try:
613
+ hz = int(hz, 0)
614
+ except ValueError:
615
+ hz = None
616
+ if isinstance(hz, int) and hz > FW_UPDATE_SPI_LIMIT_HZ:
617
+ raise FirmwareUpdateError(
618
+ f"Configured SPI clock {hz} Hz exceeds update limit {FW_UPDATE_SPI_LIMIT_HZ} Hz. "
619
+ "Run 'shuttle spi-cfg --hz 2000000' before retrying."
620
+ )
621
+ enable_resp = client.spi_enable()
622
+ if not enable_resp.get("ok"):
623
+ err = enable_resp.get("err", {})
624
+ msg = err.get("msg") if isinstance(err, dict) else "unable to enable SPI"
625
+ raise FirmwareUpdateError(f"spi.enable failed: {msg}")
626
+ return spi_caps
627
+
628
+
629
+ def _send_fw_block(
630
+ client,
631
+ opcode: int,
632
+ payload: bytes,
633
+ *,
634
+ max_transfer_bytes: int,
635
+ ):
636
+ frame = bytes([opcode]) + payload
637
+ if len(frame) > max_transfer_bytes:
638
+ raise FirmwareUpdateError(
639
+ f"FW block (opcode 0x{opcode:02X}) exceeds spi_caps.max_transfer_bytes"
640
+ )
641
+ response = client.spi_xfer(tx=frame.hex(), n=len(frame))
642
+ if not response.get("ok"):
643
+ err = response.get("err", {})
644
+ msg = err.get("msg") if isinstance(err, dict) else "unknown"
645
+ raise FirmwareUpdateError(f"FW block opcode 0x{opcode:02X} failed: {msg}")
646
+
647
+
648
+ def _read_status_byte(
649
+ client,
650
+ *,
651
+ wait_irq: timo.WaitIrqOption = None,
652
+ retries: int = FW_UPDATE_IRQ_RETRIES,
653
+ ) -> int:
654
+ reg_meta = timo.REGISTER_MAP["STATUS"]
655
+ result = _read_reg_checked(
656
+ client,
657
+ reg_meta["address"],
658
+ reg_meta.get("length", 1),
659
+ label="STATUS register",
660
+ wait_irq=wait_irq,
661
+ retries=retries,
662
+ )
663
+ return result.data[0] if result.data else 0
664
+
665
+
666
+ def _read_version_bytes(client) -> bytes:
667
+ reg_meta = timo.REGISTER_MAP["VERSION"]
668
+ result = _read_reg_checked(
669
+ client,
670
+ reg_meta["address"],
671
+ reg_meta.get("length", 8),
672
+ label="VERSION register",
673
+ )
674
+ return result.data
675
+
676
+
677
+ def _enter_update_mode(client) -> None:
678
+ config_addr = timo.REGISTER_MAP["CONFIG"]["address"]
679
+ console.print("[cyan]Requesting TiMo UPDATE_MODE[/]")
680
+ _write_reg_checked(client, config_addr, bytes([0x40]))
681
+ console.print(
682
+ f"Waiting {FW_UPDATE_BOOT_DELAY_S:.3f}s before reading STATUS for UPDATE_MODE"
683
+ )
684
+ time.sleep(FW_UPDATE_BOOT_DELAY_S)
685
+ status_byte = _read_status_byte(client, wait_irq=False, retries=3)
686
+ if status_byte & 0x80:
687
+ console.print("[green]TiMo entered UPDATE_MODE[/]")
688
+ return
689
+ raise FirmwareUpdateError("TiMo did not enter update mode (STATUS bit7 missing)")
690
+
691
+
692
+ def _format_fw_progress(block_index: int, total_blocks: int, total_bytes: int) -> str:
693
+ return f"Transferred {block_index}/{total_blocks} blocks ({total_bytes} bytes)"
694
+
695
+
696
+ def _stream_fw_image(
697
+ client,
698
+ *,
699
+ firmware_path: Path,
700
+ max_transfer_bytes: int,
701
+ flush_wait_s: float,
702
+ ) -> Tuple[int, int, bytes]:
703
+ bytes_per_block = timo.FW_BLOCK_CMD_1_SIZE + timo.FW_BLOCK_CMD_2_SIZE
704
+ try:
705
+ total_size = firmware_path.stat().st_size
706
+ payload_bytes_on_disk = total_size - timo.CCI_HEADER_SIZE
707
+ if payload_bytes_on_disk <= 0:
708
+ raise FirmwareUpdateError("CCI firmware contains no payload blocks")
709
+ if payload_bytes_on_disk % bytes_per_block != 0:
710
+ raise FirmwareUpdateError(
711
+ "CCI firmware size is not aligned to FW block payloads"
712
+ )
713
+ expected_blocks = payload_bytes_on_disk // bytes_per_block
714
+ with firmware_path.open("rb") as raw_file:
715
+ reader = io.BufferedReader(raw_file)
716
+ header = timo.read_cci_header(reader)
717
+ console.print(f"CCI header ({timo.CCI_HEADER_SIZE} bytes): {header.hex()}")
718
+ status_ctx = (
719
+ console.status(
720
+ _format_fw_progress(0, expected_blocks, 0), spinner="dots"
721
+ )
722
+ if sys.stdout.isatty()
723
+ else nullcontext(None)
724
+ )
725
+ with status_ctx as transfer_status:
726
+ total_blocks = 0
727
+ total_bytes = 0
728
+ for block_index, chunk_1, chunk_2 in timo.iter_cci_chunks(reader):
729
+ total_blocks += 1
730
+ _send_fw_block(
731
+ client,
732
+ timo.FW_BLOCK_CMD_1,
733
+ chunk_1,
734
+ max_transfer_bytes=max_transfer_bytes,
735
+ )
736
+ _send_fw_block(
737
+ client,
738
+ timo.FW_BLOCK_CMD_2,
739
+ chunk_2,
740
+ max_transfer_bytes=max_transfer_bytes,
741
+ )
742
+ total_bytes += len(chunk_1) + len(chunk_2)
743
+ message = _format_fw_progress(
744
+ block_index, expected_blocks, total_bytes
745
+ )
746
+ if transfer_status is not None:
747
+ transfer_status.update(message)
748
+ elif block_index == 1 or block_index % 16 == 0:
749
+ console.print(message)
750
+ data_blocks_sent = block_index - 1
751
+ if data_blocks_sent > 0 and data_blocks_sent % 16 == 0:
752
+ time.sleep(flush_wait_s)
753
+ if total_blocks == 0:
754
+ raise FirmwareUpdateError("CCI firmware contains no payload blocks")
755
+ return total_blocks, total_bytes, header
756
+ except OSError as exc:
757
+ raise FirmwareUpdateError(f"Unable to read firmware: {exc}") from exc
758
+ except ValueError as exc:
759
+ raise FirmwareUpdateError(str(exc)) from exc
760
+
761
+
412
762
  def _execute_timo_sequence(
413
763
  *,
414
764
  port: Optional[str],
@@ -423,13 +773,16 @@ def _execute_timo_sequence(
423
773
  responses: List[Dict[str, Any]] = []
424
774
  with spinner(f"{spinner_label} over {resolved_port}"):
425
775
  try:
426
- with NDJSONSerialClient(
776
+ with _open_serial_client(
427
777
  resolved_port,
428
778
  baudrate=baudrate,
429
779
  timeout=timeout,
430
780
  logger=logger,
431
781
  seq_tracker=seq_tracker,
432
782
  ) as client:
783
+ # Drain any pending serial noise before issuing commands, to avoid
784
+ # mixing stale data into NDJSON responses.
785
+ _flush_client_input(client)
433
786
  for transfer in sequence:
434
787
  response = client.spi_xfer(**transfer)
435
788
  responses.append(response)
@@ -1020,7 +1373,7 @@ def timo_dmx(
1020
1373
  mask = ((1 << width) - 1) << (total_bits - hi - 1)
1021
1374
  return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
1022
1375
 
1023
- with NDJSONSerialClient(
1376
+ with _open_serial_client(
1024
1377
  resolved_port,
1025
1378
  baudrate=baudrate,
1026
1379
  timeout=timeout,
@@ -1271,14 +1624,14 @@ def timo_device_name(
1271
1624
  resolved_port = _require_port(port)
1272
1625
 
1273
1626
  def _read_name(client) -> str:
1274
- seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 16))
1627
+ seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 32))
1275
1628
  responses = [client.spi_xfer(**cmd) for cmd in seq]
1276
1629
  rx = responses[-1].get("rx", "")
1277
1630
  payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
1278
1631
  data = payload[1:] if payload else b""
1279
1632
  return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
1280
1633
 
1281
- with NDJSONSerialClient(
1634
+ with _open_serial_client(
1282
1635
  resolved_port,
1283
1636
  baudrate=baudrate,
1284
1637
  timeout=timeout,
@@ -1460,6 +1813,113 @@ def timo_read_dmx(
1460
1813
  _render_read_dmx_result(parsed, rx_frames)
1461
1814
 
1462
1815
 
1816
+ @timo_app.command("update-fw")
1817
+ def timo_update_fw(
1818
+ ctx: typer.Context,
1819
+ firmware: Path = typer.Argument(
1820
+ ...,
1821
+ exists=True,
1822
+ file_okay=True,
1823
+ dir_okay=False,
1824
+ resolve_path=True,
1825
+ help="Path to TiMo .cci firmware image",
1826
+ ),
1827
+ port: Optional[str] = typer.Option(
1828
+ None,
1829
+ "--port",
1830
+ envvar="SHUTTLE_PORT",
1831
+ help="Serial port (e.g., /dev/ttyUSB0)",
1832
+ ),
1833
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1834
+ timeout: float = typer.Option(
1835
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1836
+ ),
1837
+ flush_wait_ms: float = typer.Option(
1838
+ 100.0,
1839
+ "--flush-wait-ms",
1840
+ min=0.0,
1841
+ help="Delay (ms) after each 16 data blocks (post-header)",
1842
+ ),
1843
+ final_wait_ms: float = typer.Option(
1844
+ 1000.0,
1845
+ "--final-wait-ms",
1846
+ min=0.0,
1847
+ help="Delay (ms) after streaming all blocks to let TiMo finalize",
1848
+ ),
1849
+ ):
1850
+ """Flash TiMo firmware by streaming a .cci file over FW_BLOCK commands."""
1851
+
1852
+ resources = _ctx_resources(ctx)
1853
+ resolved_port = _require_port(port)
1854
+ flush_wait_s = flush_wait_ms / 1000.0
1855
+ final_wait_s = final_wait_ms / 1000.0
1856
+ console.print(f"[cyan]Starting TiMo update via {firmware}[/]")
1857
+
1858
+ try:
1859
+ with _open_serial_client(
1860
+ resolved_port,
1861
+ baudrate=baudrate,
1862
+ timeout=timeout,
1863
+ logger=resources.get("logger"),
1864
+ seq_tracker=resources.get("seq_tracker"),
1865
+ ) as client:
1866
+ spi_caps = _ensure_spi_ready_for_update(
1867
+ client, max_frame_bytes=1 + timo.FW_BLOCK_CMD_1_SIZE
1868
+ )
1869
+ _enter_update_mode(client)
1870
+ max_transfer_bytes = int(spi_caps["max_transfer_bytes"])
1871
+ blocks_sent, payload_bytes, header = _stream_fw_image(
1872
+ client,
1873
+ firmware_path=firmware,
1874
+ max_transfer_bytes=max_transfer_bytes,
1875
+ flush_wait_s=flush_wait_s,
1876
+ )
1877
+ console.print(
1878
+ f"Waiting {final_wait_s:.3f}s for TiMo to finalize the update"
1879
+ )
1880
+ time.sleep(final_wait_s)
1881
+ status_after = _read_status_byte(client)
1882
+ if status_after & 0x80:
1883
+ raise FirmwareUpdateError(
1884
+ "TiMo still reports UPDATE_MODE after sending all blocks"
1885
+ )
1886
+ version_bytes = _read_version_bytes(client)
1887
+ except FirmwareUpdateError as exc:
1888
+ console.print(f"[red]{exc}[/]")
1889
+ raise typer.Exit(1) from exc
1890
+ except ShuttleSerialError as exc:
1891
+ console.print(f"[red]{exc}[/]")
1892
+ raise typer.Exit(1) from exc
1893
+
1894
+ summary = Table(title="TiMo firmware update", show_header=False, box=None)
1895
+ summary.add_column("Field", style="cyan", no_wrap=True)
1896
+ summary.add_column("Value", style="white")
1897
+ summary.add_row("Blocks transferred", str(blocks_sent))
1898
+ summary.add_row("Data blocks", str(blocks_sent - 1))
1899
+ summary.add_row("Bytes transferred", str(payload_bytes))
1900
+ summary.add_row("CCI header", header.hex())
1901
+ console.print(summary)
1902
+
1903
+ if len(version_bytes) < 8:
1904
+ console.print(
1905
+ "[yellow]VERSION register shorter than expected; unable to decode versions[/]"
1906
+ )
1907
+ else:
1908
+ version_fields = timo.REGISTER_MAP["VERSION"]["fields"]
1909
+ fw_field = version_fields["FW_VERSION"]["bits"]
1910
+ hw_field = version_fields["HW_VERSION"]["bits"]
1911
+ fw_version = timo.slice_bits(version_bytes, *fw_field)
1912
+ hw_version = timo.slice_bits(version_bytes, *hw_field)
1913
+ version_table = Table(title="TiMo VERSION", show_header=False, box=None)
1914
+ version_table.add_column("Field", style="cyan", no_wrap=True)
1915
+ version_table.add_column("Value", style="white")
1916
+ version_table.add_row("FW_VERSION", f"0x{fw_version:08X}")
1917
+ version_table.add_row("HW_VERSION", f"0x{hw_version:08X}")
1918
+ console.print(version_table)
1919
+
1920
+ console.print("[green]TiMo firmware update complete[/]")
1921
+
1922
+
1463
1923
  @prodtest_app.command("reset")
1464
1924
  def prodtest_reset(
1465
1925
  ctx: typer.Context,
@@ -1521,17 +1981,49 @@ def prodtest_ping(
1521
1981
  logger=resources.get("logger"),
1522
1982
  seq_tracker=resources.get("seq_tracker"),
1523
1983
  )
1524
- if not responses or len(responses) < 2:
1984
+ if not responses:
1525
1985
  console.print("[red]Device returned no response[/]")
1526
1986
  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)
1987
+
1988
+ failed_idx = next(
1989
+ (idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
1990
+ )
1991
+ if failed_idx is not None:
1992
+ phase = "command" if failed_idx == 0 else "payload"
1993
+ _render_spi_response(
1994
+ f"prodtest ping ({phase})",
1995
+ responses[failed_idx],
1996
+ command_label=f"spi.xfer (prodtest {phase})",
1997
+ )
1998
+ raise typer.Exit(1)
1999
+
2000
+ if len(responses) != len(sequence):
2001
+ console.print(
2002
+ "[red]Prodtest command halted before completing all SPI phases[/]"
2003
+ )
2004
+ raise typer.Exit(1)
2005
+
2006
+ command_response, payload_response = responses
2007
+ _render_spi_response(
2008
+ "prodtest ping (command)",
2009
+ command_response,
2010
+ command_label="spi.xfer (prodtest command)",
2011
+ )
2012
+ _render_spi_response(
2013
+ "prodtest ping (payload)",
2014
+ payload_response,
2015
+ command_label="spi.xfer (prodtest payload)",
2016
+ )
2017
+
2018
+ rx_bytes = _decode_hex_response(payload_response, label="prodtest ping (payload)")
2019
+ if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
2020
+ console.print(
2021
+ "[red]Ping failed: expected '-' (0x2D), got: "
2022
+ f"{_format_hex(payload_response.get('rx', ''))}[/]"
2023
+ )
2024
+ raise typer.Exit(1)
2025
+
2026
+ console.print("[green]Ping successful: got '-' response[/]")
1535
2027
 
1536
2028
 
1537
2029
  @prodtest_app.command("antenna")
@@ -1561,9 +2053,7 @@ def prodtest_antenna(
1561
2053
  antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
1562
2054
  except KeyError as exc:
1563
2055
  allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
1564
- raise typer.BadParameter(
1565
- f"Antenna must be one of: {allowed}"
1566
- ) from exc
2056
+ raise typer.BadParameter(f"Antenna must be one of: {allowed}") from exc
1567
2057
 
1568
2058
  sequence = [prodtest.select_antenna(antenna_value)]
1569
2059
  responses = _execute_timo_sequence(
@@ -1705,7 +2195,9 @@ def prodtest_hw_device_id(
1705
2195
  raise typer.Exit(1)
1706
2196
 
1707
2197
  if len(responses) != len(sequence):
1708
- console.print("[red]Prodtest command halted before completing all SPI phases[/]")
2198
+ console.print(
2199
+ "[red]Prodtest command halted before completing all SPI phases[/]"
2200
+ )
1709
2201
  raise typer.Exit(1)
1710
2202
 
1711
2203
  result_response = responses[-1]
@@ -1796,11 +2288,11 @@ def prodtest_serial_number(
1796
2288
  result_response,
1797
2289
  command_label="spi.xfer (prodtest payload)",
1798
2290
  )
1799
- rx_bytes = _decode_hex_response(
1800
- result_response, label="prodtest serial-number"
1801
- )
2291
+ rx_bytes = _decode_hex_response(result_response, label="prodtest serial-number")
1802
2292
  if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
1803
- console.print("[red]Prodtest serial-number response shorter than expected[/]")
2293
+ console.print(
2294
+ "[red]Prodtest serial-number response shorter than expected[/]"
2295
+ )
1804
2296
  raise typer.Exit(1)
1805
2297
  serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
1806
2298
  console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
@@ -2116,7 +2608,7 @@ def spi_cfg_command(
2116
2608
  action = "Updating" if spi_payload else "Querying"
2117
2609
  with spinner(f"{action} spi.cfg over {resolved_port}"):
2118
2610
  try:
2119
- with NDJSONSerialClient(
2611
+ with _open_serial_client(
2120
2612
  resolved_port,
2121
2613
  baudrate=baudrate,
2122
2614
  timeout=timeout,
@@ -2151,13 +2643,14 @@ def spi_enable_command(
2151
2643
  resolved_port = _require_port(port)
2152
2644
  with spinner(f"Enabling SPI over {resolved_port}"):
2153
2645
  try:
2154
- with NDJSONSerialClient(
2155
- port=resolved_port,
2646
+ with _open_serial_client(
2647
+ resolved_port,
2156
2648
  baudrate=baudrate,
2157
2649
  timeout=timeout,
2158
2650
  logger=resources.get("logger"),
2159
2651
  seq_tracker=resources.get("seq_tracker"),
2160
2652
  ) as client:
2653
+ _flush_client_input(client)
2161
2654
  response = client.spi_enable()
2162
2655
  except ShuttleSerialError as exc:
2163
2656
  console.print(f"[red]{exc}[/]")
@@ -2185,13 +2678,14 @@ def spi_disable_command(
2185
2678
  resolved_port = _require_port(port)
2186
2679
  with spinner(f"Disabling SPI over {resolved_port}"):
2187
2680
  try:
2188
- with NDJSONSerialClient(
2189
- port=resolved_port,
2681
+ with _open_serial_client(
2682
+ resolved_port,
2190
2683
  baudrate=baudrate,
2191
2684
  timeout=timeout,
2192
2685
  logger=resources.get("logger"),
2193
2686
  seq_tracker=resources.get("seq_tracker"),
2194
2687
  ) as client:
2688
+ _flush_client_input(client)
2195
2689
  response = client.spi_disable()
2196
2690
  except ShuttleSerialError as exc:
2197
2691
  console.print(f"[red]{exc}[/]")
@@ -2250,7 +2744,7 @@ def uart_cfg_command(
2250
2744
  action = "Updating" if uart_payload else "Querying"
2251
2745
  with spinner(f"{action} uart.cfg over {resolved_port}"):
2252
2746
  try:
2253
- with NDJSONSerialClient(
2747
+ with _open_serial_client(
2254
2748
  resolved_port,
2255
2749
  baudrate=baudrate,
2256
2750
  timeout=timeout,
@@ -2313,7 +2807,7 @@ def uart_sub_command(
2313
2807
  action = "Updating" if sub_payload else "Querying"
2314
2808
  with spinner(f"{action} uart.sub over {resolved_port}"):
2315
2809
  try:
2316
- with NDJSONSerialClient(
2810
+ with _open_serial_client(
2317
2811
  resolved_port,
2318
2812
  baudrate=baudrate,
2319
2813
  timeout=timeout,
@@ -2328,6 +2822,125 @@ def uart_sub_command(
2328
2822
  _render_payload_response("uart.sub", response)
2329
2823
 
2330
2824
 
2825
+ @app.command("wifi-cfg")
2826
+ def wifi_cfg_command(
2827
+ ctx: typer.Context,
2828
+ port: Optional[str] = typer.Option(
2829
+ None,
2830
+ "--port",
2831
+ envvar="SHUTTLE_PORT",
2832
+ help="Serial port or host:port (e.g., /dev/ttyUSB0 or 192.168.1.10:5000)",
2833
+ ),
2834
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
2835
+ timeout: float = typer.Option(
2836
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
2837
+ ),
2838
+ ssid: Optional[str] = typer.Option(
2839
+ None,
2840
+ "--ssid",
2841
+ help="Set the station SSID",
2842
+ show_default=False,
2843
+ ),
2844
+ psk: Optional[str] = typer.Option(
2845
+ None,
2846
+ "--psk",
2847
+ help="Set the WPA/WPA2/WPA3 passphrase",
2848
+ show_default=False,
2849
+ ),
2850
+ dhcp: Optional[bool] = typer.Option(
2851
+ None,
2852
+ "--dhcp/--static",
2853
+ help="Enable DHCP or force static IPv4 addressing",
2854
+ ),
2855
+ ip_addr: Optional[str] = typer.Option(
2856
+ None,
2857
+ "--ip",
2858
+ help="Static IPv4 address (requires --static or other static fields)",
2859
+ show_default=False,
2860
+ ),
2861
+ netmask: Optional[str] = typer.Option(
2862
+ None,
2863
+ "--netmask",
2864
+ help="Static subnet mask (e.g., 255.255.255.0)",
2865
+ show_default=False,
2866
+ ),
2867
+ gateway: Optional[str] = typer.Option(
2868
+ None,
2869
+ "--gateway",
2870
+ help="Static default gateway IPv4 address",
2871
+ show_default=False,
2872
+ ),
2873
+ dns: Optional[str] = typer.Option(
2874
+ None,
2875
+ "--dns",
2876
+ help="Primary DNS server IPv4 address",
2877
+ show_default=False,
2878
+ ),
2879
+ dns_alt: Optional[str] = typer.Option(
2880
+ None,
2881
+ "--dns-alt",
2882
+ help="Secondary DNS server IPv4 address",
2883
+ show_default=False,
2884
+ ),
2885
+ ):
2886
+ """Query or update Wi-Fi credentials and network settings."""
2887
+
2888
+ resources = _ctx_resources(ctx)
2889
+ wifi_payload: Dict[str, Any] = {}
2890
+ if ssid is not None:
2891
+ wifi_payload["ssid"] = ssid
2892
+ if psk is not None:
2893
+ wifi_payload["psk"] = psk
2894
+ if dhcp is not None:
2895
+ wifi_payload["dhcp"] = dhcp
2896
+
2897
+ network_payload: Dict[str, Any] = {}
2898
+ parsed_ip = _parse_ipv4(ip_addr, name="--ip")
2899
+ parsed_mask = _parse_ipv4(netmask, name="--netmask")
2900
+ parsed_gateway = _parse_ipv4(gateway, name="--gateway")
2901
+ parsed_dns_primary = _parse_ipv4(dns, name="--dns")
2902
+ parsed_dns_secondary = _parse_ipv4(dns_alt, name="--dns-alt")
2903
+
2904
+ if parsed_ip is not None:
2905
+ network_payload["ip"] = parsed_ip
2906
+ if parsed_mask is not None:
2907
+ network_payload["netmask"] = parsed_mask
2908
+ if parsed_gateway is not None:
2909
+ network_payload["gateway"] = parsed_gateway
2910
+
2911
+ dns_entries = [
2912
+ entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry
2913
+ ]
2914
+ if dns_entries:
2915
+ network_payload["dns"] = dns_entries
2916
+
2917
+ if network_payload:
2918
+ if wifi_payload.get("dhcp") is True:
2919
+ raise typer.BadParameter(
2920
+ "Static network options cannot be combined with --dhcp"
2921
+ )
2922
+ wifi_payload.setdefault("dhcp", False)
2923
+ wifi_payload["network"] = network_payload
2924
+
2925
+ resolved_port = _require_port(port)
2926
+ action = "Updating" if wifi_payload else "Querying"
2927
+ with spinner(f"{action} wifi.cfg over {resolved_port}"):
2928
+ try:
2929
+ with _open_serial_client(
2930
+ resolved_port,
2931
+ baudrate=baudrate,
2932
+ timeout=timeout,
2933
+ logger=resources.get("logger"),
2934
+ seq_tracker=resources.get("seq_tracker"),
2935
+ ) as client:
2936
+ response = client.wifi_cfg(wifi_payload if wifi_payload else None)
2937
+ except ShuttleSerialError as exc:
2938
+ console.print(f"[red]{exc}[/]")
2939
+ raise typer.Exit(1) from exc
2940
+
2941
+ _render_payload_response("wifi.cfg", response)
2942
+
2943
+
2331
2944
  @app.command("uart-tx")
2332
2945
  def uart_tx_command(
2333
2946
  ctx: typer.Context,
@@ -2396,7 +3009,7 @@ def uart_tx_command(
2396
3009
  byte_label = "byte" if payload_len == 1 else "bytes"
2397
3010
  with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
2398
3011
  try:
2399
- with NDJSONSerialClient(
3012
+ with _open_serial_client(
2400
3013
  resolved_port,
2401
3014
  baudrate=baudrate,
2402
3015
  timeout=timeout,
@@ -2469,7 +3082,7 @@ def uart_rx_command(
2469
3082
 
2470
3083
  events_seen = 0
2471
3084
  try:
2472
- with NDJSONSerialClient(
3085
+ with _open_serial_client(
2473
3086
  resolved_port,
2474
3087
  baudrate=baudrate,
2475
3088
  timeout=timeout,
@@ -2536,13 +3149,14 @@ def power_command(
2536
3149
 
2537
3150
  with spinner(f"{action} power over {resolved_port}"):
2538
3151
  try:
2539
- with NDJSONSerialClient(
3152
+ with _open_serial_client(
2540
3153
  resolved_port,
2541
3154
  baudrate=baudrate,
2542
3155
  timeout=timeout,
2543
3156
  logger=resources.get("logger"),
2544
3157
  seq_tracker=resources.get("seq_tracker"),
2545
3158
  ) as client:
3159
+ _flush_client_input(client)
2546
3160
  method = getattr(client, method_name)
2547
3161
  response = method()
2548
3162
  except ShuttleSerialError as exc:
@@ -2576,6 +3190,11 @@ def flash_command(
2576
3190
  "--erase-first/--no-erase-first",
2577
3191
  help="Erase the entire flash before writing",
2578
3192
  ),
3193
+ sleep_after_flash: float = typer.Option(
3194
+ 1.25,
3195
+ "--sleep-after-flash",
3196
+ help="Seconds to wait after flashing to allow device reboot",
3197
+ ),
2579
3198
  ):
2580
3199
  """Flash the bundled firmware image to the devboard."""
2581
3200
 
@@ -2599,6 +3218,24 @@ def flash_command(
2599
3218
  console.print(f"[red]{exc}[/]")
2600
3219
  raise typer.Exit(1) from exc
2601
3220
 
3221
+ if sleep_after_flash:
3222
+ time.sleep(
3223
+ sleep_after_flash
3224
+ ) # Give the device a moment to reboot. 0.75s is sometimes too short.
3225
+
3226
+ # After flashing, drain/log any startup output from the device before further commands
3227
+ logger = ctx.obj["logger"] if ctx.obj and "logger" in ctx.obj else None
3228
+ try:
3229
+ from .serial_client import NDJSONSerialClient
3230
+
3231
+ # Use a short timeout just for draining
3232
+ with NDJSONSerialClient(
3233
+ resolved_port, baudrate=baudrate, timeout=0.5, logger=logger
3234
+ ) as client:
3235
+ _flush_client_input(client)
3236
+ except Exception:
3237
+ pass
3238
+
2602
3239
  label = str(manifest.get("label", board))
2603
3240
  console.print(
2604
3241
  f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
@@ -2622,7 +3259,7 @@ def get_info(
2622
3259
  resolved_port = _require_port(port)
2623
3260
  with spinner(f"Querying get.info over {resolved_port}"):
2624
3261
  try:
2625
- with NDJSONSerialClient(
3262
+ with _open_serial_client(
2626
3263
  resolved_port,
2627
3264
  baudrate=baudrate,
2628
3265
  timeout=timeout,
@@ -2653,7 +3290,7 @@ def ping(
2653
3290
  resolved_port = _require_port(port)
2654
3291
  with spinner(f"Pinging device over {resolved_port}"):
2655
3292
  try:
2656
- with NDJSONSerialClient(
3293
+ with _open_serial_client(
2657
3294
  resolved_port,
2658
3295
  baudrate=baudrate,
2659
3296
  timeout=timeout,