Files
StarPilot/openpilot/starpilot/galaxy_bluetooth/bridge/server.py
T
Danny a531afd02a Bluetooth: Coordinate companion pairing and Android Auto ownership
Expose pairing readiness and coordinate adapter hand-offs with Android Auto.
Show app pairings separately, support individual removal, and improve pairing
controls on both comma screens. Add Galaxy companion endpoints and web asset
manifests, preserve the plain camera look, and fix headless speed-limit shapes.
2026-10-22 15:37:20 -07:00

587 lines
23 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Add a GATT peripheral beside StarPilot's existing Bluetooth service."""
import argparse
import asyncio
import json
import hashlib
import logging
import os
from pathlib import Path
import secrets
import signal
import struct
import time
from dbus_next import BusType, DBusError, Variant
from dbus_next.aio import MessageBus
from dbus_next.constants import PropertyAccess
from dbus_next.service import ServiceInterface, dbus_property, method
import claim
from protocol import (Assembler, INFO_UUID, RX_UUID, SERVICE_UUID, TX_UUID, NOTIFY_UUID,
NOTIFICATION_WINDOW, open_message, packets, seal, stream_tag, notification_packets)
from proxy import GalaxyProxy
from pairing import add_phone, introduce, link_address, load_keys, mark_used, remove_phone
from setup_health import write_ready, clear_ready
ROOT = "/link/galaxy/ble"
SERVICE_PATH = ROOT + "/service0"
GATT_SERVICE = "org.bluez.GattService1"
GATT_CHAR = "org.bluez.GattCharacteristic1"
LOG = logging.getLogger("galaxy-ble")
NOTIFICATION_ACK_TIMEOUT = 15
class Session:
def __init__(self):
self.challenge = secrets.token_bytes(16)
self.counter = 0
self.assembler = Assembler()
self.response = []
self.index = 0
self.busy = False
self.last_seen = time.monotonic()
self.task = None
self.packet_size = 20
self.read_stream = False
self.response_started = 0
self.notification_stream = False
self.notification_tag = b""
self.notification_ack = asyncio.Event()
self.notification_expected_ack = None
self.notification_task = None
self.closed = False
self.phone = None # (id, key) of the phone whose key opened this session
self.introduced = False
self.claiming = False
def close(self):
self.closed = True
if self.task:
self.task.cancel()
if self.notification_task:
self.notification_task.cancel()
class Gateway:
def __init__(self, keys, proxy, key_path=None, claim_path=None):
# keys: {phone id: key}. A bare key is accepted for a single phone.
self.keys = {"default": keys} if isinstance(keys, (bytes, bytearray)) else dict(keys or {})
self.key_path = key_path
self.claim_path = claim_path
self.claim_lock = asyncio.Lock()
self.used = set()
self.proxy = proxy
self.sessions = {}
self.notifier = None
self.notification_lock = asyncio.Lock()
def refresh_key(self):
if self.key_path is None:
return
try:
keys = load_keys(self.key_path)
except Exception:
# Fail closed if the local credential is removed or invalid.
keys = {}
if keys != self.keys:
# Only sessions of forgotten (or re-keyed) phones are revoked; other phones stay connected.
for path, state in list(self.sessions.items()):
if state.phone is None and state.claiming:
continue
if state.phone is None or keys.get(state.phone[0]) != state.phone[1]:
state.close()
del self.sessions[path]
self.keys = keys
LOG.info("Pairing credentials changed; %d phone(s) paired", len(keys))
if not keys and claim.window(self.claim_path) is None:
raise DBusError("org.bluez.Error.NotAuthorized", "No phones are paired")
async def answer_claim(self, state, data):
"""While comma's pairing window is open, give one phone its own key. False when this is not a claim."""
if self.claim_path is None or self.key_path is None or data[:4] != claim.REQUEST_MAGIC or claim.window(self.claim_path) is None:
return False
public, name = claim.parse_request(data)
claim.validate(public)
async with self.claim_lock:
window = claim.window(self.claim_path)
if window is None:
raise ValueError("Pairing window closed")
state.claiming = True
try:
phone_id, key = await asyncio.to_thread(add_phone, name, self.key_path)
state.phone = (phone_id, key)
finally:
state.claiming = False
try:
body = claim.reply(public, state.challenge, {"key": key.hex(), "id": phone_id, "adapterAddress": window["adapterAddress"],
"adapterName": window["adapterName"]})
if not claim.finish(name or "Phone", phone_id, self.claim_path):
raise ValueError("Pairing window closed")
except Exception:
await asyncio.to_thread(remove_phone, phone_id, self.key_path)
raise
linked = await asyncio.to_thread(claim.take_link, self.claim_path)
if linked:
await asyncio.to_thread(link_address, linked[0], linked[1], self.key_path)
if state.closed or load_keys(self.key_path).get(phone_id) != key:
state.closed = True
state.phone = None
raise ValueError("Pairing key was removed before delivery")
frame = struct.pack(">I", len(body)) + body
state.notification_stream = False
state.notification_tag = b""
state.read_stream = True
state.response = list(packets(frame, state.packet_size))
state.index = 0
state.response_started = time.monotonic()
LOG.info("Phone claimed pairing window")
return True
def open_request(self, state, data):
candidates = [state.phone] if state.phone else list(self.keys.items())
for phone_id, key in candidates:
try:
request = open_message(data, key, "request")
except ValueError:
continue
state.phone = (phone_id, key)
return request
raise ValueError("Message is not sealed with a paired phone's key")
async def watch_key(self):
while True:
await asyncio.sleep(0.5)
try:
self.refresh_key()
except DBusError:
pass
def session(self, options, fresh=False):
self.refresh_key()
device = options.get("device")
if not device or not isinstance(device.value, str):
raise DBusError("org.bluez.Error.NotAuthorized", "A device identity is required")
path = device.value
for old_path, state in list(self.sessions.items()):
if time.monotonic() - state.last_seen > 180:
state.close()
del self.sessions[old_path]
if fresh and path in self.sessions:
self.sessions.pop(path).close()
if path not in self.sessions:
if len(self.sessions) >= 4:
raise DBusError("org.bluez.Error.InProgress", "Too many sessions")
LOG.info("Galaxy BLE handshake challenge requested; active sessions=%d", len(self.sessions) + 1)
self.sessions[path] = Session()
self.sessions[path].proxy = self.proxy.new_session() if hasattr(self.proxy, "new_session") else self.proxy
state = self.sessions[path]
state.last_seen = time.monotonic()
if "mtu" in options:
state.packet_size = min(512, max(20, int(options["mtu"].value) - 3))
return state
def write(self, value, options):
state = self.session(options)
if options.get("offset", Variant("q", 0)).value:
raise DBusError("org.bluez.Error.InvalidOffset", "Long writes are not used")
try:
if len(value) == 13 and value[0] == 4:
sequence = struct.unpack(">I", value[9:])[0]
if (not state.notification_stream or not state.response or bytes(value[1:9]) != state.notification_tag
or sequence != state.notification_expected_ack or state.index != sequence + 1
or state.notification_ack.is_set()):
raise ValueError("Unexpected notification acknowledgement")
state.notification_ack.set()
state.notification_expected_ack = None
if state.index == len(state.response):
self.acknowledged(state)
return
if len(value) == 5 and value[0] == 2:
sequence = struct.unpack(">I", value[1:])[0]
if state.notification_stream:
raise ValueError("Notification responses require a tagged acknowledgement")
elif state.read_stream and state.response:
if state.index != len(state.response) or sequence != len(state.response) - 1:
raise ValueError("Unexpected stream acknowledgement")
self.acknowledged(state)
elif state.response and sequence == state.index:
state.index += 1
if state.index == len(state.response):
self.acknowledged(state)
else:
raise ValueError("Unexpected acknowledgement")
return
if state.busy:
raise DBusError("org.bluez.Error.InProgress", "Previous response has not been acknowledged")
data = state.assembler.add(bytes(value))
if data is not None:
state.busy = True
state.task = asyncio.create_task(self.process(state, data))
except ValueError as e:
state.assembler.reset()
raise DBusError("org.bluez.Error.InvalidValueLength", str(e)) from e
def acknowledged(self, state):
LOG.info("Response delivered: %d packets in %.2fs (%s)", len(state.response),
time.monotonic() - state.response_started,
"notification stream" if state.notification_stream else "read stream" if state.read_stream else "per-packet ACK")
state.response = []
state.index = 0
state.busy = False
def read(self, options):
state = self.session(options)
if options.get("offset", Variant("q", 0)).value:
raise DBusError("org.bluez.Error.InvalidOffset", "Packets fit inside the negotiated MTU")
if state.notification_stream:
return b"\x00"
if not state.response or state.index >= len(state.response):
return b"\x00"
packet = state.response[state.index]
if state.read_stream:
# ACK once after the read stream is verified.
state.index += 1
return packet
async def process(self, state, data):
started = time.monotonic()
try:
self.refresh_key()
if state.closed:
return
if await self.answer_claim(state, data):
return
request = self.open_request(state, data)
counter = request.get("counter")
if request.get("session") != state.challenge.hex() or type(counter) is not int or counter <= state.counter:
raise ValueError("Invalid session or replayed request")
if not isinstance(request.get("id"), str) or len(request["id"]) > 64:
raise ValueError("Invalid request identifier")
if counter > 0xffffffffffffffff:
raise ValueError("Invalid counter")
fast = request.get("responseCodec") == 2
notify = fast and request.get("responseFlow") == "notify-window8"
if notify and (self.notifier is None or not self.notifier.notifying):
raise ValueError("Subscribe to notifications before requesting that response flow")
state.counter = counter # Consume before invoking any mutating endpoint.
hello = request.get("phone")
if self.key_path is not None and not state.introduced and isinstance(hello, dict):
# Optional {"name", "replaces"}: the app names itself and drops the entry its new key replaced.
state.introduced = True
try:
await asyncio.to_thread(introduce, state.phone[0], hello.get("name"), hello.get("replaces"), self.key_path)
except (OSError, ValueError):
LOG.warning("Could not record phone name")
if self.key_path is not None and state.phone[0] not in self.used:
self.used.add(state.phone[0])
try:
await asyncio.to_thread(mark_used, state.phone[0], self.key_path)
except (OSError, ValueError):
LOG.warning("Could not record phone use")
response = await asyncio.to_thread(state.proxy.handle, request)
self.refresh_key()
if state.closed:
return
response["session"] = state.challenge.hex()
response["counter"] = counter
state.notification_stream = notify
state.notification_tag = stream_tag(state.challenge.hex(), counter) if notify else b""
state.read_stream = fast and request.get("responseFlow") == "read-stream"
frame = seal(response, state.phone[1], "response", compact_body=fast)
size = state.packet_size if fast else min(180, state.packet_size)
state.response = list(notification_packets(frame, state.notification_tag, size) if notify else packets(frame, size))
state.index = 0
state.response_started = time.monotonic()
LOG.info("Response prepared: %d bytes, %d packets, ATT payload %d, %.2fs (%s)",
len(frame), len(state.response), size, time.monotonic() - started,
"notification stream" if notify else "read stream" if state.read_stream else "per-packet ACK")
if notify:
state.notification_task = asyncio.create_task(self.push_response(state))
except asyncio.CancelledError:
raise
except Exception:
# No unauthenticated error oracle; never log payloads or keys.
LOG.warning("Rejected invalid or unauthenticated BLE message")
state.busy = False
finally:
state.task = None
async def push_response(self, state):
response = state.response
try:
# Serialize broadcasts and tag each client’s frames.
async with self.notification_lock:
while state.response is response and response and not state.closed:
if self.notifier is None or not self.notifier.notifying:
raise RuntimeError("Notification subscription ended")
window = response[state.index:state.index + NOTIFICATION_WINDOW]
state.notification_ack.clear()
state.notification_expected_ack = state.index + len(window) - 1
for packet in window:
if state.closed or not self.notifier.notifying:
raise RuntimeError("Notification session ended")
state.index += 1
self.notifier.send(packet)
# Bound the burst and yield to D-Bus/ATT.
await asyncio.sleep(0.004)
await asyncio.wait_for(state.notification_ack.wait(), timeout=NOTIFICATION_ACK_TIMEOUT)
except asyncio.CancelledError:
raise
except Exception as error:
LOG.warning("Notification transfer interrupted (%s); reconnect to check request outcome", type(error).__name__)
# Keep the session busy until a fresh INFO handshake replaces it.
finally:
if state.notification_task is asyncio.current_task():
state.notification_task = None
class GattService(ServiceInterface):
def __init__(self):
super().__init__(GATT_SERVICE)
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return SERVICE_UUID
@dbus_property(access=PropertyAccess.READ)
def Primary(self) -> 'b':
return True
def props(self):
return {"UUID": Variant("s", self.UUID), "Primary": Variant("b", True)}
class Characteristic(ServiceInterface):
def __init__(self, uuid, flags, gateway, kind):
super().__init__(GATT_CHAR)
self.uuid = uuid
self.flags = flags
self.gateway = gateway
self.kind = kind
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return self.uuid
@dbus_property(access=PropertyAccess.READ)
def Service(self) -> 'o':
return SERVICE_PATH
@dbus_property(access=PropertyAccess.READ)
def Flags(self) -> 'as':
return self.flags
@method()
def ReadValue(self, options: 'a{sv}') -> 'ay':
if self.kind == "info":
if options.get("offset", Variant("q", 0)).value:
raise DBusError("org.bluez.Error.InvalidOffset", "Invalid offset")
return b"\x01" + self.gateway.session(options, fresh=True).challenge
if self.kind == "tx":
return self.gateway.read(options)
raise DBusError("org.bluez.Error.NotSupported", "Write-only characteristic")
@method()
def WriteValue(self, value: 'ay', options: 'a{sv}'):
if self.kind != "rx":
raise DBusError("org.bluez.Error.NotSupported", "Read-only characteristic")
self.gateway.write(value, options)
def props(self):
return {"UUID": Variant("s", self.UUID), "Service": Variant("o", self.Service), "Flags": Variant("as", self.Flags)}
class NotificationCharacteristic(Characteristic):
def __init__(self, gateway):
super().__init__(NOTIFY_UUID, ["notify"], gateway, "notify")
self.value = b""
self.notifying = False
gateway.notifier = self
@dbus_property(access=PropertyAccess.READ)
def Value(self) -> 'ay':
return self.value
@dbus_property(access=PropertyAccess.READ)
def Notifying(self) -> 'b':
return self.notifying
@method()
def StartNotify(self):
if not self.notifying:
self.notifying = True
self.emit_properties_changed({"Notifying": True})
LOG.info("Bluetooth push enabled")
@method()
def StopNotify(self):
self.notifying = False
self.emit_properties_changed({"Notifying": False})
LOG.info("Bluetooth push disabled")
for state in self.gateway.sessions.values():
if state.notification_task:
state.notification_task.cancel()
def send(self, packet):
if not self.notifying:
raise RuntimeError("Not subscribed")
self.value = bytes(packet)
self.emit_properties_changed({"Value": self.value})
def props(self):
return {**super().props(), "Value": Variant("ay", self.value), "Notifying": Variant("b", self.notifying)}
class Application(ServiceInterface):
def __init__(self, objects):
super().__init__("org.freedesktop.DBus.ObjectManager")
self.objects = objects
@method()
def GetManagedObjects(self) -> 'a{oa{sa{sv}}}':
return {path: {interface.name: interface.props()} for path, interface in self.objects.items()}
class Advertisement(ServiceInterface):
def __init__(self):
super().__init__("org.bluez.LEAdvertisement1")
self.released = False
@dbus_property(access=PropertyAccess.READ)
def Type(self) -> 's':
return "peripheral"
@dbus_property(access=PropertyAccess.READ)
def ServiceUUIDs(self) -> 'as':
return [SERVICE_UUID]
@dbus_property(access=PropertyAccess.READ)
def LocalName(self) -> 's':
return "Galaxy"
@method()
def Release(self):
self.released = True
LOG.warning("BLE advertisement released by BlueZ; bridge will re-register after restart")
def create_key(path):
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
key = secrets.token_bytes(32)
with os.fdopen(fd, "w") as f:
json.dump({"key": key.hex()}, f)
return key
async def check_adapter(properties, advertisement):
if advertisement.released:
raise RuntimeError("BlueZ released the Galaxy advertisement")
if not (await asyncio.wait_for(properties.call_get("org.bluez.Adapter1", "Powered"), timeout=5)).value:
raise RuntimeError("Bluetooth switched off")
async def serve(args):
LOG.info("Starting Galaxy bridge pid=%d source=%s", os.getpid(), hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]) # noqa: ASYNC240 -- once at startup
key_path = Path(args.key_file)
clear_ready(key_path.parent)
keys = load_keys(key_path)
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
gateway = Gateway(keys, GalaxyProxy(args.galaxy_port), key_path, claim.CLAIM_PATH)
objects = {
SERVICE_PATH: GattService(),
SERVICE_PATH + "/rx": Characteristic(RX_UUID, ["write"], gateway, "rx"),
SERVICE_PATH + "/tx": Characteristic(TX_UUID, ["read"], gateway, "tx"),
SERVICE_PATH + "/info": Characteristic(INFO_UUID, ["read"], gateway, "info"),
SERVICE_PATH + "/notify": NotificationCharacteristic(gateway),
}
bus.export(ROOT, Application(objects))
for path, interface in objects.items():
bus.export(path, interface)
ad_path = ROOT + "/advertisement"
advertisement = Advertisement()
bus.export(ad_path, advertisement)
introspection = await bus.introspect("org.bluez", "/")
manager = bus.get_proxy_object("org.bluez", "/", introspection).get_interface("org.freedesktop.DBus.ObjectManager")
managed = await manager.call_get_managed_objects()
adapters = [path for path, interfaces in managed.items()
if "org.bluez.GattManager1" in interfaces and "org.bluez.LEAdvertisingManager1" in interfaces]
if args.adapter:
adapters = [p for p in adapters if p.endswith("/" + args.adapter)]
if not adapters:
raise RuntimeError("No BlueZ adapter exposes GATT server and LE advertising. Enable Bluetooth in StarPilot and inspect bluetoothctl show.")
adapter_path = adapters[0]
obj = bus.get_proxy_object("org.bluez", adapter_path, await bus.introspect("org.bluez", adapter_path))
properties = obj.get_interface("org.freedesktop.DBus.Properties")
if not (await properties.call_get("org.bluez.Adapter1", "Powered")).value:
raise RuntimeError("Bluetooth is off. Enable it in StarPilot before starting the bridge.")
gatt = obj.get_interface("org.bluez.GattManager1")
advertising = obj.get_interface("org.bluez.LEAdvertisingManager1")
LOG.info("Registering Galaxy GATT application on %s", adapter_path)
await gatt.call_register_application(ROOT, {})
watcher = asyncio.create_task(gateway.watch_key())
try:
LOG.info("Registering Galaxy BLE advertisement")
await advertising.call_register_advertisement(ad_path, {})
write_ready(key_path.parent)
LOG.info("Galaxy BLE bridge ready on %s; forwarding to localhost:%s", adapter_path, args.galaxy_port)
stopped = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stopped.set)
async def monitor_adapter():
while not stopped.is_set():
await asyncio.sleep(2)
await check_adapter(properties, advertisement)
monitor = asyncio.create_task(monitor_adapter())
stop_waiter = asyncio.create_task(stopped.wait())
disconnected = asyncio.create_task(bus.wait_for_disconnect())
try:
done, _ = await asyncio.wait((monitor, stop_waiter, disconnected), return_when=asyncio.FIRST_COMPLETED)
if disconnected in done:
raise RuntimeError("System D-Bus connection closed")
for task in done:
task.result()
finally:
monitor.cancel()
stop_waiter.cancel()
disconnected.cancel()
await asyncio.gather(monitor, stop_waiter, disconnected, return_exceptions=True)
finally:
LOG.info("Stopping Galaxy bridge; clearing ready status and Bluetooth sessions")
clear_ready(key_path.parent)
watcher.cancel()
try:
await watcher
except asyncio.CancelledError:
pass
for state in gateway.sessions.values():
state.close()
try:
await asyncio.wait_for(advertising.call_unregister_advertisement(ad_path), timeout=5)
except Exception:
pass
try:
await asyncio.wait_for(gatt.call_unregister_application(ROOT), timeout=5)
finally:
bus.disconnect()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--key-file", default="/data/galaxy-ble/pairing.json")
parser.add_argument("--galaxy-port", type=int, default=8082)
parser.add_argument("--adapter", help="Optional adapter, e.g. hci0")
parser.add_argument("--init-key", action="store_true", help="Create a private pairing key and print it once")
args = parser.parse_args()
if args.init_key:
print(create_key(Path(args.key_file)).hex())
return
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
asyncio.run(serve(args))
if __name__ == "__main__":
main()