lr-shuttle 0.2.3__py3-none-any.whl → 0.2.8__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.3
3
+ Version: 0.2.8
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:
@@ -1,18 +1,18 @@
1
- shuttle/cli.py,sha256=i75UW_r_v2AbVTMA1X4u_LnVf3fQz5Xc00IjbxNZwN8,93515
1
+ shuttle/cli.py,sha256=fv5HfJxfg8hnyO4CCA2vwG-OfTDSygnV6MrsZwv1yD8,108548
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=WjGanUAL16qw2RZcCjHjYMKHsk-B6zY3cMeS0gPtPHE,17650
6
- shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
4
+ shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
5
+ shuttle/serial_client.py,sha256=0srdCjKHW35LQFmZM_q-A9QEtSNadJp3WpqzIAHI1zo,19743
6
+ shuttle/timo.py,sha256=SfWgiYUtPjSsUln5hgDLiYMYOt8zg1DLL5t07sgu2wY,18336
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=eCkSHtcBBbLRvHQmkvXR1jRoMMqMTMEXaXMv8GMIsD8,1098256
10
+ shuttle/firmware/esp32c5/devboard.ino.bin,sha256=idrwg2JFHYjLK2KQVj4d_v31GHkqO8gEgr2TWkPHbbY,1101248
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.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,,
14
+ lr_shuttle-0.2.8.dist-info/METADATA,sha256=6yYpMjMjWpAOEMIJntX3XDiX1MveRgqJ3TnjheFZ_Ks,15574
15
+ lr_shuttle-0.2.8.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
16
+ lr_shuttle-0.2.8.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
+ lr_shuttle-0.2.8.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
+ lr_shuttle-0.2.8.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
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
 
@@ -107,7 +108,9 @@ for entry in PRODTEST_TX_POWER_LEVELS:
107
108
  for alias in entry["aliases"]:
108
109
  PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
109
110
  PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
110
- PRODTEST_TX_POWER_CANONICAL = [entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS]
111
+ PRODTEST_TX_POWER_CANONICAL = [
112
+ entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS
113
+ ]
111
114
 
112
115
  _HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
113
116
  _IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
@@ -121,6 +124,53 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
121
124
  return ctx.obj or {}
122
125
 
123
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
+ @contextmanager
155
+ def _open_serial_client(
156
+ resolved_port: str,
157
+ *,
158
+ baudrate: int,
159
+ timeout: float,
160
+ logger: Optional[SerialLogger],
161
+ seq_tracker: Optional[SequenceTracker],
162
+ ):
163
+ with NDJSONSerialClient(
164
+ resolved_port,
165
+ baudrate=baudrate,
166
+ timeout=timeout,
167
+ logger=logger,
168
+ seq_tracker=seq_tracker,
169
+ ) as client:
170
+ with _sys_error_reporter(client):
171
+ yield client
172
+
173
+
124
174
  @contextmanager
125
175
  def spinner(message: str, enabled: bool = True):
126
176
  """Show a Rich spinner while the body executes."""
@@ -159,9 +209,7 @@ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
159
209
  resolved = parsed
160
210
  if resolved is None:
161
211
  allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
162
- raise typer.BadParameter(
163
- f"Power must be one of: {allowed} or an index 0-7"
164
- )
212
+ raise typer.BadParameter(f"Power must be one of: {allowed} or an index 0-7")
165
213
  return resolved, PRODTEST_TX_POWER_META[resolved]
166
214
 
167
215
 
@@ -259,9 +307,7 @@ def _resolve_uart_payload(
259
307
  def _normalize_port(port: str) -> str:
260
308
  trimmed = port.strip()
261
309
  if not trimmed:
262
- raise typer.BadParameter(
263
- "Serial port is required (use --port or SHUTTLE_PORT)"
264
- )
310
+ raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
265
311
  if "://" in trimmed:
266
312
  return trimmed
267
313
  if trimmed.startswith("/") or trimmed.startswith("\\"):
@@ -439,6 +485,267 @@ def _render_read_dmx_result(result, rx_frames):
439
485
  )
440
486
 
441
487
 
