AnalogSensePy 1.0.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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: AnalogSensePy
3
+ Version: 1.0.0
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: hid
7
+ Dynamic: license-file
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ AnalogSensePy.egg-info/PKG-INFO
5
+ AnalogSensePy.egg-info/SOURCES.txt
6
+ AnalogSensePy.egg-info/dependency_links.txt
7
+ AnalogSensePy.egg-info/requires.txt
8
+ AnalogSensePy.egg-info/top_level.txt
9
+ analogsense/__init__.py
10
+ analogsense/analogsense.py
11
+ analogsense/keymaps.py
12
+ analogsense/layouts.py
13
+ analogsense/providers.py
14
+ analogsense/setup.py
@@ -0,0 +1 @@
1
+ analogsense
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Calamity, Inc.
4
+ Copyright (c) 2025 Deana Brcka
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: AnalogSensePy
3
+ Version: 1.0.0
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: hid
7
+ Dynamic: license-file
@@ -0,0 +1,80 @@
1
+ # AnalogSense Python SDK
2
+ Python port of [AnalogSense.js](https://github.com/AnalogSense/JavaScript-SDK/) for analog keyboard input.
3
+ ## Supported Keyboards/Devices
4
+ - Everything by Wooting
5
+ - Everything by NuPhy
6
+ - Everything by DrunkDeer
7
+ - Razer Huntsman V2 Analog<sup>R</sup>
8
+ - Razer Huntsman Mini Analog<sup>R</sup>
9
+ - Razer Huntsman V3 Pro<sup>R</sup>
10
+ - Razer Huntsman V3 Pro Mini<sup>R</sup>
11
+ - Razer Huntsman V3 Pro Tenkeyless<sup>R</sup>
12
+ - Keychron Q1 HE<sup>P, F</sup>
13
+ - Keychron Q3 HE<sup>P, F</sup>
14
+ - Keychron Q5 HE<sup>P, F</sup>
15
+ - Keychron K2 HE<sup>P, F</sup>
16
+ - Lemokey P1 HE<sup>P, F</sup>
17
+ - Madlions MAD60HE<sup>P</sup>
18
+ - Madlions MAD68HE<sup>P</sup>
19
+ - Madlions MAD68R<sup>P</sup>
20
+ - Redragon K709HE<sup>P</sup>
21
+
22
+ <sup>R</sup> Razer Synapse needs to be installed and running for analogue inputs to be received from this keyboard.
23
+ <sup>P</sup> The official firmware only supports polling, which can lead to lag and missed inputs.
24
+ <sup>F</sup> [Custom firmware with full analog report functionality is available](https://analogsense.org/firmware/).
25
+
26
+ ## Installation
27
+ ```bash
28
+ pip install hid
29
+ pip install
30
+ ```
31
+ On Linux you may need udev rules or `sudo` for hid
32
+
33
+ ## Usage
34
+ ```python
35
+ from analogsense import AnalogSense
36
+
37
+ as_ = AnalogSense()
38
+ devices = as_.get_devices()
39
+ dev = devices[0]
40
+
41
+ def on_keys(active_keys):
42
+ for k in active_keys:
43
+ print(as_.scancode_to_string(k["scancode"]), f"{k['value']:.2f}")
44
+
45
+ dev.start_listening(on_keys)
46
+ input("enter to stop...\n")
47
+ dev.stop_listening()
48
+ ```
49
+
50
+ The following functions are available on `AnalogSense`:
51
+ - `get_devices() -> list[Device]`
52
+ - `open_device(vendor_id, product_id) -> Device | None`
53
+ - `scancode_to_string(scancode: int) -> str`
54
+
55
+ A device instance has the following members:
56
+ - `start_listening(handler: Callable[[list[{"scancode": int, "value": float}]], None])`
57
+ - `stop_listening()`
58
+ - `product_name: str`
59
+ - `forget()`
60
+ - `dev: DeviceHandle`
61
+
62
+ ### Scancodes
63
+ The scancodes provided by this library are primarily HID scancodes; most keys are mapped as seen on usage page 0x07 (A = 0x04, B = 0x05, ...).
64
+
65
+ Control keys (usage page 0x0C) are mapped in the `0x3__` range, modulo 0x100:
66
+ - `0x3B5` = Next Track
67
+ - `0x3B6` = Previous Track
68
+ - `0x3B7` = Stop Media
69
+ - `0x3CD` = Play/Pause
70
+ - `0x394` = Open File Explorer
71
+ - `0x323` = Open Browser Home Page
72
+
73
+ OEM-specific keys are mapped in the `0x4__` range:
74
+ - `0x401` = Brightness Up
75
+ - `0x402` = Brightness Down
76
+ - `0x403` = Profile 1
77
+ - `0x404` = Profile 2
78
+ - `0x405` = Profile 3
79
+ - `0x408` = Profile Switch
80
+ - `0x409` = Function Key (Fn)
@@ -0,0 +1,4 @@
1
+ from .keymaps import *
2
+ from .layouts import *
3
+ from .providers import *
4
+ from .analogsense import AnalogSense
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+ import warnings
3
+
4
+ from .providers import ALL_PROVIDERS, AsProvider
5
+ from .keymaps import wooting_to_name, razer_to_wooting, nuphy_to_wooting, bytech_to_wooting
6
+ from .layouts import drunkdeer_index_to_hid_scancode
7
+
8
+ try:
9
+ import hid as _hid
10
+ _HID_AVAILABLE = True
11
+ except ImportError:
12
+ _HID_AVAILABLE = False
13
+ warnings.warn("'hid' is not installed. you can install it with: pip install hid")
14
+
15
+
16
+ class DeviceHandle:
17
+ def __init__(self, hid_dev, info: dict):
18
+ self._dev = hid_dev
19
+ self.vendor_id = info["vendor_id"]
20
+ self.product_id = info["product_id"]
21
+ self.product_string = info.get("product_string") or f"{info['vendor_id']:#06x}:{info['product_id']:#06x}"
22
+
23
+ def __getattr__(self, name):
24
+ return getattr(self._dev, name)
25
+
26
+ def read(self, size, timeout_ms=100):
27
+ return self._dev.read(size, timeout_ms)
28
+
29
+ def write(self, data):
30
+ return self._dev.write(data)
31
+
32
+ def close(self):
33
+ return self._dev.close()
34
+
35
+
36
+ class AnalogSense:
37
+ def __init__(self, extra_providers=None):
38
+ self.providers = list(ALL_PROVIDERS)
39
+ if extra_providers:
40
+ self.providers.extend(extra_providers)
41
+
42
+ def _collect_candidates(self):
43
+ candidates = []
44
+ seen = set()
45
+ for provider_cls in self.providers:
46
+ for f in provider_cls.FILTERS:
47
+ kwargs = {}
48
+ if "vendor_id" in f: kwargs["vendor_id"] = f["vendor_id"]
49
+ if "product_id" in f: kwargs["product_id"] = f["product_id"]
50
+ try:
51
+ for info in _hid.enumerate(**kwargs):
52
+ dedup_key = (info["vendor_id"], info["product_id"], info.get("usage_page", 0), info.get("usage", 0))
53
+ if dedup_key in seen: continue
54
+ if "usage_page" in f and info.get("usage_page") != f["usage_page"]: continue
55
+ seen.add(dedup_key)
56
+ candidates.append((info, provider_cls))
57
+ except Exception as e:
58
+ warnings.warn(f"Error enumerating HID devices: {e}")
59
+ return candidates
60
+
61
+ def _open(self, info, provider_cls):
62
+ dev = _hid.device()
63
+ dev.open_path(info["path"])
64
+ return provider_cls(DeviceHandle(dev, info))
65
+
66
+ def get_devices(self):
67
+ if not _HID_AVAILABLE: return []
68
+ return [self._open(info, cls) for info, cls in self._collect_candidates()]
69
+
70
+ def open_device(self, vendor_id, product_id):
71
+ if not _HID_AVAILABLE: return None
72
+ for info, provider_cls in self._collect_candidates():
73
+ if info["vendor_id"] == vendor_id and info["product_id"] == product_id:
74
+ return self._open(info, provider_cls)
75
+ return None
76
+
77
+ def scancode_to_string(self, scancode):
78
+ return wooting_to_name.get(scancode, str(scancode))
79
+
80
+ @staticmethod
81
+ def razer_scancode_to_hid(scancode):
82
+ result = razer_to_wooting.get(scancode, 0)
83
+ if result == 0: warnings.warn(f"Failed to map Razer scancode to HID: {scancode:#x}")
84
+ return result
85
+
86
+ @staticmethod
87
+ def nuphy_scancode_to_hid(scancode):
88
+ result = nuphy_to_wooting.get(scancode, 0)
89
+ if result == 0: warnings.warn(f"Failed to map NuPhy scancode to HID: {scancode:#x}")
90
+ return result
91
+
92
+ @staticmethod
93
+ def bytech_scancode_to_hid(scancode):
94
+ result = bytech_to_wooting.get(scancode, 0)
95
+ if result == 0: warnings.warn(f"Failed to map Bytech scancode to HID: {scancode:#x}")
96
+ return result
97
+
98
+ @staticmethod
99
+ def drunkdeer_index_to_hid(index):
100
+ return drunkdeer_index_to_hid_scancode(index)
@@ -0,0 +1,261 @@
1
+ KEYS = [
2
+ {"name": "Escape", "wooting": 0x29, "razer": 0x6E, "bytech": 1},
3
+ {"name": "F1", "wooting": 0x3A, "razer": 0x70, "bytech": 2},
4
+ {"name": "F2", "wooting": 0x3B, "razer": 0x71, "bytech": 3},
5
+ {"name": "F3", "wooting": 0x3C, "razer": 0x72, "bytech": 4},
6
+ {"name": "F4", "wooting": 0x3D, "razer": 0x73, "bytech": 5},
7
+ {"name": "F5", "wooting": 0x3E, "razer": 0x74, "bytech": 6},
8
+ {"name": "F6", "wooting": 0x3F, "razer": 0x75, "bytech": 7},
9
+ {"name": "F7", "wooting": 0x40, "razer": 0x76, "bytech": 8},
10
+ {"name": "F8", "wooting": 0x41, "razer": 0x77, "bytech": 9},
11
+ {"name": "F9", "wooting": 0x42, "razer": 0x78, "bytech": 10},
12
+ {"name": "F10", "wooting": 0x43, "razer": 0x79, "bytech": 11},
13
+ {"name": "F11", "wooting": 0x44, "razer": 0x7A, "bytech": 12},
14
+ {"name": "F12", "wooting": 0x45, "razer": 0x7B, "bytech": 13},
15
+ {"name": "`", "wooting": 0x35, "razer": 0x01, "bytech": 14},
16
+ {"name": "1", "wooting": 0x1E, "razer": 0x02, "bytech": 15},
17
+ {"name": "2", "wooting": 0x1F, "razer": 0x03, "bytech": 16},
18
+ {"name": "3", "wooting": 0x20, "razer": 0x04, "bytech": 17},
19
+ {"name": "4", "wooting": 0x21, "razer": 0x05, "bytech": 18},
20
+ {"name": "5", "wooting": 0x22, "razer": 0x06, "bytech": 19},
21
+ {"name": "6", "wooting": 0x23, "razer": 0x07, "bytech": 20},
22
+ {"name": "7", "wooting": 0x24, "razer": 0x08, "bytech": 21},
23
+ {"name": "8", "wooting": 0x25, "razer": 0x09, "bytech": 22},
24
+ {"name": "9", "wooting": 0x26, "razer": 0x0A, "bytech": 23},
25
+ {"name": "0", "wooting": 0x27, "razer": 0x0B, "bytech": 24},
26
+ {"name": "-", "wooting": 0x2D, "razer": 0x0C, "bytech": 25},
27
+ {"name": "=", "wooting": 0x2E, "razer": 0x0D, "bytech": 26},
28
+ {"name": "Backspace", "wooting": 0x2A, "razer": 0x0F, "bytech": 27},
29
+ {"name": "Tab", "wooting": 0x2B, "razer": 0x10, "bytech": 28},
30
+ {"name": "Q", "wooting": 0x14, "razer": 0x11, "bytech": 29},
31
+ {"name": "W", "wooting": 0x1A, "razer": 0x12, "bytech": 30},
32
+ {"name": "E", "wooting": 0x08, "razer": 0x13, "bytech": 31},
33
+ {"name": "R", "wooting": 0x15, "razer": 0x14, "bytech": 32},
34
+ {"name": "T", "wooting": 0x17, "razer": 0x15, "bytech": 33},
35
+ {"name": "Y", "wooting": 0x1C, "razer": 0x16, "bytech": 34},
36
+ {"name": "U", "wooting": 0x18, "razer": 0x17, "bytech": 35},
37
+ {"name": "I", "wooting": 0x0C, "razer": 0x18, "bytech": 36},
38
+ {"name": "O", "wooting": 0x12, "razer": 0x19, "bytech": 37},
39
+ {"name": "P", "wooting": 0x13, "razer": 0x1A, "bytech": 38},
40
+ {"name": "[", "wooting": 0x2F, "razer": 0x1B, "bytech": 39},
41
+ {"name": "]", "wooting": 0x30, "razer": 0x1C, "bytech": 40},
42
+ {"name": "Enter", "wooting": 0x28, "razer": 0x2B, "bytech": 54},
43
+ {"name": "Caps Lock", "wooting": 0x39, "razer": 0x1E, "bytech": 42},
44
+ {"name": "A", "wooting": 0x04, "razer": 0x1F, "bytech": 43},
45
+ {"name": "S", "wooting": 0x16, "razer": 0x20, "bytech": 44},
46
+ {"name": "D", "wooting": 0x07, "razer": 0x21, "bytech": 45},
47
+ {"name": "F", "wooting": 0x09, "razer": 0x22, "bytech": 46},
48
+ {"name": "G", "wooting": 0x0A, "razer": 0x23, "bytech": 47},
49
+ {"name": "H", "wooting": 0x0B, "razer": 0x24, "bytech": 48},
50
+ {"name": "J", "wooting": 0x0D, "razer": 0x25, "bytech": 49},
51
+ {"name": "K", "wooting": 0x0E, "razer": 0x26, "bytech": 50},
52
+ {"name": "L", "wooting": 0x0F, "razer": 0x27, "bytech": 51},
53
+ {"name": ";", "wooting": 0x33, "razer": 0x28, "bytech": 52},
54
+ {"name": "'", "wooting": 0x34, "razer": 0x29, "bytech": 53},
55
+ {"name": "Backslash", "wooting": 0x31, "razer": 0x2A, "bytech": 41},
56
+ {"name": "Left Shift", "wooting": 0xE1, "razer": 0x2C, "nuphy": 0x200, "bytech": 55},
57
+ {"name": "Intl Backslash", "wooting": 0x64, "razer": 0x2D},
58
+ {"name": "Z", "wooting": 0x1D, "razer": 0x2E, "bytech": 56},
59
+ {"name": "X", "wooting": 0x1B, "razer": 0x2F, "bytech": 57},
60
+ {"name": "C", "wooting": 0x06, "razer": 0x30, "bytech": 58},
61
+ {"name": "V", "wooting": 0x19, "razer": 0x31, "bytech": 59},
62
+ {"name": "B", "wooting": 0x05, "razer": 0x32, "bytech": 60},
63
+ {"name": "N", "wooting": 0x11, "razer": 0x33, "bytech": 61},
64
+ {"name": "M", "wooting": 0x10, "razer": 0x34, "bytech": 62},
65
+ {"name": ",", "wooting": 0x36, "razer": 0x35, "bytech": 63},
66
+ {"name": ".", "wooting": 0x37, "razer": 0x36, "bytech": 64},
67
+ {"name": "/", "wooting": 0x38, "razer": 0x37, "bytech": 65},
68
+ {"name": "Right Shift", "wooting": 0xE5, "razer": 0x39, "nuphy": 0x2000, "bytech": 66},
69
+ {"name": "Left Ctrl", "wooting": 0xE0, "razer": 0x3A, "nuphy": 0x100, "bytech": 67},
70
+ {"name": "Left Meta", "wooting": 0xE3, "razer": 0x7F, "nuphy": 0x800, "bytech": 68},
71
+ {"name": "Left Alt", "wooting": 0xE2, "razer": 0x3C, "nuphy": 0x400, "bytech": 69},
72
+ {"name": "Space", "wooting": 0x2C, "razer": 0x3D, "bytech": 70},
73
+ {"name": "Right Alt", "wooting": 0xE6, "razer": 0x3E, "nuphy": 0x4000, "bytech": 71},
74
+ {"name": "Right Meta", "wooting": 0xE7, "nuphy": 0x8000},
75
+ {"name": "Fn", "wooting": 0x409, "razer": 0x3B, "nuphy": 0xFF05, "bytech": 72},
76
+ {"name": "Context Menu", "wooting": 0x65, "razer": 0x81},
77
+ {"name": "Right Ctrl", "wooting": 0xE4, "razer": 0x40, "nuphy": 0x1000, "bytech": 73},
78
+ {"name": "Print Screen", "wooting": 0x46, "razer": 0x7C},
79
+ {"name": "Pause", "wooting": 0x48, "razer": 0x7D},
80
+ {"name": "Scroll Lock", "wooting": 0x47, "razer": 0x7E},
81
+ {"name": "Insert", "wooting": 0x49, "razer": 0x4B},
82
+ {"name": "Home", "wooting": 0x4A, "razer": 0x50, "bytech": 100},
83
+ {"name": "Page Up", "wooting": 0x4B, "razer": 0x55, "bytech": 102},
84
+ {"name": "Delete", "wooting": 0x4C, "razer": 0x4C, "bytech": 99},
85
+ {"name": "End", "wooting": 0x4D, "razer": 0x51},
86
+ {"name": "Page Down", "wooting": 0x4E, "razer": 0x56, "bytech": 103},
87
+ {"name": "Up Arrow", "wooting": 0x52, "razer": 0x53, "bytech": 74},
88
+ {"name": "Left Arrow", "wooting": 0x50, "razer": 0x4F, "bytech": 76},
89
+ {"name": "Down Arrow", "wooting": 0x51, "razer": 0x54, "bytech": 75},
90
+ {"name": "Right Arrow", "wooting": 0x4F, "razer": 0x59, "bytech": 77},
91
+ {"name": "Num Lock", "wooting": 0x53, "razer": 0x5A},
92
+ {"name": "Numpad /", "wooting": 0x54, "razer": 0x5F},
93
+ {"name": "Numpad *", "wooting": 0x55, "razer": 0x64},
94
+ {"name": "Numpad -", "wooting": 0x56, "razer": 0x69},
95
+ {"name": "Numpad 7", "wooting": 0x5F, "razer": 0x5B},
96
+ {"name": "Numpad 8", "wooting": 0x60, "razer": 0x60},
97
+ {"name": "Numpad 9", "wooting": 0x61, "razer": 0x65},
98
+ {"name": "Numpad +", "wooting": 0x57, "razer": 0x6A},
99
+ {"name": "Numpad 4", "wooting": 0x5C, "razer": 0x5C},
100
+ {"name": "Numpad 5", "wooting": 0x5D, "razer": 0x61},
101
+ {"name": "Numpad 6", "wooting": 0x5E, "razer": 0x66},
102
+ {"name": "Numpad 1", "wooting": 0x59, "razer": 0x5D},
103
+ {"name": "Numpad 2", "wooting": 0x5A, "razer": 0x62},
104
+ {"name": "Numpad 3", "wooting": 0x5B, "razer": 0x67},
105
+ {"name": "Numpad Enter", "wooting": 0x58, "razer": 0x6C},
106
+ {"name": "Numpad 0", "wooting": 0x62, "razer": 0x63},
107
+ {"name": "Numpad .", "wooting": 0x63, "razer": 0x68},
108
+ ]
109
+
110
+ wooting_to_name = {k["wooting"]: k["name"] for k in KEYS}
111
+ razer_to_wooting = {k["razer"]: k["wooting"] for k in KEYS if "razer" in k}
112
+ nuphy_to_wooting = {k.get("nuphy", k["wooting"]): k["wooting"] for k in KEYS}
113
+ bytech_to_wooting = {k["bytech"]: k["wooting"] for k in KEYS if "bytech" in k}
114
+
115
+ KEY_NONE = 0x00
116
+ KEY_A = 0x04
117
+ KEY_B = 0x05
118
+ KEY_C = 0x06
119
+ KEY_D = 0x07
120
+ KEY_E = 0x08
121
+ KEY_F = 0x09
122
+ KEY_G = 0x0A
123
+ KEY_H = 0x0B
124
+ KEY_I = 0x0C
125
+ KEY_J = 0x0D
126
+ KEY_K = 0x0E
127
+ KEY_L = 0x0F
128
+ KEY_M = 0x10
129
+ KEY_N = 0x11
130
+ KEY_O = 0x12
131
+ KEY_P = 0x13
132
+ KEY_Q = 0x14
133
+ KEY_R = 0x15
134
+ KEY_S = 0x16
135
+ KEY_T = 0x17
136
+ KEY_U = 0x18
137
+ KEY_V = 0x19
138
+ KEY_W = 0x1A
139
+ KEY_X = 0x1B
140
+ KEY_Y = 0x1C
141
+ KEY_Z = 0x1D
142
+ KEY_1 = 0x1E
143
+ KEY_2 = 0x1F
144
+ KEY_3 = 0x20
145
+ KEY_4 = 0x21
146
+ KEY_5 = 0x22
147
+ KEY_6 = 0x23
148
+ KEY_7 = 0x24
149
+ KEY_8 = 0x25
150
+ KEY_9 = 0x26
151
+ KEY_0 = 0x27
152
+ KEY_ENTER = 0x28
153
+ KEY_ESCAPE = 0x29
154
+ KEY_BACKSPACE = 0x2A
155
+ KEY_TAB = 0x2B
156
+ KEY_SPACE = 0x2C
157
+ KEY_MINUS = 0x2D
158
+ KEY_EQUALS = 0x2E
159
+ KEY_BRACKET_LEFT = 0x2F
160
+ KEY_BRACKET_RIGHT = 0x30
161
+ KEY_BACKSLASH = 0x31
162
+ KEY_INTL_HASH = 0x32
163
+ KEY_SEMICOLON = 0x33
164
+ KEY_QUOTE = 0x34
165
+ KEY_BACKQUOTE = 0x35
166
+ KEY_COMMA = 0x36
167
+ KEY_PERIOD = 0x37
168
+ KEY_SLASH = 0x38
169
+ KEY_CAPS_LOCK = 0x39
170
+ KEY_F1 = 0x3A
171
+ KEY_F2 = 0x3B
172
+ KEY_F3 = 0x3C
173
+ KEY_F4 = 0x3D
174
+ KEY_F5 = 0x3E
175
+ KEY_F6 = 0x3F
176
+ KEY_F7 = 0x40
177
+ KEY_F8 = 0x41
178
+ KEY_F9 = 0x42
179
+ KEY_F10 = 0x43
180
+ KEY_F11 = 0x44
181
+ KEY_F12 = 0x45
182
+ KEY_PRINT_SCREEN = 0x46
183
+ KEY_SCROLL_LOCK = 0x47
184
+ KEY_PAUSE = 0x48
185
+ KEY_INSERT = 0x49
186
+ KEY_HOME = 0x4A
187
+ KEY_PAGE_UP = 0x4B
188
+ KEY_DEL = 0x4C
189
+ KEY_END = 0x4D
190
+ KEY_PAGE_DOWN = 0x4E
191
+ KEY_ARROW_RIGHT = 0x4F
192
+ KEY_ARROW_LEFT = 0x50
193
+ KEY_ARROW_DOWN = 0x51
194
+ KEY_ARROW_UP = 0x52
195
+ KEY_NUM_LOCK = 0x53
196
+ KEY_NUMPAD_DIVIDE = 0x54
197
+ KEY_NUMPAD_MULTIPLY = 0x55
198
+ KEY_NUMPAD_SUBTRACT = 0x56
199
+ KEY_NUMPAD_ADD = 0x57
200
+ KEY_NUMPAD_ENTER = 0x58
201
+ KEY_NUMPAD1 = 0x59
202
+ KEY_NUMPAD2 = 0x5A
203
+ KEY_NUMPAD3 = 0x5B
204
+ KEY_NUMPAD4 = 0x5C
205
+ KEY_NUMPAD5 = 0x5D
206
+ KEY_NUMPAD6 = 0x5E
207
+ KEY_NUMPAD7 = 0x5F
208
+ KEY_NUMPAD8 = 0x60
209
+ KEY_NUMPAD9 = 0x61
210
+ KEY_NUMPAD0 = 0x62
211
+ KEY_NUMPAD_DECIMAL = 0x63
212
+ KEY_INTL_BACKSLASH = 0x64
213
+ KEY_CTX = 0x65
214
+ KEY_POWER = 0x66
215
+ KEY_NUMPAD_EQUAL = 0x67
216
+ KEY_F13 = 0x68
217
+ KEY_F14 = 0x69
218
+ KEY_F15 = 0x6A
219
+ KEY_F16 = 0x6B
220
+ KEY_F17 = 0x6C
221
+ KEY_F18 = 0x6D
222
+ KEY_F19 = 0x6E
223
+ KEY_F20 = 0x6F
224
+ KEY_F21 = 0x70
225
+ KEY_F22 = 0x71
226
+ KEY_F23 = 0x72
227
+ KEY_F24 = 0x73
228
+ KEY_OPEN = 0x74
229
+ KEY_HELP = 0x75
230
+ KEY_SELECT = 0x77
231
+ KEY_AGAIN = 0x79
232
+ KEY_UNDO = 0x7A
233
+ KEY_CUT = 0x7B
234
+ KEY_COPY = 0x7C
235
+ KEY_PASTE = 0x7D
236
+ KEY_FIND = 0x7E
237
+ KEY_VOLUME_MUTE = 0x7F
238
+ KEY_VOLUME_UP = 0x80
239
+ KEY_VOLUME_DOWN = 0x81
240
+ KEY_NUMPAD_COMMA = 0x85
241
+ KEY_INTL_RO = 0x87
242
+ KEY_KANA_MODE = 0x88
243
+ KEY_INTL_YEN = 0x89
244
+ KEY_CONVERT = 0x8A
245
+ KEY_NON_CONVERT = 0x8B
246
+ KEY_LANG1 = 0x90
247
+ KEY_LANG2 = 0x91
248
+ KEY_LANG3 = 0x92
249
+ KEY_LANG4 = 0x93
250
+ KEY_LCTRL = 0xE0
251
+ KEY_LSHIFT = 0xE1
252
+ KEY_LALT = 0xE2
253
+ KEY_LMETA = 0xE3
254
+ KEY_RCTRL = 0xE4
255
+ KEY_RSHIFT = 0xE5
256
+ KEY_RALT = 0xE6
257
+ KEY_RMETA = 0xE7
258
+ KEY_OEM_1 = 0x403
259
+ KEY_OEM_2 = 0x404
260
+ KEY_OEM_3 = 0x405
261
+ KEY_FN = 0x409
@@ -0,0 +1,104 @@
1
+ from .keymaps import *
2
+
3
+
4
+ def layout_rows(layout): return layout[0]
5
+ def layout_cols(layout): return layout[1]
6
+ def layout_size(layout): return layout[0] * layout[1]
7
+ def layout_key(layout, i): return layout[2 + i]
8
+ def layout_index_to_row(layout, i): return i // layout_cols(layout)
9
+ def layout_index_to_col(layout, i): return i % layout_cols(layout)
10
+
11
+
12
+ LAYOUT_KEYCHRON_Q1_HE = (6, 15,
13
+ KEY_ESCAPE, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_DEL, KEY_NONE,
14
+ KEY_BACKQUOTE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_PAGE_UP,
15
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_PAGE_DOWN,
16
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_ENTER, KEY_HOME, KEY_NONE,
17
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_NONE, KEY_SLASH, KEY_RSHIFT, KEY_ARROW_UP,
18
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RMETA, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT,
19
+ )
20
+
21
+ LAYOUT_KEYCHRON_Q3_HE = (6, 16,
22
+ KEY_ESCAPE, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_PRINT_SCREEN, KEY_OEM_1, KEY_OEM_2,
23
+ KEY_BACKQUOTE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_INSERT, KEY_HOME,
24
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_DEL, KEY_END,
25
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_NONE, KEY_ENTER, KEY_PAGE_UP, KEY_PAGE_DOWN,
26
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_NONE, KEY_SLASH, KEY_RSHIFT, KEY_NONE, KEY_ARROW_UP,
27
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RALT, KEY_RMETA, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT,
28
+ )
29
+
30
+ LAYOUT_KEYCHRON_Q5_HE = (6, 19,
31
+ KEY_ESCAPE, KEY_NONE, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_DEL, KEY_OEM_1, KEY_OEM_2, KEY_OEM_3, KEY_NONE,
32
+ KEY_BACKQUOTE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_PAGE_UP, KEY_NUM_LOCK, KEY_NUMPAD_DIVIDE, KEY_NUMPAD_MULTIPLY, KEY_NUMPAD_SUBTRACT,
33
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_PAGE_DOWN, KEY_NUMPAD7, KEY_NUMPAD8, KEY_NUMPAD9, KEY_NUMPAD_ADD,
34
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_ENTER, KEY_HOME, KEY_NONE, KEY_NUMPAD4, KEY_NUMPAD5, KEY_NUMPAD6, KEY_NONE,
35
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_NONE, KEY_SLASH, KEY_RSHIFT, KEY_ARROW_UP, KEY_NUMPAD1, KEY_NUMPAD2, KEY_NUMPAD3, KEY_NUMPAD_ENTER,
36
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RMETA, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT, KEY_NONE, KEY_NUMPAD0, KEY_NUMPAD_DECIMAL, KEY_NONE,
37
+ )
38
+
39
+ LAYOUT_KEYCHRON_K2_HE = (6, 16,
40
+ KEY_ESCAPE, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_PRINT_SCREEN, KEY_DEL, KEY_OEM_2,
41
+ KEY_BACKQUOTE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_PAGE_UP, KEY_NONE,
42
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_PAGE_DOWN, KEY_NONE,
43
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_ENTER, KEY_HOME, KEY_NONE, KEY_NONE,
44
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_SLASH, KEY_RSHIFT, KEY_ARROW_UP, KEY_END, KEY_NONE,
45
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RALT, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT, KEY_NONE,
46
+ )
47
+
48
+ LAYOUT_LEMOKEY_P1_HE = (6, 15,
49
+ KEY_ESCAPE, KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12, KEY_DEL, KEY_NONE,
50
+ KEY_BACKQUOTE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_HOME,
51
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_PAGE_UP,
52
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_ENTER, KEY_PAGE_DOWN, KEY_NONE,
53
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_NONE, KEY_SLASH, KEY_RSHIFT, KEY_ARROW_UP,
54
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RMETA, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT,
55
+ )
56
+
57
+ LAYOUT_MADLIONS_MAD60HE = [
58
+ KEY_ESCAPE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE,
59
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH,
60
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_NONE, KEY_ENTER,
61
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_SLASH, KEY_NONE, KEY_RSHIFT,
62
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RMETA, KEY_RALT, KEY_CTX, KEY_RCTRL, KEY_FN,
63
+ ]
64
+
65
+ LAYOUT_MADLIONS_MAD68HE = [
66
+ KEY_ESCAPE, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUALS, KEY_BACKSPACE, KEY_INSERT,
67
+ KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T, KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_BRACKET_LEFT, KEY_BRACKET_RIGHT, KEY_BACKSLASH, KEY_DEL,
68
+ KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_SEMICOLON, KEY_QUOTE, KEY_NONE, KEY_ENTER, KEY_PAGE_UP,
69
+ KEY_LSHIFT, KEY_NONE, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B, KEY_N, KEY_M, KEY_COMMA, KEY_PERIOD, KEY_SLASH, KEY_RSHIFT, KEY_ARROW_UP, KEY_PAGE_DOWN,
70
+ KEY_LCTRL, KEY_LMETA, KEY_LALT, KEY_NONE, KEY_NONE, KEY_NONE, KEY_SPACE, KEY_NONE, KEY_NONE, KEY_RALT, KEY_FN, KEY_RCTRL, KEY_ARROW_LEFT, KEY_ARROW_DOWN, KEY_ARROW_RIGHT,
71
+ ]
72
+
73
+ _DRUNKDEER_MAP = {
74
+ (0*21)+0: 0x29, (0*21)+2: 0x3A, (0*21)+3: 0x3B, (0*21)+4: 0x3C,
75
+ (0*21)+5: 0x3D, (0*21)+6: 0x3E, (0*21)+7: 0x3F, (0*21)+8: 0x40,
76
+ (0*21)+9: 0x41, (0*21)+10: 0x42, (0*21)+11: 0x43, (0*21)+12: 0x44,
77
+ (0*21)+13: 0x45, (0*21)+14: 0x4C,
78
+ (1*21)+0: 0x35, (1*21)+1: 0x1E, (1*21)+2: 0x1F, (1*21)+3: 0x20,
79
+ (1*21)+4: 0x21, (1*21)+5: 0x22, (1*21)+6: 0x23, (1*21)+7: 0x24,
80
+ (1*21)+8: 0x25, (1*21)+9: 0x26, (1*21)+10: 0x27, (1*21)+11: 0x2D,
81
+ (1*21)+12: 0x2E, (1*21)+13: 0x2A, (1*21)+15: 0x4A,
82
+ (2*21)+0: 0x2B, (2*21)+1: 0x14, (2*21)+2: 0x1A, (2*21)+3: 0x08,
83
+ (2*21)+4: 0x15, (2*21)+5: 0x17, (2*21)+6: 0x1C, (2*21)+7: 0x18,
84
+ (2*21)+8: 0x0C, (2*21)+9: 0x12, (2*21)+10: 0x13, (2*21)+11: 0x2F,
85
+ (2*21)+12: 0x30, (2*21)+13: 0x31, (2*21)+15: 0x4B,
86
+ (3*21)+0: 0x39, (3*21)+1: 0x04, (3*21)+2: 0x16, (3*21)+3: 0x07,
87
+ (3*21)+4: 0x09, (3*21)+5: 0x0A, (3*21)+6: 0x0B, (3*21)+7: 0x0D,
88
+ (3*21)+8: 0x0E, (3*21)+9: 0x0F, (3*21)+10: 0x33, (3*21)+11: 0x34,
89
+ (3*21)+13: 0x28, (3*21)+15: 0x4E,
90
+ (4*21)+0: 0xE1, (4*21)+2: 0x1D, (4*21)+3: 0x1B, (4*21)+4: 0x06,
91
+ (4*21)+5: 0x19, (4*21)+6: 0x05, (4*21)+7: 0x11, (4*21)+8: 0x10,
92
+ (4*21)+9: 0x36, (4*21)+10: 0x37, (4*21)+11: 0x38, (4*21)+13: 0xE5,
93
+ (4*21)+14: 0x52, (4*21)+15: 0x4D,
94
+ (5*21)+0: 0xE0, (5*21)+1: 0xE3, (5*21)+2: 0xE2, (5*21)+6: 0x2C,
95
+ (5*21)+10: 0xE6, (5*21)+11: 0x409,(5*21)+12: 0x65,
96
+ (5*21)+14: 0x50, (5*21)+15: 0x51, (5*21)+16: 0x4F,
97
+ }
98
+
99
+ def drunkdeer_index_to_hid_scancode(i):
100
+ import warnings
101
+ sc = _DRUNKDEER_MAP.get(i, 0)
102
+ if sc == 0:
103
+ warnings.warn(f"Failed to map DrunkDeer key to HID scancode: {i}")
104
+ return sc
@@ -0,0 +1,501 @@
1
+ from __future__ import annotations
2
+ import threading
3
+ import time
4
+ import warnings
5
+ from typing import Callable
6
+
7
+ from .keymaps import razer_to_wooting, nuphy_to_wooting, bytech_to_wooting, KEY_NONE
8
+ from .layouts import (
9
+ LAYOUT_KEYCHRON_Q1_HE, LAYOUT_KEYCHRON_Q3_HE, LAYOUT_KEYCHRON_Q5_HE,
10
+ LAYOUT_KEYCHRON_K2_HE, LAYOUT_LEMOKEY_P1_HE,
11
+ LAYOUT_MADLIONS_MAD60HE, LAYOUT_MADLIONS_MAD68HE,
12
+ layout_key, layout_size, layout_index_to_row, layout_index_to_col,
13
+ drunkdeer_index_to_hid_scancode,
14
+ )
15
+
16
+ ActiveKey = dict
17
+ Handler = Callable[[list[ActiveKey]], None]
18
+
19
+
20
+ class AsProvider:
21
+ FILTERS = []
22
+
23
+ def __init__(self, dev):
24
+ self.dev = dev
25
+ self._buffer: dict[int, float] = {}
26
+ self._thread: threading.Thread | None = None
27
+ self._running = False
28
+
29
+ @property
30
+ def product_name(self):
31
+ return getattr(self.dev, "product_string", "Unknown")
32
+
33
+ def forget(self):
34
+ self.stop_listening()
35
+ try:
36
+ self.dev.close()
37
+ except Exception:
38
+ pass
39
+
40
+ def _buffer_to_active_keys(self):
41
+ return [{"scancode": sc, "value": v} for sc, v in self._buffer.items()]
42
+
43
+ def start_listening(self, handler: Handler):
44
+ raise NotImplementedError
45
+
46
+ def stop_listening(self):
47
+ self._running = False
48
+ if self._thread:
49
+ self._thread.join(timeout=2)
50
+ self._thread = None
51
+
52
+ def _read_loop(self, handler: Handler):
53
+ while self._running:
54
+ try:
55
+ data = self.dev.read(64, timeout_ms=100)
56
+ if data:
57
+ self._handle_report(bytes(data), handler)
58
+ except Exception as e:
59
+ warnings.warn(f"HID read error: {e}")
60
+ break
61
+
62
+ def _handle_report(self, data: bytes, handler: Handler):
63
+ raise NotImplementedError
64
+
65
+
66
+ class AsProviderWootingV1(AsProvider):
67
+ FILTERS = [
68
+ {"usage_page": 0xFF54, "vendor_id": 0x31E3},
69
+ {"usage_page": 0xFF54, "vendor_id": 0x03EB, "product_id": 0xFF01},
70
+ {"usage_page": 0xFF54, "vendor_id": 0x03EB, "product_id": 0xFF02},
71
+ ]
72
+
73
+ def start_listening(self, handler: Handler):
74
+ self._running = True
75
+ self._prev_scancodes: set[int] = set()
76
+ self._thread = threading.Thread(target=self._read_loop, args=(handler,), daemon=True)
77
+ self._thread.start()
78
+
79
+ def _handle_report(self, data: bytes, handler: Handler):
80
+ active_keys = []
81
+ current_scancodes = set()
82
+ i = 0
83
+ while i < len(data):
84
+ if i + 2 > len(data): break
85
+ scancode = (data[i] << 8) | data[i + 1]
86
+ i += 2
87
+ if scancode == 0: break
88
+ if i >= len(data): break
89
+ value = data[i]
90
+ i += 1
91
+ active_keys.append({"scancode": scancode, "value": value / 255})
92
+ current_scancodes.add(scancode)
93
+ for released in self._prev_scancodes - current_scancodes:
94
+ active_keys.append({"scancode": released, "value": 0.0})
95
+ self._prev_scancodes = current_scancodes
96
+ handler(active_keys)
97
+
98
+
99
+ class AsProviderWootingV2(AsProvider):
100
+ FILTERS = [
101
+ {"usage_page": 0xFF53, "vendor_id": 0x31E3},
102
+ ]
103
+
104
+ def start_listening(self, handler: Handler):
105
+ self._running = True
106
+ self._prev_scancodes: set[int] = set()
107
+ self._thread = threading.Thread(target=self._read_loop, args=(handler,), daemon=True)
108
+ self._thread.start()
109
+
110
+ def _handle_report(self, data: bytes, handler: Handler):
111
+ active_keys = []
112
+ current_scancodes = set()
113
+ i = 0
114
+ while i + 4 <= len(data):
115
+ keycode = data[i + 1]
116
+ packed = data[i + 2]
117
+ value_hi = data[i + 3]
118
+ key_ns = (packed >> 2) & 0xF
119
+ value_lo = (packed >> 6) & 0x3
120
+ scancode = (key_ns << 8) | keycode
121
+ value = (value_hi << 2) | value_lo
122
+ i += 4
123
+ if scancode == 0: break
124
+ if value == 0: continue
125
+ active_keys.append({"scancode": scancode, "value": value / 1023})
126
+ current_scancodes.add(scancode)
127
+ for released in self._prev_scancodes - current_scancodes:
128
+ active_keys.append({"scancode": released, "value": 0.0})
129
+ self._prev_scancodes = current_scancodes
130
+ handler(active_keys)
131
+
132
+
133
+ class AsProviderRazerHuntsman(AsProvider):
134
+ FILTERS = [
135
+ {"vendor_id": 0x1532, "product_id": 0x0266},
136
+ {"vendor_id": 0x1532, "product_id": 0x0282},
137
+ ]
138
+
139
+ def start_listening(self, handler: Handler):
140
+ self._running = True
141
+ self._prev_scancodes: set[int] = set()
142
+ self._thread = threading.Thread(target=self._read_loop, args=(handler,), daemon=True)
143
+ self._thread.start()
144
+
145
+ def _handle_report(self, data: bytes, handler: Handler):
146
+ if data[0] != 7: return
147
+ active_keys = []
148
+ current_scancodes = set()
149
+ i = 1
150
+ while i < len(data):
151
+ scancode = data[i]; i += 1
152
+ if scancode == 0: break
153
+ value = data[i]; i += 1
154
+ hid_sc = _razer_to_hid(scancode)
155
+ if hid_sc:
156
+ active_keys.append({"scancode": hid_sc, "value": value / 255})
157
+ current_scancodes.add(hid_sc)
158
+ for released in self._prev_scancodes - current_scancodes:
159
+ active_keys.append({"scancode": released, "value": 0.0})
160
+ self._prev_scancodes = current_scancodes
161
+ handler(active_keys)
162
+
163
+
164
+ class AsProviderRazerHuntsmanV3(AsProvider):
165
+ FILTERS = [
166
+ {"vendor_id": 0x1532, "product_id": 0x02A6},
167
+ {"vendor_id": 0x1532, "product_id": 0x02A7},
168
+ {"vendor_id": 0x1532, "product_id": 0x02B0},
169
+ ]
170
+
171
+ def start_listening(self, handler: Handler):
172
+ self._running = True
173
+ self._prev_scancodes: set[int] = set()
174
+ self._thread = threading.Thread(target=self._read_loop, args=(handler,), daemon=True)
175
+ self._thread.start()
176
+
177
+ def _handle_report(self, data: bytes, handler: Handler):
178
+ if data[0] != 11: return
179
+ active_keys = []
180
+ current_scancodes = set()
181
+ i = 1
182
+ while i + 2 < len(data):
183
+ scancode = data[i]; i += 1
184
+ if scancode == 0: break
185
+ value = data[i]; i += 1
186
+ _unused = data[i]; i += 1
187
+ hid_sc = _razer_to_hid(scancode)
188
+ if hid_sc:
189
+ active_keys.append({"scancode": hid_sc, "value": value / 255})
190
+ current_scancodes.add(hid_sc)
191
+ for released in self._prev_scancodes - current_scancodes:
192
+ active_keys.append({"scancode": released, "value": 0.0})
193
+ self._prev_scancodes = current_scancodes
194
+ handler(active_keys)
195
+
196
+
197
+ class AsProviderNuphy(AsProvider):
198
+ FILTERS = [
199
+ {"vendor_id": 0x19F5},
200
+ ]
201
+
202
+ def start_listening(self, handler: Handler):
203
+ self._running = True
204
+ self._thread = threading.Thread(target=self._read_loop, args=(handler,), daemon=True)
205
+ self._thread.start()
206
+
207
+ def _handle_report(self, data: bytes, handler: Handler):
208
+ if data[0] != 0xA0: return
209
+ raw_sc = (data[2] << 8) | data[3]
210
+ hid_sc = _nuphy_to_hid(raw_sc)
211
+ if hid_sc == 0: return
212
+ if data[7] == 0:
213
+ self._buffer.pop(hid_sc, None)
214
+ else:
215
+ self._buffer[hid_sc] = data[7] / 200
216
+ handler(self._buffer_to_active_keys())
217
+
218
+
219
+ class AsProviderDrunkdeer(AsProvider):
220
+ FILTERS = [
221
+ {"usage_page": 0xFF00, "vendor_id": 0x352D},
222
+ ]
223
+
224
+ _POLL_PAYLOAD = bytes([
225
+ 0xB6, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00,
226
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
227
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
228
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
229
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
230
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
231
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
232
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
233
+ ])
234
+
235
+ def start_listening(self, handler: Handler):
236
+ self._running = True
237
+ self._active_keys = []
238
+ self._thread = threading.Thread(target=self._poll_loop, args=(handler,), daemon=True)
239
+ self._thread.start()
240
+
241
+ def _poll_loop(self, handler: Handler):
242
+ self.dev.write(b"\x04" + self._POLL_PAYLOAD)
243
+ while self._running:
244
+ try:
245
+ data = self.dev.read(64, timeout_ms=100)
246
+ if not data: continue
247
+ data = bytes(data)
248
+ n = data[3]
249
+ if n == 0:
250
+ self._active_keys = []
251
+ for i in range(4, len(data)):
252
+ value = data[i]
253
+ if value != 0:
254
+ sc = drunkdeer_index_to_hid_scancode(n * (64 - 5) + (i - 4))
255
+ self._active_keys.append({"scancode": sc, "value": value / 40})
256
+ if n == 2:
257
+ handler(list(self._active_keys))
258
+ self._active_keys = []
259
+ except Exception as e:
260
+ warnings.warn(f"DrunkDeer read error: {e}")
261
+ break
262
+
263
+
264
+ class AsProviderKeychron(AsProvider):
265
+ FILTERS = [
266
+ {"vendor_id": 0x3434, "product_id": 0x0B10},
267
+ {"vendor_id": 0x3434, "product_id": 0x0B11},
268
+ {"vendor_id": 0x3434, "product_id": 0x0B12},
269
+ {"vendor_id": 0x3434, "product_id": 0x0B30},
270
+ {"vendor_id": 0x3434, "product_id": 0x0B50},
271
+ {"vendor_id": 0x3434, "product_id": 0x0E20},
272
+ {"vendor_id": 0x3434, "product_id": 0x0E21},
273
+ {"vendor_id": 0x3434, "product_id": 0x0E22},
274
+ {"vendor_id": 0x362D, "product_id": 0x0610},
275
+ ]
276
+
277
+ _INIT_PAYLOAD = bytes([
278
+ 0xA9, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
279
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
280
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
281
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
282
+ ])
283
+
284
+ def _pick_layout(self):
285
+ pid = self.dev.product_id
286
+ if pid in (0x0B10, 0x0B11, 0x0B12): return LAYOUT_KEYCHRON_Q1_HE
287
+ if pid == 0x0B30: return LAYOUT_KEYCHRON_Q3_HE
288
+ if pid == 0x0B50: return LAYOUT_KEYCHRON_Q5_HE
289
+ if pid == 0x0610: return LAYOUT_LEMOKEY_P1_HE
290
+ return LAYOUT_KEYCHRON_K2_HE
291
+
292
+ def start_listening(self, handler: Handler):
293
+ self._running = True
294
+ self.layout = self._pick_layout()
295
+ self._thread = threading.Thread(target=self._keychron_loop, args=(handler,), daemon=True)
296
+ self._thread.start()
297
+
298
+ def _build_single_key_request(self, index):
299
+ key = layout_key(self.layout, index)
300
+ if key == KEY_NONE: return None
301
+ row = layout_index_to_row(self.layout, index)
302
+ col = layout_index_to_col(self.layout, index)
303
+ buf = bytearray(32)
304
+ buf[0] = 0xA9; buf[1] = 0x30; buf[2] = row; buf[3] = col
305
+ return bytes(buf)
306
+
307
+ def _build_all_keys_request(self):
308
+ buf = bytearray(32)
309
+ buf[0] = 0xA9; buf[1] = 0x31
310
+ return bytes(buf)
311
+
312
+ def _keychron_loop(self, handler: Handler):
313
+ self.dev.write(b"\x00" + self._INIT_PAYLOAD)
314
+ data = bytes(self.dev.read(64, timeout_ms=500) or [])
315
+ if not data: return
316
+ am_version = data[2] if len(data) > 2 else 0
317
+ has_full_report = len(data) > 31 and data[31] == 0x45
318
+ self._buffer = {}
319
+ if has_full_report:
320
+ self._run_full_mode(handler)
321
+ else:
322
+ self._run_single_mode(handler, am_version)
323
+
324
+ def _run_full_mode(self, handler: Handler):
325
+ chunk_index = 0
326
+ self.dev.write(b"\x00" + self._build_all_keys_request())
327
+ while self._running:
328
+ data = bytes(self.dev.read(64, timeout_ms=200) or [])
329
+ if not data: continue
330
+ for i in range(30):
331
+ li = chunk_index * 30 + i
332
+ if li < layout_size(self.layout):
333
+ key = layout_key(self.layout, li)
334
+ if key != KEY_NONE:
335
+ travel = data[2 + i] if 2 + i < len(data) else 0
336
+ if travel >= 5:
337
+ self._buffer[key] = min(travel / 235, 1.0)
338
+ else:
339
+ self._buffer.pop(key, None)
340
+ chunk_index += 1
341
+ if chunk_index == 4:
342
+ handler(self._buffer_to_active_keys())
343
+ chunk_index = 0
344
+ self.dev.write(b"\x00" + self._build_all_keys_request())
345
+
346
+ def _run_single_mode(self, handler: Handler, am_version: int):
347
+ value_offset = 6 if am_version >= 4 else 3
348
+ index = 0
349
+ while True:
350
+ req = self._build_single_key_request(index)
351
+ if req:
352
+ self.dev.write(b"\x00" + req)
353
+ break
354
+ index = (index + 1) % layout_size(self.layout)
355
+ while self._running:
356
+ data = bytes(self.dev.read(64, timeout_ms=200) or [])
357
+ if not data: continue
358
+ key = layout_key(self.layout, index)
359
+ travel = data[value_offset] if value_offset < len(data) else 0
360
+ if travel >= 5:
361
+ self._buffer[key] = min(travel / 235, 1.0)
362
+ else:
363
+ self._buffer.pop(key, None)
364
+ handler(self._buffer_to_active_keys())
365
+ while True:
366
+ index = (index + 1) % layout_size(self.layout)
367
+ req = self._build_single_key_request(index)
368
+ if req:
369
+ self.dev.write(b"\x00" + req)
370
+ break
371
+
372
+
373
+ class AsProviderMadlions(AsProvider):
374
+ FILTERS = [
375
+ {"vendor_id": 0x373B, "product_id": 0x1053},
376
+ {"vendor_id": 0x373B, "product_id": 0x1055},
377
+ {"vendor_id": 0x373B, "product_id": 0x1056},
378
+ {"vendor_id": 0x373B, "product_id": 0x105D},
379
+ {"vendor_id": 0x373B, "product_id": 0x1058},
380
+ {"vendor_id": 0x373B, "product_id": 0x1059},
381
+ {"vendor_id": 0x373B, "product_id": 0x105A},
382
+ {"vendor_id": 0x373B, "product_id": 0x105C},
383
+ {"vendor_id": 0x373B, "product_id": 0x10A7},
384
+ ]
385
+
386
+ _INIT_PAYLOAD = bytes([
387
+ 0x02, 0x96, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x04,
388
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
389
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
390
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
391
+ ])
392
+
393
+ _MAD60HE_PIDS = {0x1053, 0x1055, 0x1056, 0x105D}
394
+
395
+ def start_listening(self, handler: Handler):
396
+ pid = self.dev.product_id
397
+ self.layout = LAYOUT_MADLIONS_MAD60HE if pid in self._MAD60HE_PIDS else LAYOUT_MADLIONS_MAD68HE
398
+ self._running = True
399
+ self._thread = threading.Thread(target=self._madlions_loop, args=(handler,), daemon=True)
400
+ self._thread.start()
401
+
402
+ def _madlions_loop(self, handler: Handler):
403
+ payload = bytearray(self._INIT_PAYLOAD)
404
+ self.dev.write(b"\x00" + bytes(payload))
405
+ offset = 0
406
+ self._buffer = {}
407
+ while self._running:
408
+ data = bytes(self.dev.read(64, timeout_ms=200) or [])
409
+ if not data: continue
410
+ for i in range(4):
411
+ idx = offset + i
412
+ if idx < len(self.layout):
413
+ key = self.layout[idx]
414
+ pos = 7 + i * 5 + 3
415
+ travel = (data[pos] << 8) | data[pos + 1] if pos + 1 < len(data) else 0
416
+ if travel == 0:
417
+ self._buffer.pop(key, None)
418
+ else:
419
+ self._buffer[key] = travel / 350
420
+ handler(self._buffer_to_active_keys())
421
+ offset = (offset + 4) % len(self.layout)
422
+ payload[6] = offset
423
+ self.dev.write(b"\x00" + bytes(payload))
424
+
425
+
426
+ class AsProviderBytech(AsProvider):
427
+ FILTERS = [
428
+ {"vendor_id": 0x372E, "product_id": 0x105B},
429
+ ]
430
+
431
+ def _build_payload(self, cmd, sub):
432
+ buf = bytearray(63)
433
+ buf[0] = cmd
434
+ buf[1] = sub
435
+ total = 9 + sum(buf[:-1])
436
+ buf[-1] = 255 - (total % 256)
437
+ return bytes(buf)
438
+
439
+ def start_listening(self, handler: Handler):
440
+ self._running = True
441
+ self._thread = threading.Thread(target=self._bytech_loop, args=(handler,), daemon=True)
442
+ self._thread.start()
443
+
444
+ def _bytech_loop(self, handler: Handler):
445
+ payload = self._build_payload(0x97, 0x00)
446
+ self._buffer = {}
447
+ self.dev.write(b"\x09" + payload)
448
+ last_poll = time.monotonic()
449
+ while self._running:
450
+ now = time.monotonic()
451
+ if now - last_poll >= 1.0:
452
+ self.dev.write(b"\x09" + payload)
453
+ last_poll = now
454
+ data = bytes(self.dev.read(64, timeout_ms=100) or [])
455
+ if not data: continue
456
+ if data[0] == 0x97 and data[1] == 0x01:
457
+ self.dev.write(b"\x09" + payload)
458
+ last_poll = time.monotonic()
459
+ self._buffer = {}
460
+ count = data[5]
461
+ for i in range(0, count, 4):
462
+ base = 6 + i
463
+ if base + 3 >= len(data): break
464
+ pos = (data[base] << 8) | data[base + 1]
465
+ distance = (data[base + 2] << 8) | data[base + 3]
466
+ sc = _bytech_to_hid(pos)
467
+ if sc and distance > 10:
468
+ self._buffer[sc] = min(distance / 355, 1.0)
469
+ handler(self._buffer_to_active_keys())
470
+
471
+
472
+ def _razer_to_hid(sc):
473
+ result = razer_to_wooting.get(sc, 0)
474
+ if result == 0:
475
+ warnings.warn(f"Failed to map Razer scancode to HID scancode: {sc:#x}")
476
+ return result
477
+
478
+ def _nuphy_to_hid(sc):
479
+ result = nuphy_to_wooting.get(sc, 0)
480
+ if result == 0:
481
+ warnings.warn(f"Failed to map NuPhy scancode to HID scancode: {sc:#x}")
482
+ return result
483
+
484
+ def _bytech_to_hid(sc):
485
+ result = bytech_to_wooting.get(sc, 0)
486
+ if result == 0:
487
+ warnings.warn(f"Failed to map Bytech scancode to HID scancode: {sc:#x}")
488
+ return result
489
+
490
+
491
+ ALL_PROVIDERS = [
492
+ AsProviderWootingV1,
493
+ AsProviderWootingV2,
494
+ AsProviderRazerHuntsman,
495
+ AsProviderRazerHuntsmanV3,
496
+ AsProviderNuphy,
497
+ AsProviderDrunkdeer,
498
+ AsProviderKeychron,
499
+ AsProviderMadlions,
500
+ AsProviderBytech,
501
+ ]
@@ -0,0 +1,10 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="analogsense",
5
+ version="1.0.0",
6
+ description="Python port of the AnalogSense JavaScript SDK",
7
+ packages=find_packages(),
8
+ python_requires=">=3.10",
9
+ install_requires=["hid"],
10
+ )
@@ -0,0 +1,9 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "AnalogSensePy"
7
+ version = "1.0.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = ["hid"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+