Files
pi-car-companion/scripts/pi-car-companion-bluetooth
T

269 lines
9.7 KiB
Python
Executable File

#!/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"
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, ["indicate"], service)
self.notifying = False
self.queue = deque()
self.sending = False
@dbus.service.method(GATT_CHARACTERISTIC)
def StartNotify(self):
self.notifying = True
if self.queue and not self.sending:
self.sending = True
GLib.idle_add(self._send_next)
@dbus.service.method(GATT_CHARACTERISTIC)
def StopNotify(self):
self.notifying = False
self.queue.clear()
self.sending = False
@dbus.service.method(GATT_CHARACTERISTIC)
def Confirm(self):
if self.queue:
GLib.idle_add(self._send_next)
else:
self.sending = False
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.idle_add(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 False
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
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:
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)
loop = GLib.MainLoop()
registration_errors = []
def registration_failed(error):
registration_errors.append(error)
loop.quit()
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(
application.path,
{},
reply_handler=lambda: None,
error_handler=registration_failed,
)
loop.run()
if registration_errors:
raise registration_errors[0]
if __name__ == "__main__":
main()