lr-shuttle 0.2.3__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.3.dist-info → lr_shuttle-0.2.8.dist-info}/METADATA +19 -1
- {lr_shuttle-0.2.3.dist-info → lr_shuttle-0.2.8.dist-info}/RECORD +10 -10
- {lr_shuttle-0.2.3.dist-info → lr_shuttle-0.2.8.dist-info}/WHEEL +1 -1
- shuttle/cli.py +453 -37
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/prodtest.py +1 -3
- shuttle/serial_client.py +52 -2
- shuttle/timo.py +64 -5
- {lr_shuttle-0.2.3.dist-info → lr_shuttle-0.2.8.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.3.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
|
-
shuttle/prodtest.py,sha256=
|
|
5
|
-
shuttle/serial_client.py,sha256=
|
|
6
|
-
shuttle/timo.py,sha256=
|
|
4
|
+
shuttle/prodtest.py,sha256=nI8k2OndhqsOv8BMtXwfcpGEdmHU7ywbIgMuW49EULU,8006
|
|
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
|
|
|
@@ -107,7 +108,9 @@ for entry in PRODTEST_TX_POWER_LEVELS:
|
|
|
107
108
|
for alias in entry["aliases"]:
|
|
108
109
|
PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
|
|
109
110
|
PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
|
|
110
|
-
PRODTEST_TX_POWER_CANONICAL = [
|
|
111
|
+
PRODTEST_TX_POWER_CANONICAL = [
|
|
112
|
+
entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS
|
|
113
|
+
]
|
|
111
114
|
|
|
112
115
|
_HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
|
|
113
116
|
_IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
|
|
@@ -121,6 +124,53 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
|
|
|
121
124
|
return ctx.obj or {}
|
|
122
125
|
|
|
123
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
|
+
|
|
124
174
|
@contextmanager
|
|
125
175
|
def spinner(message: str, enabled: bool = True):
|
|
126
176
|
"""Show a Rich spinner while the body executes."""
|
|
@@ -159,9 +209,7 @@ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
|
|
|
159
209
|
resolved = parsed
|
|
160
210
|
if resolved is None:
|
|
161
211
|
allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
|
|
162
|
-
raise typer.BadParameter(
|
|
163
|
-
f"Power must be one of: {allowed} or an index 0-7"
|
|
164
|
-
)
|
|
212
|
+
raise typer.BadParameter(f"Power must be one of: {allowed} or an index 0-7")
|
|
165
213
|
return resolved, PRODTEST_TX_POWER_META[resolved]
|
|
166
214
|
|
|
167
215
|
|
|
@@ -259,9 +307,7 @@ def _resolve_uart_payload(
|
|
|
259
307
|
def _normalize_port(port: str) -> str:
|
|
260
308
|
trimmed = port.strip()
|
|
261
309
|
if not trimmed:
|
|
262
|
-
raise typer.BadParameter(
|
|
263
|
-
"Serial port is required (use --port or SHUTTLE_PORT)"
|
|
264
|
-
)
|
|
310
|
+
raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
|
|
265
311
|
if "://" in trimmed:
|
|
266
312
|
return trimmed
|
|
267
313
|
if trimmed.startswith("/") or trimmed.startswith("\\"):
|
|
@@ -439,6 +485,267 @@ def _render_read_dmx_result(result, rx_frames):
|
|
|
439
485
|
)
|
|
440
486
|
|
|
441
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
|
+
|
|
442
749
|
def _execute_timo_sequence(
|
|
443
750
|
*,
|
|
444
751
|
port: Optional[str],
|
|
@@ -453,7 +760,7 @@ def _execute_timo_sequence(
|
|
|
453
760
|
responses: List[Dict[str, Any]] = []
|
|
454
761
|
with spinner(f"{spinner_label} over {resolved_port}"):
|
|
455
762
|
try:
|
|
456
|
-
with
|
|
763
|
+
with _open_serial_client(
|
|
457
764
|
resolved_port,
|
|
458
765
|
baudrate=baudrate,
|
|
459
766
|
timeout=timeout,
|
|
@@ -1050,7 +1357,7 @@ def timo_dmx(
|
|
|
1050
1357
|
mask = ((1 << width) - 1) << (total_bits - hi - 1)
|
|
1051
1358
|
return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
|
|
1052
1359
|
|
|
1053
|
-
with
|
|
1360
|
+
with _open_serial_client(
|
|
1054
1361
|
resolved_port,
|
|
1055
1362
|
baudrate=baudrate,
|
|
1056
1363
|
timeout=timeout,
|
|
@@ -1308,7 +1615,7 @@ def timo_device_name(
|
|
|
1308
1615
|
data = payload[1:] if payload else b""
|
|
1309
1616
|
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
|
1310
1617
|
|
|
1311
|
-
with
|
|
1618
|
+
with _open_serial_client(
|
|
1312
1619
|
resolved_port,
|
|
1313
1620
|
baudrate=baudrate,
|
|
1314
1621
|
timeout=timeout,
|
|
@@ -1490,6 +1797,113 @@ def timo_read_dmx(
|
|
|
1490
1797
|
_render_read_dmx_result(parsed, rx_frames)
|
|
1491
1798
|
|
|
1492
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
|
+
|
|
1493
1907
|
@prodtest_app.command("reset")
|
|
1494
1908
|
def prodtest_reset(
|
|
1495
1909
|
ctx: typer.Context,
|
|
@@ -1568,7 +1982,9 @@ def prodtest_ping(
|
|
|
1568
1982
|
raise typer.Exit(1)
|
|
1569
1983
|
|
|
1570
1984
|
if len(responses) != len(sequence):
|
|
1571
|
-
console.print(
|
|
1985
|
+
console.print(
|
|
1986
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
1987
|
+
)
|
|
1572
1988
|
raise typer.Exit(1)
|
|
1573
1989
|
|
|
1574
1990
|
command_response, payload_response = responses
|
|
@@ -1583,9 +1999,7 @@ def prodtest_ping(
|
|
|
1583
1999
|
command_label="spi.xfer (prodtest payload)",
|
|
1584
2000
|
)
|
|
1585
2001
|
|
|
1586
|
-
rx_bytes = _decode_hex_response(
|
|
1587
|
-
payload_response, label="prodtest ping (payload)"
|
|
1588
|
-
)
|
|
2002
|
+
rx_bytes = _decode_hex_response(payload_response, label="prodtest ping (payload)")
|
|
1589
2003
|
if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
|
|
1590
2004
|
console.print(
|
|
1591
2005
|
"[red]Ping failed: expected '-' (0x2D), got: "
|
|
@@ -1623,9 +2037,7 @@ def prodtest_antenna(
|
|
|
1623
2037
|
antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
|
|
1624
2038
|
except KeyError as exc:
|
|
1625
2039
|
allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
|
|
1626
|
-
raise typer.BadParameter(
|
|
1627
|
-
f"Antenna must be one of: {allowed}"
|
|
1628
|
-
) from exc
|
|
2040
|
+
raise typer.BadParameter(f"Antenna must be one of: {allowed}") from exc
|
|
1629
2041
|
|
|
1630
2042
|
sequence = [prodtest.select_antenna(antenna_value)]
|
|
1631
2043
|
responses = _execute_timo_sequence(
|
|
@@ -1767,7 +2179,9 @@ def prodtest_hw_device_id(
|
|
|
1767
2179
|
raise typer.Exit(1)
|
|
1768
2180
|
|
|
1769
2181
|
if len(responses) != len(sequence):
|
|
1770
|
-
console.print(
|
|
2182
|
+
console.print(
|
|
2183
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
2184
|
+
)
|
|
1771
2185
|
raise typer.Exit(1)
|
|
1772
2186
|
|
|
1773
2187
|
result_response = responses[-1]
|
|
@@ -1858,11 +2272,11 @@ def prodtest_serial_number(
|
|
|
1858
2272
|
result_response,
|
|
1859
2273
|
command_label="spi.xfer (prodtest payload)",
|
|
1860
2274
|
)
|
|
1861
|
-
rx_bytes = _decode_hex_response(
|
|
1862
|
-
result_response, label="prodtest serial-number"
|
|
1863
|
-
)
|
|
2275
|
+
rx_bytes = _decode_hex_response(result_response, label="prodtest serial-number")
|
|
1864
2276
|
if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
|
|
1865
|
-
console.print(
|
|
2277
|
+
console.print(
|
|
2278
|
+
"[red]Prodtest serial-number response shorter than expected[/]"
|
|
2279
|
+
)
|
|
1866
2280
|
raise typer.Exit(1)
|
|
1867
2281
|
serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
|
|
1868
2282
|
console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
|
|
@@ -2178,7 +2592,7 @@ def spi_cfg_command(
|
|
|
2178
2592
|
action = "Updating" if spi_payload else "Querying"
|
|
2179
2593
|
with spinner(f"{action} spi.cfg over {resolved_port}"):
|
|
2180
2594
|
try:
|
|
2181
|
-
with
|
|
2595
|
+
with _open_serial_client(
|
|
2182
2596
|
resolved_port,
|
|
2183
2597
|
baudrate=baudrate,
|
|
2184
2598
|
timeout=timeout,
|
|
@@ -2213,8 +2627,8 @@ def spi_enable_command(
|
|
|
2213
2627
|
resolved_port = _require_port(port)
|
|
2214
2628
|
with spinner(f"Enabling SPI over {resolved_port}"):
|
|
2215
2629
|
try:
|
|
2216
|
-
with
|
|
2217
|
-
|
|
2630
|
+
with _open_serial_client(
|
|
2631
|
+
resolved_port,
|
|
2218
2632
|
baudrate=baudrate,
|
|
2219
2633
|
timeout=timeout,
|
|
2220
2634
|
logger=resources.get("logger"),
|
|
@@ -2247,8 +2661,8 @@ def spi_disable_command(
|
|
|
2247
2661
|
resolved_port = _require_port(port)
|
|
2248
2662
|
with spinner(f"Disabling SPI over {resolved_port}"):
|
|
2249
2663
|
try:
|
|
2250
|
-
with
|
|
2251
|
-
|
|
2664
|
+
with _open_serial_client(
|
|
2665
|
+
resolved_port,
|
|
2252
2666
|
baudrate=baudrate,
|
|
2253
2667
|
timeout=timeout,
|
|
2254
2668
|
logger=resources.get("logger"),
|
|
@@ -2312,7 +2726,7 @@ def uart_cfg_command(
|
|
|
2312
2726
|
action = "Updating" if uart_payload else "Querying"
|
|
2313
2727
|
with spinner(f"{action} uart.cfg over {resolved_port}"):
|
|
2314
2728
|
try:
|
|
2315
|
-
with
|
|
2729
|
+
with _open_serial_client(
|
|
2316
2730
|
resolved_port,
|
|
2317
2731
|
baudrate=baudrate,
|
|
2318
2732
|
timeout=timeout,
|
|
@@ -2375,7 +2789,7 @@ def uart_sub_command(
|
|
|
2375
2789
|
action = "Updating" if sub_payload else "Querying"
|
|
2376
2790
|
with spinner(f"{action} uart.sub over {resolved_port}"):
|
|
2377
2791
|
try:
|
|
2378
|
-
with
|
|
2792
|
+
with _open_serial_client(
|
|
2379
2793
|
resolved_port,
|
|
2380
2794
|
baudrate=baudrate,
|
|
2381
2795
|
timeout=timeout,
|
|
@@ -2476,7 +2890,9 @@ def wifi_cfg_command(
|
|
|
2476
2890
|
if parsed_gateway is not None:
|
|
2477
2891
|
network_payload["gateway"] = parsed_gateway
|
|
2478
2892
|
|
|
2479
|
-
dns_entries = [
|
|
2893
|
+
dns_entries = [
|
|
2894
|
+
entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry
|
|
2895
|
+
]
|
|
2480
2896
|
if dns_entries:
|
|
2481
2897
|
network_payload["dns"] = dns_entries
|
|
2482
2898
|
|
|
@@ -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/prodtest.py
CHANGED
|
@@ -62,9 +62,7 @@ def command(
|
|
|
62
62
|
) -> dict:
|
|
63
63
|
"""Build an NDJSON-ready spi.xfer payload for a prodtest command."""
|
|
64
64
|
|
|
65
|
-
return timo.command_payload(
|
|
66
|
-
_build_command_bytes(opcode, arguments), params=params
|
|
67
|
-
)
|
|
65
|
+
return timo.command_payload(_build_command_bytes(opcode, arguments), params=params)
|
|
68
66
|
|
|
69
67
|
|
|
70
68
|
def reset() -> dict:
|
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
|
|
|
@@ -229,10 +236,23 @@ class NDJSONSerialClient:
|
|
|
229
236
|
):
|
|
230
237
|
try:
|
|
231
238
|
self._serial = serial.serial_for_url(
|
|
232
|
-
url=port,
|
|
239
|
+
url=port,
|
|
240
|
+
baudrate=baudrate,
|
|
241
|
+
timeout=timeout,
|
|
242
|
+
do_not_open=True,
|
|
233
243
|
)
|
|
244
|
+
except SerialException as exc: # pragma: no cover - hardware specific
|
|
245
|
+
raise ShuttleSerialError(f"Unable to initialize {port}: {exc}") from exc
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
if getattr(self._serial, "open", None) is not None:
|
|
249
|
+
if not getattr(self._serial, "is_open", False):
|
|
250
|
+
self._serial.open()
|
|
234
251
|
except SerialException as exc: # pragma: no cover - hardware specific
|
|
235
252
|
raise ShuttleSerialError(f"Unable to open {port}: {exc}") from exc
|
|
253
|
+
except AttributeError:
|
|
254
|
+
# Test stubs without an open() method are already "connected"
|
|
255
|
+
pass
|
|
236
256
|
self._serial.reset_input_buffer()
|
|
237
257
|
self._lock = threading.Lock()
|
|
238
258
|
self._pending: Dict[int, CommandFuture] = {}
|
|
@@ -243,6 +263,7 @@ class NDJSONSerialClient:
|
|
|
243
263
|
self._logger = logger
|
|
244
264
|
self._seq_tracker = seq_tracker
|
|
245
265
|
self._reader: Optional[threading.Thread] = None
|
|
266
|
+
self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
246
267
|
|
|
247
268
|
def __enter__(self) -> "NDJSONSerialClient":
|
|
248
269
|
return self
|
|
@@ -306,6 +327,13 @@ class NDJSONSerialClient:
|
|
|
306
327
|
self._ensure_reader_started()
|
|
307
328
|
return listener
|
|
308
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
|
+
|
|
309
337
|
def spi_xfer(
|
|
310
338
|
self, *, tx: str, n: Optional[int] = None, **overrides: Any
|
|
311
339
|
) -> Dict[str, Any]:
|
|
@@ -413,8 +441,19 @@ class NDJSONSerialClient:
|
|
|
413
441
|
def _write(self, message: Dict[str, Any]) -> None:
|
|
414
442
|
serialized = json.dumps(message, separators=(",", ":"))
|
|
415
443
|
payload = serialized.encode("utf-8") + b"\n"
|
|
444
|
+
total_written = 0
|
|
416
445
|
with self._lock:
|
|
417
|
-
|
|
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)
|
|
418
457
|
self._log_serial("TX", payload)
|
|
419
458
|
|
|
420
459
|
def _read(self) -> Optional[Dict[str, Any]]:
|
|
@@ -458,6 +497,7 @@ class NDJSONSerialClient:
|
|
|
458
497
|
ev_name = message.get("ev")
|
|
459
498
|
if not isinstance(ev_name, str):
|
|
460
499
|
raise ShuttleSerialError("Device event missing ev field")
|
|
500
|
+
self._emit_event_callback(message)
|
|
461
501
|
with self._lock:
|
|
462
502
|
listeners = list(self._event_listeners.get(ev_name, []))
|
|
463
503
|
for listener in listeners:
|
|
@@ -499,3 +539,13 @@ class NDJSONSerialClient:
|
|
|
499
539
|
future.mark_exception(exc)
|
|
500
540
|
for listener in listeners:
|
|
501
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
|