mousebridge-mix 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MouseBridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE README.md RELEASE.md
2
+ include native/mix_sender.cpp
3
+ recursive-include src/mousebridge *.py *.dll
4
+ recursive-include tests *.py *.bin
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: mousebridge-mix
3
+ Version: 0.1.0
4
+ Summary: Windows HID movement client for MouseBridge MIX v2
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Operating System :: Microsoft :: Windows
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Human Interface Device (HID)
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # mousebridge-mix
16
+
17
+ Python client for MouseBridge MIX v2 firmware: initialize once, send mouse
18
+ movements through Windows HID. Windows x64 and 64-bit Python 3.10+ required.
19
+ The native sender DLL is included. No Zadig, WinUSB replacement, PyUSB,
20
+ libusb, or additional Python runtime dependencies are required.
21
+
22
+ ## Install
23
+
24
+ After publication on PyPI:
25
+
26
+ ```console
27
+ python -m pip install mousebridge-mix
28
+ ```
29
+
30
+ For a locally built wheel:
31
+
32
+ ```console
33
+ python -m pip install mousebridge_mix-0.1.0-py3-none-win_amd64.whl
34
+ ```
35
+
36
+ ## Use
37
+
38
+ Connect a Leonardo + USB Host Shield running **MIX v2** and its source mouse.
39
+
40
+ ```python
41
+ from mousebridge import init
42
+
43
+ with init() as mouse:
44
+ mouse.send_move(20, 0)
45
+ mouse.send_move(0, -20)
46
+ ```
47
+
48
+ Optional selection: `init(vid=0x373E, pid=0x0047)` or `init(path=hid_path)`.
49
+ Use your source device's VID/PID, not necessarily Arduino's. Generic discovery
50
+ works when exactly one supported command collection is present.
51
+
52
+ The familiar write interface remains available:
53
+
54
+ ```python
55
+ import struct
56
+ from mousebridge import find
57
+
58
+ with find(idVendor=0x373E, idProduct=0x0047) as mouse:
59
+ mouse.write(1, struct.pack('<hh', 20, 0), timeout=8)
60
+ ```
61
+
62
+ `write()` accepts endpoint `1` as a compatibility alias, or `mouse.endpoint`;
63
+ it translates a four-byte XY payload into the MIX HID output report. It is
64
+ not a PyUSB write to an arbitrary USB endpoint. `timeout` is accepted for
65
+ compatibility but does not set a native write timeout.
66
+
67
+ `send_move(x, y)` accepts signed int32 integers. `move()` is an alias.
68
+ Sending is one synchronous native Windows `WriteFile` call; there is no
69
+ Python retry loop, timer, queue, or sleep. The API returns no delivery status
70
+ and does not promise non-blocking USB I/O. Use one caller per device and
71
+ close it after use; the context manager does this automatically.
72
+
73
+ Initialization reads descriptors and registers the MIX layout once. If the
74
+ firmware reports no cached source state, move the physical mouse once,
75
+ release its buttons, and initialize again. Physical and software motion are
76
+ combined by MIX v2 firmware. This package does not update the firmware.
77
+ Importing the package neither opens HID devices nor moves the cursor.
78
+
79
+ ## Update
80
+
81
+ ```console
82
+ python -m pip install --upgrade mousebridge-mix
83
+ ```
84
+
85
+ Updates take effect in a new Python process. Version 0.1.x targets the MIX v2
86
+ `MX\x02` command capability; older MOTION and MIX v1 are rejected.
87
+
88
+ ## License
89
+
90
+ MIT. See LICENSE. Source for the bundled native DLL is in the source
91
+ archive under `native/mix_sender.cpp`.
@@ -0,0 +1,77 @@
1
+ # mousebridge-mix
2
+
3
+ Python client for MouseBridge MIX v2 firmware: initialize once, send mouse
4
+ movements through Windows HID. Windows x64 and 64-bit Python 3.10+ required.
5
+ The native sender DLL is included. No Zadig, WinUSB replacement, PyUSB,
6
+ libusb, or additional Python runtime dependencies are required.
7
+
8
+ ## Install
9
+
10
+ After publication on PyPI:
11
+
12
+ ```console
13
+ python -m pip install mousebridge-mix
14
+ ```
15
+
16
+ For a locally built wheel:
17
+
18
+ ```console
19
+ python -m pip install mousebridge_mix-0.1.0-py3-none-win_amd64.whl
20
+ ```
21
+
22
+ ## Use
23
+
24
+ Connect a Leonardo + USB Host Shield running **MIX v2** and its source mouse.
25
+
26
+ ```python
27
+ from mousebridge import init
28
+
29
+ with init() as mouse:
30
+ mouse.send_move(20, 0)
31
+ mouse.send_move(0, -20)
32
+ ```
33
+
34
+ Optional selection: `init(vid=0x373E, pid=0x0047)` or `init(path=hid_path)`.
35
+ Use your source device's VID/PID, not necessarily Arduino's. Generic discovery
36
+ works when exactly one supported command collection is present.
37
+
38
+ The familiar write interface remains available:
39
+
40
+ ```python
41
+ import struct
42
+ from mousebridge import find
43
+
44
+ with find(idVendor=0x373E, idProduct=0x0047) as mouse:
45
+ mouse.write(1, struct.pack('<hh', 20, 0), timeout=8)
46
+ ```
47
+
48
+ `write()` accepts endpoint `1` as a compatibility alias, or `mouse.endpoint`;
49
+ it translates a four-byte XY payload into the MIX HID output report. It is
50
+ not a PyUSB write to an arbitrary USB endpoint. `timeout` is accepted for
51
+ compatibility but does not set a native write timeout.
52
+
53
+ `send_move(x, y)` accepts signed int32 integers. `move()` is an alias.
54
+ Sending is one synchronous native Windows `WriteFile` call; there is no
55
+ Python retry loop, timer, queue, or sleep. The API returns no delivery status
56
+ and does not promise non-blocking USB I/O. Use one caller per device and
57
+ close it after use; the context manager does this automatically.
58
+
59
+ Initialization reads descriptors and registers the MIX layout once. If the
60
+ firmware reports no cached source state, move the physical mouse once,
61
+ release its buttons, and initialize again. Physical and software motion are
62
+ combined by MIX v2 firmware. This package does not update the firmware.
63
+ Importing the package neither opens HID devices nor moves the cursor.
64
+
65
+ ## Update
66
+
67
+ ```console
68
+ python -m pip install --upgrade mousebridge-mix
69
+ ```
70
+
71
+ Updates take effect in a new Python process. Version 0.1.x targets the MIX v2
72
+ `MX\x02` command capability; older MOTION and MIX v1 are rejected.
73
+
74
+ ## License
75
+
76
+ MIT. See LICENSE. Source for the bundled native DLL is in the source
77
+ archive under `native/mix_sender.cpp`.
@@ -0,0 +1,48 @@
1
+ # Build and publish
2
+
3
+ Build from this folder in a Windows x64 Python environment:
4
+
5
+ ```console
6
+ python -m venv .venv-build
7
+ .venv-build\Scripts\python -m pip install build twine wheel "setuptools>=77"
8
+ .venv-build\Scripts\python -m build
9
+ .venv-build\Scripts\python -m twine check dist/*
10
+ ```
11
+
12
+ This produces a Windows AMD64 wheel and a source archive. The wheel includes
13
+ only the client modules, native DLL and metadata, never firmware, uploaders,
14
+ admin credentials, logs, or project backups. Tests use fake HID transports.
15
+
16
+ Before publishing, verify the PyPI name is available to your account, review
17
+ README/license, and inspect exactly the files to be uploaded. Package-name
18
+ availability checks are advisory; a missing public page does not reserve a
19
+ name. Public publication has not been performed by this build.
20
+
21
+ Test publication (requires your TestPyPI account/token):
22
+
23
+ ```console
24
+ .venv-build\Scripts\python -m twine upload --repository testpypi dist/mousebridge_mix-0.1.0-py3-none-win_amd64.whl dist/mousebridge_mix-0.1.0.tar.gz
25
+ ```
26
+
27
+ Then install in another clean environment with TestPyPI's index. This package
28
+ has no runtime dependencies. TestPyPI and PyPI credentials are separate.
29
+
30
+ Publish the same verified artifacts using your PyPI account:
31
+
32
+ ```console
33
+ .venv-build\Scripts\python -m twine upload dist/mousebridge_mix-0.1.0-py3-none-win_amd64.whl dist/mousebridge_mix-0.1.0.tar.gz
34
+ ```
35
+
36
+ Use Twine's credential prompt or a configured trusted publisher; do not
37
+ place tokens in source code, command arguments, documentation, or this chat.
38
+ The distribution name is mousebridge-mix; the import name is mousebridge.
39
+
40
+ For an update, change `src/mousebridge/__init__.py`'s version, update code and
41
+ compatibility notes, and build into a new output directory. PyPI does not
42
+ allow replacing an already uploaded release file. Publish only explicit new
43
+ filenames, not stale artifacts via an unrestricted wildcard.
44
+
45
+ The native DLL was built from native/mix_sender.cpp with Zig 0.13.0:
46
+ `zig cc -x c++ -std=c++11 -O2 -fno-exceptions -fno-rtti -s -Wall -Wextra -Werror -shared -target x86_64-windows-gnu native/mix_sender.cpp -o src/mousebridge/mousebridge_mix.dll`.
47
+ When replacing it, recheck PE AMD64, imports/exports and firmware protocol
48
+ compatibility; synchronize the integrity reference in verification/tests.
@@ -0,0 +1,20 @@
1
+ #define WIN32_LEAN_AND_MEAN
2
+ #include <windows.h>
3
+ #include <stdint.h>
4
+ #include <string.h>
5
+ struct MixSender { HANDLE device;uint8_t report[12];uint16_t sequence; };
6
+ extern "C" __declspec(dllexport) MixSender *WINAPI MotionCreate(HANDLE device,const uint8_t *data,unsigned,unsigned,unsigned,unsigned){
7
+ if(!device || device==INVALID_HANDLE_VALUE || !data || data[0]!=1 || data[2]!=16){SetLastError(ERROR_INVALID_PARAMETER);return nullptr;}
8
+ MixSender *sender=static_cast<MixSender *>(HeapAlloc(GetProcessHeap(),HEAP_ZERO_MEMORY,sizeof(MixSender)));
9
+ if(!sender){SetLastError(ERROR_NOT_ENOUGH_MEMORY);return nullptr;}
10
+ sender->device=device;sender->report[0]=3;sender->report[1]=data[8];sender->sequence=uint16_t(data[3])|(uint16_t(data[4])<<8);return sender;
11
+ }
12
+ extern "C" __declspec(dllexport) void WINAPI MotionSendMove(MixSender *sender,int32_t x,int32_t y){
13
+ if(!sender)return;
14
+ memcpy(sender->report+2,&x,4);memcpy(sender->report+6,&y,4);++sender->sequence;
15
+ sender->report[10]=uint8_t(sender->sequence);sender->report[11]=uint8_t(sender->sequence>>8);
16
+ DWORD written;WriteFile(sender->device,sender->report,sizeof(sender->report),&written,nullptr);
17
+ }
18
+ extern "C" __declspec(dllexport) void WINAPI MotionClose(MixSender *sender){
19
+ if(!sender)return;CloseHandle(sender->device);SecureZeroMemory(sender,sizeof(*sender));HeapFree(GetProcessHeap(),0,sender);
20
+ }
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel>=0.45"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mousebridge-mix"
7
+ dynamic = ["version"]
8
+ description = "Windows HID movement client for MouseBridge MIX v2"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Operating System :: Microsoft :: Windows",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Human Interface Device (HID)",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+ include = ["mousebridge*"]
23
+
24
+ [tool.setuptools.package-data]
25
+ mousebridge = ["mousebridge_mix.dll"]
26
+
27
+ [tool.setuptools.dynamic]
28
+ version = {attr = "mousebridge.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """The bundled DLL is Windows AMD64, independent of the Python C ABI."""
2
+ from setuptools import setup
3
+ from wheel.bdist_wheel import bdist_wheel
4
+
5
+
6
+ class WindowsWheel(bdist_wheel):
7
+ def finalize_options(self):
8
+ super().finalize_options()
9
+ self.root_is_pure = False
10
+ self.plat_name = 'win_amd64'
11
+
12
+ def get_tag(self):
13
+ return 'py3', 'none', 'win_amd64'
14
+
15
+
16
+ setup(cmdclass={'bdist_wheel': WindowsWheel})
@@ -0,0 +1,5 @@
1
+ """MouseBridge MIX v2 movement client. Importing never opens a device."""
2
+ __version__ = '0.1.0'
3
+ from .hid_mix import MixDevice, init, find
4
+
5
+ __all__ = ['MixDevice', 'init', 'find', '__version__']
@@ -0,0 +1,68 @@
1
+ """Bounded initialization exchanges; independent of command-line tooling."""
2
+ from .bridge_protocol import STATUS, frame, decode_response, descriptor_frame, report_frames
3
+
4
+
5
+ class BridgeClient:
6
+ __slots__ = ('device', 'sequence')
7
+
8
+ def __init__(self, device):
9
+ self.device = device
10
+ self.sequence = 0
11
+
12
+ def next_sequence(self):
13
+ self.sequence = (self.sequence + 1) & 65535
14
+ return self.sequence
15
+
16
+ def exchange(self, request):
17
+ if self.device.send_feature_report(request) != 64:
18
+ raise RuntimeError('Incomplete HID Feature write')
19
+ return decode_response(self.device.get_feature_report(2, 64), request[2],
20
+ int.from_bytes(request[3:5], 'little'))
21
+
22
+ def checked(self, request):
23
+ response = self.exchange(request)
24
+ if response.status:
25
+ raise RuntimeError(f'Firmware rejected command: {STATUS[response.status]}')
26
+ return response.data
27
+
28
+ def descriptor(self, target):
29
+ # MIX adds 32 bytes to the bounded 192-byte source configuration.
30
+ limit = 224 if target == 0 else 18 if target == 1 else 384
31
+ result = bytearray(limit)
32
+ offset = 0
33
+ expected = None
34
+ for unused in range((limit + 54) // 55):
35
+ response = self.checked(descriptor_frame(target, offset, self.next_sequence()))
36
+ total = int.from_bytes(response[6:8], 'little')
37
+ count = response[8]
38
+ if expected is None:
39
+ expected = total
40
+ if (total != expected or total > limit or offset > total or
41
+ count != min(55, total - offset)):
42
+ raise RuntimeError('Inconsistent or oversized descriptor response')
43
+ result[offset:offset + count] = response[9:9 + count]
44
+ offset += count
45
+ if offset == total:
46
+ return bytes(result[:total])
47
+ raise RuntimeError('Descriptor exceeded bounded read count')
48
+
49
+ def info(self):
50
+ data = self.checked(frame(1, self.next_sequence()))
51
+ return dict(source_interfaces=data[6], source_endpoints=data[7], command_interface=data[8],
52
+ command_endpoint=hex(0x80 | data[9]), configuration_bytes=int.from_bytes(data[10:12], 'little'),
53
+ report_descriptor_bytes=int.from_bytes(data[12:14], 'little'),
54
+ vid=hex(int.from_bytes(data[14:16], 'little')), pid=hex(int.from_bytes(data[16:18], 'little')),
55
+ relayed_mod65536=int.from_bytes(data[18:20], 'little'),
56
+ injected_mod65536=int.from_bytes(data[20:22], 'little'),
57
+ transfer_errors_mod65536=int.from_bytes(data[22:24], 'little'),
58
+ blocked_polls_mod65536=int.from_bytes(data[24:26], 'little'),
59
+ last_error=STATUS[data[26]], configuration=data[27])
60
+
61
+ def send_report(self, endpoint, report):
62
+ # Explicit raw CLI operation. No automatic BUSY retry.
63
+ for request in report_frames(endpoint, report, self.next_sequence()):
64
+ response = self.exchange(request)
65
+ if response.status:
66
+ raise RuntimeError(f'Firmware rejected report: {STATUS[response.status]}')
67
+ if response.data[6] != request[7] + request[8]:
68
+ raise RuntimeError('Incorrect fragment acknowledgement')
@@ -0,0 +1,49 @@
1
+ """Protocol v1. HID report IDs are part of these buffers, not added a second time."""
2
+ from dataclasses import dataclass
3
+ import struct
4
+
5
+ COMMAND_ID, RESPONSE_ID, VERSION, REPORT_SIZE, CHUNK = 1, 2, 1, 64, 55
6
+ STATUS = ('OK', 'BAD_DESCRIPTOR', 'UNSUPPORTED', 'MEMORY_LIMIT', 'ENDPOINT_LIMIT',
7
+ 'HOST_ERROR', 'NOT_READY', 'BAD_COMMAND', 'BAD_TARGET', 'BAD_LENGTH',
8
+ 'BUSY', 'BAD_REPORT', 'USB_ERROR')
9
+
10
+ def frame(opcode: int, sequence: int, target=0, total=0, offset=0, payload=b'') -> bytes:
11
+ if not 0 <= sequence <= 65535 or not 0 <= target <= 255:
12
+ raise ValueError('Sequence or target out of range')
13
+ if len(payload) > CHUNK or not 0 <= total <= 64 or not 0 <= offset <= 255:
14
+ raise ValueError('Invalid frame length')
15
+ return bytes([COMMAND_ID, VERSION, opcode]) + struct.pack('<H', sequence) + bytes(
16
+ [target, total, offset, len(payload)]) + payload + bytes(CHUNK - len(payload))
17
+
18
+ def report_frames(endpoint: int, report: bytes, sequence: int):
19
+ if not 1 <= len(report) <= 64:
20
+ raise ValueError('A complete Input report must contain 1..64 bytes')
21
+ if not endpoint & 0x80 or endpoint & 0x70 or not 1 <= (endpoint & 15) <= 6:
22
+ raise ValueError('Use a source interrupt-IN endpoint address, e.g. 0x81')
23
+ return [frame(2, sequence, endpoint, len(report), pos, report[pos:pos + CHUNK])
24
+ for pos in range(0, len(report), CHUNK)]
25
+
26
+ @dataclass(frozen=True)
27
+ class Response:
28
+ __slots__ = ('status', 'opcode', 'sequence', 'data')
29
+ status: int
30
+ opcode: int
31
+ sequence: int
32
+ data: bytes
33
+
34
+ def decode_response(raw: bytes, opcode: int, sequence: int) -> Response:
35
+ if len(raw) != REPORT_SIZE or raw[0:2] != bytes([RESPONSE_ID, VERSION]):
36
+ raise ValueError('Wrong response report size, ID or protocol version')
37
+ if raw[3] != opcode or int.from_bytes(raw[4:6], 'little') != sequence:
38
+ raise ValueError('Stale response or sequence/opcode mismatch')
39
+ if raw[2] >= len(STATUS):
40
+ raise ValueError('Unknown firmware status')
41
+ return Response(raw[2], opcode, sequence, raw)
42
+
43
+ def descriptor_frame(target: int, offset: int, sequence: int):
44
+ if not 0 <= offset <= 65535:
45
+ raise ValueError('Descriptor offset out of range')
46
+ # For opcode 3, bytes 6 and 7 mean the 16-bit descriptor offset.
47
+ data = bytearray(frame(3, sequence, target))
48
+ data[6:8] = struct.pack('<H', offset)
49
+ return bytes(data)
@@ -0,0 +1,70 @@
1
+ """MIX: initialize once, one synchronous native HID Output write per movement."""
2
+ import ctypes
3
+ import struct
4
+ from pathlib import Path
5
+ from .hid_motion import release_native
6
+
7
+
8
+ class MixDevice:
9
+ __slots__ = ('_library','_context','_send','_close','endpoint','interface')
10
+ def __init__(self, transport):
11
+ from .motion_initialize import prepare_motion
12
+ self._context = self._library = None
13
+ try:
14
+ prepared = prepare_motion(transport, mix=True)
15
+ template, self.endpoint, self.interface = prepared[:3]
16
+ path = Path(__file__).with_name('mousebridge_mix.dll')
17
+ if not path.is_file():
18
+ raise OSError('Bundled mousebridge_mix.dll is missing; reinstall mousebridge-mix')
19
+ if ctypes.sizeof(ctypes.c_void_p) != 8:
20
+ raise OSError('MIX requires 64-bit Python')
21
+ self._library = ctypes.WinDLL(str(path),use_last_error=True)
22
+ create = self._library.MotionCreate
23
+ create.argtypes = [ctypes.c_void_p,ctypes.c_void_p,ctypes.c_uint,ctypes.c_uint,ctypes.c_uint,ctypes.c_uint]
24
+ create.restype = ctypes.c_void_p
25
+ self._send = self._library.MotionSendMove
26
+ self._send.argtypes = [ctypes.c_void_p,ctypes.c_int32,ctypes.c_int32]
27
+ self._send.restype = None
28
+ self._close = self._library.MotionClose
29
+ self._close.argtypes = [ctypes.c_void_p]
30
+ self._close.restype = None
31
+ buffer = (ctypes.c_ubyte*64).from_buffer(template)
32
+ self._context = create(transport.handle,buffer,0,0,0,0)
33
+ if not self._context:
34
+ raise ctypes.WinError(ctypes.get_last_error())
35
+ transport.detach_handle()
36
+ except BaseException:
37
+ self.close()
38
+ raise
39
+ finally:
40
+ transport.close()
41
+
42
+ @classmethod
43
+ def open(cls,vid=None,pid=None,path=None):
44
+ from .windows_hid import WindowsHid
45
+ return cls(WindowsHid(vid=vid,pid=pid,path=path))
46
+
47
+ def send_move(self,x,y):
48
+ if self._context is None: raise OSError('MIX sender is closed')
49
+ if type(x) is not int or type(y) is not int: raise TypeError('Integer movement required')
50
+ if not -2147483648<=x<=2147483647 or not -2147483648<=y<=2147483647: raise ValueError('Movement outside int32')
51
+ self._send(self._context,x,y)
52
+
53
+ def move(self,x,y): self.send_move(x,y)
54
+ def write(self,endpoint,data,timeout=None):
55
+ if endpoint not in (1,self.endpoint): raise ValueError('Use endpoint=1 or dev.endpoint')
56
+ self.send_move(*struct.unpack('<hh',data))
57
+
58
+ def close(self):
59
+ if self._context is not None:
60
+ self._close(self._context)
61
+ self._context = None
62
+ self._send = self._close = None
63
+ release_native(self._library)
64
+ self._library = None
65
+ def __enter__(self): return self
66
+ def __exit__(self,*args): self.close()
67
+
68
+
69
+ def init(vid=None,pid=None,path=None): return MixDevice.open(vid,pid,path)
70
+ def find(idVendor=None,idProduct=None): return init(idVendor,idProduct)
@@ -0,0 +1,11 @@
1
+ """Native module lifetime helper used by the MIX client."""
2
+ import ctypes
3
+
4
+
5
+ def release_native(library):
6
+ if library is not None and hasattr(library, '_handle'):
7
+ release = ctypes.windll.kernel32.FreeLibrary
8
+ release.argtypes = [ctypes.c_void_p]
9
+ release.restype = ctypes.c_int
10
+ release(library._handle)
11
+
@@ -0,0 +1,139 @@
1
+ """Conservative HID relative mouse layout parser (no manufacturer assumptions)."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Field:
7
+ __slots__ = ('offset', 'width', 'minimum', 'maximum', 'usage')
8
+ offset: int
9
+ width: int
10
+ minimum: int
11
+ maximum: int
12
+ usage: int
13
+
14
+
15
+ @dataclass
16
+ class Layout:
17
+ __slots__ = ('report_id', 'length', 'x', 'y', 'relative')
18
+ report_id: int
19
+ length: int
20
+ x: Field
21
+ y: Field
22
+ relative: list
23
+
24
+ def overlay(self, x, y):
25
+ value, mask = 0, 0
26
+ prefix = 8 if self.report_id else 0
27
+ for f in self.relative:
28
+ v = x if f == self.x else y if f == self.y else 0
29
+ if not f.minimum <= v <= f.maximum:
30
+ raise ValueError(f'Value {v} outside HID logical range {f.minimum}..{f.maximum}')
31
+ bits = (1 << f.width) - 1
32
+ mask |= bits << (prefix + f.offset)
33
+ value |= (v & bits) << (prefix + f.offset)
34
+ if self.report_id:
35
+ value |= self.report_id
36
+ return value.to_bytes(self.length, 'little'), mask.to_bytes(self.length, 'little')
37
+
38
+
39
+ def parse_mouse_layout(descriptor):
40
+ g = dict(page=0, size=0, count=0, id=0, minimum=0, maximum=0)
41
+ saved, collections, usages = [], [], []
42
+ usage_min = usage_max = None
43
+ offsets, fields, axes, owners = {}, {}, {}, {}
44
+ mouse_collection = 0
45
+ pos, has_ids = 0, False
46
+ while pos < len(descriptor):
47
+ head = descriptor[pos]; pos += 1
48
+ if head == 0xfe:
49
+ raise ValueError('HID long items are unsupported')
50
+ size = (0, 1, 2, 4)[head & 3]
51
+ if pos + size > len(descriptor):
52
+ raise ValueError('Truncated HID item')
53
+ data = descriptor[pos:pos+size]; pos += size
54
+ v = int.from_bytes(data, 'little')
55
+ signed = int.from_bytes(data, 'little', signed=True) if size else 0
56
+ kind, tag = (head >> 2) & 3, head >> 4
57
+ if kind == 1:
58
+ if tag == 0: g['page'] = v
59
+ elif tag == 1: g['minimum'] = signed
60
+ elif tag == 2: g['maximum'] = signed if g['minimum'] < 0 else v
61
+ elif tag == 7:
62
+ if not 1 <= v <= 32: raise ValueError('Unsupported Report Size')
63
+ g['size'] = v
64
+ elif tag == 8:
65
+ if not 1 <= v <= 255: raise ValueError('Invalid Report ID')
66
+ g['id'] = v; has_ids = True
67
+ elif tag == 9:
68
+ if not 1 <= v <= 512: raise ValueError('Unsupported Report Count')
69
+ g['count'] = v
70
+ elif tag == 10:
71
+ if size or len(saved) >= 4: raise ValueError('Invalid global PUSH')
72
+ saved.append(g.copy())
73
+ elif tag == 11:
74
+ if size or not saved: raise ValueError('Invalid global POP')
75
+ g = saved.pop()
76
+ elif tag not in (3, 4, 5, 6): raise ValueError('Unsupported global item')
77
+ elif kind == 2:
78
+ usage = v if size == 4 else (g['page'] << 16) | v
79
+ if tag == 0: usages.append(usage)
80
+ elif tag == 1: usage_min = usage
81
+ elif tag == 2: usage_max = usage
82
+ else: raise ValueError('Unsupported local HID item')
83
+ elif kind == 0:
84
+ if tag == 10:
85
+ if size != 1 or len(collections) >= 32: raise ValueError('Invalid Collection')
86
+ mouse = (v == 1 and (usages or [0])[0] == 0x10002)
87
+ if mouse: mouse_collection += 1
88
+ # A nested application collection cannot borrow ownership from a mouse.
89
+ owner = (mouse_collection if mouse else None) if v == 1 else (collections[-1] if collections else None)
90
+ collections.append(owner)
91
+ elif tag == 12:
92
+ if size or not collections: raise ValueError('Invalid End Collection')
93
+ collections.pop()
94
+ elif tag in (8, 9, 11):
95
+ if not size or not collections or not g['size'] or not g['count']:
96
+ raise ValueError('Invalid HID data item')
97
+ key = (g['id'], tag)
98
+ start = offsets.get(key, 0)
99
+ end = start + g['size'] * g['count']
100
+ if end > (504 if g['id'] else 512): raise ValueError('Report exceeds 64 bytes')
101
+ offsets[key] = end
102
+ resolved = list(usages)
103
+ if usage_min is not None or usage_max is not None:
104
+ if usage_min is None or usage_max is None or not 0 <= usage_max - usage_min <= 511:
105
+ raise ValueError('Invalid usage range')
106
+ resolved.extend(range(usage_min, usage_max + 1))
107
+ if tag == 8 and not v & 1:
108
+ if v & 4:
109
+ if not v & 2 or v & 0x100: raise ValueError('Unsupported relative Input')
110
+ if not g['minimum'] <= 0 <= g['maximum']: raise ValueError('Relative field has no neutral zero')
111
+ for i in range(g['count']):
112
+ usage = resolved[min(i, len(resolved)-1)] if resolved else 0
113
+ f = Field(start + i*g['size'], g['size'], g['minimum'], g['maximum'], usage)
114
+ lo = -(1 << (f.width-1)) if f.minimum < 0 else 0
115
+ hi = (1 << (f.width-1)) - 1 if f.minimum < 0 else (1 << f.width) - 1
116
+ if f.minimum < lo or f.maximum > hi or f.minimum > f.maximum:
117
+ raise ValueError('Logical bounds exceed field width')
118
+ fields.setdefault(g['id'], []).append(f)
119
+ owners.setdefault(g['id'], set()).add(collections[-1])
120
+ if usage in (0x10030, 0x10031) and collections[-1]:
121
+ a = axes.setdefault(g['id'], {})
122
+ if usage in a: raise ValueError('Ambiguous mouse axis')
123
+ a[usage] = f
124
+ else: raise ValueError('Unsupported main HID item')
125
+ usages = []; usage_min = usage_max = None
126
+ else:
127
+ raise ValueError('Reserved HID item')
128
+ if collections or saved: raise ValueError('Unbalanced HID descriptor')
129
+ if has_ids and any(rid == 0 for rid, _ in offsets): raise ValueError('Mixed numbered/unnumbered reports')
130
+ result = []
131
+ for rid, a in axes.items():
132
+ if set(a) == {0x10030, 0x10031}:
133
+ if None in owners[rid] or len(owners[rid]) != 1:
134
+ raise ValueError('Relative fields cross application collections in one report')
135
+ length = (offsets[(rid, 8)] + 7)//8 + bool(rid)
136
+ result.append(Layout(rid, length, a[0x10030], a[0x10031], fields[rid]))
137
+ if len(result) != 1: raise ValueError('Expected one unambiguous relative mouse X/Y report')
138
+ if result[0].length > 27: raise ValueError('Motion overlay supports reports up to 27 bytes')
139
+ return result[0]
@@ -0,0 +1,106 @@
1
+ """One-time mouse discovery and template preparation. No sending loop."""
2
+ from .bridge_client import BridgeClient
3
+ from .bridge_protocol import frame
4
+ from .hid_report_layout import parse_mouse_layout
5
+
6
+
7
+ def prepare_motion(transport, mix=False):
8
+ client = BridgeClient(transport)
9
+ identity = client.checked(frame(4, client.next_sequence()))
10
+ if identity[42:46] != b'MBD\x01':
11
+ raise RuntimeError('Command collection is not MouseBridge')
12
+ capability = client.exchange(frame(14, client.next_sequence()))
13
+ data = capability.data
14
+ if capability.status or data[6:9] != (b'MX\x02' if mix else b'MO\x01') or data[9] != 27:
15
+ raise RuntimeError('Установите прошивку MIX v2 для этого клиента' if mix else 'Install the MOTION firmware before opening the sender')
16
+ generation = data[10]
17
+ if mix and not 1 <= data[11] <= 6:
18
+ raise RuntimeError('No spare hardware endpoint for MIX OUT')
19
+ topology = client.checked(frame(13, client.next_sequence()))
20
+ if topology[6:9] != b'VT\x01':
21
+ raise RuntimeError('Cannot verify virtual interface ownership')
22
+ virtual_mask = topology[9]
23
+ information = client.checked(frame(1, client.next_sequence()))
24
+ if not 1 <= information[6] <= 4:
25
+ raise ValueError('Invalid interface count')
26
+ configuration = client.descriptor(0)
27
+ endpoints = {}
28
+ interface = None
29
+ mix_output_found = False
30
+ offset = 0
31
+ while offset < len(configuration):
32
+ length = configuration[offset]
33
+ if length < 2 or offset + length > len(configuration):
34
+ raise ValueError('Invalid configuration descriptor')
35
+ descriptor = configuration[offset:offset + length]
36
+ if descriptor[1] == 4:
37
+ if length != 9 or descriptor[3]:
38
+ raise ValueError('Unsupported interface descriptor')
39
+ interface = descriptor[2]
40
+ if interface != information[8] and not virtual_mask & (1 << interface):
41
+ endpoints[interface] = []
42
+ elif descriptor[1] == 5:
43
+ if length != 7:
44
+ raise ValueError('Invalid endpoint descriptor')
45
+ if interface in endpoints and descriptor[2] & 128 and descriptor[3] == 3:
46
+ endpoints[interface].append((descriptor[2], int.from_bytes(descriptor[4:6], 'little')))
47
+ if mix and interface == information[8] and descriptor[2] == data[11] and descriptor[3] == 3 and int.from_bytes(descriptor[4:6], 'little') >= 12:
48
+ mix_output_found = True
49
+ offset += length
50
+ if mix and not mix_output_found:
51
+ raise RuntimeError('MIX OUT endpoint missing from configuration')
52
+ selected = None
53
+ for slot in range(information[6]):
54
+ interface = information[28 + slot]
55
+ if interface not in endpoints:
56
+ continue
57
+ try:
58
+ layout = parse_mouse_layout(client.descriptor(slot + 2))
59
+ except ValueError:
60
+ continue
61
+ endpoint = None
62
+ for address, size in endpoints[interface]:
63
+ if size >= layout.length:
64
+ if endpoint is not None:
65
+ raise ValueError('Ambiguous mouse endpoint')
66
+ endpoint = address
67
+ if endpoint is not None:
68
+ if selected is not None:
69
+ raise ValueError('Ambiguous mouse interface')
70
+ selected = (endpoint, interface, layout)
71
+ if selected is None:
72
+ raise ValueError('No supported relative mouse interface')
73
+ confirmed = client.checked(frame(14, client.next_sequence()))
74
+ if confirmed[10] != generation:
75
+ raise RuntimeError('USB session changed during initialization; reopen')
76
+ endpoint, interface, layout = selected
77
+ values, mask = layout.overlay(0, 0)
78
+ template = bytearray(64)
79
+ template[0:3] = b'\x01\x01\x0e'
80
+ template[3:5] = client.sequence.to_bytes(2, 'little')
81
+ template[5:9] = bytes((endpoint, layout.length, layout.report_id, generation))
82
+ template[9:9 + layout.length] = values
83
+ template[9 + layout.length:9 + 2 * layout.length] = mask
84
+ prefix = 8 if layout.report_id else 0
85
+ if mix:
86
+ registration = bytearray(frame(15, client.next_sequence()))
87
+ registration[5:9] = template[5:9]
88
+ registration[9:13] = bytes((layout.x.offset+prefix,layout.x.width,layout.y.offset+prefix,layout.y.width))
89
+ for offset, value in ((13,layout.x.minimum),(17,layout.x.maximum),(21,layout.y.minimum),(25,layout.y.maximum)):
90
+ registration[offset:offset+4] = value.to_bytes(4,'little',signed=True)
91
+ registration[29:29+layout.length] = mask
92
+ registered = client.exchange(registration)
93
+ if registered.status == 6:
94
+ raise RuntimeError('MIX ещё не готов. Пошевелите мышь, отпустите кнопки и повторите запуск.')
95
+ if registered.status:
96
+ raise RuntimeError('Firmware rejected MIX registration: ' + str(registered.status))
97
+ ready = client.checked(frame(14, client.next_sequence()))
98
+ if ready[10] != generation or ready[12] != layout.length:
99
+ raise RuntimeError('Состояние MIX изменилось при инициализации; повторите запуск')
100
+ if not ready[13]:
101
+ raise RuntimeError('MIX ждёт состояние мыши. Пошевелите мышь один раз и повторите запуск. Движения ещё не отправлялись.')
102
+ template[2] = 16
103
+ return (template, endpoint, interface, layout.x.offset + prefix, layout.x.width,
104
+ layout.y.offset + prefix, layout.y.width,
105
+ max(layout.x.minimum, -2147483648), min(layout.x.maximum, 2147483647),
106
+ max(layout.y.minimum, -2147483648), min(layout.y.maximum, 2147483647))
@@ -0,0 +1,152 @@
1
+ """Native Windows HID Feature transport; Python standard library only."""
2
+ import ctypes
3
+ import ctypes.wintypes
4
+ import os
5
+
6
+
7
+ class GUID(ctypes.Structure):
8
+ _fields_ = [('a', ctypes.wintypes.DWORD), ('b', ctypes.wintypes.WORD), ('c', ctypes.wintypes.WORD), ('d', ctypes.c_ubyte * 8)]
9
+
10
+
11
+ class InterfaceData(ctypes.Structure):
12
+ _fields_ = [('size', ctypes.wintypes.DWORD), ('guid', GUID), ('flags', ctypes.wintypes.DWORD), ('reserved', ctypes.c_size_t)]
13
+
14
+
15
+ class WindowsHid:
16
+ __slots__ = ('hid', 'setup', 'kernel', 'handle', 'path', '_request', '_response')
17
+ def __init__(self, path=None, vid=None, pid=None):
18
+ if os.name != 'nt':
19
+ raise OSError('This transport requires Windows')
20
+ self.hid = ctypes.WinDLL('hid.dll', use_last_error=True)
21
+ self.setup = ctypes.WinDLL('setupapi.dll', use_last_error=True)
22
+ self.kernel = ctypes.WinDLL('kernel32.dll', use_last_error=True)
23
+ self.handle = None
24
+ self._request = (ctypes.c_ubyte * 64)()
25
+ self._response = (ctypes.c_ubyte * 64)()
26
+ self.kernel.CreateFileW.argtypes = [ctypes.wintypes.LPCWSTR, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p]
27
+ self.kernel.CreateFileW.restype = ctypes.wintypes.HANDLE
28
+ self.kernel.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
29
+ self.kernel.FreeLibrary.argtypes = [ctypes.wintypes.HMODULE]
30
+ self.kernel.FreeLibrary.restype = ctypes.wintypes.BOOL
31
+ self.hid.HidD_GetHidGuid.argtypes = [ctypes.POINTER(GUID)]
32
+ for name in ('HidD_SetFeature', 'HidD_GetFeature'):
33
+ fn = getattr(self.hid, name)
34
+ fn.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_void_p, ctypes.wintypes.ULONG]
35
+ fn.restype = ctypes.c_ubyte
36
+ self.hid.HidD_GetPreparsedData.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.c_void_p)]
37
+ self.hid.HidD_GetPreparsedData.restype = ctypes.c_ubyte
38
+ self.hid.HidD_FreePreparsedData.argtypes = [ctypes.c_void_p]
39
+ self.hid.HidP_GetCaps.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
40
+ self.hid.HidP_GetCaps.restype = ctypes.wintypes.LONG
41
+ self.setup.SetupDiGetClassDevsW.argtypes = [ctypes.POINTER(GUID), ctypes.wintypes.LPCWSTR, ctypes.wintypes.HWND, ctypes.wintypes.DWORD]
42
+ self.setup.SetupDiGetClassDevsW.restype = ctypes.wintypes.HANDLE
43
+ self.setup.SetupDiEnumDeviceInterfaces.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_void_p, ctypes.POINTER(GUID), ctypes.wintypes.DWORD, ctypes.POINTER(InterfaceData)]
44
+ self.setup.SetupDiGetDeviceInterfaceDetailW.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(InterfaceData), ctypes.c_void_p, ctypes.wintypes.DWORD, ctypes.POINTER(ctypes.wintypes.DWORD), ctypes.c_void_p]
45
+ self.setup.SetupDiDestroyDeviceInfoList.argtypes = [ctypes.wintypes.HANDLE]
46
+ try:
47
+ paths = self._paths(vid, pid)
48
+ if path is not None:
49
+ paths = [candidate for candidate in paths if candidate == path]
50
+ if len(paths) != 1:
51
+ raise RuntimeError(f'Expected one MouseBridge command HID; found {len(paths)}')
52
+ self.path = paths[0]
53
+ self.handle = self._open(self.path, 0xC0000000)
54
+ if self.handle == ctypes.c_void_p(-1).value:
55
+ self.handle = None
56
+ raise ctypes.WinError(ctypes.get_last_error())
57
+ except BaseException:
58
+ self.close()
59
+ raise
60
+
61
+ def _open(self, path, access=0):
62
+ return self.kernel.CreateFileW(path, access, 3, None, 3, 0, None)
63
+
64
+ def _paths(self, vid, pid):
65
+ guid = GUID()
66
+ self.hid.HidD_GetHidGuid(ctypes.byref(guid))
67
+ devices = self.setup.SetupDiGetClassDevsW(ctypes.byref(guid), None, None, 18)
68
+ if devices == ctypes.c_void_p(-1).value:
69
+ raise ctypes.WinError(ctypes.get_last_error())
70
+ result = []
71
+ try:
72
+ for index in range(1024):
73
+ entry = InterfaceData(); entry.size = ctypes.sizeof(entry)
74
+ if not self.setup.SetupDiEnumDeviceInterfaces(devices, None, ctypes.byref(guid), index, ctypes.byref(entry)):
75
+ if ctypes.get_last_error() != 259:
76
+ raise ctypes.WinError(ctypes.get_last_error())
77
+ break
78
+ needed = ctypes.wintypes.DWORD()
79
+ self.setup.SetupDiGetDeviceInterfaceDetailW(devices, ctypes.byref(entry), None, 0, ctypes.byref(needed), None)
80
+ if not 6 <= needed.value <= 65536:
81
+ raise RuntimeError('Invalid HID path length')
82
+ detail = ctypes.create_string_buffer(needed.value)
83
+ ctypes.cast(detail, ctypes.POINTER(ctypes.wintypes.DWORD))[0] = 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 6
84
+ if not self.setup.SetupDiGetDeviceInterfaceDetailW(devices, ctypes.byref(entry), detail, needed, ctypes.byref(needed), None):
85
+ continue
86
+ path = ctypes.wstring_at(ctypes.addressof(detail) + 4)
87
+ lower = path.lower()
88
+ if vid is not None and f'vid_{vid:04x}' not in lower:
89
+ continue
90
+ if pid is not None and f'pid_{pid:04x}' not in lower:
91
+ continue
92
+ handle = self._open(path)
93
+ if handle == ctypes.c_void_p(-1).value:
94
+ continue
95
+ prep = ctypes.c_void_p()
96
+ try:
97
+ if not self.hid.HidD_GetPreparsedData(handle, ctypes.byref(prep)):
98
+ continue
99
+ caps = ctypes.create_string_buffer(64)
100
+ if self.hid.HidP_GetCaps(prep, caps) == 0x110000:
101
+ words = ctypes.cast(caps, ctypes.POINTER(ctypes.wintypes.WORD))
102
+ if (words[0], words[1], words[4]) == (1, 0xFF31, 64):
103
+ result.append(path)
104
+ finally:
105
+ if prep.value:
106
+ self.hid.HidD_FreePreparsedData(prep)
107
+ self.kernel.CloseHandle(handle)
108
+ else:
109
+ raise RuntimeError('HID enumeration exceeded bounded interface count')
110
+ finally:
111
+ self.setup.SetupDiDestroyDeviceInfoList(devices)
112
+ return result
113
+
114
+ def send_feature_report(self, data):
115
+ if not self.handle:
116
+ raise OSError('HID device is closed')
117
+ data = bytes(data)
118
+ if len(data) != 64 or data[0] != 1:
119
+ raise ValueError('MouseBridge commands require a 64-byte report with ID 1')
120
+ ctypes.memmove(self._request, data, 64)
121
+ if not self.hid.HidD_SetFeature(self.handle, self._request, 64):
122
+ raise ctypes.WinError(ctypes.get_last_error())
123
+ return len(data)
124
+
125
+ def get_feature_report(self, report_id, length):
126
+ if not self.handle:
127
+ raise OSError('HID device is closed')
128
+ if report_id != 2 or length != 64:
129
+ raise ValueError('MouseBridge responses require a 64-byte report with ID 2')
130
+ ctypes.memset(self._response, 0, 64)
131
+ self._response[0] = report_id
132
+ if not self.hid.HidD_GetFeature(self.handle, self._response, 64):
133
+ raise ctypes.WinError(ctypes.get_last_error())
134
+ return bytes(self._response)
135
+
136
+ def detach_handle(self):
137
+ """Transfer handle ownership after the native context was created successfully."""
138
+ handle = self.handle
139
+ self.handle = None
140
+ return handle
141
+
142
+ def close(self):
143
+ if self.handle:
144
+ self.kernel.CloseHandle(self.handle)
145
+ self.handle = None
146
+ # Only this object's LoadLibrary references are released; native sender owns its own.
147
+ if self.kernel is not None and hasattr(self.kernel, 'FreeLibrary'):
148
+ release = self.kernel.FreeLibrary
149
+ for library in (self.hid, self.setup, self.kernel):
150
+ if library is not None:
151
+ release(library._handle)
152
+ self.hid = self.setup = self.kernel = None
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: mousebridge-mix
3
+ Version: 0.1.0
4
+ Summary: Windows HID movement client for MouseBridge MIX v2
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Operating System :: Microsoft :: Windows
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Human Interface Device (HID)
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # mousebridge-mix
16
+
17
+ Python client for MouseBridge MIX v2 firmware: initialize once, send mouse
18
+ movements through Windows HID. Windows x64 and 64-bit Python 3.10+ required.
19
+ The native sender DLL is included. No Zadig, WinUSB replacement, PyUSB,
20
+ libusb, or additional Python runtime dependencies are required.
21
+
22
+ ## Install
23
+
24
+ After publication on PyPI:
25
+
26
+ ```console
27
+ python -m pip install mousebridge-mix
28
+ ```
29
+
30
+ For a locally built wheel:
31
+
32
+ ```console
33
+ python -m pip install mousebridge_mix-0.1.0-py3-none-win_amd64.whl
34
+ ```
35
+
36
+ ## Use
37
+
38
+ Connect a Leonardo + USB Host Shield running **MIX v2** and its source mouse.
39
+
40
+ ```python
41
+ from mousebridge import init
42
+
43
+ with init() as mouse:
44
+ mouse.send_move(20, 0)
45
+ mouse.send_move(0, -20)
46
+ ```
47
+
48
+ Optional selection: `init(vid=0x373E, pid=0x0047)` or `init(path=hid_path)`.
49
+ Use your source device's VID/PID, not necessarily Arduino's. Generic discovery
50
+ works when exactly one supported command collection is present.
51
+
52
+ The familiar write interface remains available:
53
+
54
+ ```python
55
+ import struct
56
+ from mousebridge import find
57
+
58
+ with find(idVendor=0x373E, idProduct=0x0047) as mouse:
59
+ mouse.write(1, struct.pack('<hh', 20, 0), timeout=8)
60
+ ```
61
+
62
+ `write()` accepts endpoint `1` as a compatibility alias, or `mouse.endpoint`;
63
+ it translates a four-byte XY payload into the MIX HID output report. It is
64
+ not a PyUSB write to an arbitrary USB endpoint. `timeout` is accepted for
65
+ compatibility but does not set a native write timeout.
66
+
67
+ `send_move(x, y)` accepts signed int32 integers. `move()` is an alias.
68
+ Sending is one synchronous native Windows `WriteFile` call; there is no
69
+ Python retry loop, timer, queue, or sleep. The API returns no delivery status
70
+ and does not promise non-blocking USB I/O. Use one caller per device and
71
+ close it after use; the context manager does this automatically.
72
+
73
+ Initialization reads descriptors and registers the MIX layout once. If the
74
+ firmware reports no cached source state, move the physical mouse once,
75
+ release its buttons, and initialize again. Physical and software motion are
76
+ combined by MIX v2 firmware. This package does not update the firmware.
77
+ Importing the package neither opens HID devices nor moves the cursor.
78
+
79
+ ## Update
80
+
81
+ ```console
82
+ python -m pip install --upgrade mousebridge-mix
83
+ ```
84
+
85
+ Updates take effect in a new Python process. Version 0.1.x targets the MIX v2
86
+ `MX\x02` command capability; older MOTION and MIX v1 are rejected.
87
+
88
+ ## License
89
+
90
+ MIT. See LICENSE. Source for the bundled native DLL is in the source
91
+ archive under `native/mix_sender.cpp`.
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ RELEASE.md
5
+ pyproject.toml
6
+ setup.py
7
+ native/mix_sender.cpp
8
+ src/mousebridge/__init__.py
9
+ src/mousebridge/bridge_client.py
10
+ src/mousebridge/bridge_protocol.py
11
+ src/mousebridge/hid_mix.py
12
+ src/mousebridge/hid_motion.py
13
+ src/mousebridge/hid_report_layout.py
14
+ src/mousebridge/motion_initialize.py
15
+ src/mousebridge/mousebridge_mix.dll
16
+ src/mousebridge/windows_hid.py
17
+ src/mousebridge_mix.egg-info/PKG-INFO
18
+ src/mousebridge_mix.egg-info/SOURCES.txt
19
+ src/mousebridge_mix.egg-info/dependency_links.txt
20
+ src/mousebridge_mix.egg-info/top_level.txt
21
+ tests/fake_transport.py
22
+ tests/test_mix.py
23
+ tests/test_package.py
24
+ tests/fixtures/captured-report-91.bin
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+ FIXTURE = Path(__file__).parent / 'fixtures/captured-report-91.bin'
3
+
4
+ class FakeTransport:
5
+ def __init__(self, statuses=()):
6
+ self.closed = False; self.requests = []; self.statuses = list(statuses); self.handle = 123; self.detached = False
7
+ self.descriptors = [bytes.fromhex('09 02 3b 00 02 01 00 80 fa 09 04 00 00 01 03 01 02 00 09 21 11 01 00 01 22 5b 00 07 05 81 03 08 00 01 09 04 01 00 01 03 00 00 00 09 21 11 01 00 01 22 26 00 07 05 82 03 08 00 01'), b'', FIXTURE.read_bytes()]
8
+ def send_feature_report(self, request):
9
+ self.request = bytes(request); self.requests.append(self.request); return 64
10
+ def get_feature_report(self, rid, length):
11
+ r = self.request; out = bytearray(64); out[:6] = bytes([2, 1, 0, r[2], r[3], r[4]])
12
+ if r[2] == 4: out[42:46] = b'MBD\x01'
13
+ elif r[2] == 1: out[6:10] = bytes([1, 1, 1, 2]); out[27] = 1; out[28] = 0
14
+ elif r[2] == 3:
15
+ data = self.descriptors[r[5]]; offset = int.from_bytes(r[6:8], 'little'); part = data[offset:offset+55]
16
+ out[6:8] = len(data).to_bytes(2, 'little'); out[8] = len(part); out[9:9+len(part)] = part
17
+ elif r[2] == 14 and not r[5]: out[6:10] = b'MO\x01\x1b'
18
+ elif r[2] == 13: out[6:9] = b'VT\x01'
19
+ elif r[2] == 14 and self.statuses: out[2] = self.statuses.pop(0)
20
+ return out
21
+ def detach_handle(self): self.detached = True; self.handle = None
22
+ def close(self): self.closed = True
23
+
@@ -0,0 +1,75 @@
1
+ import struct
2
+ import unittest
3
+ from unittest.mock import patch,MagicMock
4
+ from fake_transport import FakeTransport
5
+ from mousebridge.motion_initialize import prepare_motion
6
+
7
+
8
+ class MixTransport(FakeTransport):
9
+ def __init__(self):
10
+ super().__init__()
11
+ config=bytearray(self.descriptors[0]);config[38]=2
12
+ config.extend(bytes((7,5,3,3,16,0,1)));config[2:4]=len(config).to_bytes(2,'little')
13
+ self.descriptors[0]=bytes(config)
14
+ def get_feature_report(self,rid,length):
15
+ result=super().get_feature_report(rid,length)
16
+ if self.request[2]==14:
17
+ result[6:9]=b'MX\x02';result[11]=3;result[12]=8;result[13]=1
18
+ return result
19
+
20
+
21
+ class MixInitTests(unittest.TestCase):
22
+ def test_registration_from_descriptor(self):
23
+ transport=MixTransport()
24
+ prepared=prepare_motion(transport,mix=True)
25
+ self.assertEqual(prepared[0][2],16)
26
+ registration=transport.requests[-2]
27
+ self.assertEqual(registration[2],15)
28
+ self.assertEqual(registration[9:13],bytes((16,16,32,16)))
29
+ self.assertEqual(struct.unpack_from('<iiii',registration,13),(-32768,32767,-32768,32767))
30
+ self.assertEqual(registration[29:37],b'\0'+b'\xff'*7)
31
+ def test_old_firmware_rejected(self):
32
+ with self.assertRaises(RuntimeError): prepare_motion(FakeTransport(),mix=True)
33
+ def test_no_baseline_rejected_before_native_or_output(self):
34
+ class NotReady(MixTransport):
35
+ def get_feature_report(self,rid,length):
36
+ result=super().get_feature_report(rid,length)
37
+ if self.request[2]==14:result[13]=0
38
+ return result
39
+ from mousebridge.hid_mix import MixDevice
40
+ transport=NotReady()
41
+ with patch('mousebridge.hid_mix.ctypes.WinDLL') as load:
42
+ with self.assertRaisesRegex(RuntimeError,'Пошевелите мышь'):MixDevice(transport)
43
+ load.assert_not_called()
44
+ self.assertTrue(transport.closed and not transport.detached)
45
+ def test_missing_out_rejected(self):
46
+ transport=MixTransport();transport.descriptors[0]=FakeTransport().descriptors[0]
47
+ with self.assertRaisesRegex(RuntimeError,'OUT endpoint'): prepare_motion(transport,mix=True)
48
+
49
+ def test_native_wrapper_one_call_and_lifetime(self):
50
+ from mousebridge.hid_mix import MixDevice
51
+ transport=MixTransport()
52
+ library=MagicMock()
53
+ library.MotionCreate.return_value=123
54
+ with patch('mousebridge.hid_mix.ctypes.WinDLL',return_value=library),patch('mousebridge.hid_mix.release_native') as unload:
55
+ device=MixDevice(transport)
56
+ self.assertTrue(transport.closed and transport.detached)
57
+ count=len(transport.requests)
58
+ device.send_move(-2147483648,2147483647)
59
+ library.MotionSendMove.assert_called_once_with(123,-2147483648,2147483647)
60
+ self.assertEqual(len(transport.requests),count)
61
+ with self.assertRaises(ValueError):device.send_move(2147483648,0)
62
+ device.close();device.close()
63
+ library.MotionClose.assert_called_once_with(123)
64
+ unload.assert_any_call(library)
65
+ with self.assertRaises(OSError):device.send_move(0,0)
66
+
67
+ def test_failed_native_create_retains_handle(self):
68
+ from mousebridge.hid_mix import MixDevice
69
+ transport=MixTransport();library=MagicMock();library.MotionCreate.return_value=0
70
+ with patch('mousebridge.hid_mix.ctypes.WinDLL',return_value=library),patch('mousebridge.hid_mix.release_native'):
71
+ with self.assertRaises(OSError):MixDevice(transport)
72
+ self.assertTrue(transport.closed and not transport.detached)
73
+
74
+
75
+ if __name__=='__main__': unittest.main()
@@ -0,0 +1,74 @@
1
+ import ctypes
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import struct
6
+ import sys
7
+ import unittest
8
+ from unittest.mock import patch, MagicMock
9
+ import mousebridge
10
+ from mousebridge import MixDevice
11
+ from test_mix import MixTransport
12
+
13
+ DLL_SHA = '64FADF3F3180FCEAE86F0F76B4BD4E8D99445C607B5C1150472C24D4B3CC3B9E'
14
+
15
+ class PackageTests(unittest.TestCase):
16
+ def test_public_api_and_namespaced_imports(self):
17
+ self.assertEqual(mousebridge.__version__, '0.1.0')
18
+ self.assertEqual(mousebridge.init.__module__, 'mousebridge.hid_mix')
19
+ self.assertNotIn('bridge_protocol', sys.modules)
20
+ self.assertNotIn('windows_hid', sys.modules)
21
+
22
+ def test_dll_hash_and_architecture(self):
23
+ data = Path(mousebridge.__file__).with_name('mousebridge_mix.dll').read_bytes()
24
+ self.assertEqual(hashlib.sha256(data).hexdigest().upper(), DLL_SHA)
25
+ offset = struct.unpack_from('<I', data, 0x3c)[0]
26
+ self.assertEqual(data[:2], b'MZ')
27
+ self.assertEqual(data[offset:offset+4], b'PE\0\0')
28
+ self.assertEqual(struct.unpack_from('<H', data, offset+4)[0], 0x8664)
29
+ self.assertEqual(struct.unpack_from('<H', data, offset+24)[0], 0x20b)
30
+
31
+ @unittest.skipUnless(os.name == 'nt' and ctypes.sizeof(ctypes.c_void_p) == 8, 'Windows x64 native check')
32
+ def test_native_load_exports_without_device(self):
33
+ path = Path(mousebridge.__file__).with_name('mousebridge_mix.dll')
34
+ library = ctypes.WinDLL(str(path), use_last_error=True)
35
+ try:
36
+ create = library.MotionCreate
37
+ create.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint]
38
+ create.restype = ctypes.c_void_p
39
+ self.assertFalse(create(None, None, 0, 0, 0, 0))
40
+ self.assertEqual(ctypes.get_last_error(), 87)
41
+ self.assertTrue(library.MotionSendMove and library.MotionClose)
42
+ finally:
43
+ from mousebridge.hid_motion import release_native
44
+ release_native(library)
45
+
46
+ def test_missing_dll_closes_transport(self):
47
+ transport = MixTransport()
48
+ with patch('mousebridge.hid_mix.Path.is_file', return_value=False), patch('mousebridge.hid_mix.ctypes.WinDLL') as load:
49
+ with self.assertRaisesRegex(OSError, 'reinstall'): MixDevice(transport)
50
+ load.assert_not_called(); self.assertTrue(transport.closed)
51
+
52
+ def test_wrong_python_bitness_never_loads_native(self):
53
+ transport = MixTransport()
54
+ with patch('mousebridge.hid_mix.ctypes.sizeof', return_value=4), patch('mousebridge.hid_mix.ctypes.WinDLL') as load:
55
+ with self.assertRaisesRegex(OSError, '64-bit'): MixDevice(transport)
56
+ load.assert_not_called(); self.assertTrue(transport.closed)
57
+
58
+ def test_write_shape_int32_bounds_and_single_native_call(self):
59
+ transport = MixTransport(); library = MagicMock(); library.MotionCreate.return_value = 123
60
+ with patch('mousebridge.hid_mix.ctypes.WinDLL', return_value=library), patch('mousebridge.hid_mix.release_native'):
61
+ with MixDevice(transport) as device:
62
+ count = len(transport.requests)
63
+ device.write(1, struct.pack('<hh', 20, -20), timeout=8)
64
+ library.MotionSendMove.assert_called_once_with(123, 20, -20)
65
+ self.assertEqual(len(transport.requests), count)
66
+ for xy in [(True,0), (0,1.0)]:
67
+ with self.assertRaises(TypeError): device.send_move(*xy)
68
+ for xy in [(-2147483649,0), (0,2147483648)]:
69
+ with self.assertRaises(ValueError): device.send_move(*xy)
70
+ with self.assertRaises(ValueError): device.write(99, b'1234')
71
+ with self.assertRaises(struct.error): device.write(1, b'123')
72
+ library.MotionClose.assert_called_once_with(123)
73
+
74
+ if __name__ == '__main__': unittest.main()