#!/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()