switch-pico/tools/dolphinbar_keepalive.py

792 lines
30 KiB
Python
Executable file

#!/usr/bin/env python3
"""Temporarily emulate a neutral Wii Remote for a DolphinBar on Linux.
Run with sudo and press SYNC on the DolphinBar after the READY message.
Runs until Ctrl+C by default; --seconds N optionally sets a runtime limit.
No MAC spoofing, firmware changes, or persistent Bluetooth pairing keys.
The normal Bluetooth daemon is temporarily runtime-masked; settings and service
are restored on exit. --restore recovers an interrupted run from its journal.
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import selectors
import signal
import socket
import struct
import subprocess
import sys
import time
from collections import deque
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import ClassVar, TypedDict, cast
# Keep this executable directly as well as through python -m tools....
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
AdapterInfo = TypedDict(
"AdapterInfo",
{
"address": str,
"version": int,
"manufacturer": int,
"supported": int,
"settings": int,
"class": str,
"name": str,
},
)
class RecoveryState(TypedDict):
schema: int
index: int
info: AdapterInfo
service_active: bool
hci: dict[str, str]
POWERED = 1 << 0
CONNECTABLE = 1 << 1
DISCOVERABLE = 1 << 3
BONDABLE = 1 << 4
SSP = 1 << 6
BREDR = 1 << 7
ADVERTISING = 1 << 10
SECURE_CONN = 1 << 11
stop_requested = False
def log(event: str, **fields) -> None:
print(
json.dumps({"time": round(time.monotonic(), 3), "event": event, **fields}),
flush=True,
)
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, check=check, text=True, capture_output=True, timeout=20)
def address(raw: bytes) -> str:
return ":".join(f"{b:02X}" for b in raw[::-1])
class Management:
def __init__(self, index: int):
self.index = index
self.socket = socket.socket(
socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI
)
self.socket.bind((0xFFFF, 3)) # HCI_CHANNEL_CONTROL
self.socket.settimeout(5)
def close(self):
self.socket.close()
def request(self, opcode: int, payload: bytes = b"") -> bytes:
self.socket.send(
struct.pack("<HHH", opcode, self.index, len(payload)) + payload
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
packet = self.socket.recv(4096)
if len(packet) < 9:
continue
event, index, size = struct.unpack_from("<HHH", packet)
if index != self.index or size + 6 != len(packet) or event not in (1, 2):
continue
command, status = struct.unpack_from("<HB", packet, 6)
if command != opcode:
continue
if status:
raise RuntimeError(
f"MGMT command 0x{opcode:04x}: status 0x{status:02x}"
)
return packet[9:]
raise TimeoutError(f"MGMT command 0x{opcode:04x} timed out")
def info(self) -> AdapterInfo:
data = self.request(4)
if len(data) != 280:
raise RuntimeError(f"Unexpected MGMT ReadInfo size {len(data)}")
return {
"address": address(data[:6]),
"version": data[6],
"manufacturer": struct.unpack_from("<H", data, 7)[0],
"supported": struct.unpack_from("<I", data, 9)[0],
"settings": struct.unpack_from("<I", data, 13)[0],
"class": data[17:20].hex(),
"name": data[20:269].split(b"\0", 1)[0].decode(errors="replace"),
}
def connections(self) -> int:
data = self.request(0x15)
if len(data) < 2:
raise RuntimeError("Short MGMT connection list")
count = struct.unpack_from("<H", data)[0]
if len(data) != 2 + count * 7:
raise RuntimeError("Malformed MGMT connection list")
return count
def setting(self, opcode: int, enabled: bool) -> None:
self.request(opcode, bytes([int(enabled)]))
def discoverable(self, mode: int, timeout: int = 0) -> None:
self.request(6, struct.pack("<BH", mode, timeout))
class Hci:
# read opcode -> (write opcode, expected payload length; -1 is variable)
SETTINGS: ClassVar[dict[int, tuple[int, int]]] = {
0x0C14: (0x0C13, 248),
0x0C23: (0x0C24, 3),
0x0C19: (0x0C1A, 1),
0x0C39: (0x0C3A, -1),
0x0C51: (0x0C52, 241),
0x0C09: (0x0C0A, 1),
}
def __init__(self, index: int):
self.socket = socket.socket(
socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI
)
self.socket.bind((index,))
self.socket.setsockopt(
0, 2, struct.pack("<IIIH2x", 1 << 4, 0xFFFFFFFF, 0xFFFFFFFF, 0)
)
self.socket.settimeout(4)
self.pending_events: deque[bytes] = deque()
def close(self):
self.socket.close()
def send(self, opcode: int, payload: bytes = b"") -> None:
if len(payload) > 255:
raise ValueError("HCI command too large")
self.socket.send(
bytes([1]) + struct.pack("<HB", opcode, len(payload)) + payload
)
def command(self, opcode: int, payload: bytes = b"") -> bytes:
self.send(opcode, payload)
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
packet = self.socket.recv(1024)
if len(packet) < 7 or packet[0] != 4 or len(packet) != packet[2] + 3:
continue
if packet[1] == 0x0E and struct.unpack_from("<H", packet, 4)[0] == opcode:
if packet[6]:
raise RuntimeError(
f"HCI command 0x{opcode:04x}: status 0x{packet[6]:02x}"
)
return packet[7:]
if (
packet[1] == 0x0F
and struct.unpack_from("<H", packet, 5)[0] == opcode
and packet[3]
):
raise RuntimeError(
f"HCI command 0x{opcode:04x}: status 0x{packet[3]:02x}"
)
if packet[1] in (3, 4, 5, 6, 0x16, 0x18):
self.pending_events.append(packet)
raise TimeoutError(f"HCI command 0x{opcode:04x} timed out")
def snapshot(self) -> dict[str, str]:
result = {}
for read_opcode, (write_opcode, size) in self.SETTINGS.items():
data = self.command(read_opcode)
if (size >= 0 and len(data) != size) or (
size < 0 and (not data or len(data) != 1 + data[0] * 3)
):
raise RuntimeError(
f"Unexpected HCI setting length for 0x{read_opcode:04x}"
)
result[str(write_opcode)] = data.hex()
return result
class AdapterSession:
def __init__(self, index: int, journal: Path):
self.index = index
self.journal = journal
self.management = Management(index)
self.hci = Hci(index)
self.state: RecoveryState | None = None
self.lock_resources = ExitStack()
def acquire_lock(self) -> None:
@contextmanager
def held_lock():
with open(
f"/run/lock/switch-pico-dolphinbar-hci{self.index}.lock", "a"
) as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
self.lock_resources.enter_context(held_lock())
def prepare(self) -> None:
self.acquire_lock()
if self.journal.exists():
raise RuntimeError(
f"Recovery journal exists. Run --restore {self.journal} first"
)
info = self.management.info()
if not info["settings"] & POWERED or not info["settings"] & BREDR:
raise RuntimeError(
"Enable the adapter and Bluetooth Classic before running this tool"
)
if self.management.connections():
raise RuntimeError(
"Refusing to interrupt connected laptop Bluetooth devices"
)
if info["settings"] & (ADVERTISING | DISCOVERABLE):
raise RuntimeError(
"Stop existing laptop Bluetooth advertising/discovery before this test"
)
active = (
run("systemctl", "is-active", "bluetooth.service", check=False).returncode
== 0
)
masked = run(
"systemctl", "is-enabled", "bluetooth.service", check=False
).stdout.strip()
if masked not in ("enabled", "disabled", "static", "indirect", "alias"):
raise RuntimeError(
f"Unexpected Bluetooth service state {masked!r}; not changing it"
)
self.state = {
"schema": 1,
"index": self.index,
"info": info,
"service_active": active,
"hci": self.hci.snapshot(),
}
self.journal.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self.journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump(self.state, stream, indent=2)
stream.flush()
os.fsync(stream.fileno())
log(
"adapter_saved",
adapter=f"hci{self.index}",
address=info["address"],
journal=str(self.journal),
)
run("systemctl", "mask", "--runtime", "bluetooth.service")
run("systemctl", "stop", "bluetooth.service")
self.management.setting(5, False)
if info["supported"] & SECURE_CONN:
self.management.setting(0x2D, False)
self.management.setting(0x0B, False) # Classic legacy PIN, not SSP.
self.management.setting(9, True)
self.management.setting(7, True)
self.management.setting(5, True)
# Discovery starts only after all L2CAP listeners are bound.
self.management.discoverable(0)
self.hci.command(0x0C13, b"Nintendo RVL-CNT-01".ljust(248, b"\0"))
self.hci.command(0x0C0A, b"\0") # Variable PIN.
def advertise(self, seconds: int | None) -> None:
assert self.state is not None
# Discovery is bounded independently of an established connection.
self.management.discoverable(
2, min(seconds, 180) if seconds is not None else 180
)
self.hci.command(0x0C24, bytes.fromhex("042500"))
supported_iac = self.hci.command(0x0C38)
laps = (
bytes.fromhex("008b9e338b9e")
if supported_iac and supported_iac[0] >= 2
else bytes.fromhex("008b9e")
)
self.hci.command(0x0C3A, bytes([len(laps) // 3]) + laps)
name = b"Nintendo RVL-CNT-01"
eir = bytes([len(name) + 1, 9]) + name + bytes.fromhex("050324110012")
self.hci.command(0x0C52, b"\0" + eir.ljust(240, b"\0"))
self.hci.command(0x0C1A, b"\x03")
log(
"identity_ready",
address=self.state["info"]["address"],
name=name.decode(),
device_class="002504",
address_spoofed=False,
)
def restore(self) -> None:
if self.state is None:
return
# Recovery can be invoked after a partial cleanup restarted BlueZ.
# Prevent it from racing these controller commands in either path.
run("systemctl", "mask", "--runtime", "bluetooth.service")
run("systemctl", "stop", "bluetooth.service")
self.hci.socket.settimeout(4)
errors = []
settings = self.state["info"]["settings"]
def attempt(label, fn):
try:
fn()
except (
OSError,
RuntimeError,
ValueError,
subprocess.SubprocessError,
) as exc:
errors.append(f"{label}: {exc}")
def restore_discoverable():
wanted = bool(settings & DISCOVERABLE)
current = bool(self.management.info()["settings"] & DISCOVERABLE)
# Clearing connectable already clears discoverable. The kernel
# rejects a redundant discoverable request when not connectable.
if current != wanted:
self.management.discoverable(int(wanted))
attempt("power down", lambda: self.management.setting(5, False))
attempt("SSP", lambda: self.management.setting(0x0B, bool(settings & SSP)))
if self.state["info"]["supported"] & SECURE_CONN:
attempt(
"secure connections",
lambda: self.management.setting(0x2D, bool(settings & SECURE_CONN)),
)
attempt(
"bondable", lambda: self.management.setting(9, bool(settings & BONDABLE))
)
attempt(
"connectable",
lambda: self.management.setting(7, bool(settings & CONNECTABLE)),
)
attempt("power up", lambda: self.management.setting(5, True))
attempt("discoverable", restore_discoverable)
for opcode, value in self.state["hci"].items():
attempt(
f"HCI {opcode}",
lambda opcode=opcode, value=value: self.hci.command(
int(opcode), bytes.fromhex(value)
),
)
attempt(
"original power",
lambda: self.management.setting(5, bool(settings & POWERED)),
)
attempt(
"unmask service",
lambda: run("systemctl", "unmask", "--runtime", "bluetooth.service"),
)
if self.state["service_active"]:
attempt(
"restart service",
lambda: run("systemctl", "start", "bluetooth.service"),
)
if errors:
log(
"restore_failed",
errors=errors,
recovery=f"sudo {sys.executable} {__file__} --restore {self.journal}",
)
raise RuntimeError("Restoration incomplete; recovery journal retained")
self.journal.unlink(missing_ok=True)
log(
"adapter_restored",
adapter=f"hci{self.index}",
service_active=self.state["service_active"],
)
self.state = None
def close(self):
self.hci.close()
self.management.close()
self.lock_resources.close()
class Peripheral:
def __init__(self, session: AdapterSession, pin_mode: str, peer: str | None):
from tools.dolphinbar_sdp import SdpResponder
from tools.dolphinbar_wiimote import HID_DESCRIPTOR, Wiimote
self.session = session
self.pin_mode = pin_mode
self.peer = peer.upper() if peer else None
self.sdp_factory = lambda: SdpResponder(HID_DESCRIPTOR)
self.sdp_sessions: dict[socket.socket, SdpResponder] = {}
self.peer_mtus: dict[socket.socket, int] = {}
self.wiimote = Wiimote()
self.selector = selectors.DefaultSelector()
self.listeners: list[socket.socket] = []
self.clients: dict[socket.socket, int] = {}
self.pending: dict[socket.socket, deque[bytes]] = {}
self.waiting_interrupt: deque[bytes] = deque()
self.interrupt: socket.socket | None = None
self.commands = 0
self.sent = 0
self.pins = 0
self.authenticated: bool | None = None
self.generated_link_key = False
self.last_ir = False
assert session.state is not None
local = session.state["info"]["address"]
self.local_address = local
try:
for psm in (1, 0x11, 0x13):
listener = socket.socket(
socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP
)
self.listeners.append(listener)
listener.setsockopt(274, 4, bytes([1, 0])) # BT_SECURITY_LOW
listener.bind((local, psm))
listener.listen(1)
listener.setblocking(False)
self.selector.register(listener, selectors.EVENT_READ, ("listen", psm))
session.hci.socket.setblocking(False)
self.selector.register(session.hci.socket, selectors.EVENT_READ, ("hci", 0))
except BaseException:
self.close()
raise
def close_client(self, client):
psm = self.clients.pop(client, None)
self.pending.pop(client, None)
self.sdp_sessions.pop(client, None)
self.peer_mtus.pop(client, None)
try:
self.selector.unregister(client)
except (KeyError, ValueError):
pass
if client is self.interrupt:
self.interrupt = None
client.close()
if psm is not None:
log("channel_closed", psm=hex(psm))
def close(self):
for client in list(self.clients):
self.close_client(client)
for listener in self.listeners:
listener.close()
self.selector.close()
def queue(self, client, packet: bytes):
if client is None:
if len(self.waiting_interrupt) >= 512:
raise RuntimeError("Peer exhausted pre-interrupt response queue")
self.waiting_interrupt.append(packet)
return
queue = self.pending[client]
if len(queue) >= 512:
raise RuntimeError("Peer exhausted bounded response queue")
queue.append(packet)
self.selector.modify(
client,
selectors.EVENT_READ | selectors.EVENT_WRITE,
("client", self.clients[client]),
)
def hci_event(self, packet: bytes):
if len(packet) < 3 or packet[0] != 4 or len(packet) != packet[2] + 3:
return
event, data = packet[1], packet[3:]
if event == 0x16 and len(data) == 6: # PIN_CODE_REQUEST
remote = address(data)
if self.peer and remote != self.peer:
self.session.management.request(0x0017, data + b"\0")
log("pin_rejected_other_peer", peer=remote)
return
self.peer = remote
pin = (
data
if self.pin_mode == "host"
else bytes.fromhex(self.local_address.replace(":", ""))[::-1]
)
# MGMT accepts binary PINs and updates the kernel's pairing state.
self.session.management.request(0x0016, data + b"\0\x06" + pin + bytes(10))
self.pins += 1
log("legacy_pin_replied", peer=remote, mode=self.pin_mode)
elif event == 3 and len(data) >= 11:
log(
"acl_connection",
status=data[0],
peer=address(data[3:9]),
encryption=data[10],
)
elif event == 6 and len(data) == 3:
self.authenticated = data[0] == 0
log(
"authentication",
status=data[0],
handle=struct.unpack_from("<H", data, 1)[0],
)
elif event == 5 and len(data) == 4:
log("acl_disconnected", reason=data[3])
elif event == 0x18 and len(data) == 23:
self.generated_link_key = True
log("link_key_generated", peer=address(data[:6]), persisted=False)
elif event == 4 and len(data) == 10:
log(
"connection_requested",
peer=address(data[:6]),
device_class=data[6:9].hex(),
)
def receive(self, client, psm: int, packet: bytes):
if psm == 1:
reply = self.sdp_sessions[client].reply(packet, self.peer_mtus[client])
self.queue(client, reply)
log("sdp", request=packet.hex(), response_bytes=len(reply))
return
if not packet:
return
prefix = packet[0]
if prefix in (0xA2, 0x52):
log("wiimote_command", psm=hex(psm), report=packet[1:].hex())
replies = self.wiimote.handle_output(packet[1:])
self.commands += 1
if prefix == 0x52 and psm == 0x11:
self.queue(client, b"\0")
for reply in replies:
self.queue(self.interrupt, reply)
if self.wiimote.ir_enabled != self.last_ir:
self.last_ir = self.wiimote.ir_enabled
log(
"emulated_camera",
enabled=self.last_ir,
mode=hex(self.wiimote.report_mode),
)
elif psm == 0x11:
if prefix == 0x15: # Virtual cable unplug.
self.close_client(client)
elif prefix == 0x60:
self.queue(client, b"\xa0\x01")
elif prefix == 0x71 or prefix & 0xF0 == 0x90:
self.queue(client, b"\0")
elif prefix == 0x80:
self.queue(client, b"\xa0\0")
elif prefix == 0x41 and len(packet) >= 2 and packet[1] == 0x20:
self.queue(client, self.wiimote.status_report())
else:
self.queue(client, b"\x03")
log("unsupported_hid_control", packet=packet.hex())
def serve(self, seconds: int | None):
self.session.hci.socket.settimeout(4)
self.session.advertise(seconds)
self.session.hci.socket.setblocking(False)
while self.session.hci.pending_events:
self.hci_event(self.session.hci.pending_events.popleft())
log(
"READY",
message="Listening; press only the DolphinBar SYNC button now",
seconds=seconds,
lifetime="until Ctrl+C" if seconds is None else f"{seconds} seconds",
pin_mode=self.pin_mode,
)
start = time.monotonic()
next_report = start
next_status = start + 10
while not stop_requested and (
seconds is None or time.monotonic() - start < seconds
):
for key, events in self.selector.select(0.01):
category, psm = key.data
channel_socket = cast(socket.socket, key.fileobj)
if category == "hci":
try:
self.hci_event(channel_socket.recv(1024))
except BlockingIOError:
pass
elif category == "listen":
client, remote = channel_socket.accept()
remote_address = remote[0].upper()
if self.peer and self.peer != remote_address:
client.close()
log("rejected_other_peer", peer=remote_address)
continue
if psm != 1:
self.peer = remote_address
if any(value == psm for value in self.clients.values()):
client.close()
continue
client.setblocking(False)
self.clients[client] = psm
self.pending[client] = deque()
options = client.getsockopt(6, 1, 12) # L2CAP_OPTIONS
self.peer_mtus[client] = struct.unpack_from("<H", options)[0]
if psm == 1:
self.sdp_sessions[client] = self.sdp_factory()
self.selector.register(
client, selectors.EVENT_READ, ("client", psm)
)
log("channel_connected", psm=hex(psm), peer=remote_address)
if psm == 0x13:
self.interrupt = client
while self.waiting_interrupt:
self.queue(client, self.waiting_interrupt.popleft())
self.queue(client, self.wiimote.status_report())
else:
client = channel_socket
try:
if events & selectors.EVENT_READ:
packet = client.recv(4096)
if not packet:
self.close_client(client)
continue
try:
self.receive(client, psm, packet)
except ValueError as exc:
log(
"malformed_peer_request",
psm=hex(psm),
error=str(exc),
)
self.close_client(client)
continue
if client in self.clients and events & selectors.EVENT_WRITE:
queue = self.pending[client]
if queue:
packet = queue[0]
if client.send(packet) != len(packet):
raise RuntimeError(
"Partial L2CAP sequenced-packet write"
)
queue.popleft()
self.sent += 1
if not queue:
self.selector.modify(
client, selectors.EVENT_READ, ("client", psm)
)
except BlockingIOError:
pass
except OSError as exc:
log("channel_error", psm=hex(psm), error=str(exc))
self.close_client(client)
now = time.monotonic()
if now >= next_report:
next_report = now + 0.01
if self.interrupt and not self.pending[self.interrupt]:
report = self.wiimote.periodic_report()
if report:
self.queue(self.interrupt, report)
if now >= next_status:
next_status = now + 10
log(
"status",
elapsed=round(now - start, 1),
peer=self.peer,
commands=self.commands,
sent=self.sent,
channels=sorted(self.clients.values()),
ir_enabled=self.wiimote.ir_enabled,
)
log(
"finished",
peer=self.peer,
commands=self.commands,
sent=self.sent,
pin_requests=self.pins,
authenticated=self.authenticated,
link_key_generated=self.generated_link_key,
ir_enabled=self.wiimote.ir_enabled,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--adapter", default="hci0")
parser.add_argument(
"--seconds",
type=int,
help="Stop after N seconds; omitted means run until Ctrl+C",
)
parser.add_argument("--pin-mode", choices=("host", "device"), default="host")
parser.add_argument(
"--peer", help="Restrict the experiment to one Bluetooth address"
)
parser.add_argument("--state-file", type=Path)
parser.add_argument(
"--inspect",
action="store_true",
help="Read adapter information without changing it",
)
parser.add_argument(
"--restore",
type=Path,
help="Restore settings from an interrupted run's journal",
)
args = parser.parse_args()
if not sys.platform.startswith("linux") or os.geteuid() != 0:
parser.error("Linux and root/CAP_NET_ADMIN access are required; run with sudo")
if not args.adapter.startswith("hci") or not args.adapter[3:].isdigit():
parser.error("--adapter must be hciN")
index = int(args.adapter[3:])
if index > 65534 or (args.seconds is not None and args.seconds < 1):
parser.error("Invalid adapter index or duration (must be positive)")
if args.peer:
try:
if len(bytes.fromhex(args.peer.replace(":", ""))) != 6:
raise ValueError
except ValueError:
parser.error("--peer must be a Bluetooth address")
journal = args.state_file or Path("build-dolphinbar") / f"hci{index}-state.json"
state: RecoveryState | None = None
if args.restore:
journal = args.restore
state = cast(RecoveryState, json.loads(journal.read_text()))
if state.get("schema") != 1:
parser.error("Unsupported recovery journal")
index = state["index"]
if type(index) is not int or not 0 <= index <= 65534:
parser.error("Invalid adapter index in recovery journal")
session = AdapterSession(index, journal)
peripheral = None
try:
if args.restore:
assert state is not None
session.acquire_lock()
if session.management.info()["address"] != state["info"]["address"]:
raise RuntimeError(
"Recovery journal belongs to a different Bluetooth adapter"
)
session.state = state
session.restore()
elif args.inspect:
log(
"inspection",
**session.management.info(),
connections=session.management.connections(),
hci=session.hci.snapshot(),
)
else:
def stop(_signal, _frame):
global stop_requested
stop_requested = True
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, stop)
session.prepare()
peripheral = Peripheral(session, args.pin_mode, args.peer)
peripheral.serve(args.seconds)
return 0
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
log("error", error=str(exc))
return 1
finally:
if peripheral:
peripheral.close()
try:
session.restore()
finally:
session.close()
if __name__ == "__main__":
raise SystemExit(main())