Add packaged per-camera X4 wake and preview recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 14:20:52 +03:00
parent f164606828
commit d45b329de8
22 changed files with 1045 additions and 16 deletions
@@ -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 {
+2 -2
View File
@@ -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.33194610: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.19873510: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.98505611:01:43.659067 UTC,
Mac monotonic458551.0406405,duration32.674049916s. Один restart, PID46997→65164,
Core operational, identity hashes до/после совпали. Private activation report
SHA256d1e3d09dbb658f259f64bd201f5c8b7781560a8ac9bffb11fb5870eaea6427e0.
P11 первая попытка10:59:46.53313910:59:53.546295 UTC,
Ubuntu monotonic336294.414243883,duration7.013164s: APT package step100,
версии не изменены. preinst требует /prepare-safe от RealSense; D455 была
streaming, запись/playback отсутствовали. Штатный Core STOP выполнен
11:04:43.00213411:04:50.089636 UTC,Mac monotonic458763.058095666,
fresh idle подтверждён. Private report SHA256
9d6716fde1a31b17c9344fd202564de0a38b9b765c2545460124c506e97b8461.
Повторён тот же hash-bound --launch, без обхода preinst/ручного root ремонта.
P11 complete11:09:11.00658911: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.54582811:12:27.665197 UTC,
Mac monotonic459219.601795958459227.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 out35s→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.
+8
View File
@@ -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[];
+8 -1
View File
@@ -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(),
+2 -1
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
from debian import package # noqa: E402
from fetch_sdk import verify # noqa: E402
VERSION = "0.1.3-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
+1
View File
@@ -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",
):
+1
View File
@@ -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()
+34 -4
View File
@@ -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)
+10
View File
@@ -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)
+278
View File
@@ -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")
+236
View File
@@ -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()
+1
View File
@@ -28,6 +28,7 @@ ACTIONS = {
"settings.read",
"settings.apply",
"files.list",
"recovery.configure",
}
MAX_INVENTORY_ITEMS = 500
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024