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.
- mousebridge_mix-0.1.0/LICENSE +21 -0
- mousebridge_mix-0.1.0/MANIFEST.in +4 -0
- mousebridge_mix-0.1.0/PKG-INFO +91 -0
- mousebridge_mix-0.1.0/README.md +77 -0
- mousebridge_mix-0.1.0/RELEASE.md +48 -0
- mousebridge_mix-0.1.0/native/mix_sender.cpp +20 -0
- mousebridge_mix-0.1.0/pyproject.toml +28 -0
- mousebridge_mix-0.1.0/setup.cfg +4 -0
- mousebridge_mix-0.1.0/setup.py +16 -0
- mousebridge_mix-0.1.0/src/mousebridge/__init__.py +5 -0
- mousebridge_mix-0.1.0/src/mousebridge/bridge_client.py +68 -0
- mousebridge_mix-0.1.0/src/mousebridge/bridge_protocol.py +49 -0
- mousebridge_mix-0.1.0/src/mousebridge/hid_mix.py +70 -0
- mousebridge_mix-0.1.0/src/mousebridge/hid_motion.py +11 -0
- mousebridge_mix-0.1.0/src/mousebridge/hid_report_layout.py +139 -0
- mousebridge_mix-0.1.0/src/mousebridge/motion_initialize.py +106 -0
- mousebridge_mix-0.1.0/src/mousebridge/mousebridge_mix.dll +0 -0
- mousebridge_mix-0.1.0/src/mousebridge/windows_hid.py +152 -0
- mousebridge_mix-0.1.0/src/mousebridge_mix.egg-info/PKG-INFO +91 -0
- mousebridge_mix-0.1.0/src/mousebridge_mix.egg-info/SOURCES.txt +24 -0
- mousebridge_mix-0.1.0/src/mousebridge_mix.egg-info/dependency_links.txt +1 -0
- mousebridge_mix-0.1.0/src/mousebridge_mix.egg-info/top_level.txt +1 -0
- mousebridge_mix-0.1.0/tests/fake_transport.py +23 -0
- mousebridge_mix-0.1.0/tests/fixtures/captured-report-91.bin +0 -0
- mousebridge_mix-0.1.0/tests/test_mix.py +75 -0
- mousebridge_mix-0.1.0/tests/test_package.py +74 -0
|
@@ -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,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,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,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))
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mousebridge
|
|
@@ -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
|
+
|
|
Binary file
|
|
@@ -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()
|