488
+ class FirmwareUpdateError(RuntimeError):
489
+ """Raised when TiMo firmware update prerequisites or transfers fail."""
490
+
491
+
492
+ FW_UPDATE_SPI_LIMIT_HZ = 2_000_000
493
+ FW_UPDATE_BOOT_DELAY_S = 1.75
494
+ FW_UPDATE_IRQ_RETRIES = 5
495
+ FW_UPDATE_IRQ_RETRY_DELAY_S = 0.25
496
+
497
+
498
+ def _run_timo_sequence_with_client(
499
+ client,
500
+ sequence: Sequence[Dict[str, Any]],
501
+ *,
502
+ label: str,
503
+ ) -> List[Dict[str, Any]]:
504
+ responses: List[Dict[str, Any]] = []
505
+ for idx, transfer in enumerate(sequence):
506
+ response = client.spi_xfer(**transfer)
507
+ responses.append(response)
508
+ if not response.get("ok"):
509
+ phase = "command" if idx == 0 else "payload"
510
+ err = response.get("err", {})
511
+ details = f"code={err.get('code')} msg={err.get('msg')}" if err else ""
512
+ raise FirmwareUpdateError(
513
+ f"{label} failed during {phase} phase {details}".strip()
514
+ )
515
+ return responses
516
+
517
+
518
+ def _write_reg_checked(client, address: int, data: bytes) -> timo.WriteRegisterResult:
519
+ label = f"write-reg 0x{address:02X}"
520
+ for attempt in range(1, FW_UPDATE_IRQ_RETRIES + 1):
521
+ responses = _run_timo_sequence_with_client(
522
+ client,
523
+ timo.write_reg_sequence(address, data),
524
+ label=label,
525
+ )
526
+ rx_frames = [resp.get("rx", "") for resp in responses]
527
+ try:
528
+ parsed = timo.parse_write_reg_response(address, data, rx_frames)
529
+ except ValueError as exc: # pragma: no cover - defensive
530
+ raise FirmwareUpdateError(
531
+ f"Unable to parse {label} response: {exc}"
532
+ ) from exc
533
+ needs_retry = timo.requires_restart(
534
+ parsed.irq_flags_command
535
+ ) or timo.requires_restart(parsed.irq_flags_payload)
536
+ if not needs_retry:
537
+ return parsed
538
+ if attempt < FW_UPDATE_IRQ_RETRIES:
539
+ console.print(
540
+ f"[yellow]{label} attempt {attempt}/{FW_UPDATE_IRQ_RETRIES} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
541
+ )
542
+ time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
543
+ raise FirmwareUpdateError(
544
+ f"{label} kept reporting IRQ bit7 after {FW_UPDATE_IRQ_RETRIES} attempts"
545
+ )
546
+
547
+
548
+ def _read_reg_checked(
549
+ client,
550
+ address: int,
551
+ length: int,
552
+ *,
553
+ label: str,
554
+ wait_irq: timo.WaitIrqOption = None,
555
+ retries: int = FW_UPDATE_IRQ_RETRIES,
556
+ ) -> timo.ReadRegisterResult:
557
+ max_attempts = max(1, retries)
558
+ for attempt in range(1, max_attempts + 1):
559
+ responses = _run_timo_sequence_with_client(
560
+ client,
561
+ timo.read_reg_sequence(address, length, wait_irq=wait_irq),
562
+ label=label,
563
+ )
564
+ rx_frames = [resp.get("rx", "") for resp in responses]
565
+ try:
566
+ parsed = timo.parse_read_reg_response(address, length, rx_frames)
567
+ except ValueError as exc:
568
+ raise FirmwareUpdateError(
569
+ f"Unable to parse {label} response: {exc}"
570
+ ) from exc
571
+ needs_retry = timo.requires_restart(
572
+ parsed.irq_flags_command
573
+ ) or timo.requires_restart(parsed.irq_flags_payload)
574
+ if not needs_retry:
575
+ return parsed
576
+ if attempt < max_attempts and max_attempts > 1:
577
+ console.print(
578
+ f"[yellow]{label} attempt {attempt}/{max_attempts} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
579
+ )
580
+ time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
581
+ raise FirmwareUpdateError(
582
+ f"{label} kept reporting IRQ bit7 after {max_attempts} attempts"
583
+ )
584
+
585
+
586
+ def _ensure_spi_ready_for_update(client, *, max_frame_bytes: int) -> Dict[str, Any]:
587
+ info = client.get_info()
588
+ spi_caps = info.get("spi_caps") or {}
589
+ max_transfer = spi_caps.get("max_transfer_bytes")
590
+ if not isinstance(max_transfer, int) or max_transfer < max_frame_bytes:
591
+ raise FirmwareUpdateError(
592
+ "Device SPI transport cannot send the required firmware block size "
593
+ f"(needs {max_frame_bytes} bytes, reports {max_transfer}). Update the devboard firmware."
594
+ )
595
+ cfg_resp = client.spi_cfg()
596
+ spi_cfg = cfg_resp.get("spi") or {}
597
+ hz = spi_cfg.get("hz") or spi_caps.get("default_hz")
598
+ if isinstance(hz, str):
599
+ try:
600
+ hz = int(hz, 0)
601
+ except ValueError:
602
+ hz = None
603
+ if isinstance(hz, int) and hz > FW_UPDATE_SPI_LIMIT_HZ:
604
+ raise FirmwareUpdateError(
605
+ f"Configured SPI clock {hz} Hz exceeds update limit {FW_UPDATE_SPI_LIMIT_HZ} Hz. "
606
+ "Run 'shuttle spi-cfg --hz 2000000' before retrying."
607
+ )
608
+ enable_resp = client.spi_enable()
609
+ if not enable_resp.get("ok"):
610
+ err = enable_resp.get("err", {})
611
+ msg = err.get("msg") if isinstance(err, dict) else "unable to enable SPI"
612
+ raise FirmwareUpdateError(f"spi.enable failed: {msg}")
613
+ return spi_caps
614
+
615
+
616
+ def _send_fw_block(
617
+ client,
618
+ opcode: int,
619
+ payload: bytes,
620
+ *,
621
+ max_transfer_bytes: int,
622
+ ):
623
+ frame = bytes([opcode]) + payload
624
+ if len(frame) > max_transfer_bytes:
625
+ raise FirmwareUpdateError(
626
+ f"FW block (opcode 0x{opcode:02X}) exceeds spi_caps.max_transfer_bytes"
627
+ )
628
+ response = client.spi_xfer(tx=frame.hex(), n=len(frame))
629
+ if not response.get("ok"):
630
+ err = response.get("err", {})
631
+ msg = err.get("msg") if isinstance(err, dict) else "unknown"
632
+ raise FirmwareUpdateError(f"FW block opcode 0x{opcode:02X} failed: {msg}")
633
+
634
+
635
+ def _read_status_byte(
636
+ client,
637
+ *,
638
+ wait_irq: timo.WaitIrqOption = None,
639
+ retries: int = FW_UPDATE_IRQ_RETRIES,
640
+ ) -> int:
641
+ reg_meta = timo.REGISTER_MAP["STATUS"]
642
+ result = _read_reg_checked(
643
+ client,
644
+ reg_meta["address"],
645
+ reg_meta.get("length", 1),
646
+ label="STATUS register",
647
+ wait_irq=wait_irq,
648
+ retries=retries,
649
+ )
650
+ return result.data[0] if result.data else 0
651
+
652
+
653
+ def _read_version_bytes(client) -> bytes:
654
+ reg_meta = timo.REGISTER_MAP["VERSION"]
655
+ result = _read_reg_checked(
656
+ client,
657
+ reg_meta["address"],
658
+ reg_meta.get("length", 8),
659
+ label="VERSION register",
660
+ )
661
+ return result.data
662
+
663
+
664
+ def _enter_update_mode(client) -> None:
665
+ config_addr = timo.REGISTER_MAP["CONFIG"]["address"]
666
+ console.print("[cyan]Requesting TiMo UPDATE_MODE[/]")
667
+ _write_reg_checked(client, config_addr, bytes([0x40]))
668
+ console.print(
669
+ f"Waiting {FW_UPDATE_BOOT_DELAY_S:.3f}s before reading STATUS for UPDATE_MODE"
670
+ )
671
+ time.sleep(FW_UPDATE_BOOT_DELAY_S)
672
+ status_byte = _read_status_byte(client, wait_irq=False, retries=3)
673
+ if status_byte & 0x80:
674
+ console.print("[green]TiMo entered UPDATE_MODE[/]")
675
+ return
676
+ raise FirmwareUpdateError("TiMo did not enter update mode (STATUS bit7 missing)")
677
+
678
+
679
+ def _format_fw_progress(block_index: int, total_blocks: int, total_bytes: int) -> str:
680
+ return f"Transferred {block_index}/{total_blocks} blocks ({total_bytes} bytes)"
681
+
682
+
683
+ def _stream_fw_image(
684
+ client,
685
+ *,
686
+ firmware_path: Path,
687
+ max_transfer_bytes: int,
688
+ flush_wait_s: float,
689
+ ) -> Tuple[int, int, bytes]:
690
+ bytes_per_block = timo.FW_BLOCK_CMD_1_SIZE + timo.FW_BLOCK_CMD_2_SIZE
691
+ try:
692
+ total_size = firmware_path.stat().st_size
693
+ payload_bytes_on_disk = total_size - timo.CCI_HEADER_SIZE
694
+ if payload_bytes_on_disk <= 0:
695
+ raise FirmwareUpdateError("CCI firmware contains no payload blocks")
696
+ if payload_bytes_on_disk % bytes_per_block != 0:
697
+ raise FirmwareUpdateError(
698
+ "CCI firmware size is not aligned to FW block payloads"
699
+ )
700
+ expected_blocks = payload_bytes_on_disk // bytes_per_block
701
+ with firmware_path.open("rb") as raw_file:
702
+ reader = io.BufferedReader(raw_file)
703
+ header = timo.read_cci_header(reader)
704
+ console.print(f"CCI header ({timo.CCI_HEADER_SIZE} bytes): {header.hex()}")
705
+ status_ctx = (
706
+ console.status(
707
+ _format_fw_progress(0, expected_blocks, 0), spinner="dots"
708
+ )
709
+ if sys.stdout.isatty()
710
+ else nullcontext(None)
711
+ )
712
+ with status_ctx as transfer_status:
713
+ total_blocks = 0
714
+ total_bytes = 0
715
+ for block_index, chunk_1, chunk_2 in timo.iter_cci_chunks(reader):
716
+ total_blocks += 1
717
+ _send_fw_block(
718
+ client,
719
+ timo.FW_BLOCK_CMD_1,
720
+ chunk_1,
721
+ max_transfer_bytes=max_transfer_bytes,
722
+ )
723
+ _send_fw_block(
724
+ client,
725
+ timo.FW_BLOCK_CMD_2,
726
+ chunk_2,
727
+ max_transfer_bytes=max_transfer_bytes,
728
+ )
729
+ total_bytes += len(chunk_1) + len(chunk_2)
730
+ message = _format_fw_progress(
731
+ block_index, expected_blocks, total_bytes
732
+ )
733
+ if transfer_status is not None:
734
+ transfer_status.update(message)
735
+ elif block_index == 1 or block_index % 16 == 0:
736
+ console.print(message)
737
+ data_blocks_sent = block_index - 1
738
+ if data_blocks_sent > 0 and data_blocks_sent % 16 == 0:
739
+ time.sleep(flush_wait_s)
740
+ if total_blocks == 0:
741
+ raise FirmwareUpdateError("CCI firmware contains no payload blocks")
742
+ return total_blocks, total_bytes, header
743
+ except OSError as exc:
744
+ raise FirmwareUpdateError(f"Unable to read firmware: {exc}") from exc
745
+ except ValueError as exc:
746
+ raise FirmwareUpdateError(str(exc)) from exc
747
+
748
+
442
749
  def _execute_timo_sequence(
443
750
  *,
444
751
  port: Optional[str],
@@ -453,7 +760,7 @@ def _execute_timo_sequence(
453
760
  responses: List[Dict[str, Any]] = []
454
761
  with spinner(f"{spinner_label} over {resolved_port}"):
455
762
  try:
456
- with NDJSONSerialClient(
763
+ with _open_serial_client(
457
764
  resolved_port,
458
765
  baudrate=baudrate,
459
766
  timeout=timeout,
@@ -1050,7 +1357,7 @@ def timo_dmx(
1050
1357
  mask = ((1 << width) - 1) << (total_bits - hi - 1)
1051
1358
  return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
1052
1359
 
1053
- with NDJSONSerialClient(
1360
+ with _open_serial_client(
1054
1361
  resolved_port,
1055
1362
  baudrate=baudrate,
1056
1363
  timeout=timeout,
@@ -1308,7 +1615,7 @@ def timo_device_name(
1308
1615
  data = payload[1:] if payload else b""
1309
1616
  return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
1310
1617
 
1311
- with NDJSONSerialClient(
1618
+ with _open_serial_client(
1312
1619
  resolved_port,
1313
1620
  baudrate=baudrate,
1314
1621
  timeout=timeout,
@@ -1490,6 +1797,113 @@ def timo_read_dmx(
1490
1797
  _render_read_dmx_result(parsed, rx_frames)
1491
1798
 
1492
1799
 
1800
+ @timo_app.command("update-fw")
1801
+ def timo_update_fw(
1802
+ ctx: typer.Context,
1803
+ firmware: Path = typer.Argument(
1804
+ ...,
1805
+ exists=True,
1806
+ file_okay=True,
1807
+ dir_okay=False,
1808
+ resolve_path=True,
1809
+ help="Path to TiMo .cci firmware image",
1810
+ ),
1811
+ port: Optional[str] = typer.Option(
1812
+ None,
1813
+ "--port",
1814
+ envvar="SHUTTLE_PORT",
1815
+ help="Serial port (e.g., /dev/ttyUSB0)",
1816
+ ),
1817
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1818
+ timeout: float = typer.Option(
1819
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1820
+ ),
1821
+ flush_wait_ms: float = typer.Option(
1822
+ 100.0,
1823
+ "--flush-wait-ms",
1824
+ min=0.0,
1825
+ help="Delay (ms) after each 16 data blocks (post-header)",
1826
+ ),
1827
+ final_wait_ms: float = typer.Option(
1828
+ 1000.0,
1829
+ "--final-wait-ms",
1830
+ min=0.0,
1831
+ help="Delay (ms) after streaming all blocks to let TiMo finalize",
1832
+ ),
1833
+ ):
1834
+ """Flash TiMo firmware by streaming a .cci file over FW_BLOCK commands."""
1835
+
1836
+ resources = _ctx_resources(ctx)
1837
+ resolved_port = _require_port(port)
1838
+ flush_wait_s = flush_wait_ms / 1000.0
1839
+ final_wait_s = final_wait_ms / 1000.0
1840
+ console.print(f"[cyan]Starting TiMo update via {firmware}[/]")
1841
+
1842
+ try:
1843
+ with _open_serial_client(
1844
+ resolved_port,
1845
+ baudrate=baudrate,
1846
+ timeout=timeout,
1847
+ logger=resources.get("logger"),
1848
+ seq_tracker=resources.get("seq_tracker"),
1849
+ ) as client:
1850
+ spi_caps = _ensure_spi_ready_for_update(
1851
+ client, max_frame_bytes=1 + timo.FW_BLOCK_CMD_1_SIZE
1852
+ )
1853
+ _enter_update_mode(client)
1854
+ max_transfer_bytes = int(spi_caps["max_transfer_bytes"])
1855
+ blocks_sent, payload_bytes, header = _stream_fw_image(
1856
+ client,
1857
+ firmware_path=firmware,
1858
+ max_transfer_bytes=max_transfer_bytes,
1859
+ flush_wait_s=flush_wait_s,
1860
+ )
1861
+ console.print(
1862
+ f"Waiting {final_wait_s:.3f}s for TiMo to finalize the update"
1863
+ )
1864
+ time.sleep(final_wait_s)
1865
+ status_after = _read_status_byte(client)
1866
+ if status_after & 0x80:
1867
+ raise FirmwareUpdateError(
1868
+ "TiMo still reports UPDATE_MODE after sending all blocks"
1869
+ )
1870
+ version_bytes = _read_version_bytes(client)
1871
+ except FirmwareUpdateError as exc:
1872
+ console.print(f"[red]{exc}[/]")
1873
+ raise typer.Exit(1) from exc
1874
+ except ShuttleSerialError as exc:
1875
+ console.print(f"[red]{exc}[/]")
1876
+ raise typer.Exit(1) from exc
1877
+
1878
+ summary = Table(title="TiMo firmware update", show_header=False, box=None)
1879
+ summary.add_column("Field", style="cyan", no_wrap=True)
1880
+ summary.add_column("Value", style="white")
1881
+ summary.add_row("Blocks transferred", str(blocks_sent))
1882
+ summary.add_row("Data blocks", str(blocks_sent - 1))
1883
+ summary.add_row("Bytes transferred", str(payload_bytes))
1884
+ summary.add_row("CCI header", header.hex())
1885
+ console.print(summary)
1886
+
1887
+ if len(version_bytes) < 8:
1888
+ console.print(
1889
+ "[yellow]VERSION register shorter than expected; unable to decode versions[/]"
1890
+ )
1891
+ else:
1892
+ version_fields = timo.REGISTER_MAP["VERSION"]["fields"]
1893
+ fw_field = version_fields["FW_VERSION"]["bits"]
1894
+ hw_field = version_fields["HW_VERSION"]["bits"]
1895
+ fw_version = timo.slice_bits(version_bytes, *fw_field)
1896
+ hw_version = timo.slice_bits(version_bytes, *hw_field)
1897
+ version_table = Table(title="TiMo VERSION", show_header=False, box=None)
1898
+ version_table.add_column("Field", style="cyan", no_wrap=True)
1899
+ version_table.add_column("Value", style="white")
1900
+ version_table.add_row("FW_VERSION", f"0x{fw_version:08X}")
1901
+ version_table.add_row("HW_VERSION", f"0x{hw_version:08X}")
1902
+ console.print(version_table)
1903
+
1904
+ console.print("[green]TiMo firmware update complete[/]")
1905
+
1906
+
1493
1907
  @prodtest_app.command("reset")
1494
1908
  def prodtest_reset(
1495
1909
  ctx: typer.Context,
@@ -1568,7 +1982,9 @@ def prodtest_ping(
1568
1982
  raise typer.Exit(1)
1569
1983
 
1570
1984
  if len(responses) != len(sequence):
1571
- console.print("[red]Prodtest command halted before completing all SPI phases[/]")
1985
+ console.print(
1986
+ "[red]Prodtest command halted before completing all SPI phases[/]"
1987
+ )
1572
1988
  raise typer.Exit(1)
1573
1989
 
1574
1990
  command_response, payload_response = responses
@@ -1583,9 +1999,7 @@ def prodtest_ping(
1583
1999
  command_label="spi.xfer (prodtest payload)",
1584
2000
  )
1585
2001
 
1586
- rx_bytes = _decode_hex_response(
1587
- payload_response, label="prodtest ping (payload)"
1588
- )
2002
+ rx_bytes = _decode_hex_response(payload_response, label="prodtest ping (payload)")
1589
2003
  if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
1590
2004
  console.print(
1591
2005
  "[red]Ping failed: expected '-' (0x2D), got: "
@@ -1623,9 +2037,7 @@ def prodtest_antenna(
1623
2037
  antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
1624
2038
  except KeyError as exc:
1625
2039
  allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
1626
- raise typer.BadParameter(
1627
- f"Antenna must be one of: {allowed}"
1628
- ) from exc
2040
+ raise typer.BadParameter(f"Antenna must be one of: {allowed}") from exc
1629
2041
 
1630
2042
  sequence = [prodtest.select_antenna(antenna_value)]
1631
2043
  responses = _execute_timo_sequence(
@@ -1767,7 +2179,9 @@ def prodtest_hw_device_id(
1767
2179
  raise typer.Exit(1)
1768
2180
 
1769
2181
  if len(responses) != len(sequence):
1770
- console.print("[red]Prodtest command halted before completing all SPI phases[/]")
2182
+ console.print(
2183
+ "[red]Prodtest command halted before completing all SPI phases[/]"
2184
+ )
1771
2185
  raise typer.Exit(1)
1772
2186
 
1773
2187
  result_response = responses[-1]
@@ -1858,11 +2272,11 @@ def prodtest_serial_number(
1858
2272
  result_response,
1859
2273
  command_label="spi.xfer (prodtest payload)",
1860
2274
  )
1861
- rx_bytes = _decode_hex_response(
1862
- result_response, label="prodtest serial-number"
1863
- )
2275
+ rx_bytes = _decode_hex_response(result_response, label="prodtest serial-number")
1864
2276
  if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
1865
- console.print("[red]Prodtest serial-number response shorter than expected[/]")
2277
+ console.print(
2278
+ "[red]Prodtest serial-number response shorter than expected[/]"
2279
+ )
1866
2280
  raise typer.Exit(1)
1867
2281
  serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
1868
2282
  console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
@@ -2178,7 +2592,7 @@ def spi_cfg_command(
2178
2592
  action = "Updating" if spi_payload else "Querying"
2179
2593
  with spinner(f"{action} spi.cfg over {resolved_port}"):
2180
2594
  try:
2181
- with NDJSONSerialClient(
2595
+ with _open_serial_client(
2182
2596
  resolved_port,
2183
2597
  baudrate=baudrate,
2184
2598
  timeout=timeout,
@@ -2213,8 +2627,8 @@ def spi_enable_command(
2213
2627
  resolved_port = _require_port(port)
2214
2628
  with spinner(f"Enabling SPI over {resolved_port}"):
2215
2629
  try:
2216
- with NDJSONSerialClient(
2217
- port=resolved_port,
2630
+ with _open_serial_client(
2631
+ resolved_port,
2218
2632
  baudrate=baudrate,
2219
2633
  timeout=timeout,
2220
2634
  logger=resources.get("logger"),
@@ -2247,8 +2661,8 @@ def spi_disable_command(
2247
2661
  resolved_port = _require_port(port)
2248
2662
  with spinner(f"Disabling SPI over {resolved_port}"):
2249
2663
  try:
2250
- with NDJSONSerialClient(
2251
- port=resolved_port,
2664
+ with _open_serial_client(
2665
+ resolved_port,
2252
2666
  baudrate=baudrate,
2253
2667
  timeout=timeout,
2254
2668
  logger=resources.get("logger"),
@@ -2312,7 +2726,7 @@ def uart_cfg_command(
2312
2726
  action = "Updating" if uart_payload else "Querying"
2313
2727
  with spinner(f"{action} uart.cfg over {resolved_port}"):
2314
2728
  try:
2315
- with NDJSONSerialClient(
2729
+ with _open_serial_client(
2316
2730
  resolved_port,
2317
2731
  baudrate=baudrate,
2318
2732
  timeout=timeout,
@@ -2375,7 +2789,7 @@ def uart_sub_command(
2375
2789
  action = "Updating" if sub_payload else "Querying"
2376
2790
  with spinner(f"{action} uart.sub over {resolved_port}"):
2377
2791
  try:
2378
- with NDJSONSerialClient(
2792
+ with _open_serial_client(
2379
2793
  resolved_port,
2380
2794
  baudrate=baudrate,
2381
2795
  timeout=timeout,
@@ -2476,7 +2890,9 @@ def wifi_cfg_command(
2476
2890
  if parsed_gateway is not None:
2477
2891
  network_payload["gateway"] = parsed_gateway
2478
2892
 
2479
- dns_entries = [entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry]
2893
+ dns_entries = [
2894
+ entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry
2895
+ ]
2480
2896
  if dns_entries:
2481
2897
  network_payload["dns"] = dns_entries
2482
2898
 
@@ -2492,7 +2908,7 @@ def wifi_cfg_command(
2492
2908
  action = "Updating" if wifi_payload else "Querying"
2493
2909
  with spinner(f"{action} wifi.cfg over {resolved_port}"):
2494
2910
  try:
2495
- with NDJSONSerialClient(
2911
+ with _open_serial_client(
2496
2912
  resolved_port,
2497
2913
  baudrate=baudrate,
2498
2914
  timeout=timeout,
@@ -2575,7 +2991,7 @@ def uart_tx_command(
2575
2991
  byte_label = "byte" if payload_len == 1 else "bytes"
2576
2992
  with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
2577
2993
  try:
2578
- with NDJSONSerialClient(
2994
+ with _open_serial_client(
2579
2995
  resolved_port,
2580
2996
  baudrate=baudrate,
2581
2997
  timeout=timeout,
@@ -2648,7 +3064,7 @@ def uart_rx_command(
2648
3064
 
2649
3065
  events_seen = 0
2650
3066
  try:
2651
- with NDJSONSerialClient(
3067
+ with _open_serial_client(
2652
3068
  resolved_port,
2653
3069
  baudrate=baudrate,
2654
3070
  timeout=timeout,
@@ -2715,7 +3131,7 @@ def power_command(
2715
3131
 
2716
3132
  with spinner(f"{action} power over {resolved_port}"):
2717
3133
  try:
2718
- with NDJSONSerialClient(
3134
+ with _open_serial_client(
2719
3135
  resolved_port,
2720
3136
  baudrate=baudrate,
2721
3137
  timeout=timeout,
@@ -2801,7 +3217,7 @@ def get_info(
2801
3217
  resolved_port = _require_port(port)
2802
3218
  with spinner(f"Querying get.info over {resolved_port}"):
2803
3219
  try:
2804
- with NDJSONSerialClient(
3220
+ with _open_serial_client(
2805
3221
  resolved_port,
2806
3222
  baudrate=baudrate,
2807
3223
  timeout=timeout,
@@ -2832,7 +3248,7 @@ def ping(
2832
3248
  resolved_port = _require_port(port)
2833
3249
  with spinner(f"Pinging device over {resolved_port}"):
2834
3250
  try:
2835
- with NDJSONSerialClient(
3251
+ with _open_serial_client(
2836
3252
  resolved_port,
2837
3253
  baudrate=baudrate,
2838
3254
  timeout=timeout,
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
@@ -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
@@ -18,6 +19,12 @@ from serial import SerialException
18
19
  from .constants import DEFAULT_BAUD, DEFAULT_TIMEOUT
19
20
 
20
21
 
22
+ USB_CDC_PACKET_SIZE = 64
23
+ # Delay between USB CDC write chunks to avoid overwhelming the host USB stack with back-to-back packets.
24
+ # Tune for typical desktop OS USB stacks & current use cases; may need adjustment for other hosts.
25
+ USB_CDC_WRITE_DELAY_S = 0.000
26
+
27
+
21
28
  class ShuttleSerialError(Exception):
22
29
  """Raised when serial transport encounters an unrecoverable error."""
23
30
 
@@ -229,10 +236,23 @@ class NDJSONSerialClient:
229
236
  ):
230
237
  try:
231
238
  self._serial = serial.serial_for_url(
232
- url=port, baudrate=baudrate, timeout=timeout
239
+ url=port,
240
+ baudrate=baudrate,
241
+ timeout=timeout,
242
+ do_not_open=True,
233
243
  )
244
+ except SerialException as exc: # pragma: no cover - hardware specific
245
+ raise ShuttleSerialError(f"Unable to initialize {port}: {exc}") from exc
246
+
247
+ try:
248
+ if getattr(self._serial, "open", None) is not None:
249
+ if not getattr(self._serial, "is_open", False):
250
+ self._serial.open()
234
251
  except SerialException as exc: # pragma: no cover - hardware specific
235
252
  raise ShuttleSerialError(f"Unable to open {port}: {exc}") from exc
253
+ except AttributeError:
254
+ # Test stubs without an open() method are already "connected"
255
+ pass
236
256
  self._serial.reset_input_buffer()
237
257
  self._lock = threading.Lock()
238
258
  self._pending: Dict[int, CommandFuture] = {}
@@ -243,6 +263,7 @@ class NDJSONSerialClient:
243
263
  self._logger = logger
244
264
  self._seq_tracker = seq_tracker
245
265
  self._reader: Optional[threading.Thread] = None
266
+ self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
246
267
 
247
268
  def __enter__(self) -> "NDJSONSerialClient":
248
269
  return self
@@ -306,6 +327,13 @@ class NDJSONSerialClient:
306
327
  self._ensure_reader_started()
307
328
  return listener
308
329
 
330
+ def set_event_callback(
331
+ self, callback: Optional[Callable[[Dict[str, Any]], None]]
332
+ ) -> None:
333
+ """Register a callback for every device event, regardless of listeners."""
334
+
335
+ self._event_callback = callback
336
+
309
337
  def spi_xfer(
310
338
  self, *, tx: str, n: Optional[int] = None, **overrides: Any
311
339
  ) -> Dict[str, Any]:
@@ -413,8 +441,19 @@ class NDJSONSerialClient:
413
441
  def _write(self, message: Dict[str, Any]) -> None:
414
442
  serialized = json.dumps(message, separators=(",", ":"))
415
443
  payload = serialized.encode("utf-8") + b"\n"
444
+ total_written = 0
416
445
  with self._lock:
417
- self._serial.write(payload)
446
+ while total_written < len(payload):
447
+ # Throttling writes to avoid overwhelming the USB stack
448
+ chunk = payload[total_written : total_written + USB_CDC_PACKET_SIZE]
449
+ written = self._serial.write(chunk)
450
+ if written != len(chunk):
451
+ raise ShuttleSerialError(
452
+ f"Short write to serial port: wrote {written} of {len(chunk)} bytes"
453
+ )
454
+ total_written += written
455
+ if total_written < len(payload):
456
+ time.sleep(USB_CDC_WRITE_DELAY_S)
418
457
  self._log_serial("TX", payload)
419
458
 
420
459
  def _read(self) -> Optional[Dict[str, Any]]:
@@ -458,6 +497,7 @@ class NDJSONSerialClient:
458
497
  ev_name = message.get("ev")
459
498
  if not isinstance(ev_name, str):
460
499
  raise ShuttleSerialError("Device event missing ev field")
500
+ self._emit_event_callback(message)
461
501
  with self._lock:
462
502
  listeners = list(self._event_listeners.get(ev_name, []))
463
503
  for listener in listeners:
@@ -499,3 +539,13 @@ class NDJSONSerialClient:
499
539
  future.mark_exception(exc)
500
540
  for listener in listeners:
501
541
  listener.fail(exc)
542
+
543
+ def _emit_event_callback(self, message: Dict[str, Any]) -> None:
544
+ callback = getattr(self, "_event_callback", None)
545
+ if callback is None:
546
+ return
547
+ try:
548
+ callback(message)
549
+ except Exception:
550
+ # Callback failures should not kill the serial reader loop
551
+ pass
shuttle/timo.py CHANGED
@@ -2,7 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """Helpers for TiMo SPI command sequences."""
4
4
  from __future__ import annotations
5
- from typing import Any, Dict, Sequence
5
+ from typing import Any, BinaryIO, Dict, Iterator, Sequence, Tuple, Union
6
6
 
7
7
  NOP_OPCODE = 0xFF
8
8
  READ_REG_BASE = 0b00000000
@@ -18,8 +18,21 @@ READ_RDM_CMD = 0x83 # 1000 0011: Read received RDM request
18
18
  WRITE_DMX_CMD = 0x91 # 1001 0001: Write DMX generation buffer
19
19
  WRITE_RDM_CMD = 0x92 # 1001 0010: Write an RDM response
20
20
 
21
+ FW_BLOCK_CMD_1 = 0x8E
22
+ FW_BLOCK_CMD_2 = 0x8F
23
+ FW_BLOCK_CMD_1_SIZE = 254
24
+ FW_BLOCK_CMD_2_SIZE = 18
25
+ CCI_CHUNK_SIZE = FW_BLOCK_CMD_1_SIZE + FW_BLOCK_CMD_2_SIZE
26
+ CCI_HEADER_SIZE = 4
27
+
21
28
  IRQ_FLAG_RESTART = 0x80 # Bit 7 signals the slave could not process the transfer
22
- IRQ_WAIT_TIMEOUT_US = 1_000 # 1 millisecond
29
+ IRQ_WAIT_TIMEOUT_US = 1_000_000 # Allow up to 1 second for IRQ trailing edge
30
+
31
+ WaitIrqOption = Union[Dict[str, Any], bool, None]
32
+ DEFAULT_WAIT_IRQ: Dict[str, Any] = {
33
+ "edge": "trailing",
34
+ "timeout_us": IRQ_WAIT_TIMEOUT_US,
35
+ }
23
36
 
24
37
  # Selected register map and field descriptions from TiMo SPI interface docs
25
38
  REGISTER_MAP: Dict[str, Dict[str, Any]] = {
@@ -39,6 +52,12 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
39
52
  "access": "R/W",
40
53
  "desc": "0=UART RDM, 1=SPI RDM",
41
54
  },
55
+ "UPDATE_MODE": {
56
+ "bits": (5, 5),
57
+ "access": "W",
58
+ "reset": 0,
59
+ "desc": "1=driver update mode",
60
+ },
42
61
  "RADIO_ENABLE": {
43
62
  "bits": (7, 7),
44
63
  "access": "R/W",
@@ -420,7 +439,12 @@ def nop_sequence() -> Sequence[Dict[str, Any]]:
420
439
  return [command_payload(nop_frame())]
421
440
 
422
441
 
423
- def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
442
+ def read_reg_sequence(
443
+ address: int,
444
+ length: int,
445
+ *,
446
+ wait_irq: WaitIrqOption = None,
447
+ ) -> Sequence[Dict[str, Any]]:
424
448
  """Build the SPI transfer sequence to read a TiMo register."""
425
449
 
426
450
  if not 0 <= address <= READ_REG_ADDR_MASK:
@@ -429,10 +453,19 @@ def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
429
453
  raise ValueError(f"length must be 1..{READ_REG_MAX_LEN}")
430
454
 
431
455
  command_byte = READ_REG_BASE | (address & READ_REG_ADDR_MASK)
432
- # Wait for IRQ trailing edge (high-to-low) after command phase
456
+ # Allow callers to override the default IRQ wait behavior for edge cases
457
+ if wait_irq is False:
458
+ resolved_wait = None
459
+ elif wait_irq is None or wait_irq is True:
460
+ resolved_wait = dict(DEFAULT_WAIT_IRQ)
461
+ else:
462
+ resolved_wait = wait_irq
463
+ command_params: Dict[str, Any] = {}
464
+ if resolved_wait is not None:
465
+ command_params["wait_irq"] = resolved_wait
433
466
  command_transfer = command_payload(
434
467
  bytes([command_byte]),
435
- params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
468
+ params=command_params or None,
436
469
  )
437
470
  payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
438
471
  return [command_transfer, payload_transfer]
@@ -497,3 +530,29 @@ def requires_restart(irq_flags: int) -> bool:
497
530
  """Return True when bit 7 indicates the command must be retried."""
498
531
 
499
532
  return bool(irq_flags & IRQ_FLAG_RESTART)
533
+
534
+
535
+ def read_cci_header(stream: BinaryIO) -> bytes:
536
+ """Read and return the 4-byte TiMo CCI header."""
537
+
538
+ header = stream.read(CCI_HEADER_SIZE)
539
+ if len(header) != CCI_HEADER_SIZE:
540
+ raise ValueError("CCI firmware header must contain 4 bytes")
541
+ return header
542
+
543
+
544
+ def iter_cci_chunks(stream: BinaryIO) -> Iterator[Tuple[int, bytes, bytes]]:
545
+ """Yield successive FW block payloads (254+18 bytes) from a TiMo CCI image."""
546
+
547
+ block_index = 0
548
+ while True:
549
+ chunk_1 = stream.read(FW_BLOCK_CMD_1_SIZE)
550
+ if not chunk_1:
551
+ break
552
+ if len(chunk_1) != FW_BLOCK_CMD_1_SIZE:
553
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_1 payload")
554
+ chunk_2 = stream.read(FW_BLOCK_CMD_2_SIZE)
555
+ if len(chunk_2) != FW_BLOCK_CMD_2_SIZE:
556
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_2 payload")
557
+ block_index += 1
558
+ yield block_index, chunk_1, chunk_2