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.

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()