Skip to content

Commit 9d8c610

Browse files
Allow user-defined filters (#108)
1 parent 2721366 commit 9d8c610

File tree

6 files changed

+104
-48
lines changed

6 files changed

+104
-48
lines changed

.gitignore

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
/commands
2-
.idea
1+
filters/*.py
32
*.so
43
__pycache__/
54
build/
65
install/on_rpi/bluez/
76
devices_config.json
8-
*~

.mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[mypy]
2-
files = adapter.py, agent.py, bluetooth_devices.py, compatibility_device.py, hid_devices.py
2+
files = adapter.py, agent.py, bluetooth_devices.py, compatibility_device.py, hid_devices.py, filters/
33
check_untyped_defs = True
44
follow_imports_for_stubs = True
55
disallow_any_decorated = True

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,3 @@ You may need to manually make changes to the system to match the changes in the
8181

8282
Inspired by [RaspiKey](https://github.com/samartzidis/RaspiKey), but wanted the solution to stay wireless, as this is why I bought Apple wireless keyboard in the first place. By doing this it allowed to turn my wired mouse to wireless as well and have one keyboard+mouse for two machines. Also, Python implementation should allow people to easily customize their mappings.
8383
If you want to do this, check out [hid-tools](https://gitlab.freedesktop.org/libevdev/hid-tools) to monitor raw hid reports from your device.
84-
85-
[This post](http://who-t.blogspot.com/2018/12/understanding-hid-report-descriptors.html) is super helpful in getting your head around HID descriptors.

FILTERS.md renamed to filters/README.md

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,25 @@ b7 09 cd 09 e2 09 e9 09 ea 81 02 95 01 81 01 c0 05 01 09 02 a1 01 09 01 a1 00 85
5959
13 15 00 26 ff 00 09 02 81 00 09 02 91 00 c0
6060
N: Bluetooth HID Hub - RPi
6161
I: 5 1d6b 0246
62-
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
62+
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
6363
E: 000000.000000 16 04 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
64-
# ReportID: 4 / Button: 1 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 1 , 0 , 0 , 0 , 0 , 0 , 0
64+
# ReportID: 4 / Button: 1 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 1 , 0 , 0 , 0 , 0 , 0 , 0
6565
E: 000000.000698 16 04 01 00 00 00 00 00 00 01 01 00 00 00 00 00 00
66-
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
66+
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
6767
E: 000000.001094 16 04 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
68-
# ReportID: 4 / Button: 1 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 1 , 0 , 0 , 0 , 0 , 0 , 0
68+
# ReportID: 4 / Button: 1 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 1 , 0 , 0 , 0 , 0 , 0 , 0
6969
E: 000000.001470 16 04 01 00 00 00 00 00 00 01 01 00 00 00 00 00 00
70-
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
70+
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
7171
E: 000000.001878 16 04 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
72-
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
72+
# ReportID: 4 / Button: 0 0 0 0 0 0 0 0 | X: 0 | Y: 0 | Wheel: 0 | 0xcff00: 0 | Vendor Usage 1: 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0
7373
E: 000000.044655 16 04 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
7474
```
7575

7676
One difference is that the ReportID has been changed from 1 to 4 on each event.
7777
This is needed for the RPi to be able to handle multiple devices. This is not the cause.
7878

7979
If we save the above output to a file, then we can repeat the event with ``hid-replay my-recording.hid``.
80-
This allows use to change parts and test them out.
80+
This allows us to change parts and test them out.
8181

8282
Through trial and error we can figure out that the double-click only works when the vendor ID (middle value under ``I:``) is set to ``0b33``.
8383
This is the ID for Contour and it suggests that there is vendor-specific code in the kernel to make this button work. Not helpful...
@@ -99,54 +99,101 @@ Hmm, it seems like the double-click actually sends 2 clicks of a different butto
9999
What if we change the ``80`` to ``01``, so we actually send 2 left-click events?
100100
Running that through ``hid-replay``, it works!
101101

102-
However, if you just change that on the RPi, we later find it still does not work.
103-
Through more trial and error, comparing working/non-working events we can also figure out that the 2 left-click events
104-
only get interpreted as a double-click if there's a gap between the events of atleast around 25ms (the first number after
105-
``E:`` is a timestamp of the event).
102+
## Writing a filter
106103

107-
## Implementing the fix
104+
To implement the fix we need to create a new filter function.
105+
While ssh'd into the RPi, create the new file: ``bthidhub/filters/contour.py``:
108106

109-
To implement the fix we need to create a new filter class in the bthidhub repo.
110-
While ssh'd into the RPi, I've created ``bthidhub/contour_message_filter.py``:
107+
```
108+
"""Contour Rollermouse"""
111109
110+
def message_filter(msg: bytes) -> bytes:
111+
if len(msg) >= 10 and msg[9] == 0x80:
112+
# Convert vendor specific double click (button 0x80), to normal double click (button 1).
113+
msg = msg[:9] + (b"\x01" if msg[1] else b"\x00") + msg[10:]
114+
return msg
112115
```
113-
import time
114-
from typing import Optional
115116

116-
from hid_message_filter import HIDMessageFilter
117+
The docstring on the first line can be used to customise the name in the UI.
118+
If omitted, the name will be based on the filename.
119+
Each module in the filters directory must define a function named ``message_filter``.
120+
This function will be automatically imported and available as a filter.
121+
122+
The above filter function changes the byte sequence for the double click button, so it
123+
looks like regular single clicks.
124+
125+
Restart the service:
126+
``sudo systemctl restart remapper``
127+
128+
After a few seconds, refresh the web page and you should see the new filter in the dropdown
129+
options. Select the new filter and it will immediately be applied to all events from that
130+
device.
131+
132+
If you add ``print()`` calls for debugging, you can view the logs with:
133+
``journalctl -xeu remapper``
134+
135+
### Using clases to store state
136+
137+
With the above code, it seems that the double click is only getting recognised as a single
138+
click. Through more trial and error, comparing working/non-working events we can also
139+
figure out that the 2 left-click events only get interpreted as a double-click if there's
140+
a gap between the events of atleast around 25ms (the first number after ``E:`` is a
141+
timestamp of the event).
142+
143+
To emulate this, we can create a class to store a boolean state and then sleep for 25ms in
144+
the middle of the double click events. A complete filter for this might look like:
145+
146+
```
147+
"""Contour Rollermouse"""
148+
149+
import time
117150
118-
class ContourMessageFilter(HIDMessageFilter):
151+
class ContourMessageFilter:
119152
delay = False
120153
121-
def filter_message_to_host(self, msg: bytes) -> Optional[bytes]:
154+
def filter_message(self, msg: bytes) -> bytes:
122155
if len(msg) >= 10 and msg[9] == 0x80:
123156
# Convert vendor specific double click (button 0x80), to normal double click (button 1).
124-
msg = msg[:9] + (b'\x01' if msg[1] else b'\x00') + msg[10:]
157+
msg = msg[:9] + (b"\x01" if msg[1] else b"\x00") + msg[10:]
125158
if msg[1]:
126159
# Ensure a small delay between click events otherwise it won't register as a double click.
127160
if self.delay:
128161
time.sleep(0.025)
129162
self.delay = not self.delay
130-
return b'\xa1' + (msg[0] + 3).to_bytes(1, 'big') + msg[1:]
163+
return msg
164+
165+
message_filter = ContourMessageFilter().filter_message
131166
```
132167

133-
The above code converts that pesky ``80`` to a ``01`` and forces a 25ms gap between the 2 click events.
168+
Note the last line which assigns the bound method to the ``message_filter`` name, so it
169+
can still be imported the same way as our first example.
170+
171+
### Suppressing events
172+
173+
If you want to block some messages from being sent entirely, instead of returning the
174+
message, simply ``return None``.
175+
176+
You will need to change the return type annotation in this case to ``-> bytes | None:``.
177+
178+
### Compiling for maximum performance
179+
180+
Once you have your filters working correctly, you can compile the code to ensure
181+
maximum performance. Simply run:
134182

135-
We also need to patch this into ``hid_devices.py`` by updating these lines:
136183
```
137-
from contour_message_filter import ContourMessageFilter
138-
FILTERS = [
139-
{"id":"Default", "name":"Default"},
140-
{"id":"Mouse", "name":"Mouse"},
141-
{"id":"A1314", "name":"A1314"},
142-
{"id":"Contour", "name":"Contour"}
143-
]
144-
FILTER_INSTANCES = {
145-
"Default" : HIDMessageFilter(), "Mouse":MouseMessageFilter(), "A1314":A1314MessageFilter(),
146-
"Contour": ContourMessageFilter(),
147-
}
184+
cd $HOME/bthidhub/
185+
mypyc
148186
```
149187

150-
Then we can select the "Contour" option in the web interface in order to enable the filter.
188+
This may take upto 20 mins to complete.
189+
190+
## Notes
191+
192+
The same techniques could be used to remap events when you just want to change
193+
the behaviour of a device.
194+
195+
[This post](http://who-t.blogspot.com/2018/12/understanding-hid-report-descriptors.html)
196+
is super helpful in getting your head around HID descriptors.
151197

152-
The same techniques can be used to remap events when you just want to change the behaviour of a device.
198+
If you have a report descriptor and don't want to use hid-tools to decode, an online parser is:
199+
https://eleccelerator.com/usbdescreqparser/

filters/__init__.py

Whitespace-only changes.

hid_devices.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import array
66
import asyncio
77
import fcntl
8+
import importlib
89
import os
910
import json
1011
import re
@@ -47,7 +48,7 @@ class _InputDevice(TypedDict):
4748

4849
class _HIDDevices(TypedDict):
4950
devices: list[_Device]
50-
filters: tuple[dict[str, str]]
51+
filters: tuple[dict[str, str], ...]
5152
input_devices: list[_InputDevice]
5253

5354

@@ -58,16 +59,27 @@ class _DeviceConfig(TypedDict, total=False):
5859
mapped_ids: dict[Union[int, Literal["_"]], int]
5960

6061

62+
class FilterDict(TypedDict):
63+
name: str
64+
func: HIDMessageFilter
65+
66+
6167
DEVICES_CONFIG_FILE_NAME = 'devices_config.json'
6268
DEVICES_CONFIG_COMPATIBILITY_DEVICE_KEY = 'compatibility_devices'
6369
CAPTURE_ELEMENT: Literal['capture'] = 'capture'
6470
FILTER_ELEMENT: Literal['filter'] = 'filter'
71+
FILTERS_PATH = Path(__file__).parent / "filters"
6572
REPORT_ID_PATTERN = re.compile(r"(a10185)(..)")
6673
SDP_TEMPLATE_PATH = Path(__file__).with_name("sdp_record_template.xml")
6774
SDP_OUTPUT_PATH = Path("/etc/bluetooth/sdp_record.xml")
6875

69-
FILTERS = ({"id": "_", "name": "No filter"},)
70-
FILTER_INSTANCES: dict[str, HIDMessageFilter] = {"_": lambda m: m}
76+
FILTERS: dict[str, FilterDict] = {"_": {"name": "No filter", "func": lambda m: m}}
77+
for mod_path in FILTERS_PATH.glob("*.py"):
78+
if mod_path.stem == "__init__":
79+
continue
80+
mod = importlib.import_module("filters." + mod_path.stem)
81+
name = mod.__doc__ or mod_path.stem.replace("_", " ").capitalize()
82+
FILTERS[mod_path.stem] = {"name": name, "func": mod.message_filter}
7183

7284

7385
# https://github.com/bentiss/hid-tools/blob/59a0c4b153dbf7d443e63bf68ff830b8353f5f7a/hidtools/hidraw.py#L33-L104
@@ -404,13 +416,14 @@ def __get_configured_device_filter(self, device_id: str) -> HIDMessageFilter:
404416
if device_id in self.devices_config:
405417
if FILTER_ELEMENT in self.devices_config[device_id]:
406418
filter_id = self.devices_config[device_id][FILTER_ELEMENT]
407-
return FILTER_INSTANCES[filter_id]
408-
return FILTER_INSTANCES["_"]
419+
return FILTERS[filter_id]["func"]
420+
return FILTERS["_"]["func"]
409421

410422
def get_hid_devices_with_config(self) -> _HIDDevices:
411423
for device in self.devices:
412424
if device["id"] in self.devices_config:
413425
device[CAPTURE_ELEMENT] = self.devices_config[device["id"]].get(CAPTURE_ELEMENT, False)
414426
if FILTER_ELEMENT in self.devices_config[device["id"]]:
415427
device[FILTER_ELEMENT] = self.devices_config[device["id"]][FILTER_ELEMENT]
416-
return {"devices": self.devices, "filters": FILTERS, "input_devices": self.input_devices}
428+
f = tuple({"id": k, "name": v["name"]} for k,v in FILTERS.items())
429+
return {"devices": self.devices, "filters": f, "input_devices": self.input_devices}

0 commit comments

Comments
 (0)