Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ clean — see `TECHNICAL_NOTES.md`.

## Features

- **Keyboard + mouse bridge** — cross a screen edge to control the iPad; a
keymap remaps modifiers (Alt→Cmd, Ctrl+C→Cmd+C, …).
- **Keyboard + mouse bridge** — cross a screen edge to control the iPad; the
default keymap sends Win→Command and Alt→Globe (Consumer `0x029D`), while
overrides retain familiar shortcuts such as Ctrl+C→Cmd+C.
- **Bluetooth audio routing** — send Windows audio to BT earbuds through the
same radio, with the normal Windows volume slider and an in-app L/R balance.
- **Two-way clipboard** — plain **Ctrl+C / Ctrl+V** keep both machines in sync
Expand Down
5 changes: 5 additions & 0 deletions TECHNICAL_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ Windows input portal (openspan_portal.py, captures at the screen edge)

**What makes it work:**

- **Globe is a separate Consumer report, not a keyboard modifier bit.** The
default map keeps Windows Win/GUI as iPad Command and sends Windows Alt as
Consumer usage `0x0C:0x029D` (AC Next Keyboard Layout Select) on Report ID 3.
This provides the iPadOS Globe system-shortcut layer without occupying a
reserved bit in the eight-bit keyboard modifier report.
- iOS needs **LE HID (HOGP)**, not Classic. An auto-accept pairing agent
(`NoInputNoOutput`) handles bonding.
- **The LE connection interval is the master dial** — it governs both mouse
Expand Down
81 changes: 75 additions & 6 deletions guest/openspan_ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
Battery) and advertises as a keyboard, then delivers input by
notifying the Report characteristics.

