Files
IQ.Pilot/system/ui/lib/setup_ble.py
T
2026-07-09 05:36:34 -05:00

745 lines
30 KiB
Python

#!/usr/bin/env python3
"""
Konn3kt BLE Setup transport (Phase A) — runs inside the setup zipapp, before any
IQ.Pilot install. Advertises the device on the setup screen so the konn3kt app
can drive Wi-Fi + install over Bluetooth. Self-contained (the compiled
ble-transportd bundle lives on the wiped /data and is unavailable here); uses the
AGNOS system python's gi/BlueZ D-Bus, mirroring the settings transport's GATT +
fragmentation + auth so the app can share client code.
See konn3kt_private/docs/konn3kt_ble_setup_protocol.md for the wire contract.
"""
import hashlib
import hmac
import json
import math
import os
import re
import secrets
import struct
import subprocess
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable
def _import_gi():
# In setup mode /data is wiped, so the installed system's gi symlink
# (/data/openpilot/gi) is gone and the venv has no gi of its own. The real gi
# dist-package + GI typelibs live in the rootfs and survive reset — make them
# importable before falling over. Works unchanged on an installed device too.
import sys
try:
import gi # noqa: F401
except Exception:
for extra in ("/usr/lib/python3/dist-packages", "/usr/lib/python3.12/dist-packages"):
import os
if os.path.isdir(os.path.join(extra, "gi")) and extra not in sys.path:
sys.path.append(extra)
import gi # noqa: F401
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
return gi, Gio, GLib
try:
gi, Gio, GLib = _import_gi()
_GI_AVAILABLE = True
except Exception:
_GI_AVAILABLE = False
BLUEZ_SERVICE = "org.bluez"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
GATT_MANAGER_IFACE = "org.bluez.GattManager1"
LE_ADV_MANAGER_IFACE = "org.bluez.LEAdvertisingManager1"
GATT_SERVICE_IFACE = "org.bluez.GattService1"
GATT_CHRC_IFACE = "org.bluez.GattCharacteristic1"
LE_ADV_IFACE = "org.bluez.LEAdvertisement1"
DEVICE_IFACE = "org.bluez.Device1"
ADAPTER_IFACE = "org.bluez.Adapter1"
SETUP_SERVICE_UUID = "73f2c700-5e40-4d0d-8b7f-fde61f729100"
SETUP_CONTROL_CHAR_UUID = "73f2c701-5e40-4d0d-8b7f-fde61f729100"
SETUP_REQUEST_CHAR_UUID = "73f2c702-5e40-4d0d-8b7f-fde61f729100"
SETUP_RESPONSE_CHAR_UUID = "73f2c703-5e40-4d0d-8b7f-fde61f729100"
# 16-bit ServiceData UUID for the advertisement — a 128-bit service UUID PLUS
# 128-bit service data overflows the 31-byte legacy adv budget, so the setupId
# rides in a compact 16-bit ServiceData AD instead (the full 128-bit service is
# still the GATT service, discovered after connect). BlueZ expands "fe01" to the
# Bluetooth base UUID; the app reads it as 0000fe01-0000-1000-8000-00805f9b34fb.
SETUP_SERVICE_DATA_UUID = "0000fe01-0000-1000-8000-00805f9b34fb"
PROTOCOL_VERSION = 1
FRAME_HEADER = struct.Struct(">IHH")
MAX_FRAGMENT_PAYLOAD = 180
ADAPTER_WAIT_TIMEOUT_S = 8.0
BLUEZ_REGISTER_TIMEOUT_MS = 15000
SESSION_IDLE_TIMEOUT_S = 120
SEQ_REPLAY_WINDOW = 128
HELLO_MAX_SKEW_MS = 300_000
MAX_AUTH_FAILURES = 5
AUTH_LOCKOUT_S = 30.0
def setup_id_for_serial(serial: str) -> str:
return hashlib.sha256(f"k3setup:{serial}".encode("utf-8")).hexdigest()[:16]
def _derive_setup_bdaddr(serial: str) -> str:
raw = bytearray(hashlib.sha256(f"konn3kt-bdaddr:{serial}".encode("utf-8")).digest()[:6])
raw[0] = (raw[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in raw)
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return value
def _canonical(value: Any) -> bytes:
return json.dumps(_json_safe(value), ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def _safe_json(data: dict[str, Any]) -> bytes:
return _canonical(data)
def _hmac_hex(key: bytes, payload: bytes) -> str:
return hmac.new(key, payload, hashlib.sha256).hexdigest()
class SetupAuthError(Exception):
pass
# ---------------------------------------------------------------------------
# Session / auth (6-digit code, mirrors settings-transport HKDF + replay window)
# ---------------------------------------------------------------------------
@dataclass
class SetupSession:
client_id: str
setup_id: str
client_nonce: str
device_nonce: str
session_id: str
authenticated: bool = False
session_key: bytes = b""
last_seen: float = 0.0
highest_seq: int = 0
seen_seq_mask: int = 0
class SetupSessionManager:
def __init__(self, setup_id: str, code_getter: Callable[[], str]):
self._setup_id = setup_id
self._code_getter = code_getter
self._sessions: dict[str, SetupSession] = {}
self._auth_failures = 0
self._lockout_until = 0.0
self._lock = threading.Lock()
def prune(self) -> None:
now = time.monotonic()
with self._lock:
for cid in [c for c, s in self._sessions.items() if (now - s.last_seen) > SESSION_IDLE_TIMEOUT_S]:
self._sessions.pop(cid, None)
def begin_hello(self, client_id: str, setup_id: str, client_nonce: str, timestamp_ms: int) -> dict[str, Any]:
if setup_id != self._setup_id:
raise SetupAuthError("setup_id_mismatch")
now_ms = int(time.time() * 1000)
# NOTE: deliberately NO timestamp-skew check here. A freshly-reset device has
# no network, so its clock is arbitrarily wrong (often weeks off) — a skew
# check would reject every real setup. The 6-digit on-screen code is the
# actual authorization and the per-session nonce prevents replay, so the
# timestamp is informational only.
session = SetupSession(
client_id=client_id,
setup_id=self._setup_id,
client_nonce=str(client_nonce),
device_nonce=secrets.token_hex(8),
session_id=secrets.token_hex(8),
last_seen=time.monotonic(),
)
with self._lock:
self._sessions[client_id] = session
return {
"type": "helloAck",
"protocolVersion": PROTOCOL_VERSION,
"setupId": self._setup_id,
"deviceNonce": session.device_nonce,
"sessionId": session.session_id,
"codeRequired": True,
"timestampMs": now_ms,
}
def _session_key(self, session: SetupSession, code: str) -> bytes:
salt = f"{session.client_nonce}:{session.device_nonce}:{session.session_id}:{self._setup_id}".encode("utf-8")
prk = hmac.new(salt, f"k3setup:{code}".encode("utf-8"), hashlib.sha256).digest()
return hmac.new(prk, b"konn3kt-ble-setup-v1\x01", hashlib.sha256).digest()
def authenticate(self, client_id: str, session_id: str, timestamp_ms: int, proof_hex: str) -> dict[str, Any]:
if time.monotonic() < self._lockout_until:
raise SetupAuthError("auth_locked_out")
session = self._sessions.get(client_id)
if session is None or session.session_id != session_id:
raise SetupAuthError("unknown_session")
code = str(self._code_getter() or "")
key = self._session_key(session, code)
expected = _hmac_hex(key, _canonical({"role": "client-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)}))
if not hmac.compare_digest(expected, str(proof_hex or "").strip().lower()):
self._auth_failures += 1
if self._auth_failures >= MAX_AUTH_FAILURES:
self._lockout_until = time.monotonic() + AUTH_LOCKOUT_S
self._auth_failures = 0
raise SetupAuthError("auth_failed")
self._auth_failures = 0
session.authenticated = True
session.session_key = key
session.highest_seq = 0
session.seen_seq_mask = 0
session.last_seen = time.monotonic()
return {
"type": "authOk",
"sessionId": session_id,
"setupId": self._setup_id,
"timestampMs": int(time.time() * 1000),
"proof": _hmac_hex(key, _canonical({"role": "device-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)})),
}
def validate_request(self, client_id: str, session_id: str) -> SetupSession:
session = self._sessions.get(client_id)
if session is None or not session.authenticated:
raise SetupAuthError("session_not_authenticated")
if session.session_id != session_id:
raise SetupAuthError("session_id_mismatch")
session.last_seen = time.monotonic()
return session
def _check_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq <= 0:
raise SetupAuthError("seq_invalid")
if seq > session.highest_seq:
return
offset = session.highest_seq - seq
if offset >= SEQ_REPLAY_WINDOW or (session.seen_seq_mask >> offset) & 1:
raise SetupAuthError("seq_replayed")
def _consume_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq > session.highest_seq:
shift = seq - session.highest_seq
session.seen_seq_mask = ((session.seen_seq_mask << shift) | 1) & ((1 << SEQ_REPLAY_WINDOW) - 1)
session.highest_seq = seq
else:
session.seen_seq_mask |= 1 << (session.highest_seq - seq)
def validate_signed_request(self, client_id: str, session_id: str, request_id: Any, seq: int, method: str, params: Any, mac_hex: str) -> SetupSession:
session = self.validate_request(client_id, session_id)
if not session.session_key:
raise SetupAuthError("missing_session_key")
self._check_seq(session, seq)
payload = _canonical({"id": request_id, "method": method, "params": params, "seq": int(seq), "sessionId": session_id, "type": "request"})
if not hmac.compare_digest(_hmac_hex(session.session_key, payload), str(mac_hex or "").strip().lower()):
raise SetupAuthError("request_mac_invalid")
self._consume_seq(session, seq)
return session
def build_response(self, session: SetupSession | None, request_id: Any, seq: int | None, *, result: Any = None, error: str | None = None) -> dict[str, Any]:
rtype = "error" if error is not None else "response"
env: dict[str, Any] = {"type": rtype, "id": request_id}
if error is not None:
env["error"] = error
else:
env["result"] = result
if session is not None and session.session_key and seq is not None:
payload = error if error is not None else result
env["seq"] = int(seq)
env["mac"] = _hmac_hex(session.session_key, _canonical({"id": request_id, "payload": payload, "seq": int(seq), "sessionId": session.session_id, "type": rtype}))
return env
# ---------------------------------------------------------------------------
# GATT plumbing (adapted from the proven settings transport, + ServiceData adv)
# ---------------------------------------------------------------------------
def _variant(sig: str, val: Any):
return GLib.Variant(sig, val)
def frame_payload(payload: bytes) -> list[bytes]:
if not payload:
payload = b"{}"
chunk = max(1, MAX_FRAGMENT_PAYLOAD - FRAME_HEADER.size)
chunks = [payload[i:i + chunk] for i in range(0, len(payload), chunk)] or [b""]
total, count = len(payload), len(chunks)
return [FRAME_HEADER.pack(total, idx, count) + c for idx, c in enumerate(chunks)]
@dataclass
class _Reassembly:
total_length: int
fragment_count: int
chunks: dict[int, bytes] = field(default_factory=dict)
def add(self, idx: int, payload: bytes) -> bytes | None:
self.chunks[idx] = payload
if len(self.chunks) != self.fragment_count:
return None
return b"".join(self.chunks[i] for i in range(self.fragment_count))[:self.total_length]
OM_XML = '<node><interface name="org.freedesktop.DBus.ObjectManager"><method name="GetManagedObjects"><arg type="a{oa{sa{sv}}}" name="objects" direction="out"/></method></interface></node>'
PROPS_XML = '<node><interface name="org.freedesktop.DBus.Properties"><method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method><method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method></interface></node>'
SERVICE_XML = '<node><interface name="org.bluez.GattService1"><property name="UUID" type="s" access="read"/><property name="Primary" type="b" access="read"/><property name="Characteristics" type="ao" access="read"/></interface></node>'
CHAR_XML = '<node><interface name="org.bluez.GattCharacteristic1"><method name="ReadValue"><arg type="a{sv}" direction="in"/><arg type="ay" direction="out"/></method><method name="WriteValue"><arg type="ay" direction="in"/><arg type="a{sv}" direction="in"/></method><method name="StartNotify"/><method name="StopNotify"/><property name="UUID" type="s" access="read"/><property name="Service" type="o" access="read"/><property name="Flags" type="as" access="read"/><property name="Value" type="ay" access="read"/><property name="Notifying" type="b" access="read"/></interface></node>'
ADV_XML = '<node><interface name="org.bluez.LEAdvertisement1"><method name="Release"/><property name="Type" type="s" access="read"/><property name="ServiceUUIDs" type="as" access="read"/><property name="LocalName" type="s" access="read"/><property name="ServiceData" type="a{sv}" access="read"/><property name="Includes" type="as" access="read"/></interface></node>'
class _Exported:
def __init__(self, path, xml, methods=None, properties=None):
self.path = path
self.node = Gio.DBusNodeInfo.new_for_xml(xml)
self.methods = methods or {}
self.properties = properties or {}
self.ids: list[int] = []
def register(self, bus):
for iface in self.node.interfaces:
self.ids.append(bus.register_object(self.path, iface, self._call, self._get, None))
def unregister(self, bus):
for i in self.ids:
try:
bus.unregister_object(i)
except Exception:
pass
self.ids.clear()
def _call(self, conn, sender, path, iface, method, params, invocation):
handler = self.methods.get((iface, method))
if handler is None:
invocation.return_dbus_error("org.konn3kt.Error", f"unsupported:{iface}.{method}")
return
try:
result = handler(params)
invocation.return_value(result)
except Exception as e:
invocation.return_dbus_error("org.konn3kt.Error", str(e))
def _get(self, conn, sender, path, iface, name):
props = self.properties.get(iface, {})
return props[name]() if name in props else None
class SetupBleServer:
"""Advertise + serve the setup GATT service. Single central (1:1 setup)."""
def __init__(self, *, serial: str, on_control: Callable[[bytes], bytes | None], on_request: Callable[[bytes], bytes | None]):
self.serial = serial
self.setup_id = setup_id_for_serial(serial)
self.local_name = f"IQSetup-{serial[-6:]}"
self.on_control = on_control
self.on_request = on_request
self.bus = None
self.adapter_path: str | None = None
self.context = None
self.loop = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
self._error: Exception | None = None
self.running = False
self._install_in_progress = False
self._root = f"/io/konn3kt/setup/p{os.getpid()}"
self._objects: list[_Exported] = []
self._notify = {"control": False, "response": False}
self._values = {"control": b"", "request": b"", "response": b""}
self._reassembly: dict[str, _Reassembly] = {}
# ---- lifecycle -----------------------------------------------------------
def start(self, timeout_s: float = 30.0) -> None:
if not _GI_AVAILABLE:
raise RuntimeError("gi_unavailable")
if self.running:
return
self._ensure_unique_bdaddr()
self._thread = threading.Thread(target=self._run, name="setup_ble_gatt", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=timeout_s):
raise RuntimeError("setup_ble_start_timeout")
if self._error is not None:
raise self._error
def stop(self) -> None:
try:
if self.context is not None:
GLib.idle_add(self._stop_on_loop)
if self._thread is not None:
self._thread.join(timeout=3.0)
except Exception:
pass
self.running = False
def set_install_in_progress(self, active: bool) -> None:
self._install_in_progress = bool(active)
# ---- BD address ----------------------------------------------------------
def _run_priv(self, args: list[str], timeout_s: float = 10.0) -> bool:
cmds = ([args] if os.geteuid() == 0 else [["sudo", "-n", *args], args])
for cmd in cmds:
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_s, check=False)
if p.returncode == 0:
return True
except Exception:
continue
return False
def _read_bdaddr(self) -> str | None:
try:
out = subprocess.run(["hciconfig", "hci0"], stdout=subprocess.PIPE, text=True, timeout=5.0, check=False).stdout
m = re.search(r"BD Address:\s*([0-9A-Fa-f:]{17})", out or "")
return m.group(1).upper() if m else None
except Exception:
return None
def _ensure_unique_bdaddr(self) -> None:
current = self._read_bdaddr()
if not current or not current.startswith("00:00:00:00"):
return
target = _derive_setup_bdaddr(self.serial)
octets = [f"0x{p}" for p in target.split(":")]
if not self._run_priv(["hcitool", "-i", "hci0", "cmd", "0x3f", "0x0014", *octets]):
return
time.sleep(0.5)
self._run_priv(["hciconfig", "hci0", "reset"])
time.sleep(1.5)
self._run_priv(["systemctl", "restart", "bluetooth.service"], timeout_s=20.0)
time.sleep(2.0)
# ---- main loop -----------------------------------------------------------
def _run(self):
self.context = GLib.MainContext()
self.loop = GLib.MainLoop.new(self.context, False)
self.context.push_thread_default()
try:
src = GLib.idle_source_new()
src.set_callback(self._startup)
src.attach(self.context)
self.loop.run()
except Exception as e:
self._error = e
self._ready.set()
finally:
try:
self.context.pop_thread_default()
except Exception:
pass
def _find_adapter(self) -> str | None:
objs = self._managed_objects()
for path, ifaces in objs.items():
if GATT_MANAGER_IFACE in ifaces and LE_ADV_MANAGER_IFACE in ifaces and ADAPTER_IFACE in ifaces:
return path
return None
def _managed_objects(self) -> dict:
reply = self.bus.call_sync(BLUEZ_SERVICE, "/", DBUS_OM_IFACE, "GetManagedObjects", None,
GLib.VariantType.new("(a{oa{sa{sv}}})"), Gio.DBusCallFlags.NONE, 5000, None)
u = reply.unpack()
return u[0] if isinstance(u, tuple) else u
def _startup(self, *_):
try:
self._dbg("startup begin")
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
# ensure powered
try:
self.adapter_path = None
start = time.monotonic()
while time.monotonic() - start < ADAPTER_WAIT_TIMEOUT_S:
self.adapter_path = self._find_adapter()
if self.adapter_path:
break
time.sleep(0.5)
if not self.adapter_path:
raise RuntimeError("bluetooth_adapter_not_found")
self._set_powered()
except Exception:
raise
self._dbg("adapter=%s, registering objects" % self.adapter_path)
self._register_objects()
self._dbg("objects registered, calling RegisterApplication")
self._register_with_bluez()
self._dbg("RegisterApplication call issued")
except Exception as e:
import traceback as _tb
self._dbg("startup EXC: %r\n%s" % (e, _tb.format_exc()))
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return False
def _set_powered(self):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, DBUS_PROPS_IFACE, "Set",
GLib.Variant("(ssv)", (ADAPTER_IFACE, "Powered", GLib.Variant("b", True))),
None, Gio.DBusCallFlags.NONE, 5000, None)
except Exception:
pass
# paths
@property
def _app_path(self): return self._root
@property
def _service_path(self): return self._root + "/service0"
@property
def _control_path(self): return self._service_path + "/char0"
@property
def _request_path(self): return self._service_path + "/char1"
@property
def _response_path(self): return self._service_path + "/char2"
@property
def _adv_path(self): return self._root + "/advertisement0"
def _char_props(self, uuid, flags, kind):
return {GATT_CHRC_IFACE: {
"UUID": lambda: _variant("s", uuid),
"Service": lambda: _variant("o", self._service_path),
"Flags": lambda: _variant("as", flags),
"Value": lambda: _variant("ay", list(self._values[kind])),
"Notifying": lambda: _variant("b", self._notify.get(kind, False)),
}}
def _service_data_variant(self):
flags = 0x01 | (0x02 if self._install_in_progress else 0x00)
data = bytes([PROTOCOL_VERSION, flags]) + bytes.fromhex(self.setup_id)
return _variant("a{sv}", {SETUP_SERVICE_DATA_UUID: GLib.Variant("ay", list(data))})
def _register_objects(self):
chars = [self._control_path, self._request_path, self._response_path]
service_props = {GATT_SERVICE_IFACE: {
"UUID": lambda: _variant("s", SETUP_SERVICE_UUID),
"Primary": lambda: _variant("b", True),
"Characteristics": lambda: _variant("ao", chars),
}}
# Keep the adv within the 31-byte legacy budget: 16-bit ServiceData (setupId)
# + a short LocalName, no 128-bit ServiceUUID, no tx-power. The app scans by
# the ServiceData UUID and matches setupId locally against its known serials.
adv_props = {LE_ADV_IFACE: {
"Type": lambda: _variant("s", "peripheral"),
"LocalName": lambda: _variant("s", "IQSetup"),
"ServiceData": self._service_data_variant,
}}
def mk_props(path, pmap):
def get_prop(params):
iface, name = params.unpack()
getter = pmap.get(iface, {}).get(name)
if getter is None:
raise RuntimeError(f"unknown_property:{iface}.{name}")
return GLib.Variant("(v)", (getter(),))
def get_all(params):
(iface,) = params.unpack()
pm = pmap.get(iface)
if pm is None:
raise RuntimeError(f"unknown_interface:{iface}")
return GLib.Variant("(a{sv})", ({k: g() for k, g in pm.items()},))
return _Exported(path, PROPS_XML, methods={(DBUS_PROPS_IFACE, "Get"): get_prop, (DBUS_PROPS_IFACE, "GetAll"): get_all})
app = _Exported(self._app_path, OM_XML, methods={(DBUS_OM_IFACE, "GetManagedObjects"): self._get_managed})
service = _Exported(self._service_path, SERVICE_XML, properties=service_props)
control = self._mk_char("control", self._control_path, SETUP_CONTROL_CHAR_UUID, ["write", "notify"])
request = self._mk_char("request", self._request_path, SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"])
response = self._mk_char("response", self._response_path, SETUP_RESPONSE_CHAR_UUID, ["notify"])
adv = _Exported(self._adv_path, ADV_XML, methods={(LE_ADV_IFACE, "Release"): lambda _: None}, properties=adv_props)
self._objects = [
app, mk_props(self._app_path, {}),
service, mk_props(self._service_path, service_props),
control, mk_props(self._control_path, self._char_props(SETUP_CONTROL_CHAR_UUID, ["write", "notify"], "control")),
request, mk_props(self._request_path, self._char_props(SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"], "request")),
response, mk_props(self._response_path, self._char_props(SETUP_RESPONSE_CHAR_UUID, ["notify"], "response")),
adv, mk_props(self._adv_path, adv_props),
]
for o in self._objects:
o.register(self.bus)
def _mk_char(self, kind, path, uuid, flags):
def read_value(_params):
return GLib.Variant("(ay)", (list(self._values[kind]),))
def write_value(params):
value, options = params.unpack()
payload = bytes(int(x) & 0xFF for x in value) if isinstance(value, (list, tuple)) else bytes(value or b"")
full = self._consume_fragment(kind, payload)
if full is None:
return None
self._values[kind] = full
if kind == "control":
resp = self.on_control(full)
if resp:
self.notify("control", resp)
elif kind == "request":
# Handle on a worker so a slow op (wifi scan, connect) never stalls BLE.
threading.Thread(target=self._handle_request_worker, args=(full,), daemon=True).start()
return None
def start_notify(_params):
self._notify[kind] = True
return None
def stop_notify(_params):
self._notify[kind] = False
return None
return _Exported(path, CHAR_XML, methods={
(GATT_CHRC_IFACE, "ReadValue"): read_value,
(GATT_CHRC_IFACE, "WriteValue"): write_value,
(GATT_CHRC_IFACE, "StartNotify"): start_notify,
(GATT_CHRC_IFACE, "StopNotify"): stop_notify,
}, properties=self._char_props(uuid, flags, kind))
def _handle_request_worker(self, full: bytes):
try:
resp = self.on_request(full)
if resp:
self.notify("response", resp)
except Exception:
pass
def _get_managed(self, _params):
managed = {
self._service_path: {GATT_SERVICE_IFACE: {
"UUID": _variant("s", SETUP_SERVICE_UUID), "Primary": _variant("b", True),
"Characteristics": _variant("ao", [self._control_path, self._request_path, self._response_path])}},
self._control_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_CONTROL_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "notify"])}},
self._request_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_REQUEST_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "write-without-response"])}},
self._response_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_RESPONSE_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["notify"])}},
}
return GLib.Variant("(a{oa{sa{sv}}})", (managed,))
def _consume_fragment(self, kind, fragment):
if len(fragment) < FRAME_HEADER.size:
raise RuntimeError("fragment_too_small")
total, idx, count = FRAME_HEADER.unpack(fragment[:FRAME_HEADER.size])
if count <= 0 or idx >= count:
raise RuntimeError("invalid_fragment_header")
st = self._reassembly.get(kind)
if st is None or st.total_length != total or st.fragment_count != count:
st = _Reassembly(total_length=total, fragment_count=count)
self._reassembly[kind] = st
payload = st.add(idx, fragment[FRAME_HEADER.size:])
if payload is not None:
self._reassembly.pop(kind, None)
return payload
def notify(self, kind: str, payload: bytes):
path = {"control": self._control_path, "response": self._response_path}.get(kind)
if not self.running or self.bus is None or not self._notify.get(kind) or path is None:
return
for fragment in frame_payload(payload):
self._values[kind] = fragment
try:
self.bus.emit_signal(None, path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (GATT_CHRC_IFACE, {"Value": _variant("ay", list(fragment))}, [])))
except Exception:
pass
def refresh_advertisement(self):
"""Re-emit ServiceData (e.g. install-in-progress flag flipped)."""
if not self.running or self.bus is None:
return
try:
self.bus.emit_signal(None, self._adv_path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (LE_ADV_IFACE, {"ServiceData": self._service_data_variant()}, [])))
except Exception:
pass
# ---- bluez registration --------------------------------------------------
def _register_with_bluez(self):
self.bus.call(BLUEZ_SERVICE, self.adapter_path, GATT_MANAGER_IFACE, "RegisterApplication",
GLib.Variant("(oa{sv})", (self._app_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_app_registered, None)
def _on_app_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("app_registered OK adv_path=%s" % self._adv_path)
except GLib.GError as e:
self._dbg("app_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.bus.call(BLUEZ_SERVICE, self.adapter_path, LE_ADV_MANAGER_IFACE, "RegisterAdvertisement",
GLib.Variant("(oa{sv})", (self._adv_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_adv_registered, None)
def _dbg(self, msg):
try:
import os as _os
fd = _os.open("/data/setup_test/setup_ble_dbg.log", _os.O_WRONLY | _os.O_CREAT | _os.O_APPEND, 0o644)
_os.write(fd, (msg + "\n").encode())
_os.close(fd)
except Exception:
pass
def _on_adv_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("adv_registered OK")
except GLib.GError as e:
self._dbg("adv_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.running = True
self._error = None
self._ready.set()
def _clear_registrations(self):
if self.bus is None or self.adapter_path is None:
return
for iface, method, arg in (
(LE_ADV_MANAGER_IFACE, "UnregisterAdvertisement", self._adv_path),
(GATT_MANAGER_IFACE, "UnregisterApplication", self._app_path),
):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, iface, method, GLib.Variant("(o)", (arg,)),
None, Gio.DBusCallFlags.NONE, 3000, None)
except Exception:
pass
def _stop_on_loop(self):
if self.bus is not None and self.adapter_path is not None:
self._clear_registrations()
if self.bus is not None:
for o in reversed(self._objects):
try:
o.unregister(self.bus)
except Exception:
pass
self._objects.clear()
self.running = False
if self.loop:
try:
self.loop.quit()
except Exception:
pass