diff --git a/README.md b/README.md index a188f85..ee57186 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TECHNICAL_NOTES.md b/TECHNICAL_NOTES.md index 30ff7a5..008385e 100644 --- a/TECHNICAL_NOTES.md +++ b/TECHNICAL_NOTES.md @@ -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 diff --git a/guest/openspan_ble.py b/guest/openspan_ble.py index 7322c96..98bc1fe 100644 --- a/guest/openspan_ble.py +++ b/guest/openspan_ble.py @@ -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"} """ @@ -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, @@ -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) @@ -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) @@ -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), @@ -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: @@ -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) @@ -621,6 +653,7 @@ def command_server(self): daemon=True).start() def handle_client(self, conn): + input_token = object() buf = b"" try: while True: @@ -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 @@ -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)) diff --git a/guest/test_globe.py b/guest/test_globe.py new file mode 100644 index 0000000..0e0d326 --- /dev/null +++ b/guest/test_globe.py @@ -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() diff --git a/openspan_keymap.json b/openspan_keymap.json index 902b64e..9b4379d 100644 --- a/openspan_keymap.json +++ b/openspan_keymap.json @@ -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", @@ -18,7 +19,7 @@ ], "modifier_remap": { - "alt": "cmd" + "alt": "globe" }, "overrides": [ diff --git a/win/openspan_portal.py b/win/openspan_portal.py index d1a12d7..aa64a36 100644 --- a/win/openspan_portal.py +++ b/win/openspan_portal.py @@ -173,6 +173,7 @@ def get_clipboard_text(): # iPad HID modifier bits by name (left-variant; iPad ignores L/R). IPAD_MOD_BIT = {"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "cmd": 0x08, "gui": 0x08, "win": 0x08} +CONSUMER_GLOBE = 0x029D # HID Consumer: AC Next Keyboard Layout Select class MSLLHOOKSTRUCT(ctypes.Structure): @@ -257,6 +258,7 @@ def __init__(self): self.raw_keys = {} # vk -> hid usage (held non-modifier keys) self.mods = 0 # physical modifier byte (L/R bits) self.buttons = 0 + self.consumer_usage = 0 self.remap, self.overrides = self._load_keymap() self._chord_until = 0.0 # passthrough reports are dropped until # then, so they can't clobber an FKA chord @@ -364,6 +366,7 @@ def leave(self): # portal keeps running but goes deaf, and edge crossings stop working # until a restart reinstalls the hook. The sender thread owns the # socket and is the only place allowed to block on it. + self._emit_consumer(0) self.q.put(("k", 0, [], 0)) self.q.put(("b", 0, 0, 0)) # drop the real cursor back just inside the monitor at the @@ -585,14 +588,18 @@ def _send_chord(self, chord): mods, usage = chord self._last_chord = now self._chord_until = now + FKA_HOLD + 0.05 + # Synthetic clipboard chords must not inherit a held Globe state. + self._emit_consumer(0) self.q.put(("k", mods, [usage], 0)) def finish(): self.q.put(("k", 0, [], 0)) if self.active: # resync the iPad with what is still physically held + mod_names = self._phys_mod_names() + self._emit_consumer(self._desired_consumer(mod_names)) out_mods = 0 - for name in self._phys_mod_names(): + for name in mod_names: tgt = self.remap.get(name, name) out_mods |= IPAD_MOD_BIT.get(tgt, 0) self.q.put(("k", out_mods, @@ -622,22 +629,35 @@ def _emit_kbd(self): fmods = frozenset(mod_names) for omods, okeys in ((o[2], o[3]) for o in self.overrides if o[0] == fmods and o[1] == key_names): + self._emit_consumer(0) self.q.put(("k", omods, okeys[:6], 0)) return # 2) passthrough with modifier remap + self._emit_consumer(self._desired_consumer(mod_names)) out_mods = 0 for name in mod_names: tgt = self.remap.get(name, name) out_mods |= IPAD_MOD_BIT.get(tgt, 0) self.q.put(("k", out_mods, key_usages[:6], 0)) + def _desired_consumer(self, mod_names): + return CONSUMER_GLOBE if any( + self.remap.get(name, name) == "globe" for name in mod_names + ) else 0 + + def _emit_consumer(self, usage): + if usage == self.consumer_usage: + return + self.consumer_usage = usage + self.q.put(("c", usage, 0, 0)) + def sender(self): period = 1.0 / SEND_HZ while True: time.sleep(period) adx = ady = awheel = 0 btn_dirty = False - keymsgs = [] + hidmsgs = [] texts = [] drained = False while True: @@ -653,15 +673,22 @@ def sender(self): elif kind == "b": btn_dirty = True elif kind == "k": - keymsgs.append((a, b)) + hidmsgs.append(("kbd", a, b)) + elif kind == "c": + hidmsgs.append(("consumer", a, None)) elif kind == "t": texts.append(a) if not drained: continue for text in texts: self.send({"cmd": "text", "text": text}) - for mods, keys in keymsgs: - self.send({"cmd": "kbd", "mods": mods, "keys": keys}) + for kind, a, b in hidmsgs: + if kind == "kbd": + self.send({"cmd": "kbd", "mods": a, "keys": b}) + else: + self.send({"cmd": "consumer", + "usage": CONSUMER_GLOBE, + "pressed": a == CONSUMER_GLOBE}) if adx or ady or awheel: while adx or ady or awheel: sx = max(-127, min(127, adx)); adx -= sx diff --git a/win/test_globe.py b/win/test_globe.py new file mode 100644 index 0000000..f9072d8 --- /dev/null +++ b/win/test_globe.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Host-side Globe remap and report-order regression tests.""" + +import os +import json +import queue +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import openspan_portal as portal + + +def make_portal(): + p = portal.Portal.__new__(portal.Portal) + p.remap = {"alt": "globe"} + p.overrides = [] + p.mods = 0 + p.raw_keys = {} + p.consumer_usage = 0 + p.q = queue.Queue() + p._chord_until = 0 + p.active = True + p.buttons = 0 + p.cur = None + return p + + +def drain(q): + out = [] + while not q.empty(): + out.append(q.get_nowait()) + return out + + +class GlobeHostTests(unittest.TestCase): + def test_shipped_keymap_maps_alt_only_to_globe(self): + with open(os.path.join(os.path.dirname(__file__), "..", + "openspan_keymap.json")) as f: + remap = json.load(f)["modifier_remap"] + self.assertEqual("globe", remap.get("alt")) + self.assertNotIn("win", remap) + + def test_alt_press_queues_globe_before_keyboard(self): + p = make_portal() + p.mods = 0x04 + p.raw_keys = {0x26: 0x52} # Up + p._emit_kbd() + self.assertEqual([ + ("c", portal.CONSUMER_GLOBE, 0, 0), + ("k", 0, [0x52], 0), + ], drain(p.q)) + + def test_alt_release_clears_globe_without_stuck_state(self): + p = make_portal() + p.consumer_usage = portal.CONSUMER_GLOBE + p._emit_kbd() + self.assertEqual([ + ("c", 0, 0, 0), + ("k", 0, [], 0), + ], drain(p.q)) + + def test_win_remains_command(self): + p = make_portal() + p.mods = 0x08 + p.raw_keys = {0x09: 0x2B} # Tab + p._emit_kbd() + self.assertEqual([("k", 0x08, [0x2B], 0)], drain(p.q)) + + def test_consumer_reports_are_deduplicated(self): + p = make_portal() + p._emit_consumer(portal.CONSUMER_GLOBE) + p._emit_consumer(portal.CONSUMER_GLOBE) + self.assertEqual( + [("c", portal.CONSUMER_GLOBE, 0, 0)], drain(p.q)) + + def test_leave_queues_globe_release(self): + p = make_portal() + p.consumer_usage = portal.CONSUMER_GLOBE + p.leave() + self.assertEqual(("c", 0, 0, 0), drain(p.q)[0]) + + def test_exact_override_releases_globe(self): + p = make_portal() + p.overrides = [ + (frozenset({"alt"}), frozenset({"c"}), 0x08, [0x06]), + ] + p.mods = 0x04 + p.raw_keys = {0x43: 0x06} + p.consumer_usage = portal.CONSUMER_GLOBE + p._emit_kbd() + self.assertEqual([ + ("c", 0, 0, 0), + ("k", 0x08, [0x06], 0), + ], drain(p.q)) + + def test_sender_preserves_consumer_keyboard_order(self): + import inspect + source = inspect.getsource(portal.Portal.sender) + self.assertLess(source.index('hidmsgs.append(("consumer"'), + source.index('for kind, a, b in hidmsgs')) + self.assertIn('"cmd": "consumer"', source) + + +if __name__ == "__main__": + unittest.main()