Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 73 additions & 11 deletions python/packages/jumpstarter-driver-nanokvm-usb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ hardware through:
- **Video capture**: Snapshots and live JPEG frame streams from the UVC device
- **Keyboard control**: Paste text and press keys via serial HID
- **Mouse control**: Absolute and relative movement, clicks, and scrolling
- **Composite driver**: Access video and HID through a unified `NanoKVMUSB` interface
- **VNC**: Embedded RFB server (view the captured HDMI and control HID from noVNC/TigerVNC)
- **Composite driver**: Access video, HID, and VNC through a unified `NanoKVMUSB` interface

## Installation

Expand All @@ -42,6 +43,11 @@ export:
video_fps: 30
screen_width: 1920
screen_height: 1080
vnc_enabled: true
# vnc_layout: es # DUT keyboard layout (default us)
# vnc_password: "secret"
# vnc_tcp_port: 5900
# vnc_tcp_bind: "0.0.0.0" # LAN; use vnc_password
```

### Config parameters
Expand All @@ -56,15 +62,24 @@ export:
| video_fps | Capture rate for `stream()` | int | no | 30 |
| screen_width | Target screen width for relative mouse moves | int | no | 1920 |
| screen_height | Target screen height for relative mouse moves | int | no | 1080 |
| vnc_enabled | Start an embedded RFB server (Unix socket child `vnc`) | bool | no | true |
| vnc_password | VNC password (VncAuth). Empty/None = no authentication | str | no | |
| vnc_tcp_port | Also bind RFB TCP on the exporter (`None` = Unix socket only) | int | no | |
| vnc_tcp_bind | Address for `vnc_tcp_port` (`127.0.0.1` or `0.0.0.0` for LAN) | str | no | 127.0.0.1 |
| vnc_encrypt | Default noVNC `encrypt` URL flag | bool | no | false |
| vnc_layout | DUT keyboard layout for VNC character injection (`us` or `es`) | str | no | us |

## Architecture

The driver is a composite with two child interfaces:
The driver is a composite with three child interfaces:

1. **video**: UVC snapshot capture and live frame streaming
2. **hid**: Keyboard and mouse control over USB serial
3. **vnc**: Unix-socket RFB endpoint (noVNC / any VNC client)

Both children share a single `NanoKVMUSBDevice` instance on the exporter so the
Video stream and VNC share a single capture pump so `/dev/video*` is opened once.
Keyboard and mouse events from the VNC client are translated to the same HID path as `hid`.
The children share a single `NanoKVMUSBDevice` instance on the exporter so the
serial port and camera are opened once.

## Video streaming
Expand All @@ -76,16 +91,14 @@ the Jumpstarter connection to whichever **client** opens the stream.
### Lifecycle

1. A client calls `video.stream("stream")` (context manager) or `open_stream()`.
2. The exporter starts an async task that captures JPEG frames from UVC and sends
them through the stream.
2. The exporter's shared FramePump already captures JPEG frames from UVC; the
stream task forwards those frames to the client.
3. The client reads frames with `stream.receive()` — each message is one JPEG.
4. When the client closes the context (or calls `close()`), the exporter stops
capturing and releases the stream.
the stream task. Capture continues if VNC or another stream is still using
the pump.

While the stream is active, the exporter dedicates a background task to video
capture. This does **not** block the whole exporter process (it is async), but it
does keep the UVC device busy until the client disconnects. HID commands remain
available on the `hid` child during streaming.
HID commands remain available on the `hid` child during streaming and VNC.

For recording, OCR, frame deduplication, and preprocessing without blocking
``jmp shell``, use ``edge-clearance-delivery/video-receiver/`` (``stream-bridge.py`` +
Expand All @@ -110,11 +123,49 @@ with video.stream("stream") as stream:
frame_jpeg = stream.receive()
```

## VNC

The exporter runs an RFB 3.8 server on a Unix socket. Jumpstarter tunnels that
socket to the **client** (same pattern as QEMU): you do not need to be on the
exporter host. Keyboard and mouse in the VNC client go to the NanoKVM-USB HID.

**From the Jumpstarter client** (any machine with a lease):

```bash
j nanokvm-usb vnc session
```

That opens noVNC against a TCP/WebSocket port on *your* machine. For a native
viewer (TigerVNC, Remmina) on the client:

```bash
j nanokvm-usb vnc forward-tcp 5900
# then connect to localhost:5900
```

Or from Python:

```python
with lease.drivers["nanokvm-usb"].session() as url:
print(url) # noVNC URL
```

**TCP on the exporter** (optional): set `vnc_tcp_port` to bind RFB on the exporter
host. Default `vnc_tcp_bind` is `127.0.0.1` (local viewers on that machine only).
Use `vnc_tcp_bind: "0.0.0.0"` to accept LAN clients without a Jumpstarter tunnel.
Binding a non-loopback address without `vnc_password` exposes an unauthenticated
session on the network; set a password.

Printable keys are injected as HID combos for **`vnc_layout`** (the DUT OS
keyboard), not the VNC client's layout. Default is `us`. Set `vnc_layout: es`
if the DUT uses a Spanish keyboard so `@`, `ñ`, and AltGr characters land
correctly even when Remmina is using another layout.

## API reference

### NanoKVMUSBClient

