Package bounded X4 BLE diagnostics and USB mode reader
This commit is contained in:
@@ -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()))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user