Command interface (unchanged): line-oriented JSON on TCP :9955
Command interface: line-oriented JSON on TCP :9955
{"cmd":"text","text":"Hello"}
{"cmd":"keys","mods":0,"keys":[4]}
{"cmd":"kbd","mods":0,"keys":[4]}
{"cmd":"consumer","usage":669,"pressed":true}
{"cmd":"mouse","dx":5,"dy":-3,"buttons":0,"wheel":0}
{"cmd":"status"}
"""
Expand Down Expand Up @@ -37,7 +39,7 @@
GATT_DESC_IFACE = "org.bluez.GattDescriptor1"
LE_ADVERTISEMENT_IFACE = "org.bluez.LEAdvertisement1"

# Combined keyboard (report id 1) + mouse (report id 2) descriptor.
# Keyboard (report id 1) + mouse (report id 2) + Consumer Control (id 3).
REPORT_MAP = bytes([
0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, 0x01,
0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00,
Expand All @@ -56,8 +58,14 @@
0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38,
0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x03, 0x81, 0x06,
0xC0, 0xC0,
0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03,
0x15, 0x00, 0x26, 0x9D, 0x02,
0x19, 0x00, 0x2A, 0x9D, 0x02,
0x75, 0x10, 0x95, 0x01, 0x81, 0x00, 0xC0,
])

CONSUMER_GLOBE = 0x029D # HID Consumer: AC Next Keyboard Layout Select

KEYMAP = {}
for i, c in enumerate("abcdefghijklmnopqrstuvwxyz"):
KEYMAP[c] = (0, 4 + i)
Expand Down Expand Up @@ -288,9 +296,14 @@ def __init__(self, bus, index):
# Mouse input report (id 2, type input=1)
self.mouse = ReportChrc(bus, 6, self, 0x02, 0x01, notify=True)
self.add_characteristic(self.mouse)
# Consumer Control input report (id 3, type input=1)
self.consumer = ReportChrc(bus, 7, self, 0x03, 0x01, notify=True)
self.consumer.value = dbus.Array(
[dbus.Byte(0), dbus.Byte(0)], signature="y")
self.add_characteristic(self.consumer)
# Boot keyboard input (0x2A22) - some hosts probe for it
self.boot_kbd = Characteristic(
bus, 7, "00002a22-0000-1000-8000-00805f9b34fb",
bus, 8, "00002a22-0000-1000-8000-00805f9b34fb",
["encrypt-read", "notify"], self)
self.add_characteristic(self.boot_kbd)

Expand Down Expand Up @@ -428,6 +441,8 @@ def __init__(self):
self._gen = int(time.time()) & 0xffff # GATT layout salt; differs each boot
self._exported = [] # every exported GATT dbus obj, for clean teardown
self._resub_tries = {} # iPad device path -> re-subscribe nudge count (cap 2)
self._consumer_lock = threading.Lock()
self._consumer_owner = None

def configure_adapter(self):
props = dbus.Interface(self.bus.get_object(BLUEZ, ADAPTER_PATH),
Expand Down Expand Up @@ -510,8 +525,18 @@ def _reregister(self):
return False

def _on_props_changed(self, interface, changed, invalidated, path=None):
if interface == "org.bluez.Device1" and changed.get("Connected") is True:
if interface != "org.bluez.Device1":
return
if changed.get("Connected") is True:
GLib.timeout_add_seconds(3, self._check_resub, path)
elif changed.get("Connected") is False and self.hid:
# Never carry a held Globe state across a BLE disconnect. BlueZ
# may not deliver StopNotify before Device1 drops, so make status
# honest and leave the characteristic neutral for reconnect/read.
with self._consumer_lock:
self._consumer_owner = None
self.hid.consumer.notifying = False
self.send_consumer(0)

def _check_resub(self, path):
if not self.hid:
Expand Down Expand Up @@ -597,6 +622,13 @@ def send_mouse(self, buttons, dx, dy, wheel):
self.hid.mouse.notify_value(
[buttons & 0x07, clamp(dx), clamp(dy), clamp(wheel)])

def send_consumer(self, usage):
usage = int(usage)
if usage not in (0, CONSUMER_GLOBE):
raise ValueError(f"unsupported consumer usage {usage:#x}")
self.hid.consumer.notify_value(
[usage & 0xFF, (usage >> 8) & 0xFF])

def type_text(self, text, delay=0.012):
for ch in text:
hit = KEYMAP.get(ch)
Expand All @@ -621,6 +653,7 @@ def command_server(self):
daemon=True).start()

def handle_client(self, conn):
input_token = object()
buf = b""
try:
while True:
Expand All @@ -633,21 +666,44 @@ def handle_client(self, conn):
if not line.strip():
continue
try:
reply = self.dispatch(json.loads(line))
reply = self.dispatch(json.loads(line), input_token)
except Exception as exc:
reply = {"ok": False, "error": str(exc)}
conn.send((json.dumps(reply) + "\n").encode())
except OSError:
pass
finally:
self._release_consumer_owner(input_token)
conn.close()

def dispatch(self, msg):
def _send_owned_consumer(self, input_token, usage):
with self._consumer_lock:
if usage:
self.send_consumer(usage)
self._consumer_owner = input_token
return True
if self._consumer_owner is not input_token:
return False
self.send_consumer(0)
self._consumer_owner = None
return True

def _release_consumer_owner(self, input_token):
with self._consumer_lock:
if self._consumer_owner is not input_token:
return False
self.send_consumer(0)
self._consumer_owner = None
return True

def dispatch(self, msg, input_token=None):
cmd = msg.get("cmd")
if cmd == "status":
return {"ok": True,
"kbd_subscribed": bool(self.hid.kbd.notifying),
"mouse_subscribed": bool(self.hid.mouse.notifying),
"consumer_subscribed": bool(
self.hid.consumer.notifying),
"advertising": bool(self.adv_on)}
if cmd == "adv":
# explicit, user-driven broadcasting: on only via Pair/Broadcast
Expand Down Expand Up @@ -689,6 +745,19 @@ def _tap():
# auto-release (for live keyboard passthrough).
self.send_keys(msg.get("mods", 0), msg.get("keys", []))
return {"ok": True}
if cmd == "consumer":
usage = int(msg.get("usage", CONSUMER_GLOBE))
pressed = msg.get("pressed")
if usage != CONSUMER_GLOBE:
raise ValueError(f"unsupported consumer usage {usage:#x}")
if not isinstance(pressed, bool):
raise ValueError("consumer pressed must be boolean")
if input_token is None:
self.send_consumer(usage if pressed else 0)
else:
self._send_owned_consumer(
input_token, usage if pressed else 0)
return {"ok": True}
if cmd == "mouse":
self.send_mouse(msg.get("buttons", 0), msg.get("dx", 0),
msg.get("dy", 0), msg.get("wheel", 0))
Expand Down
103 changes: 103 additions & 0 deletions guest/test_globe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Guest Consumer Globe report and ownership regression tests."""

