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
@@ -859,3 +859,133 @@ viewer/worker/observer не запускались. Private0600 handoff.json SHA
Python urllib health probe был запрещён sandbox до соединения; штатный
curl GET подтвердил здоровье сервиса, изменения прав/службы не требовались.
Ops MISSIONCOR-77 обновлён; аппаратные acceptance items остались открыты.
## BLE01 — versioned discovery/standard GATT inspection, 10.09.2026
Владелец разрешил реализацию плана12. Read-only preflight08:27:37 UTC подтвердил
Bluetooth active, adapter powered, central/peripheral roles, rfkill unblocked.
BlueZ5.72, python3-dbus1.3.2 и python3-gi3.48.2 уже установлены. Core8000 operational,
Node active. Пароль, установка зависимостей и изменение служб не требовались.
До радиоэксперимента добавлены packaging/ble_diagnostic.py и
build_ble_diagnostic.py; build_deb включает тот же модуль и явные Debian
dependencies bluez>=5.72, python3-dbus, python3-gi. Installed package пока не
меняется: начальная проверка является engineering discovery без новых системных
предпосылок. Если зависимостей нет — artifact завершается ошибкой, не делает
apt/pip repair. Любые последующие persistent prerequisites сначала поставляются
installer/preparation. Никакого глобального исследовательского CLI.
BLE01 source SHA-256 27f6cb9a9623cb8c1a843f098df17b4d5553621f9fcaef931e4affb31abef598;
pyz SHA-256 da02b9d3a9b959b5376304c93495c7d62cec1c50fa9321695685bc2fe9637970.
Syntax passed; immutable-by-hash artifact staging в /var/tmp, private reports.
Сначала только scan: BlueZ client-scoped LE discovery20s/Pattern X4 с проверкой
свежих сигналов. Это active discovery, не passive radio sniffing. StopDiscovery
освобождает только собственную сессию; закрытие private bus убирает фильтр.
Не меняются Powered/Pairable/Trusted, не вызывается RemoveDevice. Отчёт сохраняет
только X4 candidates, не инвентаризацию окружающих BLE устройств.
Отдельный inspect допускает только конкретный свежий адрес X4, без прежнего
connection/bond; Connect<=20s, GATT discovery<=10s, standard DIS reads<=5s.
Нет vendor WriteValue, pairing, notifications или изменения USB mode. Только
2A24/25/26/29 внутри service180A; serial hash сверяется с прежним instance ID.
Имя/MAC сами не являются identity proof. Cleanup отключает только собственное
соединение, не перезапускает адаптер/USB/Node. Повторный scan не сохраняет
device configuration; обычный BlueZ discovery cache возможен, pairing не создаётся.
Результат аппаратного опыта ещё не получен.
BLE01 initial scan complete08:33:09.43960508:33:29.630298 UTC,
Ubuntu monotonic327497.320691854327517.511407622. Свежих X4 advertisements0;
vendor writes0, pairing0, notify0, cleanup errors0. Private scan.json SHA-256
266c1ecf21c97823b2dfbc902b5453bd093a22b5968a31dd87dccf16ca17aae5.
Владелец подтвердил тёмный экран; состояние off/screen-sleep ещё не разделено.
BLE02 подготовлен до повторного наблюдения: тот же workflow теперь допускает
scan20/60s и адресные sysfs USB samples, только Insta360 X4. Пользователь один
раз коротко нажимает Power с уже вставленным USB; агент лишь наблюдает.
Source SHA-256648e0f1ed0b37051af0b4985b5b69f6909f8e1099e0f61241574beb4dd4cca42;
artifact SHA-2567c3daba57990f4365ddd2829059e3aaa88d13ffffdb7db6a134d6224a0f0d86d.
В artifact также добавлен отдельный, по умолчанию выключенный --read-options.
Он требует явного адреса из свежей X4 рекламы; на BE80/BE81 только GetOptions8:
сначала serial15/model48, затем при совпадении exact instance USB95/wakeup97.
BE82 StartNotify/StopNotify выполняют временную CCCD subscription. Нет pairing,
setters, power, reboot, capture и generic arbitrary-command API. Данная ветка
на hardware ещё не запускалась и в BLE02 scan не используется. Package source
0.1.3-4 включает оба модуля/dependencies; установленная0.1.3-3 не менялась.
14 focused synthetic tests прошли: absent scalar не превращается в0, неверный
device/session/response/framing отвергается, timeout не повторяется, чужой notify
сохраняется. Формат header проверен статически по pinned SDK CameraMessage.GetData
0x5e6cd0 и CameraPacket.GetData0x5e6ff0: header7+9, uint32 total length, command16,
content type2, message ID30bit/direction/end. Реальная BLE совместимость ещё не
доказана. Payload GET read не имеет rollback эффекта; cleanup снимает CCCD и
собственное соединение. Неизвестный ответ останавливает последовательность.
BLE02 scan complete08:42:44.76745608:43:44.963861 UTC,
Ubuntu monotonic328072.648544286328132.844958963: USB X4 отсутствует во всех
samples, свежих BLE candidates0, cleanup errors0. Private power-button-scan.json
SHA-2569dbd5e126cd628963fcc65b869abc98a361fee1e1efcbedb25f9434ee279dd44.
Факт нажатия Power владельцем в это окно не подтверждён; результат не считается
отрицательным тестом wake-on-button. --read-options не выполнялся.
B12 intent: собрать и квалифицировать X4 model0.1.3-4 на существующей Ubuntu
через native_build_entry/package source workflow. Artifact18eccc74211a8eb9094fffe2,
58331680 bytes, SHA-2561d93f3e95ba37d3d4af05bc48903d2305c4cf61d47a7daa3f15ff0c62f64fd86.
Entry ограничивает process tree1GiB/180CPU s/nice10, user-owned /var/tmp staging.
Никакой установки, исполнения SDK, камеры/USB/BLE доступа во время build/tests.
В package-tests добавлены14 BLE tests и проверка фактически упакованных модулей
и Debian dependencies. Остальные payload/native/SDK bytes сохраняются.
Core/D455/Node services и маршруты не меняются. Partial build не переигрывается
без сохранения результата; только artifact-owned --clean очищает свою папку.
B12 complete08:45:14.66258008:45:37.358068 UTC,
Ubuntu monotonic328222.54366779, duration22.695515s.52 package tests passed;
result.tar.gz SHA-25626230933c4562f9881585d5dc1fbb3a46318680b13e5389d3d74da6e9d0a8968.
После review добавлен USB ownership guard перед BLE Connect и каждым запросом:
если прежняя X4 уже в2e1a:0002, диагностический запрос не отправляется, SDK owner
сохраняется. Отдельный synthetic case проверяет этот запрет. Cleanup error не
отмечается complete; ByteArray properties сериализуются в private report.
B12-R1 intent: повторная сборка/квалификация только вследствие этих новых правок.
Source artifact fbe65bd0efe313df2ecff17a,58332043 bytes,
SHA-256 ef2f05df12b30c3503ba50403ca1c6caa22656646b8d5838469ae23a18f3b8c2.
Diagnostic artifact a919d293c292935cfd636f0e,
SHA-256 bf1325b882d696314507f98fc627235ec579fa7d910035fb6155ce51f939b698;
source SHA-2563308f4647c9f8420df05147d647376f85051ceb0ea2c8c84d726612cb2fb02b0.
Новая версия пока не запускает аппаратный read-options: камеры нет в discovery,
ответ владельца о результате нажатия Power ожидается. Нет никаких setter/reset
или pairing команд. B12/B12-R1 не устанавливаются как замена Node0.8.19 bundle:
подготовка текущей Node пока использует прежнюю0.1.3-3, это явно открытый шаг.
B12-R1 complete, result archive SHA-256
7c0bfc9ea0595611d86230b2ace7faa0d94e65acf0b807ed36329cba87000724.
Review обнаружил недочёт test harness: новый ownership guard при двух fake
transport tests читал реальные sysfs descriptors, хотя intent ограничивал
тесты синтетикой. Это не открывало USB device nodes и не отправляло команд.
USB snapshot теперь подменяется во всех BLE tests; guard отдельно проверяется
контролируемым synthetic устройством. Рабочий diagnostic/package payload не
менялся после B12-R1, изменён только test harness.
B12-R2 intent: тот же package payload и исправленная изоляция тестов.
Artifact263f37468d091c11d51a6f07,58332087 bytes,
SHA-256 ab361b9239af1a08d32c66908b6eeb12278acd95a5ef776f581f263320770ce6.
Остальные resource/install/device boundaries прежние. Это последняя требуемая
квалификация после обнаруженного дефекта тестов, не расширенный soak/load run.
B12-R2 complete08:50:54.36034908:51:17.109377 UTC,
Ubuntu monotonic328562.241436096, duration22.749061s.53 tests passed (38 прежних
и15 BLE). Cold Python imports/static ELF closure/package contents/dependencies
проверены. Package0.1.3-4:58204178 bytes, revision9c3007af852d7188e65b4706,
SHA-2569f79bf6e6fc9d258db2c329cdf2696aa61004aa409dffec95cb648838db4ae57.
Result archive SHA-2569359f34a4cab94729650a243b1449fce8dd3a14e3c0093b44cde6f87ee19bcf3;
скачан в private ignored ble01/b12-r2, проверены архивные entries и hash .deb.
SDK disassembly evidence SHA-256
bd808c21f07f8acd3af299687e43ed0ead19bac83c4b8730dafe76c54dec0f45.
Handoff08:51:39.716463 UTC,Mac monotonic450779.766443833: Core8000 operational,
Rover006 online, прежняя RealSense online/streaming, X4 offline/idle.
Private handoff.json SHA-256
c2f88192a4f7059db9419df4fd0a5b95f2425101d93bfd36097a62226e62a662.
Все собственные scan/build processes завершены; фоновых viewer/observer нет.
USB/BLE vendor commands0, pairing0, CCCD subscriptions0 на hardware за этот этап.
Открытый вопрос владельцу: результат короткого Power с уже вставленным USB.
Не получив свежего устройства, read-options/setter не запускались.
@@ -1,5 +1,16 @@
# Insta360 X4 — состояние реализации 10.09.2026
## BLE01/BLE02 — первый модуль беспроводной диагностики
Реализован [BLE diagnostic workflow](13_INSTA360_X4_BLE_DIAGNOSTIC.md): bounded
discovery, стандартное GATT inspection и отдельное чтение identity/USB options.
Те же модули и OS dependencies включены в исходники model package0.1.3-4.
На Ubuntu initial scan20s и последующий scan60s не обнаружили X4; владелец
сообщил о тёмном экране. Выполнение предложенного короткого Power не подтверждено.
Read-options, pairing, setters и power-команды на hardware не запускались.
Installed Node0.8.19 / X40.1.3-3 сохранены; bundled Node profile ещё не обновлялся.
Автовозврат Android остаётся открытым; нельзя считать успешную сборку его приёмкой.
## RESEARCH01 — программный возврат Android после USB out/in
[Исследование точного сценария](12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md)
+7
View File
@@ -16,6 +16,13 @@ P10/PREP05 complete: Node0.8.19 и X4 model0.1.3-3 установлены шта
OWNER03: владелец видит изображение X4 и D455 через Core при переходах между карточками. Read-only Node snapshot обеих камер online/streaming. Длительная одновременная работа и измерения ещё не приняты.
BLE01/BLE02: [первый диагностический модуль](13_INSTA360_X4_BLE_DIAGNOSTIC.md)
реализован и включён в исходники model package0.1.3-4. На Ubuntu два bounded
наблюдения не обнаружили X4; экран камеры тёмный. Следующий hardware шаг —
состояние после короткого Power с уже вставленным USB, затем fresh BLE candidate
и явный read-options. Ни setter, ни BLE power cycle ещё не выполнялись.
Node0.8.19 bundle пока остаётся0.1.3-3; его обновление не считается завершённым.
## Очередь реализации
**Уточнение владельца10.09 после NET03:** ближайший приоритет после применения
@@ -4,6 +4,9 @@ RESEARCH01, 10.09.2026. Запрос владельца: исследовать
включённой X4 и возможность программно вернуть Android mode. Это исследование
документации и исходных данных, не аппаратная приёмка новой команды.
Продолжение реализации: [BLE diagnostic, BLE01/BLE02/B12](13_INSTA360_X4_BLE_DIAGNOSTIC.md).
Read-only команда подготовлена; ответа самой X4 на Options95 ещё нет.
## Вывод и исправление предыдущего плана
Штатно X4 должна помнить последний USB mode. Подтверждённой готовой команды,
+122
View File
@@ -0,0 +1,122 @@
# X4: диагностика Bluetooth и чтение USB mode
10.09.2026, BLE01/BLE02/B12. Реализация первого проверяемого шага
[исследования reconnect](12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md).
Автоматический возврат Android ещё не принят.
Финальный B12-R2: пакет 0.1.3-4 собран на Ubuntu, 53 tests passed, включая
15 BLE cases. Проверены реальные содержимое .deb, зависимости, cold imports
и статическая ELF closure. SHA-256 пакета:
9f79bf6e6fc9d258db2c329cdf2696aa61004aa409dffec95cb648838db4ae57.
Пакет не установлен; рабочие службы и bundled Node payload сохранены.
## Поставляемый код и границы
- `plugins/insta360-x4/packaging/ble_diagnostic.py`: bounded BlueZ discovery,
отдельное GATT inspection и явный режим чтения настроек.
- `ble_options.py`: только GET_OPTIONS, строгие packet/protobuf bounds и
разбор ответов. Нет generic send, SET_OPTIONS, capture, reboot или power-off.
- `build_ble_diagnostic.py`: артефакт с hashes обоих модулей; тот же код
включён в model Debian package 0.1.3-4 через `build_deb.py`.
- `check_package_entry.py`: проверяет наличие/совпадение модулей в реальном
.deb, объявленные зависимости и synthetic tests.
Установленная Node 0.8.19 пока содержит X4 model 0.1.3-3. Сборка новой модели
не является её развёртыванием или аппаратной приёмкой. После проверки BLE
нужно обновить bundled model/profile Node штатным репакером, затем применить
из Core/Node через обычную подготовку. UI состояния пока не меняются.
Зависимости `bluez >= 5.72`, `python3-dbus`, `python3-gi` объявлены в Debian.
На нашей Ubuntu они уже были установлены. Диагностический artifact ничего
не устанавливает и не ремонтирует автоматически: отсутствие зависимости,
прав D-Bus или powered adapter даёт явную ошибку. До первого hardware опыта
не добавлялись apt/pip packages, chmod устройств, polkit grants, udev или
изменения служб. Versioned /var/tmp staging и private evidence — единственные
создаваемые файлы. Этот опыт не квалифицирует чистую Ubuntu.
## Последовательность и идентичность
По умолчанию — только 20 секунд LE discovery, при физическом тесте можно выбрать
60 секунд. Фильтр BlueZ принадлежит конкретному клиенту; дополнительно каждый
результат проверяется локально. Сохраняются только имена `X4 ` плюс 6 символов.
Cached Device object не считается свежим появлением: требуется новый сигнал
advertisement/RSSI/name во время наблюдения. Остальные модели не выбираются.
При отсутствии такого имени результат означает отсутствие кандидата в рамках
данного фильтра, а не доказательство полного отсутствия радиосигнала камеры.
Для GATT требуется явно выбранный свежий адрес и прежний device ID. Имя/MAC
используются только для выбора кандидата; не создаётся новая привязка и не
объединяются камеры. Existing connection/bond отвергается в первоначальном
inspection. Перед Connect и каждым запросом проверяется sysfs: если точный
экземпляр уже в USB SDK mode, диагностика сохраняет владельца SDK и прекращается.
Стандартные reads: service 180A, характеристики 2A24/25/26/29
(model/serial/firmware/manufacturer), без каких-либо записей. Если DIS serial
доступен, его hash сравнивается с прежней USB identity.
`--read-options` дополнительно допускает:
1. Точный service BE80 и его BE81/BE82. Existing notify owner не перехватывается.
2. BE82 StartNotify: BlueZ выполняет временную CCCD subscription. Это реальная
служебная BLE-запись, не provisioning камеры. StopNotify выполняется в cleanup.
3. Один GET_OPTIONS8 на BE81 для serial15 и camera_type48.
4. Только при полном serial hash, совпадающем с прежним `instax4_…`, и точной
модели X4 — второй GET_OPTIONS8 для udisk_mode95 и bt_wakeup_sw97.
5. Cleanup завершает только своё notify/соединение, затем закрывает private
D-Bus connection. Никакого Pair, Trust, RemoveDevice или adapter power cycle.
Если камера требует авторизацию, не отвечает, возвращает другой протокол или
ошибку — последовательность прекращается. Автоматических retries/альтернативных
framing/handshake guesses нет. Успех GATT write ещё не означает успешное чтение.
Поле должно присутствовать в ответе и в списке возвращённых options; отсутствие
scalar не превращается в default PC=0. UNKNOWN не разрешает setter.
## Основание wire format
SDK 2.1.8, SHA-256
6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373,
исследован статически. Библиотека не загружалась и не исполнялась.
`CameraMessage::GetData` (0x5e6cd0) сериализует command uint16, content byte,
32-bit message identifier и 2 нулевых байта перед payload: всего 9 байт.
`GetIdentifier` (0x5e6ca0) использует 30-bit message ID, direction bit30 и
end bit31. `CameraPacket::GetData` (0x5e6ff0, обычная ветка без UCD2) добавляет
7 байт: uint32 total size, packet type, два нулевых байта. Поэтому полный
заголовок занимает 16 байт, а length включает его. Не используется неверная
payload-only length из прочитанного insta360ctl Header16 encoder.
Пример synthetic GET_OPTIONS95, message ID1:
`12000000040000080002010000800000085f`.
Параметры команды подтверждены embedded protobuf descriptors из плана12.
Ответ допускается только в том же framing, с direction/end и совпадающим
полным message ID. Code200 — ожидаемая success семантика общего протокола;
реальная X4 BLE с этим codec ещё не проверена. Неподдержанная fragmentation
останавливает read, не разбирается предположительно.
X4 BE80/BE81/BE82 опубликованы в
[аппаратном разборе BLE X4](https://github.com/TheAngryRaven/insta360-ble-gps-spec).
Он подтверждает service family, но сам разбирает главным образом GPS remote;
полный direct-control framing не считается принятым на нашей камере.
BlueZ lifecycle основан на [Adapter API](https://bluez.readthedocs.io/en/latest/adapter-api/)
и [GATT API](https://bluez.readthedocs.io/en/latest/gatt-api/).
## Результат и следующий аппаратный шаг
BLE01: 20 секунд discovery, свежих X4 candidates0. BLE02: 60 секунд discovery
и sysfs sampling, X4 отсутствовала в обоих транспортных наблюдениях.
Владелец сообщил о тёмном экране. Факт короткого нажатия Power в окно BLE02
не подтверждён. Эти результаты не являются отказом команды чтения или
доказательством невозможности Bluetooth wake-up.
Нужно получить состояние после одного короткого Power с уже вставленным USB.
Если появилась штатная USB X4, сначала фиксируется этот успех и сохраняется
SDK owner. Если камера включена в обычный режим — повторный bounded scan,
выбор конкретного кандидата, GATT inspection, затем отдельный read-options.
До этого vendor commands0, pairing0, CCCD subscriptions0.
После реального чтения95 можно решать, есть ли основание для безопасного
Android setter. Если уже ANDROID=1, запись того же значения может быть лишь
сохранением предпочтения. Переход PC→Android и generic reboot остаются вне
реализованного allowlist. BLE remote off/on требует отдельного артефакта и
проверенного wake-up до выключения камеры. Ни один из этих исходов пока
не установлен. Raw reports/hashes/UTC/monotonic — в installation ledger.
@@ -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()