hidbridge 1.2.3__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.
Files changed (74) hide show
  1. hidbridge-1.2.3/.gitignore +37 -0
  2. hidbridge-1.2.3/LICENSE +21 -0
  3. hidbridge-1.2.3/PKG-INFO +201 -0
  4. hidbridge-1.2.3/README.md +171 -0
  5. hidbridge-1.2.3/pyproject.toml +82 -0
  6. hidbridge-1.2.3/src/hidbridge/__init__.py +42 -0
  7. hidbridge-1.2.3/src/hidbridge/_scripts/bt-hid.service.in +19 -0
  8. hidbridge-1.2.3/src/hidbridge/_scripts/bt_hid +11 -0
  9. hidbridge-1.2.3/src/hidbridge/_scripts/pair-mode.sh +19 -0
  10. hidbridge-1.2.3/src/hidbridge/_scripts/service.sh +59 -0
  11. hidbridge-1.2.3/src/hidbridge/_version.py +12 -0
  12. hidbridge-1.2.3/src/hidbridge/adapter.py +268 -0
  13. hidbridge-1.2.3/src/hidbridge/ble_hid.py +669 -0
  14. hidbridge-1.2.3/src/hidbridge/board.py +563 -0
  15. hidbridge-1.2.3/src/hidbridge/bridge.py +280 -0
  16. hidbridge-1.2.3/src/hidbridge/central.py +1122 -0
  17. hidbridge-1.2.3/src/hidbridge/central_core.py +690 -0
  18. hidbridge-1.2.3/src/hidbridge/cli.py +779 -0
  19. hidbridge-1.2.3/src/hidbridge/conn.py +380 -0
  20. hidbridge-1.2.3/src/hidbridge/controller.py +195 -0
  21. hidbridge-1.2.3/src/hidbridge/dismiss_trust.py +362 -0
  22. hidbridge-1.2.3/src/hidbridge/link_esp32.py +77 -0
  23. hidbridge-1.2.3/src/hidbridge/link_net.py +51 -0
  24. hidbridge-1.2.3/src/hidbridge/link_pico.py +81 -0
  25. hidbridge-1.2.3/src/hidbridge/loopback_selftest.py +277 -0
  26. hidbridge-1.2.3/src/hidbridge/name_probe.py +157 -0
  27. hidbridge-1.2.3/src/hidbridge/naming.py +98 -0
  28. hidbridge-1.2.3/src/hidbridge/reports.py +455 -0
  29. hidbridge-1.2.3/src/hidbridge/rpc.py +445 -0
  30. hidbridge-1.2.3/src/hidbridge/serial_link.py +266 -0
  31. hidbridge-1.2.3/src/hidbridge/socket_link.py +233 -0
  32. hidbridge-1.2.3/src/hidbridge/targets.py +331 -0
  33. hidbridge-1.2.3/src/hidbridge/transport_esp32.py +277 -0
  34. hidbridge-1.2.3/src/hidbridge/transport_net.py +141 -0
  35. hidbridge-1.2.3/src/hidbridge/transport_pico.py +234 -0
  36. hidbridge-1.2.3/tests/conftest.py +9 -0
  37. hidbridge-1.2.3/tests/test_bt_hid_adapter.py +64 -0
  38. hidbridge-1.2.3/tests/test_bt_hid_advert_name_live.py +139 -0
  39. hidbridge-1.2.3/tests/test_bt_hid_board.py +654 -0
  40. hidbridge-1.2.3/tests/test_bt_hid_bridge.py +286 -0
  41. hidbridge-1.2.3/tests/test_bt_hid_central_core.py +511 -0
  42. hidbridge-1.2.3/tests/test_bt_hid_central_selftest.py +183 -0
  43. hidbridge-1.2.3/tests/test_bt_hid_cli.py +128 -0
  44. hidbridge-1.2.3/tests/test_bt_hid_controller.py +120 -0
  45. hidbridge-1.2.3/tests/test_bt_hid_dismiss_trust.py +176 -0
  46. hidbridge-1.2.3/tests/test_bt_hid_dongle_link.py +163 -0
  47. hidbridge-1.2.3/tests/test_bt_hid_dual_identity_live.py +172 -0
  48. hidbridge-1.2.3/tests/test_bt_hid_esp32.py +328 -0
  49. hidbridge-1.2.3/tests/test_bt_hid_esp32_noreset.py +155 -0
  50. hidbridge-1.2.3/tests/test_bt_hid_esp32_portsafety.py +50 -0
  51. hidbridge-1.2.3/tests/test_bt_hid_gatt_interfaces.py +43 -0
  52. hidbridge-1.2.3/tests/test_bt_hid_layout_drag_live.py +205 -0
  53. hidbridge-1.2.3/tests/test_bt_hid_layouts.py +183 -0
  54. hidbridge-1.2.3/tests/test_bt_hid_link_mcu.py +38 -0
  55. hidbridge-1.2.3/tests/test_bt_hid_logging.py +184 -0
  56. hidbridge-1.2.3/tests/test_bt_hid_loopback.py +38 -0
  57. hidbridge-1.2.3/tests/test_bt_hid_mini5_bond_live.py +102 -0
  58. hidbridge-1.2.3/tests/test_bt_hid_multi_adapter.py +118 -0
  59. hidbridge-1.2.3/tests/test_bt_hid_multi_daemon_live.py +86 -0
  60. hidbridge-1.2.3/tests/test_bt_hid_naming.py +43 -0
  61. hidbridge-1.2.3/tests/test_bt_hid_naming_live.py +159 -0
  62. hidbridge-1.2.3/tests/test_bt_hid_net.py +182 -0
  63. hidbridge-1.2.3/tests/test_bt_hid_ota_live.py +164 -0
  64. hidbridge-1.2.3/tests/test_bt_hid_per_bond_identity.py +173 -0
  65. hidbridge-1.2.3/tests/test_bt_hid_readiness_live.py +61 -0
  66. hidbridge-1.2.3/tests/test_bt_hid_ready_signal.py +91 -0
  67. hidbridge-1.2.3/tests/test_bt_hid_reports.py +382 -0
  68. hidbridge-1.2.3/tests/test_bt_hid_rpc.py +505 -0
  69. hidbridge-1.2.3/tests/test_bt_hid_serial_net_live.py +168 -0
  70. hidbridge-1.2.3/tests/test_bt_hid_target_switch_live.py +60 -0
  71. hidbridge-1.2.3/tests/test_bt_hid_targets.py +206 -0
  72. hidbridge-1.2.3/tests/test_bt_hid_tls.py +113 -0
  73. hidbridge-1.2.3/tests/test_bt_hid_transport_pico.py +236 -0
  74. hidbridge-1.2.3/tests/test_bt_hid_version.py +30 -0
