lr-shuttle 0.2.2__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.2.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.2.dist-info → lr_shuttle-0.2.12.dist-info}/WHEEL +1 -1
- shuttle/cli.py +676 -39
- shuttle/constants.py +0 -1
- shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- shuttle/prodtest.py +1 -3
- shuttle/serial_client.py +123 -25
- shuttle/timo.py +67 -7
- lr_shuttle-0.2.2.dist-info/RECORD +0 -18
- {lr_shuttle-0.2.2.dist-info → lr_shuttle-0.2.12.dist-info}/entry_points.txt +0 -0
- {lr_shuttle-0.2.2.dist-info → lr_shuttle-0.2.12.dist-info}/top_level.txt +0 -0
shuttle/cli.py
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import io
|
|
6
|
+
import ipaddress
|
|
7
|
+
import re
|
|
5
8
|
import string
|
|
6
9
|
import sys
|
|
7
10
|
import time
|
|
8
|
-
from contextlib import contextmanager
|
|
11
|
+
from contextlib import contextmanager, nullcontext
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
11
14
|
|
|
@@ -105,7 +108,12 @@ for entry in PRODTEST_TX_POWER_LEVELS:
|
|
|
105
108
|
for alias in entry["aliases"]:
|
|
106
109
|
PRODTEST_TX_POWER_ALIASES[alias.lower()] = entry["value"]
|
|
107
110
|
PRODTEST_TX_POWER_ALIASES[str(entry["value"])] = entry["value"]
|
|
108
|
-
PRODTEST_TX_POWER_CANONICAL = [
|
|
111
|
+
PRODTEST_TX_POWER_CANONICAL = [
|
|
112
|
+
entry["aliases"][0] for entry in PRODTEST_TX_POWER_LEVELS
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
_HOST_PORT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+:\d+$")
|
|
116
|
+
_IPV6_HOST_PORT_PATTERN = re.compile(r"^\[[0-9A-Fa-f:]+\]:\d+$")
|
|
109
117
|
|
|
110
118
|
# Backwards-compatible aliases for tests and external callers
|
|
111
119
|
_SerialLogger = SerialLogger
|
|
@@ -116,6 +124,66 @@ def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
|
|
|
116
124
|
return ctx.obj or {}
|
|
117
125
|
|
|
118
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
|
+
|
|
119
187
|
@contextmanager
|
|
120
188
|
def spinner(message: str, enabled: bool = True):
|
|
121
189
|
"""Show a Rich spinner while the body executes."""
|
|
@@ -154,9 +222,7 @@ def _resolve_prodtest_power_choice(value: str) -> Tuple[int, Dict[str, str]]:
|
|
|
154
222
|
resolved = parsed
|
|
155
223
|
if resolved is None:
|
|
156
224
|
allowed = ", ".join(PRODTEST_TX_POWER_CANONICAL)
|
|
157
|
-
raise typer.BadParameter(
|
|
158
|
-
f"Power must be one of: {allowed} or an index 0-7"
|
|
159
|
-
)
|
|
225
|
+
raise typer.BadParameter(f"Power must be one of: {allowed} or an index 0-7")
|
|
160
226
|
return resolved, PRODTEST_TX_POWER_META[resolved]
|
|
161
227
|
|
|
162
228
|
|
|
@@ -251,9 +317,22 @@ def _resolve_uart_payload(
|
|
|
251
317
|
return payload_bytes.hex(), len(payload_bytes)
|
|
252
318
|
|
|
253
319
|
|
|
320
|
+
def _normalize_port(port: str) -> str:
|
|
321
|
+
trimmed = port.strip()
|
|
322
|
+
if not trimmed:
|
|
323
|
+
raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
|
|
324
|
+
if "://" in trimmed:
|
|
325
|
+
return trimmed
|
|
326
|
+
if trimmed.startswith("/") or trimmed.startswith("\\"):
|
|
327
|
+
return trimmed
|
|
328
|
+
if _HOST_PORT_PATTERN.match(trimmed) or _IPV6_HOST_PORT_PATTERN.match(trimmed):
|
|
329
|
+
return f"socket://{trimmed}"
|
|
330
|
+
return trimmed
|
|
331
|
+
|
|
332
|
+
|
|
254
333
|
def _require_port(port: Optional[str]) -> str:
|
|
255
334
|
if port:
|
|
256
|
-
return port
|
|
335
|
+
return _normalize_port(port)
|
|
257
336
|
raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
|
|
258
337
|
|
|
259
338
|
|
|
@@ -269,6 +348,16 @@ def _parse_int_option(value: str, *, name: str) -> int:
|
|
|
269
348
|
return parsed
|
|
270
349
|
|
|
271
350
|
|
|
351
|
+
def _parse_ipv4(value: Optional[str], *, name: str) -> Optional[str]:
|
|
352
|
+
if value is None:
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
ipaddress.IPv4Address(value)
|
|
356
|
+
except ipaddress.AddressValueError as exc:
|
|
357
|
+
raise typer.BadParameter(f"{name} must be a valid IPv4 address") from exc
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
|
|
272
361
|
def _parse_prodtest_mask(value: str) -> bytes:
|
|
273
362
|
try:
|
|
274
363
|
return prodtest.mask_from_hex(value)
|
|
@@ -409,6 +498,267 @@ def _render_read_dmx_result(result, rx_frames):
|
|
|
409
498
|
)
|
|
410
499
|
|
|
411
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
|
+
|
|
412
762
|
def _execute_timo_sequence(
|
|
413
763
|
*,
|
|
414
764
|
port: Optional[str],
|
|
@@ -423,13 +773,16 @@ def _execute_timo_sequence(
|
|
|
423
773
|
responses: List[Dict[str, Any]] = []
|
|
424
774
|
with spinner(f"{spinner_label} over {resolved_port}"):
|
|
425
775
|
try:
|
|
426
|
-
with
|
|
776
|
+
with _open_serial_client(
|
|
427
777
|
resolved_port,
|
|
428
778
|
baudrate=baudrate,
|
|
429
779
|
timeout=timeout,
|
|
430
780
|
logger=logger,
|
|
431
781
|
seq_tracker=seq_tracker,
|
|
432
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)
|
|
433
786
|
for transfer in sequence:
|
|
434
787
|
response = client.spi_xfer(**transfer)
|
|
435
788
|
responses.append(response)
|
|
@@ -1020,7 +1373,7 @@ def timo_dmx(
|
|
|
1020
1373
|
mask = ((1 << width) - 1) << (total_bits - hi - 1)
|
|
1021
1374
|
return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
|
|
1022
1375
|
|
|
1023
|
-
with
|
|
1376
|
+
with _open_serial_client(
|
|
1024
1377
|
resolved_port,
|
|
1025
1378
|
baudrate=baudrate,
|
|
1026
1379
|
timeout=timeout,
|
|
@@ -1271,14 +1624,14 @@ def timo_device_name(
|
|
|
1271
1624
|
resolved_port = _require_port(port)
|
|
1272
1625
|
|
|
1273
1626
|
def _read_name(client) -> str:
|
|
1274
|
-
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))
|
|
1275
1628
|
responses = [client.spi_xfer(**cmd) for cmd in seq]
|
|
1276
1629
|
rx = responses[-1].get("rx", "")
|
|
1277
1630
|
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
1278
1631
|
data = payload[1:] if payload else b""
|
|
1279
1632
|
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
|
1280
1633
|
|
|
1281
|
-
with
|
|
1634
|
+
with _open_serial_client(
|
|
1282
1635
|
resolved_port,
|
|
1283
1636
|
baudrate=baudrate,
|
|
1284
1637
|
timeout=timeout,
|
|
@@ -1460,6 +1813,113 @@ def timo_read_dmx(
|
|
|
1460
1813
|
_render_read_dmx_result(parsed, rx_frames)
|
|
1461
1814
|
|
|
1462
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
|
+
|
|
1463
1923
|
@prodtest_app.command("reset")
|
|
1464
1924
|
def prodtest_reset(
|
|
1465
1925
|
ctx: typer.Context,
|
|
@@ -1521,17 +1981,49 @@ def prodtest_ping(
|
|
|
1521
1981
|
logger=resources.get("logger"),
|
|
1522
1982
|
seq_tracker=resources.get("seq_tracker"),
|
|
1523
1983
|
)
|
|
1524
|
-
if not responses
|
|
1984
|
+
if not responses:
|
|
1525
1985
|
console.print("[red]Device returned no response[/]")
|
|
1526
1986
|
raise typer.Exit(1)
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1987
|
+
|
|
1988
|
+
failed_idx = next(
|
|
1989
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1990
|
+
)
|
|
1991
|
+
if failed_idx is not None:
|
|
1992
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1993
|
+
_render_spi_response(
|
|
1994
|
+
f"prodtest ping ({phase})",
|
|
1995
|
+
responses[failed_idx],
|
|
1996
|
+
command_label=f"spi.xfer (prodtest {phase})",
|
|
1997
|
+
)
|
|
1998
|
+
raise typer.Exit(1)
|
|
1999
|
+
|
|
2000
|
+
if len(responses) != len(sequence):
|
|
2001
|
+
console.print(
|
|
2002
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
2003
|
+
)
|
|
2004
|
+
raise typer.Exit(1)
|
|
2005
|
+
|
|
2006
|
+
command_response, payload_response = responses
|
|
2007
|
+
_render_spi_response(
|
|
2008
|
+
"prodtest ping (command)",
|
|
2009
|
+
command_response,
|
|
2010
|
+
command_label="spi.xfer (prodtest command)",
|
|
2011
|
+
)
|
|
2012
|
+
_render_spi_response(
|
|
2013
|
+
"prodtest ping (payload)",
|
|
2014
|
+
payload_response,
|
|
2015
|
+
command_label="spi.xfer (prodtest payload)",
|
|
2016
|
+
)
|
|
2017
|
+
|
|
2018
|
+
rx_bytes = _decode_hex_response(payload_response, label="prodtest ping (payload)")
|
|
2019
|
+
if not rx_bytes or rx_bytes[0] != 0x2D: # ord('-')
|
|
2020
|
+
console.print(
|
|
2021
|
+
"[red]Ping failed: expected '-' (0x2D), got: "
|
|
2022
|
+
f"{_format_hex(payload_response.get('rx', ''))}[/]"
|
|
2023
|
+
)
|
|
2024
|
+
raise typer.Exit(1)
|
|
2025
|
+
|
|
2026
|
+
console.print("[green]Ping successful: got '-' response[/]")
|
|
1535
2027
|
|
|
1536
2028
|
|
|
1537
2029
|
@prodtest_app.command("antenna")
|
|
@@ -1561,9 +2053,7 @@ def prodtest_antenna(
|
|
|
1561
2053
|
antenna_value = PRODTEST_ANTENNA_CHOICES[normalized]
|
|
1562
2054
|
except KeyError as exc:
|
|
1563
2055
|
allowed = ", ".join(sorted(PRODTEST_ANTENNA_CHOICES))
|
|
1564
|
-
raise typer.BadParameter(
|
|
1565
|
-
f"Antenna must be one of: {allowed}"
|
|
1566
|
-
) from exc
|
|
2056
|
+
raise typer.BadParameter(f"Antenna must be one of: {allowed}") from exc
|
|
1567
2057
|
|
|
1568
2058
|
sequence = [prodtest.select_antenna(antenna_value)]
|
|
1569
2059
|
responses = _execute_timo_sequence(
|
|
@@ -1705,7 +2195,9 @@ def prodtest_hw_device_id(
|
|
|
1705
2195
|
raise typer.Exit(1)
|
|
1706
2196
|
|
|
1707
2197
|
if len(responses) != len(sequence):
|
|
1708
|
-
console.print(
|
|
2198
|
+
console.print(
|
|
2199
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
2200
|
+
)
|
|
1709
2201
|
raise typer.Exit(1)
|
|
1710
2202
|
|
|
1711
2203
|
result_response = responses[-1]
|
|
@@ -1796,11 +2288,11 @@ def prodtest_serial_number(
|
|
|
1796
2288
|
result_response,
|
|
1797
2289
|
command_label="spi.xfer (prodtest payload)",
|
|
1798
2290
|
)
|
|
1799
|
-
rx_bytes = _decode_hex_response(
|
|
1800
|
-
result_response, label="prodtest serial-number"
|
|
1801
|
-
)
|
|
2291
|
+
rx_bytes = _decode_hex_response(result_response, label="prodtest serial-number")
|
|
1802
2292
|
if len(rx_bytes) < prodtest.SERIAL_NUMBER_LEN:
|
|
1803
|
-
console.print(
|
|
2293
|
+
console.print(
|
|
2294
|
+
"[red]Prodtest serial-number response shorter than expected[/]"
|
|
2295
|
+
)
|
|
1804
2296
|
raise typer.Exit(1)
|
|
1805
2297
|
serial_bytes = rx_bytes[-prodtest.SERIAL_NUMBER_LEN :]
|
|
1806
2298
|
console.print(f"Serial number: {_format_hex(serial_bytes.hex())}")
|
|
@@ -2116,7 +2608,7 @@ def spi_cfg_command(
|
|
|
2116
2608
|
action = "Updating" if spi_payload else "Querying"
|
|
2117
2609
|
with spinner(f"{action} spi.cfg over {resolved_port}"):
|
|
2118
2610
|
try:
|
|
2119
|
-
with
|
|
2611
|
+
with _open_serial_client(
|
|
2120
2612
|
resolved_port,
|
|
2121
2613
|
baudrate=baudrate,
|
|
2122
2614
|
timeout=timeout,
|
|
@@ -2151,13 +2643,14 @@ def spi_enable_command(
|
|
|
2151
2643
|
resolved_port = _require_port(port)
|
|
2152
2644
|
with spinner(f"Enabling SPI over {resolved_port}"):
|
|
2153
2645
|
try:
|
|
2154
|
-
with
|
|
2155
|
-
|
|
2646
|
+
with _open_serial_client(
|
|
2647
|
+
resolved_port,
|
|
2156
2648
|
baudrate=baudrate,
|
|
2157
2649
|
timeout=timeout,
|
|
2158
2650
|
logger=resources.get("logger"),
|
|
2159
2651
|
seq_tracker=resources.get("seq_tracker"),
|
|
2160
2652
|
) as client:
|
|
2653
|
+
_flush_client_input(client)
|
|
2161
2654
|
response = client.spi_enable()
|
|
2162
2655
|
except ShuttleSerialError as exc:
|
|
2163
2656
|
console.print(f"[red]{exc}[/]")
|
|
@@ -2185,13 +2678,14 @@ def spi_disable_command(
|
|
|
2185
2678
|
resolved_port = _require_port(port)
|
|
2186
2679
|
with spinner(f"Disabling SPI over {resolved_port}"):
|
|
2187
2680
|
try:
|
|
2188
|
-
with
|
|
2189
|
-
|
|
2681
|
+
with _open_serial_client(
|
|
2682
|
+
resolved_port,
|
|
2190
2683
|
baudrate=baudrate,
|
|
2191
2684
|
timeout=timeout,
|
|
2192
2685
|
logger=resources.get("logger"),
|
|
2193
2686
|
seq_tracker=resources.get("seq_tracker"),
|
|
2194
2687
|
) as client:
|
|
2688
|
+
_flush_client_input(client)
|
|
2195
2689
|
response = client.spi_disable()
|
|
2196
2690
|
except ShuttleSerialError as exc:
|
|
2197
2691
|
console.print(f"[red]{exc}[/]")
|
|
@@ -2250,7 +2744,7 @@ def uart_cfg_command(
|
|
|
2250
2744
|
action = "Updating" if uart_payload else "Querying"
|
|
2251
2745
|
with spinner(f"{action} uart.cfg over {resolved_port}"):
|
|
2252
2746
|
try:
|
|
2253
|
-
with
|
|
2747
|
+
with _open_serial_client(
|
|
2254
2748
|
resolved_port,
|
|
2255
2749
|
baudrate=baudrate,
|
|
2256
2750
|
timeout=timeout,
|
|
@@ -2313,7 +2807,7 @@ def uart_sub_command(
|
|
|
2313
2807
|
action = "Updating" if sub_payload else "Querying"
|
|
2314
2808
|
with spinner(f"{action} uart.sub over {resolved_port}"):
|
|
2315
2809
|
try:
|
|
2316
|
-
with
|
|
2810
|
+
with _open_serial_client(
|
|
2317
2811
|
resolved_port,
|
|
2318
2812
|
baudrate=baudrate,
|
|
2319
2813
|
timeout=timeout,
|
|
@@ -2328,6 +2822,125 @@ def uart_sub_command(
|
|
|
2328
2822
|
_render_payload_response("uart.sub", response)
|
|
2329
2823
|
|
|
2330
2824
|
|
|
2825
|
+
@app.command("wifi-cfg")
|
|
2826
|
+
def wifi_cfg_command(
|
|
2827
|
+
ctx: typer.Context,
|
|
2828
|
+
port: Optional[str] = typer.Option(
|
|
2829
|
+
None,
|
|
2830
|
+
"--port",
|
|
2831
|
+
envvar="SHUTTLE_PORT",
|
|
2832
|
+
help="Serial port or host:port (e.g., /dev/ttyUSB0 or 192.168.1.10:5000)",
|
|
2833
|
+
),
|
|
2834
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
2835
|
+
timeout: float = typer.Option(
|
|
2836
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
2837
|
+
),
|
|
2838
|
+
ssid: Optional[str] = typer.Option(
|
|
2839
|
+
None,
|
|
2840
|
+
"--ssid",
|
|
2841
|
+
help="Set the station SSID",
|
|
2842
|
+
show_default=False,
|
|
2843
|
+
),
|
|
2844
|
+
psk: Optional[str] = typer.Option(
|
|
2845
|
+
None,
|
|
2846
|
+
"--psk",
|
|
2847
|
+
help="Set the WPA/WPA2/WPA3 passphrase",
|
|
2848
|
+
show_default=False,
|
|
2849
|
+
),
|
|
2850
|
+
dhcp: Optional[bool] = typer.Option(
|
|
2851
|
+
None,
|
|
2852
|
+
"--dhcp/--static",
|
|
2853
|
+
help="Enable DHCP or force static IPv4 addressing",
|
|
2854
|
+
),
|
|
2855
|
+
ip_addr: Optional[str] = typer.Option(
|
|
2856
|
+
None,
|
|
2857
|
+
"--ip",
|
|
2858
|
+
help="Static IPv4 address (requires --static or other static fields)",
|
|
2859
|
+
show_default=False,
|
|
2860
|
+
),
|
|
2861
|
+
netmask: Optional[str] = typer.Option(
|
|
2862
|
+
None,
|
|
2863
|
+
"--netmask",
|
|
2864
|
+
help="Static subnet mask (e.g., 255.255.255.0)",
|
|
2865
|
+
show_default=False,
|
|
2866
|
+
),
|
|
2867
|
+
gateway: Optional[str] = typer.Option(
|
|
2868
|
+
None,
|
|
2869
|
+
"--gateway",
|
|
2870
|
+
help="Static default gateway IPv4 address",
|
|
2871
|
+
show_default=False,
|
|
2872
|
+
),
|
|
2873
|
+
dns: Optional[str] = typer.Option(
|
|
2874
|
+
None,
|
|
2875
|
+
"--dns",
|
|
2876
|
+
help="Primary DNS server IPv4 address",
|
|
2877
|
+
show_default=False,
|
|
2878
|
+
),
|
|
2879
|
+
dns_alt: Optional[str] = typer.Option(
|
|
2880
|
+
None,
|
|
2881
|
+
"--dns-alt",
|
|
2882
|
+
help="Secondary DNS server IPv4 address",
|
|
2883
|
+
show_default=False,
|
|
2884
|
+
),
|
|
2885
|
+
):
|
|
2886
|
+
"""Query or update Wi-Fi credentials and network settings."""
|
|
2887
|
+
|
|
2888
|
+
resources = _ctx_resources(ctx)
|
|
2889
|
+
wifi_payload: Dict[str, Any] = {}
|
|
2890
|
+
if ssid is not None:
|
|
2891
|
+
wifi_payload["ssid"] = ssid
|
|
2892
|
+
if psk is not None:
|
|
2893
|
+
wifi_payload["psk"] = psk
|
|
2894
|
+
if dhcp is not None:
|
|
2895
|
+
wifi_payload["dhcp"] = dhcp
|
|
2896
|
+
|
|
2897
|
+
network_payload: Dict[str, Any] = {}
|
|
2898
|
+
parsed_ip = _parse_ipv4(ip_addr, name="--ip")
|
|
2899
|
+
parsed_mask = _parse_ipv4(netmask, name="--netmask")
|
|
2900
|
+
parsed_gateway = _parse_ipv4(gateway, name="--gateway")
|
|
2901
|
+
parsed_dns_primary = _parse_ipv4(dns, name="--dns")
|
|
2902
|
+
parsed_dns_secondary = _parse_ipv4(dns_alt, name="--dns-alt")
|
|
2903
|
+
|
|
2904
|
+
if parsed_ip is not None:
|
|
2905
|
+
network_payload["ip"] = parsed_ip
|
|
2906
|
+
if parsed_mask is not None:
|
|
2907
|
+
network_payload["netmask"] = parsed_mask
|
|
2908
|
+
if parsed_gateway is not None:
|
|
2909
|
+
network_payload["gateway"] = parsed_gateway
|
|
2910
|
+
|
|
2911
|
+
dns_entries = [
|
|
2912
|
+
entry for entry in (parsed_dns_primary, parsed_dns_secondary) if entry
|
|
2913
|
+
]
|
|
2914
|
+
if dns_entries:
|
|
2915
|
+
network_payload["dns"] = dns_entries
|
|
2916
|
+
|
|
2917
|
+
if network_payload:
|
|
2918
|
+
if wifi_payload.get("dhcp") is True:
|
|
2919
|
+
raise typer.BadParameter(
|
|
2920
|
+
"Static network options cannot be combined with --dhcp"
|
|
2921
|
+
)
|
|
2922
|
+
wifi_payload.setdefault("dhcp", False)
|
|
2923
|
+
wifi_payload["network"] = network_payload
|
|
2924
|
+
|
|
2925
|
+
resolved_port = _require_port(port)
|
|
2926
|
+
action = "Updating" if wifi_payload else "Querying"
|
|
2927
|
+
with spinner(f"{action} wifi.cfg over {resolved_port}"):
|
|
2928
|
+
try:
|
|
2929
|
+
with _open_serial_client(
|
|
2930
|
+
resolved_port,
|
|
2931
|
+
baudrate=baudrate,
|
|
2932
|
+
timeout=timeout,
|
|
2933
|
+
logger=resources.get("logger"),
|
|
2934
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
2935
|
+
) as client:
|
|
2936
|
+
response = client.wifi_cfg(wifi_payload if wifi_payload else None)
|
|
2937
|
+
except ShuttleSerialError as exc:
|
|
2938
|
+
console.print(f"[red]{exc}[/]")
|
|
2939
|
+
raise typer.Exit(1) from exc
|
|
2940
|
+
|
|
2941
|
+
_render_payload_response("wifi.cfg", response)
|
|
2942
|
+
|
|
2943
|
+
|
|
2331
2944
|
@app.command("uart-tx")
|
|
2332
2945
|
def uart_tx_command(
|
|
2333
2946
|
ctx: typer.Context,
|
|
@@ -2396,7 +3009,7 @@ def uart_tx_command(
|
|
|
2396
3009
|
byte_label = "byte" if payload_len == 1 else "bytes"
|
|
2397
3010
|
with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
|
|
2398
3011
|
try:
|
|
2399
|
-
with
|
|
3012
|
+
with _open_serial_client(
|
|
2400
3013
|
resolved_port,
|
|
2401
3014
|
baudrate=baudrate,
|
|
2402
3015
|
timeout=timeout,
|
|
@@ -2469,7 +3082,7 @@ def uart_rx_command(
|
|
|
2469
3082
|
|
|
2470
3083
|
events_seen = 0
|
|
2471
3084
|
try:
|
|
2472
|
-
with
|
|
3085
|
+
with _open_serial_client(
|
|
2473
3086
|
resolved_port,
|
|
2474
3087
|
baudrate=baudrate,
|
|
2475
3088
|
timeout=timeout,
|
|
@@ -2536,13 +3149,14 @@ def power_command(
|
|
|
2536
3149
|
|
|
2537
3150
|
with spinner(f"{action} power over {resolved_port}"):
|
|
2538
3151
|
try:
|
|
2539
|
-
with
|
|
3152
|
+
with _open_serial_client(
|
|
2540
3153
|
resolved_port,
|
|
2541
3154
|
baudrate=baudrate,
|
|
2542
3155
|
timeout=timeout,
|
|
2543
3156
|
logger=resources.get("logger"),
|
|
2544
3157
|
seq_tracker=resources.get("seq_tracker"),
|
|
2545
3158
|
) as client:
|
|
3159
|
+
_flush_client_input(client)
|
|
2546
3160
|
method = getattr(client, method_name)
|
|
2547
3161
|
response = method()
|
|
2548
3162
|
except ShuttleSerialError as exc:
|
|
@@ -2576,6 +3190,11 @@ def flash_command(
|
|
|
2576
3190
|
"--erase-first/--no-erase-first",
|
|
2577
3191
|
help="Erase the entire flash before writing",
|
|
2578
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
|
+
),
|
|
2579
3198
|
):
|
|
2580
3199
|
"""Flash the bundled firmware image to the devboard."""
|
|
2581
3200
|
|
|
@@ -2599,6 +3218,24 @@ def flash_command(
|
|
|
2599
3218
|
console.print(f"[red]{exc}[/]")
|
|
2600
3219
|
raise typer.Exit(1) from exc
|
|
2601
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
|
+
|
|
2602
3239
|
label = str(manifest.get("label", board))
|
|
2603
3240
|
console.print(
|
|
2604
3241
|
f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
|
|
@@ -2622,7 +3259,7 @@ def get_info(
|
|
|
2622
3259
|
resolved_port = _require_port(port)
|
|
2623
3260
|
with spinner(f"Querying get.info over {resolved_port}"):
|
|
2624
3261
|
try:
|
|
2625
|
-
with
|
|
3262
|
+
with _open_serial_client(
|
|
2626
3263
|
resolved_port,
|
|
2627
3264
|
baudrate=baudrate,
|
|
2628
3265
|
timeout=timeout,
|
|
@@ -2653,7 +3290,7 @@ def ping(
|
|
|
2653
3290
|
resolved_port = _require_port(port)
|
|
2654
3291
|
with spinner(f"Pinging device over {resolved_port}"):
|
|
2655
3292
|
try:
|
|
2656
|
-
with
|
|
3293
|
+
with _open_serial_client(
|
|
2657
3294
|
resolved_port,
|
|
2658
3295
|
baudrate=baudrate,
|
|
2659
3296
|
timeout=timeout,
|