Skip to content

Commit c32eef0

Browse files
committed
add mouse hid actions and provide context manager
This commit adds mouse HID functionality, and provides a context-manager for the whole NanoKVMClient, in a way that a separate context manager is not necessary anymore. This is backwards imcompatible, but it should be easy to switch from: ``` async with ClientSession() as session: client = NanoKVMClient("http://kvm-8b76.local/api/", session) await client.authenticate("username", "password") ``` to: ``` async with NanoKVMClient("http://kvm-8b76.local/api/", session) as client: await client.authenticate("username", "password") ```
1 parent 4b861d6 commit c32eef0

File tree

5 files changed

+430
-38
lines changed

5 files changed

+430
-38
lines changed

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,33 @@ Async Python client for [NanoKVM](https://github.com/sipeed/NanoKVM).
55
## Usage
66

77
```python
8-
9-
from aiohttp import ClientSession
10-
from nanokvm.models import GpioType
118
from nanokvm.client import NanoKVMClient
9+
from nanokvm.models import GpioType, MouseButton
1210

13-
14-
async with ClientSession() as session:
15-
client = NanoKVMClient("http://kvm-8b76.local/api/", session)
11+
async with NanoKVMClient("http://kvm-8b76.local/api/") as client:
1612
await client.authenticate("username", "password")
1713

14+
# Get device information
1815
dev = await client.get_info()
1916
hw = await client.get_hardware()
2017
gpio = await client.get_gpio()
2118

19+
# List available images
20+
images = await client.get_images()
21+
22+
# Keyboard input
2223
await client.paste_text("Hello\nworld!")
2324

25+
# Mouse control
26+
await client.mouse_click(MouseButton.LEFT, 0.5, 0.5)
27+
await client.mouse_move_abs(0.25, 0.75)
28+
await client.mouse_scroll(0, -3)
29+
30+
# Stream video
2431
async for frame in client.mjpeg_stream():
2532
print(frame)
2633

34+
# Control GPIO
2735
await client.push_button(GpioType.POWER, duration_ms=1000)
2836
```
2937

nanokvm/client.py

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
LoginReq,
5050
LoginRsp,
5151
MountImageReq,
52+
MouseButton,
5253
MouseJigglerMode,
5354
PasteReq,
5455
SetGpioReq,
@@ -109,22 +110,45 @@ class NanoKVMClient:
109110
def __init__(
110111
self,
111112
url: str,
112-
session: ClientSession,
113113
*,
114114
token: str | None = None,
115115
request_timeout: int = 10,
116116
) -> None:
117-
"""Initialize the NanoKVM client."""
117+
"""
118+
Initialize the NanoKVM client.
119+
120+
Args:
121+
url: Base URL of the NanoKVM API (e.g., "http://192.168.1.1/api/")
122+
token: Optional pre-existing authentication token
123+
request_timeout: Request timeout in seconds (default: 10)
124+
"""
118125
self.url = yarl.URL(url)
119-
self.session = session
126+
self._session: ClientSession | None = None
120127
self._token = token
121128
self._request_timeout = request_timeout
129+
self._ws: aiohttp.ClientWebSocketResponse | None = None
122130

123131
@property
124132
def token(self) -> str | None:
125133
"""Return the current auth token."""
126134
return self._token
127135

136+
async def __aenter__(self) -> NanoKVMClient:
137+
"""Async context manager entry."""
138+
self._session = ClientSession()
139+
return self
140+
141+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
142+
"""Async context manager exit - cleanup resources."""
143+
# Close WebSocket connection
144+
if self._ws is not None and not self._ws.closed:
145+
await self._ws.close()
146+
self._ws = None
147+
# Close HTTP session
148+
if self._session is not None:
149+
await self._session.close()
150+
self._session = None
151+
128152
@contextlib.asynccontextmanager
129153
async def _request(
130154
self,
@@ -135,13 +159,17 @@ async def _request(
135159
**kwargs: Any,
136160
) -> AsyncIterator[ClientResponse]:
137161
"""Make an API request."""
162+
assert self._session is not None, (
163+
"Client session not initialized. "
164+
"Use as context manager: 'async with NanoKVMClient(url) as client:'"
165+
)
138166
cookies = {}
139167
if authenticate:
140168
if not self._token:
141169
raise NanoKVMNotAuthenticatedError("Client is not authenticated")
142170
cookies["nano-kvm-token"] = self._token
143171

144-
async with self.session.request(
172+
async with self._session.request(
145173
method,
146174
self.url / path.lstrip("/"),
147175
headers={
@@ -663,3 +691,131 @@ async def set_mouse_jiggler_state(
663691
"/vm/mouse-jiggler",
664692
data=SetMouseJigglerReq(enabled=enabled, mode=mode),
665693
)
694+
695+
async def _get_ws(self) -> aiohttp.ClientWebSocketResponse:
696+
"""Get or create WebSocket connection for mouse events."""
697+
if self._ws is None or self._ws.closed:
698+
assert self._session is not None, (
699+
"Client session not initialized. "
700+
"Use as context manager: 'async with NanoKVMClient(url) as client:'"
701+
)
702+
703+
if not self._token:
704+
raise NanoKVMNotAuthenticatedError("Client is not authenticated")
705+
706+
# WebSocket URL uses ws:// or wss:// scheme
707+
scheme = "ws" if self.url.scheme == "http" else "wss"
708+
ws_url = self.url.with_scheme(scheme) / "ws"
709+
710+
self._ws = await self._session.ws_connect(
711+
str(ws_url),
712+
headers={"Cookie": f"nano-kvm-token={self._token}"},
713+
)
714+
return self._ws
715+
716+
async def _send_mouse_event(
717+
self, event_type: int, button_state: int, x: float, y: float
718+
) -> None:
719+
"""
720+
Send a mouse event via WebSocket.
721+
722+
Args:
723+
event_type: 0=mouse_up, 1=mouse_down, 2=move_abs, 3=move_rel, 4=scroll
724+
button_state: Button state (0=no buttons, 1=left, 2=right, 4=middle)
725+
x: X coordinate (0.0-1.0 for abs/rel/scroll) or 0.0 for button events
726+
y: Y coordinate (0.0-1.0 for abs/rel/scroll) or 0.0 for button events
727+
"""
728+
ws = await self._get_ws()
729+
730+
# Scale coordinates for absolute/relative movements and scroll
731+
if event_type in (2, 3, 4): # move_abs, move_rel, or scroll
732+
x_val = int(x * 32768)
733+
y_val = int(y * 32768)
734+
else:
735+
x_val = int(x)
736+
y_val = int(y)
737+
738+
# Message format: [2, event_type, button_state, x_val, y_val]
739+
# where 2 indicates mouse event
740+
message = [2, event_type, button_state, x_val, y_val]
741+
742+
_LOGGER.debug("Sending mouse event: %s", message)
743+
await ws.send_json(message)
744+
745+
async def mouse_move_abs(self, x: float, y: float) -> None:
746+
"""
747+
Move mouse to absolute position.
748+
749+
Args:
750+
x: X coordinate (0.0 to 1.0, left to right)
751+
y: Y coordinate (0.0 to 1.0, top to bottom)
752+
"""
753+
await self._send_mouse_event(2, 0, x, y)
754+
755+
async def mouse_move_rel(self, dx: float, dy: float) -> None:
756+
"""
757+
Move mouse relative to current position.
758+
759+
Args:
760+
dx: Horizontal movement (-1.0 to 1.0)
761+
dy: Vertical movement (-1.0 to 1.0)
762+
"""
763+
await self._send_mouse_event(3, 0, dx, dy)
764+
765+
async def mouse_down(self, button: MouseButton = MouseButton.LEFT) -> None:
766+
"""
767+
Press a mouse button.
768+
769+
Args:
770+
button: Mouse button to press (MouseButton.LEFT, MouseButton.RIGHT,
771+
MouseButton.MIDDLE)
772+
"""
773+
await self._send_mouse_event(1, int(button), 0.0, 0.0)
774+
775+
async def mouse_up(self) -> None:
776+
"""
777+
Release a mouse button.
778+
779+
Note: Mouse up event always uses button_state=0 per the NanoKVM protocol.
780+
"""
781+
await self._send_mouse_event(0, 0, 0.0, 0.0)
782+
783+
async def mouse_click(
784+
self,
785+
button: MouseButton = MouseButton.LEFT,
786+
x: float | None = None,
787+
y: float | None = None,
788+
) -> None:
789+
"""
790+
Click a mouse button at current position or specified coordinates.
791+
792+
Args:
793+
button: Mouse button to click (MouseButton.LEFT, MouseButton.RIGHT,
794+
MouseButton.MIDDLE)
795+
x: Optional X coordinate (0.0 to 1.0) for absolute positioning
796+
before click
797+
y: Optional Y coordinate (0.0 to 1.0) for absolute positioning
798+
before click
799+
"""
800+
# Move to position if coordinates provided
801+
if x is not None and y is not None:
802+
await self.mouse_move_abs(x, y)
803+
# Small delay to ensure position update
804+
await asyncio.sleep(0.05)
805+
806+
# Send mouse down
807+
await self.mouse_down(button)
808+
# Small delay between down and up
809+
await asyncio.sleep(0.05)
810+
# Send mouse up
811+
await self.mouse_up()
812+
813+
async def mouse_scroll(self, dx: float, dy: float) -> None:
814+
"""
815+
Scroll the mouse wheel.
816+
817+
Args:
818+
dx: Horizontal scroll amount (-1.0 to 1.0)
819+
dy: Vertical scroll amount (-1.0 to 1.0, positive=up, negative=down)
820+
"""
821+
await self._send_mouse_event(4, 0, dx, dy)

nanokvm/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ class MouseJigglerMode(StrEnum):
102102
RELATIVE = "relative"
103103

104104

105+
class MouseButton(IntEnum):
106+
"""Mouse Button types."""
107+
108+
LEFT = 1
109+
RIGHT = 2
110+
MIDDLE = 4
111+
112+
105113
# Generic Response Wrapper
106114
class ApiResponse(BaseModel, Generic[T]):
107115
"""Generic API response structure."""

tests/test_client.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
1-
from aiohttp import ClientSession
21
from aioresponses import aioresponses
2+
import pytest
33

44
from nanokvm.client import NanoKVMApiError, NanoKVMClient
55
from nanokvm.models import ApiResponseCode
66

77

8-
async def test_client() -> None:
9-
"""Test the NanoKVMClient."""
10-
async with ClientSession() as session:
11-
client = NanoKVMClient("http://localhost:8888/api/", session)
12-
assert client is not None
13-
14-
158
async def test_get_images_success() -> None:
169
"""Test get_images with a successful response."""
17-
async with ClientSession() as session:
18-
client = NanoKVMClient(
19-
"http://localhost:8888/api/", session, token="test-token"
20-
)
21-
10+
async with NanoKVMClient(
11+
"http://localhost:8888/api/", token="test-token"
12+
) as client:
2213
with aioresponses() as m:
2314
m.get(
2415
"http://localhost:8888/api/storage/image",
@@ -44,11 +35,9 @@ async def test_get_images_success() -> None:
4435

4536
async def test_get_images_empty() -> None:
4637
"""Test get_images with an empty list."""
47-
async with ClientSession() as session:
48-
client = NanoKVMClient(
49-
"http://localhost:8888/api/", session, token="test-token"
50-
)
51-
38+
async with NanoKVMClient(
39+
"http://localhost:8888/api/", token="test-token"
40+
) as client:
5241
with aioresponses() as m:
5342
m.get(
5443
"http://localhost:8888/api/storage/image",
@@ -63,20 +52,30 @@ async def test_get_images_empty() -> None:
6352

6453
async def test_get_images_api_error() -> None:
6554
"""Test get_images with an API error response."""
66-
async with ClientSession() as session:
67-
client = NanoKVMClient(
68-
"http://localhost:8888/api/", session, token="test-token"
69-
)
70-
55+
async with NanoKVMClient(
56+
"http://localhost:8888/api/", token="test-token"
57+
) as client:
7158
with aioresponses() as m:
7259
m.get(
7360
"http://localhost:8888/api/storage/image",
7461
payload={"code": -1, "msg": "failed to list images", "data": None},
7562
)
7663

77-
try:
64+
with pytest.raises(NanoKVMApiError) as exc_info:
7865
await client.get_images()
79-
raise AssertionError("Expected NanoKVMApiError to be raised")
80-
except NanoKVMApiError as e:
81-
assert e.code == ApiResponseCode.FAILURE
82-
assert "failed to list images" in e.msg
66+
67+
assert exc_info.value.code == ApiResponseCode.FAILURE
68+
assert "failed to list images" in exc_info.value.msg
69+
70+
71+
async def test_client_context_manager() -> None:
72+
"""Test that client properly initializes and cleans up with context manager."""
73+
async with NanoKVMClient(
74+
"http://localhost:8888/api/", token="test-token"
75+
) as client:
76+
# Verify session is created
77+
assert client._session is not None
78+
assert not client._session.closed
79+
80+
# After exiting context, session should be closed
81+
assert client._session is None

0 commit comments

Comments
 (0)