Add packaged per-camera X4 wake and preview recovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 остаются незавершёнными.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 сохранила поток.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается."
|
||||
actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}>
|
||||
<p>На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.</p>
|
||||
<Button disabled={!enabled||(!online&&!value.enabled)} loading={pending} onClick={()=>change(!value.enabled)}>
|
||||
{value.enabled?'Отключить восстановление':'Включить восстановление'}
|
||||
</Button>
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -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<string,unknown>={},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<unknown>(transport,device,name,parameters);
|
||||
@@ -40,11 +41,12 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
|
||||
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={device.name} description={`${device.model}${device.firmware?' · '+device.firmware:''}`} actions={<IconButton label="Обновить состояние камеры" loading={pending==='refresh'} disabled={!enabled||busy} onClick={()=>void action('refresh')}><Icon name="refresh"/></IconButton>}>
|
||||
{!available&&<p>Нет свежей связи с камерой.</p>}
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!available||busy||status.preview===0} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!available&&!status.recovery?.preview_wanted)||(available&&status.preview===0&&!status.recovery?.preview_wanted)} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
|
||||
{status.alerts?.temperature_high&&<p>Камера сообщает о перегреве.</p>}
|
||||
{status.alerts?.storage_full&&<p>На карте камеры закончилось место.</p>}
|
||||
{status.alerts?.battery_low&&<p>Камера сообщает о низком заряде.</p>}
|
||||
</SettingsCard>
|
||||
{status.recovery?.supported&&<RecoverySettings value={status.recovery} online={available} enabled={enabled&&!busy} pending={pending==='recovery.configure'} change={value=>void action('recovery.configure',{enabled:value,wakeup_confirmed:value})}/>}
|
||||
<LiveViewport device={viewDevice} layer="preview" title="Изображение X4" note="Запись сохраняется на карте памяти камеры." inactiveMessage="Нажмите «Начать просмотр», чтобы получить изображение." transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Съёмка" description="Фото и видеозапись сохраняются на карте памяти X4." actions={<StatusBadge tone={available&&status.recording===1?'success':status.recording===0?'neutral':'warning'}>{available?recordLabel:'Состояние недоступно'}</StatusBadge>}>
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -28,6 +28,7 @@ ACTIONS = {
|
||||
"settings.read",
|
||||
"settings.apply",
|
||||
"files.list",
|
||||
"recovery.configure",
|
||||
}
|
||||
MAX_INVENTORY_ITEMS = 500
|
||||
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
||||
|
||||
Reference in New Issue
Block a user