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.

Files changed (25) hide show
  1. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/PKG-INFO +2 -2
  2. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/README.md +1 -1
  3. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/pyproject.toml +1 -1
  4. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/PKG-INFO +2 -2
  5. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/cli.py +221 -0
  6. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/serial_client.py +6 -0
  7. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli.py +332 -0
  8. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_serial_client.py +6 -0
  9. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/setup.cfg +0 -0
  10. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/SOURCES.txt +0 -0
  11. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
  12. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
  13. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/requires.txt +0 -0
  14. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/lr_shuttle.egg-info/top_level.txt +0 -0
  15. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/constants.py +0 -0
  16. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/prodtest.py +0 -0
  17. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/src/shuttle/timo.py +0 -0
  18. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_client.py +0 -0
  19. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_edge.py +0 -0
  20. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_seq.py +0 -0
  21. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_cli_utils.py +0 -0
  22. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_prodtest_edge.py +0 -0
  23. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_prodtest_helpers.py +0 -0
  24. {lr_shuttle-0.1.0 → lr_shuttle-0.1.1}/tests/test_timo.py +0 -0
  25. {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.0
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.0"
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.0
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