lr-shuttle 0.2.4__py3-none-any.whl → 0.2.9__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.4
3
+ Version: 0.2.9
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=lou_JvWNoMXxoyXyz5MSIyAUZEUAe0CmXaBnl_gqLMY,93507
1
+ shuttle/cli.py,sha256=3RkIJ4Z7RQiGObPTC_f4V2JqCTlwAaA_0PutqnYrwGU,109676
2
2
  shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
3
  shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
4
  shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
5
- shuttle/serial_client.py,sha256=bUpTs6MmJkpYBgtNYZZ0EYaybkLlrM7MlhWxHLQPh3U,18185
6
- shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
5
+ shuttle/serial_client.py,sha256=Wkkih00yt4M97S-P5kW06a2bY1fdTxafNGsf3G9Hx2Y,20408
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=CxWJQYlbL7zu9nkUzBQ0PQQET4XHZKFoxMzm9tZonqk,1099040
10
+ shuttle/firmware/esp32c5/devboard.ino.bin,sha256=HIS-3dQ_1BH0F-l0LJdkwVuFm8lG3IlYn2aHkEt0Y0g,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.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,,
14
+ lr_shuttle-0.2.9.dist-info/METADATA,sha256=vYewsrk-Da5kQrd8M5NRHe_vwbpdhThZ0V1E7fbLL90,15574
15
+ lr_shuttle-0.2.9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
16
+ lr_shuttle-0.2.9.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
+ lr_shuttle-0.2.9.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
+ lr_shuttle-0.2.9.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,53 @@ 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
+ @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
+
126
174
  @contextmanager
127
175
  def spinner(message: str, enabled: bool = True):
128
176
  """Show a Rich spinner while the body executes."""
@@ -437,6 +485,267 @@ def _render_read_dmx_result(result, rx_frames):
437
485
  )
438
486
 
