From 1a2de8df651787984ad3a90395a07f645341f38b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 10 Sep 2026 11:59:00 +0300 Subject: [PATCH] Package bounded X4 BLE diagnostics and USB mode reader --- .../07_INSTA360_X4_INSTALLATION_LEDGER.md | 130 ++++++ .../09_INSTA360_X4_IMPLEMENTATION_STATUS.md | 11 + docs/node/10_INSTA360_X4_NEXT_STEPS.md | 7 + ..._INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md | 3 + docs/node/13_INSTA360_X4_BLE_DIAGNOSTIC.md | 122 ++++++ .../insta360-x4/packaging/ble_diagnostic.py | 401 ++++++++++++++++++ plugins/insta360-x4/packaging/ble_options.py | 155 +++++++ .../packaging/build_ble_diagnostic.py | 65 +++ plugins/insta360-x4/packaging/build_deb.py | 14 +- .../packaging/check_package_entry.py | 12 + .../insta360-x4/tests/test_ble_diagnostic.py | 169 ++++++++ plugins/insta360-x4/tests/test_ble_options.py | 92 ++++ 12 files changed, 1178 insertions(+), 3 deletions(-) create mode 100644 docs/node/13_INSTA360_X4_BLE_DIAGNOSTIC.md create mode 100644 plugins/insta360-x4/packaging/ble_diagnostic.py create mode 100644 plugins/insta360-x4/packaging/ble_options.py create mode 100644 plugins/insta360-x4/packaging/build_ble_diagnostic.py create mode 100644 plugins/insta360-x4/tests/test_ble_diagnostic.py create mode 100644 plugins/insta360-x4/tests/test_ble_options.py diff --git a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md index cf23c27..8e38fc0 100644 --- a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md +++ b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md @@ -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.439605–08:33:29.630298 UTC, +Ubuntu monotonic327497.320691854–327517.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.767456–08:43:44.963861 UTC, +Ubuntu monotonic328072.648544286–328132.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.662580–08: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.360349–08: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 не запускались. diff --git a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md index 85c9cdb..99f831b 100644 --- a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md +++ b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md @@ -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) diff --git a/docs/node/10_INSTA360_X4_NEXT_STEPS.md b/docs/node/10_INSTA360_X4_NEXT_STEPS.md index 02e13d1..e216e47 100644 --- a/docs/node/10_INSTA360_X4_NEXT_STEPS.md +++ b/docs/node/10_INSTA360_X4_NEXT_STEPS.md @@ -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:** ближайший приоритет после применения diff --git a/docs/node/12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md b/docs/node/12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md index 5069637..f19a154 100644 --- a/docs/node/12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md +++ b/docs/node/12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md @@ -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. Подтверждённой готовой команды, diff --git a/docs/node/13_INSTA360_X4_BLE_DIAGNOSTIC.md b/docs/node/13_INSTA360_X4_BLE_DIAGNOSTIC.md new file mode 100644 index 0000000..ffe40ae --- /dev/null +++ b/docs/node/13_INSTA360_X4_BLE_DIAGNOSTIC.md @@ -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. diff --git a/plugins/insta360-x4/packaging/ble_diagnostic.py b/plugins/insta360-x4/packaging/ble_diagnostic.py new file mode 100644 index 0000000..266d603 --- /dev/null +++ b/plugins/insta360-x4/packaging/ble_diagnostic.py @@ -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()) diff --git a/plugins/insta360-x4/packaging/ble_options.py b/plugins/insta360-x4/packaging/ble_options.py new file mode 100644 index 0000000..f946854 --- /dev/null +++ b/plugins/insta360-x4/packaging/ble_options.py @@ -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(" MAX_PACKET * 2: + raise ValueError("Invalid notification size") + self.buffer.extend(chunk) + result = [] + while len(self.buffer) >= 4: + size = struct.unpack_from("= 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() diff --git a/plugins/insta360-x4/packaging/check_package_entry.py b/plugins/insta360-x4/packaging/check_package_entry.py index 7d9d002..17bc2c3 100644 --- a/plugins/insta360-x4/packaging/check_package_entry.py +++ b/plugins/insta360-x4/packaging/check_package_entry.py @@ -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: diff --git a/plugins/insta360-x4/tests/test_ble_diagnostic.py b/plugins/insta360-x4/tests/test_ble_diagnostic.py new file mode 100644 index 0000000..953cdee --- /dev/null +++ b/plugins/insta360-x4/tests/test_ble_diagnostic.py @@ -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("