mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
a531afd02a
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.
587 lines
23 KiB
Python
Executable File
587 lines
23 KiB
Python
Executable File
#!/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()
|