lr-shuttle 0.2.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.4
3
+ Version: 0.2.12
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
@@ -119,6 +119,7 @@ Commands implementing the SPI protocol as described at [docs.lumenradio.io/timot
119
119
  | `shuttle timo read-reg --addr 0x05 --length 2` | Performs the two-phase TiMo register read sequence and decodes the resulting payload/IRQ flags. |
120
120
  | `shuttle timo write-reg --addr 0x05 --data cafebabe` | Performs the two-phase TiMo register write sequence to write bytes to a register. |
121
121
  | `shuttle timo read-dmx --length 12` | Reads the latest received DMX values from the TiMo device using a two-phase SPI sequence. |
122
+ | `shuttle timo update-fw TIMO.cci --port /dev/ttyUSB0` | Streams a TiMo `.cci` firmware image via FW_BLOCK commands (requires SPI ≤ 2 MHz and ≥ 255-byte transfers). |
122
123
 
123
124
  All commands respect the global options declared on the root CLI (`--log`, `--seq-meta`, `--port`, etc.). Rich tables are used to render human-friendly summaries of responses and decoded payloads.
124
125
 
@@ -174,6 +175,23 @@ This will print a summary table with the length, data bytes (hex), and IRQ flags
174
175
  - `--port` is your serial device
175
176
 
176
177
 
178
+ ### TiMo Firmware Update
179
+
180
+ Use `shuttle timo update-fw` to push official `.cci` images (for example `timotwo-fx-b50f26ad.cci`; the companion `.hex` is provided for reference only) through the Shuttle bridge without touching an external programmer:
181
+
182
+ ```bash
183
+ shuttle timo update-fw timotwo-fx-b50f26ad.cci --port /dev/ttyUSB0
184
+ ```
185
+
186
+ - The command first checks `spi_caps.max_transfer_bytes` and the current SPI clock. Firmware updates require at least 255 bytes per `spi.xfer` call and a clock ≤ 2 MHz. Run `shuttle spi-cfg --hz 2000000` (or lower) if the persisted setting is faster.
187
+ - Shuttle enables SPI, sets TiMo into UPDATE_MODE by writing `0x40` to CONFIG, waits for the IRQ reboot window (0.6 s), and verifies bit 7 of STATUS before streaming data.
188
+ - `.cci` files contain a 4-byte header followed by 272-byte chunks. Because the TiMo FW loader accepts at most 255 contiguous bytes, each chunk is split into one `FW_BLOCK_CMD_1` transfer (0x8E + 254 bytes) and one `FW_BLOCK_CMD_2` transfer (0x8F + 18 bytes).
189
+ - The first chunk after the header carries metadata. After every 16 data chunks the device writes flash internally, so the CLI pauses for `--flush-wait-ms` (defaults to 500 ms) before continuing. When the whole image has been sent it waits `--final-wait-ms` (defaults to 1000 ms) to let TiMo finalize the update.
190
+ - Once STATUS clears UPDATE_MODE the command reads the VERSION register, prints FW/HW revisions, and confirms completion. If any step fails (IRQ bit 7, transport error, malformed `.cci`) the CLI aborts with a helpful message.
191
+
192
+ Tip: combine `--flush-wait-ms 0` and `--final-wait-ms 0` with a lab DUT when replaying the same firmware repeatedly, but keep the defaults when programming production hardware to honour the vendor timing guidelines.
193
+
194
+
177
195
  ### Using the Library from Python
178
196
 
179
197
  Use the transport helpers for HIL tests with explicit request→response pairing:
@@ -0,0 +1,18 @@
1
+ shuttle/cli.py,sha256=EM2vWcHFEL940SPFPkoaMHqf0p1ooX0XCJ4ydL1skoA,110024
2
+ shuttle/constants.py,sha256=222mAQEcvvjlVPpjyn_DUHrQsCBhQIzqe9-Eh6CTv1I,756
3
+ shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
+ shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
5
+ shuttle/serial_client.py,sha256=9vho6LecKw3iI0i_wGZlJjGUJDPXKLOuozPhuHceFJ8,21146
6
+ shuttle/timo.py,sha256=x5Qa_VeDEiMTbR_bcD7xw-H2kBJLpnsQhIrA4HkuoNU,18337
7
+ shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
8
+ shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
9
+ shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
10
+ shuttle/firmware/esp32c5/devboard.ino.bin,sha256=cTSGbtBQAREVSCWLOnhskBvYzTFZaKvOnJtdSxkgdUI,1101264
11
+ shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
12
+ shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
13
+ shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
14
+ lr_shuttle-0.2.12.dist-info/METADATA,sha256=_-wyxa-nUAoXYyB-KzvAQOz17qfvGd6ccl9o3CpYmdQ,15575
15
+ lr_shuttle-0.2.12.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
16
+ lr_shuttle-0.2.12.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
+ lr_shuttle-0.2.12.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
+ lr_shuttle-0.2.12.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
shuttle/cli.py CHANGED
@@ -2,12 +2,13 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  from __future__ import annotations
4
4
 
5
+ import io
5
6
  import ipaddress
6
7
  import re
7
8
  import string
8
9
  import sys
9
10
  import time
10
- from contextlib import contextmanager
11
+ from contextlib import contextmanager, nullcontext
11
12
  from pathlib import Path
12
13
  from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
13
14
 
@@ -123,6 +124,66 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
123
124
  return ctx.obj or {}
124
125
 
125
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
+
126
187
  @contextmanager
127
188
  def spinner(message: str, enabled: bool = True):
128
189
  """Show a Rich spinner while the body executes."""
@@ -437,6 +498,267 @@ def _render_read_dmx_result(result, rx_frames):
437
498
  )
