Add selectable Wii IR aiming and DolphinBar keepalive
This commit is contained in:
parent
e2c8e0d860
commit
5ecfca105a
27 changed files with 4079 additions and 53 deletions
792
tools/dolphinbar_keepalive.py
Executable file
792
tools/dolphinbar_keepalive.py
Executable file
|
|
@ -0,0 +1,792 @@
|
|||
#!/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())
|
||||
382
tools/dolphinbar_sdp.py
Normal file
382
tools/dolphinbar_sdp.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
"""Small, in-memory SDP server for an original Wii Remote identity.
|
||||
|
||||
Wire-format reference: Bluetooth Core, Vol 3, Part B. HID attribute values
|
||||
are factual values from the original remote record documented in
|
||||
https://github.com/rnconrad/WiimoteEmulator/blob/master/sdp.c and the identity
|
||||
and channel assignments at https://wiibrew.org/wiki/Wiimote . No upstream
|
||||
response arrays or implementation are used here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from collections import OrderedDict
|
||||
from typing import NamedTuple
|
||||
|
||||
_MAX_REQUEST = 4096
|
||||
_MAX_DESCRIPTOR = 4096
|
||||
_MAX_ELEMENTS = 512
|
||||
_MAX_DEPTH = 8
|
||||
_MAX_CONTINUATIONS = 64
|
||||
_TOKEN_BYTES = 8
|
||||
_UUID_BASE_SUFFIX = bytes.fromhex("00001000800000805f9b34fb")
|
||||
_VALID_SIZES = (
|
||||
(0,), # Nil
|
||||
(0, 1, 2, 3, 4), # Unsigned integer
|
||||
(0, 1, 2, 3, 4), # Signed integer
|
||||
(1, 2, 4), # UUID
|
||||
(5, 6, 7), # Text string
|
||||
(0,), # Boolean
|
||||
(5, 6, 7), # Sequence
|
||||
(5, 6, 7), # Alternative
|
||||
(5, 6, 7), # URL
|
||||
)
|
||||
|
||||
|
||||
class _SdpError(Exception):
|
||||
def __init__(self, code: int):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def _encode(kind: int, payload: bytes) -> bytes:
|
||||
"""Encode a data element, choosing a legal shortest length header."""
|
||||
size = len(payload)
|
||||
if kind == 0:
|
||||
if size:
|
||||
raise ValueError("Nil data elements have no payload")
|
||||
return b"\x00"
|
||||
if kind in (1, 2, 3, 5):
|
||||
sizes = {1: 0, 2: 1, 4: 2, 8: 3, 16: 4}
|
||||
index = sizes.get(size, -1)
|
||||
if index not in _VALID_SIZES[kind]:
|
||||
raise ValueError("Invalid scalar data element size")
|
||||
return bytes([(kind << 3) | index]) + payload
|
||||
if kind not in (4, 6, 7, 8):
|
||||
raise ValueError("Invalid data element type")
|
||||
for width, index in ((1, 5), (2, 6), (4, 7)):
|
||||
if size < 1 << (8 * width):
|
||||
return bytes([(kind << 3) | index]) + size.to_bytes(width, "big") + payload
|
||||
raise ValueError("Data element is too large")
|
||||
|
||||
|
||||
def _uint(value: int, width: int = 2) -> bytes:
|
||||
return _encode(1, value.to_bytes(width, "big"))
|
||||
|
||||
|
||||
def _uuid(value: int) -> bytes:
|
||||
return _encode(3, value.to_bytes(2, "big"))
|
||||
|
||||
|
||||
def _boolean(value: bool) -> bytes:
|
||||
return _encode(5, bytes([int(value)]))
|
||||
|
||||
|
||||
def _text(value: str) -> bytes:
|
||||
return _encode(4, value.encode("utf-8"))
|
||||
|
||||
|
||||
def _sequence(*elements: bytes) -> bytes:
|
||||
return _encode(6, b"".join(elements))
|
||||
|
||||
|
||||
class _Element(NamedTuple):
|
||||
kind: int
|
||||
size: int
|
||||
value: int | bytes | tuple[_Element, ...] | None
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.position = 0
|
||||
self.elements = 0
|
||||
|
||||
def integer(self, width: int, end: int | None = None) -> int:
|
||||
if end is None:
|
||||
end = len(self.data)
|
||||
if self.position + width > end:
|
||||
raise _SdpError(0x0003)
|
||||
value = int.from_bytes(self.data[self.position : self.position + width], "big")
|
||||
self.position += width
|
||||
return value
|
||||
|
||||
def element(self, end: int | None = None, depth: int = 0) -> _Element:
|
||||
if end is None:
|
||||
end = len(self.data)
|
||||
self.elements += 1
|
||||
if depth > _MAX_DEPTH or self.elements > _MAX_ELEMENTS:
|
||||
raise _SdpError(0x0006)
|
||||
header = self.integer(1, end)
|
||||
kind, index = header >> 3, header & 7
|
||||
if kind >= len(_VALID_SIZES) or index not in _VALID_SIZES[kind]:
|
||||
raise _SdpError(0x0003)
|
||||
if kind == 0:
|
||||
return _Element(kind, 0, None)
|
||||
size = (1 << index) if index < 5 else self.integer(1 << (index - 5), end)
|
||||
stop = self.position + size
|
||||
if stop > end:
|
||||
raise _SdpError(0x0003)
|
||||
if kind in (6, 7):
|
||||
children = []
|
||||
while self.position < stop:
|
||||
children.append(self.element(stop, depth + 1))
|
||||
return _Element(kind, size, tuple(children))
|
||||
raw = self.data[self.position : stop]
|
||||
self.position = stop
|
||||
if kind in (1, 2, 5):
|
||||
value = int.from_bytes(raw, "big", signed=kind == 2)
|
||||
if kind == 5:
|
||||
value = int(bool(value))
|
||||
return _Element(kind, size, value)
|
||||
return _Element(kind, size, raw)
|
||||
|
||||
def search_pattern(self) -> tuple[bytes, ...]:
|
||||
element = self.element()
|
||||
if element.kind != 6 or not 1 <= len(element.value) <= 12:
|
||||
raise _SdpError(0x0003)
|
||||
uuids = []
|
||||
for child in element.value:
|
||||
if child.kind != 3:
|
||||
raise _SdpError(0x0003)
|
||||
uuid = child.value
|
||||
if child.size != 16:
|
||||
uuid = uuid.rjust(4, b"\x00") + _UUID_BASE_SUFFIX
|
||||
uuids.append(uuid)
|
||||
return tuple(uuids)
|
||||
|
||||
def attributes(self) -> tuple[tuple[int, int], ...]:
|
||||
element = self.element()
|
||||
if element.kind != 6:
|
||||
raise _SdpError(0x0003)
|
||||
ranges = []
|
||||
previous = -1
|
||||
for child in element.value:
|
||||
if child.kind != 1 or child.size not in (2, 4):
|
||||
raise _SdpError(0x0003)
|
||||
if child.size == 2:
|
||||
first = last = child.value
|
||||
else:
|
||||
first, last = child.value >> 16, child.value & 0xFFFF
|
||||
if first > last or first <= previous:
|
||||
raise _SdpError(0x0003)
|
||||
ranges.append((first, last))
|
||||
previous = last
|
||||
return tuple(ranges)
|
||||
|
||||
def continuation(self) -> bytes:
|
||||
size = self.integer(1)
|
||||
if size > 16:
|
||||
raise _SdpError(0x0005)
|
||||
if self.position + size != len(self.data):
|
||||
raise _SdpError(0x0003)
|
||||
return self.data[self.position :]
|
||||
|
||||
|
||||
def _records(descriptor: bytes) -> dict[int, dict[int, bytes]]:
|
||||
language = _sequence(_uint(0x656E), _uint(106), _uint(0x0100))
|
||||
browse = _sequence(_uuid(0x1002))
|
||||
|
||||
def hid_protocol(psm: int) -> bytes:
|
||||
return _sequence(_sequence(_uuid(0x0100), _uint(psm)), _sequence(_uuid(0x0011)))
|
||||
|
||||
hid = {
|
||||
0x0000: _uint(0x00010000, 4),
|
||||
0x0001: _sequence(_uuid(0x1124)),
|
||||
0x0004: hid_protocol(0x0011),
|
||||
0x0005: browse,
|
||||
0x0006: language,
|
||||
0x0009: _sequence(_sequence(_uuid(0x1124), _uint(0x0100))),
|
||||
0x000D: _sequence(hid_protocol(0x0013)),
|
||||
0x0100: _text("Nintendo RVL-CNT-01"),
|
||||
0x0101: _text("Nintendo RVL-CNT-01"),
|
||||
0x0102: _text("Nintendo"),
|
||||
0x0200: _uint(0x0100), # HIDDeviceReleaseNumber
|
||||
0x0201: _uint(0x0111), # HIDParserVersion
|
||||
0x0202: _uint(0x04, 1), # HIDDeviceSubclass
|
||||
0x0203: _uint(0x33, 1), # HIDCountryCode in the original record
|
||||
0x0204: _boolean(False), # HIDVirtualCable
|
||||
0x0205: _boolean(True), # HIDReconnectInitiate
|
||||
0x0206: _sequence(_sequence(_uint(0x22, 1), _encode(4, descriptor))),
|
||||
0x0207: _sequence(_sequence(_uint(0x0409), _uint(0x0100))),
|
||||
0x0208: _boolean(False), # HIDSDPDisable
|
||||
0x0209: _boolean(True), # HIDBatteryPower
|
||||
0x020A: _boolean(True), # HIDRemoteWake
|
||||
0x020B: _uint(0x0100), # HIDProfileVersion
|
||||
0x020C: _uint(0x0C80), # HIDSupervisionTimeout
|
||||
0x020D: _boolean(False), # HIDNormallyConnectable
|
||||
0x020E: _boolean(False), # HIDBootDevice
|
||||
}
|
||||
pnp = {
|
||||
0x0000: _uint(0x00010001, 4),
|
||||
0x0001: _sequence(_uuid(0x1200)),
|
||||
0x0005: browse,
|
||||
0x0006: language,
|
||||
0x0009: _sequence(_sequence(_uuid(0x1200), _uint(0x0103))),
|
||||
0x0100: _text("Nintendo RVL-CNT-01"),
|
||||
0x0102: _text("Nintendo"),
|
||||
0x0200: _uint(0x0103), # Device ID specification version
|
||||
0x0201: _uint(0x057E), # USB vendor: Nintendo
|
||||
0x0202: _uint(0x0306), # Original Wii Remote
|
||||
0x0203: _uint(0x0100), # Device version
|
||||
0x0204: _boolean(True), # PrimaryRecord
|
||||
0x0205: _uint(0x0002), # VendorIDSource: USB-IF
|
||||
}
|
||||
return {0x00010000: hid, 0x00010001: pnp}
|
||||
|
||||
|
||||
def _collect_uuids(element: _Element, result: set[bytes]) -> None:
|
||||
if element.kind == 3:
|
||||
uuid = element.value
|
||||
if element.size != 16:
|
||||
uuid = uuid.rjust(4, b"\x00") + _UUID_BASE_SUFFIX
|
||||
result.add(uuid)
|
||||
elif element.kind in (6, 7):
|
||||
for child in element.value:
|
||||
_collect_uuids(child, result)
|
||||
|
||||
|
||||
def _attribute_list(
|
||||
record: dict[int, bytes], ranges: tuple[tuple[int, int], ...]
|
||||
) -> bytes:
|
||||
return _encode(
|
||||
6,
|
||||
b"".join(
|
||||
_uint(attribute) + value
|
||||
for attribute, value in sorted(record.items())
|
||||
if any(first <= attribute <= last for first, last in ranges)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SdpResponder:
|
||||
"""Respond to one complete SDP request PDU at a time, without socket IO.
|
||||
|
||||
``mtu`` is the peer's L2CAP receive MTU, including the five-byte SDP
|
||||
header, but not an L2CAP header. Keep this instance for the lifetime of
|
||||
an SDP connection. At most 64 recently issued continuation states are
|
||||
retained; evicted or mismatched states receive Invalid Continuation
|
||||
State. Transaction IDs and attribute byte limits may change between
|
||||
fragments; the actual query may not.
|
||||
"""
|
||||
|
||||
def __init__(self, report_descriptor: bytes):
|
||||
if not isinstance(report_descriptor, bytes):
|
||||
raise TypeError("report_descriptor must be bytes")
|
||||
if not 1 <= len(report_descriptor) <= _MAX_DESCRIPTOR:
|
||||
raise ValueError("report_descriptor must contain 1 to 4096 bytes")
|
||||
self._records = _records(report_descriptor)
|
||||
self._record_uuids = {}
|
||||
for handle, record in self._records.items():
|
||||
uuids = set()
|
||||
for value in record.values():
|
||||
_collect_uuids(_Reader(value).element(), uuids)
|
||||
self._record_uuids[handle] = uuids
|
||||
self._continuations: OrderedDict[bytes, tuple[tuple, bytes, int]] = (
|
||||
OrderedDict()
|
||||
)
|
||||
self._serial = 0
|
||||
|
||||
def _save(self, query: tuple, data: bytes, offset: int) -> bytes:
|
||||
if self._serial == (1 << (8 * _TOKEN_BYTES)) - 1:
|
||||
raise _SdpError(0x0006)
|
||||
self._serial += 1
|
||||
token = self._serial.to_bytes(_TOKEN_BYTES, "big")
|
||||
self._continuations[token] = (query, data, offset)
|
||||
if len(self._continuations) > _MAX_CONTINUATIONS:
|
||||
self._continuations.popitem(last=False)
|
||||
return token
|
||||
|
||||
def _data(
|
||||
self,
|
||||
pdu: int,
|
||||
target: int | tuple[bytes, ...],
|
||||
ranges: tuple[tuple[int, int], ...],
|
||||
maximum: int,
|
||||
) -> bytes:
|
||||
if pdu == 0x04:
|
||||
record = self._records.get(target)
|
||||
if record is None:
|
||||
raise _SdpError(0x0002)
|
||||
return _attribute_list(record, ranges)
|
||||
handles = [
|
||||
handle
|
||||
for handle, uuids in self._record_uuids.items()
|
||||
if all(uuid in uuids for uuid in target)
|
||||
]
|
||||
if pdu == 0x02:
|
||||
return b"".join(handle.to_bytes(4, "big") for handle in handles[:maximum])
|
||||
return _sequence(
|
||||
*(_attribute_list(self._records[handle], ranges) for handle in handles)
|
||||
)
|
||||
|
||||
def _response(self, pdu: int, reader: _Reader, mtu: int) -> bytes:
|
||||
if pdu not in (0x02, 0x04, 0x06):
|
||||
raise _SdpError(0x0003)
|
||||
target = reader.integer(4) if pdu == 0x04 else reader.search_pattern()
|
||||
maximum = reader.integer(2)
|
||||
if maximum < (1 if pdu == 0x02 else 7):
|
||||
raise _SdpError(0x0003)
|
||||
ranges = () if pdu == 0x02 else reader.attributes()
|
||||
token = reader.continuation()
|
||||
query = (pdu, target, ranges, maximum if pdu == 0x02 else None)
|
||||
if token:
|
||||
state = self._continuations.get(token)
|
||||
if state is None or state[0] != query:
|
||||
raise _SdpError(0x0005)
|
||||
_, data, offset = state
|
||||
self._continuations.move_to_end(token)
|
||||
else:
|
||||
data = self._data(pdu, target, ranges, maximum)
|
||||
offset = 0
|
||||
|
||||
# Attribute fragments may split any data element, but must each
|
||||
# contain at least two bytes; search fragments contain whole handles.
|
||||
search = pdu == 0x02
|
||||
overhead = 10 if search else 8
|
||||
limit = 0xFFFF if search else maximum
|
||||
remaining = len(data) - offset
|
||||
final_capacity = min(limit, mtu - overhead)
|
||||
if remaining <= final_capacity:
|
||||
count = remaining
|
||||
continuation = b"\x00"
|
||||
else:
|
||||
count = min(limit, mtu - overhead - _TOKEN_BYTES)
|
||||
if search:
|
||||
count -= count % 4
|
||||
elif remaining - count == 1:
|
||||
count -= 1
|
||||
if count < (4 if search else 2):
|
||||
raise _SdpError(0x0006)
|
||||
next_token = self._save(query, data, offset + count)
|
||||
continuation = bytes([len(next_token)]) + next_token
|
||||
fragment = data[offset : offset + count]
|
||||
if search:
|
||||
prefix = struct.pack(">HH", len(data) // 4, count // 4)
|
||||
else:
|
||||
prefix = struct.pack(">H", count)
|
||||
return prefix + fragment + continuation
|
||||
|
||||
def reply(self, packet: bytes, mtu: int = 672) -> bytes:
|
||||
"""Return the matching response or an SDP ErrorResponse.
|
||||
|
||||
Invalid local MTUs below seven bytes raise ValueError: even an SDP
|
||||
error cannot fit. Requests over 4096 bytes or overly complex data
|
||||
elements receive Insufficient Resources. A truncated header lacking
|
||||
a complete transaction ID is answered with transaction ID zero.
|
||||
"""
|
||||
if mtu < 7:
|
||||
raise ValueError("MTU cannot fit an SDP ErrorResponse")
|
||||
mtu = min(mtu, 0xFFFF + 5)
|
||||
transaction = int.from_bytes(packet[1:3], "big") if len(packet) >= 3 else 0
|
||||
try:
|
||||
if len(packet) < 5 or int.from_bytes(packet[3:5], "big") != len(packet) - 5:
|
||||
raise _SdpError(0x0004)
|
||||
if len(packet) > _MAX_REQUEST:
|
||||
raise _SdpError(0x0006)
|
||||
response = self._response(packet[0], _Reader(packet[5:]), mtu)
|
||||
response_pdu = packet[0] + 1
|
||||
except _SdpError as error:
|
||||
response_pdu = 0x01
|
||||
response = struct.pack(">H", error.code)
|
||||
return struct.pack(">BHH", response_pdu, transaction, len(response)) + response
|
||||
314
tools/dolphinbar_wiimote.py
Normal file
314
tools/dolphinbar_wiimote.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""RAM-only, neutral RVL-CNT-01 protocol for the DolphinBar experiment.
|
||||
|
||||
Wire formats: https://wiibrew.org/wiki/Wiimote . Report handling facts were
|
||||
cross-checked against rnconrad/WiimoteEmulator and Dolphin's WiimoteEmu.
|
||||
This module contains no transport, physical inputs, audio output, or file I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_OUTPUT_LENGTHS = {
|
||||
0x10: 1,
|
||||
0x11: 1,
|
||||
0x12: 2,
|
||||
0x13: 1,
|
||||
0x14: 1,
|
||||
0x15: 1,
|
||||
0x16: 21,
|
||||
0x17: 6,
|
||||
0x18: 21,
|
||||
0x19: 1,
|
||||
0x1A: 1,
|
||||
}
|
||||
_INPUT_LENGTHS = {
|
||||
0x20: 6,
|
||||
0x21: 21,
|
||||
0x22: 4,
|
||||
0x30: 2,
|
||||
0x31: 5,
|
||||
0x32: 10,
|
||||
0x33: 17,
|
||||
0x34: 21,
|
||||
0x35: 21,
|
||||
0x36: 21,
|
||||
0x37: 21,
|
||||
0x3D: 21,
|
||||
0x3E: 21,
|
||||
0x3F: 21,
|
||||
}
|
||||
|
||||
|
||||
def _hid_descriptor() -> bytes:
|
||||
# Generic Desktop/Game Pad application; vendor-defined, opaque byte arrays.
|
||||
descriptor = bytearray.fromhex("05 01 09 05 a1 01 15 00 26 ff 00 75 08 06 00 ff")
|
||||
for lengths, item in ((_OUTPUT_LENGTHS, 0x91), (_INPUT_LENGTHS, 0x81)):
|
||||
for report_id, size in lengths.items():
|
||||
descriptor.extend((0x85, report_id, 0x95, size, 0x09, 0x01, item, 0x00))
|
||||
descriptor.append(0xC0)
|
||||
return bytes(descriptor)
|
||||
|
||||
|
||||
HID_DESCRIPTOR = _hid_descriptor()
|
||||
|
||||
# High eight accelerometer bits: zero-g = 0x80, one-g = 0x9a.
|
||||
# A stationary, face-up remote measures (0g, 0g, +1g); low bits are zero.
|
||||
_ACCEL_ZERO = 0x80
|
||||
_ACCEL_ONE = 0x9A
|
||||
_ACCEL = bytes((_ACCEL_ZERO, _ACCEL_ZERO, _ACCEL_ONE))
|
||||
_BUTTONS = b"\x00\x00"
|
||||
_ABSENT = b"\xff" * 21
|
||||
_NEUTRAL_REPORTS = {
|
||||
0x30: b"\xa1\x30" + _BUTTONS,
|
||||
0x31: b"\xa1\x31" + _BUTTONS + _ACCEL,
|
||||
0x32: b"\xa1\x32" + _BUTTONS + _ABSENT[:8],
|
||||
0x33: b"\xa1\x33" + _BUTTONS + _ACCEL + _ABSENT[:12],
|
||||
0x34: b"\xa1\x34" + _BUTTONS + _ABSENT[:19],
|
||||
0x35: b"\xa1\x35" + _BUTTONS + _ACCEL + _ABSENT[:16],
|
||||
0x36: b"\xa1\x36" + _BUTTONS + _ABSENT[:19],
|
||||
0x37: b"\xa1\x37" + _BUTTONS + _ACCEL + _ABSENT[:16],
|
||||
0x3D: b"\xa1\x3d" + _ABSENT,
|
||||
# The interleaved button fields carry the high and low nibbles of Z's
|
||||
# eight-bit value, not the low bits used by normal accelerometer reports.
|
||||
0x3E: bytes(
|
||||
(
|
||||
0xA1,
|
||||
0x3E,
|
||||
((_ACCEL_ONE >> 4) & 3) << 5,
|
||||
((_ACCEL_ONE >> 6) & 3) << 5,
|
||||
_ACCEL_ZERO,
|
||||
)
|
||||
)
|
||||
+ _ABSENT[:18],
|
||||
0x3F: bytes(
|
||||
(0xA1, 0x3F, (_ACCEL_ONE & 3) << 5, ((_ACCEL_ONE >> 2) & 3) << 5, _ACCEL_ZERO)
|
||||
)
|
||||
+ _ABSENT[:18],
|
||||
}
|
||||
|
||||
|
||||
def _calibration_block(data: bytes) -> bytes:
|
||||
return data + bytes(((sum(data) + 0x55) & 0xFF,))
|
||||
|
||||
|
||||
def _new_eeprom() -> bytearray:
|
||||
eeprom = bytearray(0x1700)
|
||||
# Symmetric factory reference points in all four camera quadrants. These
|
||||
# are calibration constants only, never emitted as observed IR spots.
|
||||
points = ((128, 128), (896, 128), (128, 640), (896, 640))
|
||||
packed = bytearray()
|
||||
for index in (0, 2):
|
||||
x1, y1 = points[index]
|
||||
x2, y2 = points[index + 1]
|
||||
high = ((y1 >> 8) << 6) | ((x1 >> 8) << 4) | ((y2 >> 8) << 2) | (x2 >> 8)
|
||||
packed.extend((x1 & 0xFF, y1 & 0xFF, high, x2 & 0xFF, y2 & 0xFF))
|
||||
ir_calibration = _calibration_block(bytes(packed))
|
||||
accel_calibration = _calibration_block(
|
||||
bytes(
|
||||
(
|
||||
_ACCEL_ZERO,
|
||||
_ACCEL_ZERO,
|
||||
_ACCEL_ZERO,
|
||||
0,
|
||||
_ACCEL_ONE,
|
||||
_ACCEL_ONE,
|
||||
_ACCEL_ONE,
|
||||
0,
|
||||
0x40,
|
||||
)
|
||||
)
|
||||
)
|
||||
eeprom[0x00:0x0B] = ir_calibration
|
||||
eeprom[0x0B:0x16] = ir_calibration
|
||||
eeprom[0x16:0x20] = accel_calibration
|
||||
eeprom[0x20:0x2A] = accel_calibration
|
||||
return eeprom
|
||||
|
||||
|
||||
class Wiimote:
|
||||
"""An original remote with no buttons pressed, extension, or visible IR.
|
||||
|
||||
``handle_output`` consumes report ID + its exact descriptor-sized payload;
|
||||
callers remove the Bluetooth 0xa2/0x52 header. Every returned packet already
|
||||
includes 0xa1. Drain command replies before requesting periodic reports.
|
||||
There is no internal reply queue: a read returns at most 368 packets.
|
||||
EEPROM writes change only this instance's RAM, not the simulated sensor.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ir_enabled = False # Report 0x13 also controls the status flag/I2C.
|
||||
self.ir_secondary_enabled = False # Report 0x1a, the second camera gate.
|
||||
self.report_mode = 0x30
|
||||
self.rumble = False
|
||||
self.leds = 0
|
||||
self.speaker_enabled = False
|
||||
self.speaker_muted = False
|
||||
self.speaker_data = b"" # Last accepted FIFO packet only; never played.
|
||||
self._continuous = False
|
||||
self._report_pending = True
|
||||
self._interleaved_next = 0x3E
|
||||
self._pair_pending = False
|
||||
self._eeprom = _new_eeprom()
|
||||
self._speaker = bytearray(0x0A)
|
||||
self._camera = bytearray(0x5B)
|
||||
self._camera[0x37:0x5B] = b"\xff" * 36
|
||||
|
||||
@staticmethod
|
||||
def _ack(report_id: int, error: int = 0) -> bytes:
|
||||
return bytes((0xA1, 0x22, 0, 0, report_id, error))
|
||||
|
||||
@staticmethod
|
||||
def _read_reply(address: int, data: bytes = b"", error: int = 0) -> bytes:
|
||||
size = 16 if error else len(data)
|
||||
return bytes(
|
||||
(
|
||||
0xA1,
|
||||
0x21,
|
||||
0,
|
||||
0,
|
||||
((size - 1) << 4) | error,
|
||||
(address >> 8) & 0xFF,
|
||||
address & 0xFF,
|
||||
)
|
||||
) + data.ljust(16, b"\x00")
|
||||
|
||||
def status_report(self) -> bytes:
|
||||
"""Return full battery, no extension, and the actual LED/feature flags."""
|
||||
flags = (
|
||||
self.leds | (int(self.speaker_enabled) << 2) | (int(self.ir_enabled) << 3)
|
||||
)
|
||||
return bytes((0xA1, 0x20, 0, 0, flags, 0, 0, 0xC0))
|
||||
|
||||
def _memory(
|
||||
self, flags: int, address: int, size: int, writing: bool = False
|
||||
) -> tuple[bytearray | None, int, int]:
|
||||
"""Resolve the whole transfer before mutation; never resize a bank."""
|
||||
space = flags & 0x0C
|
||||
if space == 0x0C:
|
||||
return None, 0, 6 # Invalid address space, not EEPROM or I2C.
|
||||
if space == 0:
|
||||
offset = address & 0xFFFF # EEPROM mirrors every 64 KiB.
|
||||
if offset + size <= len(self._eeprom):
|
||||
return self._eeprom, offset, 0
|
||||
return None, 0, 8
|
||||
|
||||
peripheral = (address >> 16) & 0xFE
|
||||
offset = address & 0xFF # Peripheral register high address byte is ignored.
|
||||
if peripheral in (0xA4, 0xA6):
|
||||
return None, 0, 7 # No extension or MotionPlus on the I2C bus.
|
||||
if peripheral == 0xA2:
|
||||
if offset + size <= len(self._speaker):
|
||||
return self._speaker, offset, 0
|
||||
elif peripheral == 0xB0:
|
||||
if not self.ir_enabled:
|
||||
return None, 0, 7
|
||||
if offset + size <= 0x34:
|
||||
return self._camera, offset, 0
|
||||
if 0x37 <= offset and offset + size <= len(self._camera):
|
||||
if writing:
|
||||
return None, 0, 7 # Sensor output is read-only.
|
||||
return self._camera, offset, 0
|
||||
return None, 0, 8
|
||||
|
||||
def handle_output(self, report: bytes) -> list[bytes]:
|
||||
"""Apply a well-formed command atomically and return its wire replies.
|
||||
|
||||
Unsupported/malformed commands return error 3, absent I2C extensions
|
||||
error 7, invalid addresses error 8, invalid address-space selection 6.
|
||||
Malformed reports never even change rumble. Empty input has no report
|
||||
ID to acknowledge and is ignored. Valid writes always ACK; feature/mode
|
||||
commands ACK only when requested by bit 1. Status, reads, rumble and
|
||||
speaker streaming have their own reply rules, as on the original remote.
|
||||
"""
|
||||
if not report:
|
||||
return []
|
||||
report_id = report[0]
|
||||
expected = _OUTPUT_LENGTHS.get(report_id)
|
||||
if expected is None or len(report) != expected + 1:
|
||||
return [self._ack(report_id, 3)]
|
||||
flags = report[1]
|
||||
|
||||
if report_id == 0x12 and report[2] not in _NEUTRAL_REPORTS:
|
||||
return [self._ack(report_id, 3)]
|
||||
if report_id == 0x18 and not 1 <= flags >> 3 <= 20:
|
||||
return [self._ack(report_id, 3)]
|
||||
|
||||
if report_id in (0x16, 0x17):
|
||||
address = int.from_bytes(report[2:5], "big")
|
||||
size = (
|
||||
report[5] if report_id == 0x16 else int.from_bytes(report[5:7], "big")
|
||||
)
|
||||
if size == 0 or (report_id == 0x16 and size > 16):
|
||||
if report_id == 0x17:
|
||||
return [self._read_reply(address, error=3)]
|
||||
return [self._ack(report_id, 3)]
|
||||
# Register zero of the speaker is a streaming FIFO, not a write
|
||||
# spanning configuration registers. Keep only the latest samples.
|
||||
speaker_fifo = (
|
||||
report_id == 0x16
|
||||
and flags & 0x0C in (4, 8)
|
||||
and (address >> 16) & 0xFE == 0xA2
|
||||
and address & 0xFF == 0
|
||||
)
|
||||
if speaker_fifo:
|
||||
self.speaker_data = report[6 : 6 + size]
|
||||
self.rumble = bool(flags & 1)
|
||||
return [self._ack(report_id)]
|
||||
bank, offset, error = self._memory(flags, address, size, report_id == 0x16)
|
||||
if error:
|
||||
if report_id == 0x17:
|
||||
return [self._read_reply(address, error=error)]
|
||||
return [self._ack(report_id, error)]
|
||||
self.rumble = bool(flags & 1)
|
||||
if report_id == 0x16:
|
||||
bank[offset : offset + size] = report[6 : 6 + size]
|
||||
return [self._ack(report_id)]
|
||||
return [
|
||||
self._read_reply(
|
||||
address + index,
|
||||
bytes(bank[offset + index : offset + min(index + 16, size)]),
|
||||
)
|
||||
for index in range(0, size, 16)
|
||||
]
|
||||
|
||||
self.rumble = bool(flags & 1)
|
||||
if report_id == 0x10:
|
||||
return []
|
||||
if report_id == 0x11:
|
||||
self.leds = flags & 0xF0
|
||||
elif report_id == 0x12:
|
||||
self.report_mode = report[2]
|
||||
self._continuous = bool(flags & 4)
|
||||
self._report_pending = True
|
||||
self._interleaved_next = 0x3E
|
||||
self._pair_pending = False
|
||||
elif report_id == 0x13:
|
||||
self.ir_enabled = bool(flags & 4)
|
||||
elif report_id == 0x14:
|
||||
self.speaker_enabled = bool(flags & 4)
|
||||
elif report_id == 0x15:
|
||||
return [self.status_report()]
|
||||
elif report_id == 0x18:
|
||||
self.speaker_data = report[2 : 2 + (flags >> 3)]
|
||||
return []
|
||||
elif report_id == 0x19:
|
||||
self.speaker_muted = bool(flags & 4)
|
||||
elif report_id == 0x1A:
|
||||
self.ir_secondary_enabled = bool(flags & 4)
|
||||
return [self._ack(report_id)] if flags & 2 else []
|
||||
|
||||
def periodic_report(self) -> bytes | None:
|
||||
"""Return one 100-Hz tick's neutral report, or None if unchanged.
|
||||
|
||||
Interleaved modes always finish their 0x3e/0x3f pair, even with continuous
|
||||
reporting disabled. The requested mode remains stable for logging.
|
||||
Immutable packets are shared; idle/continuous ticks do not allocate.
|
||||
"""
|
||||
if not (self._continuous or self._report_pending or self._pair_pending):
|
||||
return None
|
||||
self._report_pending = False
|
||||
if self.report_mode in (0x3E, 0x3F):
|
||||
report_id = self._interleaved_next
|
||||
self._pair_pending = report_id == 0x3E
|
||||
self._interleaved_next = 0x3F if self._pair_pending else 0x3E
|
||||
return _NEUTRAL_REPORTS[report_id]
|
||||
return _NEUTRAL_REPORTS[self.report_mode]
|
||||
Loading…
Add table
Add a link
Reference in a new issue