lr-shuttle 0.2.4__py3-none-any.whl → 0.2.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.12.dist-info}/METADATA +19 -1
- lr_shuttle-0.2.12.dist-info/RECORD +18 -0
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.12.dist-info}/WHEEL +1 -1
- shuttle/cli.py +476 -18
- shuttle/constants.py +0 -1
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/serial_client.py +101 -24
- shuttle/timo.py +67 -7
- lr_shuttle-0.2.4.dist-info/RECORD +0 -18
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.12.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.12.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.12
|
|
4
4
|
Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -119,6 +119,7 @@ Commands implementing the SPI protocol as described at [docs.lumenradio.io/timot
|
|
|
119
119
|
| `shuttle timo read-reg --addr 0x05 --length 2` | Performs the two-phase TiMo register read sequence and decodes the resulting payload/IRQ flags. |
|
|
120
120
|
| `shuttle timo write-reg --addr 0x05 --data cafebabe` | Performs the two-phase TiMo register write sequence to write bytes to a register. |
|
|
121
121
|
| `shuttle timo read-dmx --length 12` | Reads the latest received DMX values from the TiMo device using a two-phase SPI sequence. |
|
|
122
|
+
| `shuttle timo update-fw TIMO.cci --port /dev/ttyUSB0` | Streams a TiMo `.cci` firmware image via FW_BLOCK commands (requires SPI ≤ 2 MHz and ≥ 255-byte transfers). |
|
|
122
123
|
|
|
123
124
|
All commands respect the global options declared on the root CLI (`--log`, `--seq-meta`, `--port`, etc.). Rich tables are used to render human-friendly summaries of responses and decoded payloads.
|
|
124
125
|
|
|
@@ -174,6 +175,23 @@ This will print a summary table with the length, data bytes (hex), and IRQ flags
|
|
|
174
175
|
- `--port` is your serial device
|
|
175
176
|
|
|
176
177
|
|
|
178
|
+
### TiMo Firmware Update
|
|
179
|
+
|
|
180
|
+
Use `shuttle timo update-fw` to push official `.cci` images (for example `timotwo-fx-b50f26ad.cci`; the companion `.hex` is provided for reference only) through the Shuttle bridge without touching an external programmer:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
shuttle timo update-fw timotwo-fx-b50f26ad.cci --port /dev/ttyUSB0
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- The command first checks `spi_caps.max_transfer_bytes` and the current SPI clock. Firmware updates require at least 255 bytes per `spi.xfer` call and a clock ≤ 2 MHz. Run `shuttle spi-cfg --hz 2000000` (or lower) if the persisted setting is faster.
|
|
187
|
+
- Shuttle enables SPI, sets TiMo into UPDATE_MODE by writing `0x40` to CONFIG, waits for the IRQ reboot window (0.6 s), and verifies bit 7 of STATUS before streaming data.
|
|
188
|
+
- `.cci` files contain a 4-byte header followed by 272-byte chunks. Because the TiMo FW loader accepts at most 255 contiguous bytes, each chunk is split into one `FW_BLOCK_CMD_1` transfer (0x8E + 254 bytes) and one `FW_BLOCK_CMD_2` transfer (0x8F + 18 bytes).
|
|
189
|
+
- The first chunk after the header carries metadata. After every 16 data chunks the device writes flash internally, so the CLI pauses for `--flush-wait-ms` (defaults to 500 ms) before continuing. When the whole image has been sent it waits `--final-wait-ms` (defaults to 1000 ms) to let TiMo finalize the update.
|
|
190
|
+
- Once STATUS clears UPDATE_MODE the command reads the VERSION register, prints FW/HW revisions, and confirms completion. If any step fails (IRQ bit 7, transport error, malformed `.cci`) the CLI aborts with a helpful message.
|
|
191
|
+
|
|
192
|
+
Tip: combine `--flush-wait-ms 0` and `--final-wait-ms 0` with a lab DUT when replaying the same firmware repeatedly, but keep the defaults when programming production hardware to honour the vendor timing guidelines.
|
|
193
|
+
|
|
194
|
+
|
|
177
195
|
### Using the Library from Python
|
|
178
196
|
|
|
179
197
|
Use the transport helpers for HIL tests with explicit request→response pairing:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
shuttle/cli.py,sha256=EM2vWcHFEL940SPFPkoaMHqf0p1ooX0XCJ4ydL1skoA,110024
|
|
2
|
+
shuttle/constants.py,sha256=222mAQEcvvjlVPpjyn_DUHrQsCBhQIzqe9-Eh6CTv1I,756
|
|
3
|
+
shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
|
|
4
|
+
shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
|
|
5
|
+
shuttle/serial_client.py,sha256=9vho6LecKw3iI0i_wGZlJjGUJDPXKLOuozPhuHceFJ8,21146
|
|
6
|
+
shuttle/timo.py,sha256=x5Qa_VeDEiMTbR_bcD7xw-H2kBJLpnsQhIrA4HkuoNU,18337
|
|
7
|
+
shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
|
|
8
|
+
shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
|
|
9
|
+
shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
|
|
10
|
+
shuttle/firmware/esp32c5/devboard.ino.bin,sha256=cTSGbtBQAREVSCWLOnhskBvYzTFZaKvOnJtdSxkgdUI,1101264
|
|
11
|
+
shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
|
|
12
|
+
shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
|
|
13
|
+
shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
|
|
14
|
+
lr_shuttle-0.2.12.dist-info/METADATA,sha256=_-wyxa-nUAoXYyB-KzvAQOz17qfvGd6ccl9o3CpYmdQ,15575
|
|
15
|
+
lr_shuttle-0.2.12.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
16
|
+
lr_shuttle-0.2.12.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
|
|
17
|
+
lr_shuttle-0.2.12.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
|
|
18
|
+
lr_shuttle-0.2.12.dist-info/RECORD,,
|
shuttle/cli.py
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import io
|
|
5
6
|
import ipaddress
|
|
6
7
|
import re
|
|
7
8
|
import string
|
|
8
9
|
import sys
|
|
9
10
|
import time
|
|
10
|
-
from contextlib import contextmanager
|
|
11
|
+
from contextlib import contextmanager, nullcontext
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
13
14
|
|
|
@@ -123,6 +124,66 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
|
|
|
123
124
|
return ctx.obj or {}
|
|
124
125
|
|
|
125
126
|
|
|
127
|
+
@contextmanager
|
|
128
|
+
def _sys_error_reporter(client) -> None:
|
|
129
|
+
setter = getattr(client, "set_event_callback", None)
|
|
130
|
+
if setter is None:
|
|
131
|
+
yield
|
|
132
|
+
return
|
|
133
|
+
setter(_handle_sys_error_event)
|
|
134
|
+
try:
|
|
135
|
+
yield
|
|
136
|
+
finally:
|
|
137
|
+
setter(None)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _handle_sys_error_event(event: Dict[str, Any]) -> None:
|
|
141
|
+
if event.get("ev") != "sys.error":
|
|
142
|
+
return
|
|
143
|
+
code = event.get("code", "?")
|
|
144
|
+
msg = event.get("msg", "")
|
|
145
|
+
seq = event.get("seq")
|
|
146
|
+
parts = [f"Device sys.error ({code})"]
|
|
147
|
+
if seq is not None:
|
|
148
|
+
parts.append(f"seq={seq}")
|
|
149
|
+
if msg:
|
|
150
|
+
parts.append(f"- {msg}")
|
|
151
|
+
console.print(f"[red]{' '.join(parts)}[/]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _flush_client_input(client) -> None:
|
|
155
|
+
"""Best-effort drain any unread serial noise before issuing commands."""
|
|
156
|
+
|
|
157
|
+
flush = getattr(client, "flush_input_and_log", None)
|
|
158
|
+
if flush is None:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
flush()
|
|
162
|
+
except Exception:
|
|
163
|
+
# Flushing is opportunistic; failures should not leak into CLI flows.
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@contextmanager
|
|
168
|
+
def _open_serial_client(
|
|
169
|
+
resolved_port: str,
|
|
170
|
+
*,
|
|
171
|
+
baudrate: int,
|
|
172
|
+
timeout: float,
|
|
173
|
+
logger: Optional[SerialLogger],
|
|
174
|
+
seq_tracker: Optional[SequenceTracker],
|
|
175
|
+
):
|
|
176
|
+
with NDJSONSerialClient(
|
|
177
|
+
resolved_port,
|
|
178
|
+
baudrate=baudrate,
|
|
179
|
+
timeout=timeout,
|
|
180
|
+
logger=logger,
|
|
181
|
+
seq_tracker=seq_tracker,
|
|
182
|
+
) as client:
|
|
183
|
+
with _sys_error_reporter(client):
|
|
184
|
+
yield client
|
|
185
|
+
|
|
186
|
+
|
|
126
187
|
@contextmanager
|
|
127
188
|
def spinner(message: str, enabled: bool = True):
|
|
128
189
|
"""Show a Rich spinner while the body executes."""
|
|
@@ -437,6 +498,267 @@ def _render_read_dmx_result(result, rx_frames):
|
|
|
437
498
|
)
|
|
438
499
|
|
|
439
500
|
|
|
501
|
+
class FirmwareUpdateError(RuntimeError):
|
|
502
|
+
"""Raised when TiMo firmware update prerequisites or transfers fail."""
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
FW_UPDATE_SPI_LIMIT_HZ = 2_000_000
|
|
506
|
+
FW_UPDATE_BOOT_DELAY_S = 1.75
|
|
507
|
+
FW_UPDATE_IRQ_RETRIES = 5
|
|
508
|
+
FW_UPDATE_IRQ_RETRY_DELAY_S = 0.25
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _run_timo_sequence_with_client(
|
|
512
|
+
client,
|
|
513
|
+
sequence: Sequence[Dict[str, Any]],
|
|
514
|
+
*,
|
|
515
|
+
label: str,
|
|
516
|
+
) -> List[Dict[str, Any]]:
|
|
517
|
+
responses: List[Dict[str, Any]] = []
|
|
518
|
+
for idx, transfer in enumerate(sequence):
|
|
519
|
+
response = client.spi_xfer(**transfer)
|
|
520
|
+
responses.append(response)
|
|
521
|
+
if not response.get("ok"):
|
|
522
|
+
phase = "command" if idx == 0 else "payload"
|
|
523
|
+
err = response.get("err", {})
|
|
524
|
+
details = f"code={err.get('code')} msg={err.get('msg')}" if err else ""
|
|
525
|
+
raise FirmwareUpdateError(
|
|
526
|
+
f"{label} failed during {phase} phase {details}".strip()
|
|
527
|
+
)
|
|
528
|
+
return responses
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _write_reg_checked(client, address: int, data: bytes) -> timo.WriteRegisterResult:
|
|
532
|
+
label = f"write-reg 0x{address:02X}"
|
|
533
|
+
for attempt in range(1, FW_UPDATE_IRQ_RETRIES + 1):
|
|
534
|
+
responses = _run_timo_sequence_with_client(
|
|
535
|
+
client,
|
|
536
|
+
timo.write_reg_sequence(address, data),
|
|
537
|
+
label=label,
|
|
538
|
+
)
|
|
539
|
+
rx_frames = [resp.get("rx", "") for resp in responses]
|
|
540
|
+
try:
|
|
541
|
+
parsed = timo.parse_write_reg_response(address, data, rx_frames)
|
|
542
|
+
except ValueError as exc: # pragma: no cover - defensive
|
|
543
|
+
raise FirmwareUpdateError(
|
|
544
|
+
f"Unable to parse {label} response: {exc}"
|
|
545
|
+
) from exc
|
|
546
|
+
needs_retry = timo.requires_restart(
|
|
547
|
+
parsed.irq_flags_command
|
|
548
|
+
) or timo.requires_restart(parsed.irq_flags_payload)
|
|
549
|
+
if not needs_retry:
|
|
550
|
+
return parsed
|
|
551
|
+
if attempt < FW_UPDATE_IRQ_RETRIES:
|
|
552
|
+
console.print(
|
|
553
|
+
f"[yellow]{label} attempt {attempt}/{FW_UPDATE_IRQ_RETRIES} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
|
|
554
|
+
)
|
|
555
|
+
time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
|
|
556
|
+
raise FirmwareUpdateError(
|
|
557
|
+
f"{label} kept reporting IRQ bit7 after {FW_UPDATE_IRQ_RETRIES} attempts"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _read_reg_checked(
|
|
562
|
+
client,
|
|
563
|
+
address: int,
|
|
564
|
+
length: int,
|
|
565
|
+
*,
|
|
566
|
+
label: str,
|
|
567
|
+
wait_irq: timo.WaitIrqOption = None,
|
|
568
|
+
retries: int = FW_UPDATE_IRQ_RETRIES,
|
|
569
|
+
) -> timo.ReadRegisterResult:
|
|
570
|
+
max_attempts = max(1, retries)
|
|
571
|
+
for attempt in range(1, max_attempts + 1):
|
|
572
|
+
responses = _run_timo_sequence_with_client(
|
|
573
|
+
client,
|
|
574
|
+
timo.read_reg_sequence(address, length, wait_irq=wait_irq),
|
|
575
|
+
label=label,
|
|
576
|
+
)
|
|
577
|
+
rx_frames = [resp.get("rx", "") for resp in responses]
|
|
578
|
+
try:
|
|
579
|
+
parsed = timo.parse_read_reg_response(address, length, rx_frames)
|
|
580
|
+
except ValueError as exc:
|
|
581
|
+
raise FirmwareUpdateError(
|
|
582
|
+
f"Unable to parse {label} response: {exc}"
|
|
583
|
+
) from exc
|
|
584
|
+
needs_retry = timo.requires_restart(
|
|
585
|
+
parsed.irq_flags_command
|
|
586
|
+
) or timo.requires_restart(parsed.irq_flags_payload)
|
|
587
|
+
if not needs_retry:
|
|
588
|
+
return parsed
|
|
589
|
+
if attempt < max_attempts and max_attempts > 1:
|
|
590
|
+
console.print(
|
|
591
|
+
f"[yellow]{label} attempt {attempt}/{max_attempts} reported IRQ bit7; retrying after {FW_UPDATE_IRQ_RETRY_DELAY_S:.3f}s...[/]"
|
|
592
|
+
)
|
|
593
|
+
time.sleep(FW_UPDATE_IRQ_RETRY_DELAY_S)
|
|
594
|
+
raise FirmwareUpdateError(
|
|
595
|
+
f"{label} kept reporting IRQ bit7 after {max_attempts} attempts"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _ensure_spi_ready_for_update(client, *, max_frame_bytes: int) -> Dict[str, Any]:
|
|
600
|
+
info = client.get_info()
|
|
601
|
+
spi_caps = info.get("spi_caps") or {}
|
|
602
|
+
max_transfer = spi_caps.get("max_transfer_bytes")
|
|
603
|
+
if not isinstance(max_transfer, int) or max_transfer < max_frame_bytes:
|
|
604
|
+
raise FirmwareUpdateError(
|
|
605
|
+
"Device SPI transport cannot send the required firmware block size "
|
|
606
|
+
f"(needs {max_frame_bytes} bytes, reports {max_transfer}). Update the devboard firmware."
|
|
607
|
+
)
|
|
608
|
+
cfg_resp = client.spi_cfg()
|
|
609
|
+
spi_cfg = cfg_resp.get("spi") or {}
|
|
610
|
+
hz = spi_cfg.get("hz") or spi_caps.get("default_hz")
|
|
611
|
+
if isinstance(hz, str):
|
|
612
|
+
try:
|
|
613
|
+
hz = int(hz, 0)
|
|
614
|
+
except ValueError:
|
|
615
|
+
hz = None
|
|
616
|
+
if isinstance(hz, int) and hz > FW_UPDATE_SPI_LIMIT_HZ:
|
|
617
|
+
raise FirmwareUpdateError(
|
|
618
|
+
f"Configured SPI clock {hz} Hz exceeds update limit {FW_UPDATE_SPI_LIMIT_HZ} Hz. "
|
|
619
|
+
"Run 'shuttle spi-cfg --hz 2000000' before retrying."
|
|
620
|
+
)
|
|
621
|
+
enable_resp = client.spi_enable()
|
|
622
|
+
if not enable_resp.get("ok"):
|
|
623
|
+
err = enable_resp.get("err", {})
|
|
624
|
+
msg = err.get("msg") if isinstance(err, dict) else "unable to enable SPI"
|
|
625
|
+
raise FirmwareUpdateError(f"spi.enable failed: {msg}")
|
|
626
|
+
return spi_caps
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _send_fw_block(
|
|
630
|
+
client,
|
|
631
|
+
opcode: int,
|
|
632
|
+
payload: bytes,
|
|
633
|
+
*,
|
|
634
|
+
max_transfer_bytes: int,
|
|
635
|
+
):
|
|
636
|
+
frame = bytes([opcode]) + payload
|
|
637
|
+
if len(frame) > max_transfer_bytes:
|
|
638
|
+
raise FirmwareUpdateError(
|
|
639
|
+
f"FW block (opcode 0x{opcode:02X}) exceeds spi_caps.max_transfer_bytes"
|
|
640
|
+
)
|
|
641
|
+
response = client.spi_xfer(tx=frame.hex(), n=len(frame))
|
|
642
|
+
if not response.get("ok"):
|
|
643
|
+
err = response.get("err", {})
|
|
644
|
+
msg = err.get("msg") if isinstance(err, dict) else "unknown"
|
|
645
|
+
raise FirmwareUpdateError(f"FW block opcode 0x{opcode:02X} failed: {msg}")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _read_status_byte(
|
|
649
|
+
client,
|
|
650
|
+
*,
|
|
651
|
+
wait_irq: timo.WaitIrqOption = None,
|
|
652
|
+
retries: int = FW_UPDATE_IRQ_RETRIES,
|
|
653
|
+
) -> int:
|
|
654
|
+
reg_meta = timo.REGISTER_MAP["STATUS"]
|
|
655
|
+
result = _read_reg_checked(
|
|
656
|
+
client,
|
|
657
|
+
reg_meta["address"],
|
|
658
|
+
reg_meta.get("length", 1),
|
|
659
|
+
label="STATUS register",
|
|
660
|
+
wait_irq=wait_irq,
|
|
661
|
+
retries=retries,
|
|
662
|
+
)
|
|
663
|
+
return result.data[0] if result.data else 0
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _read_version_bytes(client) -> bytes:
|
|
667
|
+
reg_meta = timo.REGISTER_MAP["VERSION"]
|
|
668
|
+
result = _read_reg_checked(
|
|
669
|
+
client,
|
|
670
|
+
reg_meta["address"],
|
|
671
|
+
reg_meta.get("length", 8),
|
|
672
|
+
label="VERSION register",
|
|
673
|
+
)
|
|
674
|
+
return result.data
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _enter_update_mode(client) -> None:
|
|
678
|
+
config_addr = timo.REGISTER_MAP["CONFIG"]["address"]
|
|
679
|
+
console.print("[cyan]Requesting TiMo UPDATE_MODE[/]")
|
|
680
|
+
_write_reg_checked(client, config_addr, bytes([0x40]))
|
|
681
|
+
console.print(
|
|
682
|
+
f"Waiting {FW_UPDATE_BOOT_DELAY_S:.3f}s before reading STATUS for UPDATE_MODE"
|
|
683
|
+
)
|
|
684
|
+
time.sleep(FW_UPDATE_BOOT_DELAY_S)
|
|
685
|
+
status_byte = _read_status_byte(client, wait_irq=False, retries=3)
|
|
686
|
+
if status_byte & 0x80:
|
|
687
|
+
console.print("[green]TiMo entered UPDATE_MODE[/]")
|
|
688
|
+
return
|
|
689
|
+
raise FirmwareUpdateError("TiMo did not enter update mode (STATUS bit7 missing)")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _format_fw_progress(block_index: int, total_blocks: int, total_bytes: int) -> str:
|
|
693
|
+
return f"Transferred {block_index}/{total_blocks} blocks ({total_bytes} bytes)"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _stream_fw_image(
|
|
697
|
+
client,
|
|
698
|
+
*,
|
|
699
|
+
firmware_path: Path,
|
|
700
|
+
max_transfer_bytes: int,
|
|
701
|
+
flush_wait_s: float,
|
|
702
|
+
) -> Tuple[int, int, bytes]:
|
|
703
|
+
bytes_per_block = timo.FW_BLOCK_CMD_1_SIZE + timo.FW_BLOCK_CMD_2_SIZE
|
|
704
|
+
try:
|
|
705
|
+
total_size = firmware_path.stat().st_size
|
|
706
|
+
payload_bytes_on_disk = total_size - timo.CCI_HEADER_SIZE
|
|
707
|
+
if payload_bytes_on_disk <= 0:
|
|
708
|
+
raise FirmwareUpdateError("CCI firmware contains no payload blocks")
|
|
709
|
+
if payload_bytes_on_disk % bytes_per_block != 0:
|
|
710
|
+
raise FirmwareUpdateError(
|
|
711
|
+
"CCI firmware size is not aligned to FW block payloads"
|
|
712
|
+
)
|
|
713
|
+
expected_blocks = payload_bytes_on_disk // bytes_per_block
|
|
714
|
+
with firmware_path.open("rb") as raw_file:
|
|
715
|
+
reader = io.BufferedReader(raw_file)
|
|
716
|
+
header = timo.read_cci_header(reader)
|
|
717
|
+
console.print(f"CCI header ({timo.CCI_HEADER_SIZE} bytes): {header.hex()}")
|
|
718
|
+
status_ctx = (
|
|
719
|
+
console.status(
|
|
720
|
+
_format_fw_progress(0, expected_blocks, 0), spinner="dots"
|
|
721
|
+
)
|
|
722
|
+
if sys.stdout.isatty()
|
|
723
|
+
else nullcontext(None)
|
|
724
|
+
)
|
|
725
|
+
with status_ctx as transfer_status:
|
|
726
|
+
total_blocks = 0
|
|
727
|
+
total_bytes = 0
|
|
728
|
+
for block_index, chunk_1, chunk_2 in timo.iter_cci_chunks(reader):
|
|
729
|
+
total_blocks += 1
|
|
730
|
+
_send_fw_block(
|
|
731
|
+
client,
|
|
732
|
+
timo.FW_BLOCK_CMD_1,
|
|
733
|
+
chunk_1,
|
|
734
|
+
max_transfer_bytes=max_transfer_bytes,
|
|
735
|
+
)
|
|
736
|
+
_send_fw_block(
|
|
737
|
+
client,
|
|
738
|
+
timo.FW_BLOCK_CMD_2,
|
|
739
|
+
chunk_2,
|
|
740
|
+
max_transfer_bytes=max_transfer_bytes,
|
|
741
|
+
)
|
|
742
|
+
total_bytes += len(chunk_1) + len(chunk_2)
|
|
743
|
+
message = _format_fw_progress(
|
|
744
|
+
block_index, expected_blocks, total_bytes
|
|
745
|
+
)
|
|
746
|
+
if transfer_status is not None:
|
|
747
|
+
transfer_status.update(message)
|
|
748
|
+
elif block_index == 1 or block_index % 16 == 0:
|
|
749
|
+
console.print(message)
|
|
750
|
+
data_blocks_sent = block_index - 1
|
|
751
|
+
if data_blocks_sent > 0 and data_blocks_sent % 16 == 0:
|
|
752
|
+
time.sleep(flush_wait_s)
|
|
753
|
+
if total_blocks == 0:
|
|
754
|
+
raise FirmwareUpdateError("CCI firmware contains no payload blocks")
|
|
755
|
+
return total_blocks, total_bytes, header
|
|
756
|
+
except OSError as exc:
|
|
757
|
+
raise FirmwareUpdateError(f"Unable to read firmware: {exc}") from exc
|
|
758
|
+
except ValueError as exc:
|
|
759
|
+
raise FirmwareUpdateError(str(exc)) from exc
|
|
760
|
+
|
|
761
|
+
|
|
440
762
|
def _execute_timo_sequence(
|
|
441
763
|
*,
|
|
442
764
|
port: Optional[str],
|
|
@@ -451,13 +773,16 @@ def _execute_timo_sequence(
|
|
|
451
773
|
responses: List[Dict[str, Any]] = []
|
|
452
774
|
with spinner(f"{spinner_label} over {resolved_port}"):
|
|
453
775
|
try:
|
|
454
|
-
with
|
|
776
|
+
with _open_serial_client(
|
|
455
777
|
resolved_port,
|
|
456
778
|
baudrate=baudrate,
|
|
457
779
|
timeout=timeout,
|
|
458
780
|
logger=logger,
|
|
459
781
|
seq_tracker=seq_tracker,
|
|
460
782
|
) as client:
|
|
783
|
+
# Drain any pending serial noise before issuing commands, to avoid
|
|
784
|
+
# mixing stale data into NDJSON responses.
|
|
785
|
+
_flush_client_input(client)
|
|
461
786
|
for transfer in sequence:
|
|
462
787
|
response = client.spi_xfer(**transfer)
|
|
463
788
|
responses.append(response)
|
|
@@ -1048,7 +1373,7 @@ def timo_dmx(
|
|
|
1048
1373
|
mask = ((1 << width) - 1) << (total_bits - hi - 1)
|
|
1049
1374
|
return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
|
|
1050
1375
|
|
|
1051
|
-
with
|
|
1376
|
+
with _open_serial_client(
|
|
1052
1377
|
resolved_port,
|
|
1053
1378
|
baudrate=baudrate,
|
|
1054
1379
|
timeout=timeout,
|
|
@@ -1299,14 +1624,14 @@ def timo_device_name(
|
|
|
1299
1624
|
resolved_port = _require_port(port)
|
|
1300
1625
|
|
|
1301
1626
|
def _read_name(client) -> str:
|
|
1302
|
-
seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length",
|
|
1627
|
+
seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 32))
|
|
1303
1628
|
responses = [client.spi_xfer(**cmd) for cmd in seq]
|
|
1304
1629
|
rx = responses[-1].get("rx", "")
|
|
1305
1630
|
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
1306
1631
|
data = payload[1:] if payload else b""
|
|
1307
1632
|
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
|
1308
1633
|
|
|
1309
|
-
with
|
|
1634
|
+
with _open_serial_client(
|
|
1310
1635
|
resolved_port,
|
|
1311
1636
|
baudrate=baudrate,
|
|
1312
1637
|
timeout=timeout,
|
|
@@ -1488,6 +1813,113 @@ def timo_read_dmx(
|
|
|
1488
1813
|
_render_read_dmx_result(parsed, rx_frames)
|
|
1489
1814
|
|
|
1490
1815
|
|
|
1816
|
+
@timo_app.command("update-fw")
|
|
1817
|
+
def timo_update_fw(
|
|
1818
|
+
ctx: typer.Context,
|
|
1819
|
+
firmware: Path = typer.Argument(
|
|
1820
|
+
...,
|
|
1821
|
+
exists=True,
|
|
1822
|
+
file_okay=True,
|
|
1823
|
+
dir_okay=False,
|
|
1824
|
+
resolve_path=True,
|
|
1825
|
+
help="Path to TiMo .cci firmware image",
|
|
1826
|
+
),
|
|
1827
|
+
port: Optional[str] = typer.Option(
|
|
1828
|
+
None,
|
|
1829
|
+
"--port",
|
|
1830
|
+
envvar="SHUTTLE_PORT",
|
|
1831
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1832
|
+
),
|
|
1833
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1834
|
+
timeout: float = typer.Option(
|
|
1835
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1836
|
+
),
|
|
1837
|
+
flush_wait_ms: float = typer.Option(
|
|
1838
|
+
100.0,
|
|
1839
|
+
"--flush-wait-ms",
|
|
1840
|
+
min=0.0,
|
|
1841
|
+
help="Delay (ms) after each 16 data blocks (post-header)",
|
|
1842
|
+
),
|
|
1843
|
+
final_wait_ms: float = typer.Option(
|
|
1844
|
+
1000.0,
|
|
1845
|
+
"--final-wait-ms",
|
|
1846
|
+
min=0.0,
|
|
1847
|
+
help="Delay (ms) after streaming all blocks to let TiMo finalize",
|
|
1848
|
+
),
|
|
1849
|
+
):
|
|
1850
|
+
"""Flash TiMo firmware by streaming a .cci file over FW_BLOCK commands."""
|
|
1851
|
+
|
|
1852
|
+
resources = _ctx_resources(ctx)
|
|
1853
|
+
resolved_port = _require_port(port)
|
|
1854
|
+
flush_wait_s = flush_wait_ms / 1000.0
|
|
1855
|
+
final_wait_s = final_wait_ms / 1000.0
|
|
1856
|
+
console.print(f"[cyan]Starting TiMo update via {firmware}[/]")
|
|
1857
|
+
|
|
1858
|
+
try:
|
|
1859
|
+
with _open_serial_client(
|
|
1860
|
+
resolved_port,
|
|
1861
|
+
baudrate=baudrate,
|
|
1862
|
+
timeout=timeout,
|
|
1863
|
+
logger=resources.get("logger"),
|
|
1864
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1865
|
+
) as client:
|
|
1866
|
+
spi_caps = _ensure_spi_ready_for_update(
|
|
1867
|
+
client, max_frame_bytes=1 + timo.FW_BLOCK_CMD_1_SIZE
|
|
1868
|
+
)
|
|
1869
|
+
_enter_update_mode(client)
|
|
1870
|
+
max_transfer_bytes = int(spi_caps["max_transfer_bytes"])
|
|
1871
|
+
blocks_sent, payload_bytes, header = _stream_fw_image(
|
|
1872
|
+
client,
|
|
1873
|
+
firmware_path=firmware,
|
|
1874
|
+
max_transfer_bytes=max_transfer_bytes,
|
|
1875
|
+
flush_wait_s=flush_wait_s,
|
|
1876
|
+
)
|
|
1877
|
+
console.print(
|
|
1878
|
+
f"Waiting {final_wait_s:.3f}s for TiMo to finalize the update"
|
|
1879
|
+
)
|
|
1880
|
+
time.sleep(final_wait_s)
|
|
1881
|
+
status_after = _read_status_byte(client)
|
|
1882
|
+
if status_after & 0x80:
|
|
1883
|
+
raise FirmwareUpdateError(
|
|
1884
|
+
"TiMo still reports UPDATE_MODE after sending all blocks"
|
|
1885
|
+
)
|
|
1886
|
+
version_bytes = _read_version_bytes(client)
|
|
1887
|
+
except FirmwareUpdateError as exc:
|
|
1888
|
+
console.print(f"[red]{exc}[/]")
|
|
1889
|
+
raise typer.Exit(1) from exc
|
|
1890
|
+
except ShuttleSerialError as exc:
|
|
1891
|
+
console.print(f"[red]{exc}[/]")
|
|
1892
|
+
raise typer.Exit(1) from exc
|
|
1893
|
+
|
|
1894
|
+
summary = Table(title="TiMo firmware update", show_header=False, box=None)
|
|
1895
|
+
summary.add_column("Field", style="cyan", no_wrap=True)
|
|
1896
|
+
summary.add_column("Value", style="white")
|
|
1897
|
+
summary.add_row("Blocks transferred", str(blocks_sent))
|
|
1898
|
+
summary.add_row("Data blocks", str(blocks_sent - 1))
|
|
1899
|
+
summary.add_row("Bytes transferred", str(payload_bytes))
|
|
1900
|
+
summary.add_row("CCI header", header.hex())
|
|
1901
|
+
console.print(summary)
|
|
1902
|
+
|
|
1903
|
+
if len(version_bytes) < 8:
|
|
1904
|
+
console.print(
|
|
1905
|
+
"[yellow]VERSION register shorter than expected; unable to decode versions[/]"
|
|
1906
|
+
)
|
|
1907
|
+
else:
|
|
1908
|
+
version_fields = timo.REGISTER_MAP["VERSION"]["fields"]
|
|
1909
|
+
fw_field = version_fields["FW_VERSION"]["bits"]
|
|
1910
|
+
hw_field = version_fields["HW_VERSION"]["bits"]
|
|
1911
|
+
fw_version = timo.slice_bits(version_bytes, *fw_field)
|
|
1912
|
+
hw_version = timo.slice_bits(version_bytes, *hw_field)
|
|
1913
|
+
version_table = Table(title="TiMo VERSION", show_header=False, box=None)
|
|
1914
|
+
version_table.add_column("Field", style="cyan", no_wrap=True)
|
|
1915
|
+
version_table.add_column("Value", style="white")
|
|
1916
|
+
version_table.add_row("FW_VERSION", f"0x{fw_version:08X}")
|
|
1917
|
+
version_table.add_row("HW_VERSION", f"0x{hw_version:08X}")
|
|
1918
|
+
console.print(version_table)
|
|
1919
|
+
|
|
1920
|
+
console.print("[green]TiMo firmware update complete[/]")
|
|
1921
|
+
|
|
1922
|
+
|
|
1491
1923
|
@prodtest_app.command("reset")
|
|
1492
1924
|
def prodtest_reset(
|
|
1493
1925
|
ctx: typer.Context,
|
|
@@ -2176,7 +2608,7 @@ def spi_cfg_command(
|
|
|
2176
2608
|
action = "Updating" if spi_payload else "Querying"
|
|
2177
2609
|
with spinner(f"{action} spi.cfg over {resolved_port}"):
|
|
2178
2610
|
try:
|
|
2179
|
-
with
|
|
2611
|
+
with _open_serial_client(
|
|
2180
2612
|
resolved_port,
|
|
2181
2613
|
baudrate=baudrate,
|
|
2182
2614
|
timeout=timeout,
|
|
@@ -2211,13 +2643,14 @@ def spi_enable_command(
|
|
|
2211
2643
|
resolved_port = _require_port(port)
|
|
2212
2644
|
with spinner(f"Enabling SPI over {resolved_port}"):
|
|
2213
2645
|
try:
|
|
2214
|
-
with
|
|
2215
|
-
|
|
2646
|
+
with _open_serial_client(
|
|
2647
|
+
resolved_port,
|
|
2216
2648
|
baudrate=baudrate,
|
|
2217
2649
|
timeout=timeout,
|
|
2218
2650
|
logger=resources.get("logger"),
|
|
2219
2651
|
seq_tracker=resources.get("seq_tracker"),
|
|
2220
2652
|
) as client:
|
|
2653
|
+
_flush_client_input(client)
|
|
2221
2654
|
response = client.spi_enable()
|
|
2222
2655
|
except ShuttleSerialError as exc:
|
|
2223
2656
|
console.print(f"[red]{exc}[/]")
|
|
@@ -2245,13 +2678,14 @@ def spi_disable_command(
|
|
|
2245
2678
|
resolved_port = _require_port(port)
|
|
2246
2679
|
with spinner(f"Disabling SPI over {resolved_port}"):
|
|
2247
2680
|
try:
|
|
2248
|
-
with
|
|
2249
|
-
|
|
2681
|
+
with _open_serial_client(
|
|
2682
|
+
resolved_port,
|
|
2250
2683
|
baudrate=baudrate,
|
|
2251
2684
|
timeout=timeout,
|
|
2252
2685
|
logger=resources.get("logger"),
|
|
2253
2686
|
seq_tracker=resources.get("seq_tracker"),
|
|
2254
2687
|
) as client:
|
|
2688
|
+
_flush_client_input(client)
|
|
2255
2689
|
response = client.spi_disable()
|
|
2256
2690
|
except ShuttleSerialError as exc:
|
|
2257
2691
|
console.print(f"[red]{exc}[/]")
|
|
@@ -2310,7 +2744,7 @@ def uart_cfg_command(
|
|
|
2310
2744
|
action = "Updating" if uart_payload else "Querying"
|
|
2311
2745
|
with spinner(f"{action} uart.cfg over {resolved_port}"):
|
|
2312
2746
|
try:
|
|
2313
|
-
with
|
|
2747
|
+
with _open_serial_client(
|
|
2314
2748
|
resolved_port,
|
|
2315
2749
|
baudrate=baudrate,
|
|
2316
2750
|
timeout=timeout,
|
|
@@ -2373,7 +2807,7 @@ def uart_sub_command(
|
|
|
2373
2807
|
action = "Updating" if sub_payload else "Querying"
|
|
2374
2808
|
with spinner(f"{action} uart.sub over {resolved_port}"):
|
|
2375
2809
|
try:
|
|
2376
|
-
with
|
|
2810
|
+
with _open_serial_client(
|
|
2377
2811
|
resolved_port,
|
|
2378
2812
|
baudrate=baudrate,
|
|
2379
2813
|
timeout=timeout,
|
|
@@ -2492,7 +2926,7 @@ def wifi_cfg_command(
|
|
|
2492
2926
|
action = "Updating" if wifi_payload else "Querying"
|
|
2493
2927
|
with spinner(f"{action} wifi.cfg over {resolved_port}"):
|
|
2494
2928
|
try:
|
|
2495
|
-
with
|
|
2929
|
+
with _open_serial_client(
|
|
2496
2930
|
resolved_port,
|
|
2497
2931
|
baudrate=baudrate,
|
|
2498
2932
|
timeout=timeout,
|
|
@@ -2575,7 +3009,7 @@ def uart_tx_command(
|
|
|
2575
3009
|
byte_label = "byte" if payload_len == 1 else "bytes"
|
|
2576
3010
|
with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
|
|
2577
3011
|
try:
|
|
2578
|
-
with
|
|
3012
|
+
with _open_serial_client(
|
|
2579
3013
|
resolved_port,
|
|
2580
3014
|
baudrate=baudrate,
|
|
2581
3015
|
timeout=timeout,
|
|
@@ -2648,7 +3082,7 @@ def uart_rx_command(
|
|
|
2648
3082
|
|
|
2649
3083
|
events_seen = 0
|
|
2650
3084
|
try:
|
|
2651
|
-
with
|
|
3085
|
+
with _open_serial_client(
|
|
2652
3086
|
resolved_port,
|
|
2653
3087
|
baudrate=baudrate,
|
|
2654
3088
|
timeout=timeout,
|
|
@@ -2715,13 +3149,14 @@ def power_command(
|
|
|
2715
3149
|
|
|
2716
3150
|
with spinner(f"{action} power over {resolved_port}"):
|
|
2717
3151
|
try:
|
|
2718
|
-
with
|
|
3152
|
+
with _open_serial_client(
|
|
2719
3153
|
resolved_port,
|
|
2720
3154
|
baudrate=baudrate,
|
|
2721
3155
|
timeout=timeout,
|
|
2722
3156
|
logger=resources.get("logger"),
|
|
2723
3157
|
seq_tracker=resources.get("seq_tracker"),
|
|
2724
3158
|
) as client:
|
|
3159
|
+
_flush_client_input(client)
|
|
2725
3160
|
method = getattr(client, method_name)
|
|
2726
3161
|
response = method()
|
|
2727
3162
|
except ShuttleSerialError as exc:
|
|
@@ -2755,6 +3190,11 @@ def flash_command(
|
|
|
2755
3190
|
"--erase-first/--no-erase-first",
|
|
2756
3191
|
help="Erase the entire flash before writing",
|
|
2757
3192
|
),
|
|
3193
|
+
sleep_after_flash: float = typer.Option(
|
|
3194
|
+
1.25,
|
|
3195
|
+
"--sleep-after-flash",
|
|
3196
|
+
help="Seconds to wait after flashing to allow device reboot",
|
|
3197
|
+
),
|
|
2758
3198
|
):
|
|
2759
3199
|
"""Flash the bundled firmware image to the devboard."""
|
|
2760
3200
|
|
|
@@ -2778,6 +3218,24 @@ def flash_command(
|
|
|
2778
3218
|
console.print(f"[red]{exc}[/]")
|
|
2779
3219
|
raise typer.Exit(1) from exc
|
|
2780
3220
|
|
|
3221
|
+
if sleep_after_flash:
|
|
3222
|
+
time.sleep(
|
|
3223
|
+
sleep_after_flash
|
|
3224
|
+
) # Give the device a moment to reboot. 0.75s is sometimes too short.
|
|
3225
|
+
|
|
3226
|
+
# After flashing, drain/log any startup output from the device before further commands
|
|
3227
|
+
logger = ctx.obj["logger"] if ctx.obj and "logger" in ctx.obj else None
|
|
3228
|
+
try:
|
|
3229
|
+
from .serial_client import NDJSONSerialClient
|
|
3230
|
+
|
|
3231
|
+
# Use a short timeout just for draining
|
|
3232
|
+
with NDJSONSerialClient(
|
|
3233
|
+
resolved_port, baudrate=baudrate, timeout=0.5, logger=logger
|
|
3234
|
+
) as client:
|
|
3235
|
+
_flush_client_input(client)
|
|
3236
|
+
except Exception:
|
|
3237
|
+
pass
|
|
3238
|
+
|
|
2781
3239
|
label = str(manifest.get("label", board))
|
|
2782
3240
|
console.print(
|
|
2783
3241
|
f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
|
|
@@ -2801,7 +3259,7 @@ def get_info(
|
|
|
2801
3259
|
resolved_port = _require_port(port)
|
|
2802
3260
|
with spinner(f"Querying get.info over {resolved_port}"):
|
|
2803
3261
|
try:
|
|
2804
|
-
with
|
|
3262
|
+
with _open_serial_client(
|
|
2805
3263
|
resolved_port,
|
|
2806
3264
|
baudrate=baudrate,
|
|
2807
3265
|
timeout=timeout,
|
|
@@ -2832,7 +3290,7 @@ def ping(
|
|
|
2832
3290
|
resolved_port = _require_port(port)
|
|
2833
3291
|
with spinner(f"Pinging device over {resolved_port}"):
|
|
2834
3292
|
try:
|
|
2835
|
-
with
|
|
3293
|
+
with _open_serial_client(
|
|
2836
3294
|
resolved_port,
|
|
2837
3295
|
baudrate=baudrate,
|
|
2838
3296
|
timeout=timeout,
|
shuttle/constants.py
CHANGED
|
Binary file
|
shuttle/serial_client.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
import json
|
|
8
8
|
import secrets
|
|
9
9
|
import threading
|
|
10
|
+
import time
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Callable, Dict, List, Optional
|
|
@@ -17,6 +18,11 @@ import serial
|
|
|
17
18
|
from serial import SerialException
|
|
18
19
|
from .constants import DEFAULT_BAUD, DEFAULT_TIMEOUT
|
|
19
20
|
|
|
21
|
+
USB_CDC_PACKET_SIZE = 64
|
|
22
|
+
# Delay between USB CDC write chunks to avoid overwhelming the host USB stack with back-to-back packets.
|
|
23
|
+
# Tune for typical desktop OS USB stacks & current use cases; may need adjustment for other hosts.
|
|
24
|
+
USB_CDC_WRITE_DELAY_S = 0.000
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
class ShuttleSerialError(Exception):
|
|
22
28
|
"""Raised when serial transport encounters an unrecoverable error."""
|
|
@@ -246,7 +252,7 @@ class NDJSONSerialClient:
|
|
|
246
252
|
except AttributeError:
|
|
247
253
|
# Test stubs without an open() method are already "connected"
|
|
248
254
|
pass
|
|
249
|
-
self.
|
|
255
|
+
self._reset_input_buffer()
|
|
250
256
|
self._lock = threading.Lock()
|
|
251
257
|
self._pending: Dict[int, CommandFuture] = {}
|
|
252
258
|
self._response_backlog: Dict[int, Dict[str, Any]] = {}
|
|
@@ -256,6 +262,7 @@ class NDJSONSerialClient:
|
|
|
256
262
|
self._logger = logger
|
|
257
263
|
self._seq_tracker = seq_tracker
|
|
258
264
|
self._reader: Optional[threading.Thread] = None
|
|
265
|
+
self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
259
266
|
|
|
260
267
|
def __enter__(self) -> "NDJSONSerialClient":
|
|
261
268
|
return self
|
|
@@ -273,9 +280,41 @@ class NDJSONSerialClient:
|
|
|
273
280
|
if getattr(self, "_serial", None) and self._serial.is_open:
|
|
274
281
|
self._serial.close()
|
|
275
282
|
|
|
283
|
+
def _reset_input_buffer(self) -> None:
|
|
284
|
+
serial_obj = getattr(self, "_serial", None)
|
|
285
|
+
if serial_obj is None:
|
|
286
|
+
return
|
|
287
|
+
reset = getattr(serial_obj, "reset_input_buffer", None)
|
|
288
|
+
if reset is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
reset()
|
|
292
|
+
except SerialException:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def flush_input_and_log(self):
|
|
296
|
+
"""Read and log all available data from the serial buffer before sending a command."""
|
|
297
|
+
if not hasattr(self, "_serial"):
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
while True:
|
|
301
|
+
waiting = getattr(self._serial, "in_waiting", 0)
|
|
302
|
+
if not waiting:
|
|
303
|
+
break
|
|
304
|
+
data = self._serial.read(waiting)
|
|
305
|
+
if data:
|
|
306
|
+
self._log_serial("RX", data)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
finally:
|
|
310
|
+
self._reset_input_buffer()
|
|
311
|
+
|
|
276
312
|
def send_command(self, op: str, params: Dict[str, Any]) -> CommandFuture:
|
|
277
313
|
"""Send a command without blocking, returning a future for the response."""
|
|
278
314
|
|
|
315
|
+
# Flush and log any unread data before sending a command
|
|
316
|
+
self.flush_input_and_log()
|
|
317
|
+
|
|
279
318
|
cmd_id = self._next_cmd_id()
|
|
280
319
|
message: Dict[str, Any] = {"type": "cmd", "id": cmd_id, "op": op}
|
|
281
320
|
message.update(params)
|
|
@@ -319,6 +358,13 @@ class NDJSONSerialClient:
|
|
|
319
358
|
self._ensure_reader_started()
|
|
320
359
|
return listener
|
|
321
360
|
|
|
361
|
+
def set_event_callback(
|
|
362
|
+
self, callback: Optional[Callable[[Dict[str, Any]], None]]
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Register a callback for every device event, regardless of listeners."""
|
|
365
|
+
|
|
366
|
+
self._event_callback = callback
|
|
367
|
+
|
|
322
368
|
def spi_xfer(
|
|
323
369
|
self, *, tx: str, n: Optional[int] = None, **overrides: Any
|
|
324
370
|
) -> Dict[str, Any]:
|
|
@@ -426,33 +472,53 @@ class NDJSONSerialClient:
|
|
|
426
472
|
def _write(self, message: Dict[str, Any]) -> None:
|
|
427
473
|
serialized = json.dumps(message, separators=(",", ":"))
|
|
428
474
|
payload = serialized.encode("utf-8") + b"\n"
|
|
475
|
+
total_written = 0
|
|
429
476
|
with self._lock:
|
|
430
|
-
|
|
477
|
+
while total_written < len(payload):
|
|
478
|
+
# Throttling writes to avoid overwhelming the USB stack
|
|
479
|
+
chunk = payload[total_written : total_written + USB_CDC_PACKET_SIZE]
|
|
480
|
+
written = self._serial.write(chunk)
|
|
481
|
+
if written != len(chunk):
|
|
482
|
+
raise ShuttleSerialError(
|
|
483
|
+
f"Short write to serial port: wrote {written} of {len(chunk)} bytes"
|
|
484
|
+
)
|
|
485
|
+
total_written += written
|
|
486
|
+
if total_written < len(payload):
|
|
487
|
+
time.sleep(USB_CDC_WRITE_DELAY_S)
|
|
431
488
|
self._log_serial("TX", payload)
|
|
432
489
|
|
|
433
490
|
def _read(self) -> Optional[Dict[str, Any]]:
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
491
|
+
while True:
|
|
492
|
+
try:
|
|
493
|
+
line = self._serial.readline()
|
|
494
|
+
except SerialException as exc: # pragma: no cover - hardware specific
|
|
495
|
+
raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
|
|
496
|
+
if not line:
|
|
497
|
+
return None
|
|
498
|
+
self._log_serial("RX", line)
|
|
499
|
+
stripped = line.strip()
|
|
500
|
+
if not stripped:
|
|
501
|
+
return None
|
|
502
|
+
try:
|
|
503
|
+
decoded = stripped.decode("utf-8")
|
|
504
|
+
except UnicodeDecodeError as exc:
|
|
505
|
+
self._reset_input_buffer()
|
|
506
|
+
raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
|
|
507
|
+
trimmed = decoded.lstrip()
|
|
508
|
+
if not trimmed:
|
|
509
|
+
continue
|
|
510
|
+
if trimmed[0] not in ("{", "["):
|
|
511
|
+
self._reset_input_buffer()
|
|
512
|
+
continue
|
|
513
|
+
try:
|
|
514
|
+
message = json.loads(decoded)
|
|
515
|
+
except json.JSONDecodeError as exc:
|
|
516
|
+
self._reset_input_buffer()
|
|
517
|
+
raise ShuttleSerialError(
|
|
518
|
+
f"Invalid JSON from device: {decoded} ({exc})"
|
|
519
|
+
) from exc
|
|
520
|
+
self._record_sequence(message)
|
|
521
|
+
return message
|
|
456
522
|
|
|
457
523
|
def _dispatch(self, message: Dict[str, Any]) -> None:
|
|
458
524
|
mtype = message.get("type")
|
|
@@ -471,6 +537,7 @@ class NDJSONSerialClient:
|
|
|
471
537
|
ev_name = message.get("ev")
|
|
472
538
|
if not isinstance(ev_name, str):
|
|
473
539
|
raise ShuttleSerialError("Device event missing ev field")
|
|
540
|
+
self._emit_event_callback(message)
|
|
474
541
|
with self._lock:
|
|
475
542
|
listeners = list(self._event_listeners.get(ev_name, []))
|
|
476
543
|
for listener in listeners:
|
|
@@ -512,3 +579,13 @@ class NDJSONSerialClient:
|
|
|
512
579
|
future.mark_exception(exc)
|
|
513
580
|
for listener in listeners:
|
|
514
581
|
listener.fail(exc)
|
|
582
|
+
|
|
583
|
+
def _emit_event_callback(self, message: Dict[str, Any]) -> None:
|
|
584
|
+
callback = getattr(self, "_event_callback", None)
|
|
585
|
+
if callback is None:
|
|
586
|
+
return
|
|
587
|
+
try:
|
|
588
|
+
callback(message)
|
|
589
|
+
except Exception:
|
|
590
|
+
# Callback failures should not kill the serial reader loop
|
|
591
|
+
pass
|
shuttle/timo.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#! /usr/bin/env python
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""Helpers for TiMo SPI command sequences."""
|
|
4
|
+
|
|
4
5
|
from __future__ import annotations
|
|
5
|
-
from typing import Any, Dict, Sequence
|
|
6
|
+
from typing import Any, BinaryIO, Dict, Iterator, Sequence, Tuple, Union
|
|
6
7
|
|
|
7
8
|
NOP_OPCODE = 0xFF
|
|
8
9
|
READ_REG_BASE = 0b00000000
|
|
@@ -18,8 +19,21 @@ READ_RDM_CMD = 0x83 # 1000 0011: Read received RDM request
|
|
|
18
19
|
WRITE_DMX_CMD = 0x91 # 1001 0001: Write DMX generation buffer
|
|
19
20
|
WRITE_RDM_CMD = 0x92 # 1001 0010: Write an RDM response
|
|
20
21
|
|
|
22
|
+
FW_BLOCK_CMD_1 = 0x8E
|
|
23
|
+
FW_BLOCK_CMD_2 = 0x8F
|
|
24
|
+
FW_BLOCK_CMD_1_SIZE = 254
|
|
25
|
+
FW_BLOCK_CMD_2_SIZE = 18
|
|
26
|
+
CCI_CHUNK_SIZE = FW_BLOCK_CMD_1_SIZE + FW_BLOCK_CMD_2_SIZE
|
|
27
|
+
CCI_HEADER_SIZE = 4
|
|
28
|
+
|
|
21
29
|
IRQ_FLAG_RESTART = 0x80 # Bit 7 signals the slave could not process the transfer
|
|
22
|
-
IRQ_WAIT_TIMEOUT_US =
|
|
30
|
+
IRQ_WAIT_TIMEOUT_US = 1_000_000 # Allow up to 1 second for IRQ trailing edge
|
|
31
|
+
|
|
32
|
+
WaitIrqOption = Union[Dict[str, Any], bool, None]
|
|
33
|
+
DEFAULT_WAIT_IRQ: Dict[str, Any] = {
|
|
34
|
+
"edge": "trailing",
|
|
35
|
+
"timeout_us": IRQ_WAIT_TIMEOUT_US,
|
|
36
|
+
}
|
|
23
37
|
|
|
24
38
|
# Selected register map and field descriptions from TiMo SPI interface docs
|
|
25
39
|
REGISTER_MAP: Dict[str, Dict[str, Any]] = {
|
|
@@ -39,6 +53,12 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
|
|
|
39
53
|
"access": "R/W",
|
|
40
54
|
"desc": "0=UART RDM, 1=SPI RDM",
|
|
41
55
|
},
|
|
56
|
+
"UPDATE_MODE": {
|
|
57
|
+
"bits": (5, 5),
|
|
58
|
+
"access": "W",
|
|
59
|
+
"reset": 0,
|
|
60
|
+
"desc": "1=driver update mode",
|
|
61
|
+
},
|
|
42
62
|
"RADIO_ENABLE": {
|
|
43
63
|
"bits": (7, 7),
|
|
44
64
|
"access": "R/W",
|
|
@@ -229,9 +249,9 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
|
|
|
229
249
|
},
|
|
230
250
|
"DEVICE_NAME": {
|
|
231
251
|
"address": 0x36,
|
|
232
|
-
"length":
|
|
252
|
+
"length": 32,
|
|
233
253
|
"fields": {
|
|
234
|
-
"DEVICE_NAME": {"bits": (0,
|
|
254
|
+
"DEVICE_NAME": {"bits": (0, 255), "access": "R/W"},
|
|
235
255
|
},
|
|
236
256
|
},
|
|
237
257
|
"UNIVERSE_NAME": {
|
|
@@ -420,7 +440,12 @@ def nop_sequence() -> Sequence[Dict[str, Any]]:
|
|
|
420
440
|
return [command_payload(nop_frame())]
|
|
421
441
|
|
|
422
442
|
|
|
423
|
-
def read_reg_sequence(
|
|
443
|
+
def read_reg_sequence(
|
|
444
|
+
address: int,
|
|
445
|
+
length: int,
|
|
446
|
+
*,
|
|
447
|
+
wait_irq: WaitIrqOption = None,
|
|
448
|
+
) -> Sequence[Dict[str, Any]]:
|
|
424
449
|
"""Build the SPI transfer sequence to read a TiMo register."""
|
|
425
450
|
|
|
426
451
|
if not 0 <= address <= READ_REG_ADDR_MASK:
|
|
@@ -429,10 +454,19 @@ def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
|
|
|
429
454
|
raise ValueError(f"length must be 1..{READ_REG_MAX_LEN}")
|
|
430
455
|
|
|
431
456
|
command_byte = READ_REG_BASE | (address & READ_REG_ADDR_MASK)
|
|
432
|
-
#
|
|
457
|
+
# Allow callers to override the default IRQ wait behavior for edge cases
|
|
458
|
+
if wait_irq is False:
|
|
459
|
+
resolved_wait = None
|
|
460
|
+
elif wait_irq is None or wait_irq is True:
|
|
461
|
+
resolved_wait = dict(DEFAULT_WAIT_IRQ)
|
|
462
|
+
else:
|
|
463
|
+
resolved_wait = wait_irq
|
|
464
|
+
command_params: Dict[str, Any] = {}
|
|
465
|
+
if resolved_wait is not None:
|
|
466
|
+
command_params["wait_irq"] = resolved_wait
|
|
433
467
|
command_transfer = command_payload(
|
|
434
468
|
bytes([command_byte]),
|
|
435
|
-
params=
|
|
469
|
+
params=command_params or None,
|
|
436
470
|
)
|
|
437
471
|
payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
|
|
438
472
|
return [command_transfer, payload_transfer]
|
|
@@ -497,3 +531,29 @@ def requires_restart(irq_flags: int) -> bool:
|
|
|
497
531
|
"""Return True when bit 7 indicates the command must be retried."""
|
|
498
532
|
|
|
499
533
|
return bool(irq_flags & IRQ_FLAG_RESTART)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def read_cci_header(stream: BinaryIO) -> bytes:
|
|
537
|
+
"""Read and return the 4-byte TiMo CCI header."""
|
|
538
|
+
|
|
539
|
+
header = stream.read(CCI_HEADER_SIZE)
|
|
540
|
+
if len(header) != CCI_HEADER_SIZE:
|
|
541
|
+
raise ValueError("CCI firmware header must contain 4 bytes")
|
|
542
|
+
return header
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def iter_cci_chunks(stream: BinaryIO) -> Iterator[Tuple[int, bytes, bytes]]:
|
|
546
|
+
"""Yield successive FW block payloads (254+18 bytes) from a TiMo CCI image."""
|
|
547
|
+
|
|
548
|
+
block_index = 0
|
|
549
|
+
while True:
|
|
550
|
+
chunk_1 = stream.read(FW_BLOCK_CMD_1_SIZE)
|
|
551
|
+
if not chunk_1:
|
|
552
|
+
break
|
|
553
|
+
if len(chunk_1) != FW_BLOCK_CMD_1_SIZE:
|
|
554
|
+
raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_1 payload")
|
|
555
|
+
chunk_2 = stream.read(FW_BLOCK_CMD_2_SIZE)
|
|
556
|
+
if len(chunk_2) != FW_BLOCK_CMD_2_SIZE:
|
|
557
|
+
raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_2 payload")
|
|
558
|
+
block_index += 1
|
|
559
|
+
yield block_index, chunk_1, chunk_2
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
shuttle/cli.py,sha256=lou_JvWNoMXxoyXyz5MSIyAUZEUAe0CmXaBnl_gqLMY,93507
|
|
2
|
-
shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
|
|
3
|
-
shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
|
|
4
|
-
shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
|
|
5
|
-
shuttle/serial_client.py,sha256=bUpTs6MmJkpYBgtNYZZ0EYaybkLlrM7MlhWxHLQPh3U,18185
|
|
6
|
-
shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
|
|
7
|
-
shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
|
|
8
|
-
shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
|
|
9
|
-
shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
|
|
10
|
-
shuttle/firmware/esp32c5/devboard.ino.bin,sha256=CxWJQYlbL7zu9nkUzBQ0PQQET4XHZKFoxMzm9tZonqk,1099040
|
|
11
|
-
shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
|
|
12
|
-
shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
|
|
13
|
-
shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
|
|
14
|
-
lr_shuttle-0.2.4.dist-info/METADATA,sha256=EEgzPGFjeZ3L04elKT8VcUkiRjm0CyUn9dAN5Cq4_IQ,13611
|
|
15
|
-
lr_shuttle-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
-
lr_shuttle-0.2.4.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
|
|
17
|
-
lr_shuttle-0.2.4.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
|
|
18
|
-
lr_shuttle-0.2.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|