makcu 2.1.2__py3-none-any.whl → 2.1.3__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.
- makcu/__init__.py +378 -60
- makcu/__main__.py +387 -387
- makcu/conftest.py +33 -33
- makcu/connection.py +459 -459
- makcu/controller.py +388 -376
- makcu/enums.py +7 -7
- makcu/errors.py +13 -13
- makcu/makcu.pyi +10 -10
- makcu/mouse.py +249 -249
- makcu/test_suite.py +144 -144
- makcu-2.1.3.dist-info/METADATA +32 -0
- makcu-2.1.3.dist-info/RECORD +15 -0
- makcu-2.1.2.dist-info/METADATA +0 -1141
- makcu-2.1.2.dist-info/RECORD +0 -16
- makcu-2.1.2.dist-info/licenses/LICENSE +0 -674
- {makcu-2.1.2.dist-info → makcu-2.1.3.dist-info}/WHEEL +0 -0
- {makcu-2.1.2.dist-info → makcu-2.1.3.dist-info}/top_level.txt +0 -0
makcu/connection.py
CHANGED
@@ -1,460 +1,460 @@
|
|
1
|
-
import serial
|
2
|
-
import threading
|
3
|
-
import time
|
4
|
-
from typing import Optional, Dict, Callable
|
5
|
-
from serial.tools import list_ports
|
6
|
-
from dataclasses import dataclass
|
7
|
-
from collections import deque
|
8
|
-
from concurrent.futures import Future
|
9
|
-
import logging
|
10
|
-
import asyncio
|
11
|
-
from .errors import MakcuConnectionError, MakcuTimeoutError
|
12
|
-
from .enums import MouseButton
|
13
|
-
|
14
|
-
logger = logging.getLogger(__name__)
|
15
|
-
|
16
|
-
@dataclass
|
17
|
-
class PendingCommand:
|
18
|
-
command_id: int
|
19
|
-
command: str
|
20
|
-
future: Future
|
21
|
-
timestamp: float
|
22
|
-
expect_response: bool = True
|
23
|
-
timeout: float = 0.1
|
24
|
-
|
25
|
-
@dataclass
|
26
|
-
class ParsedResponse:
|
27
|
-
command_id: Optional[int]
|
28
|
-
content: str
|
29
|
-
is_button_data: bool = False
|
30
|
-
button_mask: Optional[int] = None
|
31
|
-
|
32
|
-
class SerialTransport:
|
33
|
-
|
34
|
-
BAUD_CHANGE_COMMAND = bytearray([0xDE, 0xAD, 0x05, 0x00, 0xA5, 0x00, 0x09, 0x3D, 0x00])
|
35
|
-
DEFAULT_TIMEOUT = 0.1
|
36
|
-
MAX_RECONNECT_ATTEMPTS = 3
|
37
|
-
RECONNECT_DELAY = 0.1
|
38
|
-
|
39
|
-
|
40
|
-
BUTTON_MAP = (
|
41
|
-
'left', 'right', 'middle', 'mouse4', 'mouse5'
|
42
|
-
)
|
43
|
-
|
44
|
-
BUTTON_ENUM_MAP = (
|
45
|
-
MouseButton.LEFT,
|
46
|
-
MouseButton.RIGHT,
|
47
|
-
MouseButton.MIDDLE,
|
48
|
-
MouseButton.MOUSE4,
|
49
|
-
MouseButton.MOUSE5,
|
50
|
-
)
|
51
|
-
|
52
|
-
def __init__(self, fallback: str = "", debug: bool = False,
|
53
|
-
send_init: bool = True, auto_reconnect: bool = True,
|
54
|
-
override_port: bool = False) -> None:
|
55
|
-
|
56
|
-
self._fallback_com_port = fallback
|
57
|
-
self.debug = debug
|
58
|
-
self.send_init = send_init
|
59
|
-
self.auto_reconnect = auto_reconnect
|
60
|
-
self.override_port = override_port
|
61
|
-
|
62
|
-
|
63
|
-
self._is_connected = False
|
64
|
-
self._reconnect_attempts = 0
|
65
|
-
self.port: Optional[str] = None
|
66
|
-
self.baudrate = 115200
|
67
|
-
self.serial: Optional[serial.Serial] = None
|
68
|
-
self._current_baud: Optional[int] = None
|
69
|
-
|
70
|
-
|
71
|
-
self._command_counter = 0
|
72
|
-
self._pending_commands: Dict[int, PendingCommand] = {}
|
73
|
-
self._command_lock = threading.Lock()
|
74
|
-
|
75
|
-
|
76
|
-
self._parse_buffer = bytearray(1024)
|
77
|
-
self._buffer_pos = 0
|
78
|
-
self._response_queue = deque(maxlen=100)
|
79
|
-
|
80
|
-
|
81
|
-
self._button_callback: Optional[Callable[[MouseButton, bool], None]] = None
|
82
|
-
self._last_button_mask = 0
|
83
|
-
self._button_states = 0
|
84
|
-
|
85
|
-
|
86
|
-
self._stop_event = threading.Event()
|
87
|
-
self._listener_thread: Optional[threading.Thread] = None
|
88
|
-
|
89
|
-
|
90
|
-
self._log_messages: deque = deque(maxlen=100)
|
91
|
-
|
92
|
-
|
93
|
-
self._ascii_decode_table = bytes(range(128))
|
94
|
-
|
95
|
-
def _log(self, message: str, level: str = "INFO") -> None:
|
96
|
-
if not self.debug and level == "DEBUG":
|
97
|
-
return
|
98
|
-
|
99
|
-
if self.debug:
|
100
|
-
|
101
|
-
timestamp = f"{time.time():.3f}"
|
102
|
-
entry = f"[{timestamp}] [{level}] {message}"
|
103
|
-
self._log_messages.append(entry)
|
104
|
-
print(entry, flush=True)
|
105
|
-
|
106
|
-
def _generate_command_id(self) -> int:
|
107
|
-
self._command_counter = (self._command_counter + 1) & 0x2710
|
108
|
-
return self._command_counter
|
109
|
-
|
110
|
-
def find_com_port(self) -> Optional[str]:
|
111
|
-
if self.override_port:
|
112
|
-
return self._fallback_com_port
|
113
|
-
|
114
|
-
|
115
|
-
target_hwid = "VID:PID=1A86:55D3"
|
116
|
-
|
117
|
-
for port in list_ports.comports():
|
118
|
-
if target_hwid in port.hwid.upper():
|
119
|
-
self._log(f"Device found: {port.device}")
|
120
|
-
return port.device
|
121
|
-
|
122
|
-
if self._fallback_com_port:
|
123
|
-
self._log(f"Using fallback: {self._fallback_com_port}")
|
124
|
-
return self._fallback_com_port
|
125
|
-
|
126
|
-
return None
|
127
|
-
|
128
|
-
def _parse_response_line(self, line: bytes) -> ParsedResponse:
|
129
|
-
|
130
|
-
if line.startswith(b'>>> '):
|
131
|
-
content = line[4:].decode('ascii', 'ignore').strip()
|
132
|
-
return ParsedResponse(None, content, False)
|
133
|
-
|
134
|
-
content = line.decode('ascii', 'ignore').strip()
|
135
|
-
return ParsedResponse(None, content, False)
|
136
|
-
|
137
|
-
def _handle_button_data(self, byte_val: int) -> None:
|
138
|
-
if byte_val == self._last_button_mask:
|
139
|
-
return
|
140
|
-
|
141
|
-
changed_bits = byte_val ^ self._last_button_mask
|
142
|
-
|
143
|
-
|
144
|
-
for bit in range(5):
|
145
|
-
if changed_bits & (1 << bit):
|
146
|
-
is_pressed = bool(byte_val & (1 << bit))
|
147
|
-
|
148
|
-
|
149
|
-
if is_pressed:
|
150
|
-
self._button_states |= (1 << bit)
|
151
|
-
else:
|
152
|
-
self._button_states &= ~(1 << bit)
|
153
|
-
|
154
|
-
if self._button_callback and bit < len(self.BUTTON_ENUM_MAP):
|
155
|
-
try:
|
156
|
-
self._button_callback(self.BUTTON_ENUM_MAP[bit], is_pressed)
|
157
|
-
except Exception:
|
158
|
-
pass
|
159
|
-
|
160
|
-
self._last_button_mask = byte_val
|
161
|
-
|
162
|
-
def _process_pending_commands(self, content: str) -> None:
|
163
|
-
if not content or not self._pending_commands:
|
164
|
-
return
|
165
|
-
|
166
|
-
with self._command_lock:
|
167
|
-
if not self._pending_commands:
|
168
|
-
return
|
169
|
-
|
170
|
-
|
171
|
-
oldest_id = next(iter(self._pending_commands))
|
172
|
-
pending = self._pending_commands[oldest_id]
|
173
|
-
|
174
|
-
if pending.future.done():
|
175
|
-
return
|
176
|
-
|
177
|
-
|
178
|
-
if content == pending.command:
|
179
|
-
if not pending.expect_response:
|
180
|
-
pending.future.set_result(pending.command)
|
181
|
-
del self._pending_commands[oldest_id]
|
182
|
-
else:
|
183
|
-
pending.future.set_result(content)
|
184
|
-
del self._pending_commands[oldest_id]
|
185
|
-
|
186
|
-
def _cleanup_timed_out_commands(self) -> None:
|
187
|
-
if not self._pending_commands:
|
188
|
-
return
|
189
|
-
|
190
|
-
current_time = time.time()
|
191
|
-
with self._command_lock:
|
192
|
-
|
193
|
-
timed_out = [
|
194
|
-
(cmd_id, pending)
|
195
|
-
for cmd_id, pending in self._pending_commands.items()
|
196
|
-
if current_time - pending.timestamp > pending.timeout
|
197
|
-
]
|
198
|
-
|
199
|
-
|
200
|
-
for cmd_id, pending in timed_out:
|
201
|
-
del self._pending_commands[cmd_id]
|
202
|
-
if not pending.future.done():
|
203
|
-
pending.future.set_exception(
|
204
|
-
MakcuTimeoutError(f"Command #{cmd_id} timed out")
|
205
|
-
)
|
206
|
-
|
207
|
-
|
208
|
-
def _listen(self) -> None:
|
209
|
-
|
210
|
-
read_buffer = bytearray(4096)
|
211
|
-
line_buffer = bytearray(256)
|
212
|
-
line_pos = 0
|
213
|
-
|
214
|
-
|
215
|
-
serial_read = self.serial.read
|
216
|
-
serial_in_waiting = lambda: self.serial.in_waiting
|
217
|
-
is_connected = lambda: self._is_connected
|
218
|
-
stop_requested = self._stop_event.is_set
|
219
|
-
|
220
|
-
|
221
|
-
last_cleanup = time.time()
|
222
|
-
cleanup_interval = 0.05
|
223
|
-
|
224
|
-
while is_connected() and not stop_requested():
|
225
|
-
try:
|
226
|
-
|
227
|
-
bytes_available = serial_in_waiting()
|
228
|
-
if not bytes_available:
|
229
|
-
time.sleep(0.001)
|
230
|
-
continue
|
231
|
-
|
232
|
-
|
233
|
-
bytes_read = serial_read(min(bytes_available, 4096))
|
234
|
-
|
235
|
-
|
236
|
-
for byte_val in bytes_read:
|
237
|
-
|
238
|
-
if byte_val < 32 and byte_val not in (0x0D, 0x0A):
|
239
|
-
self._handle_button_data(byte_val)
|
240
|
-
else:
|
241
|
-
|
242
|
-
if byte_val == 0x0A:
|
243
|
-
if line_pos > 0:
|
244
|
-
|
245
|
-
line = bytes(line_buffer[:line_pos])
|
246
|
-
line_pos = 0
|
247
|
-
|
248
|
-
if line:
|
249
|
-
response = self._parse_response_line(line)
|
250
|
-
if response.content:
|
251
|
-
self._process_pending_commands(response.content)
|
252
|
-
elif byte_val != 0x0D:
|
253
|
-
if line_pos < 256:
|
254
|
-
line_buffer[line_pos] = byte_val
|
255
|
-
line_pos += 1
|
256
|
-
|
257
|
-
|
258
|
-
current_time = time.time()
|
259
|
-
if current_time - last_cleanup > cleanup_interval:
|
260
|
-
self._cleanup_timed_out_commands()
|
261
|
-
last_cleanup = current_time
|
262
|
-
|
263
|
-
except serial.SerialException:
|
264
|
-
if self.auto_reconnect:
|
265
|
-
self._attempt_reconnect()
|
266
|
-
else:
|
267
|
-
break
|
268
|
-
except Exception:
|
269
|
-
pass
|
270
|
-
|
271
|
-
def _attempt_reconnect(self) -> None:
|
272
|
-
if self._reconnect_attempts >= self.MAX_RECONNECT_ATTEMPTS:
|
273
|
-
self._is_connected = False
|
274
|
-
return
|
275
|
-
|
276
|
-
self._reconnect_attempts += 1
|
277
|
-
|
278
|
-
try:
|
279
|
-
if self.serial and self.serial.is_open:
|
280
|
-
self.serial.close()
|
281
|
-
|
282
|
-
time.sleep(self.RECONNECT_DELAY)
|
283
|
-
|
284
|
-
self.port = self.find_com_port()
|
285
|
-
if not self.port:
|
286
|
-
raise MakcuConnectionError("Device not found")
|
287
|
-
|
288
|
-
|
289
|
-
self.serial = serial.Serial(
|
290
|
-
self.port,
|
291
|
-
self.baudrate,
|
292
|
-
timeout=0.001,
|
293
|
-
write_timeout=0.01
|
294
|
-
)
|
295
|
-
self._change_baud_to_4M()
|
296
|
-
|
297
|
-
if self.send_init:
|
298
|
-
self.serial.write(b"km.buttons(1)\r")
|
299
|
-
self.serial.flush()
|
300
|
-
|
301
|
-
self._reconnect_attempts = 0
|
302
|
-
|
303
|
-
except Exception:
|
304
|
-
time.sleep(self.RECONNECT_DELAY)
|
305
|
-
|
306
|
-
def _change_baud_to_4M(self) -> bool:
|
307
|
-
if self.serial and self.serial.is_open:
|
308
|
-
self.serial.write(self.BAUD_CHANGE_COMMAND)
|
309
|
-
self.serial.flush()
|
310
|
-
time.sleep(0.02)
|
311
|
-
self.serial.baudrate = 4000000
|
312
|
-
self._current_baud = 4000000
|
313
|
-
return True
|
314
|
-
return False
|
315
|
-
|
316
|
-
def connect(self) -> None:
|
317
|
-
if self._is_connected:
|
318
|
-
return
|
319
|
-
|
320
|
-
if not self.override_port:
|
321
|
-
self.port = self.find_com_port()
|
322
|
-
else:
|
323
|
-
self.port = self._fallback_com_port
|
324
|
-
|
325
|
-
if not self.port:
|
326
|
-
raise MakcuConnectionError("Makcu device not found")
|
327
|
-
|
328
|
-
try:
|
329
|
-
|
330
|
-
self.serial = serial.Serial(
|
331
|
-
self.port,
|
332
|
-
115200,
|
333
|
-
timeout=0.001,
|
334
|
-
write_timeout=0.01,
|
335
|
-
xonxoff=False,
|
336
|
-
rtscts=False,
|
337
|
-
dsrdtr=False
|
338
|
-
)
|
339
|
-
|
340
|
-
if not self._change_baud_to_4M():
|
341
|
-
raise MakcuConnectionError("Failed to switch to 4M baud")
|
342
|
-
|
343
|
-
self._is_connected = True
|
344
|
-
self._reconnect_attempts = 0
|
345
|
-
|
346
|
-
if self.send_init:
|
347
|
-
self.serial.write(b"km.buttons(1)\r")
|
348
|
-
self.serial.flush()
|
349
|
-
|
350
|
-
|
351
|
-
self._stop_event.clear()
|
352
|
-
self._listener_thread = threading.Thread(
|
353
|
-
target=self._listen,
|
354
|
-
daemon=True,
|
355
|
-
name="MakcuListener"
|
356
|
-
)
|
357
|
-
self._listener_thread.start()
|
358
|
-
|
359
|
-
except Exception as e:
|
360
|
-
if self.serial:
|
361
|
-
self.serial.close()
|
362
|
-
raise MakcuConnectionError(f"Failed to connect: {e}")
|
363
|
-
|
364
|
-
def disconnect(self) -> None:
|
365
|
-
self._is_connected = False
|
366
|
-
|
367
|
-
if self.send_init:
|
368
|
-
self._stop_event.set()
|
369
|
-
if self._listener_thread and self._listener_thread.is_alive():
|
370
|
-
self._listener_thread.join(timeout=0.1)
|
371
|
-
|
372
|
-
with self._command_lock:
|
373
|
-
for pending in self._pending_commands.values():
|
374
|
-
if not pending.future.done():
|
375
|
-
pending.future.cancel()
|
376
|
-
self._pending_commands.clear()
|
377
|
-
|
378
|
-
if self.serial and self.serial.is_open:
|
379
|
-
self.serial.close()
|
380
|
-
self.serial = None
|
381
|
-
|
382
|
-
def send_command(self, command: str, expect_response: bool = False,
|
383
|
-
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
384
|
-
if not self._is_connected or not self.serial or not self.serial.is_open:
|
385
|
-
raise MakcuConnectionError("Not connected")
|
386
|
-
|
387
|
-
if not expect_response:
|
388
|
-
self.serial.write(f"{command}\r\n".encode('ascii'))
|
389
|
-
self.serial.flush()
|
390
|
-
return command
|
391
|
-
|
392
|
-
cmd_id = self._generate_command_id()
|
393
|
-
tagged_command = f"{command}#{cmd_id}"
|
394
|
-
|
395
|
-
future = Future()
|
396
|
-
|
397
|
-
with self._command_lock:
|
398
|
-
self._pending_commands[cmd_id] = PendingCommand(
|
399
|
-
command_id=cmd_id,
|
400
|
-
command=command,
|
401
|
-
future=future,
|
402
|
-
timestamp=time.time(),
|
403
|
-
expect_response=expect_response,
|
404
|
-
timeout=timeout
|
405
|
-
)
|
406
|
-
|
407
|
-
try:
|
408
|
-
self.serial.write(f"{tagged_command}\r\n".encode('ascii'))
|
409
|
-
self.serial.flush()
|
410
|
-
|
411
|
-
result = future.result(timeout=timeout)
|
412
|
-
return result.split('#')[0] if '#' in result else result
|
413
|
-
|
414
|
-
except TimeoutError:
|
415
|
-
raise MakcuTimeoutError(f"Command timed out: {command}")
|
416
|
-
except Exception as e:
|
417
|
-
with self._command_lock:
|
418
|
-
self._pending_commands.pop(cmd_id, None)
|
419
|
-
raise
|
420
|
-
|
421
|
-
async def async_send_command(self, command: str, expect_response: bool = False,
|
422
|
-
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
423
|
-
loop = asyncio.get_running_loop()
|
424
|
-
return await loop.run_in_executor(
|
425
|
-
None, self.send_command, command, expect_response, timeout
|
426
|
-
)
|
427
|
-
|
428
|
-
def is_connected(self) -> bool:
|
429
|
-
return self._is_connected and self.serial is not None and self.serial.is_open
|
430
|
-
|
431
|
-
def set_button_callback(self, callback: Optional[Callable[[MouseButton, bool], None]]) -> None:
|
432
|
-
self._button_callback = callback
|
433
|
-
|
434
|
-
def get_button_states(self) -> Dict[str, bool]:
|
435
|
-
return {
|
436
|
-
self.BUTTON_MAP[i]: bool(self._button_states & (1 << i))
|
437
|
-
for i in range(5)
|
438
|
-
}
|
439
|
-
|
440
|
-
def get_button_mask(self) -> int:
|
441
|
-
return self._last_button_mask
|
442
|
-
|
443
|
-
def enable_button_monitoring(self, enable: bool = True) -> None:
|
444
|
-
self.send_command("km.buttons(1)" if enable else "km.buttons(0)")
|
445
|
-
|
446
|
-
async def __aenter__(self):
|
447
|
-
loop = asyncio.get_running_loop()
|
448
|
-
await loop.run_in_executor(None, self.connect)
|
449
|
-
return self
|
450
|
-
|
451
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
452
|
-
loop = asyncio.get_running_loop()
|
453
|
-
await loop.run_in_executor(None, self.disconnect)
|
454
|
-
|
455
|
-
def __enter__(self):
|
456
|
-
self.connect()
|
457
|
-
return self
|
458
|
-
|
459
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
1
|
+
import serial
|
2
|
+
import threading
|
3
|
+
import time
|
4
|
+
from typing import Optional, Dict, Callable
|
5
|
+
from serial.tools import list_ports
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from collections import deque
|
8
|
+
from concurrent.futures import Future
|
9
|
+
import logging
|
10
|
+
import asyncio
|
11
|
+
from .errors import MakcuConnectionError, MakcuTimeoutError
|
12
|
+
from .enums import MouseButton
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class PendingCommand:
|
18
|
+
command_id: int
|
19
|
+
command: str
|
20
|
+
future: Future
|
21
|
+
timestamp: float
|
22
|
+
expect_response: bool = True
|
23
|
+
timeout: float = 0.1
|
24
|
+
|
25
|
+
@dataclass
|
26
|
+
class ParsedResponse:
|
27
|
+
command_id: Optional[int]
|
28
|
+
content: str
|
29
|
+
is_button_data: bool = False
|
30
|
+
button_mask: Optional[int] = None
|
31
|
+
|
32
|
+
class SerialTransport:
|
33
|
+
|
34
|
+
BAUD_CHANGE_COMMAND = bytearray([0xDE, 0xAD, 0x05, 0x00, 0xA5, 0x00, 0x09, 0x3D, 0x00])
|
35
|
+
DEFAULT_TIMEOUT = 0.1
|
36
|
+
MAX_RECONNECT_ATTEMPTS = 3
|
37
|
+
RECONNECT_DELAY = 0.1
|
38
|
+
|
39
|
+
|
40
|
+
BUTTON_MAP = (
|
41
|
+
'left', 'right', 'middle', 'mouse4', 'mouse5'
|
42
|
+
)
|
43
|
+
|
44
|
+
BUTTON_ENUM_MAP = (
|
45
|
+
MouseButton.LEFT,
|
46
|
+
MouseButton.RIGHT,
|
47
|
+
MouseButton.MIDDLE,
|
48
|
+
MouseButton.MOUSE4,
|
49
|
+
MouseButton.MOUSE5,
|
50
|
+
)
|
51
|
+
|
52
|
+
def __init__(self, fallback: str = "", debug: bool = False,
|
53
|
+
send_init: bool = True, auto_reconnect: bool = True,
|
54
|
+
override_port: bool = False) -> None:
|
55
|
+
|
56
|
+
self._fallback_com_port = fallback
|
57
|
+
self.debug = debug
|
58
|
+
self.send_init = send_init
|
59
|
+
self.auto_reconnect = auto_reconnect
|
60
|
+
self.override_port = override_port
|
61
|
+
|
62
|
+
|
63
|
+
self._is_connected = False
|
64
|
+
self._reconnect_attempts = 0
|
65
|
+
self.port: Optional[str] = None
|
66
|
+
self.baudrate = 115200
|
67
|
+
self.serial: Optional[serial.Serial] = None
|
68
|
+
self._current_baud: Optional[int] = None
|
69
|
+
|
70
|
+
|
71
|
+
self._command_counter = 0
|
72
|
+
self._pending_commands: Dict[int, PendingCommand] = {}
|
73
|
+
self._command_lock = threading.Lock()
|
74
|
+
|
75
|
+
|
76
|
+
self._parse_buffer = bytearray(1024)
|
77
|
+
self._buffer_pos = 0
|
78
|
+
self._response_queue = deque(maxlen=100)
|
79
|
+
|
80
|
+
|
81
|
+
self._button_callback: Optional[Callable[[MouseButton, bool], None]] = None
|
82
|
+
self._last_button_mask = 0
|
83
|
+
self._button_states = 0
|
84
|
+
|
85
|
+
|
86
|
+
self._stop_event = threading.Event()
|
87
|
+
self._listener_thread: Optional[threading.Thread] = None
|
88
|
+
|
89
|
+
|
90
|
+
self._log_messages: deque = deque(maxlen=100)
|
91
|
+
|
92
|
+
|
93
|
+
self._ascii_decode_table = bytes(range(128))
|
94
|
+
|
95
|
+
def _log(self, message: str, level: str = "INFO") -> None:
|
96
|
+
if not self.debug and level == "DEBUG":
|
97
|
+
return
|
98
|
+
|
99
|
+
if self.debug:
|
100
|
+
|
101
|
+
timestamp = f"{time.time():.3f}"
|
102
|
+
entry = f"[{timestamp}] [{level}] {message}"
|
103
|
+
self._log_messages.append(entry)
|
104
|
+
print(entry, flush=True)
|
105
|
+
|
106
|
+
def _generate_command_id(self) -> int:
|
107
|
+
self._command_counter = (self._command_counter + 1) & 0x2710
|
108
|
+
return self._command_counter
|
109
|
+
|
110
|
+
def find_com_port(self) -> Optional[str]:
|
111
|
+
if self.override_port:
|
112
|
+
return self._fallback_com_port
|
113
|
+
|
114
|
+
|
115
|
+
target_hwid = "VID:PID=1A86:55D3"
|
116
|
+
|
117
|
+
for port in list_ports.comports():
|
118
|
+
if target_hwid in port.hwid.upper():
|
119
|
+
self._log(f"Device found: {port.device}")
|
120
|
+
return port.device
|
121
|
+
|
122
|
+
if self._fallback_com_port:
|
123
|
+
self._log(f"Using fallback: {self._fallback_com_port}")
|
124
|
+
return self._fallback_com_port
|
125
|
+
|
126
|
+
return None
|
127
|
+
|
128
|
+
def _parse_response_line(self, line: bytes) -> ParsedResponse:
|
129
|
+
|
130
|
+
if line.startswith(b'>>> '):
|
131
|
+
content = line[4:].decode('ascii', 'ignore').strip()
|
132
|
+
return ParsedResponse(None, content, False)
|
133
|
+
|
134
|
+
content = line.decode('ascii', 'ignore').strip()
|
135
|
+
return ParsedResponse(None, content, False)
|
136
|
+
|
137
|
+
def _handle_button_data(self, byte_val: int) -> None:
|
138
|
+
if byte_val == self._last_button_mask:
|
139
|
+
return
|
140
|
+
|
141
|
+
changed_bits = byte_val ^ self._last_button_mask
|
142
|
+
|
143
|
+
|
144
|
+
for bit in range(5):
|
145
|
+
if changed_bits & (1 << bit):
|
146
|
+
is_pressed = bool(byte_val & (1 << bit))
|
147
|
+
|
148
|
+
|
149
|
+
if is_pressed:
|
150
|
+
self._button_states |= (1 << bit)
|
151
|
+
else:
|
152
|
+
self._button_states &= ~(1 << bit)
|
153
|
+
|
154
|
+
if self._button_callback and bit < len(self.BUTTON_ENUM_MAP):
|
155
|
+
try:
|
156
|
+
self._button_callback(self.BUTTON_ENUM_MAP[bit], is_pressed)
|
157
|
+
except Exception:
|
158
|
+
pass
|
159
|
+
|
160
|
+
self._last_button_mask = byte_val
|
161
|
+
|
162
|
+
def _process_pending_commands(self, content: str) -> None:
|
163
|
+
if not content or not self._pending_commands:
|
164
|
+
return
|
165
|
+
|
166
|
+
with self._command_lock:
|
167
|
+
if not self._pending_commands:
|
168
|
+
return
|
169
|
+
|
170
|
+
|
171
|
+
oldest_id = next(iter(self._pending_commands))
|
172
|
+
pending = self._pending_commands[oldest_id]
|
173
|
+
|
174
|
+
if pending.future.done():
|
175
|
+
return
|
176
|
+
|
177
|
+
|
178
|
+
if content == pending.command:
|
179
|
+
if not pending.expect_response:
|
180
|
+
pending.future.set_result(pending.command)
|
181
|
+
del self._pending_commands[oldest_id]
|
182
|
+
else:
|
183
|
+
pending.future.set_result(content)
|
184
|
+
del self._pending_commands[oldest_id]
|
185
|
+
|
186
|
+
def _cleanup_timed_out_commands(self) -> None:
|
187
|
+
if not self._pending_commands:
|
188
|
+
return
|
189
|
+
|
190
|
+
current_time = time.time()
|
191
|
+
with self._command_lock:
|
192
|
+
|
193
|
+
timed_out = [
|
194
|
+
(cmd_id, pending)
|
195
|
+
for cmd_id, pending in self._pending_commands.items()
|
196
|
+
if current_time - pending.timestamp > pending.timeout
|
197
|
+
]
|
198
|
+
|
199
|
+
|
200
|
+
for cmd_id, pending in timed_out:
|
201
|
+
del self._pending_commands[cmd_id]
|
202
|
+
if not pending.future.done():
|
203
|
+
pending.future.set_exception(
|
204
|
+
MakcuTimeoutError(f"Command #{cmd_id} timed out")
|
205
|
+
)
|
206
|
+
|
207
|
+
|
208
|
+
def _listen(self) -> None:
|
209
|
+
|
210
|
+
read_buffer = bytearray(4096)
|
211
|
+
line_buffer = bytearray(256)
|
212
|
+
line_pos = 0
|
213
|
+
|
214
|
+
|
215
|
+
serial_read = self.serial.read
|
216
|
+
serial_in_waiting = lambda: self.serial.in_waiting
|
217
|
+
is_connected = lambda: self._is_connected
|
218
|
+
stop_requested = self._stop_event.is_set
|
219
|
+
|
220
|
+
|
221
|
+
last_cleanup = time.time()
|
222
|
+
cleanup_interval = 0.05
|
223
|
+
|
224
|
+
while is_connected() and not stop_requested():
|
225
|
+
try:
|
226
|
+
|
227
|
+
bytes_available = serial_in_waiting()
|
228
|
+
if not bytes_available:
|
229
|
+
time.sleep(0.001)
|
230
|
+
continue
|
231
|
+
|
232
|
+
|
233
|
+
bytes_read = serial_read(min(bytes_available, 4096))
|
234
|
+
|
235
|
+
|
236
|
+
for byte_val in bytes_read:
|
237
|
+
|
238
|
+
if byte_val < 32 and byte_val not in (0x0D, 0x0A):
|
239
|
+
self._handle_button_data(byte_val)
|
240
|
+
else:
|
241
|
+
|
242
|
+
if byte_val == 0x0A:
|
243
|
+
if line_pos > 0:
|
244
|
+
|
245
|
+
line = bytes(line_buffer[:line_pos])
|
246
|
+
line_pos = 0
|
247
|
+
|
248
|
+
if line:
|
249
|
+
response = self._parse_response_line(line)
|
250
|
+
if response.content:
|
251
|
+
self._process_pending_commands(response.content)
|
252
|
+
elif byte_val != 0x0D:
|
253
|
+
if line_pos < 256:
|
254
|
+
line_buffer[line_pos] = byte_val
|
255
|
+
line_pos += 1
|
256
|
+
|
257
|
+
|
258
|
+
current_time = time.time()
|
259
|
+
if current_time - last_cleanup > cleanup_interval:
|
260
|
+
self._cleanup_timed_out_commands()
|
261
|
+
last_cleanup = current_time
|
262
|
+
|
263
|
+
except serial.SerialException:
|
264
|
+
if self.auto_reconnect:
|
265
|
+
self._attempt_reconnect()
|
266
|
+
else:
|
267
|
+
break
|
268
|
+
except Exception:
|
269
|
+
pass
|
270
|
+
|
271
|
+
def _attempt_reconnect(self) -> None:
|
272
|
+
if self._reconnect_attempts >= self.MAX_RECONNECT_ATTEMPTS:
|
273
|
+
self._is_connected = False
|
274
|
+
return
|
275
|
+
|
276
|
+
self._reconnect_attempts += 1
|
277
|
+
|
278
|
+
try:
|
279
|
+
if self.serial and self.serial.is_open:
|
280
|
+
self.serial.close()
|
281
|
+
|
282
|
+
time.sleep(self.RECONNECT_DELAY)
|
283
|
+
|
284
|
+
self.port = self.find_com_port()
|
285
|
+
if not self.port:
|
286
|
+
raise MakcuConnectionError("Device not found")
|
287
|
+
|
288
|
+
|
289
|
+
self.serial = serial.Serial(
|
290
|
+
self.port,
|
291
|
+
self.baudrate,
|
292
|
+
timeout=0.001,
|
293
|
+
write_timeout=0.01
|
294
|
+
)
|
295
|
+
self._change_baud_to_4M()
|
296
|
+
|
297
|
+
if self.send_init:
|
298
|
+
self.serial.write(b"km.buttons(1)\r")
|
299
|
+
self.serial.flush()
|
300
|
+
|
301
|
+
self._reconnect_attempts = 0
|
302
|
+
|
303
|
+
except Exception:
|
304
|
+
time.sleep(self.RECONNECT_DELAY)
|
305
|
+
|
306
|
+
def _change_baud_to_4M(self) -> bool:
|
307
|
+
if self.serial and self.serial.is_open:
|
308
|
+
self.serial.write(self.BAUD_CHANGE_COMMAND)
|
309
|
+
self.serial.flush()
|
310
|
+
time.sleep(0.02)
|
311
|
+
self.serial.baudrate = 4000000
|
312
|
+
self._current_baud = 4000000
|
313
|
+
return True
|
314
|
+
return False
|
315
|
+
|
316
|
+
def connect(self) -> None:
|
317
|
+
if self._is_connected:
|
318
|
+
return
|
319
|
+
|
320
|
+
if not self.override_port:
|
321
|
+
self.port = self.find_com_port()
|
322
|
+
else:
|
323
|
+
self.port = self._fallback_com_port
|
324
|
+
|
325
|
+
if not self.port:
|
326
|
+
raise MakcuConnectionError("Makcu device not found")
|
327
|
+
|
328
|
+
try:
|
329
|
+
|
330
|
+
self.serial = serial.Serial(
|
331
|
+
self.port,
|
332
|
+
115200,
|
333
|
+
timeout=0.001,
|
334
|
+
write_timeout=0.01,
|
335
|
+
xonxoff=False,
|
336
|
+
rtscts=False,
|
337
|
+
dsrdtr=False
|
338
|
+
)
|
339
|
+
|
340
|
+
if not self._change_baud_to_4M():
|
341
|
+
raise MakcuConnectionError("Failed to switch to 4M baud")
|
342
|
+
|
343
|
+
self._is_connected = True
|
344
|
+
self._reconnect_attempts = 0
|
345
|
+
|
346
|
+
if self.send_init:
|
347
|
+
self.serial.write(b"km.buttons(1)\r")
|
348
|
+
self.serial.flush()
|
349
|
+
|
350
|
+
|
351
|
+
self._stop_event.clear()
|
352
|
+
self._listener_thread = threading.Thread(
|
353
|
+
target=self._listen,
|
354
|
+
daemon=True,
|
355
|
+
name="MakcuListener"
|
356
|
+
)
|
357
|
+
self._listener_thread.start()
|
358
|
+
|
359
|
+
except Exception as e:
|
360
|
+
if self.serial:
|
361
|
+
self.serial.close()
|
362
|
+
raise MakcuConnectionError(f"Failed to connect: {e}")
|
363
|
+
|
364
|
+
def disconnect(self) -> None:
|
365
|
+
self._is_connected = False
|
366
|
+
|
367
|
+
if self.send_init:
|
368
|
+
self._stop_event.set()
|
369
|
+
if self._listener_thread and self._listener_thread.is_alive():
|
370
|
+
self._listener_thread.join(timeout=0.1)
|
371
|
+
|
372
|
+
with self._command_lock:
|
373
|
+
for pending in self._pending_commands.values():
|
374
|
+
if not pending.future.done():
|
375
|
+
pending.future.cancel()
|
376
|
+
self._pending_commands.clear()
|
377
|
+
|
378
|
+
if self.serial and self.serial.is_open:
|
379
|
+
self.serial.close()
|
380
|
+
self.serial = None
|
381
|
+
|
382
|
+
def send_command(self, command: str, expect_response: bool = False,
|
383
|
+
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
384
|
+
if not self._is_connected or not self.serial or not self.serial.is_open:
|
385
|
+
raise MakcuConnectionError("Not connected")
|
386
|
+
|
387
|
+
if not expect_response:
|
388
|
+
self.serial.write(f"{command}\r\n".encode('ascii'))
|
389
|
+
self.serial.flush()
|
390
|
+
return command
|
391
|
+
|
392
|
+
cmd_id = self._generate_command_id()
|
393
|
+
tagged_command = f"{command}#{cmd_id}"
|
394
|
+
|
395
|
+
future = Future()
|
396
|
+
|
397
|
+
with self._command_lock:
|
398
|
+
self._pending_commands[cmd_id] = PendingCommand(
|
399
|
+
command_id=cmd_id,
|
400
|
+
command=command,
|
401
|
+
future=future,
|
402
|
+
timestamp=time.time(),
|
403
|
+
expect_response=expect_response,
|
404
|
+
timeout=timeout
|
405
|
+
)
|
406
|
+
|
407
|
+
try:
|
408
|
+
self.serial.write(f"{tagged_command}\r\n".encode('ascii'))
|
409
|
+
self.serial.flush()
|
410
|
+
|
411
|
+
result = future.result(timeout=timeout)
|
412
|
+
return result.split('#')[0] if '#' in result else result
|
413
|
+
|
414
|
+
except TimeoutError:
|
415
|
+
raise MakcuTimeoutError(f"Command timed out: {command}")
|
416
|
+
except Exception as e:
|
417
|
+
with self._command_lock:
|
418
|
+
self._pending_commands.pop(cmd_id, None)
|
419
|
+
raise
|
420
|
+
|
421
|
+
async def async_send_command(self, command: str, expect_response: bool = False,
|
422
|
+
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
423
|
+
loop = asyncio.get_running_loop()
|
424
|
+
return await loop.run_in_executor(
|
425
|
+
None, self.send_command, command, expect_response, timeout
|
426
|
+
)
|
427
|
+
|
428
|
+
def is_connected(self) -> bool:
|
429
|
+
return self._is_connected and self.serial is not None and self.serial.is_open
|
430
|
+
|
431
|
+
def set_button_callback(self, callback: Optional[Callable[[MouseButton, bool], None]]) -> None:
|
432
|
+
self._button_callback = callback
|
433
|
+
|
434
|
+
def get_button_states(self) -> Dict[str, bool]:
|
435
|
+
return {
|
436
|
+
self.BUTTON_MAP[i]: bool(self._button_states & (1 << i))
|
437
|
+
for i in range(5)
|
438
|
+
}
|
439
|
+
|
440
|
+
def get_button_mask(self) -> int:
|
441
|
+
return self._last_button_mask
|
442
|
+
|
443
|
+
def enable_button_monitoring(self, enable: bool = True) -> None:
|
444
|
+
self.send_command("km.buttons(1)" if enable else "km.buttons(0)")
|
445
|
+
|
446
|
+
async def __aenter__(self):
|
447
|
+
loop = asyncio.get_running_loop()
|
448
|
+
await loop.run_in_executor(None, self.connect)
|
449
|
+
return self
|
450
|
+
|
451
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
452
|
+
loop = asyncio.get_running_loop()
|
453
|
+
await loop.run_in_executor(None, self.disconnect)
|
454
|
+
|
455
|
+
def __enter__(self):
|
456
|
+
self.connect()
|
457
|
+
return self
|
458
|
+
|
459
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
460
460
|
self.disconnect()
|