lr-shuttle 0.1.0__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.1.0.dist-info/METADATA +244 -0
- lr_shuttle-0.1.0.dist-info/RECORD +10 -0
- lr_shuttle-0.1.0.dist-info/WHEEL +5 -0
- lr_shuttle-0.1.0.dist-info/entry_points.txt +2 -0
- lr_shuttle-0.1.0.dist-info/top_level.txt +1 -0
- shuttle/cli.py +1820 -0
- shuttle/constants.py +41 -0
- shuttle/prodtest.py +120 -0
- shuttle/serial_client.py +478 -0
- shuttle/timo.py +499 -0
shuttle/cli.py
ADDED
|
@@ -0,0 +1,1820 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.pretty import Pretty
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from . import prodtest, timo
|
|
19
|
+
from .constants import (
|
|
20
|
+
DEFAULT_BAUD,
|
|
21
|
+
DEFAULT_TIMEOUT,
|
|
22
|
+
SPI_CHOICE_FIELDS,
|
|
23
|
+
UART_PARITY_ALIASES,
|
|
24
|
+
)
|
|
25
|
+
from .serial_client import (
|
|
26
|
+
NDJSONSerialClient,
|
|
27
|
+
SerialLogger,
|
|
28
|
+
SequenceTracker,
|
|
29
|
+
ShuttleSerialError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
add_completion=False, no_args_is_help=True, help="Shuttle command-line utility"
|
|
34
|
+
)
|
|
35
|
+
timo_app = typer.Typer(help="TiMo SPI helpers")
|
|
36
|
+
app.add_typer(timo_app, name="timo", help="Interact with TiMo over SPI")
|
|
37
|
+
prodtest_app = typer.Typer(help="Prodtest SPI helpers")
|
|
38
|
+
app.add_typer(
|
|
39
|
+
prodtest_app, name="prodtest", help="Interact with prodtest firmware over SPI"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
# Backwards-compatible aliases for tests and external callers
|
|
45
|
+
_SerialLogger = SerialLogger
|
|
46
|
+
_SequenceTracker = SequenceTracker
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ctx_resources(ctx: typer.Context) -> Dict[str, Optional[object]]:
|
|
50
|
+
return ctx.obj or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def spinner(message: str, enabled: bool = True):
|
|
55
|
+
"""Show a Rich spinner while the body executes."""
|
|
56
|
+
|
|
57
|
+
if enabled and sys.stdout.isatty():
|
|
58
|
+
with console.status(message, spinner="dots"):
|
|
59
|
+
yield
|
|
60
|
+
else:
|
|
61
|
+
yield
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normalize_choice(value: Optional[str], *, name: str) -> Optional[str]:
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
choices = SPI_CHOICE_FIELDS.get(name)
|
|
68
|
+
if choices is None:
|
|
69
|
+
return value
|
|
70
|
+
normalized = value.lower()
|
|
71
|
+
if normalized not in choices:
|
|
72
|
+
allowed = ", ".join(sorted(choices))
|
|
73
|
+
raise typer.BadParameter(f"{name} must be one of: {allowed}")
|
|
74
|
+
return normalized
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_uart_parity(value: Optional[str]) -> Optional[str]:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
trimmed = value.strip().lower()
|
|
81
|
+
if not trimmed:
|
|
82
|
+
return None
|
|
83
|
+
mapped = UART_PARITY_ALIASES.get(trimmed)
|
|
84
|
+
if mapped is not None:
|
|
85
|
+
return mapped
|
|
86
|
+
first = trimmed[0]
|
|
87
|
+
if first in ("n", "e", "o"):
|
|
88
|
+
return first
|
|
89
|
+
allowed = ", ".join(sorted({"n", "e", "o", "none", "even", "odd"}))
|
|
90
|
+
raise typer.BadParameter(f"parity must be one of: {allowed}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_hex_payload(value: str) -> str:
|
|
94
|
+
cleaned = "".join(ch for ch in value if not ch.isspace() and ch != "_")
|
|
95
|
+
if cleaned.startswith(("0x", "0X")):
|
|
96
|
+
cleaned = cleaned[2:]
|
|
97
|
+
if not cleaned:
|
|
98
|
+
raise typer.BadParameter("UART payload cannot be empty")
|
|
99
|
+
if len(cleaned) % 2 != 0:
|
|
100
|
+
raise typer.BadParameter(
|
|
101
|
+
"UART payload must contain an even number of hex digits"
|
|
102
|
+
)
|
|
103
|
+
if any(ch not in string.hexdigits for ch in cleaned):
|
|
104
|
+
raise typer.BadParameter("UART payload must be valid hex")
|
|
105
|
+
return cleaned.lower()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_uart_payload(
|
|
109
|
+
*,
|
|
110
|
+
data_arg: Optional[str],
|
|
111
|
+
text_payload: Optional[str],
|
|
112
|
+
file_path: Optional[Path],
|
|
113
|
+
encoding: str,
|
|
114
|
+
append_newline: bool,
|
|
115
|
+
) -> Tuple[str, int]:
|
|
116
|
+
sources = {
|
|
117
|
+
"hex": data_arg not in (None, "-"),
|
|
118
|
+
"stdin": data_arg == "-",
|
|
119
|
+
"text": text_payload is not None,
|
|
120
|
+
"file": file_path is not None,
|
|
121
|
+
}
|
|
122
|
+
used = [name for name, active in sources.items() if active]
|
|
123
|
+
if not used:
|
|
124
|
+
raise typer.BadParameter(
|
|
125
|
+
"Provide UART data as HEX argument, --text, --file, or '-' for stdin"
|
|
126
|
+
)
|
|
127
|
+
if len(used) > 1:
|
|
128
|
+
raise typer.BadParameter(
|
|
129
|
+
"Specify exactly one UART payload source (HEX, --text, --file, or '-')"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
source = used[0]
|
|
133
|
+
if source == "hex":
|
|
134
|
+
if append_newline:
|
|
135
|
+
raise typer.BadParameter(
|
|
136
|
+
"--newline is only valid with --text, --file, or '-' payloads"
|
|
137
|
+
)
|
|
138
|
+
normalized = _normalize_hex_payload(data_arg or "")
|
|
139
|
+
length = len(normalized) // 2
|
|
140
|
+
if length == 0:
|
|
141
|
+
raise typer.BadParameter("UART payload cannot be empty")
|
|
142
|
+
return normalized, length
|
|
143
|
+
|
|
144
|
+
if source == "text":
|
|
145
|
+
assert text_payload is not None
|
|
146
|
+
try:
|
|
147
|
+
payload_bytes = text_payload.encode(encoding)
|
|
148
|
+
except LookupError as exc:
|
|
149
|
+
raise typer.BadParameter(f"Unknown encoding '{encoding}'") from exc
|
|
150
|
+
elif source == "file":
|
|
151
|
+
assert file_path is not None
|
|
152
|
+
try:
|
|
153
|
+
payload_bytes = file_path.read_bytes()
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
raise typer.BadParameter(f"Unable to read {file_path}: {exc}") from exc
|
|
156
|
+
else: # stdin
|
|
157
|
+
payload_bytes = sys.stdin.buffer.read()
|
|
158
|
+
|
|
159
|
+
if append_newline:
|
|
160
|
+
payload_bytes += b"\n"
|
|
161
|
+
|
|
162
|
+
if not payload_bytes:
|
|
163
|
+
raise typer.BadParameter("UART payload cannot be empty")
|
|
164
|
+
|
|
165
|
+
return payload_bytes.hex(), len(payload_bytes)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _require_port(port: Optional[str]) -> str:
|
|
169
|
+
if port:
|
|
170
|
+
return port
|
|
171
|
+
raise typer.BadParameter("Serial port is required (use --port or SHUTTLE_PORT)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_int_option(value: str, *, name: str) -> int:
|
|
175
|
+
try:
|
|
176
|
+
parsed = int(value, 0)
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
raise typer.BadParameter(
|
|
179
|
+
f"{name} must be an integer literal (e.g. 5 or 0x05)"
|
|
180
|
+
) from exc
|
|
181
|
+
if parsed < 0:
|
|
182
|
+
raise typer.BadParameter(f"{name} must be non-negative")
|
|
183
|
+
return parsed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_prodtest_mask(value: str) -> bytes:
|
|
187
|
+
try:
|
|
188
|
+
return prodtest.mask_from_hex(value)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _format_hex(hex_str: str) -> str:
|
|
194
|
+
if not hex_str:
|
|
195
|
+
return "—"
|
|
196
|
+
grouped = [hex_str[i : i + 2] for i in range(0, len(hex_str), 2)]
|
|
197
|
+
return " ".join(grouped)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _decode_hex_response(response: Dict[str, Any], *, label: str) -> bytes:
|
|
201
|
+
data = response.get("rx")
|
|
202
|
+
if not isinstance(data, str):
|
|
203
|
+
console.print(f"[red]{label} missing RX payload[/]")
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
try:
|
|
206
|
+
return bytes.fromhex(data)
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
console.print(f"[red]{label} RX payload is not valid hex[/]")
|
|
209
|
+
raise typer.Exit(1) from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _format_failed_pins_line(failed: Sequence[int]) -> str:
|
|
213
|
+
if not failed:
|
|
214
|
+
return "Test failed on pins: [ ]"
|
|
215
|
+
joined = ", ".join(str(pin) for pin in failed)
|
|
216
|
+
return f"Test failed on pins: [ {joined} ]"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _build_status_table(title: str, response: Dict[str, Any]) -> Table:
|
|
220
|
+
table = Table(title=title, show_header=False, box=None)
|
|
221
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
222
|
+
table.add_column("Value", style="white")
|
|
223
|
+
status = "OK" if response.get("ok") else "ERROR"
|
|
224
|
+
status_color = "green" if response.get("ok") else "red"
|
|
225
|
+
table.add_row("Status", f"[{status_color}]{status}[/]")
|
|
226
|
+
if response.get("err"):
|
|
227
|
+
err = response["err"]
|
|
228
|
+
code = err.get("code", "?")
|
|
229
|
+
msg = err.get("msg", "")
|
|
230
|
+
table.add_row("Error", f"{code}: {msg}")
|
|
231
|
+
return table
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _render_spi_response(
|
|
235
|
+
title: str, response: Dict[str, Any], *, command_label: str
|
|
236
|
+
) -> None:
|
|
237
|
+
table = _build_status_table(title, response)
|
|
238
|
+
table.add_row("Command", command_label)
|
|
239
|
+
if response.get("ok") and "rx" in response:
|
|
240
|
+
table.add_row("RX", _format_hex(response.get("rx", "")))
|
|
241
|
+
irq_level = response.get("irq")
|
|
242
|
+
if irq_level is not None:
|
|
243
|
+
table.add_row("IRQ level", str(irq_level))
|
|
244
|
+
console.print(table)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _render_read_reg_result(
|
|
248
|
+
result: timo.ReadRegisterResult, rx_frames: Sequence[str]
|
|
249
|
+
) -> None:
|
|
250
|
+
data_table = Table(title="TiMo read-reg", show_header=False, box=None)
|
|
251
|
+
data_table.add_column("Field", style="cyan", no_wrap=True)
|
|
252
|
+
data_table.add_column("Value", style="white")
|
|
253
|
+
data_table.add_row("Address", f"0x{result.address:02X}")
|
|
254
|
+
data_table.add_row("Length", str(result.length))
|
|
255
|
+
data_table.add_row("Data", timo.format_bytes(result.data))
|
|
256
|
+
data_table.add_row("IRQ (command)", f"0x{result.irq_flags_command:02X}")
|
|
257
|
+
data_table.add_row("IRQ (payload)", f"0x{result.irq_flags_payload:02X}")
|
|
258
|
+
data_table.add_row("Command RX", _format_hex(rx_frames[0]))
|
|
259
|
+
data_table.add_row("Payload RX", _format_hex(rx_frames[1]))
|
|
260
|
+
console.print(data_table)
|
|
261
|
+
|
|
262
|
+
warnings: List[str] = []
|
|
263
|
+
for label, value in ("command", result.irq_flags_command), (
|
|
264
|
+
"payload",
|
|
265
|
+
result.irq_flags_payload,
|
|
266
|
+
):
|
|
267
|
+
if timo.requires_restart(value):
|
|
268
|
+
warnings.append(
|
|
269
|
+
f"IRQ bit7 asserted during {label} phase — resend the entire sequence per TiMo spec."
|
|
270
|
+
)
|
|
271
|
+
if warnings:
|
|
272
|
+
console.print(
|
|
273
|
+
Panel("\n".join(warnings), title="IRQ warning", border_style="yellow")
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _render_write_reg_result(
|
|
278
|
+
result: timo.WriteRegisterResult, rx_frames: Sequence[str]
|
|
279
|
+
) -> None:
|
|
280
|
+
data_table = Table(title="TiMo write-reg", show_header=False, box=None)
|
|
281
|
+
data_table.add_row("Address", f"0x{result.address:02X}")
|
|
282
|
+
data_table.add_row("Data written", timo.format_bytes(result.data))
|
|
283
|
+
data_table.add_row("IRQ flags (cmd)", f"0x{result.irq_flags_command:02X}")
|
|
284
|
+
data_table.add_row("IRQ flags (payload)", f"0x{result.irq_flags_payload:02X}")
|
|
285
|
+
console.print(data_table)
|
|
286
|
+
for label, value in zip(
|
|
287
|
+
["command", "payload"], [result.irq_flags_command, result.irq_flags_payload]
|
|
288
|
+
):
|
|
289
|
+
if timo.requires_restart(value):
|
|
290
|
+
console.print(
|
|
291
|
+
f"[yellow]IRQ bit7 asserted during {label} phase — resend the entire sequence per TiMo spec.[/]"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _render_read_dmx_result(result, rx_frames):
|
|
296
|
+
data_table = Table(title="TiMo read-dmx", show_header=False, box=None)
|
|
297
|
+
data_table.add_row("Length", str(result.length))
|
|
298
|
+
data_table.add_row("Data", timo.format_bytes(result.data))
|
|
299
|
+
data_table.add_row("IRQ (command phase)", f"0x{result.irq_flags_command:02X}")
|
|
300
|
+
data_table.add_row("IRQ (payload phase)", f"0x{result.irq_flags_payload:02X}")
|
|
301
|
+
console.print(data_table)
|
|
302
|
+
for label, value in zip(
|
|
303
|
+
["command", "payload"], [result.irq_flags_command, result.irq_flags_payload]
|
|
304
|
+
):
|
|
305
|
+
if timo.requires_restart(value):
|
|
306
|
+
console.print(
|
|
307
|
+
f"[yellow]IRQ bit7 asserted during {label} phase — resend the entire sequence per TiMo spec.[/]"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _execute_timo_sequence(
|
|
312
|
+
*,
|
|
313
|
+
port: Optional[str],
|
|
314
|
+
baudrate: int,
|
|
315
|
+
timeout: float,
|
|
316
|
+
sequence: Sequence[Dict[str, Any]],
|
|
317
|
+
spinner_label: str,
|
|
318
|
+
logger: Optional[SerialLogger],
|
|
319
|
+
seq_tracker: Optional[SequenceTracker],
|
|
320
|
+
) -> List[Dict[str, Any]]:
|
|
321
|
+
resolved_port = _require_port(port)
|
|
322
|
+
responses: List[Dict[str, Any]] = []
|
|
323
|
+
with spinner(f"{spinner_label} over {resolved_port}"):
|
|
324
|
+
try:
|
|
325
|
+
with NDJSONSerialClient(
|
|
326
|
+
resolved_port,
|
|
327
|
+
baudrate=baudrate,
|
|
328
|
+
timeout=timeout,
|
|
329
|
+
logger=logger,
|
|
330
|
+
seq_tracker=seq_tracker,
|
|
331
|
+
) as client:
|
|
332
|
+
for transfer in sequence:
|
|
333
|
+
response = client.spi_xfer(**transfer)
|
|
334
|
+
responses.append(response)
|
|
335
|
+
if not response.get("ok"):
|
|
336
|
+
break
|
|
337
|
+
except ShuttleSerialError as exc:
|
|
338
|
+
console.print(f"[red]{exc}[/]")
|
|
339
|
+
raise typer.Exit(1) from exc
|
|
340
|
+
return responses
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _render_payload_response(
|
|
344
|
+
title: str, response: Dict[str, Any], *, drop_fields: Optional[Set[str]] = None
|
|
345
|
+
) -> None:
|
|
346
|
+
table = _build_status_table(title, response)
|
|
347
|
+
console.print(table)
|
|
348
|
+
if not response.get("ok"):
|
|
349
|
+
return
|
|
350
|
+
drop = drop_fields if drop_fields is not None else {"type", "id", "ok"}
|
|
351
|
+
payload = {k: v for k, v in response.items() if k not in drop}
|
|
352
|
+
if not payload:
|
|
353
|
+
console.print("[yellow]Device returned no additional info[/]")
|
|
354
|
+
return
|
|
355
|
+
console.print(
|
|
356
|
+
Panel(
|
|
357
|
+
Pretty(payload, expand_all=True),
|
|
358
|
+
title=f"{title} payload",
|
|
359
|
+
border_style="cyan",
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _render_info_response(response: Dict[str, Any]) -> None:
|
|
365
|
+
_render_payload_response("get.info", response)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _render_ping_response(response: Dict[str, Any]) -> None:
|
|
369
|
+
_render_payload_response("ping", response)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.callback()
|
|
373
|
+
def main(
|
|
374
|
+
ctx: typer.Context,
|
|
375
|
+
log: Optional[Path] = typer.Option(
|
|
376
|
+
None,
|
|
377
|
+
"--log",
|
|
378
|
+
help="Append raw serial RX/TX lines with timestamps to the given file",
|
|
379
|
+
show_default=False,
|
|
380
|
+
metavar="FILE",
|
|
381
|
+
),
|
|
382
|
+
seq_meta: Optional[Path] = typer.Option(
|
|
383
|
+
None,
|
|
384
|
+
"--seq-meta",
|
|
385
|
+
help="Persist last observed device sequence number to ensure gap detection across runs",
|
|
386
|
+
show_default=False,
|
|
387
|
+
metavar="FILE",
|
|
388
|
+
),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Interact with the Shuttle devboard over the JSON serial link."""
|
|
391
|
+
|
|
392
|
+
logger = SerialLogger(log) if log else None
|
|
393
|
+
tracker: Optional[SequenceTracker]
|
|
394
|
+
try:
|
|
395
|
+
tracker = (
|
|
396
|
+
SequenceTracker(seq_meta) if seq_meta is not None else SequenceTracker()
|
|
397
|
+
)
|
|
398
|
+
except ValueError as exc:
|
|
399
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
400
|
+
ctx.obj = {"logger": logger, "seq_tracker": tracker}
|
|
401
|
+
if logger is not None:
|
|
402
|
+
atexit.register(logger.close)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@timo_app.command("nop")
|
|
406
|
+
def timo_nop(
|
|
407
|
+
ctx: typer.Context,
|
|
408
|
+
port: Optional[str] = typer.Option(
|
|
409
|
+
None,
|
|
410
|
+
"--port",
|
|
411
|
+
envvar="SHUTTLE_PORT",
|
|
412
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
413
|
+
),
|
|
414
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
415
|
+
timeout: float = typer.Option(
|
|
416
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
417
|
+
),
|
|
418
|
+
):
|
|
419
|
+
"""Send a TiMo NOP SPI transfer over the Shuttle link."""
|
|
420
|
+
|
|
421
|
+
resources = _ctx_resources(ctx)
|
|
422
|
+
responses = _execute_timo_sequence(
|
|
423
|
+
port=port,
|
|
424
|
+
baudrate=baudrate,
|
|
425
|
+
timeout=timeout,
|
|
426
|
+
sequence=timo.nop_sequence(),
|
|
427
|
+
spinner_label="Sending TiMo NOP",
|
|
428
|
+
logger=resources.get("logger"),
|
|
429
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
430
|
+
)
|
|
431
|
+
if not responses:
|
|
432
|
+
console.print("[red]Device returned no response[/]")
|
|
433
|
+
raise typer.Exit(1)
|
|
434
|
+
_render_spi_response("TiMo NOP", responses[0], command_label="spi.xfer (NOP)")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@timo_app.command("read-reg")
|
|
438
|
+
def timo_read_reg(
|
|
439
|
+
ctx: typer.Context,
|
|
440
|
+
address: str = typer.Option(
|
|
441
|
+
..., "--addr", "--address", help="Register address (decimal or 0x-prefixed)"
|
|
442
|
+
),
|
|
443
|
+
length: int = typer.Option(
|
|
444
|
+
1,
|
|
445
|
+
"--length",
|
|
446
|
+
min=1,
|
|
447
|
+
max=timo.READ_REG_MAX_LEN,
|
|
448
|
+
help=f"Bytes to read (1..{timo.READ_REG_MAX_LEN})",
|
|
449
|
+
),
|
|
450
|
+
port: Optional[str] = typer.Option(
|
|
451
|
+
None,
|
|
452
|
+
"--port",
|
|
453
|
+
envvar="SHUTTLE_PORT",
|
|
454
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
455
|
+
),
|
|
456
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
457
|
+
timeout: float = typer.Option(
|
|
458
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
459
|
+
),
|
|
460
|
+
):
|
|
461
|
+
"""Read a TiMo register via a two-phase SPI sequence."""
|
|
462
|
+
|
|
463
|
+
resources = _ctx_resources(ctx)
|
|
464
|
+
addr_value = _parse_int_option(address, name="address")
|
|
465
|
+
try:
|
|
466
|
+
sequence = timo.read_reg_sequence(addr_value, length)
|
|
467
|
+
except ValueError as exc:
|
|
468
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
469
|
+
|
|
470
|
+
responses = _execute_timo_sequence(
|
|
471
|
+
port=port,
|
|
472
|
+
baudrate=baudrate,
|
|
473
|
+
timeout=timeout,
|
|
474
|
+
sequence=sequence,
|
|
475
|
+
spinner_label=f"Reading TiMo register 0x{addr_value:02X}",
|
|
476
|
+
logger=resources.get("logger"),
|
|
477
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if not responses:
|
|
481
|
+
console.print("[red]Device returned no response[/]")
|
|
482
|
+
raise typer.Exit(1)
|
|
483
|
+
|
|
484
|
+
failed_idx = next(
|
|
485
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
486
|
+
)
|
|
487
|
+
if failed_idx is not None:
|
|
488
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
489
|
+
_render_spi_response(
|
|
490
|
+
f"TiMo read-reg ({phase})",
|
|
491
|
+
responses[failed_idx],
|
|
492
|
+
command_label=f"spi.xfer ({phase} phase)",
|
|
493
|
+
)
|
|
494
|
+
raise typer.Exit(1)
|
|
495
|
+
|
|
496
|
+
if len(responses) != len(sequence):
|
|
497
|
+
console.print("[red]Command halted before completing all SPI phases[/]")
|
|
498
|
+
raise typer.Exit(1)
|
|
499
|
+
|
|
500
|
+
rx_frames = [resp.get("rx", "") for resp in responses]
|
|
501
|
+
try:
|
|
502
|
+
parsed = timo.parse_read_reg_response(addr_value, length, rx_frames)
|
|
503
|
+
except ValueError as exc:
|
|
504
|
+
console.print(f"[red]Unable to parse read-reg response: {exc}[/]")
|
|
505
|
+
raise typer.Exit(1) from exc
|
|
506
|
+
|
|
507
|
+
_render_spi_response(
|
|
508
|
+
"TiMo read-reg",
|
|
509
|
+
responses[-1],
|
|
510
|
+
command_label="spi.xfer (payload phase)",
|
|
511
|
+
)
|
|
512
|
+
_render_read_reg_result(parsed, rx_frames)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@timo_app.command("status")
|
|
516
|
+
def timo_status(
|
|
517
|
+
ctx: typer.Context,
|
|
518
|
+
port: Optional[str] = typer.Option(
|
|
519
|
+
None,
|
|
520
|
+
"--port",
|
|
521
|
+
envvar="SHUTTLE_PORT",
|
|
522
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
523
|
+
),
|
|
524
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
525
|
+
timeout: float = typer.Option(
|
|
526
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
527
|
+
),
|
|
528
|
+
):
|
|
529
|
+
"""Read and render the TiMo STATUS register with bit breakdown."""
|
|
530
|
+
|
|
531
|
+
resources = _ctx_resources(ctx)
|
|
532
|
+
reg_meta = timo.REGISTER_MAP["STATUS"]
|
|
533
|
+
length = reg_meta.get("length", 1)
|
|
534
|
+
sequence = timo.read_reg_sequence(reg_meta["address"], length)
|
|
535
|
+
|
|
536
|
+
responses = _execute_timo_sequence(
|
|
537
|
+
port=port,
|
|
538
|
+
baudrate=baudrate,
|
|
539
|
+
timeout=timeout,
|
|
540
|
+
sequence=sequence,
|
|
541
|
+
spinner_label="Reading TiMo STATUS",
|
|
542
|
+
logger=resources.get("logger"),
|
|
543
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
544
|
+
)
|
|
545
|
+
if not responses or not responses[-1].get("ok"):
|
|
546
|
+
console.print("[red]Failed to read STATUS register[/]")
|
|
547
|
+
raise typer.Exit(1)
|
|
548
|
+
|
|
549
|
+
rx = responses[-1].get("rx", "")
|
|
550
|
+
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
551
|
+
irq_flags = payload[:1]
|
|
552
|
+
data = payload[1:] if len(payload) > 1 else b""
|
|
553
|
+
status_byte = data[0] if data else 0
|
|
554
|
+
|
|
555
|
+
table = Table(title="TiMo STATUS (0x01)", show_header=False, box=None)
|
|
556
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
557
|
+
table.add_column("Value", style="white")
|
|
558
|
+
table.add_row("Raw", f"0x{status_byte:02X}")
|
|
559
|
+
table.add_row("IRQ flags", timo.format_bytes(irq_flags))
|
|
560
|
+
|
|
561
|
+
fields = reg_meta["fields"]
|
|
562
|
+
|
|
563
|
+
def bit_set(bit: int) -> bool:
|
|
564
|
+
return bool(status_byte & (1 << bit))
|
|
565
|
+
|
|
566
|
+
for name, meta in fields.items():
|
|
567
|
+
lo, hi = meta["bits"]
|
|
568
|
+
if lo != hi:
|
|
569
|
+
val = (status_byte >> lo) & ((1 << (hi - lo + 1)) - 1)
|
|
570
|
+
display = f"{val}"
|
|
571
|
+
else:
|
|
572
|
+
display = "ON" if bit_set(lo) else "off"
|
|
573
|
+
table.add_row(name, display)
|
|
574
|
+
|
|
575
|
+
console.print(table)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@timo_app.command("link")
|
|
579
|
+
def timo_link(
|
|
580
|
+
ctx: typer.Context,
|
|
581
|
+
port: Optional[str] = typer.Option(
|
|
582
|
+
None,
|
|
583
|
+
"--port",
|
|
584
|
+
envvar="SHUTTLE_PORT",
|
|
585
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
586
|
+
),
|
|
587
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
588
|
+
timeout: float = typer.Option(
|
|
589
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
590
|
+
),
|
|
591
|
+
):
|
|
592
|
+
"""Force TiMo into link mode by setting RF_LINK bit in STATUS register."""
|
|
593
|
+
|
|
594
|
+
resources = _ctx_resources(ctx)
|
|
595
|
+
# Set RF_LINK bit in STATUS.
|
|
596
|
+
sequence = timo.write_reg_sequence(0x01, bytes([0x02])) # RF_LINK bit set
|
|
597
|
+
responses = _execute_timo_sequence(
|
|
598
|
+
port=port,
|
|
599
|
+
baudrate=baudrate,
|
|
600
|
+
timeout=timeout,
|
|
601
|
+
sequence=sequence,
|
|
602
|
+
spinner_label="Setting TiMo RF_LINK",
|
|
603
|
+
logger=resources.get("logger"),
|
|
604
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
605
|
+
)
|
|
606
|
+
if (
|
|
607
|
+
not responses
|
|
608
|
+
or len(responses) < len(sequence)
|
|
609
|
+
or not all(resp.get("ok") for resp in responses)
|
|
610
|
+
):
|
|
611
|
+
console.print("[red]Failed to set RF_LINK[/]")
|
|
612
|
+
raise typer.Exit(1)
|
|
613
|
+
|
|
614
|
+
table = Table(title="TiMo link", show_header=False, box=None)
|
|
615
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
616
|
+
table.add_column("Value", style="white")
|
|
617
|
+
table.add_row("Register", "STATUS (0x01) RF_LINK=1")
|
|
618
|
+
table.add_row("Result", "[green]OK[/]")
|
|
619
|
+
console.print(table)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@timo_app.command("unlink")
|
|
623
|
+
def timo_unlink(
|
|
624
|
+
ctx: typer.Context,
|
|
625
|
+
port: Optional[str] = typer.Option(
|
|
626
|
+
None,
|
|
627
|
+
"--port",
|
|
628
|
+
envvar="SHUTTLE_PORT",
|
|
629
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
630
|
+
),
|
|
631
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
632
|
+
timeout: float = typer.Option(
|
|
633
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
634
|
+
),
|
|
635
|
+
):
|
|
636
|
+
"""Unlink TiMo by setting the LINKED bit in STATUS to 1 (write-to-unlink)."""
|
|
637
|
+
|
|
638
|
+
resources = _ctx_resources(ctx)
|
|
639
|
+
status_sequence = timo.write_reg_sequence(0x01, bytes([0x01])) # LINKED bit set
|
|
640
|
+
responses = _execute_timo_sequence(
|
|
641
|
+
port=port,
|
|
642
|
+
baudrate=baudrate,
|
|
643
|
+
timeout=timeout,
|
|
644
|
+
sequence=status_sequence,
|
|
645
|
+
spinner_label="Clearing TiMo link",
|
|
646
|
+
logger=resources.get("logger"),
|
|
647
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
648
|
+
)
|
|
649
|
+
if not responses or not all(resp.get("ok") for resp in responses):
|
|
650
|
+
console.print("[red]Failed to unlink[/]")
|
|
651
|
+
raise typer.Exit(1)
|
|
652
|
+
|
|
653
|
+
table = Table(title="TiMo unlink", show_header=False, box=None)
|
|
654
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
655
|
+
table.add_column("Value", style="white")
|
|
656
|
+
table.add_row("Register", "STATUS (0x01) LINKED=1 (unlink)")
|
|
657
|
+
table.add_row("Result", "[green]OK[/]")
|
|
658
|
+
console.print(table)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@timo_app.command("antenna")
|
|
662
|
+
def timo_antenna(
|
|
663
|
+
ctx: typer.Context,
|
|
664
|
+
value: Optional[str] = typer.Argument(
|
|
665
|
+
None,
|
|
666
|
+
metavar="[on-board|ipex]",
|
|
667
|
+
help="Read current antenna when omitted; set antenna when provided",
|
|
668
|
+
),
|
|
669
|
+
port: Optional[str] = typer.Option(
|
|
670
|
+
None,
|
|
671
|
+
"--port",
|
|
672
|
+
envvar="SHUTTLE_PORT",
|
|
673
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
674
|
+
),
|
|
675
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
676
|
+
timeout: float = typer.Option(
|
|
677
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
678
|
+
),
|
|
679
|
+
):
|
|
680
|
+
"""Get or set the selected antenna (on-board or IPEX/u.FL)."""
|
|
681
|
+
|
|
682
|
+
resources = _ctx_resources(ctx)
|
|
683
|
+
reg_meta = timo.REGISTER_MAP["ANTENNA"]
|
|
684
|
+
spinner_label = "Reading TiMo antenna"
|
|
685
|
+
sequence = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 1))
|
|
686
|
+
|
|
687
|
+
set_value: Optional[int] = None
|
|
688
|
+
if value is not None:
|
|
689
|
+
normalized = value.strip().lower()
|
|
690
|
+
if normalized not in ("on-board", "ipex", "ipx", "u.fl", "u-fl", "ufl"):
|
|
691
|
+
raise typer.BadParameter("antenna must be 'on-board' or 'ipex'")
|
|
692
|
+
set_value = 0 if normalized == "on-board" else 1
|
|
693
|
+
spinner_label = f"Setting TiMo antenna to {normalized}"
|
|
694
|
+
sequence = timo.write_reg_sequence(reg_meta["address"], bytes([set_value]))
|
|
695
|
+
|
|
696
|
+
responses = _execute_timo_sequence(
|
|
697
|
+
port=port,
|
|
698
|
+
baudrate=baudrate,
|
|
699
|
+
timeout=timeout,
|
|
700
|
+
sequence=sequence,
|
|
701
|
+
spinner_label=spinner_label,
|
|
702
|
+
logger=resources.get("logger"),
|
|
703
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
704
|
+
)
|
|
705
|
+
if not responses or not responses[-1].get("ok"):
|
|
706
|
+
console.print("[red]Antenna operation failed[/]")
|
|
707
|
+
raise typer.Exit(1)
|
|
708
|
+
|
|
709
|
+
table = Table(
|
|
710
|
+
title="TiMo antenna",
|
|
711
|
+
show_header=False,
|
|
712
|
+
box=None,
|
|
713
|
+
)
|
|
714
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
715
|
+
table.add_column("Value", style="white")
|
|
716
|
+
|
|
717
|
+
if set_value is None:
|
|
718
|
+
rx = responses[-1].get("rx", "")
|
|
719
|
+
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
720
|
+
antenna_byte = payload[1] if len(payload) > 1 else 0 # skip IRQ flags
|
|
721
|
+
selection = "on-board" if (antenna_byte & 0x01) == 0 else "ipex"
|
|
722
|
+
table.add_row("Register", "ANTENNA (0x07)")
|
|
723
|
+
table.add_row("Selected", selection)
|
|
724
|
+
else:
|
|
725
|
+
table.add_row("Action", f"Set to {'on-board' if set_value == 0 else 'ipex'}")
|
|
726
|
+
table.add_row("Status", "[green]OK[/]")
|
|
727
|
+
|
|
728
|
+
console.print(table)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@timo_app.command("link-quality")
|
|
732
|
+
def timo_link_quality(
|
|
733
|
+
ctx: typer.Context,
|
|
734
|
+
port: Optional[str] = typer.Option(
|
|
735
|
+
None,
|
|
736
|
+
"--port",
|
|
737
|
+
envvar="SHUTTLE_PORT",
|
|
738
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
739
|
+
),
|
|
740
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
741
|
+
timeout: float = typer.Option(
|
|
742
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
743
|
+
),
|
|
744
|
+
):
|
|
745
|
+
"""Read TiMo link quality register and render packet delivery rate."""
|
|
746
|
+
|
|
747
|
+
resources = _ctx_resources(ctx)
|
|
748
|
+
reg_meta = timo.REGISTER_MAP["LINK_QUALITY"]
|
|
749
|
+
sequence = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 1))
|
|
750
|
+
responses = _execute_timo_sequence(
|
|
751
|
+
port=port,
|
|
752
|
+
baudrate=baudrate,
|
|
753
|
+
timeout=timeout,
|
|
754
|
+
sequence=sequence,
|
|
755
|
+
spinner_label="Reading TiMo link quality",
|
|
756
|
+
logger=resources.get("logger"),
|
|
757
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
758
|
+
)
|
|
759
|
+
if not responses or not responses[-1].get("ok"):
|
|
760
|
+
console.print("[red]Failed to read link quality[/]")
|
|
761
|
+
raise typer.Exit(1)
|
|
762
|
+
|
|
763
|
+
rx = responses[-1].get("rx", "")
|
|
764
|
+
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
765
|
+
pdr = payload[1] if len(payload) > 1 else 0 # skip IRQ flags
|
|
766
|
+
percent = pdr / 255 * 100
|
|
767
|
+
|
|
768
|
+
table = Table(title="TiMo Link Quality", show_header=False, box=None)
|
|
769
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
770
|
+
table.add_column("Value", style="white")
|
|
771
|
+
table.add_row("Register", "LINK_QUALITY (0x06)")
|
|
772
|
+
table.add_row("Raw", f"0x{pdr:02X}")
|
|
773
|
+
table.add_row("PDR", f"{percent:.1f}%")
|
|
774
|
+
console.print(table)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@timo_app.command("dmx")
|
|
778
|
+
def timo_dmx(
|
|
779
|
+
ctx: typer.Context,
|
|
780
|
+
window_size: Optional[str] = typer.Option(
|
|
781
|
+
None,
|
|
782
|
+
"--window-size",
|
|
783
|
+
help="DMX window size (0-65535)",
|
|
784
|
+
),
|
|
785
|
+
start_address: Optional[str] = typer.Option(
|
|
786
|
+
None,
|
|
787
|
+
"--start-address",
|
|
788
|
+
help="DMX window start address",
|
|
789
|
+
),
|
|
790
|
+
n_channels: Optional[str] = typer.Option(
|
|
791
|
+
None,
|
|
792
|
+
"--channels",
|
|
793
|
+
help="Number of channels to generate",
|
|
794
|
+
),
|
|
795
|
+
interslot_time: Optional[str] = typer.Option(
|
|
796
|
+
None,
|
|
797
|
+
"--interslot",
|
|
798
|
+
help="Interslot spacing in microseconds",
|
|
799
|
+
),
|
|
800
|
+
refresh_period: Optional[str] = typer.Option(
|
|
801
|
+
None,
|
|
802
|
+
"--refresh",
|
|
803
|
+
help="DMX frame length in microseconds",
|
|
804
|
+
),
|
|
805
|
+
enable: bool = typer.Option(
|
|
806
|
+
False, "--enable", help="Enable internal DMX generation", show_default=False
|
|
807
|
+
),
|
|
808
|
+
disable: bool = typer.Option(
|
|
809
|
+
False, "--disable", help="Disable internal DMX generation", show_default=False
|
|
810
|
+
),
|
|
811
|
+
port: Optional[str] = typer.Option(
|
|
812
|
+
None,
|
|
813
|
+
"--port",
|
|
814
|
+
envvar="SHUTTLE_PORT",
|
|
815
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
816
|
+
),
|
|
817
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
818
|
+
timeout: float = typer.Option(
|
|
819
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
820
|
+
),
|
|
821
|
+
):
|
|
822
|
+
"""Read or update TiMo DMX registers (window, spec, control)."""
|
|
823
|
+
|
|
824
|
+
resources = _ctx_resources(ctx)
|
|
825
|
+
desired_enable: Optional[bool] = True if enable else False if disable else None
|
|
826
|
+
|
|
827
|
+
def _parse_opt(value: Optional[str], name: str) -> Optional[int]:
|
|
828
|
+
return _parse_int_option(value, name=name) if value is not None else None
|
|
829
|
+
|
|
830
|
+
parsed_window = _parse_opt(window_size, "window_size")
|
|
831
|
+
parsed_start = _parse_opt(start_address, "start_address")
|
|
832
|
+
parsed_channels = _parse_opt(n_channels, "channels")
|
|
833
|
+
parsed_interslot = _parse_opt(interslot_time, "interslot_time")
|
|
834
|
+
parsed_refresh = _parse_opt(refresh_period, "refresh_period")
|
|
835
|
+
|
|
836
|
+
reg_window = timo.REGISTER_MAP["DMX_WINDOW"]
|
|
837
|
+
reg_spec = timo.REGISTER_MAP["DMX_SPEC"]
|
|
838
|
+
reg_ctrl = timo.REGISTER_MAP["DMX_CONTROL"]
|
|
839
|
+
|
|
840
|
+
resolved_port = _require_port(port)
|
|
841
|
+
|
|
842
|
+
def _read_register(client, reg_meta):
|
|
843
|
+
seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 1))
|
|
844
|
+
responses = [client.spi_xfer(**cmd) for cmd in seq]
|
|
845
|
+
rx = responses[-1].get("rx", "")
|
|
846
|
+
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
847
|
+
return payload[1:] if payload else b""
|
|
848
|
+
|
|
849
|
+
def _set_bits(base: int, lo: int, hi: int, value: int, total_bits: int) -> int:
|
|
850
|
+
width = hi - lo + 1
|
|
851
|
+
mask = ((1 << width) - 1) << (total_bits - hi - 1)
|
|
852
|
+
return (base & ~mask) | ((value & ((1 << width) - 1)) << (total_bits - hi - 1))
|
|
853
|
+
|
|
854
|
+
with NDJSONSerialClient(
|
|
855
|
+
resolved_port,
|
|
856
|
+
baudrate=baudrate,
|
|
857
|
+
timeout=timeout,
|
|
858
|
+
logger=resources.get("logger"),
|
|
859
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
860
|
+
) as client:
|
|
861
|
+
# Read current values
|
|
862
|
+
window_bytes = _read_register(client, reg_window)
|
|
863
|
+
spec_bytes = _read_register(client, reg_spec)
|
|
864
|
+
ctrl_bytes = _read_register(client, reg_ctrl)
|
|
865
|
+
|
|
866
|
+
window_int = int.from_bytes(
|
|
867
|
+
window_bytes.ljust(reg_window["length"], b"\x00"), "big"
|
|
868
|
+
)
|
|
869
|
+
spec_int = int.from_bytes(spec_bytes.ljust(reg_spec["length"], b"\x00"), "big")
|
|
870
|
+
ctrl_int = int.from_bytes(ctrl_bytes.ljust(reg_ctrl["length"], b"\x00"), "big")
|
|
871
|
+
|
|
872
|
+
# Apply updates if provided
|
|
873
|
+
any_change = False
|
|
874
|
+
total_window_bits = reg_window["length"] * 8
|
|
875
|
+
if parsed_window is not None:
|
|
876
|
+
window_int = _set_bits(
|
|
877
|
+
window_int,
|
|
878
|
+
*reg_window["fields"]["WINDOW_SIZE"]["bits"],
|
|
879
|
+
parsed_window,
|
|
880
|
+
total_window_bits,
|
|
881
|
+
)
|
|
882
|
+
any_change = True
|
|
883
|
+
if parsed_start is not None:
|
|
884
|
+
window_int = _set_bits(
|
|
885
|
+
window_int,
|
|
886
|
+
*reg_window["fields"]["START_ADDRESS"]["bits"],
|
|
887
|
+
parsed_start,
|
|
888
|
+
total_window_bits,
|
|
889
|
+
)
|
|
890
|
+
any_change = True
|
|
891
|
+
|
|
892
|
+
total_spec_bits = reg_spec["length"] * 8
|
|
893
|
+
if parsed_channels is not None:
|
|
894
|
+
spec_int = _set_bits(
|
|
895
|
+
spec_int,
|
|
896
|
+
*reg_spec["fields"]["N_CHANNELS"]["bits"],
|
|
897
|
+
parsed_channels,
|
|
898
|
+
total_spec_bits,
|
|
899
|
+
)
|
|
900
|
+
any_change = True
|
|
901
|
+
if parsed_interslot is not None:
|
|
902
|
+
spec_int = _set_bits(
|
|
903
|
+
spec_int,
|
|
904
|
+
*reg_spec["fields"]["INTERSLOT_TIME"]["bits"],
|
|
905
|
+
parsed_interslot,
|
|
906
|
+
total_spec_bits,
|
|
907
|
+
)
|
|
908
|
+
any_change = True
|
|
909
|
+
if parsed_refresh is not None:
|
|
910
|
+
spec_int = _set_bits(
|
|
911
|
+
spec_int,
|
|
912
|
+
*reg_spec["fields"]["REFRESH_PERIOD"]["bits"],
|
|
913
|
+
parsed_refresh,
|
|
914
|
+
total_spec_bits,
|
|
915
|
+
)
|
|
916
|
+
any_change = True
|
|
917
|
+
|
|
918
|
+
if desired_enable is not None:
|
|
919
|
+
total_ctrl_bits = reg_ctrl["length"] * 8
|
|
920
|
+
ctrl_int = _set_bits(
|
|
921
|
+
ctrl_int,
|
|
922
|
+
*reg_ctrl["fields"]["ENABLE"]["bits"],
|
|
923
|
+
1 if desired_enable else 0,
|
|
924
|
+
total_ctrl_bits,
|
|
925
|
+
)
|
|
926
|
+
any_change = True
|
|
927
|
+
|
|
928
|
+
if any_change:
|
|
929
|
+
# Write back registers that changed
|
|
930
|
+
write_sequences = [
|
|
931
|
+
timo.write_reg_sequence(
|
|
932
|
+
reg_window["address"],
|
|
933
|
+
window_int.to_bytes(reg_window["length"], "big"),
|
|
934
|
+
),
|
|
935
|
+
timo.write_reg_sequence(
|
|
936
|
+
reg_spec["address"], spec_int.to_bytes(reg_spec["length"], "big")
|
|
937
|
+
),
|
|
938
|
+
timo.write_reg_sequence(
|
|
939
|
+
reg_ctrl["address"], ctrl_int.to_bytes(reg_ctrl["length"], "big")
|
|
940
|
+
),
|
|
941
|
+
]
|
|
942
|
+
for seq in write_sequences:
|
|
943
|
+
for cmd in seq:
|
|
944
|
+
client.spi_xfer(**cmd)
|
|
945
|
+
|
|
946
|
+
# Prepare display values (after potential updates)
|
|
947
|
+
window_size_val = timo.slice_bits(
|
|
948
|
+
window_int.to_bytes(reg_window["length"], "big"),
|
|
949
|
+
*reg_window["fields"]["WINDOW_SIZE"]["bits"],
|
|
950
|
+
)
|
|
951
|
+
start_val = timo.slice_bits(
|
|
952
|
+
window_int.to_bytes(reg_window["length"], "big"),
|
|
953
|
+
*reg_window["fields"]["START_ADDRESS"]["bits"],
|
|
954
|
+
)
|
|
955
|
+
channels_val = timo.slice_bits(
|
|
956
|
+
spec_int.to_bytes(reg_spec["length"], "big"),
|
|
957
|
+
*reg_spec["fields"]["N_CHANNELS"]["bits"],
|
|
958
|
+
)
|
|
959
|
+
interslot_val = timo.slice_bits(
|
|
960
|
+
spec_int.to_bytes(reg_spec["length"], "big"),
|
|
961
|
+
*reg_spec["fields"]["INTERSLOT_TIME"]["bits"],
|
|
962
|
+
)
|
|
963
|
+
refresh_val = timo.slice_bits(
|
|
964
|
+
spec_int.to_bytes(reg_spec["length"], "big"),
|
|
965
|
+
*reg_spec["fields"]["REFRESH_PERIOD"]["bits"],
|
|
966
|
+
)
|
|
967
|
+
enable_val = bool(
|
|
968
|
+
timo.slice_bits(
|
|
969
|
+
ctrl_int.to_bytes(reg_ctrl["length"], "big"),
|
|
970
|
+
*reg_ctrl["fields"]["ENABLE"]["bits"],
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
table = Table(title="TiMo DMX", show_header=False, box=None)
|
|
975
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
976
|
+
table.add_column("Value", style="white")
|
|
977
|
+
table.add_row("Window size", str(window_size_val))
|
|
978
|
+
table.add_row("Start address", str(start_val))
|
|
979
|
+
table.add_row("Channels", str(channels_val))
|
|
980
|
+
table.add_row("Interslot (us)", str(interslot_val))
|
|
981
|
+
table.add_row("Refresh (us)", str(refresh_val))
|
|
982
|
+
table.add_row("Enable", "ON" if enable_val else "off")
|
|
983
|
+
console.print(table)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
@timo_app.command("radio-mode")
|
|
987
|
+
def timo_radio_mode(
|
|
988
|
+
ctx: typer.Context,
|
|
989
|
+
mode: Optional[str] = typer.Option(
|
|
990
|
+
None, "--mode", help="Set radio mode: rx or tx", case_sensitive=False
|
|
991
|
+
),
|
|
992
|
+
enable: bool = typer.Option(
|
|
993
|
+
False, "--enable", help="Enable wireless operation", show_default=False
|
|
994
|
+
),
|
|
995
|
+
disable: bool = typer.Option(
|
|
996
|
+
False, "--disable", help="Disable wireless operation", show_default=False
|
|
997
|
+
),
|
|
998
|
+
port: Optional[str] = typer.Option(
|
|
999
|
+
None,
|
|
1000
|
+
"--port",
|
|
1001
|
+
envvar="SHUTTLE_PORT",
|
|
1002
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1003
|
+
),
|
|
1004
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1005
|
+
timeout: float = typer.Option(
|
|
1006
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1007
|
+
),
|
|
1008
|
+
):
|
|
1009
|
+
"""Read or set TiMo radio mode and wireless enable (CONFIG register)."""
|
|
1010
|
+
|
|
1011
|
+
if enable and disable:
|
|
1012
|
+
raise typer.BadParameter("Cannot specify both --enable and --disable")
|
|
1013
|
+
desired_mode: Optional[int] = None
|
|
1014
|
+
if mode:
|
|
1015
|
+
normalized = mode.strip().lower()
|
|
1016
|
+
if normalized not in ("rx", "tx"):
|
|
1017
|
+
raise typer.BadParameter("mode must be rx or tx")
|
|
1018
|
+
desired_mode = 0 if normalized == "rx" else 1
|
|
1019
|
+
|
|
1020
|
+
resources = _ctx_resources(ctx)
|
|
1021
|
+
reg_meta = timo.REGISTER_MAP["CONFIG"]
|
|
1022
|
+
seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 1))
|
|
1023
|
+
|
|
1024
|
+
responses = _execute_timo_sequence(
|
|
1025
|
+
port=port,
|
|
1026
|
+
baudrate=baudrate,
|
|
1027
|
+
timeout=timeout,
|
|
1028
|
+
sequence=seq,
|
|
1029
|
+
spinner_label="Reading TiMo CONFIG",
|
|
1030
|
+
logger=resources.get("logger"),
|
|
1031
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1032
|
+
)
|
|
1033
|
+
if not responses or not responses[-1].get("ok"):
|
|
1034
|
+
console.print("[red]Failed to read CONFIG[/]")
|
|
1035
|
+
raise typer.Exit(1)
|
|
1036
|
+
|
|
1037
|
+
payload = (
|
|
1038
|
+
bytes.fromhex(responses[-1].get("rx", ""))
|
|
1039
|
+
if isinstance(responses[-1].get("rx"), str)
|
|
1040
|
+
else b""
|
|
1041
|
+
)
|
|
1042
|
+
config_byte = payload[1] if len(payload) > 1 else 0
|
|
1043
|
+
new_byte = config_byte
|
|
1044
|
+
|
|
1045
|
+
if desired_mode is not None:
|
|
1046
|
+
new_byte = (new_byte & ~(1 << 1)) | (desired_mode << 1)
|
|
1047
|
+
if enable:
|
|
1048
|
+
new_byte = new_byte | (1 << 7)
|
|
1049
|
+
if disable:
|
|
1050
|
+
new_byte = new_byte & ~(1 << 7)
|
|
1051
|
+
|
|
1052
|
+
if new_byte != config_byte:
|
|
1053
|
+
write_seq = timo.write_reg_sequence(reg_meta["address"], bytes([new_byte]))
|
|
1054
|
+
for cmd in write_seq:
|
|
1055
|
+
resp = _execute_timo_sequence(
|
|
1056
|
+
port=port,
|
|
1057
|
+
baudrate=baudrate,
|
|
1058
|
+
timeout=timeout,
|
|
1059
|
+
sequence=[cmd],
|
|
1060
|
+
spinner_label="Writing TiMo CONFIG",
|
|
1061
|
+
logger=resources.get("logger"),
|
|
1062
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1063
|
+
)
|
|
1064
|
+
if not resp or not resp[-1].get("ok"):
|
|
1065
|
+
console.print("[red]Failed to update CONFIG[/]")
|
|
1066
|
+
raise typer.Exit(1)
|
|
1067
|
+
config_byte = new_byte
|
|
1068
|
+
|
|
1069
|
+
table = Table(title="TiMo Radio Mode", show_header=False, box=None)
|
|
1070
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
1071
|
+
table.add_column("Value", style="white")
|
|
1072
|
+
table.add_row("CONFIG (0x00)", f"0x{config_byte:02X}")
|
|
1073
|
+
mode_str = "tx" if config_byte & (1 << 1) else "rx"
|
|
1074
|
+
table.add_row("RADIO_TX_RX_MODE", mode_str)
|
|
1075
|
+
table.add_row("SPI_RDM", "enabled" if config_byte & (1 << 3) else "disabled")
|
|
1076
|
+
table.add_row("RADIO_ENABLE", "ON" if config_byte & (1 << 7) else "off")
|
|
1077
|
+
console.print(table)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
@timo_app.command("device-name")
|
|
1081
|
+
def timo_device_name(
|
|
1082
|
+
ctx: typer.Context,
|
|
1083
|
+
name: Optional[str] = typer.Argument(
|
|
1084
|
+
None,
|
|
1085
|
+
help="New device name (omit to read current). Will be truncated to register length.",
|
|
1086
|
+
),
|
|
1087
|
+
port: Optional[str] = typer.Option(
|
|
1088
|
+
None,
|
|
1089
|
+
"--port",
|
|
1090
|
+
envvar="SHUTTLE_PORT",
|
|
1091
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1092
|
+
),
|
|
1093
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1094
|
+
timeout: float = typer.Option(
|
|
1095
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1096
|
+
),
|
|
1097
|
+
):
|
|
1098
|
+
"""Read or write the TiMo device name register."""
|
|
1099
|
+
|
|
1100
|
+
resources = _ctx_resources(ctx)
|
|
1101
|
+
reg_meta = timo.REGISTER_MAP["DEVICE_NAME"]
|
|
1102
|
+
resolved_port = _require_port(port)
|
|
1103
|
+
|
|
1104
|
+
def _read_name(client) -> str:
|
|
1105
|
+
seq = timo.read_reg_sequence(reg_meta["address"], reg_meta.get("length", 16))
|
|
1106
|
+
responses = [client.spi_xfer(**cmd) for cmd in seq]
|
|
1107
|
+
rx = responses[-1].get("rx", "")
|
|
1108
|
+
payload = bytes.fromhex(rx) if isinstance(rx, str) else b""
|
|
1109
|
+
data = payload[1:] if payload else b""
|
|
1110
|
+
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
|
1111
|
+
|
|
1112
|
+
with NDJSONSerialClient(
|
|
1113
|
+
resolved_port,
|
|
1114
|
+
baudrate=baudrate,
|
|
1115
|
+
timeout=timeout,
|
|
1116
|
+
logger=resources.get("logger"),
|
|
1117
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1118
|
+
) as client:
|
|
1119
|
+
if name is None:
|
|
1120
|
+
current = _read_name(client)
|
|
1121
|
+
table = Table(title="TiMo device name", show_header=False, box=None)
|
|
1122
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
1123
|
+
table.add_column("Value", style="white")
|
|
1124
|
+
table.add_row("Current", current)
|
|
1125
|
+
console.print(table)
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
encoded = name.encode("ascii", errors="ignore")[: reg_meta["length"]]
|
|
1129
|
+
payload = encoded.ljust(reg_meta["length"], b"\x00")
|
|
1130
|
+
write_seq = timo.write_reg_sequence(reg_meta["address"], payload)
|
|
1131
|
+
for cmd in write_seq:
|
|
1132
|
+
resp = client.spi_xfer(**cmd)
|
|
1133
|
+
if not resp.get("ok"):
|
|
1134
|
+
console.print("[red]Failed to write device name[/]")
|
|
1135
|
+
raise typer.Exit(1)
|
|
1136
|
+
|
|
1137
|
+
updated = _read_name(client)
|
|
1138
|
+
|
|
1139
|
+
table = Table(title="TiMo device name", show_header=False, box=None)
|
|
1140
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
1141
|
+
table.add_column("Value", style="white")
|
|
1142
|
+
table.add_row("Updated", updated)
|
|
1143
|
+
console.print(table)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
@timo_app.command("write-reg")
|
|
1147
|
+
def timo_write_reg(
|
|
1148
|
+
ctx: typer.Context,
|
|
1149
|
+
address: str = typer.Option(
|
|
1150
|
+
..., "--addr", "--address", help="Register address (decimal or 0x-prefixed)"
|
|
1151
|
+
),
|
|
1152
|
+
data: str = typer.Option(..., "--data", help="Hex bytes to write (e.g. cafebabe)"),
|
|
1153
|
+
port: Optional[str] = typer.Option(
|
|
1154
|
+
None,
|
|
1155
|
+
"--port",
|
|
1156
|
+
envvar="SHUTTLE_PORT",
|
|
1157
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1158
|
+
),
|
|
1159
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1160
|
+
timeout: float = typer.Option(
|
|
1161
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1162
|
+
),
|
|
1163
|
+
):
|
|
1164
|
+
"""Write a TiMo register via a two-phase SPI sequence."""
|
|
1165
|
+
resources = _ctx_resources(ctx)
|
|
1166
|
+
addr_value = _parse_int_option(address, name="address")
|
|
1167
|
+
try:
|
|
1168
|
+
data_bytes = bytes.fromhex(data)
|
|
1169
|
+
except Exception as exc:
|
|
1170
|
+
raise typer.BadParameter(f"Invalid hex for data: {exc}") from exc
|
|
1171
|
+
try:
|
|
1172
|
+
sequence = timo.write_reg_sequence(addr_value, data_bytes)
|
|
1173
|
+
except ValueError as exc:
|
|
1174
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
1175
|
+
|
|
1176
|
+
responses = _execute_timo_sequence(
|
|
1177
|
+
port=port,
|
|
1178
|
+
baudrate=baudrate,
|
|
1179
|
+
timeout=timeout,
|
|
1180
|
+
sequence=sequence,
|
|
1181
|
+
spinner_label=f"Writing TiMo register 0x{addr_value:02X}",
|
|
1182
|
+
logger=resources.get("logger"),
|
|
1183
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
if not responses:
|
|
1187
|
+
console.print("[red]Device returned no response[/]")
|
|
1188
|
+
raise typer.Exit(1)
|
|
1189
|
+
|
|
1190
|
+
failed_idx = next(
|
|
1191
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1192
|
+
)
|
|
1193
|
+
if failed_idx is not None:
|
|
1194
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1195
|
+
_render_spi_response(
|
|
1196
|
+
f"TiMo write-reg ({phase})",
|
|
1197
|
+
responses[failed_idx],
|
|
1198
|
+
command_label=f"spi.xfer ({phase} phase)",
|
|
1199
|
+
)
|
|
1200
|
+
raise typer.Exit(1)
|
|
1201
|
+
|
|
1202
|
+
if len(responses) != len(sequence):
|
|
1203
|
+
console.print("[red]Command halted before completing all SPI phases[/]")
|
|
1204
|
+
raise typer.Exit(1)
|
|
1205
|
+
|
|
1206
|
+
rx_frames = [resp.get("rx", "") for resp in responses]
|
|
1207
|
+
try:
|
|
1208
|
+
parsed = timo.parse_write_reg_response(addr_value, data_bytes, rx_frames)
|
|
1209
|
+
except ValueError as exc:
|
|
1210
|
+
console.print(f"[red]Unable to parse write-reg response: {exc}[/]")
|
|
1211
|
+
raise typer.Exit(1) from exc
|
|
1212
|
+
|
|
1213
|
+
_render_spi_response(
|
|
1214
|
+
"TiMo write-reg",
|
|
1215
|
+
responses[-1],
|
|
1216
|
+
command_label="spi.xfer (payload phase)",
|
|
1217
|
+
)
|
|
1218
|
+
_render_write_reg_result(parsed, rx_frames)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
@timo_app.command("read-dmx")
|
|
1222
|
+
def timo_read_dmx(
|
|
1223
|
+
ctx: typer.Context,
|
|
1224
|
+
length: int = typer.Option(
|
|
1225
|
+
32,
|
|
1226
|
+
"--length",
|
|
1227
|
+
min=1,
|
|
1228
|
+
max=timo.DMX_READ_MAX_LEN,
|
|
1229
|
+
help=f"Bytes to read (1..{timo.DMX_READ_MAX_LEN})",
|
|
1230
|
+
),
|
|
1231
|
+
port: Optional[str] = typer.Option(
|
|
1232
|
+
None,
|
|
1233
|
+
"--port",
|
|
1234
|
+
envvar="SHUTTLE_PORT",
|
|
1235
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1236
|
+
),
|
|
1237
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1238
|
+
timeout: float = typer.Option(
|
|
1239
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1240
|
+
),
|
|
1241
|
+
):
|
|
1242
|
+
"""Read the latest received DMX values from TiMo via a two-phase SPI sequence."""
|
|
1243
|
+
resources = _ctx_resources(ctx)
|
|
1244
|
+
try:
|
|
1245
|
+
sequence = timo.read_dmx_sequence(length)
|
|
1246
|
+
except ValueError as exc:
|
|
1247
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
1248
|
+
|
|
1249
|
+
responses = _execute_timo_sequence(
|
|
1250
|
+
port=port,
|
|
1251
|
+
baudrate=baudrate,
|
|
1252
|
+
timeout=timeout,
|
|
1253
|
+
sequence=sequence,
|
|
1254
|
+
spinner_label=f"Reading DMX values (length={length})",
|
|
1255
|
+
logger=resources.get("logger"),
|
|
1256
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
if not responses:
|
|
1260
|
+
console.print("[red]Device returned no response[/]")
|
|
1261
|
+
raise typer.Exit(1)
|
|
1262
|
+
|
|
1263
|
+
failed_idx = next(
|
|
1264
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1265
|
+
)
|
|
1266
|
+
if failed_idx is not None:
|
|
1267
|
+
phase = "command" if failed_idx == 0 else "payload"
|
|
1268
|
+
_render_spi_response(
|
|
1269
|
+
f"TiMo read-dmx ({phase})",
|
|
1270
|
+
responses[failed_idx],
|
|
1271
|
+
command_label=f"spi.xfer ({phase} phase)",
|
|
1272
|
+
)
|
|
1273
|
+
raise typer.Exit(1)
|
|
1274
|
+
|
|
1275
|
+
if len(responses) != len(sequence):
|
|
1276
|
+
console.print("[red]Command halted before completing all SPI phases[/]")
|
|
1277
|
+
raise typer.Exit(1)
|
|
1278
|
+
|
|
1279
|
+
rx_frames = [resp.get("rx", "") for resp in responses]
|
|
1280
|
+
try:
|
|
1281
|
+
parsed = timo.parse_read_dmx_response(length, rx_frames)
|
|
1282
|
+
except ValueError as exc:
|
|
1283
|
+
console.print(f"[red]Unable to parse read-dmx response: {exc}[/]")
|
|
1284
|
+
raise typer.Exit(1) from exc
|
|
1285
|
+
|
|
1286
|
+
_render_spi_response(
|
|
1287
|
+
"TiMo read-dmx",
|
|
1288
|
+
responses[-1],
|
|
1289
|
+
command_label="spi.xfer (payload phase)",
|
|
1290
|
+
)
|
|
1291
|
+
_render_read_dmx_result(parsed, rx_frames)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
@prodtest_app.command("reset")
|
|
1295
|
+
def prodtest_reset(
|
|
1296
|
+
ctx: typer.Context,
|
|
1297
|
+
port: Optional[str] = typer.Option(
|
|
1298
|
+
None,
|
|
1299
|
+
"--port",
|
|
1300
|
+
envvar="SHUTTLE_PORT",
|
|
1301
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1302
|
+
),
|
|
1303
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1304
|
+
timeout: float = typer.Option(
|
|
1305
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1306
|
+
),
|
|
1307
|
+
):
|
|
1308
|
+
"""Send the prodtest '?' command to reset the attached module over SPI."""
|
|
1309
|
+
|
|
1310
|
+
resources = _ctx_resources(ctx)
|
|
1311
|
+
sequence = [prodtest.reset_transfer()]
|
|
1312
|
+
responses = _execute_timo_sequence(
|
|
1313
|
+
port=port,
|
|
1314
|
+
baudrate=baudrate,
|
|
1315
|
+
timeout=timeout,
|
|
1316
|
+
sequence=sequence,
|
|
1317
|
+
spinner_label="Sending prodtest reset",
|
|
1318
|
+
logger=resources.get("logger"),
|
|
1319
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1320
|
+
)
|
|
1321
|
+
if not responses:
|
|
1322
|
+
console.print("[red]Device returned no response[/]")
|
|
1323
|
+
raise typer.Exit(1)
|
|
1324
|
+
_render_spi_response(
|
|
1325
|
+
"prodtest reset", responses[0], command_label="spi.xfer (prodtest)"
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
@prodtest_app.command("ping")
|
|
1330
|
+
def prodtest_ping(
|
|
1331
|
+
ctx: typer.Context,
|
|
1332
|
+
port: Optional[str] = typer.Option(
|
|
1333
|
+
None,
|
|
1334
|
+
"--port",
|
|
1335
|
+
envvar="SHUTTLE_PORT",
|
|
1336
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1337
|
+
),
|
|
1338
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1339
|
+
timeout: float = typer.Option(
|
|
1340
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1341
|
+
),
|
|
1342
|
+
):
|
|
1343
|
+
"""Send the prodtest 'ping' command: send '+' and expect '-' back."""
|
|
1344
|
+
resources = _ctx_resources(ctx)
|
|
1345
|
+
sequence = prodtest.ping_sequence()
|
|
1346
|
+
responses = _execute_timo_sequence(
|
|
1347
|
+
port=port,
|
|
1348
|
+
baudrate=baudrate,
|
|
1349
|
+
timeout=timeout,
|
|
1350
|
+
sequence=sequence,
|
|
1351
|
+
spinner_label="Sending prodtest ping",
|
|
1352
|
+
logger=resources.get("logger"),
|
|
1353
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1354
|
+
)
|
|
1355
|
+
if not responses or len(responses) < 2:
|
|
1356
|
+
console.print("[red]Device returned no response[/]")
|
|
1357
|
+
raise typer.Exit(1)
|
|
1358
|
+
rx2 = responses[1].get("rx", "")
|
|
1359
|
+
if rx2 and isinstance(rx2, str):
|
|
1360
|
+
rx_bytes = bytes.fromhex(rx2)
|
|
1361
|
+
if rx_bytes and rx_bytes[0] == 0x2D: # ord('-')
|
|
1362
|
+
console.print("[green]Ping successful: got '-' response[/]")
|
|
1363
|
+
return
|
|
1364
|
+
console.print(f"[red]Ping failed: expected '-' (0x2D), got: {rx2}[/]")
|
|
1365
|
+
raise typer.Exit(1)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
@prodtest_app.command("io-self-test")
|
|
1369
|
+
def prodtest_io_self_test(
|
|
1370
|
+
ctx: typer.Context,
|
|
1371
|
+
pins_mask: str = typer.Argument(
|
|
1372
|
+
...,
|
|
1373
|
+
metavar="PINS",
|
|
1374
|
+
help="Eight-byte hex mask describing pins 1-64 (e.g. 0000000000000004)",
|
|
1375
|
+
),
|
|
1376
|
+
port: Optional[str] = typer.Option(
|
|
1377
|
+
None,
|
|
1378
|
+
"--port",
|
|
1379
|
+
envvar="SHUTTLE_PORT",
|
|
1380
|
+
help="Serial port (e.g., /dev/ttyACM0)",
|
|
1381
|
+
),
|
|
1382
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1383
|
+
timeout: float = typer.Option(
|
|
1384
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1385
|
+
),
|
|
1386
|
+
):
|
|
1387
|
+
"""Perform the prodtest GPIO self-test (opcode 'T')."""
|
|
1388
|
+
|
|
1389
|
+
resources = _ctx_resources(ctx)
|
|
1390
|
+
mask_bytes = _parse_prodtest_mask(pins_mask)
|
|
1391
|
+
sequence = prodtest.io_self_test(mask_bytes)
|
|
1392
|
+
responses = _execute_timo_sequence(
|
|
1393
|
+
port=port,
|
|
1394
|
+
baudrate=baudrate,
|
|
1395
|
+
timeout=timeout,
|
|
1396
|
+
sequence=sequence,
|
|
1397
|
+
spinner_label="Running prodtest IO self-test",
|
|
1398
|
+
logger=resources.get("logger"),
|
|
1399
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
if not responses:
|
|
1403
|
+
console.print("[red]Device returned no response[/]")
|
|
1404
|
+
raise typer.Exit(1)
|
|
1405
|
+
|
|
1406
|
+
if len(responses) != len(sequence):
|
|
1407
|
+
console.print(
|
|
1408
|
+
"[red]Prodtest command halted before completing all SPI phases[/]"
|
|
1409
|
+
)
|
|
1410
|
+
raise typer.Exit(1)
|
|
1411
|
+
|
|
1412
|
+
stage_labels = ("command", "result")
|
|
1413
|
+
failed_idx = next(
|
|
1414
|
+
(idx for idx, resp in enumerate(responses) if not resp.get("ok")), None
|
|
1415
|
+
)
|
|
1416
|
+
if failed_idx is not None:
|
|
1417
|
+
stage = stage_labels[failed_idx]
|
|
1418
|
+
_render_spi_response(
|
|
1419
|
+
f"prodtest io-self-test ({stage})",
|
|
1420
|
+
responses[failed_idx],
|
|
1421
|
+
command_label=f"spi.xfer (prodtest {stage})",
|
|
1422
|
+
)
|
|
1423
|
+
raise typer.Exit(1)
|
|
1424
|
+
|
|
1425
|
+
result_response = responses[-1]
|
|
1426
|
+
_render_spi_response(
|
|
1427
|
+
"prodtest io-self-test",
|
|
1428
|
+
result_response,
|
|
1429
|
+
command_label="spi.xfer (prodtest result)",
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
rx_bytes = _decode_hex_response(
|
|
1433
|
+
result_response, label="prodtest io-self-test result"
|
|
1434
|
+
)
|
|
1435
|
+
if len(rx_bytes) < prodtest.IO_SELF_TEST_MASK_LEN:
|
|
1436
|
+
console.print("[red]Prodtest response shorter than expected[/]")
|
|
1437
|
+
raise typer.Exit(1)
|
|
1438
|
+
|
|
1439
|
+
result_mask = rx_bytes[-prodtest.IO_SELF_TEST_MASK_LEN :]
|
|
1440
|
+
pins_hex = prodtest.mask_to_hex(mask_bytes)
|
|
1441
|
+
result_hex = prodtest.mask_to_hex(result_mask)
|
|
1442
|
+
failures = prodtest.failed_pins(mask_bytes, result_mask)
|
|
1443
|
+
|
|
1444
|
+
console.print(f"PINS TO TEST BASE16 ENCODED: {pins_hex}")
|
|
1445
|
+
console.print(f"RESULT OF TEST BASE16 ENCODED: {result_hex}")
|
|
1446
|
+
console.print(_format_failed_pins_line(failures))
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
@app.command("spi-cfg")
|
|
1450
|
+
def spi_cfg_command(
|
|
1451
|
+
ctx: typer.Context,
|
|
1452
|
+
port: Optional[str] = typer.Option(
|
|
1453
|
+
None,
|
|
1454
|
+
"--port",
|
|
1455
|
+
envvar="SHUTTLE_PORT",
|
|
1456
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1457
|
+
),
|
|
1458
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1459
|
+
timeout: float = typer.Option(
|
|
1460
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1461
|
+
),
|
|
1462
|
+
hz: Optional[int] = typer.Option(
|
|
1463
|
+
None,
|
|
1464
|
+
"--hz",
|
|
1465
|
+
help="SPI clock frequency in Hz",
|
|
1466
|
+
min=1,
|
|
1467
|
+
),
|
|
1468
|
+
setup_us: Optional[int] = typer.Option(
|
|
1469
|
+
None,
|
|
1470
|
+
"--setup-us",
|
|
1471
|
+
help="Delay after asserting CS, in microseconds",
|
|
1472
|
+
min=0,
|
|
1473
|
+
),
|
|
1474
|
+
cs_active: Optional[str] = typer.Option(
|
|
1475
|
+
None,
|
|
1476
|
+
"--cs-active",
|
|
1477
|
+
help="CS polarity (low/high)",
|
|
1478
|
+
),
|
|
1479
|
+
bit_order: Optional[str] = typer.Option(
|
|
1480
|
+
None,
|
|
1481
|
+
"--bit-order",
|
|
1482
|
+
help="Bit order (msb/lsb)",
|
|
1483
|
+
),
|
|
1484
|
+
byte_order: Optional[str] = typer.Option(
|
|
1485
|
+
None,
|
|
1486
|
+
"--byte-order",
|
|
1487
|
+
help="Byte order (big/little)",
|
|
1488
|
+
),
|
|
1489
|
+
clock_polarity: Optional[str] = typer.Option(
|
|
1490
|
+
None,
|
|
1491
|
+
"--clock-polarity",
|
|
1492
|
+
help="Clock polarity (idle_low/idle_high)",
|
|
1493
|
+
),
|
|
1494
|
+
clock_phase: Optional[str] = typer.Option(
|
|
1495
|
+
None,
|
|
1496
|
+
"--clock-phase",
|
|
1497
|
+
help="Clock phase (leading/trailing)",
|
|
1498
|
+
),
|
|
1499
|
+
):
|
|
1500
|
+
"""Query or update the devboard SPI defaults."""
|
|
1501
|
+
|
|
1502
|
+
resources = _ctx_resources(ctx)
|
|
1503
|
+
spi_payload: Dict[str, Any] = {}
|
|
1504
|
+
|
|
1505
|
+
str_fields = {
|
|
1506
|
+
"cs_active": cs_active,
|
|
1507
|
+
"bit_order": bit_order,
|
|
1508
|
+
"byte_order": byte_order,
|
|
1509
|
+
"clock_polarity": clock_polarity,
|
|
1510
|
+
"clock_phase": clock_phase,
|
|
1511
|
+
}
|
|
1512
|
+
for name, raw_value in str_fields.items():
|
|
1513
|
+
normalized = _normalize_choice(raw_value, name=name)
|
|
1514
|
+
if normalized is not None:
|
|
1515
|
+
spi_payload[name] = normalized
|
|
1516
|
+
|
|
1517
|
+
for name, numeric_value in (("hz", hz), ("setup_us", setup_us)):
|
|
1518
|
+
if numeric_value is not None:
|
|
1519
|
+
spi_payload[name] = numeric_value
|
|
1520
|
+
|
|
1521
|
+
resolved_port = _require_port(port)
|
|
1522
|
+
action = "Updating" if spi_payload else "Querying"
|
|
1523
|
+
with spinner(f"{action} spi.cfg over {resolved_port}"):
|
|
1524
|
+
try:
|
|
1525
|
+
with NDJSONSerialClient(
|
|
1526
|
+
resolved_port,
|
|
1527
|
+
baudrate=baudrate,
|
|
1528
|
+
timeout=timeout,
|
|
1529
|
+
logger=resources.get("logger"),
|
|
1530
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1531
|
+
) as client:
|
|
1532
|
+
response = client.spi_cfg(spi=spi_payload if spi_payload else None)
|
|
1533
|
+
except ShuttleSerialError as exc:
|
|
1534
|
+
console.print(f"[red]{exc}[/]")
|
|
1535
|
+
raise typer.Exit(1) from exc
|
|
1536
|
+
|
|
1537
|
+
_render_payload_response("spi.cfg", response)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
@app.command("spi-enable")
|
|
1541
|
+
def spi_enable_command(
|
|
1542
|
+
ctx: typer.Context,
|
|
1543
|
+
port: Optional[str] = typer.Option(
|
|
1544
|
+
None,
|
|
1545
|
+
"--port",
|
|
1546
|
+
envvar="SHUTTLE_PORT",
|
|
1547
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1548
|
+
),
|
|
1549
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1550
|
+
timeout: float = typer.Option(
|
|
1551
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1552
|
+
),
|
|
1553
|
+
):
|
|
1554
|
+
"""Enable SPI on the devboard using the persisted configuration."""
|
|
1555
|
+
|
|
1556
|
+
resources = _ctx_resources(ctx)
|
|
1557
|
+
resolved_port = _require_port(port)
|
|
1558
|
+
with spinner(f"Enabling SPI over {resolved_port}"):
|
|
1559
|
+
try:
|
|
1560
|
+
with NDJSONSerialClient(
|
|
1561
|
+
port=resolved_port,
|
|
1562
|
+
baudrate=baudrate,
|
|
1563
|
+
timeout=timeout,
|
|
1564
|
+
logger=resources.get("logger"),
|
|
1565
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1566
|
+
) as client:
|
|
1567
|
+
response = client.spi_enable()
|
|
1568
|
+
except ShuttleSerialError as exc:
|
|
1569
|
+
console.print(f"[red]{exc}[/]")
|
|
1570
|
+
raise typer.Exit(1) from exc
|
|
1571
|
+
_render_payload_response("spi.enable", response)
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
@app.command("spi-disable")
|
|
1575
|
+
def spi_disable_command(
|
|
1576
|
+
ctx: typer.Context,
|
|
1577
|
+
port: Optional[str] = typer.Option(
|
|
1578
|
+
None,
|
|
1579
|
+
"--port",
|
|
1580
|
+
envvar="SHUTTLE_PORT",
|
|
1581
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1582
|
+
),
|
|
1583
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1584
|
+
timeout: float = typer.Option(
|
|
1585
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1586
|
+
),
|
|
1587
|
+
):
|
|
1588
|
+
"""Disable SPI on the devboard and tri-state SPI pins."""
|
|
1589
|
+
|
|
1590
|
+
resources = _ctx_resources(ctx)
|
|
1591
|
+
resolved_port = _require_port(port)
|
|
1592
|
+
with spinner(f"Disabling SPI over {resolved_port}"):
|
|
1593
|
+
try:
|
|
1594
|
+
with NDJSONSerialClient(
|
|
1595
|
+
port=resolved_port,
|
|
1596
|
+
baudrate=baudrate,
|
|
1597
|
+
timeout=timeout,
|
|
1598
|
+
logger=resources.get("logger"),
|
|
1599
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1600
|
+
) as client:
|
|
1601
|
+
response = client.spi_disable()
|
|
1602
|
+
except ShuttleSerialError as exc:
|
|
1603
|
+
console.print(f"[red]{exc}[/]")
|
|
1604
|
+
raise typer.Exit(1) from exc
|
|
1605
|
+
_render_payload_response("spi.disable", response)
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
@app.command("uart-cfg")
|
|
1609
|
+
def uart_cfg_command(
|
|
1610
|
+
ctx: typer.Context,
|
|
1611
|
+
port: Optional[str] = typer.Option(
|
|
1612
|
+
None,
|
|
1613
|
+
"--port",
|
|
1614
|
+
envvar="SHUTTLE_PORT",
|
|
1615
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1616
|
+
),
|
|
1617
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1618
|
+
timeout: float = typer.Option(
|
|
1619
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1620
|
+
),
|
|
1621
|
+
cfg_baudrate: Optional[int] = typer.Option(
|
|
1622
|
+
None,
|
|
1623
|
+
"--baudrate",
|
|
1624
|
+
help="UART baudrate in Hz",
|
|
1625
|
+
min=1200,
|
|
1626
|
+
max=4_000_000,
|
|
1627
|
+
),
|
|
1628
|
+
stopbits: Optional[int] = typer.Option(
|
|
1629
|
+
None,
|
|
1630
|
+
"--stopbits",
|
|
1631
|
+
help="Stop bits (1 or 2)",
|
|
1632
|
+
min=1,
|
|
1633
|
+
max=2,
|
|
1634
|
+
),
|
|
1635
|
+
parity: Optional[str] = typer.Option(
|
|
1636
|
+
None,
|
|
1637
|
+
"--parity",
|
|
1638
|
+
help="Parity (n/none, e/even, o/odd)",
|
|
1639
|
+
),
|
|
1640
|
+
):
|
|
1641
|
+
"""Query or update the devboard UART defaults."""
|
|
1642
|
+
|
|
1643
|
+
resources = _ctx_resources(ctx)
|
|
1644
|
+
uart_payload: Dict[str, Any] = {}
|
|
1645
|
+
normalized_parity = _normalize_uart_parity(parity)
|
|
1646
|
+
if normalized_parity is not None:
|
|
1647
|
+
uart_payload["parity"] = normalized_parity
|
|
1648
|
+
|
|
1649
|
+
if stopbits is not None:
|
|
1650
|
+
uart_payload["stopbits"] = stopbits
|
|
1651
|
+
|
|
1652
|
+
if cfg_baudrate is not None:
|
|
1653
|
+
uart_payload["baudrate"] = cfg_baudrate
|
|
1654
|
+
|
|
1655
|
+
resolved_port = _require_port(port)
|
|
1656
|
+
action = "Updating" if uart_payload else "Querying"
|
|
1657
|
+
with spinner(f"{action} uart.cfg over {resolved_port}"):
|
|
1658
|
+
try:
|
|
1659
|
+
with NDJSONSerialClient(
|
|
1660
|
+
resolved_port,
|
|
1661
|
+
baudrate=baudrate,
|
|
1662
|
+
timeout=timeout,
|
|
1663
|
+
logger=resources.get("logger"),
|
|
1664
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1665
|
+
) as client:
|
|
1666
|
+
response = client.uart_cfg(uart=uart_payload if uart_payload else None)
|
|
1667
|
+
except ShuttleSerialError as exc:
|
|
1668
|
+
console.print(f"[red]{exc}[/]")
|
|
1669
|
+
raise typer.Exit(1) from exc
|
|
1670
|
+
|
|
1671
|
+
_render_payload_response("uart.cfg", response)
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
@app.command("uart-tx")
|
|
1675
|
+
def uart_tx_command(
|
|
1676
|
+
ctx: typer.Context,
|
|
1677
|
+
data: Optional[str] = typer.Argument(
|
|
1678
|
+
None,
|
|
1679
|
+
metavar="[HEX|'-']",
|
|
1680
|
+
help="Hex payload to send; pass '-' to read raw bytes from stdin",
|
|
1681
|
+
show_default=False,
|
|
1682
|
+
),
|
|
1683
|
+
text: Optional[str] = typer.Option(
|
|
1684
|
+
None,
|
|
1685
|
+
"--text",
|
|
1686
|
+
help="Literal text payload to encode using --encoding",
|
|
1687
|
+
show_default=False,
|
|
1688
|
+
),
|
|
1689
|
+
file: Optional[Path] = typer.Option(
|
|
1690
|
+
None,
|
|
1691
|
+
"--file",
|
|
1692
|
+
exists=True,
|
|
1693
|
+
file_okay=True,
|
|
1694
|
+
dir_okay=False,
|
|
1695
|
+
readable=True,
|
|
1696
|
+
resolve_path=True,
|
|
1697
|
+
help="Read raw bytes from FILE instead of specifying HEX on the command line",
|
|
1698
|
+
show_default=False,
|
|
1699
|
+
),
|
|
1700
|
+
newline: bool = typer.Option(
|
|
1701
|
+
False,
|
|
1702
|
+
"--newline",
|
|
1703
|
+
help="Append a newline when using --text/--file/stdin payloads",
|
|
1704
|
+
show_default=False,
|
|
1705
|
+
),
|
|
1706
|
+
encoding: str = typer.Option(
|
|
1707
|
+
"utf-8",
|
|
1708
|
+
"--encoding",
|
|
1709
|
+
help="Text encoding for --text payloads",
|
|
1710
|
+
),
|
|
1711
|
+
uart_port: Optional[int] = typer.Option(
|
|
1712
|
+
None,
|
|
1713
|
+
"--uart-port",
|
|
1714
|
+
min=0,
|
|
1715
|
+
help="Target device UART index (defaults to firmware's primary UART)",
|
|
1716
|
+
),
|
|
1717
|
+
port: Optional[str] = typer.Option(
|
|
1718
|
+
None,
|
|
1719
|
+
"--port",
|
|
1720
|
+
envvar="SHUTTLE_PORT",
|
|
1721
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1722
|
+
),
|
|
1723
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1724
|
+
timeout: float = typer.Option(
|
|
1725
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1726
|
+
),
|
|
1727
|
+
):
|
|
1728
|
+
"""Send raw UART bytes using the uart.tx protocol command."""
|
|
1729
|
+
|
|
1730
|
+
resources = _ctx_resources(ctx)
|
|
1731
|
+
payload_hex, payload_len = _resolve_uart_payload(
|
|
1732
|
+
data_arg=data,
|
|
1733
|
+
text_payload=text,
|
|
1734
|
+
file_path=file,
|
|
1735
|
+
encoding=encoding,
|
|
1736
|
+
append_newline=newline,
|
|
1737
|
+
)
|
|
1738
|
+
resolved_port = _require_port(port)
|
|
1739
|
+
byte_label = "byte" if payload_len == 1 else "bytes"
|
|
1740
|
+
with spinner(f"Sending {payload_len} UART {byte_label} over {resolved_port}"):
|
|
1741
|
+
try:
|
|
1742
|
+
with NDJSONSerialClient(
|
|
1743
|
+
resolved_port,
|
|
1744
|
+
baudrate=baudrate,
|
|
1745
|
+
timeout=timeout,
|
|
1746
|
+
logger=resources.get("logger"),
|
|
1747
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1748
|
+
) as client:
|
|
1749
|
+
response = client.uart_tx(payload_hex, port=uart_port)
|
|
1750
|
+
except ShuttleSerialError as exc:
|
|
1751
|
+
console.print(f"[red]{exc}[/]")
|
|
1752
|
+
raise typer.Exit(1) from exc
|
|
1753
|
+
|
|
1754
|
+
_render_payload_response("uart.tx", response)
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
@app.command("get-info")
|
|
1758
|
+
def get_info(
|
|
1759
|
+
ctx: typer.Context,
|
|
1760
|
+
port: Optional[str] = typer.Option(
|
|
1761
|
+
None, "--port", envvar="SHUTTLE_PORT", help="Serial port (e.g., /dev/ttyUSB0)"
|
|
1762
|
+
),
|
|
1763
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1764
|
+
timeout: float = typer.Option(
|
|
1765
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1766
|
+
),
|
|
1767
|
+
):
|
|
1768
|
+
"""Fetch device capabilities using the get.info command."""
|
|
1769
|
+
|
|
1770
|
+
resources = _ctx_resources(ctx)
|
|
1771
|
+
resolved_port = _require_port(port)
|
|
1772
|
+
with spinner(f"Querying get.info over {resolved_port}"):
|
|
1773
|
+
try:
|
|
1774
|
+
with NDJSONSerialClient(
|
|
1775
|
+
resolved_port,
|
|
1776
|
+
baudrate=baudrate,
|
|
1777
|
+
timeout=timeout,
|
|
1778
|
+
logger=resources.get("logger"),
|
|
1779
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1780
|
+
) as client:
|
|
1781
|
+
response = client.get_info()
|
|
1782
|
+
except ShuttleSerialError as exc:
|
|
1783
|
+
console.print(f"[red]{exc}[/]")
|
|
1784
|
+
raise typer.Exit(1) from exc
|
|
1785
|
+
_render_info_response(response)
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
@app.command("ping")
|
|
1789
|
+
def ping(
|
|
1790
|
+
ctx: typer.Context,
|
|
1791
|
+
port: Optional[str] = typer.Option(
|
|
1792
|
+
None, "--port", envvar="SHUTTLE_PORT", help="Serial port (e.g., /dev/ttyUSB0)"
|
|
1793
|
+
),
|
|
1794
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1795
|
+
timeout: float = typer.Option(
|
|
1796
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1797
|
+
),
|
|
1798
|
+
):
|
|
1799
|
+
"""Send a ping command to get firmware/protocol metadata."""
|
|
1800
|
+
|
|
1801
|
+
resources = _ctx_resources(ctx)
|
|
1802
|
+
resolved_port = _require_port(port)
|
|
1803
|
+
with spinner(f"Pinging device over {resolved_port}"):
|
|
1804
|
+
try:
|
|
1805
|
+
with NDJSONSerialClient(
|
|
1806
|
+
resolved_port,
|
|
1807
|
+
baudrate=baudrate,
|
|
1808
|
+
timeout=timeout,
|
|
1809
|
+
logger=resources.get("logger"),
|
|
1810
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1811
|
+
) as client:
|
|
1812
|
+
response = client.ping()
|
|
1813
|
+
except ShuttleSerialError as exc:
|
|
1814
|
+
console.print(f"[red]{exc}[/]")
|
|
1815
|
+
raise typer.Exit(1) from exc
|
|
1816
|
+
_render_ping_response(response)
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
if __name__ == "__main__":
|
|
1820
|
+
app()
|