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.
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.9.dist-info}/METADATA +19 -1
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.9.dist-info}/RECORD +9 -9
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.9.dist-info}/WHEEL +1 -1
- shuttle/cli.py +462 -17
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/serial_client.py +56 -1
- shuttle/timo.py +64 -5
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.9.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.9.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.2.
|
|
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=
|
|
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=
|
|
6
|
-
shuttle/timo.py,sha256=
|
|
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=
|
|
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.
|
|
15
|
-
lr_shuttle-0.2.
|
|
16
|
-
lr_shuttle-0.2.
|
|
17
|
-
lr_shuttle-0.2.
|
|
18
|
-
lr_shuttle-0.2.
|
|
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,,
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2215
|
-
|
|
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
|
|
2249
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
#
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|