438
499
 
439
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
+
440
762
  def _execute_timo_sequence(
441
763
  *,
442
764
  port: Optional[str],
@@ -451,13 +773,16 @@ def _execute_timo_sequence(
451
773
  responses: List[Dict[str, Any]] = []
452
774
  with spinner(f"{spinner_label} over {resolved_port}"):
453
775
  try:
454
- with NDJSONSerialClient(
776
+ with _open_serial_client(
455
777
  resolved_port,
456
778
  baudrate=baudrate,
457
779
  timeout=timeout,
458
780
  logger=logger,
459
781
  seq_tracker=seq_tracker,
460
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)
461
786
  for transfer in sequence:
462
787
  response = client.spi_xfer(**transfer)
463
788
  responses.append(response)
@@ -1048,7 +1373,7 @@ def timo_dmx(
1048
1373
  mask = ((1 << width) - 1) << (total_bits - hi - 1)
1049
1374
  return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
1050
1375
 
1051
- with NDJSONSerialClient(
1376
+ with _open_serial_client(
1052
1377
  resolved_port,
1053
1378
  baudrate=baudrate,
1054
1379
  timeout=timeout,
@@ -1299,14 +1624,14 @@ def timo_device_name(
1299
1624
  resolved_port = _require_port(port)
1300
1625
 
1301
1626
  def _read_name(client) -> str:
1302
- 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))
1303
1628
  responses = [client.spi_xfer(**cmd) for cmd in seq]
1304
1629
  rx = responses[-1].get("rx", "")
1305
1630
  payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
1306
1631
  data = payload[1:] if payload else b""
1307
1632
  return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
1308
1633
 
1309
- with NDJSONSerialClient(
1634
+ with _open_serial_client(
1310
1635
  resolved_port,
1311
1636
  baudrate=baudrate,
1312
1637
  timeout=timeout,
@@ -1488,6 +1813,113 @@ def timo_read_dmx(
1488
1813
  _render_read_dmx_result(parsed, rx_frames)
1489
1814
 
1490
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
+
1491
1923
  @prodtest_app.command("reset")
1492
1924
  def prodtest_reset(
1493
1925
  ctx: typer.Context,
@@ -2176,7 +2608,7 @@ def spi_cfg_command(
2176
2608
  action = "Updating" if spi_payload else "Querying"
2177
2609
  with spinner(f"{action} spi.cfg over {resolved_port}"):
2178
2610
  try:
2179
- with NDJSONSerialClient(
2611
+ with _open_serial_client(
2180
2612
  resolved_port,
2181
2613
  baudrate=baudrate,
2182
2614
  timeout=timeout,
@@ -2211,13 +2643,14 @@ def spi_enable_command(
2211
2643
  resolved_port = _require_port(port)
2212
2644
  with spinner(f"Enabling SPI over {resolved_port}"):
2213
2645
  try:
2214
- with NDJSONSerialClient(
2215
- port=resolved_port,
2646
+ with _open_serial_client(
2647
+ resolved_port,
2216
2648
  baudrate=baudrate,
2217
2649
  timeout=timeout,
2218
2650
  logger=resources.get("logger"),
2219
2651
  seq_tracker=resources.get("seq_tracker"),
2220
2652
  ) as client:
2653
+ _flush_client_input(client)
2221
2654
  response = client.spi_enable()
2222
2655
  except ShuttleSerialError as exc:
2223
2656
  console.print(f"[red]{exc}[/]")
@@ -2245,13 +2678,14 @@ def spi_disable_command(
2245
2678
  resolved_port = _require_port(port)
2246
2679
  with spinner(f"Disabling SPI over {resolved_port}"):
2247
2680
  try:
2248
- with NDJSONSerialClient(
2249
- port=resolved_port,
2681
+ with _open_serial_client(
2682
+ resolved_port,
2250
2683
  baudrate=baudrate,
2251
2684
  timeout=timeout,
2252
2685
  logger=resources.get("logger"),
2253
2686
  seq_tracker=resources.get("seq_tracker"),
2254
2687
  ) as client:
2688
+ _flush_client_input(client)
2255
2689
  response = client.spi_disable()
2256
2690
  except ShuttleSerialError as exc:
2257
2691
  console.print(f"[red]{exc}[/]")
@@ -2310,7 +2744,7 @@ def uart_cfg_command(
2310
2744
  action = "Updating" if uart_payload else "Querying"
2311
2745
  with spinner(f"{action} uart.cfg over {resolved_port}"):
2312
2746
  try:
2313
- with NDJSONSerialClient(
2747
+ with _open_serial_client(
2314
2748
  resolved_port,
2315
2749
  baudrate=baudrate,
2316
2750
  timeout=timeout,
@@ -2373,7 +2807,7 @@ def uart_sub_command(
2373
2807
  action = "Updating" if sub_payload else "Querying"
2374
2808
  with spinner(f"{action} uart.sub over {resolved_port}"):
2375
2809
  try:
2376
- with NDJSONSerialClient(
2810
+ with _open_serial_client(
2377
2811
  resolved_port,
2378
2812
  baudrate=baudrate,
2379
2813
  timeout=timeout,
@@ -2492,7 +2926,7 @@ def wifi_cfg_command(
2492
2926
  action = "Updating" if wifi_payload else "Querying"
2493
2927
  with spinner(f"{action} wifi.cfg over {resolved_port}"):
2494
2928
  try:
2495
- with NDJSONSerialClient(
2929
+ with _open_serial_client(
2496
2930
  resolved_port,
2497
2931
  baudrate=baudrate,
2498
2932
  timeout=timeout,
@@ -2575,7 +3009,7 @@ def uart_tx_command(
2575
3009
  byte_label = "byte" if payload_len == 1 else "bytes"
2576
3010
  with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
2577
3011
  try:
2578
- with NDJSONSerialClient(
3012
+ with _open_serial_client(
2579
3013
  resolved_port,
2580
3014
  baudrate=baudrate,
2581
3015
  timeout=timeout,
@@ -2648,7 +3082,7 @@ def uart_rx_command(
2648
3082
 
2649
3083
  events_seen = 0
2650
3084
  try:
2651
- with NDJSONSerialClient(
3085
+ with _open_serial_client(
2652
3086
  resolved_port,
2653
3087
  baudrate=baudrate,
2654
3088
  timeout=timeout,
@@ -2715,13 +3149,14 @@ def power_command(
2715
3149
 
2716
3150
  with spinner(f"{action} power over {resolved_port}"):
2717
3151
  try:
2718
- with NDJSONSerialClient(
3152
+ with _open_serial_client(
2719
3153
  resolved_port,
2720
3154
  baudrate=baudrate,
2721
3155
  timeout=timeout,
2722
3156
  logger=resources.get("logger"),
2723
3157
  seq_tracker=resources.get("seq_tracker"),
2724
3158
  ) as client:
3159
+ _flush_client_input(client)
2725
3160
  method = getattr(client, method_name)
2726
3161
  response = method()
2727
3162
  except ShuttleSerialError as exc:
@@ -2755,6 +3190,11 @@ def flash_command(
2755
3190
  "--erase-first/--no-erase-first",
2756
3191
  help="Erase the entire flash before writing",
2757
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
+ ),
2758
3198
  ):
2759
3199
  """Flash the bundled firmware image to the devboard."""
2760
3200
 
@@ -2778,6 +3218,24 @@ def flash_command(
2778
3218
  console.print(f"[red]{exc}[/]")
2779
3219
  raise typer.Exit(1) from exc
2780
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
+
2781
3239
  label = str(manifest.get("label", board))
2782
3240
  console.print(
2783
3241
  f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
@@ -2801,7 +3259,7 @@ def get_info(
2801
3259
  resolved_port = _require_port(port)
2802
3260
  with spinner(f"Querying get.info over {resolved_port}"):
2803
3261
  try:
2804
- with NDJSONSerialClient(
3262
+ with _open_serial_client(
2805
3263
  resolved_port,
2806
3264
  baudrate=baudrate,
2807
3265
  timeout=timeout,
@@ -2832,7 +3290,7 @@ def ping(
2832
3290
  resolved_port = _require_port(port)
2833
3291
  with spinner(f"Pinging device over {resolved_port}"):
2834
3292
  try:
2835
- with NDJSONSerialClient(
3293
+ with _open_serial_client(
2836
3294
  resolved_port,
2837
3295
  baudrate=baudrate,
2838
3296
  timeout=timeout,
shuttle/constants.py CHANGED
@@ -6,7 +6,6 @@ from __future__ import annotations
6
6
 
7
7
  from typing import Dict, Set
8
8
 
9
-
10
9
  SPI_ALLOWED_FIELDS: Set[str] = {
11
10
  "cs_active",
12
11
  "setup_us",
Binary file
shuttle/serial_client.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import json
8
8
  import secrets
9
9
  import threading
10
+ import time
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
12
13
  from typing import Any, Callable, Dict, List, Optional
@@ -17,6 +18,11 @@ import serial
17
18
  from serial import SerialException
18
19
  from .constants import DEFAULT_BAUD, DEFAULT_TIMEOUT
19
20
 
21
+ USB_CDC_PACKET_SIZE = 64
22
+ # Delay between USB CDC write chunks to avoid overwhelming the host USB stack with back-to-back packets.
23
+ # Tune for typical desktop OS USB stacks & current use cases; may need adjustment for other hosts.
24
+ USB_CDC_WRITE_DELAY_S = 0.000
25
+
20
26
 
21
27
  class ShuttleSerialError(Exception):
22
28
  """Raised when serial transport encounters an unrecoverable error."""
@@ -246,7 +252,7 @@ class NDJSONSerialClient:
246
252
  except AttributeError:
247
253
  # Test stubs without an open() method are already "connected"
248
254
  pass
249
- self._serial.reset_input_buffer()
255
+ self._reset_input_buffer()
250
256
  self._lock = threading.Lock()
251
257
  self._pending: Dict[int, CommandFuture] = {}
252
258
  self._response_backlog: Dict[int, Dict[str, Any]] = {}
@@ -256,6 +262,7 @@ class NDJSONSerialClient:
256
262
  self._logger = logger
257
263
  self._seq_tracker = seq_tracker
258
264
  self._reader: Optional[threading.Thread] = None
265
+ self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
259
266
 
260
267
  def __enter__(self) -> "NDJSONSerialClient":
261
268
  return self
@@ -273,9 +280,41 @@ class NDJSONSerialClient:
273
280
  if getattr(self, "_serial", None) and self._serial.is_open:
274
281
  self._serial.close()
275
282
 
283
+ def _reset_input_buffer(self) -> None:
284
+ serial_obj = getattr(self, "_serial", None)
285
+ if serial_obj is None:
286
+ return
287
+ reset = getattr(serial_obj, "reset_input_buffer", None)
288
+ if reset is None:
289
+ return
290
+ try:
291
+ reset()
292
+ except SerialException:
293
+ pass
294
+
295
+ def flush_input_and_log(self):
296
+ """Read and log all available data from the serial buffer before sending a command."""
297
+ if not hasattr(self, "_serial"):
298
+ return
299
+ try:
300
+ while True:
301
+ waiting = getattr(self._serial, "in_waiting", 0)
302
+ if not waiting:
303
+ break
304
+ data = self._serial.read(waiting)
305
+ if data:
306
+ self._log_serial("RX", data)
307
+ except Exception:
308
+ pass
309
+ finally:
310
+ self._reset_input_buffer()
311
+
276
312
  def send_command(self, op: str, params: Dict[str, Any]) -> CommandFuture:
277
313
  """Send a command without blocking, returning a future for the response."""
278
314
 
315
+ # Flush and log any unread data before sending a command
316
+ self.flush_input_and_log()
317
+
279
318
  cmd_id = self._next_cmd_id()
280
319
  message: Dict[str, Any] = {"type": "cmd", "id": cmd_id, "op": op}
281
320
  message.update(params)
@@ -319,6 +358,13 @@ class NDJSONSerialClient:
319
358
  self._ensure_reader_started()
320
359
  return listener
321
360
 
361
+ def set_event_callback(
362
+ self, callback: Optional[Callable[[Dict[str, Any]], None]]
363
+ ) -> None:
364
+ """Register a callback for every device event, regardless of listeners."""
365
+
366
+ self._event_callback = callback
367
+
322
368
  def spi_xfer(
323
369
  self, *, tx: str, n: Optional[int] = None, **overrides: Any
324
370
  ) -> Dict[str, Any]:
@@ -426,33 +472,53 @@ class NDJSONSerialClient:
426
472
  def _write(self, message: Dict[str, Any]) -> None:
427
473
  serialized = json.dumps(message, separators=(",", ":"))
428
474
  payload = serialized.encode("utf-8") + b"\n"
475
+ total_written = 0
429
476
  with self._lock:
430
- self._serial.write(payload)
477
+ while total_written < len(payload):
478
+ # Throttling writes to avoid overwhelming the USB stack
479
+ chunk = payload[total_written : total_written + USB_CDC_PACKET_SIZE]
480
+ written = self._serial.write(chunk)
481
+ if written != len(chunk):
482
+ raise ShuttleSerialError(
483
+ f"Short write to serial port: wrote {written} of {len(chunk)} bytes"
484
+ )
485
+ total_written += written
486
+ if total_written < len(payload):
487
+ time.sleep(USB_CDC_WRITE_DELAY_S)
431
488
  self._log_serial("TX", payload)
432
489
 
433
490
  def _read(self) -> Optional[Dict[str, Any]]:
434
- try:
435
- line = self._serial.readline()
436
- except SerialException as exc: # pragma: no cover - hardware specific
437
- raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
438
- if not line:
439
- return None
440
- self._log_serial("RX", line)
441
- stripped = line.strip()
442
- if not stripped:
443
- return None
444
- try:
445
- decoded = stripped.decode("utf-8")
446
- except UnicodeDecodeError as exc:
447
- raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
448
- try:
449
- message = json.loads(decoded)
450
- except json.JSONDecodeError as exc:
451
- raise ShuttleSerialError(
452
- f"Invalid JSON from device: {decoded} ({exc})"
453
- ) from exc
454
- self._record_sequence(message)
455
- return message
491
+ while True:
492
+ try:
493
+ line = self._serial.readline()
494
+ except SerialException as exc: # pragma: no cover - hardware specific
495
+ raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
496
+ if not line:
497
+ return None
498
+ self._log_serial("RX", line)
499
+ stripped = line.strip()
500
+ if not stripped:
501
+ return None
502
+ try:
503
+ decoded = stripped.decode("utf-8")
504
+ except UnicodeDecodeError as exc:
505
+ self._reset_input_buffer()
506
+ raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
507
+ trimmed = decoded.lstrip()
508
+ if not trimmed:
509
+ continue
510
+ if trimmed[0] not in ("{", "["):
511
+ self._reset_input_buffer()
512
+ continue
513
+ try:
514
+ message = json.loads(decoded)
515
+ except json.JSONDecodeError as exc:
516
+ self._reset_input_buffer()
517
+ raise ShuttleSerialError(
518
+ f"Invalid JSON from device: {decoded} ({exc})"
519
+ ) from exc
520
+ self._record_sequence(message)
521
+ return message
456
522
 
457
523
  def _dispatch(self, message: Dict[str, Any]) -> None:
458
524
  mtype = message.get("type")
@@ -471,6 +537,7 @@ class NDJSONSerialClient:
471
537
  ev_name = message.get("ev")
472
538
  if not isinstance(ev_name, str):
473
539
  raise ShuttleSerialError("Device event missing ev field")
540
+ self._emit_event_callback(message)
474
541
  with self._lock:
475
542
  listeners = list(self._event_listeners.get(ev_name, []))
476
543
  for listener in listeners:
@@ -512,3 +579,13 @@ class NDJSONSerialClient:
512
579
  future.mark_exception(exc)
513
580
  for listener in listeners:
514
581
  listener.fail(exc)
582
+
583
+ def _emit_event_callback(self, message: Dict[str, Any]) -> None:
584
+ callback = getattr(self, "_event_callback", None)
585
+ if callback is None:
586
+ return
587
+ try:
588
+ callback(message)
589
+ except Exception:
590
+ # Callback failures should not kill the serial reader loop
591
+ pass
shuttle/timo.py CHANGED
@@ -1,8 +1,9 @@
1
1
  #! /usr/bin/env python
2
2
  # -*- coding: utf-8 -*-
3
3
  """Helpers for TiMo SPI command sequences."""
4
+
4
5
  from __future__ import annotations
5
- from typing import Any, Dict, Sequence
6
+ from typing import Any, BinaryIO, Dict, Iterator, Sequence, Tuple, Union
6
7
 
7
8
  NOP_OPCODE = 0xFF
8
9
  READ_REG_BASE = 0b00000000
@@ -18,8 +19,21 @@ READ_RDM_CMD = 0x83 # 1000 0011: Read received RDM request
18
19
  WRITE_DMX_CMD = 0x91 # 1001 0001: Write DMX generation buffer
19
20
  WRITE_RDM_CMD = 0x92 # 1001 0010: Write an RDM response
20
21
 
22
+ FW_BLOCK_CMD_1 = 0x8E
23
+ FW_BLOCK_CMD_2 = 0x8F
24
+ FW_BLOCK_CMD_1_SIZE = 254
25
+ FW_BLOCK_CMD_2_SIZE = 18
26
+ CCI_CHUNK_SIZE = FW_BLOCK_CMD_1_SIZE + FW_BLOCK_CMD_2_SIZE
27
+ CCI_HEADER_SIZE = 4
28
+
21
29
  IRQ_FLAG_RESTART = 0x80 # Bit 7 signals the slave could not process the transfer
22
- IRQ_WAIT_TIMEOUT_US = 1_000 # 1 millisecond
30
+ IRQ_WAIT_TIMEOUT_US = 1_000_000 # Allow up to 1 second for IRQ trailing edge
31
+
32
+ WaitIrqOption = Union[Dict[str, Any], bool, None]
33
+ DEFAULT_WAIT_IRQ: Dict[str, Any] = {
34
+ "edge": "trailing",
35
+ "timeout_us": IRQ_WAIT_TIMEOUT_US,
36
+ }
23
37
 
24
38
  # Selected register map and field descriptions from TiMo SPI interface docs
25
39
  REGISTER_MAP: Dict[str, Dict[str, Any]] = {
@@ -39,6 +53,12 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
39
53
  "access": "R/W",
40
54
  "desc": "0=UART RDM, 1=SPI RDM",
41
55
  },
56
+ "UPDATE_MODE": {
57
+ "bits": (5, 5),
58
+ "access": "W",
59
+ "reset": 0,
60
+ "desc": "1=driver update mode",
61
+ },
42
62
  "RADIO_ENABLE": {
43
63
  "bits": (7, 7),
44
64
  "access": "R/W",
@@ -229,9 +249,9 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
229
249
  },
230
250
  "DEVICE_NAME": {
231
251
  "address": 0x36,
232
- "length": 16,
252
+ "length": 32,
233
253
  "fields": {
234
- "DEVICE_NAME": {"bits": (0, 128), "access": "R/W"},
254
+ "DEVICE_NAME": {"bits": (0, 255), "access": "R/W"},
235
255
  },
236
256
  },
237
257
  "UNIVERSE_NAME": {
@@ -420,7 +440,12 @@ def nop_sequence() -> Sequence[Dict[str, Any]]:
420
440
  return [command_payload(nop_frame())]
421
441
 
422
442
 
423
- def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
443
+ def read_reg_sequence(
444
+ address: int,
445
+ length: int,
446
+ *,
447
+ wait_irq: WaitIrqOption = None,
448
+ ) -> Sequence[Dict[str, Any]]:
424
449
  """Build the SPI transfer sequence to read a TiMo register."""
425
450
 
426
451
  if not 0 <= address <= READ_REG_ADDR_MASK:
@@ -429,10 +454,19 @@ def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
429
454
  raise ValueError(f"length must be 1..{READ_REG_MAX_LEN}")
430
455
 
431
456
  command_byte = READ_REG_BASE | (address & READ_REG_ADDR_MASK)
432
- # Wait for IRQ trailing edge (high-to-low) after command phase
457
+ # Allow callers to override the default IRQ wait behavior for edge cases
458
+ if wait_irq is False:
459
+ resolved_wait = None
460
+ elif wait_irq is None or wait_irq is True:
461
+ resolved_wait = dict(DEFAULT_WAIT_IRQ)
462
+ else:
463
+ resolved_wait = wait_irq
464
+ command_params: Dict[str, Any] = {}
465
+ if resolved_wait is not None:
466
+ command_params["wait_irq"] = resolved_wait
433
467
  command_transfer = command_payload(
434
468
  bytes([command_byte]),
435
- params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
469
+ params=command_params or None,
436
470
  )
437
471
  payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
438
472
  return [command_transfer, payload_transfer]
@@ -497,3 +531,29 @@ def requires_restart(irq_flags: int) -> bool:
497
531
  """Return True when bit 7 indicates the command must be retried."""
498
532
 
499
533
  return bool(irq_flags & IRQ_FLAG_RESTART)
534
+
535
+
536
+ def read_cci_header(stream: BinaryIO) -> bytes:
537
+ """Read and return the 4-byte TiMo CCI header."""
538
+
539
+ header = stream.read(CCI_HEADER_SIZE)
540
+ if len(header) != CCI_HEADER_SIZE:
541
+ raise ValueError("CCI firmware header must contain 4 bytes")
542
+ return header
543
+
544
+
545
+ def iter_cci_chunks(stream: BinaryIO) -> Iterator[Tuple[int, bytes, bytes]]:
546
+ """Yield successive FW block payloads (254+18 bytes) from a TiMo CCI image."""
547
+
548
+ block_index = 0
549
+ while True:
550
+ chunk_1 = stream.read(FW_BLOCK_CMD_1_SIZE)
551
+ if not chunk_1:
552
+ break
553
+ if len(chunk_1) != FW_BLOCK_CMD_1_SIZE:
554
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_1 payload")
555
+ chunk_2 = stream.read(FW_BLOCK_CMD_2_SIZE)
556
+ if len(chunk_2) != FW_BLOCK_CMD_2_SIZE:
557
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_2 payload")
558
+ block_index += 1
559
+ yield block_index, chunk_1, chunk_2
@@ -1,18 +0,0 @@
1
- shuttle/cli.py,sha256=lou_JvWNoMXxoyXyz5MSIyAUZEUAe0CmXaBnl_gqLMY,93507
2
- shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
- shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
- shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
5
- shuttle/serial_client.py,sha256=bUpTs6MmJkpYBgtNYZZ0EYaybkLlrM7MlhWxHLQPh3U,18185
6
- shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
7
- shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
8
- shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
9
- shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
10
- shuttle/firmware/esp32c5/devboard.ino.bin,sha256=CxWJQYlbL7zu9nkUzBQ0PQQET4XHZKFoxMzm9tZonqk,1099040
11
- shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
12
- shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
13
- shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
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,,