264 lines
10 KiB
Python
Executable File
264 lines
10 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"
|
|
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()
|