Package bounded X4 BLE diagnostics and USB mode reader

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 11:59:00 +03:00
parent b536f0049a
commit 1a2de8df65
12 changed files with 1178 additions and 3 deletions
@@ -0,0 +1,401 @@
"""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))
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())
@@ -0,0 +1,155 @@
"""Read-only Options codec derived from the pinned SDK's descriptors/serializer.
This establishes packet structure, not BLE support on a particular X4 firmware.
No transport, pairing, setting changes, capture, reboot or generic send API.
"""
import struct
MAX_PACKET = 4096
READ_OPTIONS = frozenset({15, 48, 95, 97})
def varint(value):
if type(value) is not int or not 0 <= value < 2**64:
raise ValueError("Invalid protobuf integer")
result = bytearray()
while value >= 128:
result.append((value & 127) | 128)
value >>= 7
result.append(value)
return bytes(result)
def read_varint(data, offset):
value = 0
for shift in range(0, 70, 7):
if offset >= len(data):
raise ValueError("Truncated varint")
byte = data[offset]
offset += 1
if shift == 63 and byte > 1:
raise ValueError("Overflowed varint")
value |= (byte & 127) << shift
if not byte & 128:
return value, offset
raise ValueError("Oversized varint")
def protobuf_fields(data):
if len(data) > MAX_PACKET:
raise ValueError("Oversized protobuf")
result, offset = [], 0
while offset < len(data):
tag, offset = read_varint(data, offset)
number, wire = tag >> 3, tag & 7
if not 1 <= number <= 0x1FFFFFFF:
raise ValueError("Invalid field number")
if wire == 0:
value, offset = read_varint(data, offset)
elif wire in {1, 2, 5}:
if wire == 2:
size, offset = read_varint(data, offset)
else:
size = 8 if wire == 1 else 4
if size > len(data) - offset:
raise ValueError("Truncated field")
value, offset = data[offset : offset + size], offset + size
else:
raise ValueError("Unsupported protobuf wire type")
result.append((number, wire, value))
return result
def get_options_packet(options, message_id):
if (
not options
or len(options) > 4
or len(set(options)) != len(options)
or any(type(x) is not int or x not in READ_OPTIONS for x in options)
):
raise ValueError("Only identity and USB/wakeup option reads are allowed")
if type(message_id) is not int or not 1 <= message_id < 0x40000000:
raise ValueError("Invalid message ID")
payload = b"".join(b"\x08" + varint(option) for option in options)
# CameraPacket header7 + CameraMessage header9. Command8=GET_OPTIONS.
return (
struct.pack("<IBHHBIH", 16 + len(payload), 4, 0, 8, 2, message_id | 0x80000000, 0) + payload
)
class PacketReader:
"""Accept split/combined notifications; no unbounded resync or CRC guessing."""
def __init__(self):
self.buffer = bytearray()
def feed(self, chunk):
if not chunk or len(self.buffer) + len(chunk) > MAX_PACKET * 2:
raise ValueError("Invalid notification size")
self.buffer.extend(chunk)
result = []
while len(self.buffer) >= 4:
size = struct.unpack_from("<I", self.buffer)[0]
if not 16 <= size <= MAX_PACKET:
raise ValueError("Unknown packet framing")
if len(self.buffer) < size:
break
raw = bytes(self.buffer[:size])
del self.buffer[:size]
_, kind, reserved, code, content, ident, tail = struct.unpack("<IBHHBIH", raw[:16])
if kind != 4 or reserved or tail or content != 2 or ident & 0xC0000000 != 0xC0000000:
raise ValueError("Unsupported message header/direction/fragmentation")
result.append(
{"code": code, "message_id": ident & 0x3FFFFFFF, "payload": raw[16:], "raw": raw}
)
return result
def decode_options_response(packet, message_id, requested):
if packet["message_id"] != message_id:
raise ValueError("Response belongs to another request")
# HTTP-style response codes in the common protocol. Hardware must confirm.
if packet["code"] != 200:
raise ValueError("Camera did not return a successful response")
listed, values = [], None
for number, wire, value in protobuf_fields(packet["payload"]):
if number == 1:
if wire == 0:
listed.append(value)
elif wire == 2:
offset = 0
while offset < len(value):
item, offset = read_varint(value, offset)
listed.append(item)
else:
raise ValueError("Invalid option list")
elif number == 2:
if wire != 2 or values is not None:
raise ValueError("Invalid or duplicate Options value")
values = value
if values is None or len(listed) != len(set(listed)) or not set(listed) <= set(requested):
raise ValueError("Missing/ambiguous Options response")
decoded = {}
for number, wire, value in protobuf_fields(values):
if number not in requested:
continue
if number in decoded or number not in listed:
raise ValueError("Duplicate or unacknowledged option")
if number in {15, 48}:
if wire != 2 or not 1 <= len(value) <= 256:
raise ValueError("Invalid camera identity")
decoded[number] = value.decode("utf-8", errors="strict")
else:
if wire != 0 or value not in {0, 1}:
raise ValueError("Unsupported option enum")
decoded[number] = value
# Missing scalar is unknown, NEVER default PC=0 or wakeup=0.
return {
number: {
"acknowledged": number in listed,
"present": number in decoded,
"value": decoded.get(number),
}
for number in requested
}
@@ -0,0 +1,65 @@
"""Package the exact diagnostic entry point; never install host prerequisites."""
import hashlib
import json
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ENTRY = """import hashlib, json, sys, zipfile
from pathlib import Path
with zipfile.ZipFile(sys.argv[0]) as archive:
manifest = json.loads(archive.read("manifest.json"))
source = archive.read("ble_diagnostic.py")
codec = archive.read("ble_options.py")
if hashlib.sha256(source).hexdigest() != manifest["source_sha256"]:
raise RuntimeError("Diagnostic source hash mismatch")
if hashlib.sha256(codec).hexdigest() != manifest["codec_sha256"]:
raise RuntimeError("Diagnostic codec hash mismatch")
ident = hashlib.sha256(source + b"\\0" + codec).hexdigest()[:24]
if Path(sys.argv[0]).name != "mission-core-x4-ble-" + ident + ".pyz":
raise RuntimeError("Diagnostic artifact name mismatch")
import types
module = types.ModuleType("ble_options")
exec(compile(codec, "ble_options.py", "exec"), module.__dict__)
sys.modules["ble_options"] = module
exec(compile(source, "ble_diagnostic.py", "exec"), {"__name__": "__main__"})
"""
def build():
source = (ROOT / "packaging/ble_diagnostic.py").read_bytes()
codec = (ROOT / "packaging/ble_options.py").read_bytes()
sha = hashlib.sha256(source).hexdigest()
manifest = {
"schema": "missioncore.insta360.ble-diagnostic-artifact/v1",
"source_sha256": sha,
"codec_sha256": hashlib.sha256(codec).hexdigest(),
"dependencies": ["bluez", "python3-dbus", "python3-gi"],
"persistent_system_changes": False,
"vendor_commands": [8],
"scope": "bounded-discovery-and-explicit-identity-options-read",
}
ident = hashlib.sha256(source + b"\0" + codec).hexdigest()[:24]
output = ROOT / "build" / ("mission-core-x4-ble-" + ident + ".pyz")
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
for name, data in {
"__main__.py": ENTRY.encode(),
"ble_diagnostic.py": source,
"ble_options.py": codec,
"manifest.json": json.dumps(manifest, sort_keys=True).encode(),
}.items():
info = zipfile.ZipInfo(name, date_time=(2026, 9, 10, 0, 0, 0))
info.external_attr = 0o600 << 16
archive.writestr(info, data)
output.chmod(0o600)
return {
"artifact": str(output),
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
"source_sha256": sha,
"bytes": output.stat().st_size,
}
if __name__ == "__main__":
print(json.dumps(build()))
+11 -3
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
from debian import package # noqa: E402
from fetch_sdk import verify # noqa: E402
VERSION = "0.1.3-3"
VERSION = "0.1.3-4"
WHEELS = {
"aiohappyeyeballs",
"aiohttp",
@@ -152,7 +152,14 @@ def build(output):
0o644,
)
)
for name in ("layout.py", "bootstrap.py", "supervisor.py", "prepare.py"):
for name in (
"layout.py",
"bootstrap.py",
"supervisor.py",
"prepare.py",
"ble_diagnostic.py",
"ble_options.py",
):
files.append(
("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644)
)
@@ -199,7 +206,8 @@ Section: admin
Priority: optional
Depends: mission-core-node (>= 0.8.16), mission-core-node (<< 0.9.0),
systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), adduser, udev, polkitd,
libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g,
bluez (>= 5.72), python3-dbus, python3-gi
Description: Optional Insta360 X4 control and operator camera integration
Private USB instances and pinned offline runtime for Ubuntu 24.04 amd64.
""".encode()
@@ -23,6 +23,17 @@ data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(packa
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
payload = archive.extractfile("usr/share/mission-core-node/insta360/payload.zip").read()
bundle = json.load(archive.extractfile("usr/share/mission-core-node/insta360/bundle.json"))
for name in ("ble_diagnostic.py", "ble_options.py"):
packaged = archive.extractfile("usr/lib/mission-core-node/insta360/" + name).read()
if packaged != (ROOT / "packaging" / name).read_bytes():
raise ValueError("Packaged BLE module differs from the qualified source")
compile(packaged, name, "exec")
dependencies = subprocess.check_output(
["/usr/bin/dpkg-deb", "--field", str(package), "Depends"], text=True, timeout=5
)
declared = {item.split("(", 1)[0].strip() for item in dependencies.split(",")}
if not {"bluez", "python3-dbus", "python3-gi"} <= declared:
raise ValueError("BLE system prerequisites are not declared by the package")
if hashlib.sha256(payload).hexdigest() != bundle["payload_sha256"]:
raise ValueError("Package payload hash mismatch")
stage = ROOT / "build/cold-runtime"
@@ -78,6 +89,7 @@ try:
+ "\n"
)
suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="check_*.py")
suite.addTests(unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="test_ble*.py"))
if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful():
raise RuntimeError("Package qualification tests failed")
finally:
@@ -0,0 +1,169 @@
"""No second query without full identity; disconnect only the owned connection."""
import importlib.util
import struct
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1] / "packaging"
def load(name):
spec = importlib.util.spec_from_file_location(name, ROOT / (name + ".py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
diag, codec = load("ble_diagnostic"), load("ble_options")
sys.modules["ble_options"] = codec
TARGET = "/org/bluez/hci0/dev_00_00_00_00_00_01"
SERVICE = TARGET + "/service1"
WRITE = SERVICE + "/char1"
NOTIFY = SERVICE + "/char2"
def fixture(number, value):
if isinstance(value, bytes):
return codec.varint(number * 8 + 2) + codec.varint(len(value)) + value
return codec.varint(number * 8) + codec.varint(value)
class Fake:
def __init__(self, serial=b"synthetic-x4-001", notifying=False, timeout=False):
self.inspector = diag.Inspector.__new__(diag.Inspector)
ins = self.inspector
ins.report = {"vendor_writes": 0, "notify_subscriptions": 0}
ins.dbus = SimpleNamespace(Array=lambda x, **kw: x, Dictionary=lambda x, **kw: x)
ins.bus = self
ins.iface = lambda path, name: self
ins.pump = lambda duration, predicate: predicate()
ins.owned_connection, ins.owned_notify, ins.scanning = TARGET, None, False
ins.objects = lambda: {
SERVICE: {diag.SERVICE: {"Device": TARGET, "UUID": "0000be80" + diag.UUID_BASE}},
WRITE: {
diag.CHAR: {
"Service": SERVICE,
"UUID": "0000be81" + diag.UUID_BASE,
"Flags": ["write"],
}
},
NOTIFY: {
diag.CHAR: {
"Service": SERVICE,
"UUID": "0000be82" + diag.UUID_BASE,
"Flags": ["notify"],
"Notifying": notifying,
}
},
}
self.serial, self.timeout = serial, timeout
self.calls = []
def add_signal_receiver(self, callback, **kwargs):
self.callback = callback
def StartNotify(self, **kwargs):
self.calls.append("notify-start")
def StopNotify(self, **kwargs):
self.calls.append("notify-stop")
def Disconnect(self, **kwargs):
self.calls.append("disconnect")
def close(self):
self.calls.append("bus-close")
def WriteValue(self, raw, options, **kwargs):
_, _, _, command, _, ident, _ = struct.unpack("<IBHHBIH", raw[:16])
self.calls.append(command)
if self.timeout:
raise TimeoutError("synthetic timeout")
fields = codec.protobuf_fields(raw[16:])
requested = [x[2] for x in fields]
values = {15: self.serial, 48: b"Insta360 X4", 95: 1, 97: 1}
payload = b"".join(fixture(1, n) for n in requested)
payload += fixture(2, b"".join(fixture(n, values[n]) for n in requested))
reply = (
struct.pack("<IBHHBIH", 16 + len(payload), 4, 0, 200, 2, ident | 0x40000000, 0)
+ payload
)
self.callback(diag.CHAR, {"Value": reply}, [])
class DiagnosticTests(unittest.TestCase):
def setUp(self):
mock = patch.object(diag, "usb_snapshot", return_value={"devices": []})
mock.start()
self.addCleanup(mock.stop)
def test_usb_sdk_owner_blocks_radio_command(self):
fake = Fake()
expected = "instax4_" + diag.hashlib.sha256(fake.serial).hexdigest()[:32]
with (
patch.object(
diag,
"usb_snapshot",
return_value={
"devices": [
{
"idVendor": "2e1a",
"idProduct": "0002",
"serial": fake.serial.decode(),
}
]
},
),
self.assertRaisesRegex(RuntimeError, "preserve its current owner"),
):
fake.inspector.read_options(TARGET, expected)
fake.inspector.close()
self.assertNotIn(8, fake.calls)
def test_full_identity_precedes_usb_query_and_cleanup(self):
fake = Fake()
expected = "instax4_" + diag.hashlib.sha256(fake.serial).hexdigest()[:32]
fake.inspector.read_options(TARGET, expected)
fake.inspector.close()
self.assertEqual(
fake.calls, ["notify-start", 8, 8, "notify-stop", "disconnect", "bus-close"]
)
self.assertTrue(fake.inspector.report["identity_match"])
def test_wrong_camera_cannot_reach_usb_query(self):
fake = Fake()
with self.assertRaisesRegex(RuntimeError, "does not match"):
fake.inspector.read_options(TARGET, "instax4_" + "0" * 32)
fake.inspector.close()
self.assertEqual(fake.calls.count(8), 1)
self.assertNotIn("usb_options", fake.inspector.report)
def test_ambiguous_write_timeout_never_retried(self):
fake = Fake(timeout=True)
with self.assertRaises(TimeoutError):
fake.inspector.read_options(TARGET, "instax4_" + "0" * 32)
fake.inspector.close()
self.assertEqual(fake.calls.count(8), 1)
self.assertEqual(fake.inspector.report["vendor_writes"], 1)
def test_existing_notify_owner_is_preserved(self):
fake = Fake(notifying=True)
with self.assertRaisesRegex(RuntimeError, "already owned"):
fake.inspector.read_options(TARGET, "instax4_" + "0" * 32)
fake.inspector.close()
self.assertNotIn("notify-stop", fake.calls)
self.assertNotIn(8, fake.calls)
def test_discovery_filters_exact_model_names(self):
for name in ("X4 ABC123", "X4 123456"):
self.assertTrue(diag.camera_name(name))
for name in ("X4 Air 123456", "X5 123456", "X4", "Insta360", None):
self.assertFalse(diag.camera_name(name))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,92 @@
"""Synthetic malformed/partial replies must never authorize a camera mutation."""
import importlib.util
import struct
import unittest
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "packaging/ble_options.py"
spec = importlib.util.spec_from_file_location("ble_options", path)
codec = importlib.util.module_from_spec(spec)
spec.loader.exec_module(codec)
def field(number, value):
if isinstance(value, bytes):
return codec.varint(number * 8 + 2) + codec.varint(len(value)) + value
return codec.varint(number * 8) + codec.varint(value)
def reply(payload, code=200, ident=1):
return (
struct.pack("<IBHHBIH", 16 + len(payload), 4, 0, code, 2, 0xC0000000 | ident, 0) + payload
)
class OptionsTests(unittest.TestCase):
def test_request_matches_sdk_header_and_proto_descriptors(self):
# Fixed independent vector: total18, message8, protobuf2, ID1/end, option95.
self.assertEqual(
codec.get_options_packet([95], 1).hex(), "12000000040000080002010000800000085f"
)
def test_capture_and_arbitrary_options_are_not_encodable(self):
for options in ([], [4], [95, 95], [True], [65536]):
with self.assertRaises(ValueError):
codec.get_options_packet(options, 1)
def test_fragmented_and_combined_notifications(self):
raw = reply(field(1, 95) + field(2, field(95, 1)))
reader = codec.PacketReader()
self.assertEqual(reader.feed(raw[:3]), [])
packets = reader.feed(raw[3:] + raw)
self.assertEqual(len(packets), 2)
self.assertEqual(
codec.decode_options_response(packets[0], 1, [95])[95],
{"acknowledged": True, "present": True, "value": 1},
)
def test_absent_scalar_is_not_zero(self):
packet = codec.PacketReader().feed(reply(field(1, 95) + field(2, b"")))[0]
self.assertEqual(
codec.decode_options_response(packet, 1, [95])[95],
{"acknowledged": True, "present": False, "value": None},
)
def test_explicit_zero_and_packed_option_list(self):
packet = codec.PacketReader().feed(reply(field(1, b"\x5f") + field(2, field(95, 0))))[0]
self.assertEqual(codec.decode_options_response(packet, 1, [95])[95]["value"], 0)
def test_error_and_wrong_session_are_not_success(self):
for code, ident in [(400, 1), (500, 1), (200, 2)]:
packet = codec.PacketReader().feed(
reply(field(1, 95) + field(2, field(95, 1)), code, ident)
)[0]
with self.assertRaises(ValueError):
codec.decode_options_response(packet, 1, [95])
def test_unacknowledged_duplicate_and_unknown_enums_rejected(self):
for data in (
field(2, field(95, 1)),
field(1, 95) + field(2, field(95, 1) * 2),
field(1, 95) + field(2, field(95, 3)),
field(1, 95) * 2 + field(2, field(95, 1)),
field(1, 15) + field(2, field(95, 1)),
):
packet = codec.PacketReader().feed(reply(data))[0]
with self.assertRaises(ValueError):
codec.decode_options_response(packet, 1, [95])
def test_unknown_framing_rejected_without_resync(self):
for raw in (b"\xff\x07\x40\x10", b"\x00\x10\x01\x00", codec.get_options_packet([95], 1)):
with self.assertRaises(ValueError):
codec.PacketReader().feed(raw)
def test_malformed_protobuf_rejected(self):
for raw in (b"\x08\x80", b"\x12\x10\x00", b"\x00", b"\x08" + b"\xff" * 10):
with self.assertRaises(ValueError):
codec.protobuf_fields(raw)
if __name__ == "__main__":
unittest.main()