Package bounded X4 BLE diagnostics and USB mode reader
This commit is contained in:
@@ -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 не запускались.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. Подтверждённой готовой команды,
|
||||
|
||||
@@ -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()))
|
||||
@@ -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