airmouse-core 2.0.0__py3-none-any.whl

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.
airmouse/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ AirMouse Core Library
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+ Production-grade Python package for AirMouse servers and input processing.
5
+ """
6
+
7
+ from core.jitter_buffer import UDPJitterBuffer
8
+ from core.one_euro import OneEuroFilter
9
+ from network.protocol import ProtocolParser
10
+ from security.auth import AuthManager
11
+ from airmouse.server import AirMouseServer
12
+
13
+ __version__ = "2.0.0"
14
+
15
+ __all__ = [
16
+ "AirMouseServer",
17
+ "OneEuroFilter",
18
+ "UDPJitterBuffer",
19
+ "AuthManager",
20
+ "ProtocolParser",
21
+ ]
airmouse/cli.py ADDED
@@ -0,0 +1,201 @@
1
+ """
2
+ airmouse.cli
3
+ ------------
4
+ Command Line Interface for AirMouse server.
5
+ Generates terminal QR code for zero-friction client pairing and runs the server out-of-the-box.
6
+ """
7
+
8
+ import argparse
9
+ import asyncio
10
+ import io
11
+ import json
12
+ import logging
13
+ import os
14
+ import signal
15
+ import socket
16
+ import sys
17
+ from typing import Optional
18
+
19
+ import qrcode
20
+
21
+ from airmouse.server import AirMouseServer
22
+ from security.auth import AuthManager
23
+
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
27
+ )
28
+ logger = logging.getLogger("AirMouseCLI")
29
+
30
+
31
+ def get_local_ip() -> str:
32
+ """Best-effort discovery of non-loopback LAN IP address."""
33
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
34
+ try:
35
+ # Does not actually connect but picks the outgoing route interface
36
+ s.connect(("8.8.8.8", 80))
37
+ ip = s.getsockname()[0]
38
+ except Exception:
39
+ ip = "127.0.0.1"
40
+ finally:
41
+ s.close()
42
+ return ip
43
+
44
+
45
+ def display_terminal_qr(data: str) -> None:
46
+ """Prints an ASCII/ANSI QR code directly in the terminal."""
47
+ qr = qrcode.QRCode(
48
+ version=1,
49
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
50
+ box_size=1,
51
+ border=1,
52
+ )
53
+ qr.add_data(data)
54
+ qr.make(fit=True)
55
+
56
+ f = io.StringIO()
57
+ qr.print_ascii(out=f, invert=True)
58
+ f.seek(0)
59
+ print(f.read())
60
+
61
+
62
+ def parse_args(argv=None) -> argparse.Namespace:
63
+ parser = argparse.ArgumentParser(
64
+ prog="airmouse-server",
65
+ description="AirMouse Server - Zero-latency, TLS-secured remote input server",
66
+ )
67
+ parser.add_argument(
68
+ "--host",
69
+ type=str,
70
+ default="0.0.0.0",
71
+ help="IP address to bind to (default: 0.0.0.0)",
72
+ )
73
+ parser.add_argument(
74
+ "--port",
75
+ type=int,
76
+ default=8080,
77
+ help="TCP listening port (default: 8080)",
78
+ )
79
+ parser.add_argument(
80
+ "--ws-port",
81
+ type=int,
82
+ default=8081,
83
+ help="WebSocket listening port (default: 8081)",
84
+ )
85
+ parser.add_argument(
86
+ "--udp-port",
87
+ type=int,
88
+ default=8083,
89
+ help="UDP discovery broadcast listening port (default: 8083)",
90
+ )
91
+ parser.add_argument(
92
+ "--no-tls",
93
+ action="store_true",
94
+ help="Disable TLS encryption (TLS 1.2+ is enabled by default)",
95
+ )
96
+ parser.add_argument(
97
+ "--no-smoothing",
98
+ action="store_true",
99
+ help="Disable 1€ adaptive low-pass filter smoothing",
100
+ )
101
+ parser.add_argument(
102
+ "--sensitivity",
103
+ type=float,
104
+ default=1.0,
105
+ help="Mouse speed multiplier (default: 1.0)",
106
+ )
107
+ parser.add_argument(
108
+ "--qr",
109
+ action="store_true",
110
+ help="Display terminal QR code for instant Android client pairing",
111
+ )
112
+ parser.add_argument(
113
+ "--name",
114
+ type=str,
115
+ default="AirMouse Server",
116
+ help="Friendly server display name",
117
+ )
118
+ return parser.parse_args(argv)
119
+
120
+
121
+ async def run_server(args: argparse.Namespace) -> None:
122
+ auth_mgr = AuthManager()
123
+ token = auth_mgr.generate_token("cli_client")
124
+ lan_ip = get_local_ip()
125
+
126
+ enable_tls = not args.no_tls
127
+
128
+ pairing_payload = {
129
+ "host": lan_ip,
130
+ "tcp_port": args.port,
131
+ "ws_port": args.ws_port,
132
+ "token": token,
133
+ "tls": enable_tls,
134
+ "name": args.name,
135
+ }
136
+ pairing_json = json.dumps(pairing_payload)
137
+
138
+ print("=" * 60)
139
+ print(f" {args.name}")
140
+ print(f" LAN Address: {lan_ip}")
141
+ print(f" TCP Port : {args.port} (TLS: {enable_tls})")
142
+ print(f" WS Port : {args.ws_port} (TLS: {enable_tls})")
143
+ print(f" UDP Port : {args.udp_port}")
144
+ print(f" Pairing Token: {token}")
145
+ print("=" * 60)
146
+
147
+ if args.qr:
148
+ print("\nScan this QR code with the AirMouse Android App to pair:\n")
149
+ try:
150
+ display_terminal_qr(pairing_json)
151
+ except Exception as e:
152
+ logger.warning(f"Could not print QR code to terminal: {e}")
153
+
154
+ server = AirMouseServer(
155
+ host=args.host,
156
+ tcp_port=args.port,
157
+ ws_port=args.ws_port,
158
+ udp_port=args.udp_port,
159
+ enable_tls=enable_tls,
160
+ auth_manager=auth_mgr,
161
+ sensitivity=args.sensitivity,
162
+ smoothing=not args.no_smoothing,
163
+ server_name=args.name,
164
+ )
165
+
166
+ server.on_connect(lambda s_id: logger.info(f"[Event] Client Connected: {s_id}"))
167
+ server.on_disconnect(lambda s_id: logger.info(f"[Event] Client Disconnected: {s_id}"))
168
+ server.on_click(lambda btn: logger.debug(f"[Event] Mouse Click: {btn}"))
169
+
170
+ await server.start()
171
+ logger.info("AirMouse server started successfully and ready for connections.")
172
+
173
+ stop_event = asyncio.Event()
174
+
175
+ def _shutdown():
176
+ logger.info("Shutdown signal received, shutting down gracefully...")
177
+ stop_event.set()
178
+
179
+ for sig in (signal.SIGINT, signal.SIGTERM):
180
+ try:
181
+ asyncio.get_running_loop().add_signal_handler(sig, _shutdown)
182
+ except NotImplementedError:
183
+ pass
184
+
185
+ try:
186
+ await stop_event.wait()
187
+ finally:
188
+ await server.stop()
189
+ logger.info("AirMouse server shut down cleanly.")
190
+
191
+
192
+ def main(argv=None) -> None:
193
+ args = parse_args(argv)
194
+ try:
195
+ asyncio.run(run_server(args))
196
+ except KeyboardInterrupt:
197
+ pass
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
airmouse/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561 type checking.
airmouse/server.py ADDED
@@ -0,0 +1,442 @@
1
+ """
2
+ airmouse.server
3
+ ---------------
4
+ Asyncio TCP & WebSocket dual-stack server with TLS 1.2+ encryption,
5
+ UDP discovery responder with challenge-response nonces,
6
+ thread-safe mouse command queue and actuator thread,
7
+ and event callbacks: on_connect, on_disconnect, on_move, on_click.
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+ import queue
13
+ import socket
14
+ import ssl
15
+ import threading
16
+ import time
17
+ from typing import Any, Callable, Dict, Optional, Set, Tuple
18
+
19
+ from core.actuator import MouseActuatorThread
20
+ from core.mouse_controller import MouseController
21
+ from network.protocol import (
22
+ BoundsViolationError,
23
+ ClickPayload,
24
+ HelloPayload,
25
+ MovePayload,
26
+ ProtocolParser,
27
+ ReplayAttackError,
28
+ SchemaViolationError,
29
+ ScrollPayload,
30
+ )
31
+ from network.udp_discovery import UDPDiscoveryServer
32
+ from security.auth import AuthManager
33
+ from security.tls_manager import TLSManager
34
+
35
+ try:
36
+ import websockets
37
+ import websockets.server
38
+ WEBSOCKETS_AVAILABLE = True
39
+ except ImportError:
40
+ WEBSOCKETS_AVAILABLE = False
41
+
42
+ logger = logging.getLogger("AirMouseServer")
43
+
44
+
45
+ class AirMouseServer:
46
+ """
47
+ High-level AirMouse server combining TCP, WebSocket, and UDP discovery services.
48
+ Decouples networking from OS mouse actuation using an internal thread-safe queue.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ host: str = "0.0.0.0",
54
+ tcp_port: int = 8080,
55
+ ws_port: int = 8081,
56
+ udp_port: int = 8083,
57
+ enable_tls: bool = True,
58
+ auth_manager: Optional[AuthManager] = None,
59
+ tls_manager: Optional[TLSManager] = None,
60
+ sensitivity: float = 1.0,
61
+ smoothing: bool = True,
62
+ server_name: str = "AirMouse Desktop Server",
63
+ ) -> None:
64
+ self.host = host
65
+ self.tcp_port = tcp_port
66
+ self.ws_port = ws_port
67
+ self.udp_port = udp_port
68
+ self.enable_tls = enable_tls
69
+ self.server_name = server_name
70
+
71
+ self.auth_manager = auth_manager or AuthManager()
72
+ self.tls_manager = tls_manager or (TLSManager() if enable_tls else None)
73
+
74
+ # Thread-safe queue for OS mouse actuation
75
+ self.mouse_queue: queue.Queue = queue.Queue()
76
+ self.actuator = MouseActuatorThread(
77
+ command_queue=self.mouse_queue,
78
+ sensitivity=sensitivity,
79
+ smoothing=smoothing,
80
+ )
81
+ self._actuator_thread: Optional[threading.Thread] = None
82
+
83
+ # Protocol parser with per-session sequence and replay attack checks
84
+ self.parser = ProtocolParser(replay_window_ms=5000)
85
+
86
+ # UDP Discovery
87
+ self.udp_discovery: Optional[UDPDiscoveryServer] = None
88
+
89
+ # Server handles
90
+ self._tcp_server: Optional[asyncio.Server] = None
91
+ self._ws_server: Optional[Any] = None
92
+ self._is_running = False
93
+
94
+ # Session tracking
95
+ self.authenticated_sessions: Set[str] = set()
96
+ self.active_tcp_writers: Dict[str, asyncio.StreamWriter] = {}
97
+
98
+ # Event callbacks
99
+ self._on_connect_callbacks: list[Callable[[str], None]] = []
100
+ self._on_disconnect_callbacks: list[Callable[[str], None]] = []
101
+ self._on_move_callbacks: list[Callable[[float, float], None]] = []
102
+ self._on_click_callbacks: list[Callable[[str], None]] = []
103
+
104
+ def on_connect(self, callback: Callable[[str], None]) -> None:
105
+ """Register a callback invoked when a client connects and authenticates."""
106
+ self._on_connect_callbacks.append(callback)
107
+
108
+ def on_disconnect(self, callback: Callable[[str], None]) -> None:
109
+ """Register a callback invoked when a client disconnects."""
110
+ self._on_disconnect_callbacks.append(callback)
111
+
112
+ def on_move(self, callback: Callable[[float, float], None]) -> None:
113
+ """Register a callback invoked when a motion event is processed."""
114
+ self._on_move_callbacks.append(callback)
115
+
116
+ def on_click(self, callback: Callable[[str], None]) -> None:
117
+ """Register a callback invoked when a mouse click event is processed."""
118
+ self._on_click_callbacks.append(callback)
119
+
120
+ def _trigger_connect(self, session_id: str) -> None:
121
+ for cb in self._on_connect_callbacks:
122
+ try:
123
+ cb(session_id)
124
+ except Exception as e:
125
+ logger.error(f"Error in on_connect callback: {e}")
126
+
127
+ def _trigger_disconnect(self, session_id: str) -> None:
128
+ for cb in self._on_disconnect_callbacks:
129
+ try:
130
+ cb(session_id)
131
+ except Exception as e:
132
+ logger.error(f"Error in on_disconnect callback: {e}")
133
+
134
+ def _trigger_move(self, dx: float, dy: float) -> None:
135
+ for cb in self._on_move_callbacks:
136
+ try:
137
+ cb(dx, dy)
138
+ except Exception as e:
139
+ logger.error(f"Error in on_move callback: {e}")
140
+
141
+ def _trigger_click(self, button: str) -> None:
142
+ for cb in self._on_click_callbacks:
143
+ try:
144
+ cb(button)
145
+ except Exception as e:
146
+ logger.error(f"Error in on_click callback: {e}")
147
+
148
+ async def _handle_tcp_client(
149
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
150
+ ) -> None:
151
+ peer = writer.get_extra_info("peername")
152
+ session_id = f"{peer[0]}:{peer[1]}" if peer else f"tcp_{id(writer)}"
153
+ self.active_tcp_writers[session_id] = writer
154
+ logger.info(f"TCP client connected from {session_id}")
155
+
156
+ try:
157
+ while True:
158
+ line = await reader.readline()
159
+ if not line:
160
+ break
161
+
162
+ raw_json = line.decode("utf-8").strip()
163
+ if not raw_json:
164
+ continue
165
+
166
+ try:
167
+ msg_type, payload, req_id, seq = self.parser.parse_message(
168
+ raw_json, session_id
169
+ )
170
+ except ReplayAttackError as re:
171
+ logger.warning(f"Replay rejected for {session_id}: {re}")
172
+ nack = self.parser.serialize_response(
173
+ "nack", {"nack_id": "", "error": "replay_detected"}, seq=0
174
+ )
175
+ writer.write(nack.encode("utf-8"))
176
+ await writer.drain()
177
+ continue
178
+ except (SchemaViolationError, BoundsViolationError) as se:
179
+ logger.warning(f"Schema violation from {session_id}: {se}")
180
+ nack = self.parser.serialize_response(
181
+ "nack", {"nack_id": "", "error": str(se)}, seq=0
182
+ )
183
+ writer.write(nack.encode("utf-8"))
184
+ await writer.drain()
185
+ continue
186
+
187
+ # Authentication check
188
+ if session_id not in self.authenticated_sessions:
189
+ if msg_type != "hello" or not isinstance(payload, HelloPayload):
190
+ logger.warning(f"Unauthenticated packet {msg_type} from {session_id}")
191
+ nack = self.parser.serialize_response(
192
+ "nack", {"nack_id": req_id or "", "error": "unauthenticated"}, seq=seq
193
+ )
194
+ writer.write(nack.encode("utf-8"))
195
+ await writer.drain()
196
+ break
197
+
198
+ if not self.auth_manager.validate_token(payload.token):
199
+ logger.warning(f"Invalid auth token from {session_id}")
200
+ nack = self.parser.serialize_response(
201
+ "nack", {"nack_id": req_id or "", "error": "invalid_token"}, seq=seq
202
+ )
203
+ writer.write(nack.encode("utf-8"))
204
+ await writer.drain()
205
+ break
206
+
207
+ self.authenticated_sessions.add(session_id)
208
+ logger.info(f"Client {session_id} successfully authenticated ({payload.name})")
209
+ self._trigger_connect(session_id)
210
+
211
+ welcome = self.parser.serialize_response(
212
+ "welcome",
213
+ {
214
+ "server": self.server_name,
215
+ "version": "2.0.0",
216
+ "client_id": session_id,
217
+ "approved": True,
218
+ },
219
+ seq=seq,
220
+ req_id=req_id,
221
+ )
222
+ writer.write(welcome.encode("utf-8"))
223
+ await writer.drain()
224
+ continue
225
+
226
+ # Authenticated event dispatch
227
+ if msg_type == "move" and isinstance(payload, MovePayload):
228
+ self.mouse_queue.put(("move", payload.dx, payload.dy))
229
+ self._trigger_move(payload.dx, payload.dy)
230
+
231
+ elif msg_type == "click" and isinstance(payload, ClickPayload):
232
+ self.mouse_queue.put(("click", payload.button))
233
+ self._trigger_click(payload.button)
234
+ ack = self.parser.serialize_response(
235
+ "ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
236
+ )
237
+ writer.write(ack.encode("utf-8"))
238
+ await writer.drain()
239
+
240
+ elif msg_type == "scroll" and isinstance(payload, ScrollPayload):
241
+ self.mouse_queue.put(("scroll", payload.delta))
242
+ ack = self.parser.serialize_response(
243
+ "ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
244
+ )
245
+ writer.write(ack.encode("utf-8"))
246
+ await writer.drain()
247
+
248
+ elif msg_type == "ping":
249
+ pong = self.parser.serialize_response("pong", {}, seq=seq, req_id=req_id)
250
+ writer.write(pong.encode("utf-8"))
251
+ await writer.drain()
252
+
253
+ except asyncio.CancelledError:
254
+ pass
255
+ except Exception as e:
256
+ logger.error(f"TCP exception on {session_id}: {e}")
257
+ finally:
258
+ self.active_tcp_writers.pop(session_id, None)
259
+ was_auth = session_id in self.authenticated_sessions
260
+ self.authenticated_sessions.discard(session_id)
261
+ self.parser.reset_session(session_id)
262
+ try:
263
+ writer.close()
264
+ await writer.wait_closed()
265
+ except Exception:
266
+ pass
267
+ if was_auth:
268
+ self._trigger_disconnect(session_id)
269
+ logger.info(f"TCP connection closed for {session_id}")
270
+
271
+ async def _handle_ws_client(self, websocket: Any) -> None:
272
+ session_id = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}"
273
+ logger.info(f"WebSocket client connected from {session_id}")
274
+
275
+ try:
276
+ async for raw_msg in websocket:
277
+ try:
278
+ msg_type, payload, req_id, seq = self.parser.parse_message(
279
+ raw_msg, session_id
280
+ )
281
+ except ReplayAttackError:
282
+ resp = self.parser.serialize_response(
283
+ "nack", {"nack_id": "", "error": "replay_detected"}, seq=0
284
+ )
285
+ await websocket.send(resp.strip())
286
+ continue
287
+ except (SchemaViolationError, BoundsViolationError) as se:
288
+ resp = self.parser.serialize_response(
289
+ "nack", {"nack_id": "", "error": str(se)}, seq=0
290
+ )
291
+ await websocket.send(resp.strip())
292
+ continue
293
+
294
+ if session_id not in self.authenticated_sessions:
295
+ if msg_type != "hello" or not isinstance(payload, HelloPayload):
296
+ resp = self.parser.serialize_response(
297
+ "nack", {"nack_id": req_id or "", "error": "unauthenticated"}, seq=seq
298
+ )
299
+ await websocket.send(resp.strip())
300
+ break
301
+
302
+ if not self.auth_manager.validate_token(payload.token):
303
+ resp = self.parser.serialize_response(
304
+ "nack", {"nack_id": req_id or "", "error": "invalid_token"}, seq=seq
305
+ )
306
+ await websocket.send(resp.strip())
307
+ break
308
+
309
+ self.authenticated_sessions.add(session_id)
310
+ self._trigger_connect(session_id)
311
+
312
+ welcome = self.parser.serialize_response(
313
+ "welcome",
314
+ {
315
+ "server": self.server_name,
316
+ "version": "2.0.0",
317
+ "client_id": session_id,
318
+ "approved": True,
319
+ },
320
+ seq=seq,
321
+ req_id=req_id,
322
+ )
323
+ await websocket.send(welcome.strip())
324
+ continue
325
+
326
+ if msg_type == "move" and isinstance(payload, MovePayload):
327
+ self.mouse_queue.put(("move", payload.dx, payload.dy))
328
+ self._trigger_move(payload.dx, payload.dy)
329
+
330
+ elif msg_type == "click" and isinstance(payload, ClickPayload):
331
+ self.mouse_queue.put(("click", payload.button))
332
+ self._trigger_click(payload.button)
333
+ ack = self.parser.serialize_response(
334
+ "ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
335
+ )
336
+ await websocket.send(ack.strip())
337
+
338
+ elif msg_type == "scroll" and isinstance(payload, ScrollPayload):
339
+ self.mouse_queue.put(("scroll", payload.delta))
340
+ ack = self.parser.serialize_response(
341
+ "ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
342
+ )
343
+ await websocket.send(ack.strip())
344
+
345
+ elif msg_type == "ping":
346
+ pong = self.parser.serialize_response("pong", {}, seq=seq, req_id=req_id)
347
+ await websocket.send(pong.strip())
348
+
349
+ except Exception as e:
350
+ logger.debug(f"WS connection error: {e}")
351
+ finally:
352
+ was_auth = session_id in self.authenticated_sessions
353
+ self.authenticated_sessions.discard(session_id)
354
+ self.parser.reset_session(session_id)
355
+ if was_auth:
356
+ self._trigger_disconnect(session_id)
357
+ logger.info(f"WS client disconnected: {session_id}")
358
+
359
+ async def start(self) -> None:
360
+ """Starts mouse actuator thread, UDP discovery, TCP server, and WebSocket server."""
361
+ if self._is_running:
362
+ return
363
+ self._is_running = True
364
+
365
+ # 1. Start mouse actuator worker thread
366
+ self._actuator_thread = threading.Thread(
367
+ target=self.actuator.start,
368
+ name="AirMouse-ActuatorThread",
369
+ daemon=True,
370
+ )
371
+ self._actuator_thread.start()
372
+
373
+ # 2. Setup TLS context if enabled
374
+ ssl_ctx: Optional[ssl.SSLContext] = None
375
+ if self.enable_tls:
376
+ if not self.tls_manager:
377
+ self.tls_manager = TLSManager()
378
+ ssl_ctx = self.tls_manager.get_server_ssl_context()
379
+
380
+ # 3. Start UDP discovery responder
381
+ if self.udp_port > 0:
382
+ self.udp_discovery = UDPDiscoveryServer(
383
+ port=self.udp_port,
384
+ server_name=self.server_name,
385
+ tcp_port=self.tcp_port,
386
+ ws_port=self.ws_port,
387
+ tls_enabled=self.enable_tls,
388
+ )
389
+ self.udp_discovery.start()
390
+ logger.info(f"UDP Discovery service started on port {self.udp_port}")
391
+
392
+ # 4. Start TCP server
393
+ self._tcp_server = await asyncio.start_server(
394
+ self._handle_tcp_client,
395
+ self.host,
396
+ self.tcp_port,
397
+ ssl=ssl_ctx,
398
+ )
399
+ logger.info(
400
+ f"TCP Protocol server listening on {self.host}:{self.tcp_port} (TLS={self.enable_tls})"
401
+ )
402
+
403
+ # 5. Start WebSocket server
404
+ if WEBSOCKETS_AVAILABLE and self.ws_port > 0:
405
+ self._ws_server = await websockets.serve(
406
+ self._handle_ws_client,
407
+ self.host,
408
+ self.ws_port,
409
+ ssl=ssl_ctx,
410
+ )
411
+ logger.info(
412
+ f"WebSocket server listening on {self.host}:{self.ws_port} (TLS={self.enable_tls})"
413
+ )
414
+
415
+ async def stop(self) -> None:
416
+ """Stops all servers, UDP discovery, and the mouse actuator worker thread."""
417
+ self._is_running = False
418
+
419
+ # Close TCP server
420
+ if self._tcp_server:
421
+ self._tcp_server.close()
422
+ await self._tcp_server.wait_closed()
423
+ self._tcp_server = None
424
+
425
+ # Close WebSocket server
426
+ if self._ws_server:
427
+ self._ws_server.close()
428
+ await self._ws_server.wait_closed()
429
+ self._ws_server = None
430
+
431
+ # Stop UDP discovery
432
+ if self.udp_discovery:
433
+ self.udp_discovery.stop()
434
+ self.udp_discovery = None
435
+
436
+ # Stop mouse actuator thread
437
+ self.actuator.stop()
438
+ if self._actuator_thread and self._actuator_thread.is_alive():
439
+ self._actuator_thread.join(timeout=1.0)
440
+ self._actuator_thread = None
441
+
442
+ logger.info("AirMouseServer stopped cleanly.")