"""Bounded X4 BLE discovery/GATT inspection; optional allowlisted option reads. Executed by the versioned diagnostic artifact or the installed model package. Default: standard Device Information only. Explicit read-options admits command8 for identity, then USB mode/wakeup only after exact serial identity matches. No pairing, camera setters, adapter power, trust, routing or service edits. """ import argparse import hashlib import json import re import sys import time from datetime import UTC, datetime from pathlib import Path if "__file__" in globals(): # Installed entry also works with python -I. Pyz injects its verified codec. sys.path.insert(0, str(Path(__file__).resolve().parent)) BLUEZ = "org.bluez" ADAPTER = "org.bluez.Adapter1" DEVICE = "org.bluez.Device1" SERVICE = "org.bluez.GattService1" CHAR = "org.bluez.GattCharacteristic1" PROPS = "org.freedesktop.DBus.Properties" MANAGER = "org.freedesktop.DBus.ObjectManager" UUID_BASE = "-0000-1000-8000-00805f9b34fb" def timestamp(): return {"utc": datetime.now(UTC).isoformat(), "monotonic": time.monotonic()} def camera_name(value): return isinstance(value, str) and re.fullmatch(r"X4 [A-Za-z0-9]{6}", value) is not None def usb_snapshot(): devices = [] for path in Path("/sys/bus/usb/devices").glob("*"): try: if (path / "product").read_text().strip() != "Insta360 X4": continue item = {"port": path.name} for key in ("idVendor", "idProduct", "serial", "speed"): if (path / key).exists(): item[key] = (path / key).read_text().strip() devices.append(item) except OSError: continue return {"devices": devices, **timestamp()} def assert_no_usb_owner(expected_id): for device in usb_snapshot()["devices"]: serial = device.get("serial", "") ident = "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32] if (device.get("idVendor"), device.get("idProduct")) == ("2e1a", "0002") and ( ident == expected_id or not serial ): raise RuntimeError("Target USB SDK mode is present; preserve its current owner") def plain(value): if isinstance(value, (bytes, bytearray)): return list(value) if isinstance(value, dict): return {str(k): plain(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [plain(v) for v in value] if isinstance(value, str): return str(value) if isinstance(value, int): return int(value) return value class Inspector: def __init__(self, report): # System packages declared by the artifact manifest and Debian package. # Missing prerequisites are an error, never an implicit pip/apt repair. import dbus from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib DBusGMainLoop(set_as_default=True) self.dbus, self.glib = dbus, GLib self.bus = dbus.SystemBus(private=True) self.manager = self.iface("/", MANAGER) self.report, self.fresh = report, {} self.adapter = None self.scanning = False self.owned_connection = None self.owned_notify = None def iface(self, path, name): return self.dbus.Interface(self.bus.get_object(BLUEZ, path), name) def objects(self): return self.manager.GetManagedObjects(timeout=5) def pump(self, duration, predicate=None): deadline = time.monotonic() + duration next_usb = 0 context = self.glib.MainContext.default() while time.monotonic() < deadline: if time.monotonic() >= next_usb: self.report.setdefault("usb_samples", []).append(usb_snapshot()) next_usb = time.monotonic() + 1 for _ in range(100): if not context.pending(): break context.iteration(False) if predicate is not None and predicate(): return True time.sleep(0.05) return False def remember(self, path, props): if not camera_name(str(props.get("Name", ""))): return self.fresh[str(path)] = {"observed": timestamp(), "properties": plain(props)} def added(self, path, interfaces): if DEVICE in interfaces: self.remember(path, interfaces[DEVICE]) def changed(self, interface, changes, invalidated, path=None): if interface != DEVICE or not {"RSSI", "ManufacturerData", "ServiceData", "Name"} & set( changes ): return props = self.objects().get(path, {}).get(DEVICE, {}) self.remember(path, props) def scan(self, duration=20): if duration not in {20, 60}: raise ValueError("Discovery duration must be 20 or 60 seconds") objects = self.objects() adapters = [(str(p), x[ADAPTER]) for p, x in objects.items() if ADAPTER in x] self.report["adapters"] = [{"path": p, "properties": plain(x)} for p, x in adapters] powered = [(p, x) for p, x in adapters if bool(x.get("Powered"))] if len(powered) != 1: raise RuntimeError("Exactly one powered BLE adapter is required") path, props = powered[0] self.adapter = self.iface(path, ADAPTER) self.bus.add_signal_receiver( self.added, dbus_interface=MANAGER, signal_name="InterfacesAdded", bus_name=BLUEZ ) self.bus.add_signal_receiver( self.changed, dbus_interface=PROPS, signal_name="PropertiesChanged", bus_name=BLUEZ, path_keyword="path", ) self.adapter.SetDiscoveryFilter( self.dbus.Dictionary( { "Transport": "le", "Pattern": "X4 ", "DuplicateData": False, }, signature="sv", ), timeout=5, ) self.adapter.StartDiscovery(timeout=5) self.scanning = True self.report["scan_started"] = timestamp() self.pump(duration) self.adapter.StopDiscovery(timeout=5) self.scanning = False self.report["scan_finished"] = timestamp() self.report["candidates"] = self.fresh return self.fresh def inspect(self, address, expected_id, read_options=False): assert_no_usb_owner(expected_id) candidates = [ (p, x) for p, x in self.fresh.items() if str(x["properties"].get("Address", "")).upper() == address ] if len(candidates) != 1: raise RuntimeError("Target must have exactly one fresh X4 advertisement") path, _ = candidates[0] before = self.objects()[path][DEVICE] if before.get("Connected") or before.get("Paired") or before.get("Bonded"): raise RuntimeError("Existing connection or pairing is outside initial inspection") self.report["target"] = {"path": path, "before": plain(before)} device = self.iface(path, DEVICE) self.owned_connection = path device.Connect(timeout=20) ready = self.pump( 10, lambda: bool(self.objects().get(path, {}).get(DEVICE, {}).get("ServicesResolved")) ) if not ready: raise RuntimeError("GATT service discovery timed out") objects = self.objects() admitted = { str(p): plain(x) for p, x in objects.items() if str(p).startswith(path + "/") and (SERVICE in x or CHAR in x) } self.report["gatt"] = admitted self.report["standard_reads"] = [] for p, interfaces in objects.items(): char = interfaces.get(CHAR, {}) service = objects.get(char.get("Service"), {}).get(SERVICE, {}) if ( str(service.get("Device")) != path or str(service.get("UUID")) != "0000180a" + UUID_BASE or "read" not in char.get("Flags", []) ): continue uuid = str(char.get("UUID")) if uuid not in { "00002a24" + UUID_BASE, "00002a25" + UUID_BASE, "00002a26" + UUID_BASE, "00002a29" + UUID_BASE, }: continue value = bytes( self.iface(p, CHAR).ReadValue(self.dbus.Dictionary({}, signature="sv"), timeout=5) ) if len(value) > 256: raise RuntimeError("Device information exceeds limit") item = {"uuid": uuid, "value": value.decode("utf-8", errors="strict"), **timestamp()} self.report["standard_reads"].append(item) if uuid == "00002a25" + UUID_BASE: ident = ( "instax4_" + hashlib.sha256(value.decode().strip().encode()).hexdigest()[:32] ) self.report["identity_match"] = ident == expected_id self.report["target"]["after"] = plain(self.objects().get(path, {}).get(DEVICE, {})) if read_options: self.read_options(path, expected_id) def read_options(self, path, expected_id): from ble_options import PacketReader, decode_options_response, get_options_packet objects = self.objects() services = [ p for p, x in objects.items() if SERVICE in x and str(x[SERVICE].get("Device")) == path and str(x[SERVICE].get("UUID")) == "0000be80" + UUID_BASE ] if len(services) != 1: raise RuntimeError("Exact BE80 service is unavailable") chars = { str(x[CHAR].get("UUID")): (p, x[CHAR]) for p, x in objects.items() if CHAR in x and x[CHAR].get("Service") == services[0] } write_path, write_props = chars["0000be81" + UUID_BASE] notify_path, notify_props = chars["0000be82" + UUID_BASE] if notify_props.get("Notifying") or "notify" not in notify_props.get("Flags", []): raise RuntimeError("Notify characteristic is already owned or unsupported") flags = write_props.get("Flags", []) write_type = "request" if "write" in flags else "command" if write_type == "command" and "write-without-response" not in flags: raise RuntimeError("BE81 has no supported write method") reader, received, errors = PacketReader(), [], [] self.report["notifications"] = [] self.report["requests"] = [] total = 0 def notification(interface, changed, invalidated): nonlocal total if interface != CHAR or "Value" not in changed or errors: return raw = bytes(changed["Value"]) total += len(raw) if total > 32768 or len(self.report["notifications"]) >= 64: errors.append("Notification bound exceeded") return self.report["notifications"].append({"hex": raw.hex(), **timestamp()}) try: received.extend(reader.feed(raw)) self.report["heartbeat_packets"] = reader.heartbeats except ValueError as error: errors.append(str(error)) self.bus.add_signal_receiver( notification, dbus_interface=PROPS, signal_name="PropertiesChanged", bus_name=BLUEZ, path=notify_path, ) # Temporary CCCD write by BlueZ. Never a camera provisioning operation. self.owned_notify = notify_path self.iface(notify_path, CHAR).StartNotify(timeout=5) self.report["notify_subscriptions"] += 1 def query(options, message_id): assert_no_usb_owner(expected_id) raw = get_options_packet(options, message_id) self.report["requests"].append( {"hex": raw.hex(), "options": options, "message_id": message_id, **timestamp()} ) # Count intent before the effect; timeout is not a retry signal. self.report["vendor_writes"] += 1 self.iface(write_path, CHAR).WriteValue( self.dbus.Array(raw, signature="y"), self.dbus.Dictionary({"type": write_type}, signature="sv"), timeout=5, ) self.pump( 5, lambda: bool(errors or any(x["message_id"] == message_id for x in received)) ) if errors: raise RuntimeError(errors[0]) matches = [x for x in received if x["message_id"] == message_id] if len(matches) != 1: raise RuntimeError("Missing or ambiguous response; no retry") return decode_options_response(matches[0], message_id, options) identity = query([15, 48], 1) self.report["identity_options"] = identity serial = identity[15]["value"] model = identity[48]["value"] if not isinstance(serial, str) or not serial.strip() or model not in {"X4", "Insta360 X4"}: raise RuntimeError("Camera did not return full X4 identity") ident = "instax4_" + hashlib.sha256(serial.strip().encode()).hexdigest()[:32] self.report["identity_match"] = ident == expected_id if not self.report["identity_match"]: raise RuntimeError("BLE camera does not match the expected USB instance") self.report["usb_options"] = query([95, 97], 2) def close(self): errors = [] if self.owned_notify: try: self.iface(self.owned_notify, CHAR).StopNotify(timeout=5) except Exception as error: errors.append(str(error)) if self.owned_connection: try: self.iface(self.owned_connection, DEVICE).Disconnect(timeout=5) except Exception as error: errors.append(str(error)) if self.scanning: try: self.adapter.StopDiscovery(timeout=5) except Exception as error: errors.append(str(error)) self.bus.close() self.report["cleanup_errors"] = errors def main(): parser = argparse.ArgumentParser() parser.add_argument("--address") parser.add_argument("--expected-device-id") parser.add_argument("--read-options", action="store_true") parser.add_argument("--scan-seconds", type=int, choices=(20, 60), default=20) args = parser.parse_args() if args.address and ( not re.fullmatch(r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", args.address) or not re.fullmatch(r"instax4_[0-9a-f]{32}", args.expected_device_id or "") ): parser.error("Inspection requires an exact address and expected device ID") if args.read_options and not args.address: parser.error("Option reads require an explicit target") report = { "schema": "missioncore.insta360.ble-diagnostic/v1", "started": timestamp(), "vendor_writes": 0, "pair_calls": 0, "notify_subscriptions": 0, "identity_match": None, "state": "running", } inspector = None try: inspector = Inspector(report) inspector.scan(args.scan_seconds) if args.address: inspector.inspect(args.address, args.expected_device_id, args.read_options) report["state"] = "complete" except Exception as error: report.update(state="error", error=str(error)) finally: if inspector: inspector.close() if report.get("cleanup_errors"): report["state"] = "error" report.setdefault("error", "Diagnostic cleanup was incomplete") report["finished"] = timestamp() print(json.dumps(report), flush=True) return 0 if report["state"] == "complete" else 1 if __name__ == "__main__": raise SystemExit(main())