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.
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.8.dist-info}/METADATA +19 -1
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.8.dist-info}/RECORD +9 -9
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.8.dist-info}/WHEEL +1 -1
- shuttle/cli.py +433 -17
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/serial_client.py +38 -1
- shuttle/timo.py +64 -5
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.8.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.4.dist-info → lr_shuttle-0.2.8.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.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=
|
|
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=
|
|
6
|
-
shuttle/timo.py,sha256=
|
|
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=
|
|
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.
|
|
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.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,,
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2215
|
-
|
|
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
|
|
2249
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|