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/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
- 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
+ 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()