Composite client with `video` and `hid` children.
Composite client with `video`, `hid`, and `vnc` children.

### NanoKVMUSBVideoClient

Expand All @@ -130,6 +181,13 @@ Composite client with `video` and `hid` children.
:members: paste_text, press_key, reset_hid, mouse_move_abs, mouse_move_rel, mouse_click, mouse_scroll
```

### NanoKVMUSBVNCClient

```{eval-rst}
.. autoclass:: jumpstarter_driver_nanokvm_usb.client.NanoKVMUSBVNCClient()
:members: session
```

## CLI usage

```bash
Expand All @@ -143,6 +201,10 @@ j nanokvm-usb hid press enter
# Mouse
j nanokvm-usb hid mouse move 0.5 0.5
j nanokvm-usb hid mouse click --button left --x 0.5 --y 0.5

# VNC (view + HID) via Jumpstarter tunnel
j nanokvm-usb vnc session
j nanokvm-usb vnc forward-tcp 5900
```

## Host requirements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ export:
video_fps: 30
screen_width: 1920
screen_height: 1080
vnc_enabled: true
# vnc_layout: es # DUT keyboard layout (default us)
# vnc_password: "secret"
# vnc_tcp_port: 5900
# vnc_tcp_bind: "0.0.0.0" # LAN; use vnc_password
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from .client import NanoKVMUSBClient, NanoKVMUSBHIDClient, NanoKVMUSBVideoClient
from .driver import NanoKVMUSB, NanoKVMUSBHID, NanoKVMUSBVideo
from .client import NanoKVMUSBClient, NanoKVMUSBHIDClient, NanoKVMUSBVideoClient, NanoKVMUSBVNCClient
from .driver import NanoKVMUSB, NanoKVMUSBHID, NanoKVMUSBVideo, NanoKVMUSBVNC
from .mouse import MouseButton

__all__ = [
"NanoKVMUSB",
"NanoKVMUSBVideo",
"NanoKVMUSBHID",
"NanoKVMUSBVNC",
"NanoKVMUSBClient",
"NanoKVMUSBVideoClient",
"NanoKVMUSBHIDClient",
"NanoKVMUSBVNCClient",
"MouseButton",
]
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import io
import webbrowser
from base64 import b64decode
from contextlib import contextmanager
from dataclasses import dataclass

import anyio
import click
from jumpstarter_driver_composite.client import CompositeClient
from jumpstarter_driver_network.adapters.novnc import NovncAdapter
from jumpstarter_driver_network.client import NetworkClient
from PIL import Image

from .mouse import MouseButton, resolve_button
from jumpstarter.client import DriverClient
from jumpstarter.client.decorators import driver_click_group

__all__ = ["NanoKVMUSBVideoClient", "NanoKVMUSBHIDClient", "NanoKVMUSBClient", "MouseButton"]
__all__ = [
"NanoKVMUSBVideoClient",
"NanoKVMUSBHIDClient",
"NanoKVMUSBClient",
"NanoKVMUSBVNCClient",
"MouseButton",
]


def _decode_cli_escapes(text: str) -> str:
Expand Down Expand Up @@ -161,6 +171,51 @@ def scroll(dx, dy):
return base


class NanoKVMUSBVNCClient(NetworkClient):
"""Client for the embedded RFB endpoint (noVNC session)."""

def get_default_encrypt(self) -> bool:
return bool(self.call("get_default_encrypt"))

@contextmanager
def session(self, *, encrypt: bool | None = None):
use_encrypt = self.get_default_encrypt() if encrypt is None else encrypt
with NovncAdapter(client=self, method="connect", encrypt=use_encrypt) as url:
yield url

def cli(self):
base = super().cli()

@base.command()
@click.option("--browser/--no-browser", default=True, help="Open the session in a web browser.")
@click.option(
"--encrypt",
"encrypt_override",
flag_value=True,
default=None,
help="Force an encrypted noVNC connection.",
)
@click.option(
"--no-encrypt",
"encrypt_override",
flag_value=False,
help="Force an unencrypted noVNC connection.",
)
def session(browser: bool, encrypt_override: bool | None):
"""Open a noVNC session to the captured HDMI and HID."""
with self.session(encrypt=encrypt_override) as url:
click.echo(f"To connect, please visit: {url}")
if browser:
webbrowser.open(url)
click.echo("Press Ctrl+C to close the VNC session.")
try:
self.portal.call(anyio.sleep_forever)
except (KeyboardInterrupt, anyio.get_cancelled_exc_class()):
click.echo("\nClosing VNC session.")

return base


@dataclass(kw_only=True)
class NanoKVMUSBClient(CompositeClient):
"""
Expand All @@ -169,7 +224,16 @@ class NanoKVMUSBClient(CompositeClient):
Provides access to:
- video: UVC snapshot capture and live stream
- hid: keyboard and mouse control over USB serial
- vnc: embedded RFB / noVNC session (when vnc_enabled)
"""

@contextmanager
def session(self, *, encrypt: bool | None = None):
"""Open a noVNC session via the ``vnc`` child."""
if not hasattr(self, "vnc"):
raise RuntimeError("VNC is disabled (vnc_enabled=false)")
with self.vnc.session(encrypt=encrypt) as url:
yield url

def cli(self):
return super().cli()
Loading
Loading