lr-shuttle 0.1.0__tar.gz → 0.1.1__tar.gz
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 → lr_shuttle-0.1.1}/PKG-INFO +2 -2
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/README.md +1 -1
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/pyproject.toml +1 -1
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/PKG-INFO +2 -2
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/cli.py +221 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/serial_client.py +6 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli.py +332 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_serial_client.py +6 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/setup.cfg +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/SOURCES.txt +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/requires.txt +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/top_level.txt +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/constants.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/prodtest.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/timo.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_client.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_edge.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_seq.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_utils.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_prodtest_edge.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_prodtest_helpers.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_timo.py +0 -0
- {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_timo_write.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -80,7 +80,7 @@ make -C host dev
|
|
|
80
80
|
|
|
81
81
|
### Sequence Integrity Checks
|
|
82
82
|
|
|
83
|
-
Every device message carries a monotonically increasing `seq` counter. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
83
|
+
Every device message carries a monotonically increasing `seq` counter emitted by the firmware transport itself. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
84
84
|
|
|
85
85
|
- During a command, any gap in response/event sequence numbers raises a `ShuttleSerialError`, helping you catch dropped frames immediately.
|
|
86
86
|
- Pass `--seq-meta /path/to/seq.meta` to persist the last observed sequence number. Subsequent Shuttle runs expect the very next `seq` value; if a gap is detected (for example because the device dropped messages while Shuttle was offline), the CLI exits with an error detailing the missing value.
|
|
@@ -55,7 +55,7 @@ make -C host dev
|
|
|
55
55
|
|
|
56
56
|
### Sequence Integrity Checks
|
|
57
57
|
|
|
58
|
-
Every device message carries a monotonically increasing `seq` counter. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
58
|
+
Every device message carries a monotonically increasing `seq` counter emitted by the firmware transport itself. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
59
59
|
|
|
60
60
|
- During a command, any gap in response/event sequence numbers raises a `ShuttleSerialError`, helping you catch dropped frames immediately.
|
|
61
61
|
- Pass `--seq-meta /path/to/seq.meta` to persist the last observed sequence number. Subsequent Shuttle runs expect the very next `seq` value; if a gap is detected (for example because the device dropped messages while Shuttle was offline), the CLI exits with an error detailing the missing value.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lr-shuttle"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "CLI and Python client for host-side of json based serial communication with embedded device bridge."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-shuttle
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -80,7 +80,7 @@ make -C host dev
|
|
|
80
80
|
|
|
81
81
|
### Sequence Integrity Checks
|
|
82
82
|
|
|
83
|
-
Every device message carries a monotonically increasing `seq` counter. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
83
|
+
Every device message carries a monotonically increasing `seq` counter emitted by the firmware transport itself. Shuttle enforces sequential integrity both within multi-transfer operations and across invocations when requested:
|
|
84
84
|
|
|
85
85
|
- During a command, any gap in response/event sequence numbers raises a `ShuttleSerialError`, helping you catch dropped frames immediately.
|
|
86
86
|
- Pass `--seq-meta /path/to/seq.meta` to persist the last observed sequence number. Subsequent Shuttle runs expect the very next `seq` value; if a gap is detected (for example because the device dropped messages while Shuttle was offline), the CLI exits with an error detailing the missing value.
|
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import string
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from contextlib import contextmanager
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
10
11
|
|
|
11
12
|
import atexit
|
|
13
|
+
from concurrent.futures import TimeoutError as FutureTimeout
|
|
12
14
|
import typer
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
from rich.panel import Panel
|
|
@@ -40,6 +42,7 @@ app.add_typer(
|
|
|
40
42
|
)
|
|
41
43
|
|
|
42
44
|
console = Console()
|
|
45
|
+
UART_RX_POLL_INTERVAL = 0.25
|
|
43
46
|
|
|
44
47
|
# Backwards-compatible aliases for tests and external callers
|
|
45
48
|
_SerialLogger = SerialLogger
|
|
@@ -369,6 +372,74 @@ def _render_ping_response(response: Dict[str, Any]) -> None:
|
|
|
369
372
|
_render_payload_response("ping", response)
|
|
370
373
|
|
|
371
374
|
|
|
375
|
+
def _render_uart_event(event: Dict[str, Any]) -> None:
|
|
376
|
+
data_hex = event.get("data")
|
|
377
|
+
if not isinstance(data_hex, str):
|
|
378
|
+
console.print("[yellow]uart.rx event missing data payload[/]")
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
payload = bytes.fromhex(data_hex)
|
|
382
|
+
except ValueError:
|
|
383
|
+
console.print("[red]uart.rx event payload is not valid hex[/]")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
seq = event.get("seq", "?")
|
|
387
|
+
port = event.get("port", 0)
|
|
388
|
+
n_field = event.get("n")
|
|
389
|
+
byte_count = n_field if isinstance(n_field, int) else len(payload)
|
|
390
|
+
preview_limit = 64
|
|
391
|
+
ascii_preview = "".join(
|
|
392
|
+
chr(b) if 32 <= b < 127 else "." for b in payload[:preview_limit]
|
|
393
|
+
)
|
|
394
|
+
if len(payload) > preview_limit:
|
|
395
|
+
ascii_preview += " ..."
|
|
396
|
+
|
|
397
|
+
console.print(f"[green]uart.rx[/] seq={seq} port={port} bytes={byte_count}")
|
|
398
|
+
console.print(f" hex : {_format_hex(data_hex)}")
|
|
399
|
+
if payload:
|
|
400
|
+
console.print(
|
|
401
|
+
f" ascii: {ascii_preview if ascii_preview else '(non-printable)'}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _consume_uart_events(
|
|
406
|
+
listener,
|
|
407
|
+
*,
|
|
408
|
+
duration: Optional[float],
|
|
409
|
+
forever: bool,
|
|
410
|
+
) -> int:
|
|
411
|
+
events_seen = 0
|
|
412
|
+
start = time.monotonic()
|
|
413
|
+
deadline = start + duration if duration is not None else None
|
|
414
|
+
|
|
415
|
+
while True:
|
|
416
|
+
if deadline is not None:
|
|
417
|
+
remaining = deadline - time.monotonic()
|
|
418
|
+
if remaining <= 0:
|
|
419
|
+
break
|
|
420
|
+
timeout_value = (
|
|
421
|
+
remaining
|
|
422
|
+
if remaining < UART_RX_POLL_INTERVAL
|
|
423
|
+
else UART_RX_POLL_INTERVAL
|
|
424
|
+
)
|
|
425
|
+
elif forever:
|
|
426
|
+
timeout_value = UART_RX_POLL_INTERVAL
|
|
427
|
+
else:
|
|
428
|
+
timeout_value = None
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
event = listener.next(timeout=timeout_value)
|
|
432
|
+
except FutureTimeout:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
_render_uart_event(event)
|
|
436
|
+
events_seen += 1
|
|
437
|
+
if duration is None and not forever:
|
|
438
|
+
break
|
|
439
|
+
|
|
440
|
+
return events_seen
|
|
441
|
+
|
|
442
|
+
|
|
372
443
|
@app.callback()
|
|
373
444
|
def main(
|
|
374
445
|
ctx: typer.Context,
|
|
@@ -1671,6 +1742,69 @@ def uart_cfg_command(
|
|
|
1671
1742
|
_render_payload_response("uart.cfg", response)
|
|
1672
1743
|
|
|
1673
1744
|
|
|
1745
|
+
@app.command("uart-sub")
|
|
1746
|
+
def uart_sub_command(
|
|
1747
|
+
ctx: typer.Context,
|
|
1748
|
+
port: Optional[str] = typer.Option(
|
|
1749
|
+
None,
|
|
1750
|
+
"--port",
|
|
1751
|
+
envvar="SHUTTLE_PORT",
|
|
1752
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1753
|
+
),
|
|
1754
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1755
|
+
timeout: float = typer.Option(
|
|
1756
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1757
|
+
),
|
|
1758
|
+
enable: Optional[bool] = typer.Option(
|
|
1759
|
+
None,
|
|
1760
|
+
"--enable/--disable",
|
|
1761
|
+
help="Enable or disable uart.rx event emission",
|
|
1762
|
+
),
|
|
1763
|
+
gap_ms: Optional[int] = typer.Option(
|
|
1764
|
+
None,
|
|
1765
|
+
"--gap-ms",
|
|
1766
|
+
min=0,
|
|
1767
|
+
max=1000,
|
|
1768
|
+
help="Milliseconds of idle before emitting buffered bytes",
|
|
1769
|
+
),
|
|
1770
|
+
buf: Optional[int] = typer.Option(
|
|
1771
|
+
None,
|
|
1772
|
+
"--buf",
|
|
1773
|
+
min=1,
|
|
1774
|
+
max=1024,
|
|
1775
|
+
help="Emit an event once this many bytes are buffered",
|
|
1776
|
+
),
|
|
1777
|
+
):
|
|
1778
|
+
"""Query or update uart.rx subscription settings."""
|
|
1779
|
+
|
|
1780
|
+
resources = _ctx_resources(ctx)
|
|
1781
|
+
sub_payload: Dict[str, Any] = {}
|
|
1782
|
+
if enable is not None:
|
|
1783
|
+
sub_payload["enable"] = enable
|
|
1784
|
+
if gap_ms is not None:
|
|
1785
|
+
sub_payload["gap_ms"] = gap_ms
|
|
1786
|
+
if buf is not None:
|
|
1787
|
+
sub_payload["buf"] = buf
|
|
1788
|
+
|
|
1789
|
+
resolved_port = _require_port(port)
|
|
1790
|
+
action = "Updating" if sub_payload else "Querying"
|
|
1791
|
+
with spinner(f"{action} uart.sub over {resolved_port}"):
|
|
1792
|
+
try:
|
|
1793
|
+
with NDJSONSerialClient(
|
|
1794
|
+
resolved_port,
|
|
1795
|
+
baudrate=baudrate,
|
|
1796
|
+
timeout=timeout,
|
|
1797
|
+
logger=resources.get("logger"),
|
|
1798
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1799
|
+
) as client:
|
|
1800
|
+
response = client.uart_sub(sub_payload if sub_payload else None)
|
|
1801
|
+
except ShuttleSerialError as exc:
|
|
1802
|
+
console.print(f"[red]{exc}[/]")
|
|
1803
|
+
raise typer.Exit(1) from exc
|
|
1804
|
+
|
|
1805
|
+
_render_payload_response("uart.sub", response)
|
|
1806
|
+
|
|
1807
|
+
|
|
1674
1808
|
@app.command("uart-tx")
|
|
1675
1809
|
def uart_tx_command(
|
|
1676
1810
|
ctx: typer.Context,
|
|
@@ -1754,6 +1888,93 @@ def uart_tx_command(
|
|
|
1754
1888
|
_render_payload_response("uart.tx", response)
|
|
1755
1889
|
|
|
1756
1890
|
|
|
1891
|
+
@app.command("uart-rx")
|
|
1892
|
+
def uart_rx_command(
|
|
1893
|
+
ctx: typer.Context,
|
|
1894
|
+
port: Optional[str] = typer.Option(
|
|
1895
|
+
None,
|
|
1896
|
+
"--port",
|
|
1897
|
+
envvar="SHUTTLE_PORT",
|
|
1898
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
1899
|
+
),
|
|
1900
|
+
baudrate: int = typer.Option(DEFAULT_BAUD, "--baud", help="Serial baud rate"),
|
|
1901
|
+
timeout: float = typer.Option(
|
|
1902
|
+
DEFAULT_TIMEOUT, "--timeout", help="Read timeout in seconds"
|
|
1903
|
+
),
|
|
1904
|
+
duration: Optional[float] = typer.Option(
|
|
1905
|
+
None,
|
|
1906
|
+
"--duration",
|
|
1907
|
+
min=0.0,
|
|
1908
|
+
help="Listen for uart.rx events for N seconds before exiting",
|
|
1909
|
+
),
|
|
1910
|
+
forever: bool = typer.Option(
|
|
1911
|
+
False,
|
|
1912
|
+
"--forever",
|
|
1913
|
+
help="Stream uart.rx events until interrupted",
|
|
1914
|
+
),
|
|
1915
|
+
ensure_subscription: bool = typer.Option(
|
|
1916
|
+
True,
|
|
1917
|
+
"--ensure-subscription/--no-ensure-subscription",
|
|
1918
|
+
help="Call uart.sub --enable before listening",
|
|
1919
|
+
),
|
|
1920
|
+
gap_ms: Optional[int] = typer.Option(
|
|
1921
|
+
None,
|
|
1922
|
+
"--gap-ms",
|
|
1923
|
+
min=0,
|
|
1924
|
+
max=1000,
|
|
1925
|
+
help="Override gap_ms while ensuring the subscription",
|
|
1926
|
+
),
|
|
1927
|
+
buf: Optional[int] = typer.Option(
|
|
1928
|
+
None,
|
|
1929
|
+
"--buf",
|
|
1930
|
+
min=1,
|
|
1931
|
+
max=1024,
|
|
1932
|
+
help="Override buf while ensuring the subscription",
|
|
1933
|
+
),
|
|
1934
|
+
):
|
|
1935
|
+
"""Stream uart.rx events emitted by the firmware."""
|
|
1936
|
+
|
|
1937
|
+
if forever and duration is not None:
|
|
1938
|
+
raise typer.BadParameter("--duration cannot be combined with --forever")
|
|
1939
|
+
|
|
1940
|
+
if (gap_ms is not None or buf is not None) and not ensure_subscription:
|
|
1941
|
+
raise typer.BadParameter("--gap-ms/--buf require --ensure-subscription")
|
|
1942
|
+
|
|
1943
|
+
resources = _ctx_resources(ctx)
|
|
1944
|
+
resolved_port = _require_port(port)
|
|
1945
|
+
console.print(f"Listening for uart.rx events on {resolved_port}...")
|
|
1946
|
+
|
|
1947
|
+
events_seen = 0
|
|
1948
|
+
try:
|
|
1949
|
+
with NDJSONSerialClient(
|
|
1950
|
+
resolved_port,
|
|
1951
|
+
baudrate=baudrate,
|
|
1952
|
+
timeout=timeout,
|
|
1953
|
+
logger=resources.get("logger"),
|
|
1954
|
+
seq_tracker=resources.get("seq_tracker"),
|
|
1955
|
+
) as client:
|
|
1956
|
+
if ensure_subscription:
|
|
1957
|
+
sub_payload: Dict[str, Any] = {"enable": True}
|
|
1958
|
+
if gap_ms is not None:
|
|
1959
|
+
sub_payload["gap_ms"] = gap_ms
|
|
1960
|
+
if buf is not None:
|
|
1961
|
+
sub_payload["buf"] = buf
|
|
1962
|
+
client.uart_sub(sub_payload)
|
|
1963
|
+
listener = client.register_event_listener("uart.rx")
|
|
1964
|
+
events_seen = _consume_uart_events(
|
|
1965
|
+
listener, duration=duration, forever=forever
|
|
1966
|
+
)
|
|
1967
|
+
except ShuttleSerialError as exc:
|
|
1968
|
+
console.print(f"[red]{exc}[/]")
|
|
1969
|
+
raise typer.Exit(1) from exc
|
|
1970
|
+
except KeyboardInterrupt:
|
|
1971
|
+
console.print("\n[yellow]Interrupted by user[/]")
|
|
1972
|
+
raise typer.Exit(1)
|
|
1973
|
+
|
|
1974
|
+
if not events_seen:
|
|
1975
|
+
console.print("[yellow]No uart.rx events observed[/]")
|
|
1976
|
+
|
|
1977
|
+
|
|
1757
1978
|
@app.command("get-info")
|
|
1758
1979
|
def get_info(
|
|
1759
1980
|
ctx: typer.Context,
|
|
@@ -342,6 +342,12 @@ class NDJSONSerialClient:
|
|
|
342
342
|
payload["port"] = port
|
|
343
343
|
return self._command("uart.tx", payload)
|
|
344
344
|
|
|
345
|
+
def uart_sub(self, sub: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
346
|
+
payload: Dict[str, Any] = {}
|
|
347
|
+
if sub:
|
|
348
|
+
payload["uart"] = {"sub": sub}
|
|
349
|
+
return self._command("uart.sub", payload)
|
|
350
|
+
|
|
345
351
|
def _command(self, op: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
346
352
|
future = self.send_command(op, params)
|
|
347
353
|
return future.result()
|
|
@@ -240,6 +240,7 @@ def test_timo_write_reg_reports_parse_error(monkeypatch):
|
|
|
240
240
|
# -*- coding: utf-8 -*-
|
|
241
241
|
|
|
242
242
|
import io
|
|
243
|
+
from concurrent.futures import TimeoutError as FutureTimeout
|
|
243
244
|
from contextlib import contextmanager
|
|
244
245
|
from types import SimpleNamespace
|
|
245
246
|
|
|
@@ -1640,6 +1641,337 @@ def test_uart_tx_stdin(monkeypatch):
|
|
|
1640
1641
|
assert record.data == "4f4b0a"
|
|
1641
1642
|
|
|
1642
1643
|
|
|
1644
|
+
def test_uart_sub_queries_device(monkeypatch, recorded_console):
|
|
1645
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
1646
|
+
record = SimpleNamespace(payloads=[])
|
|
1647
|
+
|
|
1648
|
+
class UartClient:
|
|
1649
|
+
def __init__(self, *args, **kwargs):
|
|
1650
|
+
pass
|
|
1651
|
+
|
|
1652
|
+
def __enter__(self):
|
|
1653
|
+
return self
|
|
1654
|
+
|
|
1655
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1656
|
+
return False
|
|
1657
|
+
|
|
1658
|
+
def uart_sub(self, sub=None):
|
|
1659
|
+
record.payloads.append(sub)
|
|
1660
|
+
return {
|
|
1661
|
+
"type": "resp",
|
|
1662
|
+
"id": 1,
|
|
1663
|
+
"ok": True,
|
|
1664
|
+
"uart": {"subscription": {"enabled": False}},
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", UartClient)
|
|
1668
|
+
|
|
1669
|
+
runner = CliRunner()
|
|
1670
|
+
result = runner.invoke(app, ["uart-sub", "--port", "/dev/ttyUSB0"])
|
|
1671
|
+
|
|
1672
|
+
assert result.exit_code == 0
|
|
1673
|
+
assert record.payloads == [None]
|
|
1674
|
+
assert "uart.sub payload" in recorded_console.getvalue()
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def test_uart_sub_updates_fields(monkeypatch):
|
|
1678
|
+
monkeypatch.setattr(cli_module, "spinner", _noop_spinner)
|
|
1679
|
+
record = SimpleNamespace(payload=None)
|
|
1680
|
+
|
|
1681
|
+
class UartClient:
|
|
1682
|
+
def __init__(self, *args, **kwargs):
|
|
1683
|
+
pass
|
|
1684
|
+
|
|
1685
|
+
def __enter__(self):
|
|
1686
|
+
return self
|
|
1687
|
+
|
|
1688
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1689
|
+
return False
|
|
1690
|
+
|
|
1691
|
+
def uart_sub(self, sub=None):
|
|
1692
|
+
record.payload = sub
|
|
1693
|
+
return {"type": "resp", "id": 1, "ok": True}
|
|
1694
|
+
|
|
1695
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", UartClient)
|
|
1696
|
+
|
|
1697
|
+
runner = CliRunner()
|
|
1698
|
+
result = runner.invoke(
|
|
1699
|
+
app,
|
|
1700
|
+
[
|
|
1701
|
+
"uart-sub",
|
|
1702
|
+
"--port",
|
|
1703
|
+
"/dev/ttyUSB0",
|
|
1704
|
+
"--enable",
|
|
1705
|
+
"--gap-ms",
|
|
1706
|
+
"10",
|
|
1707
|
+
"--buf",
|
|
1708
|
+
"32",
|
|
1709
|
+
],
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
assert result.exit_code == 0
|
|
1713
|
+
assert record.payload == {"enable": True, "gap_ms": 10, "buf": 32}
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def test_uart_rx_waits_for_single_event(monkeypatch, recorded_console):
|
|
1717
|
+
record = SimpleNamespace(uart_sub_calls=[])
|
|
1718
|
+
|
|
1719
|
+
class Listener:
|
|
1720
|
+
def __init__(self):
|
|
1721
|
+
self.returned = False
|
|
1722
|
+
|
|
1723
|
+
def next(self, timeout=None):
|
|
1724
|
+
assert timeout is None
|
|
1725
|
+
if self.returned:
|
|
1726
|
+
raise AssertionError("listener queried more than once")
|
|
1727
|
+
self.returned = True
|
|
1728
|
+
return {"data": "4142", "seq": 1, "n": 2, "port": 0}
|
|
1729
|
+
|
|
1730
|
+
class RxClient:
|
|
1731
|
+
def __init__(self, *args, **kwargs):
|
|
1732
|
+
pass
|
|
1733
|
+
|
|
1734
|
+
def __enter__(self):
|
|
1735
|
+
return self
|
|
1736
|
+
|
|
1737
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1738
|
+
return False
|
|
1739
|
+
|
|
1740
|
+
def register_event_listener(self, name):
|
|
1741
|
+
assert name == "uart.rx"
|
|
1742
|
+
return Listener()
|
|
1743
|
+
|
|
1744
|
+
def uart_sub(self, payload=None):
|
|
1745
|
+
record.uart_sub_calls.append(payload)
|
|
1746
|
+
return {"type": "resp", "id": 2, "ok": True}
|
|
1747
|
+
|
|
1748
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
|
|
1749
|
+
|
|
1750
|
+
runner = CliRunner()
|
|
1751
|
+
result = runner.invoke(app, ["uart-rx", "--port", "/dev/ttyUSB0"])
|
|
1752
|
+
|
|
1753
|
+
assert result.exit_code == 0
|
|
1754
|
+
assert record.uart_sub_calls == [{"enable": True}]
|
|
1755
|
+
output = recorded_console.getvalue()
|
|
1756
|
+
assert "uart.rx" in output
|
|
1757
|
+
assert "41 42" in output
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def test_uart_rx_duration_mode(monkeypatch, recorded_console):
|
|
1761
|
+
class DurationListener:
|
|
1762
|
+
def __init__(self):
|
|
1763
|
+
self.calls = 0
|
|
1764
|
+
|
|
1765
|
+
def next(self, timeout=None):
|
|
1766
|
+
if self.calls < 2:
|
|
1767
|
+
self.calls += 1
|
|
1768
|
+
return {
|
|
1769
|
+
"data": f"0{self.calls}",
|
|
1770
|
+
"seq": self.calls,
|
|
1771
|
+
"n": 1,
|
|
1772
|
+
"port": 0,
|
|
1773
|
+
}
|
|
1774
|
+
raise FutureTimeout()
|
|
1775
|
+
|
|
1776
|
+
listener = DurationListener()
|
|
1777
|
+
|
|
1778
|
+
class RxClient:
|
|
1779
|
+
def __init__(self, *args, **kwargs):
|
|
1780
|
+
pass
|
|
1781
|
+
|
|
1782
|
+
def __enter__(self):
|
|
1783
|
+
return self
|
|
1784
|
+
|
|
1785
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1786
|
+
return False
|
|
1787
|
+
|
|
1788
|
+
def register_event_listener(self, name):
|
|
1789
|
+
assert name == "uart.rx"
|
|
1790
|
+
return listener
|
|
1791
|
+
|
|
1792
|
+
def uart_sub(self, payload=None): # pragma: no cover - disabled via CLI flag
|
|
1793
|
+
return {"type": "resp", "id": 3, "ok": True}
|
|
1794
|
+
|
|
1795
|
+
fake_time = SimpleNamespace(current=0.0)
|
|
1796
|
+
|
|
1797
|
+
def fake_monotonic():
|
|
1798
|
+
value = fake_time.current
|
|
1799
|
+
fake_time.current += 0.3
|
|
1800
|
+
return value
|
|
1801
|
+
|
|
1802
|
+
monkeypatch.setattr(cli_module, "time", SimpleNamespace(monotonic=fake_monotonic))
|
|
1803
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
|
|
1804
|
+
|
|
1805
|
+
runner = CliRunner()
|
|
1806
|
+
result = runner.invoke(
|
|
1807
|
+
app,
|
|
1808
|
+
[
|
|
1809
|
+
"uart-rx",
|
|
1810
|
+
"--port",
|
|
1811
|
+
"/dev/ttyUSB0",
|
|
1812
|
+
"--duration",
|
|
1813
|
+
"0.6",
|
|
1814
|
+
"--no-ensure-subscription",
|
|
1815
|
+
],
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
assert result.exit_code == 0
|
|
1819
|
+
output = recorded_console.getvalue()
|
|
1820
|
+
assert output.count("uart.rx") == 2
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def test_uart_rx_duration_conflict():
|
|
1824
|
+
runner = CliRunner()
|
|
1825
|
+
result = runner.invoke(
|
|
1826
|
+
app,
|
|
1827
|
+
[
|
|
1828
|
+
"uart-rx",
|
|
1829
|
+
"--port",
|
|
1830
|
+
"/dev/ttyUSB0",
|
|
1831
|
+
"--duration",
|
|
1832
|
+
"1",
|
|
1833
|
+
"--forever",
|
|
1834
|
+
],
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
assert result.exit_code != 0
|
|
1838
|
+
combined = (result.stdout + result.stderr).lower()
|
|
1839
|
+
assert "cannot" in combined
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def test_uart_rx_gap_requires_ensure():
|
|
1843
|
+
runner = CliRunner()
|
|
1844
|
+
result = runner.invoke(
|
|
1845
|
+
app,
|
|
1846
|
+
[
|
|
1847
|
+
"uart-rx",
|
|
1848
|
+
"--port",
|
|
1849
|
+
"/dev/ttyUSB0",
|
|
1850
|
+
"--gap-ms",
|
|
1851
|
+
"5",
|
|
1852
|
+
"--no-ensure-subscription",
|
|
1853
|
+
],
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
assert result.exit_code != 0
|
|
1857
|
+
combined = (result.stdout + result.stderr).lower()
|
|
1858
|
+
assert "require" in combined
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def test_uart_rx_applies_gap_and_buf(monkeypatch, recorded_console):
|
|
1862
|
+
record = SimpleNamespace(uart_sub_calls=[])
|
|
1863
|
+
|
|
1864
|
+
class Listener:
|
|
1865
|
+
def __init__(self):
|
|
1866
|
+
self.called = False
|
|
1867
|
+
|
|
1868
|
+
def next(self, timeout=None):
|
|
1869
|
+
if self.called:
|
|
1870
|
+
raise AssertionError("listener should only be polled once")
|
|
1871
|
+
self.called = True
|
|
1872
|
+
return {"data": "aa", "seq": 1, "n": 1, "port": 0}
|
|
1873
|
+
|
|
1874
|
+
class RxClient:
|
|
1875
|
+
def __init__(self, *args, **kwargs):
|
|
1876
|
+
pass
|
|
1877
|
+
|
|
1878
|
+
def __enter__(self):
|
|
1879
|
+
return self
|
|
1880
|
+
|
|
1881
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1882
|
+
return False
|
|
1883
|
+
|
|
1884
|
+
def register_event_listener(self, name):
|
|
1885
|
+
assert name == "uart.rx"
|
|
1886
|
+
return Listener()
|
|
1887
|
+
|
|
1888
|
+
def uart_sub(self, payload=None):
|
|
1889
|
+
record.uart_sub_calls.append(payload)
|
|
1890
|
+
return {"type": "resp", "id": 9, "ok": True}
|
|
1891
|
+
|
|
1892
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
|
|
1893
|
+
|
|
1894
|
+
runner = CliRunner()
|
|
1895
|
+
result = runner.invoke(
|
|
1896
|
+
app,
|
|
1897
|
+
[
|
|
1898
|
+
"uart-rx",
|
|
1899
|
+
"--port",
|
|
1900
|
+
"/dev/ttyUSB0",
|
|
1901
|
+
"--gap-ms",
|
|
1902
|
+
"15",
|
|
1903
|
+
"--buf",
|
|
1904
|
+
"8",
|
|
1905
|
+
],
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
assert result.exit_code == 0
|
|
1909
|
+
assert record.uart_sub_calls == [{"enable": True, "gap_ms": 15, "buf": 8}]
|
|
1910
|
+
assert "aa" in recorded_console.getvalue().lower()
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def test_uart_rx_no_events_message(monkeypatch, recorded_console):
|
|
1914
|
+
class SilentListener:
|
|
1915
|
+
def next(self, timeout=None):
|
|
1916
|
+
raise FutureTimeout()
|
|
1917
|
+
|
|
1918
|
+
class RxClient:
|
|
1919
|
+
def __init__(self, *args, **kwargs):
|
|
1920
|
+
pass
|
|
1921
|
+
|
|
1922
|
+
def __enter__(self):
|
|
1923
|
+
return self
|
|
1924
|
+
|
|
1925
|
+
def __exit__(self, exc_type, exc, exc_tb):
|
|
1926
|
+
return False
|
|
1927
|
+
|
|
1928
|
+
def register_event_listener(self, name):
|
|
1929
|
+
assert name == "uart.rx"
|
|
1930
|
+
return SilentListener()
|
|
1931
|
+
|
|
1932
|
+
def uart_sub(self, payload=None): # pragma: no cover - disabled via CLI flag
|
|
1933
|
+
return {"type": "resp", "id": 10, "ok": True}
|
|
1934
|
+
|
|
1935
|
+
fake_time = SimpleNamespace(current=0.0)
|
|
1936
|
+
|
|
1937
|
+
def fake_monotonic():
|
|
1938
|
+
value = fake_time.current
|
|
1939
|
+
fake_time.current += 0.2
|
|
1940
|
+
return value
|
|
1941
|
+
|
|
1942
|
+
monkeypatch.setattr(cli_module, "time", SimpleNamespace(monotonic=fake_monotonic))
|
|
1943
|
+
monkeypatch.setattr(cli_module, "NDJSONSerialClient", RxClient)
|
|
1944
|
+
|
|
1945
|
+
runner = CliRunner()
|
|
1946
|
+
result = runner.invoke(
|
|
1947
|
+
app,
|
|
1948
|
+
[
|
|
1949
|
+
"uart-rx",
|
|
1950
|
+
"--port",
|
|
1951
|
+
"/dev/ttyUSB0",
|
|
1952
|
+
"--duration",
|
|
1953
|
+
"0.4",
|
|
1954
|
+
"--no-ensure-subscription",
|
|
1955
|
+
],
|
|
1956
|
+
)
|
|
1957
|
+
|
|
1958
|
+
assert result.exit_code == 0
|
|
1959
|
+
assert "No uart.rx events observed" in recorded_console.getvalue()
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def test_render_uart_event_handles_invalid_payload(monkeypatch):
|
|
1963
|
+
buffer = io.StringIO()
|
|
1964
|
+
console = Console(file=buffer, force_terminal=False, width=80)
|
|
1965
|
+
monkeypatch.setattr(cli_module, "console", console)
|
|
1966
|
+
|
|
1967
|
+
cli_module._render_uart_event({"seq": 1})
|
|
1968
|
+
cli_module._render_uart_event({"data": "zz"})
|
|
1969
|
+
|
|
1970
|
+
output = buffer.getvalue()
|
|
1971
|
+
assert "missing data" in output.lower()
|
|
1972
|
+
assert "not valid hex" in output.lower()
|
|
1973
|
+
|
|
1974
|
+
|
|
1643
1975
|
def test_ndjson_serial_client_handles_event_and_response(monkeypatch, recorded_console):
|
|
1644
1976
|
monkeypatch.setattr(serial_client_module.secrets, "randbits", lambda bits: 1)
|
|
1645
1977
|
stub = _install_serial_stub(
|
|
@@ -71,6 +71,8 @@ def test_ndjson_serial_client_payload_builders():
|
|
|
71
71
|
client.uart_cfg({"baudrate": 9600})
|
|
72
72
|
client.spi_enable()
|
|
73
73
|
client.spi_disable()
|
|
74
|
+
client.uart_sub({"enable": True, "gap_ms": 5})
|
|
75
|
+
client.uart_sub()
|
|
74
76
|
|
|
75
77
|
assert calls[0][0] == "spi.cfg"
|
|
76
78
|
assert calls[0][1]["spi"]["hz"] == 123
|
|
@@ -78,6 +80,10 @@ def test_ndjson_serial_client_payload_builders():
|
|
|
78
80
|
assert calls[1][1]["uart"]["baudrate"] == 9600
|
|
79
81
|
assert calls[2][0] == "spi.enable"
|
|
80
82
|
assert calls[3][0] == "spi.disable"
|
|
83
|
+
assert calls[4][0] == "uart.sub"
|
|
84
|
+
assert calls[4][1]["uart"]["sub"] == {"enable": True, "gap_ms": 5}
|
|
85
|
+
assert calls[5][0] == "uart.sub"
|
|
86
|
+
assert calls[5][1] == {}
|
|
81
87
|
|
|
82
88
|
|
|
83
89
|
def test_record_sequence_no_tracker(monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|