439
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
+
440
749
  def _execute_timo_sequence(
441
750
  *,
442
751
  port: Optional[str],
@@ -451,13 +760,16 @@ def _execute_timo_sequence(
451
760
  responses: List[Dict[str, Any]] = []
452
761
  with spinner(f"{spinner_label} over {resolved_port}"):
453
762
  try:
454
- with NDJSONSerialClient(
763
+ with _open_serial_client(
455
764
  resolved_port,
456
765
  baudrate=baudrate,
457
766
  timeout=timeout,
458
767
  logger=logger,
459
768
  seq_tracker=seq_tracker,
460
769
  ) as client:
770
+ # Drain any pending serial noise before issuing commands, to avoid
771
+ # mixing stale data into NDJSON responses.
772
+ client.flush_input_and_log()
461
773
  for transfer in sequence:
462
774
  response = client.spi_xfer(**transfer)
463
775
  responses.append(response)
@@ -1048,7 +1360,7 @@ def timo_dmx(
1048
1360
  mask = ((1 << width) - 1) << (total_bits - hi - 1)
1049
1361
  return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
1050
1362
 
1051
- with NDJSONSerialClient(
1363
+ with _open_serial_client(
1052
1364
  resolved_port,
1053
1365
  baudrate=baudrate,
1054
1366
  timeout=timeout,
@@ -1306,7 +1618,7 @@ def timo_device_name(
1306
1618
  data = payload[1:] if payload else b""
1307
1619
  return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
1308
1620
 
1309
- with NDJSONSerialClient(
1621
+ with _open_serial_client(
1310
1622
  resolved_port,
1311
1623
  baudrate=baudrate,
1312
1624
  timeout=timeout,
@@ -1488,6 +1800,113 @@ def timo_read_dmx(
1488
1800
  _render_read_dmx_result(parsed, rx_frames)
1489
1801
 
1490
1802
 
1803
+ @timo_app.command("update-fw")
1804
+ def timo_update_fw(
1805
+ ctx: typer.Context,
1806
+ firmware: Path = typer.Argument(
1807
+ ...,
1808
+ exists=True,
1809
+ file_okay=True,
1810
+ dir_okay=False,
1811
+ resolve_path=True,
1812
+ help="Path to TiMo .cci firmware image",
1813
+ ),
1814
+ port: Optional[str] = typer.Option(
1815
+ None,
1816
+ "--port",
1817
+ envvar="SHUTTLE_PORT",
1818
+ help="Serial port (e.g., /dev/ttyUSB0)",
1819
+ ),
1820
+ baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
1821
+ timeout: float = typer.Option(
1822
+ DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
1823
+ ),
1824
+ flush_wait_ms: float = typer.Option(
1825
+ 100.0,
1826
+ "--flush-wait-ms",
1827
+ min=0.0,
1828
+ help="Delay (ms) after each 16 data blocks (post-header)",
1829
+ ),
1830
+ final_wait_ms: float = typer.Option(
1831
+ 1000.0,
1832
+ "--final-wait-ms",
1833
+ min=0.0,
1834
+ help="Delay (ms) after streaming all blocks to let TiMo finalize",
1835
+ ),
1836
+ ):
1837
+ """Flash TiMo firmware by streaming a .cci file over FW_BLOCK commands."""
1838
+
1839
+ resources = _ctx_resources(ctx)
1840
+ resolved_port = _require_port(port)
1841
+ flush_wait_s = flush_wait_ms / 1000.0
1842
+ final_wait_s = final_wait_ms / 1000.0
1843
+ console.print(f"[cyan]Starting TiMo update via {firmware}[/]")
1844
+
1845
+ try:
1846
+ with _open_serial_client(
1847
+ resolved_port,
1848
+ baudrate=baudrate,
1849
+ timeout=timeout,
1850
+ logger=resources.get("logger"),
1851
+ seq_tracker=resources.get("seq_tracker"),
1852
+ ) as client:
1853
+ spi_caps = _ensure_spi_ready_for_update(
1854
+ client, max_frame_bytes=1 + timo.FW_BLOCK_CMD_1_SIZE
1855
+ )
1856
+ _enter_update_mode(client)
1857
+ max_transfer_bytes = int(spi_caps["max_transfer_bytes"])
1858
+ blocks_sent, payload_bytes, header = _stream_fw_image(
1859
+ client,
1860
+ firmware_path=firmware,
1861
+ max_transfer_bytes=max_transfer_bytes,
1862
+ flush_wait_s=flush_wait_s,
1863
+ )
1864
+ console.print(
1865
+ f"Waiting {final_wait_s:.3f}s for TiMo to finalize the update"
1866
+ )
1867
+ time.sleep(final_wait_s)
1868
+ status_after = _read_status_byte(client)
1869
+ if status_after & 0x80:
1870
+ raise FirmwareUpdateError(
1871
+ "TiMo still reports UPDATE_MODE after sending all blocks"
1872
+ )
1873
+ version_bytes = _read_version_bytes(client)
1874
+ except FirmwareUpdateError as exc:
1875
+ console.print(f"[red]{exc}[/]")
1876
+ raise typer.Exit(1) from exc
1877
+ except ShuttleSerialError as exc:
1878
+ console.print(f"[red]{exc}[/]")
1879
+ raise typer.Exit(1) from exc
1880
+
1881
+ summary = Table(title="TiMo firmware update", show_header=False, box=None)
1882
+ summary.add_column("Field", style="cyan", no_wrap=True)
1883
+ summary.add_column("Value", style="white")
1884
+ summary.add_row("Blocks transferred", str(blocks_sent))
1885
+ summary.add_row("Data blocks", str(blocks_sent - 1))
1886
+ summary.add_row("Bytes transferred", str(payload_bytes))
1887
+ summary.add_row("CCI header", header.hex())
1888
+ console.print(summary)
1889
+
1890
+ if len(version_bytes) < 8:
1891
+ console.print(
1892
+ "[yellow]VERSION register shorter than expected; unable to decode versions[/]"
1893
+ )
1894
+ else:
1895
+ version_fields = timo.REGISTER_MAP["VERSION"]["fields"]
1896
+ fw_field = version_fields["FW_VERSION"]["bits"]
1897
+ hw_field = version_fields["HW_VERSION"]["bits"]
1898
+ fw_version = timo.slice_bits(version_bytes, *fw_field)
1899
+ hw_version = timo.slice_bits(version_bytes, *hw_field)
1900
+ version_table = Table(title="TiMo VERSION", show_header=False, box=None)
1901
+ version_table.add_column("Field", style="cyan", no_wrap=True)
1902
+ version_table.add_column("Value", style="white")
1903
+ version_table.add_row("FW_VERSION", f"0x{fw_version:08X}")
1904
+ version_table.add_row("HW_VERSION", f"0x{hw_version:08X}")
1905
+ console.print(version_table)
1906
+
1907
+ console.print("[green]TiMo firmware update complete[/]")
1908
+
1909
+
1491
1910
  @prodtest_app.command("reset")
1492
1911
  def prodtest_reset(
1493
1912
  ctx: typer.Context,
@@ -2176,7 +2595,7 @@ def spi_cfg_command(
2176
2595
  action = "Updating" if spi_payload else "Querying"
2177
2596
  with spinner(f"{action} spi.cfg over {resolved_port}"):
2178
2597
  try:
2179
- with NDJSONSerialClient(
2598
+ with _open_serial_client(
2180
2599
  resolved_port,
2181
2600
  baudrate=baudrate,
2182
2601
  timeout=timeout,
@@ -2211,13 +2630,14 @@ def spi_enable_command(
2211
2630
  resolved_port = _require_port(port)
2212
2631
  with spinner(f"Enabling SPI over {resolved_port}"):
2213
2632
  try:
2214
- with NDJSONSerialClient(
2215
- port=resolved_port,
2633
+ with _open_serial_client(
2634
+ resolved_port,
2216
2635
  baudrate=baudrate,
2217
2636
  timeout=timeout,
2218
2637
  logger=resources.get("logger"),
2219
2638
  seq_tracker=resources.get("seq_tracker"),
2220
2639
  ) as client:
2640
+ client.flush_input_and_log()
2221
2641
  response = client.spi_enable()
2222
2642
  except ShuttleSerialError as exc:
2223
2643
  console.print(f"[red]{exc}[/]")
@@ -2245,13 +2665,14 @@ def spi_disable_command(
2245
2665
  resolved_port = _require_port(port)
2246
2666
  with spinner(f"Disabling SPI over {resolved_port}"):
2247
2667
  try:
2248
- with NDJSONSerialClient(
2249
- port=resolved_port,
2668
+ with _open_serial_client(
2669
+ resolved_port,
2250
2670
  baudrate=baudrate,
2251
2671
  timeout=timeout,
2252
2672
  logger=resources.get("logger"),
2253
2673
  seq_tracker=resources.get("seq_tracker"),
2254
2674
  ) as client:
2675
+ client.flush_input_and_log()
2255
2676
  response = client.spi_disable()
2256
2677
  except ShuttleSerialError as exc:
2257
2678
  console.print(f"[red]{exc}[/]")
@@ -2310,7 +2731,7 @@ def uart_cfg_command(
2310
2731
  action = "Updating" if uart_payload else "Querying"
2311
2732
  with spinner(f"{action} uart.cfg over {resolved_port}"):
2312
2733
  try:
2313
- with NDJSONSerialClient(
2734
+ with _open_serial_client(
2314
2735
  resolved_port,
2315
2736
  baudrate=baudrate,
2316
2737
  timeout=timeout,
@@ -2373,7 +2794,7 @@ def uart_sub_command(
2373
2794
  action = "Updating" if sub_payload else "Querying"
2374
2795
  with spinner(f"{action} uart.sub over {resolved_port}"):
2375
2796
  try:
2376
- with NDJSONSerialClient(
2797
+ with _open_serial_client(
2377
2798
  resolved_port,
2378
2799
  baudrate=baudrate,
2379
2800
  timeout=timeout,
@@ -2492,7 +2913,7 @@ def wifi_cfg_command(
2492
2913
  action = "Updating" if wifi_payload else "Querying"
2493
2914
  with spinner(f"{action} wifi.cfg over {resolved_port}"):
2494
2915
  try:
2495
- with NDJSONSerialClient(
2916
+ with _open_serial_client(
2496
2917
  resolved_port,
2497
2918
  baudrate=baudrate,
2498
2919
  timeout=timeout,
@@ -2575,7 +2996,7 @@ def uart_tx_command(
2575
2996
  byte_label = "byte" if payload_len == 1 else "bytes"
2576
2997
  with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
2577
2998
  try:
2578
- with NDJSONSerialClient(
2999
+ with _open_serial_client(
2579
3000
  resolved_port,
2580
3001
  baudrate=baudrate,
2581
3002
  timeout=timeout,
@@ -2648,7 +3069,7 @@ def uart_rx_command(
2648
3069
 
2649
3070
  events_seen = 0
2650
3071
  try:
2651
- with NDJSONSerialClient(
3072
+ with _open_serial_client(
2652
3073
  resolved_port,
2653
3074
  baudrate=baudrate,
2654
3075
  timeout=timeout,
@@ -2715,13 +3136,14 @@ def power_command(
2715
3136
 
2716
3137
  with spinner(f"{action} power over {resolved_port}"):
2717
3138
  try:
2718
- with NDJSONSerialClient(
3139
+ with _open_serial_client(
2719
3140
  resolved_port,
2720
3141
  baudrate=baudrate,
2721
3142
  timeout=timeout,
2722
3143
  logger=resources.get("logger"),
2723
3144
  seq_tracker=resources.get("seq_tracker"),
2724
3145
  ) as client:
3146
+ client.flush_input_and_log()
2725
3147
  method = getattr(client, method_name)
2726
3148
  response = method()
2727
3149
  except ShuttleSerialError as exc:
@@ -2755,6 +3177,11 @@ def flash_command(
2755
3177
  "--erase-first/--no-erase-first",
2756
3178
  help="Erase the entire flash before writing",
2757
3179
  ),
3180
+ sleep_after_flash: float = typer.Option(
3181
+ 1.25,
3182
+ "--sleep-after-flash",
3183
+ help="Seconds to wait after flashing to allow device reboot",
3184
+ ),
2758
3185
  ):
2759
3186
  """Flash the bundled firmware image to the devboard."""
2760
3187
 
@@ -2778,6 +3205,24 @@ def flash_command(
2778
3205
  console.print(f"[red]{exc}[/]")
2779
3206
  raise typer.Exit(1) from exc
2780
3207
 
3208
+ if sleep_after_flash:
3209
+ time.sleep(
3210
+ sleep_after_flash
3211
+ ) # Give the device a moment to reboot. 0.75s is sometimes too short.
3212
+
3213
+ # After flashing, drain/log any startup output from the device before further commands
3214
+ logger = ctx.obj["logger"] if ctx.obj and "logger" in ctx.obj else None
3215
+ try:
3216
+ from .serial_client import NDJSONSerialClient
3217
+
3218
+ # Use a short timeout just for draining
3219
+ with NDJSONSerialClient(
3220
+ resolved_port, baudrate=baudrate, timeout=0.5, logger=logger
3221
+ ) as client:
3222
+ client.flush_input_and_log()
3223
+ except Exception:
3224
+ pass
3225
+
2781
3226
  label = str(manifest.get("label", board))
2782
3227
  console.print(
2783
3228
  f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
@@ -2801,7 +3246,7 @@ def get_info(
2801
3246
  resolved_port = _require_port(port)
2802
3247
  with spinner(f"Querying get.info over {resolved_port}"):
2803
3248
  try:
2804
- with NDJSONSerialClient(
3249
+ with _open_serial_client(
2805
3250
  resolved_port,
2806
3251
  baudrate=baudrate,
2807
3252
  timeout=timeout,
@@ -2832,7 +3277,7 @@ def ping(
2832
3277
  resolved_port = _require_port(port)
2833
3278
  with spinner(f"Pinging device over {resolved_port}"):
2834
3279
  try:
2835
- with NDJSONSerialClient(
3280
+ with _open_serial_client(
2836
3281
  resolved_port,
2837
3282
  baudrate=baudrate,
2838
3283
  timeout=timeout,
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
@@ -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
 
@@ -256,6 +263,7 @@ class NDJSONSerialClient:
256
263
  self._logger = logger
257
264
  self._seq_tracker = seq_tracker
258
265
  self._reader: Optional[threading.Thread] = None
266
+ self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
259
267
 
260
268
  def __enter__(self) -> "NDJSONSerialClient":
261
269
  return self
@@ -273,9 +281,27 @@ class NDJSONSerialClient:
273
281
  if getattr(self, "_serial", None) and self._serial.is_open:
274
282
  self._serial.close()
275
283
 
284
+ def flush_input_and_log(self):
285
+ """Read and log all available data from the serial buffer before sending a command."""
286
+ if not hasattr(self, "_serial") or not getattr(self._serial, "in_waiting", 0):
287
+ return
288
+ try:
289
+ while True:
290
+ waiting = getattr(self._serial, "in_waiting", 0)
291
+ if not waiting:
292
+ break
293
+ data = self._serial.read(waiting)
294
+ if data:
295
+ self._log_serial("RX", data)
296
+ except Exception:
297
+ pass
298
+
276
299
  def send_command(self, op: str, params: Dict[str, Any]) -> CommandFuture:
277
300
  """Send a command without blocking, returning a future for the response."""
278
301
 
302
+ # Flush and log any unread data before sending a command
303
+ self.flush_input_and_log()
304
+
279
305
  cmd_id = self._next_cmd_id()
280
306
  message: Dict[str, Any] = {"type": "cmd", "id": cmd_id, "op": op}
281
307
  message.update(params)
@@ -319,6 +345,13 @@ class NDJSONSerialClient:
319
345
  self._ensure_reader_started()
320
346
  return listener
321
347
 
348
+ def set_event_callback(
349
+ self, callback: Optional[Callable[[Dict[str, Any]], None]]
350
+ ) -> None:
351
+ """Register a callback for every device event, regardless of listeners."""
352
+
353
+ self._event_callback = callback
354
+
322
355
  def spi_xfer(
323
356
  self, *, tx: str, n: Optional[int] = None, **overrides: Any
324
357
  ) -> Dict[str, Any]:
@@ -426,8 +459,19 @@ class NDJSONSerialClient:
426
459
  def _write(self, message: Dict[str, Any]) -> None:
427
460
  serialized = json.dumps(message, separators=(",", ":"))
428
461
  payload = serialized.encode("utf-8") + b"\n"
462
+ total_written = 0
429
463
  with self._lock:
430
- self._serial.write(payload)
464
+ while total_written < len(payload):
465
+ # Throttling writes to avoid overwhelming the USB stack
466
+ chunk = payload[total_written : total_written + USB_CDC_PACKET_SIZE]
467
+ written = self._serial.write(chunk)
468
+ if written != len(chunk):
469
+ raise ShuttleSerialError(
470
+ f"Short write to serial port: wrote {written} of {len(chunk)} bytes"
471
+ )
472
+ total_written += written
473
+ if total_written < len(payload):
474
+ time.sleep(USB_CDC_WRITE_DELAY_S)
431
475
  self._log_serial("TX", payload)
432
476
 
433
477
  def _read(self) -> Optional[Dict[str, Any]]:
@@ -471,6 +515,7 @@ class NDJSONSerialClient:
471
515
  ev_name = message.get("ev")
472
516
  if not isinstance(ev_name, str):
473
517
  raise ShuttleSerialError("Device event missing ev field")
518
+ self._emit_event_callback(message)
474
519
  with self._lock:
475
520
  listeners = list(self._event_listeners.get(ev_name, []))
476
521
  for listener in listeners:
@@ -512,3 +557,13 @@ class NDJSONSerialClient:
512
557
  future.mark_exception(exc)
513
558
  for listener in listeners:
514
559
  listener.fail(exc)
560
+
561
+ def _emit_event_callback(self, message: Dict[str, Any]) -> None:
562
+ callback = getattr(self, "_event_callback", None)
563
+ if callback is None:
564
+ return
565
+ try:
566
+ callback(message)
567
+ except Exception:
568
+ # Callback failures should not kill the serial reader loop
569
+ 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