makcu 2.1.2__py3-none-any.whl → 2.2.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.
- makcu/README.md +405 -0
- makcu/__init__.py +22 -60
- makcu/__main__.py +388 -387
- makcu/conftest.py +33 -33
- makcu/connection.py +553 -459
- makcu/controller.py +410 -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 +141 -144
- {makcu-2.1.2.dist-info → makcu-2.2.0.dist-info}/METADATA +3 -29
- makcu-2.2.0.dist-info/RECORD +17 -0
- makcu-2.1.2.dist-info/RECORD +0 -16
- {makcu-2.1.2.dist-info → makcu-2.2.0.dist-info}/WHEEL +0 -0
- {makcu-2.1.2.dist-info → makcu-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {makcu-2.1.2.dist-info → makcu-2.2.0.dist-info}/top_level.txt +0 -0
makcu/connection.py
CHANGED
@@ -1,460 +1,554 @@
|
|
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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
self.
|
77
|
-
self.
|
78
|
-
self.
|
79
|
-
|
80
|
-
|
81
|
-
self.
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
self.
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
self.
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
self.
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
self.
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
self.
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
)
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
self.
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
self.
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
if self.
|
368
|
-
self.
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
self.
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
self.
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
raise
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
self.
|
457
|
-
|
458
|
-
|
459
|
-
|
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
|
+
if not hasattr(SerialTransport, '_thread_counter'):
|
63
|
+
SerialTransport._thread_counter = 0
|
64
|
+
SerialTransport._thread_map = {}
|
65
|
+
|
66
|
+
# Log version info during initialization
|
67
|
+
try:
|
68
|
+
from makcu import __version__
|
69
|
+
version = __version__
|
70
|
+
self._log(f"Makcu version: {version}")
|
71
|
+
except ImportError:
|
72
|
+
self._log("Makcu version info not available")
|
73
|
+
|
74
|
+
self._log(f"Initializing SerialTransport with params: fallback='{fallback}', debug={debug}, send_init={send_init}, auto_reconnect={auto_reconnect}, override_port={override_port}")
|
75
|
+
|
76
|
+
self._is_connected = False
|
77
|
+
self._reconnect_attempts = 0
|
78
|
+
self.port: Optional[str] = None
|
79
|
+
self.baudrate = 115200
|
80
|
+
self.serial: Optional[serial.Serial] = None
|
81
|
+
self._current_baud: Optional[int] = None
|
82
|
+
|
83
|
+
|
84
|
+
self._command_counter = 0
|
85
|
+
self._pending_commands: Dict[int, PendingCommand] = {}
|
86
|
+
self._command_lock = threading.Lock()
|
87
|
+
|
88
|
+
|
89
|
+
self._parse_buffer = bytearray(1024)
|
90
|
+
self._buffer_pos = 0
|
91
|
+
self._response_queue = deque(maxlen=100)
|
92
|
+
|
93
|
+
|
94
|
+
self._button_callback: Optional[Callable[[MouseButton, bool], None]] = None
|
95
|
+
self._last_button_mask = 0
|
96
|
+
self._button_states = 0
|
97
|
+
|
98
|
+
|
99
|
+
self._stop_event = threading.Event()
|
100
|
+
self._listener_thread: Optional[threading.Thread] = None
|
101
|
+
|
102
|
+
|
103
|
+
self._ascii_decode_table = bytes(range(128))
|
104
|
+
|
105
|
+
self._log("SerialTransport initialization completed")
|
106
|
+
|
107
|
+
|
108
|
+
def _log(self, message: str, level: str = "INFO") -> None:
|
109
|
+
if not self.debug:
|
110
|
+
return
|
111
|
+
|
112
|
+
timestamp = time.strftime("%H:%M:%S", time.localtime())
|
113
|
+
thread_id = threading.get_ident()
|
114
|
+
|
115
|
+
# Map thread ID to a simple number
|
116
|
+
if thread_id not in SerialTransport._thread_map:
|
117
|
+
SerialTransport._thread_counter += 1
|
118
|
+
SerialTransport._thread_map[thread_id] = SerialTransport._thread_counter
|
119
|
+
|
120
|
+
thread_num = SerialTransport._thread_map[thread_id]
|
121
|
+
entry = f"[{timestamp}] [T:{thread_num}] [{level}] {message}"
|
122
|
+
print(entry, flush=True)
|
123
|
+
|
124
|
+
def _generate_command_id(self) -> int:
|
125
|
+
old_counter = self._command_counter
|
126
|
+
self._command_counter = (self._command_counter + 1) & 0x2710
|
127
|
+
return self._command_counter
|
128
|
+
|
129
|
+
def find_com_port(self) -> Optional[str]:
|
130
|
+
self._log("Starting COM port discovery")
|
131
|
+
|
132
|
+
if self.override_port:
|
133
|
+
self._log(f"Override port enabled, using: {self._fallback_com_port}")
|
134
|
+
return self._fallback_com_port
|
135
|
+
|
136
|
+
all_ports = list_ports.comports()
|
137
|
+
self._log(f"Found {len(all_ports)} COM ports total")
|
138
|
+
|
139
|
+
target_hwid = "VID:PID=1A86:55D3"
|
140
|
+
|
141
|
+
for i, port in enumerate(all_ports):
|
142
|
+
self._log(f"Port {i}: {port.device} - HWID: {port.hwid}")
|
143
|
+
if target_hwid in port.hwid.upper():
|
144
|
+
self._log(f"Target device found on port: {port.device}")
|
145
|
+
return port.device
|
146
|
+
|
147
|
+
self._log("Target device not found in COM port scan")
|
148
|
+
|
149
|
+
if self._fallback_com_port:
|
150
|
+
self._log(f"Using fallback COM port: {self._fallback_com_port}")
|
151
|
+
return self._fallback_com_port
|
152
|
+
|
153
|
+
self._log("No fallback port specified, returning None")
|
154
|
+
return None
|
155
|
+
|
156
|
+
def _parse_response_line(self, line: bytes) -> ParsedResponse:
|
157
|
+
if line.startswith(b'>>> '):
|
158
|
+
content = line[4:].decode('ascii', 'ignore').strip()
|
159
|
+
return ParsedResponse(None, content, False)
|
160
|
+
|
161
|
+
content = line.decode('ascii', 'ignore').strip()
|
162
|
+
return ParsedResponse(None, content, False)
|
163
|
+
|
164
|
+
def _handle_button_data(self, byte_val: int) -> None:
|
165
|
+
if byte_val == self._last_button_mask:
|
166
|
+
return
|
167
|
+
|
168
|
+
changed_bits = byte_val ^ self._last_button_mask
|
169
|
+
self._log(f"Button state changed: 0x{self._last_button_mask:02X} -> 0x{byte_val:02X}")
|
170
|
+
|
171
|
+
for bit in range(5):
|
172
|
+
if changed_bits & (1 << bit):
|
173
|
+
is_pressed = bool(byte_val & (1 << bit))
|
174
|
+
button_name = self.BUTTON_MAP[bit] if bit < len(self.BUTTON_MAP) else f"bit{bit}"
|
175
|
+
|
176
|
+
self._log(f"Button {button_name}: {'PRESSED' if is_pressed else 'RELEASED'}")
|
177
|
+
|
178
|
+
if is_pressed:
|
179
|
+
self._button_states |= (1 << bit)
|
180
|
+
else:
|
181
|
+
self._button_states &= ~(1 << bit)
|
182
|
+
|
183
|
+
if self._button_callback and bit < len(self.BUTTON_ENUM_MAP):
|
184
|
+
try:
|
185
|
+
self._button_callback(self.BUTTON_ENUM_MAP[bit], is_pressed)
|
186
|
+
except Exception as e:
|
187
|
+
self._log(f"Button callback failed: {e}", "ERROR")
|
188
|
+
|
189
|
+
self._last_button_mask = byte_val
|
190
|
+
|
191
|
+
def _process_pending_commands(self, content: str) -> None:
|
192
|
+
if not content or not self._pending_commands:
|
193
|
+
return
|
194
|
+
|
195
|
+
with self._command_lock:
|
196
|
+
if not self._pending_commands:
|
197
|
+
return
|
198
|
+
|
199
|
+
oldest_id = next(iter(self._pending_commands))
|
200
|
+
pending = self._pending_commands[oldest_id]
|
201
|
+
|
202
|
+
if pending.future.done():
|
203
|
+
return
|
204
|
+
|
205
|
+
if content == pending.command:
|
206
|
+
if not pending.expect_response:
|
207
|
+
pending.future.set_result(pending.command)
|
208
|
+
del self._pending_commands[oldest_id]
|
209
|
+
else:
|
210
|
+
pending.future.set_result(content)
|
211
|
+
del self._pending_commands[oldest_id]
|
212
|
+
|
213
|
+
def _cleanup_timed_out_commands(self) -> None:
|
214
|
+
if not self._pending_commands:
|
215
|
+
return
|
216
|
+
|
217
|
+
current_time = time.time()
|
218
|
+
|
219
|
+
with self._command_lock:
|
220
|
+
timed_out = [
|
221
|
+
(cmd_id, pending)
|
222
|
+
for cmd_id, pending in self._pending_commands.items()
|
223
|
+
if current_time - pending.timestamp > pending.timeout
|
224
|
+
]
|
225
|
+
|
226
|
+
for cmd_id, pending in timed_out:
|
227
|
+
age = current_time - pending.timestamp
|
228
|
+
self._log(f"Command '{pending.command}' timed out after {age:.3f}s", "ERROR")
|
229
|
+
del self._pending_commands[cmd_id]
|
230
|
+
if not pending.future.done():
|
231
|
+
pending.future.set_exception(
|
232
|
+
MakcuTimeoutError(f"Command #{cmd_id} timed out")
|
233
|
+
)
|
234
|
+
|
235
|
+
def _listen(self) -> None:
|
236
|
+
self._log("Starting listener thread")
|
237
|
+
|
238
|
+
read_buffer = bytearray(4096)
|
239
|
+
line_buffer = bytearray(256)
|
240
|
+
line_pos = 0
|
241
|
+
|
242
|
+
|
243
|
+
serial_read = self.serial.read
|
244
|
+
serial_in_waiting = lambda: self.serial.in_waiting
|
245
|
+
is_connected = lambda: self._is_connected
|
246
|
+
stop_requested = self._stop_event.is_set
|
247
|
+
|
248
|
+
|
249
|
+
last_cleanup = time.time()
|
250
|
+
cleanup_interval = 0.05
|
251
|
+
|
252
|
+
while is_connected() and not stop_requested():
|
253
|
+
try:
|
254
|
+
bytes_available = serial_in_waiting()
|
255
|
+
if not bytes_available:
|
256
|
+
time.sleep(0.001)
|
257
|
+
continue
|
258
|
+
|
259
|
+
bytes_read = serial_read(min(bytes_available, 4096))
|
260
|
+
|
261
|
+
for byte_val in bytes_read:
|
262
|
+
if byte_val < 32 and byte_val not in (0x0D, 0x0A):
|
263
|
+
# Button data
|
264
|
+
self._handle_button_data(byte_val)
|
265
|
+
else:
|
266
|
+
if byte_val == 0x0A: # LF
|
267
|
+
if line_pos > 0:
|
268
|
+
line = bytes(line_buffer[:line_pos])
|
269
|
+
line_pos = 0
|
270
|
+
|
271
|
+
if line:
|
272
|
+
response = self._parse_response_line(line)
|
273
|
+
if response.content:
|
274
|
+
self._process_pending_commands(response.content)
|
275
|
+
elif byte_val != 0x0D: # Not CR
|
276
|
+
if line_pos < 256:
|
277
|
+
line_buffer[line_pos] = byte_val
|
278
|
+
line_pos += 1
|
279
|
+
|
280
|
+
current_time = time.time()
|
281
|
+
if current_time - last_cleanup > cleanup_interval:
|
282
|
+
self._cleanup_timed_out_commands()
|
283
|
+
last_cleanup = current_time
|
284
|
+
|
285
|
+
except serial.SerialException as e:
|
286
|
+
self._log(f"Serial exception in listener: {e}", "ERROR")
|
287
|
+
if self.auto_reconnect:
|
288
|
+
self._attempt_reconnect()
|
289
|
+
else:
|
290
|
+
break
|
291
|
+
except Exception as e:
|
292
|
+
self._log(f"Unexpected exception in listener: {e}", "ERROR")
|
293
|
+
|
294
|
+
self._log("Listener thread ending")
|
295
|
+
|
296
|
+
def _attempt_reconnect(self) -> None:
|
297
|
+
self._log(f"Attempting reconnect #{self._reconnect_attempts + 1}/{self.MAX_RECONNECT_ATTEMPTS}")
|
298
|
+
|
299
|
+
if self._reconnect_attempts >= self.MAX_RECONNECT_ATTEMPTS:
|
300
|
+
self._log("Max reconnect attempts reached, giving up", "ERROR")
|
301
|
+
self._is_connected = False
|
302
|
+
return
|
303
|
+
|
304
|
+
self._reconnect_attempts += 1
|
305
|
+
|
306
|
+
try:
|
307
|
+
if self.serial and self.serial.is_open:
|
308
|
+
self._log("Closing existing serial connection for reconnect")
|
309
|
+
self.serial.close()
|
310
|
+
|
311
|
+
time.sleep(self.RECONNECT_DELAY)
|
312
|
+
|
313
|
+
self.port = self.find_com_port()
|
314
|
+
if not self.port:
|
315
|
+
raise MakcuConnectionError("Device not found during reconnect")
|
316
|
+
|
317
|
+
self._log(f"Reconnecting to {self.port} at {self.baudrate} baud")
|
318
|
+
self.serial = serial.Serial(
|
319
|
+
self.port,
|
320
|
+
self.baudrate,
|
321
|
+
timeout=0.001,
|
322
|
+
write_timeout=0.01
|
323
|
+
)
|
324
|
+
|
325
|
+
if not self._change_baud_to_4M():
|
326
|
+
raise MakcuConnectionError("Failed to change baud during reconnect")
|
327
|
+
|
328
|
+
if self.send_init:
|
329
|
+
self._log("Sending init command during reconnect")
|
330
|
+
self.serial.write(b"km.buttons(1)\r")
|
331
|
+
self.serial.flush()
|
332
|
+
|
333
|
+
self._reconnect_attempts = 0
|
334
|
+
self._log("Reconnect successful")
|
335
|
+
|
336
|
+
except Exception as e:
|
337
|
+
self._log(f"Reconnect attempt failed: {e}", "ERROR")
|
338
|
+
time.sleep(self.RECONNECT_DELAY)
|
339
|
+
|
340
|
+
def _change_baud_to_4M(self) -> bool:
|
341
|
+
self._log("Changing baud rate to 4M")
|
342
|
+
|
343
|
+
if self.serial and self.serial.is_open:
|
344
|
+
self.serial.write(self.BAUD_CHANGE_COMMAND)
|
345
|
+
self.serial.flush()
|
346
|
+
|
347
|
+
time.sleep(0.02)
|
348
|
+
|
349
|
+
old_baud = self.serial.baudrate
|
350
|
+
self.serial.baudrate = 4000000
|
351
|
+
self._current_baud = 4000000
|
352
|
+
|
353
|
+
self._log(f"Baud rate changed: {old_baud} -> {self.serial.baudrate}")
|
354
|
+
return True
|
355
|
+
|
356
|
+
self._log("Cannot change baud - serial not open", "ERROR")
|
357
|
+
return False
|
358
|
+
|
359
|
+
def connect(self) -> None:
|
360
|
+
connection_start = time.time()
|
361
|
+
self._log("Starting connection process")
|
362
|
+
|
363
|
+
if self._is_connected:
|
364
|
+
self._log("Already connected")
|
365
|
+
return
|
366
|
+
|
367
|
+
if not self.override_port:
|
368
|
+
self.port = self.find_com_port()
|
369
|
+
else:
|
370
|
+
self.port = self._fallback_com_port
|
371
|
+
|
372
|
+
if not self.port:
|
373
|
+
raise MakcuConnectionError("Makcu device not found")
|
374
|
+
|
375
|
+
self._log(f"Connecting to {self.port}")
|
376
|
+
|
377
|
+
try:
|
378
|
+
self.serial = serial.Serial(
|
379
|
+
self.port,
|
380
|
+
115200,
|
381
|
+
timeout=0.001,
|
382
|
+
write_timeout=0.01,
|
383
|
+
xonxoff=False,
|
384
|
+
rtscts=False,
|
385
|
+
dsrdtr=False
|
386
|
+
)
|
387
|
+
|
388
|
+
if not self._change_baud_to_4M():
|
389
|
+
raise MakcuConnectionError("Failed to switch to 4M baud")
|
390
|
+
|
391
|
+
self._is_connected = True
|
392
|
+
self._reconnect_attempts = 0
|
393
|
+
|
394
|
+
connection_time = time.time() - connection_start
|
395
|
+
self._log(f"Connection established in {connection_time:.3f}s")
|
396
|
+
|
397
|
+
if self.send_init:
|
398
|
+
self._log("Sending initialization command")
|
399
|
+
init_cmd = b"km.buttons(1)\r"
|
400
|
+
self.serial.write(init_cmd)
|
401
|
+
self.serial.flush()
|
402
|
+
|
403
|
+
self._stop_event.clear()
|
404
|
+
self._listener_thread = threading.Thread(
|
405
|
+
target=self._listen,
|
406
|
+
daemon=True,
|
407
|
+
name="MakcuListener"
|
408
|
+
)
|
409
|
+
self._listener_thread.start()
|
410
|
+
self._log(f"Listener thread started: {self._listener_thread.name}")
|
411
|
+
|
412
|
+
except Exception as e:
|
413
|
+
self._log(f"Connection failed: {e}", "ERROR")
|
414
|
+
if self.serial:
|
415
|
+
try:
|
416
|
+
self.serial.close()
|
417
|
+
except:
|
418
|
+
pass
|
419
|
+
raise MakcuConnectionError(f"Failed to connect: {e}")
|
420
|
+
|
421
|
+
def disconnect(self) -> None:
|
422
|
+
self._log("Starting disconnection process")
|
423
|
+
|
424
|
+
self._is_connected = False
|
425
|
+
|
426
|
+
if self.send_init:
|
427
|
+
self._stop_event.set()
|
428
|
+
if self._listener_thread and self._listener_thread.is_alive():
|
429
|
+
self._listener_thread.join(timeout=0.1)
|
430
|
+
if self._listener_thread.is_alive():
|
431
|
+
self._log("Listener thread did not join within timeout")
|
432
|
+
else:
|
433
|
+
self._log("Listener thread stopped")
|
434
|
+
|
435
|
+
pending_count = len(self._pending_commands)
|
436
|
+
if pending_count > 0:
|
437
|
+
self._log(f"Cancelling {pending_count} pending commands")
|
438
|
+
|
439
|
+
with self._command_lock:
|
440
|
+
for cmd_id, pending in self._pending_commands.items():
|
441
|
+
if not pending.future.done():
|
442
|
+
pending.future.cancel()
|
443
|
+
self._pending_commands.clear()
|
444
|
+
|
445
|
+
if self.serial and self.serial.is_open:
|
446
|
+
self._log(f"Closing serial port: {self.serial.port}")
|
447
|
+
self.serial.close()
|
448
|
+
|
449
|
+
self.serial = None
|
450
|
+
self._log("Disconnection completed")
|
451
|
+
|
452
|
+
def send_command(self, command: str, expect_response: bool = True,
|
453
|
+
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
454
|
+
command_start = time.time()
|
455
|
+
|
456
|
+
if not self._is_connected or not self.serial or not self.serial.is_open:
|
457
|
+
raise MakcuConnectionError("Not connected")
|
458
|
+
|
459
|
+
if not expect_response:
|
460
|
+
cmd_bytes = f"{command}\r\n".encode('ascii')
|
461
|
+
self.serial.write(cmd_bytes)
|
462
|
+
self.serial.flush()
|
463
|
+
send_time = time.time() - command_start
|
464
|
+
self._log(f"Command '{command}' sent in {send_time:.3f}s (no response expected)")
|
465
|
+
return command
|
466
|
+
|
467
|
+
cmd_id = self._generate_command_id()
|
468
|
+
tagged_command = f"{command}#{cmd_id}"
|
469
|
+
|
470
|
+
future = Future()
|
471
|
+
|
472
|
+
with self._command_lock:
|
473
|
+
self._pending_commands[cmd_id] = PendingCommand(
|
474
|
+
command_id=cmd_id,
|
475
|
+
command=command,
|
476
|
+
future=future,
|
477
|
+
timestamp=time.time(),
|
478
|
+
expect_response=expect_response,
|
479
|
+
timeout=timeout
|
480
|
+
)
|
481
|
+
|
482
|
+
try:
|
483
|
+
cmd_bytes = f"{tagged_command}\r\n".encode('ascii')
|
484
|
+
self.serial.write(cmd_bytes)
|
485
|
+
self.serial.flush()
|
486
|
+
|
487
|
+
result = future.result(timeout=timeout)
|
488
|
+
|
489
|
+
response = result.split('#')[0] if '#' in result else result
|
490
|
+
total_time = time.time() - command_start
|
491
|
+
self._log(f"Command '{command}' completed in {total_time:.3f}s total")
|
492
|
+
return response
|
493
|
+
|
494
|
+
except TimeoutError:
|
495
|
+
total_time = time.time() - command_start
|
496
|
+
self._log(f"Command '{command}' timed out after {total_time:.3f}s", "ERROR")
|
497
|
+
raise MakcuTimeoutError(f"Command timed out: {command}")
|
498
|
+
except Exception as e:
|
499
|
+
total_time = time.time() - command_start
|
500
|
+
self._log(f"Command '{command}' failed after {total_time:.3f}s: {e}", "ERROR")
|
501
|
+
with self._command_lock:
|
502
|
+
self._pending_commands.pop(cmd_id, None)
|
503
|
+
raise
|
504
|
+
|
505
|
+
async def async_send_command(self, command: str, expect_response: bool = False,
|
506
|
+
timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
|
507
|
+
self._log(f"Async sending command: '{command}'")
|
508
|
+
loop = asyncio.get_running_loop()
|
509
|
+
return await loop.run_in_executor(
|
510
|
+
None, self.send_command, command, expect_response, timeout
|
511
|
+
)
|
512
|
+
|
513
|
+
def is_connected(self) -> bool:
|
514
|
+
connected = self._is_connected and self.serial is not None and self.serial.is_open
|
515
|
+
return connected
|
516
|
+
|
517
|
+
def set_button_callback(self, callback: Optional[Callable[[MouseButton, bool], None]]) -> None:
|
518
|
+
self._log(f"Setting button callback: {callback is not None}")
|
519
|
+
self._button_callback = callback
|
520
|
+
|
521
|
+
def get_button_states(self) -> Dict[str, bool]:
|
522
|
+
states = {
|
523
|
+
self.BUTTON_MAP[i]: bool(self._button_states & (1 << i))
|
524
|
+
for i in range(5)
|
525
|
+
}
|
526
|
+
return states
|
527
|
+
|
528
|
+
def get_button_mask(self) -> int:
|
529
|
+
return self._last_button_mask
|
530
|
+
|
531
|
+
def enable_button_monitoring(self, enable: bool = True) -> None:
|
532
|
+
cmd = "km.buttons(1)" if enable else "km.buttons(0)"
|
533
|
+
self._log(f"{'Enabling' if enable else 'Disabling'} button monitoring")
|
534
|
+
self.send_command(cmd)
|
535
|
+
|
536
|
+
async def __aenter__(self):
|
537
|
+
self._log("Async context manager enter")
|
538
|
+
loop = asyncio.get_running_loop()
|
539
|
+
await loop.run_in_executor(None, self.connect)
|
540
|
+
return self
|
541
|
+
|
542
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
543
|
+
self._log("Async context manager exit")
|
544
|
+
loop = asyncio.get_running_loop()
|
545
|
+
await loop.run_in_executor(None, self.disconnect)
|
546
|
+
|
547
|
+
def __enter__(self):
|
548
|
+
self._log("Sync context manager enter")
|
549
|
+
self.connect()
|
550
|
+
return self
|
551
|
+
|
552
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
553
|
+
self._log("Sync context manager exit")
|
460
554
|
self.disconnect()
|