@@ -0,0 +1,37 @@
1
+ # --- Python ---
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ *.whl
14
+ *.tar.gz
15
+
16
+ # --- PlatformIO / firmware build output ---
17
+ .pio/
18
+ .pioenvs/
19
+ .piolibdeps/
20
+ firmware/*/.pio/
21
+
22
+ # --- editor / OS ---
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ .DS_Store
27
+
28
+ # --- local secrets / runtime state (NEVER commit) ---
29
+ # The device passcode PIN lives OUTSIDE the repo at ~/.config/bt_hid/idevice.pin (0600).
30
+ *.pin
31
+ idevice.pin
32
+ targets.json
33
+ # The network backend auth token also lives OUTSIDE the repo at ~/.config/bt_hid/net.token (0600);
34
+ # block it anywhere in case it is ever copied into a tree.
35
+ net.token
36
+ *.token
37
+ .claude/worktrees/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Munk Hansen
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,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: hidbridge
3
+ Version: 1.2.3
4
+ Summary: Drive a Bluetooth-HID target (iPad / iPhone / any host) from Linux -- keyboard + pointer over the host BlueZ stack or a Pico 2W / ESP32 serial-attached BLE peripheral
5
+ Project-URL: Homepage, https://github.com/martinmunk/hidbridge
6
+ Project-URL: Repository, https://github.com/martinmunk/hidbridge
7
+ Project-URL: Issues, https://github.com/martinmunk/hidbridge/issues
8
+ Author: Martin Munk Hansen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ble,bluetooth,bluez,esp32,hid,hog,ipad,keyboard,mouse,pico
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Human Interface Device (HID)
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: dev
20
+ Requires-Dist: build>=1.0; extra == 'dev'
21
+ Requires-Dist: pyserial>=3.5; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.4; extra == 'dev'
24
+ Provides-Extra: dongle
25
+ Requires-Dist: dbus-python>=1.2; extra == 'dongle'
26
+ Requires-Dist: pygobject>=3.40; extra == 'dongle'
27
+ Provides-Extra: mcu
28
+ Requires-Dist: pyserial>=3.5; extra == 'mcu'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # hidbridge (Python)
32
+
33
+ The Python side of **hidbridge** -- a Linux toolbox that presents a Bluetooth-LE HID
34
+ keyboard + relative pointer to any BLE-HID (HOG) host and drives it programmatically: type text,
35
+ press keys, move/click the pointer, and enter a lock-screen passcode.
36
+
37
+ Two backends, one report layer:
38
+
39
+ - **dongle** -- the host's own **BlueZ** stack (a USB Bluetooth adapter). A small daemon
40
+ (`ble_hid`) exports the HID-over-GATT peripheral; the CLI talks to it over a Unix socket.
41
+ Needs `dbus` + `PyGObject` (system python) and usually root.
42
+ - **mcu** -- a **Raspberry Pi Pico 2 W** or an **ESP32** running the firmware in
43
+ `../firmware/`, reached over **USB serial**. The MCU owns its own BLE stack + bond database,
44
+ so it sidesteps the HID-over-GATT CCCD/Report-Reference subscription quirk that trips BlueZ with
45
+ some hosts. Needs only `pyserial`; no dbus, no daemon, no root.
46
+ - **net** -- an **Ethernet/WiFi ESP32** running that same firmware, reached over **TCP** (no USB
47
+ cable): `--backend net --host hidbridge-<nick>.local`. **TLS-PSK encrypted** and fails closed -- the
48
+ provisioned token is the pre-shared key; set it on the board over USB once with
49
+ `hidbridge net-provision`. Needs Python 3.13+ (ssl PSK). Stdlib-only (sockets + ssl); no pyserial,
50
+ dbus, daemon, or root. See [`../docs/bt-hid-network-backend.md`](../docs/bt-hid-network-backend.md).
51
+
52
+ Both backends emit the **exact same report bytes** via `hidbridge.reports` (the single source of
53
+ truth for the HID report descriptor), so behaviour is identical regardless of transport.
54
+
55
+ ## Install
56
+
57
+ **As a consumer** (released wheels are on PyPI):
58
+
59
+ ```bash
60
+ pip install "hidbridge[mcu]" # + pyserial for the Pico/ESP32 serial backend
61
+ pip install hidbridge # core only: reports + the net (TCP) backend
62
+ ```
63
+
64
+ Source, issues and firmware releases: <https://github.com/martinmunk/hidbridge>. The release flow
65
+ (wheel and firmware version independently; the git tag is the version):
66
+ [`docs/releasing.md`](https://github.com/martinmunk/hidbridge/blob/main/docs/releasing.md).
67
+
68
+ **From source** (this checkout):
69
+
70
+ ```bash
71
+ # from this directory (hidbridge/python)
72
+ pip install -e ".[mcu]" # Pico / ESP32 serial backend (pyserial)
73
+ pip install -e ".[dongle]" # BlueZ backend (dbus-python + PyGObject; system python)
74
+ pip install -e ".[dev]" # + pytest, ruff, build
75
+ ```
76
+
77
+ The **dongle** backend must run under the **system python** (`/usr/bin/python3`) because
78
+ `dbus` + `AF_BLUETOOTH` sockets are only available there. On Debian/Ubuntu install the system
79
+ deps with apt instead of pip:
80
+
81
+ ```bash
82
+ sudo apt install python3-dbus python3-gi
83
+ ```
84
+
85
+ For a venv that still sees them, create it with `--system-site-packages`
86
+ (`uv venv --system-site-packages` or `python3 -m venv --system-site-packages`).
87
+
88
+ ## Quick start
89
+
90
+ ```bash
91
+ # MCU backend (a Pico on /dev/ttyACM0) -- no root, no daemon
92
+ hidbridge --backend pico type "hello world"
93
+ hidbridge --backend pico move 40 0
94
+ hidbridge --backend pico click left
95
+
96
+ # dongle backend (BlueZ) -- needs the daemon up + root
97
+ sudo hidbridge pair-mode # advertise as a keyboard; pair from the host's Bluetooth settings
98
+ sudo hidbridge connect mini5 # (re)connect a bonded target
99
+ sudo hidbridge type "hello"
100
+ sudo hidbridge unlock # types the passcode from ~/.config/bt_hid/idevice.pin (never logged)
101
+
102
+ # named targets (device -> backend map in ~/.config/bt_hid/targets.json)
103
+ hidbridge targets add labpad pico:/dev/ttyACM0 AA:BB:CC:DD:EE:FF
104
+ hidbridge --target labpad type "hi"
105
+ ```
106
+
107
+ Run `hidbridge --help` for the full subcommand list.
108
+
109
+ ## Python API
110
+
111
+ The driving surface is two context managers that mirror the hardware: a **`HidBridge`** is the board
112
+ channel; **`bridge.connect(name)`** advertises a bonded identity, waits until it is ready, and hands
113
+ back a **`HidSession`** you drive. You pass a *location* ("/dev/ttyUSB3", a net host, "hci1", or
114
+ nothing to autodetect) -- never a board type; the board self-identifies into `bridge.info` /
115
+ `bridge.capabilities`.
116
+
117
+ ```python
118
+ from hidbridge import HidBridge
119
+
120
+ with HidBridge("/dev/ttyUSB3") as bridge, bridge.connect("laptop3") as s:
121
+ s.type("hello world")
122
+ s.key("enter")
123
+ s.move(40, 0) # relative pointer
124
+ s.click("left")
125
+ s.scroll(-3)
126
+ # leaving the `with` disconnects but keeps the bond -- no idle connections
127
+ ```
128
+
129
+ The session verbs are the whole vocabulary: `type(text)`, `key(name, mod=0)`, `move(dx, dy)`,
130
+ `click(button="left")`, `scroll(clicks)`. Who builds the report bytes is a backend detail -- an MCU
131
+ board delegates each verb to the firmware (the target slot's keyboard layout applies board-side); a
132
+ dongle builds the bytes host-side. Either way the surface is identical. "Unlock an iPad" is just
133
+ typing the passcode: `s.type(pin); s.key("enter")`.
134
+
135
+ For the single-host case, `hidbridge.session(location, device)` opens the board, connects the one
136
+ device, and yields the session (tearing both down on exit):
137
+
138
+ ```python
139
+ from hidbridge import session
140
+
141
+ with session("/dev/ttyUSB3", "laptop3") as s:
142
+ s.type("hi")
143
+ ```
144
+
145
+ Errors are typed under `HidbridgeError`: `NotBonded`, `ConnectTimeout`, `Unsupported`,
146
+ `CapacityError`, `IncompatibleFirmware`. Multiple sessions can be open at once on a board that
147
+ supports it (the ESP32 holds several identities); otherwise a second `connect()` raises. The wheel
148
+ version is `hidbridge.__version__`.
149
+
150
+ ## The passcode PIN (secret, external)
151
+
152
+ `hidbridge unlock` (and the trust-overlay helper) read the device passcode from
153
+ `~/.config/bt_hid/idevice.pin` -- a `0600` file that lives **outside** this repo and is **never
154
+ committed or logged**. Create it yourself:
155
+
156
+ ```bash
157
+ mkdir -p ~/.config/bt_hid && umask 077 && printf '%s' 1234 > ~/.config/bt_hid/idevice.pin
158
+ ```
159
+
160
+ The passcode is expanded to HID frames locally; only frames are ever sent, never the string.
161
+
162
+ ## Layout
163
+
164
+ ```
165
+ python/
166
+ pyproject.toml PEP 621 metadata, console_script `hidbridge`, ruff + pytest config
167
+ src/hidbridge/ the package
168
+ reports.py PURE HID report layer (stdlib only) -- the report-byte source of truth
169
+ controller.py backend-agnostic report driver (type/key/move/click/unlock/timeline)
170
+ conn.py HidSession: connect-on-demand context manager, readiness gate
171
+ targets.py device -> backend registry + per-bond identity slots
172
+ naming.py per-adapter runtime names (multi-daemon isolation) + slot names
173
+ adapter.py BlueZ adapter discovery / dongle selection (sysfs + dbus)
174
+ ble_hid.py the HID-over-GATT peripheral daemon (dongle backend)
175
+ central.py / central_core.py on-host BLE-central HID self-test (dongle/MCU parity)
176
+ transport_pico.py / link_pico.py Pico serial transport + link backend
177
+ transport_esp32.py / link_esp32.py ESP32 serial transport + link backend
178
+ dismiss_trust.py hands-free "Trust This Computer" overlay dismissal
179
+ cli.py the `hidbridge` command
180
+ _scripts/ reference launcher + systemd unit + pair-mode/service shell helpers
181
+ tests/ pytest suite (offline by default; hardware tests skip)
182
+ ```
183
+
184
+ ## Tests
185
+
186
+ ```bash
187
+ pytest # offline suite (hardware/live tests skip automatically)
188
+ ```
189
+
190
+ Hardware-gated tests opt in via env vars, e.g. `BT_HID_LOOPBACK=1` (on-host BLE loopback,
191
+ needs two adapters + root) or `BT_HID_CENTRAL=1` (central self-test, needs the built-in adapter
192
+ + an MCU). `*_live.py` tests need a real bonded host.
193
+
194
+ ## Lint
195
+
196
+ ```bash
197
+ ruff check .
198
+ ```
199
+
200
+ The ruleset is deliberately scoped (see `pyproject.toml`): it enforces the pyflakes correctness
201
+ rules but tolerates this codebase's dense one-liner report/parser style.
@@ -0,0 +1,171 @@
1
+ # hidbridge (Python)
2
+
3
+ The Python side of **hidbridge** -- a Linux toolbox that presents a Bluetooth-LE HID
4
+ keyboard + relative pointer to any BLE-HID (HOG) host and drives it programmatically: type text,
5
+ press keys, move/click the pointer, and enter a lock-screen passcode.
6
+
7
+ Two backends, one report layer:
8
+
9
+ - **dongle** -- the host's own **BlueZ** stack (a USB Bluetooth adapter). A small daemon
10
+ (`ble_hid`) exports the HID-over-GATT peripheral; the CLI talks to it over a Unix socket.
11
+ Needs `dbus` + `PyGObject` (system python) and usually root.
12
+ - **mcu** -- a **Raspberry Pi Pico 2 W** or an **ESP32** running the firmware in
13
+ `../firmware/`, reached over **USB serial**. The MCU owns its own BLE stack + bond database,
14
+ so it sidesteps the HID-over-GATT CCCD/Report-Reference subscription quirk that trips BlueZ with
15
+ some hosts. Needs only `pyserial`; no dbus, no daemon, no root.
16
+ - **net** -- an **Ethernet/WiFi ESP32** running that same firmware, reached over **TCP** (no USB
17
+ cable): `--backend net --host hidbridge-<nick>.local`. **TLS-PSK encrypted** and fails closed -- the
18
+ provisioned token is the pre-shared key; set it on the board over USB once with
19
+ `hidbridge net-provision`. Needs Python 3.13+ (ssl PSK). Stdlib-only (sockets + ssl); no pyserial,
20
+ dbus, daemon, or root. See [`../docs/bt-hid-network-backend.md`](../docs/bt-hid-network-backend.md).
21
+
22
+ Both backends emit the **exact same report bytes** via `hidbridge.reports` (the single source of
23
+ truth for the HID report descriptor), so behaviour is identical regardless of transport.
24
+
25
+ ## Install
26
+
27
+ **As a consumer** (released wheels are on PyPI):
28
+
29
+ ```bash
30
+ pip install "hidbridge[mcu]" # + pyserial for the Pico/ESP32 serial backend
31
+ pip install hidbridge # core only: reports + the net (TCP) backend
32
+ ```
33
+
34
+ Source, issues and firmware releases: <https://github.com/martinmunk/hidbridge>. The release flow
35
+ (wheel and firmware version independently; the git tag is the version):
36
+ [`docs/releasing.md`](https://github.com/martinmunk/hidbridge/blob/main/docs/releasing.md).
37
+
38
+ **From source** (this checkout):
39
+
40
+ ```bash
41
+ # from this directory (hidbridge/python)
42
+ pip install -e ".[mcu]" # Pico / ESP32 serial backend (pyserial)
43
+ pip install -e ".[dongle]" # BlueZ backend (dbus-python + PyGObject; system python)
44
+ pip install -e ".[dev]" # + pytest, ruff, build
45
+ ```
46
+
47
+ The **dongle** backend must run under the **system python** (`/usr/bin/python3`) because
48
+ `dbus` + `AF_BLUETOOTH` sockets are only available there. On Debian/Ubuntu install the system
49
+ deps with apt instead of pip:
50
+
51
+ ```bash
52
+ sudo apt install python3-dbus python3-gi
53
+ ```
54
+
55
+ For a venv that still sees them, create it with `--system-site-packages`
56
+ (`uv venv --system-site-packages` or `python3 -m venv --system-site-packages`).
57
+
58
+ ## Quick start
59
+
60
+ ```bash
61
+ # MCU backend (a Pico on /dev/ttyACM0) -- no root, no daemon
62
+ hidbridge --backend pico type "hello world"
63
+ hidbridge --backend pico move 40 0
64
+ hidbridge --backend pico click left
65
+
66
+ # dongle backend (BlueZ) -- needs the daemon up + root
67
+ sudo hidbridge pair-mode # advertise as a keyboard; pair from the host's Bluetooth settings
68
+ sudo hidbridge connect mini5 # (re)connect a bonded target
69
+ sudo hidbridge type "hello"
70
+ sudo hidbridge unlock # types the passcode from ~/.config/bt_hid/idevice.pin (never logged)
71
+
72
+ # named targets (device -> backend map in ~/.config/bt_hid/targets.json)
73
+ hidbridge targets add labpad pico:/dev/ttyACM0 AA:BB:CC:DD:EE:FF
74
+ hidbridge --target labpad type "hi"
75
+ ```
76
+
77
+ Run `hidbridge --help` for the full subcommand list.
78
+
79
+ ## Python API
80
+
81
+ The driving surface is two context managers that mirror the hardware: a **`HidBridge`** is the board
82
+ channel; **`bridge.connect(name)`** advertises a bonded identity, waits until it is ready, and hands
83
+ back a **`HidSession`** you drive. You pass a *location* ("/dev/ttyUSB3", a net host, "hci1", or
84
+ nothing to autodetect) -- never a board type; the board self-identifies into `bridge.info` /
85
+ `bridge.capabilities`.
86
+
87
+ ```python
88
+ from hidbridge import HidBridge
89
+
90
+ with HidBridge("/dev/ttyUSB3") as bridge, bridge.connect("laptop3") as s:
91
+ s.type("hello world")
92
+ s.key("enter")
93
+ s.move(40, 0) # relative pointer
94
+ s.click("left")
95
+ s.scroll(-3)
96
+ # leaving the `with` disconnects but keeps the bond -- no idle connections
97
+ ```
98
+
99
+ The session verbs are the whole vocabulary: `type(text)`, `key(name, mod=0)`, `move(dx, dy)`,
100
+ `click(button="left")`, `scroll(clicks)`. Who builds the report bytes is a backend detail -- an MCU
101
+ board delegates each verb to the firmware (the target slot's keyboard layout applies board-side); a
102
+ dongle builds the bytes host-side. Either way the surface is identical. "Unlock an iPad" is just
103
+ typing the passcode: `s.type(pin); s.key("enter")`.
104
+
105
+ For the single-host case, `hidbridge.session(location, device)` opens the board, connects the one
106
+ device, and yields the session (tearing both down on exit):
107
+
108
+ ```python
109
+ from hidbridge import session
110
+
111
+ with session("/dev/ttyUSB3", "laptop3") as s:
112
+ s.type("hi")
113
+ ```
114
+
115
+ Errors are typed under `HidbridgeError`: `NotBonded`, `ConnectTimeout`, `Unsupported`,
116
+ `CapacityError`, `IncompatibleFirmware`. Multiple sessions can be open at once on a board that
117
+ supports it (the ESP32 holds several identities); otherwise a second `connect()` raises. The wheel
118
+ version is `hidbridge.__version__`.
119
+
120
+ ## The passcode PIN (secret, external)
121
+
122
+ `hidbridge unlock` (and the trust-overlay helper) read the device passcode from
123
+ `~/.config/bt_hid/idevice.pin` -- a `0600` file that lives **outside** this repo and is **never
124
+ committed or logged**. Create it yourself:
125
+
126
+ ```bash
127
+ mkdir -p ~/.config/bt_hid && umask 077 && printf '%s' 1234 > ~/.config/bt_hid/idevice.pin
128
+ ```
129
+
130
+ The passcode is expanded to HID frames locally; only frames are ever sent, never the string.
131
+
132
+ ## Layout
133
+
134
+ ```
135
+ python/
136
+ pyproject.toml PEP 621 metadata, console_script `hidbridge`, ruff + pytest config
137
+ src/hidbridge/ the package
138
+ reports.py PURE HID report layer (stdlib only) -- the report-byte source of truth
139
+ controller.py backend-agnostic report driver (type/key/move/click/unlock/timeline)
140
+ conn.py HidSession: connect-on-demand context manager, readiness gate
141
+ targets.py device -> backend registry + per-bond identity slots
142
+ naming.py per-adapter runtime names (multi-daemon isolation) + slot names
143
+ adapter.py BlueZ adapter discovery / dongle selection (sysfs + dbus)
144
+ ble_hid.py the HID-over-GATT peripheral daemon (dongle backend)
145
+ central.py / central_core.py on-host BLE-central HID self-test (dongle/MCU parity)
146
+ transport_pico.py / link_pico.py Pico serial transport + link backend
147
+ transport_esp32.py / link_esp32.py ESP32 serial transport + link backend
148
+ dismiss_trust.py hands-free "Trust This Computer" overlay dismissal
149
+ cli.py the `hidbridge` command
150
+ _scripts/ reference launcher + systemd unit + pair-mode/service shell helpers
151
+ tests/ pytest suite (offline by default; hardware tests skip)
152
+ ```
153
+
154
+ ## Tests
155
+
156
+ ```bash
157
+ pytest # offline suite (hardware/live tests skip automatically)
158
+ ```
159
+
160
+ Hardware-gated tests opt in via env vars, e.g. `BT_HID_LOOPBACK=1` (on-host BLE loopback,
161
+ needs two adapters + root) or `BT_HID_CENTRAL=1` (central self-test, needs the built-in adapter
162
+ + an MCU). `*_live.py` tests need a real bonded host.
163
+
164
+ ## Lint
165
+
166
+ ```bash
167
+ ruff check .
168
+ ```
169
+
170
+ The ruleset is deliberately scoped (see `pyproject.toml`): it enforces the pyflakes correctness
171
+ rules but tolerates this codebase's dense one-liner report/parser style.
@@ -0,0 +1,82 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hidbridge"
7
+ # Version is single-sourced from src/hidbridge/_version.py (baseline 1.0.0). The `wheel/release/<X.Y.Z>`
8
+ # release build rewrites that file from the git tag before `uv build` -- the tag is the source of
9
+ # truth. See docs/releasing.md.
10
+ dynamic = ["version"]
11
+ description = "Drive a Bluetooth-HID target (iPad / iPhone / any host) from Linux -- keyboard + pointer over the host BlueZ stack or a Pico 2W / ESP32 serial-attached BLE peripheral"
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ authors = [{ name = "Martin Munk Hansen" }]
17
+ keywords = ["bluetooth", "ble", "hid", "hog", "keyboard", "mouse", "ipad", "bluez", "pico", "esp32"]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Human Interface Device (HID)",
25
+ ]
26
+
27
+ # Core runtime is stdlib-only for the PURE report layer (hidbridge.reports).
28
+ # Backends pull extra deps -- install the extra(s) you need:
29
+ # pip install "hidbridge[mcu]" # Pico / ESP32 over USB serial (pyserial)
30
+ # pip install "hidbridge[dongle]" # the host's own BlueZ stack (dbus + PyGObject)
31
+ # On Debian/Ubuntu the dongle backend deps are usually the apt packages
32
+ # python3-dbus + python3-gi (system-python only); the [dongle] extra is provided
33
+ # for venvs built with --system-site-packages.
34
+ dependencies = []
35
+
36
+ [project.optional-dependencies]
37
+ mcu = ["pyserial>=3.5"]
38
+ dongle = ["dbus-python>=1.2", "PyGObject>=3.40"]
39
+ dev = ["pytest>=7.0", "ruff>=0.4", "build>=1.0", "pyserial>=3.5"]
40
+
41
+ [project.scripts]
42
+ hidbridge = "hidbridge.cli:main"
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/martinmunk/hidbridge"
46
+ Repository = "https://github.com/martinmunk/hidbridge"
47
+ Issues = "https://github.com/martinmunk/hidbridge/issues"
48
+
49
+ [tool.hatch.version]
50
+ # Single-source the version from the package (release build rewrites this file from the git tag).
51
+ path = "src/hidbridge/_version.py"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/hidbridge"]
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ include = ["src/hidbridge", "tests", "README.md", "LICENSE", "pyproject.toml"]
58
+
59
+ # --- lint: mirrors the source project's scoped ruleset (dense bit-parser style is deliberate) ---
60
+ [tool.ruff]
61
+ target-version = "py311"
62
+ line-length = 100
63
+
64
+ [tool.ruff.lint]
65
+ # EXPLICIT select (not extend-select): pin the exact rule scope so a ruff release that BROADENS its
66
+ # default set (0.16 did -- it started pulling RUF/S/SIM/UP/I/... and lit up 160 house-style "errors")
67
+ # cannot silently expand what CI enforces. This is pyflakes correctness (F) + the E4/E7/E9 + W subset,
68
+ # and nothing else; the dense one-liner report/bit style stays tolerated (see the ignore list below).
69
+ select = ["E4", "E7", "E9", "F", "W"]
70
+ ignore = [
71
+ "E701", "E702", "E703", # multi-statement one-liners: deliberate in the report/bit code
72
+ "E401", # `import a,b,c`: deliberate house style
73
+ "E402", # imports after a guarded block
74
+ "E731", "E741", # lambda-assign / ambiguous names (l, I, O): harmless here
75
+ "F841", # unused locals often capture a parsed field with a side effect
76
+ ]
77
+
78
+ [tool.pytest.ini_options]
79
+ testpaths = ["tests"]
80
+ markers = [
81
+ "live: requires real hardware (BT adapter / MCU / iPad); deselected by default",
82
+ ]
@@ -0,0 +1,42 @@
1
+ """bt_hid -- Linux Bluetooth-HID peripheral toolbox (keyboard + mouse to an iPad/iPhone).
2
+
3
+ Import `bt_hid.reports` for the PURE report layer (stdlib only). The emulator / adapter modules
4
+ pull in bluetooth deps (AF_BLUETOOTH sockets, dbus) that only exist under the system python, so
5
+ they are NOT imported here -- import them explicitly when needed.
6
+
7
+ `HidBridge` is the driving entry point (`with HidBridge(...) as bridge: bridge.connect(...) as s`),
8
+ with `session()` a single-host shorthand. The `bridge` module imports only the stdlib-clean
9
+ controller/reports layers at load time (the real serial/net/dongle board drivers are pulled in
10
+ lazily), so `import hidbridge` stays free of bluetooth/serial deps.
11
+ """
12
+
13
+ from ._version import __version__ # noqa: E402
14
+ from .bridge import ( # noqa: E402
15
+ CapacityError,
16
+ ConnectTimeout,
17
+ HidBridge,
18
+ HidbridgeError,
19
+ HidSession,
20
+ IncompatibleFirmware,
21
+ NotBonded,
22
+ ProbeError,
23
+ Unsupported,
24
+ session,
25
+ )
26
+
27
+
28
+ def discover():
29
+ """List plugged-in hidbridge boards: [{port, board, fw, device}] (vendor-gated + handshake).
30
+
31
+ Lazy-imports the serial drivers so `import hidbridge` stays free of serial/dbus deps.
32
+ """
33
+ from .board import discover as _discover
34
+ return _discover()
35
+
36
+
37
+ __all__ = [
38
+ "__version__",
39
+ "HidBridge", "HidSession", "session", "discover",
40
+ "HidbridgeError", "NotBonded", "ConnectTimeout", "Unsupported", "CapacityError",
41
+ "IncompatibleFirmware", "ProbeError",
42
+ ]
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=bt_hid BLE HID keyboard+mouse peripheral on adapter %i (dongle only)
3
+ Documentation=file://__REPO__/tools/bt_hid/README.md
4
+ After=bluetooth.service
5
+ Wants=bluetooth.service
6
+
7
+ [Service]
8
+ Type=simple
9
+ # Instance %i is the ADAPTER id (hciN / MAC / USB-path). Each instance pins ONE dongle and gets its
10
+ # OWN per-adapter control socket + D-Bus objects + advertised name (see tools/bt_hid/naming.py), so
11
+ # you can run bt-hid@hci1 and bt-hid@hci2 side by side to two different iPads. Do NOT set a fixed
12
+ # BT_HID_SOCK here -- that would force every instance onto one socket and re-introduce the collision.
13
+ ExecStart=/usr/bin/python3 __REPO__/tools/bt_hid/bt_hid --adapter %i pair-mode
14
+ Restart=on-failure
15
+ RestartSec=2
16
+ # The daemon refuses the built-in adapter by design; it only ever configures the dongle.
17
+
18
+ [Install]
19
+ WantedBy=multi-user.target
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/python3
2
+ """bt_hid launcher -- MUST run under the system python (needs dbus + AF_BLUETOOTH; uv's CPython
3
+ lacks both). Thin shim onto bt_hid.cli.main().
4
+ """
5
+ import os
6
+ import sys
7
+
8
+ from hidbridge.cli import main # noqa: E402
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Bring the USB Bluetooth dongle up as a discoverable BLE HID keyboard so an iPad/iPhone can pair.
3
+ # Runs the daemon in the foreground (Ctrl-C to stop). Uses sudo (BlueZ adapter config needs root).
4
+ # Scoped to the DONGLE only; refuses the built-in adapter unless BT_HID_FORCE_BUILTIN=1.
5
+ #
6
+ # Multi-adapter: set BT_HID_ADAPTER=hciN to pin a specific dongle (default: auto-detect the sole
7
+ # dongle -- errors if ambiguous; run `tools/bt_hid/bt_hid preflight`). The advertised name defaults
8
+ # to HIDBRIDGE-dongle-<hciN> (per-adapter) unless BT_HID_NAME is set.
9
+ set -euo pipefail
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ # --adapter is a PARENT (pre-subcommand) option; --name/--force-builtin are pair-mode options.
12
+ PARENT=()
13
+ [ -n "${BT_HID_ADAPTER:-}" ] && PARENT+=(--adapter "$BT_HID_ADAPTER")
14
+ SUB=(pair-mode)
15
+ [ -n "${BT_HID_NAME:-}" ] && SUB+=(--name "$BT_HID_NAME")
16
+ [ "${BT_HID_FORCE_BUILTIN:-0}" = "1" ] && SUB+=(--force-builtin)
17
+ echo "Starting BLE HID keyboard on the dongle (${BT_HID_ADAPTER:-auto}); pair from the iPad now."
18
+ exec sudo -n BT_HID_ADAPTER="${BT_HID_ADAPTER:-}" BT_HID_NAME="${BT_HID_NAME:-}" \
19
+ /usr/bin/python3 "$HERE/bt_hid" "${PARENT[@]}" "${SUB[@]}"
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Manage the bt_hid BLE HID daemon as a systemd unit (persists across shells + reboots).
3
+ # Branch-tracked (unit template = bt-hid.service.in) so it is reviewable + cleanly removable --
4
+ # NOT a hand-installed artifact. The unit is a templated instance bt-hid@<ADAPTER>.service: the
5
+ # instance parameter is the ADAPTER id (hciN), so you can run one daemon PER dongle concurrently
6
+ # (bt-hid@hci1 to iPadA + bt-hid@hci2 to iPadB). Each instance gets its own per-adapter control
7
+ # socket + D-Bus objects + advertised name (see tools/bt_hid/naming.py).
8
+ #
9
+ # Usage:
10
+ # sudo tools/bt_hid/service.sh install [ADAPTER] # install + enable + start (default: auto dongle)
11
+ # sudo tools/bt_hid/service.sh start [ADAPTER]
12
+ # sudo tools/bt_hid/service.sh stop [ADAPTER]
13
+ # sudo tools/bt_hid/service.sh status [ADAPTER]
14
+ # sudo tools/bt_hid/service.sh logs [ADAPTER]
15
+ # sudo tools/bt_hid/service.sh uninstall [ADAPTER] # stop + disable + remove the unit file
16
+ #
17
+ # ADAPTER defaults to the auto-resolved single dongle (errors if ambiguous -- run `bt_hid preflight`).
18
+ # For a quick throwaway run without installing a persistent unit, use: tools/bt_hid/pair-mode.sh
19
+ set -euo pipefail
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ REPO="$(cd "$HERE/../.." && pwd)"
22
+ UNIT_TEMPLATE="/etc/systemd/system/bt-hid@.service"
23
+ CMD="${1:-status}"
24
+ # Resolve the adapter instance: explicit arg, else the auto-detected sole dongle's hciN.
25
+ default_adapter() {
26
+ /usr/bin/python3 "$HERE/bt_hid" adapters 2>/dev/null | awk '/<-- USED/{print $1; exit}'
27
+ }
28
+ ADAPTER="${2:-$(default_adapter || true)}"
29
+ [ -n "$ADAPTER" ] || { echo "no adapter given and none auto-resolved; run: $HERE/bt_hid preflight"; exit 2; }
30
+ INSTANCE="bt-hid@${ADAPTER}.service"
31
+
32
+ need_root() { [ "$(id -u)" = "0" ] || { echo "run with sudo"; exit 1; }; }
33
+
34
+ install_unit() {
35
+ need_root
36
+ sed "s#__REPO__#${REPO}#g" "$HERE/bt-hid.service.in" > "$UNIT_TEMPLATE"
37
+ systemctl daemon-reload
38
+ echo "installed $UNIT_TEMPLATE (REPO=$REPO)"
39
+ }
40
+
41
+ case "$CMD" in
42
+ install)
43
+ install_unit
44
+ systemctl enable --now "$INSTANCE"
45
+ systemctl --no-pager status "$INSTANCE" | head -6 || true
46
+ ;;
47
+ start) need_root; [ -f "$UNIT_TEMPLATE" ] || install_unit; systemctl start "$INSTANCE"; systemctl --no-pager status "$INSTANCE" | head -5 || true ;;
48
+ stop) need_root; systemctl stop "$INSTANCE" || true ;;
49
+ status) systemctl --no-pager status "$INSTANCE" | head -8 || true ;;
50
+ logs) journalctl -u "$INSTANCE" --no-pager -n "${3:-40}" ;;
51
+ uninstall)
52
+ need_root
53
+ systemctl disable --now "$INSTANCE" 2>/dev/null || true
54
+ rm -f "$UNIT_TEMPLATE"
55
+ systemctl daemon-reload
56
+ echo "removed $UNIT_TEMPLATE"
57
+ ;;
58
+ *) echo "usage: $0 {install|start|stop|status|logs|uninstall} [ADAPTER]"; exit 2 ;;
59
+ esac