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.
- hidbridge-1.2.3/.gitignore +37 -0
- hidbridge-1.2.3/LICENSE +21 -0
- hidbridge-1.2.3/PKG-INFO +201 -0
- hidbridge-1.2.3/README.md +171 -0
- hidbridge-1.2.3/pyproject.toml +82 -0
- hidbridge-1.2.3/src/hidbridge/__init__.py +42 -0
- hidbridge-1.2.3/src/hidbridge/_scripts/bt-hid.service.in +19 -0
- hidbridge-1.2.3/src/hidbridge/_scripts/bt_hid +11 -0
- hidbridge-1.2.3/src/hidbridge/_scripts/pair-mode.sh +19 -0
- hidbridge-1.2.3/src/hidbridge/_scripts/service.sh +59 -0
- hidbridge-1.2.3/src/hidbridge/_version.py +12 -0
- hidbridge-1.2.3/src/hidbridge/adapter.py +268 -0
- hidbridge-1.2.3/src/hidbridge/ble_hid.py +669 -0
- hidbridge-1.2.3/src/hidbridge/board.py +563 -0
- hidbridge-1.2.3/src/hidbridge/bridge.py +280 -0
- hidbridge-1.2.3/src/hidbridge/central.py +1122 -0
- hidbridge-1.2.3/src/hidbridge/central_core.py +690 -0
- hidbridge-1.2.3/src/hidbridge/cli.py +779 -0
- hidbridge-1.2.3/src/hidbridge/conn.py +380 -0
- hidbridge-1.2.3/src/hidbridge/controller.py +195 -0
- hidbridge-1.2.3/src/hidbridge/dismiss_trust.py +362 -0
- hidbridge-1.2.3/src/hidbridge/link_esp32.py +77 -0
- hidbridge-1.2.3/src/hidbridge/link_net.py +51 -0
- hidbridge-1.2.3/src/hidbridge/link_pico.py +81 -0
- hidbridge-1.2.3/src/hidbridge/loopback_selftest.py +277 -0
- hidbridge-1.2.3/src/hidbridge/name_probe.py +157 -0
- hidbridge-1.2.3/src/hidbridge/naming.py +98 -0
- hidbridge-1.2.3/src/hidbridge/reports.py +455 -0
- hidbridge-1.2.3/src/hidbridge/rpc.py +445 -0
- hidbridge-1.2.3/src/hidbridge/serial_link.py +266 -0
- hidbridge-1.2.3/src/hidbridge/socket_link.py +233 -0
- hidbridge-1.2.3/src/hidbridge/targets.py +331 -0
- hidbridge-1.2.3/src/hidbridge/transport_esp32.py +277 -0
- hidbridge-1.2.3/src/hidbridge/transport_net.py +141 -0
- hidbridge-1.2.3/src/hidbridge/transport_pico.py +234 -0
- hidbridge-1.2.3/tests/conftest.py +9 -0
- hidbridge-1.2.3/tests/test_bt_hid_adapter.py +64 -0
- hidbridge-1.2.3/tests/test_bt_hid_advert_name_live.py +139 -0
- hidbridge-1.2.3/tests/test_bt_hid_board.py +654 -0
- hidbridge-1.2.3/tests/test_bt_hid_bridge.py +286 -0
- hidbridge-1.2.3/tests/test_bt_hid_central_core.py +511 -0
- hidbridge-1.2.3/tests/test_bt_hid_central_selftest.py +183 -0
- hidbridge-1.2.3/tests/test_bt_hid_cli.py +128 -0
- hidbridge-1.2.3/tests/test_bt_hid_controller.py +120 -0
- hidbridge-1.2.3/tests/test_bt_hid_dismiss_trust.py +176 -0
- hidbridge-1.2.3/tests/test_bt_hid_dongle_link.py +163 -0
- hidbridge-1.2.3/tests/test_bt_hid_dual_identity_live.py +172 -0
- hidbridge-1.2.3/tests/test_bt_hid_esp32.py +328 -0
- hidbridge-1.2.3/tests/test_bt_hid_esp32_noreset.py +155 -0
- hidbridge-1.2.3/tests/test_bt_hid_esp32_portsafety.py +50 -0
- hidbridge-1.2.3/tests/test_bt_hid_gatt_interfaces.py +43 -0
- hidbridge-1.2.3/tests/test_bt_hid_layout_drag_live.py +205 -0
- hidbridge-1.2.3/tests/test_bt_hid_layouts.py +183 -0
- hidbridge-1.2.3/tests/test_bt_hid_link_mcu.py +38 -0
- hidbridge-1.2.3/tests/test_bt_hid_logging.py +184 -0
- hidbridge-1.2.3/tests/test_bt_hid_loopback.py +38 -0
- hidbridge-1.2.3/tests/test_bt_hid_mini5_bond_live.py +102 -0
- hidbridge-1.2.3/tests/test_bt_hid_multi_adapter.py +118 -0
- hidbridge-1.2.3/tests/test_bt_hid_multi_daemon_live.py +86 -0
- hidbridge-1.2.3/tests/test_bt_hid_naming.py +43 -0
- hidbridge-1.2.3/tests/test_bt_hid_naming_live.py +159 -0
- hidbridge-1.2.3/tests/test_bt_hid_net.py +182 -0
- hidbridge-1.2.3/tests/test_bt_hid_ota_live.py +164 -0
- hidbridge-1.2.3/tests/test_bt_hid_per_bond_identity.py +173 -0
- hidbridge-1.2.3/tests/test_bt_hid_readiness_live.py +61 -0
- hidbridge-1.2.3/tests/test_bt_hid_ready_signal.py +91 -0
- hidbridge-1.2.3/tests/test_bt_hid_reports.py +382 -0
- hidbridge-1.2.3/tests/test_bt_hid_rpc.py +505 -0
- hidbridge-1.2.3/tests/test_bt_hid_serial_net_live.py +168 -0
- hidbridge-1.2.3/tests/test_bt_hid_target_switch_live.py +60 -0
- hidbridge-1.2.3/tests/test_bt_hid_targets.py +206 -0
- hidbridge-1.2.3/tests/test_bt_hid_tls.py +113 -0
- hidbridge-1.2.3/tests/test_bt_hid_transport_pico.py +236 -0
- 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/
|
hidbridge-1.2.3/LICENSE
ADDED
|
@@ -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.
|
hidbridge-1.2.3/PKG-INFO
ADDED
|
@@ -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
|