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 )
0 commit comments