hidbridge 1.2.3__py3-none-any.whl

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/__init__.py ADDED
@@ -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
hidbridge/_version.py ADDED
@@ -0,0 +1,12 @@
1
+ """Single source of the wheel version.
2
+
3
+ BASELINE = the committed default (an untagged `uv build` produces this). The release builds
4
+ (.forgejo/workflows/release-wheel.yml for the private registry, .github/workflows/pypi.yml for
5
+ public PyPI) REWRITE this line from the `wheel/release/<X.Y.Z>` git tag before building, so the
6
+ published wheel's version derives from the tag -- the tag is the source of truth.
7
+ The firmware version is SEPARATE (baked via a PlatformIO flag; see firmware/*/src/main.cpp), and the
8
+ wire-compat integer `proto` is separate again (see bridge.MIN_FIRMWARE_PROTO). SemVer; keep it a plain
9
+ release string here so `hatchling` reads it as the package version. See docs/releasing.md.
10
+ """
11
+
12
+ __version__ = "1.2.3"
hidbridge/adapter.py ADDED
@@ -0,0 +1,268 @@
1
+ """bt_hid adapter selection -- identify the USB dongle, REFUSE the wsb built-in adapter.
2
+
3
+ HARD REQUIREMENT (the maintainer): this toolbox must drive the USB Bluetooth DONGLE plugged into the hub,
4
+ never the laptop's built-in adapter (he may be using the built-in for his own BT peripherals). The
5
+ classifier below distinguishes them by the two most reliable, general signals:
6
+
7
+ 1. USB `removable` sysfs attribute: the built-in radio is `fixed`; a hub-plugged dongle is
8
+ `removable` or `unknown` (it hangs off an external hub port, not a soldered root port).
9
+ 2. USB vendor: Intel (0x8087) and the common soldered-in Broadcom (0x0a5c) IDs are built-in
10
+ combo radios -> treated as built-in regardless of `removable`.
11
+
12
+ Any adapter that is neither `fixed` nor a known built-in vendor is a dongle candidate.
13
+
14
+ Selection policy:
15
+ * default: auto-pick the single dongle; error out if there is only the built-in.
16
+ * `prefer=<hciN | MAC>`: pin a specific adapter. Pinning the built-in RAISES unless
17
+ `allow_builtin=True` (documented escape hatch), so the tool never silently grabs hci0.
18
+
19
+ The detection reads sysfs only (no bluetooth sockets) so `classify_adapter` / `select_adapter` are
20
+ pure and unit-tested offline; `list_adapters()` does the live sysfs walk.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import subprocess
27
+
28
+ # USB vendor IDs of radios that are soldered-in built-in combo cards (never a plug-in dongle).
29
+ _BUILTIN_USB_VENDORS = {"8087", "0a5c"} # Intel, Broadcom (common laptop BT/WiFi combos)
30
+
31
+ _SYS_BT = "/sys/class/bluetooth"
32
+
33
+
34
+ class NoDongleError(RuntimeError):
35
+ """No USB Bluetooth dongle found (only the built-in adapter is present)."""
36
+
37
+
38
+ class BuiltinRefusedError(RuntimeError):
39
+ """The pinned adapter is the built-in; refusing unless allow_builtin=True."""
40
+
41
+
42
+ class AmbiguousDongleError(RuntimeError):
43
+ """A dongle id matched MORE THAN ONE adapter (e.g. the shared UB500 BD address); the caller
44
+ must disambiguate with a unique id (hciN or the USB path)."""
45
+
46
+
47
+ def classify_adapter(info: dict) -> str:
48
+ """'builtin' or 'dongle' for one adapter-info dict (see list_adapters for the shape)."""
49
+ vendor = (info.get("usb_vendor") or "").lower()
50
+ if vendor in _BUILTIN_USB_VENDORS:
51
+ return "builtin"
52
+ if (info.get("removable") or "").lower() == "fixed":
53
+ return "builtin"
54
+ return "dongle"
55
+
56
+
57
+ def _read(path: str) -> str:
58
+ try:
59
+ with open(path) as f:
60
+ return f.read().strip()
61
+ except OSError:
62
+ return ""
63
+
64
+
65
+ def _usb_device_dir(hci: str) -> str:
66
+ """Walk up from /sys/class/bluetooth/<hci>/device to the enclosing USB device dir."""
67
+ start = os.path.realpath(os.path.join(_SYS_BT, hci, "device"))
68
+ cur = start
69
+ while cur and cur != "/":
70
+ if os.path.exists(os.path.join(cur, "idVendor")):
71
+ return cur
72
+ cur = os.path.dirname(cur)
73
+ return ""
74
+
75
+
76
+ def _hci_address(hci: str) -> str:
77
+ addr = _read(os.path.join(_SYS_BT, hci, "address"))
78
+ if not addr:
79
+ # sysfs `address` can be empty without root; fall back to hciconfig.
80
+ try:
81
+ out = subprocess.run(["hciconfig", hci], capture_output=True, text=True).stdout
82
+ for tok in out.split():
83
+ if len(tok) == 17 and tok.count(":") == 5:
84
+ addr = tok
85
+ break
86
+ except OSError:
87
+ pass
88
+ return addr.upper()
89
+
90
+
91
+ def list_adapters() -> list[dict]:
92
+ """Enumerate all local HCI adapters with the USB attributes we classify on."""
93
+ out: list[dict] = []
94
+ try:
95
+ names = sorted(os.listdir(_SYS_BT))
96
+ except OSError:
97
+ names = []
98
+ for hci in names:
99
+ if not hci.startswith("hci"):
100
+ continue
101
+ usb = _usb_device_dir(hci)
102
+ info = {
103
+ "hci": hci,
104
+ "address": _hci_address(hci),
105
+ "usb_vendor": _read(os.path.join(usb, "idVendor")) if usb else "",
106
+ "usb_product": _read(os.path.join(usb, "idProduct")) if usb else "",
107
+ "product_name": _read(os.path.join(usb, "product")) if usb else "",
108
+ "removable": _read(os.path.join(usb, "removable")) if usb else "",
109
+ "usb_path": usb,
110
+ }
111
+ info["kind"] = classify_adapter(info)
112
+ out.append(info)
113
+ return out
114
+
115
+
116
+ def _matches(info: dict, pin: str) -> bool:
117
+ pin_l = pin.lower()
118
+ return info["hci"].lower() == pin_l or (info.get("address", "").lower() == pin_l)
119
+
120
+
121
+ def select_adapter(adapters: list[dict] | None = None, prefer: str | None = None,
122
+ allow_builtin: bool = False) -> dict:
123
+ """Pick the adapter to drive.
124
+
125
+ prefer: hciN or MAC to pin. Pinning the built-in raises BuiltinRefusedError unless
126
+ allow_builtin. With no prefer, auto-pick the sole dongle (NoDongleError if none).
127
+ """
128
+ if adapters is None:
129
+ adapters = list_adapters()
130
+
131
+ if prefer:
132
+ pinned = next((a for a in adapters if _matches(a, prefer)), None)
133
+ if pinned is None:
134
+ raise NoDongleError(f"pinned adapter {prefer!r} not found among "
135
+ f"{[a['hci'] for a in adapters]}")
136
+ if classify_adapter(pinned) == "builtin" and not allow_builtin:
137
+ raise BuiltinRefusedError(
138
+ f"refusing to use built-in adapter {pinned['hci']} ({prefer}); "
139
+ f"pass the dongle adapter, or set allow_builtin/--force-builtin to override")
140
+ return pinned
141
+
142
+ dongles = [a for a in adapters if classify_adapter(a) == "dongle"]
143
+ if not dongles:
144
+ raise NoDongleError(
145
+ "no USB Bluetooth dongle found (only the built-in adapter is present); "
146
+ "plug in the dongle or pin it with BT_HID_ADAPTER=hciN")
147
+ if len(dongles) > 1:
148
+ # deterministic: lowest hci index; operator can pin to disambiguate.
149
+ dongles.sort(key=lambda a: a["hci"])
150
+ return dongles[0]
151
+
152
+
153
+ def resolve(prefer_env: str | None = None, allow_builtin: bool = False) -> dict:
154
+ """Convenience: resolve the adapter honouring the BT_HID_ADAPTER env if prefer_env is None."""
155
+ prefer = prefer_env if prefer_env is not None else os.environ.get("BT_HID_ADAPTER")
156
+ return select_adapter(prefer=prefer or None, allow_builtin=allow_builtin)
157
+
158
+
159
+ # --------------------------------------------------------------------------- #
160
+ # Multi-adapter support: stable slug, duplicate-address detection, flexible id resolution
161
+ # --------------------------------------------------------------------------- #
162
+ def adapter_slug(info: dict) -> str:
163
+ """The stable-within-a-boot key we isolate a daemon's runtime artifacts on.
164
+
165
+ We use `hciN`: it is DISTINCT per physical adapter even when two dongles SHARE a BD address
166
+ (the UB500 defect), and every BlueZ object is already scoped `/org/bluez/hciN`. See naming.py.
167
+ """
168
+ return info.get("hci", "") or "default"
169
+
170
+
171
+ def duplicate_address_groups(adapters: list[dict] | None = None) -> dict[str, list[str]]:
172
+ """Return {BD_ADDRESS: [hciA, hciB, ...]} for every address shared by >1 present adapter.
173
+
174
+ This is the UB500 trap: two dongles with the SAME public BD address confuse BlueZ + the kernel
175
+ routing (both index partly on BD_ADDR), so the iPad may never see a stable HID peripheral. The
176
+ address is NOT runtime-reprogrammable, so the only safe answer is DETECT + WARN + do not run
177
+ both at once. An empty dict means every adapter has a unique address.
178
+ """
179
+ if adapters is None:
180
+ adapters = list_adapters()
181
+ by_addr: dict[str, list[str]] = {}
182
+ for a in adapters:
183
+ addr = (a.get("address") or "").upper()
184
+ if not addr or addr == "00:00:00:00:00:00":
185
+ continue
186
+ by_addr.setdefault(addr, []).append(a.get("hci", ""))
187
+ return {addr: sorted(hcis) for addr, hcis in by_addr.items() if len(hcis) > 1}
188
+
189
+
190
+ def _fuzzy_matches(info: dict, dongle_id: str) -> bool:
191
+ """Beyond hci/MAC (see _matches): also match a USB-path substring or product-name substring."""
192
+ d = dongle_id.lower()
193
+ return (d in (info.get("usb_path") or "").lower()
194
+ or d in (info.get("product_name") or "").lower())
195
+
196
+
197
+ def resolve_dongle(dongle_id: str | None = None, adapters: list[dict] | None = None,
198
+ allow_builtin: bool = False) -> dict:
199
+ """Resolve a caller-supplied dongle id to exactly ONE adapter, or raise.
200
+
201
+ `dongle_id` accepts (in priority order): hciN, a full BD address, a USB-path substring, or a
202
+ product-name substring. None/"" auto-picks the sole dongle (select_adapter semantics).
203
+
204
+ Raises NoDongleError (no match), AmbiguousDongleError (the id matched several adapters -- the
205
+ classic case is targeting the SHARED UB500 address, which hits both UB500 dongles), or
206
+ BuiltinRefusedError (matched the built-in and allow_builtin is False).
207
+ """
208
+ if adapters is None:
209
+ adapters = list_adapters()
210
+ if not dongle_id:
211
+ return select_adapter(adapters, allow_builtin=allow_builtin)
212
+
213
+ matched = [a for a in adapters if _matches(a, dongle_id)]
214
+ if not matched:
215
+ matched = [a for a in adapters if _fuzzy_matches(a, dongle_id)]
216
+ if not matched:
217
+ raise NoDongleError(
218
+ f"dongle id {dongle_id!r} matched no adapter among {[a['hci'] for a in adapters]}")
219
+ if len(matched) > 1:
220
+ raise AmbiguousDongleError(
221
+ f"dongle id {dongle_id!r} matched {len(matched)} adapters "
222
+ f"{[a['hci'] for a in matched]} (shared BD address?); pin a unique id: use the hciN or "
223
+ f"the USB path")
224
+ picked = matched[0]
225
+ if classify_adapter(picked) == "builtin" and not allow_builtin:
226
+ raise BuiltinRefusedError(
227
+ f"refusing to use built-in adapter {picked['hci']} ({dongle_id}); pass the dongle, "
228
+ f"or set allow_builtin/--force-builtin to override")
229
+ return picked
230
+
231
+
232
+ def preflight(adapters: list[dict] | None = None) -> dict:
233
+ """Audit all local adapters + surface the multi-adapter hazards BEFORE a daemon starts.
234
+
235
+ Returns a report dict:
236
+ {"adapters": [...], "dongles": [hci,...], "builtin": [hci,...],
237
+ "duplicate_address_groups": {addr: [hci,...]}, "warnings": [str,...], "ok": bool}
238
+ `ok` is False iff there is a same-address collision (the routing-corrupting UB500 trap). Pure
239
+ over the supplied adapter list so it unit-tests offline.
240
+ """
241
+ if adapters is None:
242
+ adapters = list_adapters()
243
+ dupes = duplicate_address_groups(adapters)
244
+ warnings: list[str] = []
245
+ for addr, hcis in sorted(dupes.items()):
246
+ warnings.append(
247
+ f"DUPLICATE BD ADDRESS {addr} shared by {', '.join(hcis)} -- BlueZ/kernel routing can "
248
+ f"corrupt and the iPad may never recognise the HID dongle. Run ONLY ONE of these at a "
249
+ f"time (the address is not runtime-reprogrammable; known TP-Link UB500 V1 defect).")
250
+ dongles = [a["hci"] for a in adapters if classify_adapter(a) == "dongle"]
251
+ builtin = [a["hci"] for a in adapters if classify_adapter(a) == "builtin"]
252
+ if not dongles:
253
+ warnings.append("no USB Bluetooth dongle present (only the built-in adapter, which is "
254
+ "REFUSED as a HID peripheral).")
255
+ return {
256
+ "adapters": adapters,
257
+ "dongles": dongles,
258
+ "builtin": builtin,
259
+ "duplicate_address_groups": dupes,
260
+ "warnings": warnings,
261
+ "ok": not dupes,
262
+ }
263
+
264
+
265
+ def hciconfig(hci: str, *args: str) -> str:
266
+ """Run hciconfig scoped to a single adapter and return stdout (best-effort)."""
267
+ cmd = ["hciconfig", hci, *args]
268
+ return subprocess.run(cmd, capture_output=True, text=True).stdout