diff --git a/apps/node-agent/internal/node/sensor_models.go b/apps/node-agent/internal/node/sensor_models.go index 44e5648..5a35e5a 100644 --- a/apps/node-agent/internal/node/sensor_models.go +++ b/apps/node-agent/internal/node/sensor_models.go @@ -41,7 +41,7 @@ var sensorModels = []sensorModel{ // OS product descriptor as well; SDK identity is verified after prepare. Vendor: "2e1a", Product: "0002", USBName: "Insta360 X4", Socket: "/run/mission-core-insta360/driver.sock", PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json", - Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer")}, + Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure")}, } func modelForDevice(id string) *sensorModel { diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 4a2b28f..378d681 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,8 +11,8 @@ import sys ROOT = Path(__file__).resolve().parents[1] -BINARY_VERSION = "0.8.19" -VERSION = "0.8.19" +BINARY_VERSION = "0.8.20" +VERSION = "0.8.20" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/apps/node-agent/packaging/insta360-profile.json b/apps/node-agent/packaging/insta360-profile.json index 1d4121d..93af59d 100644 --- a/apps/node-agent/packaging/insta360-profile.json +++ b/apps/node-agent/packaging/insta360-profile.json @@ -1,8 +1,8 @@ { "schema": "missioncore.node.bundled-model/v1", "model_id": "insta360.x4", - "version": "0.1.3-3", - "revision": "b291418f2a404cbdafc57431", - "bytes": 58199970, - "sha256": "c10f9c1e7f8237d9df202bc29483d61f27280920d76611b1aa1c4f3a72332540" + "version": "0.1.3-7", + "revision": "45e14de38a278914eedf5cbb", + "bytes": 58210794, + "sha256": "96ce6b52fa72d79ec6f8b0d13bc2af53afda61077b40a3358a3d145295a62d19" } diff --git a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md index 788191e..553f750 100644 --- a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md +++ b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md @@ -1259,3 +1259,136 @@ trigger/retry/preview restore ещё не реализованы; immediate warm REPLUG04 не исправлен. Camera-side Wakeup On остаётся явной предпосылкой. Installed Node0.8.19/X40.1.3-3 сохранены; clean Ubuntu и несколько циклов ещё не квалифицированы. Новых OS packages/services/permissions не добавляли. + +### REC01 / B15 — автоматическое восстановление, 10.09.2026 + +Intent после разрешения владельца «Погнали дальше»: model0.1.3-7 добавляет +per-instance recovery intent, отдельный fixed root wake helper, default-off +настройку в общей карточке Core/Node и возврат только preview. Полный контракт, +границы команд, OS ownership и rollback в docs15. Camera-side Wakeup On +остаётся явной предпосылкой. Установка сначала versioned package и Node bundle, +никаких ad-hoc изменений Ubuntu. Чтение10:40:23 UTC: Node0.8.19/X40.1.3-3, +три действующие службы active; RAM available5371MiB,swap0. + +16 новых synthetic recovery/helper tests прошли локально. Первый локальный +запуск выявил только ошибку тестового mock root ownership в двух случаях; +исправлен fixture, повтор16/16 passed. Производственный trust check сохранён. +B15 source0bcd3f3e04251f27f959d1ce,58346786 bytes, SHA-256 +697a553d3564fa3b2e8bcf8e3a3cd098983c14ebcb130a071fe8085ae02e6885. +Штатный versioned build на той же Ubuntu:1GiB/180CPU s/nice10, без SDK/device +access/install. Установленные службы/пакет не меняются во время квалификации. + +B15 complete10:43:08.331946–10:43:31.874127 UTC, monotonic335296.213035555, +duration23.542184s;75 tests passed4.792s. Model0.1.3-7, +revision45e14de38a278914eedf5cbb,58210794 bytes,SHA-256 +96ce6b52fa72d79ec6f8b0d13bc2af53afda61077b40a3358a3d145295a62d19. +Result archive5ee6bc007f5c6436dbc2196f7baf1c15ebc8954fa0e4514e46cc53f06013e4f0 +скачан, entries/hash/package проверены. Core fleet31 tests passed. +Readback10:44:29.267748 UTC,Mac monotonic457549.378966166: Core operational, +Rover006 online,X4 streaming/preview1/recording0. Private before.json SHA-256 +612b32bea4badd208f025d5089cdab73667677e13a095d895f96fedb8811484a. + +N11 intent: Node0.8.20 с B15 и общей UI-настройкой, DG прежний8c53f73. +Source d982b8d2bf3a390d6c70e3f3,2728 files,309752241 bytes,SHA-256 +9762db8c6f47d318036e4ab84c04ad56baa50507945a58e8da3227a22abb3a35. +Штатный Linux builder на той же Mini,3GiB/150%CPU/256tasks/nice10, +sequential DG/Node/Core tests и builds. Это сборка, не установка; SDK/USB/SD +не вызываются. Перед дальнейшей штатной подготовкой preview будет остановлен +через Core при подтверждённом recording0; новые installed versions пока не заявлены. + +N11 complete10:46:47.198735–10:55:50.833998 UTC,monotonic335515.079830142, +duration543.635276s, cgroup memory peak3221225472 bytes (3GiB cap). +DG build/typecheck/registry/loading/catalog, Node UI tests/build, Go race tests +и binary, Core architecture/typecheck/819 tests/build прошли. Штатная политика +builder исключает прежний Python/Rerun fixture, не входящий в X4 build inputs. +Node0.8.20:168273420 bytes,SHA-256 +4d021c638493d9d2ffba8a90530f95c232def024164b56881f426f905f773072. +Core dist:23067834 bytes,SHA-256 +48ec9b799d638ca52fc4e1a824793370e93398e7d58b8c2bb089c364190f8808. +Result archive7280f5c8c89d881108579ad965272e18f9de2a01e68fef92f7250d2fce3551ca +скачан, entries/artifacts проверены, private qualified-n11. + +P11 owner release63be43462a32d49daca823fb,168292853 bytes,SHA-256 +ca8983dd94ea8257c81f83dd426b625f8b930c54d57c1ddcb15fea9935852824. +Далее штатный --plan/--launch, локальная Ubuntu OS authentication при запросе. +Node maintainer script штатно перезапускает Node и RealSense; владелец об этом +уведомлён, D455 проверяется после установки. X4 остановлена через обычный +Core preview.stop; STOP instrument SHA-256 +630f686cff11d270e9604b5c596b0e64710397df3cd94f0069134502ce156976. +Model prepare instrumentfa2bd84fdacce3ab0e047d9a38ab23be870e67ff8b8a6895b4aee9642e0e3740 +будет вызван после подтверждения Node0.8.20. Никаких прямых SDK/root ремонтов. + +REC01-Core intent: активировать квалифицированные N11 frontend bytes и Core +action allowlist на единственном8000; текущая UI сохраняется в private +qualified-n11/core-dist-before, прежний sensors.py изf164606 сохраняется рядом. +Hash-bound activation instrument 36b01ca8ab5ba58a3ecb0e0503dbef4fa022e87bc807b09a8ec609108378c315. +Проверить новый PID/health и прежние Core/Node/vehicle/binding identities; +launchd restart наблюдается до55s, второй backend не создаётся. + +REC01-Core complete11:01:10.985056–11:01:43.659067 UTC, +Mac monotonic458551.0406405,duration32.674049916s. Один restart, PID46997→65164, +Core operational, identity hashes до/после совпали. Private activation report +SHA256d1e3d09dbb658f259f64bd201f5c8b7781560a8ac9bffb11fb5870eaea6427e0. + +P11 первая попытка10:59:46.533139–10:59:53.546295 UTC, +Ubuntu monotonic336294.414243883,duration7.013164s: APT package step100, +версии не изменены. preinst требует /prepare-safe от RealSense; D455 была +streaming, запись/playback отсутствовали. Штатный Core STOP выполнен +11:04:43.002134–11:04:50.089636 UTC,Mac monotonic458763.058095666, +fresh idle подтверждён. Private report SHA256 +9d6716fde1a31b17c9344fd202564de0a38b9b765c2545460124c506e97b8461. +Повторён тот же hash-bound --launch, без обхода preinst/ручного root ремонта. +P11 complete11:09:11.006589–11:09:29.842966 UTC, +Ubuntu monotonic336858.887692502,duration18.836391699s, +session07b18fa6895e43999f6d043aaf983878. Все шаги0, UI ready, +Node0.8.20 установлен, Node/K1/RealSense active,NRestarts0. + +PREP06 через Core queued11:10:21.069014 UTC,Mac monotonic459101.124631166, +complete receipt11:10:38.238343; fresh after11:10:43.252557, +monotonic459123.308188291. Установлен model0.1.3-7; broker/supervisor/wake +active, новая SDK session от11:10:33.074024, прежний instance, preview0/recording0. +Recovery supported/disabled по умолчанию. Report SHA256 +82b7b43cb3ff21f6f0820f7cdb1642f710a90c9aaa58f7589c99ff23d447b9ed. +Первый read-only service check ошибочно запросил несуществующее имя +mission-core-insta360-broker; проверка правильного mission-core-insta360 +подтвердила active. Служба не исправлялась/не перезапускалась вручную. + +REC01-RESTORE11:12:19.545828–11:12:27.665197 UTC, +Mac monotonic459219.601795958–459227.7207915, +sourceac778fa8786e98586eb4456a2df7dc19acf795a23671a0d91494415ce6127784: +штатные D455 start(record=false) и X4 preview.start complete. +Instrument слишком рано потребовал online сразу после X4 receipt и остановил +readback на промежуточном offline. Команда не повторялась; последующее чтение +11:12:53 UTC подтвердило прежнюю X4 SDK session, preview1/recording0 и +свежие кадры D455. Private start-both.json SHA256 +7ba21efb5c1cbe8e9a8a421f96169e29f546890a683a7298fd47adbf82b6d845. +Через новую Core UI включено recovery для этой X4; пользователь ранее явно +подтвердил camera-side Wakeup On. ARM readback11:13:25.509513 UTC, +Mac monotonic459285.565453666,enabled=true,phase=ready, +preview_wanted=true,recording0. Private armed.json SHA256 +513c07fd09e543ea4d9423ff505e6be0c68339e91323e3963793d4bd94e54540. +Core browser video1280×640,readyState4,paused=false,currentTime68.848s. +Loading расположен внутри нажатой кнопки; SDK session/SD не меняются. + +REC01-REPLUG intent11:14:15.854035 UTC,Mac monotonic459335.90952175, +source7aa48b033e688fca78968f05288075a51db8fe272d61b3955a9df50fa6daa4b7: +bounded600s observer читает только +Core inventory каждые2s, сохраняет UTC/monotonic/исходник hash. Владелец +попросил продолжить автономность; запрошено USB out3–5s→in без Power, +оставить кабель подключённым. Успех требует исчезновения и новой SDK session, +автовозврата preview и свежих decoded frames; отсутствие действия владельца +не считается аппаратной неудачей. Изменения Ubuntu вне installer отсутствуют. + +REC01-REPLUG окно завершено11:19:48.340831 UTC через SIGINT только временного +observer, finally сохранил отчёт; duration332.486881666s,165 samples. Физическое +действие владельца не подтверждено, disappearance не наблюдалось; hardware +gate остаётся pending, это не failed recovery. Report SHA256 +44ca7ee2c32229d957294178dde6cce0ab15eabb0526596846fcaa7a01761da3. +Последний readback11:19:46.880983: обе камеры online, прежние SDK sessions, +D455 color1625→6571, X4 recovery enabled/ready/preview_wanted. В браузере +живой1280×640 currentTime68.848→265.315→443.377s,readyState4,paused=false. +Expanded viewport и Escape проверены. Собственная QA вкладка закрыта, +временные build/activation/observer процессы завершены; штатные preview и +recovery intent сохранены для теста владельца. Native Node WebRTC ограничение +из docs09 не закрывается общей frontend сборкой. Ops MISSIONCOR-77 обновлена, +replug/recovery/checkers остаются незавершёнными. diff --git a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md index 5a5e9ee..57a44e7 100644 --- a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md +++ b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md @@ -1,5 +1,24 @@ # Insta360 X4 — состояние реализации 10.09.2026 +## REC01 / B15 — установлено, физическая проверка восстановления открыта + +[Per-instance recovery](15_INSTA360_X4_AUTOMATIC_RECOVERY.md) добавлен в broker +и отдельный fixed wake helper. В общей карточке Core/Node настройка default-off; +отключение работает без камеры. Сохраняются разрешение wake и намерение +preview; три ограниченные попытки, бюджет переживает restart. Запись/SD-команды +не повторяются. Preview — одна попытка в новой SDK session при recording0. + +B15 model0.1.3-7 прошёл75 Ubuntu tests; Core fleet31 tests passed. N11 Node0.8.20 +и общая UI квалифицированы на той же Mini: DG/Node/Go race/Core checks, +819 frontend tests и builds прошли. Core активирован на8000, identities сохранены. +P11 установил Node0.8.20; PREP06 из удалённого Core установил X40.1.3-7 и +запустил helper. Первый P11 штатно отказал из-за активного захвата D455; +после обычного STOP тот же installer завершился. Оба просмотра восстановлены. +В Core подтверждено живое1280×640; настройка recovery включена через карточку, +readback enabled=true/preview_wanted=true/recording0. REC01 USB out/in без +Power и возврат SDK/preview ещё требуют физического теста. Сборка и установка +не заменяют эту приёмку. Текущие версии — Node0.8.20/X40.1.3-7. + ## WAKE01 — первый успешный Bluetooth wake с возвратом Android Ubuntu выполнила один адресный20s wake advertisement при оставленном USB. diff --git a/docs/node/10_INSTA360_X4_NEXT_STEPS.md b/docs/node/10_INSTA360_X4_NEXT_STEPS.md index 0b7391b..a8ba497 100644 --- a/docs/node/10_INSTA360_X4_NEXT_STEPS.md +++ b/docs/node/10_INSTA360_X4_NEXT_STEPS.md @@ -30,6 +30,14 @@ Node0.8.19 bundle пока остаётся0.1.3-3; его обновление ## Очередь реализации +**REC01 в работе:** код per-instance wake/preview recovery и настройка Core/Node +реализованы; B15 model0.1.3-7 прошёл75 Ubuntu tests. Node0.8.20 и X40.1.3-7 +установлены штатно; общая UI активирована в Core, identities сохранены. +Оба просмотра возвращены, recovery включено через карточку X4. Ближайшее +действие — реальный USB out/in без Power с возвратом SDK/preview, затем +повторные циклы и проверка отмены. Аппаратная приёмка остаётся открытой. +Архитектура и границы — в docs15. + **Актуальный результат WAKE01:** один адресный Bluetooth wake с Ubuntu разбудил X4 при подключённом USB; камера сама вернулась в Android/SDK, владелец подтвердил экран. Node открыл прежний instance, D455 сохранила поток. diff --git a/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md b/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md new file mode 100644 index 0000000..e29cdab --- /dev/null +++ b/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md @@ -0,0 +1,71 @@ +# X4 — восстановление подключения и просмотра + +REC01 / B15, 10.09.2026. Продолжение принятого WAKE01. Штатно установлены +Node 0.8.20 и X4 0.1.3-7, общая UI активирована в Core. Настройка включена +через Core; физический USB out/in и автоматический возврат ещё не приняты. + +## Поведение и полномочия + +В существующей карточке каждой X4 есть отдельная настройка восстановления. +По умолчанию выключена. Для включения камера должна быть подключена по USB; +оператор подтверждает включённое на самой X4 «Пробуждение по Bluetooth». +USB disappearance не различает кнопку Power и потерю кабеля: перед намеренным +выключением оператор отключает восстановление. Настройка и её отключение +доступны через один контракт Core/Node, включая отключённую камеру. + +Отдельный broker хранит для каждого полного device_id разрешение wake, +намерение просмотра, число попыток и сессию последней попытки восстановления +preview. После исчезновения выдерживается 8 секунд; максимум три wake-попытки +с порогами 8/40/100 секунд от потери связи. Неизвестный результат расходует +попытку. Перезапуск не обнуляет счётчик. Минута устойчивой связи обнуляет его; +оператор может отдельно выключить и снова включить восстановление. + +Preview восстанавливается один раз в новой SDK session, только при сохранённом +намерении START и подтверждённом recording=0. STOP отменяет намерение, в том +числе без камеры. Повтор старого START receipt после STOP не возобновляет +намерение. Для автоматического START сохраняется отметка до вызова и отдельный +OperationRequest; unknown не повторяется в той же сессии. Record/photo/settings +и SD-команды не входят в алгоритм восстановления. Существующий media broker +закрывает источники/peers исчезнувшей SDK session; LiveViewport работает с новой. + +## Граница Bluetooth и установка + +Новый `mission-core-insta360-wake.service` — отдельный root helper с Unix socket, +kernel peer credentials допускают только root и UID model broker. Нет public +listener, SDK loading, USB writes, произвольных serial/path/command arguments. +При включении helper сам читает единственный полный serial из текущего USB +binding и сохраняет в root-only StateDirectory. API принимает только device_id, +boolean control либо attempt UUID. Известные совпадения radio suffix запрещены. + +Один adapter lock сериализует wake для разных камер. Helper независимо +сохраняет до трёх attempt UUID за десять минут до публикации; повтор UUID +не переигрывает команду. Он запускает только установленный ble_wake.py с +private stdin. При отключении настройки завершается только child этой камеры. +Child SIGTERM снимает рекламу в finally; BlueZ удаляет её и при потере private +D-Bus owner, advertisement имеет собственный Timeout20s. Parent ограничивает +child45s; последующая установка защищена общим lifecycle lock. + +От WAKE01 изменено только требование ранее сопоставленного BLE address: +для runtime оно необязательно, потому что wake адресуется USB serial suffix, +а выключенная X4 не обязана рекламироваться сама. Без сопоставленного адреса +helper не отключает никакие новые BLE connections. Неизвестное соединение +сохраняется и отмечается cleanup error; успех требует прежнего полного serial +в USB SDK. Ни discovery, ни Pair, ни GATT writes, ни глобальная настройка +адаптера не добавлены. Factory Pairable/Discoverable или занятый advertiser +дают ограниченный отказ; helper не перенастраивает BlueZ автоматически. + +Model 0.1.3-7 владеет helper, service, broker recovery и их зависимостями +bluez/python3-dbus/python3-gi. prepare запускает сервис; prerm останавливает +его в общей процедуре quiesce. Новых apt/pip/chmod/service edits вне пакета нет. +Root serials/raw reports и broker intents/receipts не попадают в публичный +inventory или Git. Удаление пакета сохраняет журналы, не трогает SD-карту. + +## Проверка + +Synthetic tests проверяют default-off, независимые instances, границы попыток, +restart budget, stable reset, offline cancellation, запрет SD replay, +unknown preview receipt, сериализацию adapter, точный OS enrollment, +collision/SDK-owner guards и подтверждение camera-side prerequisite. +Сборка model и Node выполняется только существующим versioned artifact на Mini. +Фактические UTC/monotonic/hashes, установка и аппаратный итог — в ledger07. +Успех WAKE01 не заменяет REC01, несколько реальных камер или clean Ubuntu. diff --git a/plugins/insta360-x4/frontend/src/RecoverySettings.tsx b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx new file mode 100644 index 0000000..ff0f023 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx @@ -0,0 +1,17 @@ +import {Button,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import type {RecoveryStatus} from './model'; + +export function RecoverySettings({value,online,enabled,pending,change}:{ + value:RecoveryStatus; online:boolean; enabled:boolean; pending:boolean; change:(enabled:boolean)=>void; +}) { + const failed=['exhausted','unavailable','preview_failed'].includes(value.phase); + const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры': + ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено'; + return {label}}> +

На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.

+ +
; +} diff --git a/plugins/insta360-x4/frontend/src/X4Detail.tsx b/plugins/insta360-x4/frontend/src/X4Detail.tsx index 5ee79a3..ee1a9d4 100644 --- a/plugins/insta360-x4/frontend/src/X4Detail.tsx +++ b/plugins/insta360-x4/frontend/src/X4Detail.tsx @@ -4,6 +4,7 @@ import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensi import {perform} from '../../../../packages/sensor-ui/src/contracts'; import {LiveViewport} from '../../../../packages/sensor-ui/src/LiveViewport'; import {CameraSettings} from './CameraSettings'; +import {RecoverySettings} from './RecoverySettings'; import {cameraStatus,fileName,type CameraFiles,type CameraSettings as Settings} from './model'; export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorDetailProps){ @@ -22,7 +23,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD },[transport,device.id,device.snapshot.context.session_id,status.function_mode]); useEffect(()=>{if(available)void load().catch(error=>{if(mounted.current)failure(error);});},[available,load]); async function action(name:string,parameters:Record={},key=name){ - if(running.current||!enabled||(!available&&name!=='refresh'))return; + if(running.current||!enabled||(!available&&!['refresh','recovery.configure','preview.stop'].includes(name)))return; running.current=true;setPending(key);failure(null); try{ const result=name==='refresh'?(available?await load():undefined):await perform(transport,device,name,parameters); @@ -40,11 +41,12 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD return
void action('refresh')}>}> {!available&&

Нет свежей связи с камерой.

} -
+
{status.alerts?.temperature_high&&

Камера сообщает о перегреве.

} {status.alerts?.storage_full&&

На карте камеры закончилось место.

} {status.alerts?.battery_low&&

Камера сообщает о низком заряде.

}
+ {status.recovery?.supported&&void action('recovery.configure',{enabled:value,wakeup_confirmed:value})}/>} {available?recordLabel:'Состояние недоступно'}}>
diff --git a/plugins/insta360-x4/frontend/src/model.ts b/plugins/insta360-x4/frontend/src/model.ts index a997275..681dcc9 100644 --- a/plugins/insta360-x4/frontend/src/model.ts +++ b/plugins/insta360-x4/frontend/src/model.ts @@ -1,9 +1,14 @@ import type {Sensor} from '../../../../packages/sensor-ui/src/contracts'; +export interface RecoveryStatus { + supported:boolean; enabled:boolean; preview_wanted:boolean; phase:string; +} + export interface CameraStatus { connected:boolean; preview:number|null; recording:number|null; function_mode:number|null; firmware?:string; battery?:{level:number}; storage?:{free_bytes:number}; alerts?:{battery_low?:boolean;storage_full?:boolean;temperature_high?:boolean}; + recovery?:RecoveryStatus; } export interface CameraSettings { mode:number; photo_modes:number[]; video_modes:number[]; photo_sizes:number[]; diff --git a/plugins/insta360-x4/packaging/ble_wake.py b/plugins/insta360-x4/packaging/ble_wake.py index 23e9429..b4804ce 100644 --- a/plugins/insta360-x4/packaging/ble_wake.py +++ b/plugins/insta360-x4/packaging/ble_wake.py @@ -3,6 +3,7 @@ import hashlib import json import re +import signal import sys from pathlib import Path @@ -33,7 +34,9 @@ def wake_payload(target): ident = "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32] if target["device_id"] != ident or target["wakeup_enabled"] is not True: raise ValueError("USB identity and enabled camera wakeup must be confirmed") - if not re.fullmatch(r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]): + if target["bluetooth_address"] is not None and not re.fullmatch( + r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"] + ): raise ValueError("Prior correlated Bluetooth address is required") # Company ID 0x004c is passed separately to BlueZ. X4 remote spec section1. return ( @@ -189,6 +192,10 @@ def run(target, report): def main(): + def cancel(*_): + raise RuntimeError("Wake cancelled") + + signal.signal(signal.SIGTERM, cancel) report = { "schema": "missioncore.insta360.ble-wake/v1", "started": timestamp(), diff --git a/plugins/insta360-x4/packaging/build_deb.py b/plugins/insta360-x4/packaging/build_deb.py index 0b8804d..f990bcc 100644 --- a/plugins/insta360-x4/packaging/build_deb.py +++ b/plugins/insta360-x4/packaging/build_deb.py @@ -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-6" +VERSION = "0.1.3-7" WHEELS = { "aiohappyeyeballs", "aiohttp", @@ -160,6 +160,7 @@ def build(output): "ble_diagnostic.py", "ble_options.py", "ble_wake.py", + "wake_service.py", ): files.append( ("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644) diff --git a/plugins/insta360-x4/packaging/check_package_entry.py b/plugins/insta360-x4/packaging/check_package_entry.py index b577594..81f2b04 100644 --- a/plugins/insta360-x4/packaging/check_package_entry.py +++ b/plugins/insta360-x4/packaging/check_package_entry.py @@ -23,7 +23,7 @@ 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", "ble_wake.py"): + for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py", "wake_service.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") diff --git a/plugins/insta360-x4/packaging/mission-core-insta360-wake.service b/plugins/insta360-x4/packaging/mission-core-insta360-wake.service new file mode 100644 index 0000000..52d4cbf --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-insta360-wake.service @@ -0,0 +1,35 @@ +[Unit] +Description=Mission Core bounded X4 Bluetooth wake +After=bluetooth.service +Wants=bluetooth.service +ConditionPathExists=/var/lib/mission-core-insta360/active.path +[Service] +Type=simple +Group=mission-core-node +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/wake_service.py +RuntimeDirectory=mission-core-x4-recovery +RuntimeDirectoryMode=0750 +StateDirectory=mission-core-x4-recovery +StateDirectoryMode=0700 +UMask=0077 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet= +TasksMax=20 +MemoryMax=128M +CPUQuota=30% +LimitCORE=0 +TimeoutStopSec=8 +KillMode=control-group +Restart=on-failure +RestartSec=5 +[Install] +WantedBy=multi-user.target diff --git a/plugins/insta360-x4/packaging/prepare.py b/plugins/insta360-x4/packaging/prepare.py index d532fd0..4a6db3a 100644 --- a/plugins/insta360-x4/packaging/prepare.py +++ b/plugins/insta360-x4/packaging/prepare.py @@ -184,6 +184,7 @@ def prepare_locked(): trusted(maintenance).unlink() run("/usr/bin/systemctl", "daemon-reload") for service in ( + "mission-core-insta360-wake.service", "mission-core-insta360.service", "mission-core-insta360-supervisor.service", ): diff --git a/plugins/insta360-x4/packaging/prerm b/plugins/insta360-x4/packaging/prerm index 9077918..d4bf4cd 100644 --- a/plugins/insta360-x4/packaging/prerm +++ b/plugins/insta360-x4/packaging/prerm @@ -5,6 +5,7 @@ case "$1" in /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce if [ -d /run/systemd/system ]; then systemctl stop mission-core-insta360-supervisor.service + systemctl stop mission-core-insta360-wake.service systemctl stop 'mission-core-x4@*.service' systemctl stop mission-core-insta360.service fi diff --git a/plugins/insta360-x4/packaging/wake_service.py b/plugins/insta360-x4/packaging/wake_service.py new file mode 100644 index 0000000..6462563 --- /dev/null +++ b/plugins/insta360-x4/packaging/wake_service.py @@ -0,0 +1,174 @@ +"""Fixed Bluetooth privilege boundary. Never accepts serials, paths or SDK calls.""" + +import json +import os +import pwd +import re +import subprocess +import sys +import tempfile +import threading +import time +from contextlib import suppress +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from layout import CODE, trusted, write # noqa: E402 +from runtime.http import Server # noqa: E402 +from runtime.identity import read_binding # noqa: E402 +from runtime.lifecycle import acquisition # noqa: E402 + +ROOT = Path("/var/lib/mission-core-x4-recovery") +SOCKET = Path("/run/mission-core-x4-recovery/driver.sock") +IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}") + + +def connected(): + values = [] + for path in Path("/sys/bus/usb/devices").iterdir(): + with suppress(OSError, ValueError): + values.append(read_binding(path.name)) + return values + + +class WakeService: + def __init__(self, root=ROOT): + self.root = root + self.lock = threading.RLock() + self.advertiser = threading.Lock() + self.running = {} + + def load(self, ident): + path = self.root / (ident + ".json") + if not path.exists(): + return {"enabled": False, "attempts": []} + return json.loads(trusted(path).read_text()) + + def save(self, ident, value): + write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600) + + def control(self, ident, enabled): + with self.lock: + value = self.load(ident) + if not enabled and "serial" not in value: + return {"enabled": False} + if enabled: + matches = [x for x in connected() if x.device_id == ident] + if len(matches) != 1 or not re.fullmatch(r"[A-Za-z0-9]{7,64}", matches[0].serial): + raise ValueError("Connect the exact X4 before enabling recovery") + if len(list(self.root.glob("instax4_*.json"))) >= 500 and "serial" not in value: + raise ValueError("Recovery inventory is full") + value["serial"] = matches[0].serial + value["enabled"] = enabled + self.save(ident, value) + if not enabled and ident in self.running: + self.running[ident].terminate() + return {"enabled": enabled} + + def wake(self, ident, attempt): + if not self.advertiser.acquire(blocking=False): + return {"state": "busy"} + try: + # Installation cannot replace code/state while a wake is in flight. + with acquisition(): + return self.wake_locked(ident, attempt) + finally: + self.advertiser.release() + + def wake_locked(self, ident, attempt): + with self.lock: + value = self.load(ident) + if not value["enabled"] or "serial" not in value: + return {"state": "disabled"} + serial = value["serial"] + if any(x.device_id == ident for x in connected()): + return {"state": "connected"} + # A radio suffix is weaker than full USB identity. Refuse known collisions. + for path in self.root.glob("instax4_*.json"): + other = json.loads(trusted(path).read_text()).get("serial", "") + if other and other != serial and other[-6:] == serial[-6:]: + return {"state": "identity_conflict"} + now = time.time() + attempts = [x for x in value["attempts"] if now - x["at"] < 600] + if any(x["id"] == attempt for x in attempts): + return {"state": "already_attempted"} + if len(attempts) >= 3: + return {"state": "exhausted"} + value["attempts"] = attempts + [{"id": attempt, "at": now}] + # Persist before advertising; a crash cannot silently replay the attempt. + self.save(ident, value) + target = { + "device_id": ident, + "serial": serial, + "bluetooth_address": None, + "wakeup_enabled": True, + } + with tempfile.TemporaryFile(dir=self.root) as output: + process = subprocess.Popen( + ["/usr/bin/python3", "-I", str(CODE / "ble_wake.py")], + stdin=subprocess.PIPE, + stdout=output, + stderr=subprocess.DEVNULL, + ) + self.running[ident] = process + process.stdin.write(json.dumps(target).encode()) + process.stdin.close() + # Release state lock during hardware wait so disable can cancel. + self.lock.release() + try: + try: + process.wait(timeout=45) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + output.seek(0) + raw = output.read(1024 * 1024 + 1) + finally: + self.lock.acquire() + self.running.pop(ident, None) + state = "unavailable" + try: + if len(raw) <= 1024 * 1024: + report = json.loads(raw) + write(self.root / (ident + ".last-wake"), raw, 0o600) + if process.returncode == 0 and report.get("sdk_usb_returned"): + state = "connected" + except (ValueError, OSError): + pass + return {"state": state if self.load(ident)["enabled"] else "disabled"} + + def dispatch(self, method, route, value, _headers): + if method == "GET" and route == "/health": + return {"ready": True} + if method != "POST" or not isinstance(value, dict): + raise ValueError("Unsupported recovery request") + ident = value.get("device_id") + if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident): + raise ValueError("Invalid recovery identity") + if route == "/control" and set(value) == {"device_id", "enabled"}: + if type(value["enabled"]) is not bool: + raise ValueError("Recovery requires an explicit boolean") + return self.control(ident, value["enabled"]) + if route == "/wake" and set(value) == {"device_id", "attempt"}: + if not isinstance(value["attempt"], str) or not re.fullmatch( + r"[0-9a-f]{32}", value["attempt"] + ): + raise ValueError("Invalid wake attempt") + return self.wake(ident, value["attempt"]) + raise ValueError("Unsupported recovery request") + + +def main(): + if os.geteuid() or sys.argv[1:]: + raise RuntimeError("Use the installed fixed wake service") + trusted(ROOT, True) + if SOCKET.exists(): + SOCKET.unlink() + allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid} + with Server(SOCKET, WakeService().dispatch, allowed) as server: + SOCKET.chmod(0o660) + server.serve_forever(poll_interval=0.5) + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/runtime/broker.py b/plugins/insta360-x4/runtime/broker.py index 52a5165..b859387 100644 --- a/plugins/insta360-x4/runtime/broker.py +++ b/plugins/insta360-x4/runtime/broker.py @@ -30,14 +30,19 @@ class Broker: self.media = None self.media_operations = {} self.capture_locks = {} + self.recovery = None + + @staticmethod + def validate_identifier(identifier): + worker_socket(identifier) def capture_lock(self, identifier): with self.media_lock: if identifier not in self.capture_locks and len(self.capture_locks) >= 500: raise RuntimeError("Camera inventory exceeds the protocol limit") - return self.capture_locks.setdefault(identifier, threading.Lock()) + return self.capture_locks.setdefault(identifier, threading.RLock()) - def capture_operation(self, identifier, value): + def capture_operation(self, identifier, value, restoring=False): path = worker_socket(identifier) lock = self.capture_lock(identifier) with self.media_lock: @@ -58,6 +63,10 @@ class Broker: key = (identifier, session) starting = value["action_id"] == "preview.start" starting_idle = starting and current.get("status", {}).get("preview") != 1 + if restoring and ( + not current.get("online") or current.get("status", {}).get("recording") != 0 + ): + return dict(UNKNOWN) if starting: if not current.get("online"): raise RuntimeError("Camera state is unavailable") @@ -67,12 +76,16 @@ class Broker: except (OSError, RuntimeError, ValueError): if starting_idle: self.media.capture(key, path, False) + if not starting and self.recovery and not restoring: + self.recovery.preview_intent(identifier, value, dict(UNKNOWN)) raise if (starting_idle and result.get("state") != "complete") or ( not starting and result.get("state") == "complete" ): # Releasing a decoder never sends STOP to the camera or SD. self.media.capture(key, path, False) + if self.recovery and not restoring: + self.recovery.preview_intent(identifier, value, result) return result def verify_operation(self, identifier, value): @@ -204,9 +217,21 @@ class Broker: if not re.fullmatch(r"node_[0-9a-f]{64}", node): raise ValueError("Node identity is required") if method == "GET" and route == "/inventory": - return {"items": [self.item(item, node) for item in self.snapshots()]} + snapshots = self.snapshots() + if self.recovery: + snapshots = self.recovery.augment(snapshots) + return {"items": [self.item(item, node) for item in snapshots]} if method == "POST" and route == "/operation": identifier = value["session"]["device_id"] + if value.get("action_id") == "recovery.configure" and self.recovery: + return self.recovery.operation(identifier, value) + if value.get("action_id") == "preview.stop" and self.recovery: + try: + current = request(worker_socket(identifier), "/snapshot", timeout=2) + except (OSError, RuntimeError, ValueError): + current = {} + if not current.get("online"): + return self.recovery.operation(identifier, value) if value.get("action_id") in ("preview.start", "preview.stop"): return self.capture_operation(identifier, value) if value.get("action_id") in ("offer", "close-peer"): @@ -265,6 +290,11 @@ def main(): if path.exists(): path.unlink() allowed = {0, pwd.getpwnam("mission-core-node").pw_uid} - with Server(path, Broker().dispatch, allowed) as server: + broker = Broker() + from .recovery import Recovery + + broker.recovery = Recovery(broker, STATE / "recovery") + broker.recovery.start() + with Server(path, broker.dispatch, allowed) as server: path.chmod(0o660) server.serve_forever(poll_interval=0.5) diff --git a/plugins/insta360-x4/runtime/native.py b/plugins/insta360-x4/runtime/native.py index fadaa49..32b2e3d 100644 --- a/plugins/insta360-x4/runtime/native.py +++ b/plugins/insta360-x4/runtime/native.py @@ -35,6 +35,16 @@ class VideoHeader(ctypes.Structure): def parameters(action, value): """Validate before any SDK call; parameters never select a file or library.""" + if action == "recovery.configure": + if ( + not isinstance(value, dict) + or set(value) != {"enabled", "wakeup_confirmed"} + or type(value["enabled"]) is not bool + or type(value["wakeup_confirmed"]) is not bool + or (value["enabled"] and not value["wakeup_confirmed"]) + ): + raise ValueError("Confirm camera Bluetooth Wakeup before enabling recovery") + return 0, "", 0.0 if action == "offer": if ( not isinstance(value, dict) diff --git a/plugins/insta360-x4/runtime/recovery.py b/plugins/insta360-x4/runtime/recovery.py new file mode 100644 index 0000000..b2b6f50 --- /dev/null +++ b/plugins/insta360-x4/runtime/recovery.py @@ -0,0 +1,278 @@ +"""Per-camera recovery intent. No SDK ownership, recording replay or BLE access.""" + +import copy +import hashlib +import json +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from .http import request +from .operations import Operations, atomic + +HELPER = Path("/run/mission-core-x4-recovery/driver.sock") + + +class Recovery: + def __init__(self, broker, root): + self.broker, self.root = broker, root + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + self.lock = threading.RLock() + self.states = {} + self.current = {} + self.absent = {} + self.stable = {} + self.jobs = {} + self.stopping = threading.Event() + self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="x4-recovery") + for path in sorted(root.glob("instax4_*.json"))[:500]: + state = json.loads(path.read_text()) + self.broker.validate_identifier(path.stem) + self.states[path.stem] = state + + def default(self): + now = datetime.now(UTC).isoformat() + return { + "enabled": False, + "preview_wanted": False, + "attempts": 0, + "phase": "disabled", + "session_id": "x4_recovery_" + uuid.uuid4().hex, + "opened_at": now, + "updated_at": now, + "revision": 1, + "resume_session": None, + "last_preview_request": "", + "last_preview_id": "", + } + + def save(self, ident, state): + state["revision"] += 1 + state["updated_at"] = datetime.now(UTC).isoformat() + atomic(self.root / (ident + ".json"), state) + + def view(self, ident): + with self.lock: + state = self.states.get(ident, {}) + return { + "supported": True, + "enabled": state.get("enabled", False), + "preview_wanted": state.get("preview_wanted", False), + "phase": state.get("phase", "disabled"), + } + + def snapshot(self, ident): + with self.lock: + current = self.current.get(ident) + if current is not None: + return copy.deepcopy(current) + state = self.states.get(ident) + if state is None: + raise ValueError("Camera identity is unavailable") + return { + "id": ident, + "session_id": state["session_id"], + "opened_at": state["opened_at"], + "observed_at": state["updated_at"], + "revision": state["revision"], + "prepared": True, + "verified": False, + "online": False, + "preparation_safe": False, + "status": {}, + "message": None, + } + + def augment(self, snapshots): + values = {x["id"]: copy.deepcopy(x) for x in snapshots} + with self.lock: + for ident in self.states: + if ident not in values: + values[ident] = self.snapshot(ident) + # Never retain an online snapshot when its worker disappeared. + values[ident].update(online=False, status={}, preparation_safe=False) + for ident, value in values.items(): + value["status"]["recovery"] = self.view(ident) + return list(values.values()) + + def configure(self, ident, enabled): + # Called under the camera capture lock, also used by preview restoration. + reply = request(HELPER, "/control", {"device_id": ident, "enabled": enabled}, timeout=8) + if reply.get("enabled") is not enabled: + raise RuntimeError("Recovery setting was not confirmed") + with self.lock: + state = self.states.setdefault(ident, self.default()) + state.update(enabled=enabled, attempts=0, phase="ready" if enabled else "disabled") + if not enabled: + state["preview_wanted"] = False + current = self.current.get(ident) + if current: + state.update(session_id=current["session_id"], opened_at=current["opened_at"]) + if enabled: + state["preview_wanted"] = current.get("status", {}).get("preview") == 1 + self.save(ident, state) + self.absent.pop(ident, None) + return {"state": "complete", "result": self.view(ident)} + + def operation(self, ident, command): + with self.broker.capture_lock(ident): + snapshot = self.snapshot(ident) + operations = Operations( + ident, + snapshot["session_id"], + self.root / ident / "operations", + RecoveryCalls(self, ident, command), + ) + return operations.execute(command) + + def preview_intent(self, ident, command, result): + start = command["action_id"] == "preview.start" + if start and result.get("state") != "complete": + return + with self.lock: + state = self.states.setdefault(ident, self.default()) + requested = datetime.fromisoformat(command["requested_at"]) + previous = state["last_preview_request"] + if command["operation_id"] == state["last_preview_id"] or ( + previous and requested <= datetime.fromisoformat(previous) + ): + return + state.update( + preview_wanted=start, + last_preview_request=command["requested_at"], + last_preview_id=command["operation_id"], + ) + self.save(ident, state) + + def observe(self, snapshots, now): + """Pure scheduling decisions under one short lock; no device calls here.""" + self.current = {x["id"]: copy.deepcopy(x) for x in snapshots} + decisions = [] + for ident, state in self.states.items(): + current = self.current.get(ident) + online = bool(current and current.get("online")) + if online: + self.absent.pop(ident, None) + self.stable.setdefault(ident, now) + if state["session_id"] != current["session_id"]: + state.update(session_id=current["session_id"], opened_at=current["opened_at"]) + self.save(ident, state) + if now - self.stable[ident] >= 60 and state["attempts"]: + state["attempts"] = 0 + self.save(ident, state) + if ( + state["enabled"] + and state["preview_wanted"] + and state["resume_session"] != current["session_id"] + and current.get("status", {}).get("preview") == 0 + and current.get("status", {}).get("recording") == 0 + ): + decisions.append((ident, "preview")) + else: + self.stable.pop(ident, None) + since = self.absent.setdefault(ident, now) + if ( + state["enabled"] + and state["attempts"] < 3 + and now - since >= (8, 40, 100)[state["attempts"]] + ): + decisions.append((ident, "wake")) + return decisions + + def work(self, ident, kind): + if kind == "preview": + # Serialize restore with user START/STOP/configure for this camera only. + with self.broker.capture_lock(ident): + with self.lock: + state = self.states[ident] + current = self.current.get(ident) + if not state["enabled"] or not state["preview_wanted"] or not current: + return + session = current["session_id"] + if state["resume_session"] == session: + return + state.update(resume_session=session, phase="restoring_preview") + self.save(ident, state) + now = datetime.now(UTC) + operation = ( + "op_" + hashlib.sha256((ident + session + "restore").encode()).hexdigest()[:32] + ) + command = { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": operation, + "idempotency_key": operation, + "session": {"device_id": ident, "session_id": session}, + "action_id": "preview.start", + "parameters": {}, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + } + result = self.broker.capture_operation(ident, command, restoring=True) + with self.lock: + state["phase"] = ( + "ready" if result.get("state") == "complete" else "preview_failed" + ) + self.save(ident, state) + else: + with self.lock: + state = self.states[ident] + if not state["enabled"] or state["attempts"] >= 3: + return + state["attempts"] += 1 + state["phase"] = "waking" + self.save(ident, state) + result = request( + HELPER, "/wake", {"device_id": ident, "attempt": uuid.uuid4().hex}, timeout=52 + ) + with self.lock: + state = self.states[ident] + if state["enabled"]: + if result.get("state") == "busy": + state["attempts"] -= 1 + state["phase"] = ( + "ready" + if result.get("state") == "connected" + else "exhausted" + if state["attempts"] >= 3 + else "waiting" + ) + self.save(ident, state) + + def loop(self): + while not self.stopping.wait(2): + try: + snapshots = self.broker.snapshots() + with self.lock: + for ident, future in list(self.jobs.items()): + if future.done(): + if future.exception(): + state = self.states[ident] + state["phase"] = "unavailable" if state["enabled"] else "disabled" + self.save(ident, state) + del self.jobs[ident] + for ident, kind in self.observe(snapshots, time.monotonic()): + if ident not in self.jobs and len(self.jobs) < 2: + self.jobs[ident] = self.pool.submit(self.work, ident, kind) + except (OSError, ValueError, RuntimeError, KeyError): + # No wake is inferred from failed model-wide inventory sampling. + continue + + def start(self): + threading.Thread(target=self.loop, name="x4-recovery", daemon=True).start() + + +class RecoveryCalls: + def __init__(self, recovery, ident, command): + self.recovery, self.ident, self.command = recovery, ident, command + + def call(self, action, params): + if action == "recovery.configure": + return self.recovery.configure(self.ident, params["enabled"]) + if action == "preview.stop": + self.recovery.preview_intent(self.ident, self.command, {"state": "complete"}) + return {"state": "complete", "result": {"preview_wanted": False}} + raise ValueError("Unsupported recovery action") diff --git a/plugins/insta360-x4/tests/check_recovery.py b/plugins/insta360-x4/tests/check_recovery.py new file mode 100644 index 0000000..1631077 --- /dev/null +++ b/plugins/insta360-x4/tests/check_recovery.py @@ -0,0 +1,236 @@ +"""Synthetic recovery/privilege-boundary tests. No USB, Bluetooth or service changes.""" + +import json +import sys +import tempfile +import unittest +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "packaging")] +import layout # noqa: E402 +import wake_service # noqa: E402 +from check_runtime import command # noqa: E402 +from runtime import broker, identity, recovery # noqa: E402 +from runtime.native import parameters # noqa: E402 + +A = identity.device_id("SYNTHETICAAAAAA") +B = identity.device_id("SYNTHETICBBBBBB") + + +def snapshot(ident=A, session="x4_one", preview=0, recording=0): + now = datetime.now(UTC).isoformat() + return { + "id": ident, + "session_id": session, + "opened_at": now, + "observed_at": now, + "revision": 2, + "prepared": True, + "verified": True, + "preparation_safe": True, + "message": None, + "online": True, + "status": {"connected": True, "preview": preview, "recording": recording}, + } + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build") + self.root = Path(self.temporary.name) + self.broker = broker.Broker() + self.value = recovery.Recovery(self.broker, self.root / "recovery") + self.broker.recovery = self.value + self.addCleanup(self.value.pool.shutdown, wait=True) + self.addCleanup(self.temporary.cleanup) + + def enable(self, ident=A): + self.value.observe([snapshot(ident)], 0) + with patch.object(recovery, "request", return_value={"enabled": True}): + self.value.configure(ident, True) + + def test_default_has_no_wake_and_independent_instances_keep_distinct_intents(self): + self.assertEqual(self.value.observe([], 0), []) + self.enable() + self.value.states[B] = self.value.default() + self.assertEqual(self.value.observe([], 0), []) + self.assertEqual(self.value.observe([], 8), [(A, "wake")]) + self.assertFalse(self.value.view(B)["enabled"]) + + def test_grace_three_attempts_and_restart_do_not_reset_budget(self): + self.enable() + self.value.observe([], 0) + with patch.object(recovery, "request", return_value={"state": "unavailable"}): + for number, threshold in enumerate((8, 40, 100)): + self.assertEqual(self.value.observe([], threshold - 1), []) + self.assertEqual(self.value.observe([], threshold), [(A, "wake")]) + self.value.work(A, "wake") + self.assertEqual(self.value.states[A]["attempts"], number + 1) + self.assertEqual(self.value.observe([], 900), []) + restored = recovery.Recovery(self.broker, self.root / "recovery") + self.addCleanup(restored.pool.shutdown, wait=True) + self.assertEqual(restored.observe([], 1000), []) + + def test_stable_return_resets_budget_only_after_one_minute(self): + self.enable() + self.value.states[A]["attempts"] = 3 + self.value.observe([], 0) + self.value.observe([snapshot()], 10) + self.assertEqual(self.value.states[A]["attempts"], 3) + self.value.observe([snapshot()], 70) + self.assertEqual(self.value.states[A]["attempts"], 0) + + def test_disable_cancels_helper_even_while_offline_and_clears_preview(self): + self.enable() + self.value.states[A]["preview_wanted"] = True + self.value.observe([], 1) + with patch.object(recovery, "request", return_value={"enabled": False}) as call: + self.value.configure(A, False) + call.assert_called_once() + self.assertFalse(self.value.view(A)["preview_wanted"]) + self.assertEqual(self.value.observe([], 500), []) + + def test_no_preview_restore_without_confirmed_intent_or_when_recording_unknown(self): + self.enable() + self.assertEqual(self.value.observe([snapshot(session="x4_two")], 1), []) + self.value.states[A]["preview_wanted"] = True + for recording in (1, -1, None): + self.assertEqual(self.value.observe([snapshot(recording=recording)], 2), []) + + def test_restore_once_for_new_session_and_never_replay_sd_commands(self): + self.enable() + self.value.states[A]["preview_wanted"] = True + self.value.observe([snapshot(session="x4_two")], 2) + with patch.object( + self.broker, "capture_operation", return_value={"state": "unknown"} + ) as call: + self.value.work(A, "preview") + self.value.work(A, "preview") + self.assertEqual(call.call_count, 1) + self.assertEqual(call.call_args.args[1]["action_id"], "preview.start") + self.assertEqual(self.value.observe([snapshot(session="x4_two")], 3), []) + + def test_cancelled_intent_blocks_previously_scheduled_preview_job(self): + self.enable() + self.value.states[A]["preview_wanted"] = True + self.assertEqual(self.value.observe([snapshot(session="x4_two")], 1), [(A, "preview")]) + self.value.states[A]["preview_wanted"] = False + with patch.object(self.broker, "capture_operation") as call: + self.value.work(A, "preview") + call.assert_not_called() + + def test_old_start_receipt_cannot_reenable_preview_after_stop(self): + self.enable() + start = command(A, "x4_one", "preview.start") + stop = command(A, "x4_one", "preview.stop") + stop["requested_at"] = (datetime.now(UTC) + timedelta(seconds=1)).isoformat() + for value in (start, stop, start): + self.value.preview_intent(A, value, {"state": "complete"}) + self.assertFalse(self.value.view(A)["preview_wanted"]) + + def test_offline_inventory_preserves_control_but_never_claims_online(self): + self.enable() + offline = self.value.augment([])[0] + self.assertFalse(offline["online"]) + self.assertFalse(offline["preparation_safe"]) + self.assertEqual(offline["status"], {"recovery": self.value.view(A)}) + self.assertNotIn("serial", json.dumps(offline)) + + def test_recovery_parameters_require_explicit_camera_setting_confirmation(self): + parameters("recovery.configure", {"enabled": False, "wakeup_confirmed": False}) + for value in ( + {"enabled": True, "wakeup_confirmed": False}, + {"enabled": 1, "wakeup_confirmed": True}, + {"enabled": True}, + ): + with self.assertRaises(ValueError): + parameters("recovery.configure", value) + + +class WakeServiceTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build") + self.root = Path(self.temporary.name) + self.addCleanup(self.temporary.cleanup) + self.service = wake_service.WakeService(self.root) + self.trust = patch.object(wake_service, "trusted", side_effect=lambda path, *args: path) + self.trust.start() + self.addCleanup(self.trust.stop) + layout_trust = patch.object(layout, "trusted", side_effect=lambda path, *args: path) + layout_trust.start() + self.addCleanup(layout_trust.stop) + self.binding = identity.Binding(A, "1-2", 1, 2, "SYNTHETICAAAAAA") + + def enable(self): + with patch.object(wake_service, "connected", return_value=[self.binding]): + self.service.control(A, True) + + def test_enrollment_uses_os_serial_and_refuses_arbitrary_transport_input(self): + self.enable() + self.assertEqual(self.service.load(A)["serial"], self.binding.serial) + with self.assertRaises(ValueError): + self.service.dispatch( + "POST", "/control", {"device_id": A, "enabled": True, "serial": "ATTACKER"}, {} + ) + with ( + patch.object(wake_service, "connected", return_value=[]), + self.assertRaises(ValueError), + ): + self.service.control(B, True) + + def test_present_sdk_and_duplicate_serials_never_launch_advertiser(self): + self.enable() + with ( + patch.object(wake_service, "connected", return_value=[self.binding]), + patch.object(wake_service.subprocess, "Popen") as spawn, + ): + self.assertEqual(self.service.wake_locked(A, "1" * 32)["state"], "connected") + spawn.assert_not_called() + with ( + patch.object(wake_service, "connected", return_value=[self.binding, self.binding]), + self.assertRaises(ValueError), + ): + self.service.control(A, True) + + def test_persisted_rate_limit_and_duplicate_attempt_prevent_commands(self): + self.enable() + value = self.service.load(A) + value["attempts"] = [{"id": str(i) * 32, "at": wake_service.time.time()} for i in range(3)] + self.service.save(A, value) + with ( + patch.object(wake_service, "connected", return_value=[]), + patch.object(wake_service.subprocess, "Popen") as spawn, + ): + self.assertEqual(self.service.wake_locked(A, "0" * 32)["state"], "already_attempted") + self.assertEqual(self.service.wake_locked(A, "4" * 32)["state"], "exhausted") + spawn.assert_not_called() + + def test_known_radio_suffix_collision_blocks_wake(self): + self.enable() + self.service.save(B, {"serial": "DIFFERENTAAAAAA", "enabled": True, "attempts": []}) + with patch.object(wake_service, "connected", return_value=[]): + self.assertEqual(self.service.wake_locked(A, "1" * 32)["state"], "identity_conflict") + + def test_disable_persists_before_cancelling_only_selected_child(self): + self.enable() + left, right = Mock(), Mock() + self.service.running = {A: left, B: right} + self.service.control(A, False) + left.terminate.assert_called_once() + right.terminate.assert_not_called() + self.assertFalse(self.service.load(A)["enabled"]) + + def test_one_adapter_busy_does_not_consume_another_camera_attempt(self): + self.service.advertiser.acquire() + try: + self.assertEqual(self.service.wake(A, "1" * 32), {"state": "busy"}) + finally: + self.service.advertiser.release() + self.assertEqual(list(self.root.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/k1link/fleet/sensors.py b/src/k1link/fleet/sensors.py index 37cc3ab..a138379 100644 --- a/src/k1link/fleet/sensors.py +++ b/src/k1link/fleet/sensors.py @@ -28,6 +28,7 @@ ACTIONS = { "settings.read", "settings.apply", "files.list", + "recovery.configure", } MAX_INVENTORY_ITEMS = 500 MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024