import unittest

import openspan_ble as ble


CONSUMER_DESCRIPTOR = bytes([
0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03,
0x15, 0x00, 0x26, 0x9D, 0x02,
0x19, 0x00, 0x2A, 0x9D, 0x02,
0x75, 0x10, 0x95, 0x01, 0x81, 0x00, 0xC0,
])


class FakeConsumer:
def __init__(self):
self.values = []
self.notifying = True

def notify_value(self, value):
self.values.append(value)


def make_daemon():
d = ble.OpenSpanBLE.__new__(ble.OpenSpanBLE)
d.hid = type("Hid", (), {"consumer": FakeConsumer()})()
d._consumer_lock = __import__("threading").Lock()
d._consumer_owner = None
return d


class GlobeGuestTests(unittest.TestCase):
def test_report_map_contains_consumer_id_3_usage_029d(self):
self.assertIn(CONSUMER_DESCRIPTOR, ble.REPORT_MAP)

def test_report_reference_is_id3_input(self):
import inspect
source = inspect.getsource(ble.HidService.__init__)
self.assertIn("ReportChrc(bus, 7, self, 0x03, 0x01", source)

def test_press_and_release_payloads_are_little_endian(self):
d = make_daemon()
d.send_consumer(ble.CONSUMER_GLOBE)
d.send_consumer(0)
self.assertEqual([[0x9D, 0x02], [0x00, 0x00]],
d.hid.consumer.values)

def test_unsupported_consumer_usage_is_rejected(self):
d = make_daemon()
with self.assertRaises(ValueError):
d.send_consumer(0x00E9)

def test_dispatch_requires_boolean_pressed(self):
d = make_daemon()
with self.assertRaises(ValueError):
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": 1})

def test_owner_close_releases_globe(self):
d = make_daemon()
owner = object()
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": True}, owner)
self.assertTrue(d._release_consumer_owner(owner))
self.assertEqual([[0x9D, 0x02], [0, 0]], d.hid.consumer.values)

def test_old_owner_cannot_clear_new_owner(self):
d = make_daemon()
old, new = object(), object()
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": True}, old)
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": True}, new)
self.assertFalse(d._release_consumer_owner(old))
self.assertEqual([0x9D, 0x02], d.hid.consumer.values[-1])

def test_old_owner_release_command_cannot_clear_new_owner(self):
d = make_daemon()
old, new = object(), object()
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": True}, old)
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": True}, new)
d.dispatch({"cmd": "consumer", "usage": 669,
"pressed": False}, old)
self.assertEqual([0x9D, 0x02], d.hid.consumer.values[-1])
self.assertIs(new, d._consumer_owner)

def test_ble_disconnect_clears_subscription_owner_and_value(self):
d = make_daemon()
d._consumer_owner = object()
d.hid.consumer.notifying = True
d._on_props_changed(
"org.bluez.Device1", {"Connected": False}, [], "/device")
self.assertIsNone(d._consumer_owner)
self.assertFalse(d.hid.consumer.notifying)
self.assertEqual([0, 0], d.hid.consumer.values[-1])


if __name__ == "__main__":
unittest.main()
9 changes: 5 additions & 4 deletions openspan_keymap.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"",
"modifier_remap: make a Windows modifier behave as an iPad one.",
" Physical (Windows) names: ctrl shift alt win",
" iPad names: ctrl shift alt cmd",
" With 'alt':'cmd', holding Alt acts as Command, so Alt+Tab",
" becomes Cmd+Tab (the app switcher) and holding it cycles apps.",
" iPad names: ctrl shift alt cmd globe",
" Globe is a separate Consumer Control report, not an 8-bit keyboard modifier.",
" With 'alt':'globe', holding Alt drives iPadOS Globe shortcuts.",
" Windows Win/GUI remains Command through the normal HID modifier bit.",
"",
"overrides: when the exact 'from' combo is pressed, send 'to' instead.",
" Key names: a-z, 0-9, f1-f12, tab space enter esc backspace",
Expand All @@ -18,7 +19,7 @@
],

"modifier_remap": {
"alt": "cmd"
"alt": "globe"
},

"overrides": [
Expand Down
Loading