feat: add Bluetooth Android companion
This commit is contained in:
Executable
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BlueZ GATT transport for the Pi Car Companion Android application."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from collections import deque
|
||||
|
||||
import dbus
|
||||
import dbus.exceptions
|
||||
import dbus.mainloop.glib
|
||||
import dbus.service
|
||||
from gi.repository import GLib
|
||||
|
||||
BLUEZ = "org.bluez"
|
||||
DBUS_OM = "org.freedesktop.DBus.ObjectManager"
|
||||
DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
|
||||
GATT_MANAGER = "org.bluez.GattManager1"
|
||||
GATT_SERVICE = "org.bluez.GattService1"
|
||||
GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1"
|
||||
AD_MANAGER = "org.bluez.LEAdvertisingManager1"
|
||||
ADVERTISEMENT = "org.bluez.LEAdvertisement1"
|
||||
|
||||
SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||
RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||
TX_UUID = "6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||
API_BASE = os.environ.get("MOBILE_API_BASE", "http://127.0.0.1:8787")
|
||||
MAX_MESSAGE_BYTES = 64 * 1024
|
||||
NOTIFY_CHUNK_BYTES = 180
|
||||
|
||||
|
||||
class InvalidArgs(dbus.exceptions.DBusException):
|
||||
_dbus_error_name = "org.freedesktop.DBus.Error.InvalidArgs"
|
||||
|
||||
|
||||
class NotSupported(dbus.exceptions.DBusException):
|
||||
_dbus_error_name = "org.bluez.Error.NotSupported"
|
||||
|
||||
|
||||
class Application(dbus.service.Object):
|
||||
def __init__(self, bus):
|
||||
self.path = "/org/pi_car_companion"
|
||||
self.services = []
|
||||
super().__init__(bus, self.path)
|
||||
|
||||
def add_service(self, service):
|
||||
self.services.append(service)
|
||||
|
||||
@dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
|
||||
def GetManagedObjects(self):
|
||||
objects = {}
|
||||
for service in self.services:
|
||||
objects[service.path] = service.properties()
|
||||
for characteristic in service.characteristics:
|
||||
objects[characteristic.path] = characteristic.properties()
|
||||
return objects
|
||||
|
||||
|
||||
class Service(dbus.service.Object):
|
||||
def __init__(self, bus, index, uuid_value):
|
||||
self.path = f"/org/pi_car_companion/service{index}"
|
||||
self.uuid = uuid_value
|
||||
self.characteristics = []
|
||||
super().__init__(bus, self.path)
|
||||
|
||||
def add_characteristic(self, characteristic):
|
||||
self.characteristics.append(characteristic)
|
||||
|
||||
def properties(self):
|
||||
return {GATT_SERVICE: {"UUID": self.uuid, "Primary": dbus.Boolean(True), "Characteristics": dbus.Array([item.path for item in self.characteristics], signature="o")}}
|
||||
|
||||
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||
def GetAll(self, interface):
|
||||
if interface != GATT_SERVICE:
|
||||
raise InvalidArgs()
|
||||
return self.properties()[GATT_SERVICE]
|
||||
|
||||
|
||||
class Characteristic(dbus.service.Object):
|
||||
def __init__(self, bus, index, uuid_value, flags, service):
|
||||
self.path = service.path + f"/char{index}"
|
||||
self.uuid = uuid_value
|
||||
self.flags = flags
|
||||
self.service = service
|
||||
super().__init__(bus, self.path)
|
||||
|
||||
def properties(self):
|
||||
return {GATT_CHARACTERISTIC: {"Service": dbus.ObjectPath(self.service.path), "UUID": self.uuid, "Flags": dbus.Array(self.flags, signature="s")}}
|
||||
|
||||
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||
def GetAll(self, interface):
|
||||
if interface != GATT_CHARACTERISTIC:
|
||||
raise InvalidArgs()
|
||||
return self.properties()[GATT_CHARACTERISTIC]
|
||||
|
||||
@dbus.service.signal(DBUS_PROPERTIES, signature="sa{sv}as")
|
||||
def PropertiesChanged(self, interface, changed, invalidated):
|
||||
pass
|
||||
|
||||
|
||||
class TxCharacteristic(Characteristic):
|
||||
def __init__(self, bus, service):
|
||||
super().__init__(bus, 1, TX_UUID, ["notify"], service)
|
||||
self.notifying = False
|
||||
self.queue = deque()
|
||||
self.sending = False
|
||||
|
||||
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
|
||||
def StartNotify(self, _options):
|
||||
self.notifying = True
|
||||
|
||||
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
|
||||
def StopNotify(self, _options):
|
||||
self.notifying = False
|
||||
self.queue.clear()
|
||||
|
||||
def send(self, message):
|
||||
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
for offset in range(0, len(encoded), NOTIFY_CHUNK_BYTES):
|
||||
self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES])
|
||||
if not self.sending:
|
||||
self.sending = True
|
||||
GLib.timeout_add(20, self._send_next)
|
||||
|
||||
def _send_next(self):
|
||||
if not self.notifying or not self.queue:
|
||||
self.sending = False
|
||||
return False
|
||||
chunk = self.queue.popleft()
|
||||
value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y")
|
||||
self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, [])
|
||||
return bool(self.queue)
|
||||
|
||||
|
||||
class RxCharacteristic(Characteristic):
|
||||
def __init__(self, bus, service, tx):
|
||||
super().__init__(bus, 0, RX_UUID, ["write", "write-without-response"], service)
|
||||
self.tx = tx
|
||||
self.buffer = bytearray()
|
||||
|
||||
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="aya{sv}")
|
||||
def WriteValue(self, value, _options):
|
||||
self.buffer.extend(bytes(value))
|
||||
if len(self.buffer) > MAX_MESSAGE_BYTES:
|
||||
self.buffer.clear()
|
||||
self.tx.send({"id": None, "ok": False, "error": {"code": "MESSAGE_TOO_LARGE", "message": "Bluetooth message was too large"}})
|
||||
return
|
||||
while b"\n" in self.buffer:
|
||||
raw, _, remaining = self.buffer.partition(b"\n")
|
||||
self.buffer = bytearray(remaining)
|
||||
if raw:
|
||||
threading.Thread(target=self._handle, args=(bytes(raw),), daemon=True).start()
|
||||
|
||||
def _handle(self, raw):
|
||||
request_id = None
|
||||
try:
|
||||
message = json.loads(raw.decode("utf-8"))
|
||||
request_id = message.get("id")
|
||||
operation = message.get("op")
|
||||
token = message.get("token")
|
||||
payload = message.get("payload") or {}
|
||||
method, path, body = route_operation(operation, payload)
|
||||
data = call_api(method, path, body, token)
|
||||
response = {"id": request_id, "ok": True, "data": data}
|
||||
except ProtocolError as error:
|
||||
response = {"id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)}}
|
||||
except Exception:
|
||||
response = {"id": request_id, "ok": False, "error": {"code": "BRIDGE_ERROR", "message": "The Pi Bluetooth bridge could not complete the request"}}
|
||||
GLib.idle_add(self.tx.send, response)
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def route_operation(operation, payload):
|
||||
if operation == "pair":
|
||||
return "POST", "/internal/mobile/pair", payload
|
||||
if operation == "snapshot":
|
||||
return "GET", "/internal/mobile/snapshot", None
|
||||
if operation == "location":
|
||||
return "POST", "/internal/mobile/location", payload
|
||||
if operation == "launchShortcut":
|
||||
shortcut_id = payload.get("id")
|
||||
if not isinstance(shortcut_id, int) or shortcut_id < 1:
|
||||
raise ProtocolError("INVALID_SHORTCUT", "Shortcut ID was invalid")
|
||||
return "POST", f"/internal/mobile/shortcuts/{shortcut_id}/launch", {}
|
||||
raise ProtocolError("UNKNOWN_OPERATION", "That Bluetooth operation is not available")
|
||||
|
||||
|
||||
def call_api(method, path, body, token):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if isinstance(token, str):
|
||||
headers["X-Mobile-Token"] = token
|
||||
request = urllib.request.Request(API_BASE + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
try:
|
||||
detail = json.loads(error.read())
|
||||
api_error = detail.get("error", {})
|
||||
raise ProtocolError(api_error.get("code", "REQUEST_FAILED"), api_error.get("message", "The Pi rejected the request"))
|
||||
except (ValueError, AttributeError):
|
||||
raise ProtocolError("REQUEST_FAILED", "The Pi rejected the request") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error
|
||||
|
||||
|
||||
class Advertisement(dbus.service.Object):
|
||||
def __init__(self, bus):
|
||||
self.path = "/org/pi_car_companion/advertisement0"
|
||||
super().__init__(bus, self.path)
|
||||
|
||||
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||
def GetAll(self, interface):
|
||||
if interface != ADVERTISEMENT:
|
||||
raise InvalidArgs()
|
||||
return {"Type": "peripheral", "ServiceUUIDs": dbus.Array([SERVICE_UUID], signature="s"), "LocalName": "Pi Car"}
|
||||
|
||||
@dbus.service.method(ADVERTISEMENT)
|
||||
def Release(self):
|
||||
pass
|
||||
|
||||
|
||||
def find_adapter(bus):
|
||||
objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if GATT_MANAGER in interfaces and AD_MANAGER in interfaces:
|
||||
return path
|
||||
raise RuntimeError("No Bluetooth LE adapter with GATT server support was found")
|
||||
|
||||
|
||||
def main():
|
||||
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SystemBus()
|
||||
adapter_path = find_adapter(bus)
|
||||
adapter = bus.get_object(BLUEZ, adapter_path)
|
||||
dbus.Interface(adapter, DBUS_PROPERTIES).Set("org.bluez.Adapter1", "Powered", dbus.Boolean(True))
|
||||
application = Application(bus)
|
||||
service = Service(bus, 0, SERVICE_UUID)
|
||||
tx = TxCharacteristic(bus, service)
|
||||
rx = RxCharacteristic(bus, service, tx)
|
||||
service.add_characteristic(rx)
|
||||
service.add_characteristic(tx)
|
||||
application.add_service(service)
|
||||
advertisement = Advertisement(bus)
|
||||
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(application.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
|
||||
dbus.Interface(adapter, AD_MANAGER).RegisterAdvertisement(advertisement.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user