Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 595a7fa

Browse files
authored
Merge pull request #798 from jumpstarter-dev/nvdemux-serial
Nvdemux serial support
2 parents 640229b + 856392e commit 595a7fa

6 files changed

Lines changed: 1361 additions & 0 deletions

File tree

packages/jumpstarter-driver-pyserial/README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,108 @@ export:
3333
| check_present | Check if the serial port exists during exporter initialization, disable if you are connecting to a dynamically created port (i.e. USB from your DUT) | bool | no | True |
3434
| cps | Characters per second throttling limit. When set, data transmission will be throttled to simulate slow typing. Useful for devices that can't handle fast input | float | no | None |
3535
36+
## NVDemuxSerial Driver
37+
38+
The `NVDemuxSerial` driver provides serial access to NVIDIA Tegra demultiplexed UART channels using the [nv_tcu_demuxer](https://docs.nvidia.com/jetson/archives/r38.2.1/DeveloperGuide/AT/JetsonLinuxDevelopmentTools/TegraCombinedUART.html) tool. It automatically handles device reconnection when the target device restarts.
39+
40+
The nv_tcu_demuxer tool can be obtained from the NVIDIA Jetson BSP, at this path: `Linux_for_Tegra/tools/demuxer/nv_tcu_demuxer`.
41+
42+
### Multi-Instance Support
43+
44+
Multiple driver instances can share a single demuxer process by specifying different target channels. This allows simultaneous access to multiple UART channels (CCPLEX, BPMP, SCE, etc.) from the same physical device.
45+
46+
### Configuration
47+
48+
#### Single channel example:
49+
50+
```yaml
51+
export:
52+
ccplex:
53+
type: jumpstarter_driver_pyserial.nvdemux.driver.NVDemuxSerial
54+
config:
55+
demuxer_path: "/opt/nvidia/nv_tcu_demuxer"
56+
# device defaults to auto-detect NVIDIA Tegra On-Platform Operator
57+
# chip defaults to T264 (Thor), use T234 for Orin
58+
```
59+
60+
#### Multiple channels example:
61+
62+
```yaml
63+
export:
64+
ccplex:
65+
type: jumpstarter_driver_pyserial.nvdemux.driver.NVDemuxSerial
66+
config:
67+
demuxer_path: "/opt/nvidia/nv_tcu_demuxer"
68+
target: "CCPLEX: 0"
69+
chip: "T264"
70+
71+
bpmp:
72+
type: jumpstarter_driver_pyserial.nvdemux.driver.NVDemuxSerial
73+
config:
74+
demuxer_path: "/opt/nvidia/nv_tcu_demuxer"
75+
target: "BPMP: 1"
76+
chip: "T264"
77+
78+
sce:
79+
type: jumpstarter_driver_pyserial.nvdemux.driver.NVDemuxSerial
80+
config:
81+
demuxer_path: "/opt/nvidia/nv_tcu_demuxer"
82+
target: "SCE: 2"
83+
chip: "T264"
84+
```
85+
86+
### Config parameters
87+
88+
| Parameter | Description | Type | Required | Default |
89+
| -------------- | ----------------------------------------------------------------------------------------------- | ----- | -------- | ------------------------------------------------------------------------- |
90+
| demuxer_path | Path to the `nv_tcu_demuxer` binary | str | yes | |
91+
| device | Device path or glob pattern for auto-detection | str | no | `/dev/serial/by-id/usb-NVIDIA_Tegra_On-Platform_Operator_*-if01` |
92+
| target | Target channel to extract from demuxer output | str | no | `CCPLEX: 0` |
93+
| chip | Chip type for demuxer (`T234` for Orin, `T264` for Thor) | str | no | `T264` |
94+
| baudrate | Baud rate for the serial connection | int | no | 115200 |
95+
| cps | Characters per second throttling limit | float | no | None |
96+
| timeout | Timeout in seconds waiting for demuxer to detect pts | float | no | 10.0 |
97+
| poll_interval | Interval in seconds to poll for device reappearance after disconnect | float | no | 1.0 |
98+
99+
### Device Auto-Detection
100+
101+
The `device` parameter supports glob patterns for automatic device discovery:
102+
103+
```yaml
104+
# Auto-detect any NVIDIA Tegra On-Platform Operator device (default)
105+
device: "/dev/serial/by-id/usb-NVIDIA_Tegra_On-Platform_Operator_*-if01"
106+
107+
# Specific serial number
108+
device: "/dev/serial/by-id/usb-NVIDIA_Tegra_On-Platform_Operator_ABC123-if01"
109+
110+
# Direct device path (no glob)
111+
device: "/dev/ttyUSB0"
112+
```
113+
114+
### Auto-Recovery
115+
116+
When the target device restarts (e.g., power cycle), the serial device disappears and the demuxer exits. The driver automatically:
117+
118+
1. Detects the device disconnection
119+
2. Polls for the device to reappear
120+
3. Restarts the demuxer with the new device
121+
4. Discovers the new pts path (which changes on each restart)
122+
123+
Active connections will receive errors when the device disconnects. Clients should reconnect, and the driver will wait for the device to be available again.
124+
125+
### Configuration Validation / Limitations
126+
127+
When using multiple driver instances, all instances must have compatible configurations:
128+
129+
- **demuxer_path**: Must be identical across all instances
130+
- **device**: Must be identical across all instances
131+
- **chip**: Must be identical across all instances
132+
- **target**: Must be unique for each instance (no duplicates allowed)
133+
134+
If these requirements are not met, the driver will raise a `ValueError` during initialization.
135+
136+
137+
36138
## CLI Commands
37139

38140
The pyserial driver provides two CLI commands for interacting with serial ports:

packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/nvdemux/__init__.py

Whitespace-only changes.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import time
2+
from contextlib import asynccontextmanager
3+
from dataclasses import dataclass, field
4+
from typing import Optional
5+
6+
from anyio import sleep
7+
from anyio._backends._asyncio import StreamReaderWrapper, StreamWriterWrapper
8+
from serial_asyncio import open_serial_connection
9+
10+
from ..driver import AsyncSerial
11+
from .manager import DemuxerManager
12+
from jumpstarter.driver import Driver, exportstream
13+
14+
# Default glob pattern for NVIDIA Tegra On-Platform Operator devices
15+
NV_DEVICE_PATTERN = "/dev/serial/by-id/usb-NVIDIA_Tegra_On-Platform_Operator_*-if01"
16+
17+
18+
@dataclass(kw_only=True)
19+
class NVDemuxSerial(Driver):
20+
"""Serial driver for NVIDIA TCU demultiplexed UART channels.
21+
22+
This driver wraps the nv_tcu_demuxer tool to extract a specific demultiplexed
23+
UART channel (like CCPLEX) from a multiplexed serial device. Multiple driver
24+
instances can share the same demuxer process by specifying different targets.
25+
26+
Args:
27+
demuxer_path: Path to the nv_tcu_demuxer binary
28+
device: Device path or glob pattern for auto-detection.
29+
Default: /dev/serial/by-id/usb-NVIDIA_Tegra_On-Platform_Operator_*-if01
30+
target: Target channel to extract (e.g., "CCPLEX: 0", "BPMP: 1")
31+
chip: Chip type for demuxer (T234 for Orin, T264 for Thor)
32+
baudrate: Baud rate for the serial connection
33+
cps: Characters per second throttling (optional)
34+
timeout: Timeout waiting for demuxer to detect pts
35+
poll_interval: Interval to poll for device reappearance after disconnect
36+
37+
Note:
38+
Multiple instances can be created with different targets. All instances
39+
must use the same demuxer_path, device, and chip configuration.
40+
"""
41+
42+
demuxer_path: str
43+
device: str = field(default=NV_DEVICE_PATTERN)
44+
target: str = field(default="CCPLEX: 0")
45+
chip: str = field(default="T264")
46+
baudrate: int = field(default=115200)
47+
cps: Optional[float] = field(default=None)
48+
timeout: float = field(default=10.0)
49+
poll_interval: float = field(default=1.0)
50+
51+
# Internal state (not init params)
52+
_registered: bool = field(init=False, default=False)
53+
54+
def __post_init__(self):
55+
if hasattr(super(), "__post_init__"):
56+
super().__post_init__()
57+
58+
# Register with the DemuxerManager
59+
manager = DemuxerManager.get_instance()
60+
try:
61+
manager.register_driver(
62+
driver_id=str(self.uuid),
63+
demuxer_path=self.demuxer_path,
64+
device=self.device,
65+
chip=self.chip,
66+
target=self.target,
67+
poll_interval=self.poll_interval,
68+
)
69+
self._registered = True
70+
except ValueError as e:
71+
self.logger.error("Failed to register with DemuxerManager: %s", e)
72+
raise
73+
74+
75+
@classmethod
76+
def client(cls) -> str:
77+
return "jumpstarter_driver_pyserial.client.PySerialClient"
78+
79+
def close(self):
80+
"""Unregister from the DemuxerManager."""
81+
if self._registered:
82+
manager = DemuxerManager.get_instance()
83+
manager.unregister_driver(str(self.uuid))
84+
self._registered = False
85+
86+
super().close()
87+
88+
@exportstream
89+
@asynccontextmanager
90+
async def connect(self):
91+
"""Connect to the demultiplexed serial port.
92+
93+
Waits for the demuxer to be ready (device connected and pts path discovered)
94+
before opening the serial connection.
95+
"""
96+
# Poll for pts path until available or timeout
97+
manager = DemuxerManager.get_instance()
98+
pts_start = time.monotonic()
99+
pts_path = manager.get_pts_path(str(self.uuid))
100+
while not pts_path:
101+
elapsed = time.monotonic() - pts_start
102+
if elapsed >= self.timeout:
103+
raise TimeoutError(
104+
f"Timeout waiting for demuxer to become ready (device pattern: {self.device})"
105+
)
106+
await sleep(0.1)
107+
pts_path = manager.get_pts_path(str(self.uuid))
108+
109+
cps_info = f", cps: {self.cps}" if self.cps is not None else ""
110+
self.logger.info("Connecting to %s at %s, baudrate: %d%s", self.target, pts_path, self.baudrate, cps_info)
111+
112+
reader, writer = await open_serial_connection(url=pts_path, baudrate=self.baudrate, limit=1)
113+
writer.transport.set_write_buffer_limits(high=4096, low=0)
114+
async with AsyncSerial(
115+
reader=StreamReaderWrapper(reader),
116+
writer=StreamWriterWrapper(writer),
117+
cps=self.cps,
118+
) as stream:
119+
yield stream
120+
self.logger.info("Disconnected from %s", pts_path)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Tests for NVDemuxSerial driver."""
2+
3+
import tempfile
4+
from unittest.mock import MagicMock, patch
5+
6+
from .driver import NVDemuxSerial
7+
8+
9+
def test_nvdemux_registration():
10+
"""Test that driver registers with DemuxerManager on init."""
11+
with tempfile.NamedTemporaryFile() as device_file:
12+
with patch("jumpstarter_driver_pyserial.nvdemux.driver.DemuxerManager") as mock_manager_class:
13+
mock_manager = MagicMock()
14+
mock_manager_class.get_instance.return_value = mock_manager
15+
16+
driver = NVDemuxSerial(
17+
demuxer_path="/usr/bin/demuxer",
18+
device=device_file.name,
19+
target="CCPLEX: 0",
20+
chip="T264",
21+
timeout=0.1,
22+
)
23+
24+
try:
25+
# Verify driver registered with manager
26+
mock_manager.register_driver.assert_called_once()
27+
call_kwargs = mock_manager.register_driver.call_args[1]
28+
assert call_kwargs["driver_id"] == str(driver.uuid)
29+
assert call_kwargs["demuxer_path"] == "/usr/bin/demuxer"
30+
assert call_kwargs["device"] == device_file.name
31+
assert call_kwargs["chip"] == "T264"
32+
assert call_kwargs["target"] == "CCPLEX: 0"
33+
finally:
34+
driver.close()
35+
36+
37+
def test_nvdemux_gets_pts_from_manager():
38+
"""Test that connect() gets pts path from manager."""
39+
with tempfile.NamedTemporaryFile() as device_file:
40+
with patch("jumpstarter_driver_pyserial.nvdemux.driver.DemuxerManager") as mock_manager_class:
41+
mock_manager = MagicMock()
42+
mock_manager_class.get_instance.return_value = mock_manager
43+
mock_manager.get_pts_path.return_value = "/dev/pts/5"
44+
45+
driver = NVDemuxSerial(
46+
demuxer_path="/usr/bin/demuxer",
47+
device=device_file.name,
48+
target="CCPLEX: 0",
49+
timeout=0.1,
50+
)
51+
52+
try:
53+
# Should call get_pts_path when checking pts availability
54+
# (We can't test connect() easily without mocking serial, but we can test the logic)
55+
pts_path = mock_manager.get_pts_path(str(driver.uuid))
56+
assert pts_path == "/dev/pts/5"
57+
finally:
58+
driver.close()
59+
60+
61+
def test_nvdemux_unregisters_on_close():
62+
"""Test that driver unregisters from manager on close."""
63+
with tempfile.NamedTemporaryFile() as device_file:
64+
with patch("jumpstarter_driver_pyserial.nvdemux.driver.DemuxerManager") as mock_manager_class:
65+
mock_manager = MagicMock()
66+
mock_manager_class.get_instance.return_value = mock_manager
67+
68+
driver = NVDemuxSerial(
69+
demuxer_path="/usr/bin/demuxer",
70+
device=device_file.name,
71+
target="CCPLEX: 0",
72+
timeout=0.1,
73+
)
74+
75+
driver_id = str(driver.uuid)
76+
driver.close()
77+
78+
# Verify driver unregistered
79+
mock_manager.unregister_driver.assert_called_once_with(driver_id)
80+
81+
82+
def test_nvdemux_default_values():
83+
"""Test default parameter values."""
84+
with tempfile.NamedTemporaryFile() as device_file:
85+
with patch("jumpstarter_driver_pyserial.nvdemux.driver.DemuxerManager") as mock_manager_class:
86+
mock_manager = MagicMock()
87+
mock_manager_class.get_instance.return_value = mock_manager
88+
89+
driver = NVDemuxSerial(
90+
demuxer_path="/usr/bin/demuxer",
91+
device=device_file.name,
92+
timeout=0.1,
93+
)
94+
95+
try:
96+
# Check defaults
97+
assert driver.chip == "T264"
98+
assert driver.target == "CCPLEX: 0"
99+
assert driver.baudrate == 115200
100+
assert driver.poll_interval == 1.0
101+
finally:
102+
driver.close()
103+
104+
105+
def test_nvdemux_registration_error_propagates():
106+
"""Test that registration errors are propagated."""
107+
with tempfile.NamedTemporaryFile() as device_file:
108+
with patch("jumpstarter_driver_pyserial.nvdemux.driver.DemuxerManager") as mock_manager_class:
109+
mock_manager = MagicMock()
110+
mock_manager_class.get_instance.return_value = mock_manager
111+
mock_manager.register_driver.side_effect = ValueError("Config mismatch")
112+
113+
try:
114+
_driver = NVDemuxSerial(
115+
demuxer_path="/usr/bin/demuxer",
116+
device=device_file.name,
117+
target="CCPLEX: 0",
118+
timeout=0.1,
119+
)
120+
raise AssertionError("Should have raised ValueError")
121+
except ValueError as e:
122+
assert "Config mismatch" in str(e)
123+
124+
125+
def test_nvdemux_client_class():
126+
"""Test that NVDemuxSerial uses PySerialClient."""
127+
assert NVDemuxSerial.client() == "jumpstarter_driver_pyserial.client.PySerialClient"

0 commit comments

Comments
 (0)