lr-shuttle 0.2.4__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.4
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=lou_JvWNoMXxoyXyz5MSIyAUZEUAe0CmXaBnl_gqLMY,93507
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
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=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=CxWJQYlbL7zu9nkUzBQ0PQQET4XHZKFoxMzm9tZonqk,1099040
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.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.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
 
@@ -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,7 +760,7 @@ 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,
@@ -1048,7 +1357,7 @@ def timo_dmx(
1048
1357
  mask = ((1 << width) - 1) << (total_bits - hi - 1)
1049
1358
  return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
1050
1359
 
1051
- with NDJSONSerialClient(
1360
+ with _open_serial_client(
1052
1361
  resolved_port,
1053
1362
  baudrate=baudrate,
1054
1363
  timeout=timeout,
@@ -1306,7 +1615,7 @@ def timo_device_name(
1306
1615
  data = payload[1:] if payload else b""
1307
1616
  return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
1308
1617
 
1309
- with NDJSONSerialClient(
1618
+ with _open_serial_client(
1310
1619
  resolved_port,
1311
1620
  baudrate=baudrate,
1312
1621
  timeout=timeout,
@@ -1488,6 +1797,113 @@ def timo_read_dmx(
1488
1797
  _render_read_dmx_result(parsed, rx_frames)
1489
1798
 
1490
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
+
1491
1907
  @prodtest_app.command("reset")
1492
1908
  def prodtest_reset(
1493
1909
  ctx: typer.Context,
@@ -2176,7 +2592,7 @@ def spi_cfg_command(
2176
2592
  action = "Updating" if spi_payload else "Querying"
2177
2593
  with spinner(f"{action} spi.cfg over {resolved_port}"):
2178
2594
  try:
2179
- with NDJSONSerialClient(
2595
+ with _open_serial_client(
2180
2596
  resolved_port,
2181
2597
  baudrate=baudrate,
2182
2598
  timeout=timeout,
@@ -2211,8 +2627,8 @@ def spi_enable_command(
2211
2627
  resolved_port = _require_port(port)
2212
2628
  with spinner(f"Enabling SPI over {resolved_port}"):
2213
2629
  try:
2214
- with NDJSONSerialClient(
2215
- port=resolved_port,
2630
+ with _open_serial_client(
2631
+ resolved_port,
2216
2632
  baudrate=baudrate,
2217
2633
  timeout=timeout,
2218
2634
  logger=resources.get("logger"),
@@ -2245,8 +2661,8 @@ def spi_disable_command(
2245
2661
  resolved_port = _require_port(port)
2246
2662
  with spinner(f"Disabling SPI over {resolved_port}"):
2247
2663
  try:
2248
- with NDJSONSerialClient(
2249
- port=resolved_port,
2664
+ with _open_serial_client(
2665
+ resolved_port,
2250
2666
  baudrate=baudrate,
2251
2667
  timeout=timeout,
2252
2668
  logger=resources.get("logger"),
@@ -2310,7 +2726,7 @@ def uart_cfg_command(
2310
2726
  action = "Updating" if uart_payload else "Querying"
2311
2727
  with spinner(f"{action} uart.cfg over {resolved_port}"):
2312
2728
  try:
2313
- with NDJSONSerialClient(
2729
+ with _open_serial_client(
2314
2730
  resolved_port,
2315
2731
  baudrate=baudrate,
2316
2732
  timeout=timeout,
@@ -2373,7 +2789,7 @@ def uart_sub_command(
2373
2789
  action = "Updating" if sub_payload else "Querying"
2374
2790
  with spinner(f"{action} uart.sub over {resolved_port}"):
2375
2791
  try:
2376
- with NDJSONSerialClient(
2792
+ with _open_serial_client(
2377
2793
  resolved_port,
2378
2794
  baudrate=baudrate,
2379
2795
  timeout=timeout,
@@ -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/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
@@ -319,6 +327,13 @@ class NDJSONSerialClient:
319
327
  self._ensure_reader_started()
320
328
  return listener
321
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
+
322
337
  def spi_xfer(
323
338
  self, *, tx: str, n: Optional[int] = None, **overrides: Any
324
339
  ) -> Dict[str, Any]:
@@ -426,8 +441,19 @@ class NDJSONSerialClient:
426
441
  def _write(self, message: Dict[str, Any]) -> None:
427
442
  serialized = json.dumps(message, separators=(",", ":"))
428
443
  payload = serialized.encode("utf-8") + b"\n"
444
+ total_written = 0
429
445
  with self._lock:
430
- 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)
431
457
  self._log_serial("TX", payload)
432
458
 
433
459
  def _read(self) -> Optional[Dict[str, Any]]:
@@ -471,6 +497,7 @@ class NDJSONSerialClient:
471
497
  ev_name = message.get("ev")
472
498
  if not isinstance(ev_name, str):
473
499
  raise ShuttleSerialError("Device event missing ev field")
500
+ self._emit_event_callback(message)
474
501
  with self._lock:
475
502
  listeners = list(self._event_listeners.get(ev_name, []))
476
503
  for listener in listeners:
@@ -512,3 +539,13 @@ class NDJSONSerialClient:
512
539
  future.mark_exception(exc)
513
540
  for listener in listeners:
514
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