diff --git a/AGENTS.md b/AGENTS.md index aa2f23a..eb97e6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code. - Synthetic or explicitly redacted fixtures may be committed under `tests/fixtures/`. +## Insta360 and clean-host installation — owner requirement, 2026-09-08 + +- The current X4 starting point is USB enumeration only. SDK installation, + camera initialization and live video have not been accepted. +- Every Ubuntu change needed for Insta360 must execute through the shipped + installer or the application's versioned device-preparation workflow from + the first board experiment. Do not repair the board with an ad-hoc apt/pip + install, copied library, chmod, environment override, service edit or root + invocation and promise to package it later. Fix the product artifact first, + then rerun that artifact. Read-only engineering inspection remains allowed. +- Document every relevant action and its before/after evidence in + `docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version, + owning installer step, dependencies, idempotency, failure behavior and rollback. + Keep private logs/identifiers/media out of Git and redact their public summary. +- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency + closure on the declared clean OS image and actual USB operation separately; + installer, preparation and GUI acceptance must use the same shipped code. +- X4 is operator live video and camera control (settings, photo, recording + start/stop and file access), per the owner’s subsequent scope expansion. + Preview and recording are separate operations. No stitching, AI, navigation + or vehicle-control integration is admitted. Model support and physical device instances are separate: several + cameras of the same model must retain independent identity and lifecycle. +- Follow the scoped implementation plan in + `docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan, + not evidence that SDK compatibility, hardware capacity or installation passed. +- Subsequent owner direction: the existing Ubuntu Mini is the available build + and qualification machine; do not depend on Worker 006 or another host for + this X4 work. Its existing GCC may compile through a versioned build artifact + in bounded temporary staging. This is a build step, not runtime installation: + no system changes, SDK execution or camera access during compilation. The + final installer must carry the compiled payload so a clean operator Ubuntu + does not inherit an undeclared compiler/build-cache prerequisite. All board + staging/build/install/test actions remain documented and artifact-owned. + ## Implementation order Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy diff --git a/apps/control-station/src/composition/devicePlugins.ts b/apps/control-station/src/composition/devicePlugins.ts index 8e77533..2091dcb 100644 --- a/apps/control-station/src/composition/devicePlugins.ts +++ b/apps/control-station/src/composition/devicePlugins.ts @@ -1,8 +1,13 @@ import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin"; +import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin'; // Composition root: this is the only place where Mission Core chooses which // statically reviewed device plugins are shipped in the current build. export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([ xgridsK1Plugin, ]); + +// Node-executed camera controls use the existing sensor contribution contract; +// they do not register a desktop capture/AI runtime or a new product workspace. +export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]); diff --git a/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx b/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx index 1a843aa..5a30083 100644 --- a/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx +++ b/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx @@ -11,6 +11,8 @@ import { import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext"; import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts"; import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry"; +import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions'; +const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]); interface DevicePluginHostValue { registry: DevicePluginRegistry; @@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId( export function DevicePluginHostProvider({ plugins, + nodeSensorContributions = NO_NODE_SENSORS, children, }: { plugins: readonly DeviceUiPlugin[]; + nodeSensorContributions?: readonly SensorUiContribution[]; children: ReactNode; }) { - const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]); + const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]); const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []); const [selectedModelId, setSelectedModelId] = useState(() => restorePersistedDeviceModelId(registry, selectionStorage) diff --git a/apps/control-station/src/core/device-plugins/registry.ts b/apps/control-station/src/core/device-plugins/registry.ts index 1429d2d..8da5b82 100644 --- a/apps/control-station/src/core/device-plugins/registry.ts +++ b/apps/control-station/src/core/device-plugins/registry.ts @@ -6,8 +6,10 @@ import { type DeviceUiPlugin, type RegisteredDeviceModel, } from "./contracts"; +import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions'; export interface DevicePluginRegistry { + readonly sensorContributions: readonly SensorUiContribution[]; readonly plugins: readonly DeviceUiPlugin[]; readonly models: readonly RegisteredDeviceModel[]; resolveModel: (modelId: string) => RegisteredDeviceModel | null; @@ -15,6 +17,7 @@ export interface DevicePluginRegistry { export function createDevicePluginRegistry( installedPlugins: readonly DeviceUiPlugin[], + nodeSensorContributions: readonly SensorUiContribution[] = [], ): DevicePluginRegistry { const pluginIds = new Set(); const modelIds = new Set(); @@ -133,7 +136,15 @@ export function createDevicePluginRegistry( } const modelById = new Map(models.map((registered) => [registered.model.id, registered])); + const sensorContributions = [ + ...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []), + ...nodeSensorContributions, + ]; + if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) { + throw new Error('Плагины повторяют модель устройства БК.'); + } return { + sensorContributions: Object.freeze(sensorContributions), plugins: Object.freeze([...installedPlugins]), models: Object.freeze(models), resolveModel: (modelId) => modelById.get(modelId) ?? null, diff --git a/apps/control-station/src/main.tsx b/apps/control-station/src/main.tsx index dcf0203..a486dcf 100644 --- a/apps/control-station/src/main.tsx +++ b/apps/control-station/src/main.tsx @@ -6,7 +6,7 @@ import "@nodedc/tokens/themes.css"; import "@nodedc/ui-core/styles.css"; import App from "./App"; -import { installedDevicePlugins } from "./composition/devicePlugins"; +import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins"; import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost"; import { ComputeContourProvider } from "./core/system/ComputeContourContext"; import "./styles.css"; @@ -23,7 +23,7 @@ applyNodedcTheme(rootElement, { theme: "dark" }); createRoot(rootElement).render( - + diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index 6686905..3a29aad 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -6,7 +6,7 @@ import type {SensorInventory,SensorTransport} from '../../../../../packages/sens import {fleetRequest} from '../../core/fleet/useFleet'; export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){ const {registry}=useDevicePluginHost(); - const sensorContributions=useMemo(()=>registry.plugins.flatMap(plugin=>plugin.sensorUi?.contributions??[]),[registry]); + const sensorContributions=registry.sensorContributions; const transport=useMemo(()=>({ enrollment:{ state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`), diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index b1a3787..1038fbe 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -1,6 +1,6 @@ import {BoardMonitorView} from './BoardMonitorView'; import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react"; +import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react"; import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet"; import "./fleet.css"; import { VehicleSensors } from "./VehicleSensors"; @@ -58,7 +58,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe {detail.enrollment !== "revoked" && } } - : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } + : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } {preview ? : }}>
    ({value,label:videoNames[value]??value}))} onChange={setLayer}/>
    {groups.map(group=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/>}
    - {detail.recordings?.length?detail.recordings.map(item=>
    {new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
    ):

    Записей пока нет.

    }
    } +
    apply('function_mode',Number(next))}/> + {value.mode===7&&PHOTO_SIZES[size]).map(size=>({value:String(size),label:PHOTO_SIZES[size]}))} disabled={disabled} onChange={next=>apply('photo_size',Number(next))}/>} + {exposure.length>0&&apply('white_balance',Number(next))}/>} +
    ISO
    {value.values.iso||'Авто'}
    Выдержка
    {value.values.shutter_seconds>0?`${value.values.shutter_seconds.toFixed(4)} с`:'Авто'}
    ; +} diff --git a/plugins/insta360-x4/frontend/src/X4Detail.tsx b/plugins/insta360-x4/frontend/src/X4Detail.tsx new file mode 100644 index 0000000..5ee79a3 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/X4Detail.tsx @@ -0,0 +1,60 @@ +import {useCallback,useEffect,useRef,useState} from 'react'; +import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions'; +import {perform} from '../../../../packages/sensor-ui/src/contracts'; +import {LiveViewport} from '../../../../packages/sensor-ui/src/LiveViewport'; +import {CameraSettings} from './CameraSettings'; +import {cameraStatus,fileName,type CameraFiles,type CameraSettings as Settings} from './model'; + +export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorDetailProps){ + const status=cameraStatus(device); + const [settings,setSettings]=useState(null); + const [files,setFiles]=useState(null); + const [pending,setPending]=useState(null);const busy=pending!==null; + const [settingsLoading,setSettingsLoading]=useState(false);const running=useRef(false);const mounted=useRef(true); + const available=enabled&&device.online&&status.connected; + useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]); + const load=useCallback(async()=>{ + if(status.function_mode===null)return; + if(mounted.current)setSettingsLoading(true); + try{const value=await perform(transport,device,'settings.read',{mode:status.function_mode});if(mounted.current)setSettings(value);} + finally{if(mounted.current)setSettingsLoading(false);} + },[transport,device.id,device.snapshot.context.session_id,status.function_mode]); + useEffect(()=>{if(available)void load().catch(error=>{if(mounted.current)failure(error);});},[available,load]); + async function action(name:string,parameters:Record={},key=name){ + if(running.current||!enabled||(!available&&name!=='refresh'))return; + running.current=true;setPending(key);failure(null); + try{ + const result=name==='refresh'?(available?await load():undefined):await perform(transport,device,name,parameters); + if(!mounted.current)return; + if(name==='settings.apply')setSettings(result as Settings); + if(name==='files.list')setFiles(result as CameraFiles); + if(name==='photo.capture'||name==='record.stop')setFiles(null); + await refresh(); + }catch(error){if(mounted.current)failure(error);} + finally{running.current=false;if(mounted.current)setPending(null);} + } + const mode=settings?.mode??status.function_mode; + const recordLabel=status.recording===1?'Идёт запись на карту':status.recording===0?'Запись остановлена':'Состояние записи не подтверждено'; + const viewDevice=available?device:{...device,snapshot:{...device.snapshot,acquisition:'idle'}}; + return
    + void action('refresh')}>}> + {!available&&

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

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

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

    } + {status.alerts?.storage_full&&

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

    } + {status.alerts?.battery_low&&

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

    } +
    + + {available?recordLabel:'Состояние недоступно'}}> +
    + {available&&status.recording===1&&

    Запись продолжится после закрытия просмотра.

    } +
    + + {settings?void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?

    Настройки недоступны. Обновите состояние камеры.

    :null} +
    + void action('files.list',{offset:0},'files.refresh')}>Обновить список}> + {files?files.items.length?<>{files.items.map((item,index)=>
  • }/>
  • )}
    {files.offset+1}–{files.offset+files.items.length} из {files.total}
    :

    Файлов нет.

    :

    Обновите список, чтобы увидеть записи на карте камеры.

    } +
    +
    ; +} diff --git a/plugins/insta360-x4/frontend/src/model.ts b/plugins/insta360-x4/frontend/src/model.ts new file mode 100644 index 0000000..a997275 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/model.ts @@ -0,0 +1,41 @@ +import type {Sensor} from '../../../../packages/sensor-ui/src/contracts'; + +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}; +} +export interface CameraSettings { + mode:number; photo_modes:number[]; video_modes:number[]; photo_sizes:number[]; + video_resolutions:{id:number;name:string}[]; + values:Record; + attributes:Record; +} +export interface CameraFiles {items:string[];offset:number;total:number} +const state=(value:unknown)=>value===0||value===1||value===-1?value:null; +export function cameraStatus(device:Sensor):CameraStatus { + const value=device.camera_status??{}; + return {...value,connected:value.connected===true,preview:state(value.preview),recording:state(value.recording), + function_mode:typeof value.function_mode==='number'&&Number.isInteger(value.function_mode)?value.function_mode:null} as CameraStatus; +} +export function cameraLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} { + if(!fresh||!device.online)return {label:fresh?'Камера отключена':'Нет свежих сведений',tone:'neutral'}; + if(device.initializable===false)return {label:'Не удалось определить камеру',tone:'warning'}; + if(!(device.configured??device.snapshot.enrollment==='enrolled'))return {label:'Требуется подготовка',tone:'neutral'}; + if(!device.prepared)return {label:'Драйвер недоступен',tone:'warning'}; + const value=cameraStatus(device); + if(!value.connected||value.recording===null||value.preview===null)return {label:'Состояние камеры не подтверждено',tone:'warning'}; + if(value.recording===1)return {label:'Запись на карту камеры',tone:'success'}; + if(value.recording===-1)return {label:'Состояние записи не подтверждено',tone:'warning'}; + if(value.preview===-1)return {label:'Состояние просмотра не подтверждено',tone:'warning'}; + if(value.preview===1)return {label:'Идёт просмотр',tone:'success'}; + return {label:'Подключена',tone:'success'}; +} +export function resolutionLabel(value:string):string { + const match=/^(\d+)_(\d+)_(\d+)(.*)$/.exec(value); + return match?`${match[1]} × ${match[2]} · ${match[3]} кадр/с${match[4]==='_plus'?' · повышенное качество':''}`:value; +} +export function fileName(value:string):string { + const path=value.split('?')[0].split('/').at(-1)??value; + try{return decodeURIComponent(path);}catch{return path;} +} diff --git a/plugins/insta360-x4/frontend/src/plugin.ts b/plugins/insta360-x4/frontend/src/plugin.ts new file mode 100644 index 0000000..3e7c649 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/plugin.ts @@ -0,0 +1,8 @@ +import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions'; +import {X4Detail} from './X4Detail'; +import {cameraLabel} from './model'; + +export const insta360X4SensorUi:SensorUiContribution={ + kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true, + supportsPreparation:true,supportsRenaming:true,status:cameraLabel, +}; diff --git a/plugins/insta360-x4/native/bridge.cpp b/plugins/insta360-x4/native/bridge.cpp new file mode 100644 index 0000000..be79801 --- /dev/null +++ b/plugins/insta360-x4/native/bridge.cpp @@ -0,0 +1,429 @@ +#include "bridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ins_camera; + +namespace { +constexpr size_t MAX_PACKET = 4 * 1024 * 1024; +constexpr size_t MAX_QUEUE = 8 * 1024 * 1024; +constexpr size_t MAX_RESULT = 60 * 1024; + +std::string quote(const std::string& value) { + if (value.size() > 1024) throw std::runtime_error("oversized SDK string"); + std::ostringstream out; + out << '"'; + for (unsigned char c : value) { + if (c == '"' || c == '\\') out << '\\' << c; + else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c); + else out << c; + } + return out.str() + '"'; +} + +template bool contains(const std::vector& values, T value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +template std::string numbers(const std::vector& values) { + if (values.size() > 256) throw std::runtime_error("oversized capabilities"); + std::string out = "["; + for (auto v : values) { if (out.size() > 1) out += ','; out += std::to_string(int(v)); } + return out + ']'; +} + +std::string strings(const std::vector& values) { + if (values.size() > 256) throw std::runtime_error("oversized SDK list"); + std::string out = "["; + for (const auto& v : values) { if (out.size() > 1) out += ','; out += quote(v); } + return out + ']'; +} + +std::string complete(const std::string& result) { + return "{\"state\":\"complete\",\"result\":" + result + '}'; +} +const char* UNKNOWN = "{\"state\":\"unknown\",\"error\":\"camera_result_unconfirmed\"}"; +const char* INVALID = "{\"state\":\"error\",\"error\":\"unsupported_camera_parameter\"}"; + +struct Packet { mc_x4_video info; std::vector bytes; }; +struct Frames final : StreamDelegate { + std::mutex mutex; + std::deque queue; + size_t bytes = 0; + uint64_t generation = 0, sequence = 0; + std::atomic codec{0}; + std::atomic enabled{false}; + + void reset() { + std::lock_guard lock(mutex); + queue.clear(); bytes = 0; ++generation; + } + void OnVideoData(const uint8_t* data, size_t size, int64_t timestamp, + uint8_t, int stream_index) override { + if (!enabled || !data || !size || size > MAX_PACKET || stream_index < 0 || stream_index > 1) return; + // SDK owns the callback bytes. Copy into a bounded queue; never block + // a vendor callback on a browser, disk, decoder or Python interpreter. + try { + std::lock_guard lock(mutex); + if (bytes + size > MAX_QUEUE || queue.size() >= 64) { + queue.clear(); bytes = 0; ++generation; + } + queue.push_back({{++sequence, generation, timestamp, stream_index, + codec.load(), uint32_t(size)}, {data, data + size}}); + bytes += size; + } catch (...) { + // A memory allocation failure drops data; it must not unwind into + // the vendor's callback thread or terminate camera recording. + } + } + void OnAudioData(const uint8_t*, size_t, int64_t) override {} + void OnGyroData(const std::vector&) override {} + void OnExposureData(const ExposureData&) override {} +}; + +struct Notifications { + std::mutex mutex; + std::atomic recording{-1}; + std::atomic revision{0}; + std::atomic stopped_reason{-1}; + std::atomic battery_low{false}, storage_full{false}, temperature_high{false}; +}; +} + +struct mc_x4 { + DeviceDiscovery discovery; + std::vector descriptors; + std::shared_ptr camera; + std::shared_ptr frames = std::make_shared(); + std::shared_ptr notifications = std::make_shared(); + std::string firmware, result; + bool opened = false; + int preview = 0; + // Only the isolated worker calls control methods; callbacks use separate + // shared state that remains alive until the vendor releases its callbacks. + ~mc_x4() { + frames->enabled = false; + if (camera && opened) { try { camera->Close(); } catch (...) {} } + camera.reset(); + try { discovery.FreeDeviceDescriptors(descriptors); } catch (...) {} + } + + bool mode_supported(CameraFunctionMode mode) { + return contains(camera->GetSupportedVideoModes(), mode) || contains(camera->GetSupportedPhotoModes(), mode); + } + + std::string status() { + BatteryStatus battery{}; + StorageStatus storage{}; + bool battery_ok = camera->GetBatteryStatus(battery); + bool storage_ok = camera->GetStorageState(storage); + // The SDK exposes a boolean capture poll, not a separate timeout code. + // Report its value only while independent status reads succeed; never + // replace a notification that arrived during the blocking poll. + auto revision = notifications->revision.load(); + bool active = camera->CaptureCurrentStatus(); + bool connected = camera->IsConnected(); + { + std::lock_guard lock(notifications->mutex); + if (revision == notifications->revision.load()) { + notifications->recording = connected && battery_ok && storage_ok ? (active ? 1 : 0) : -1; + } + } + std::string result = "{\"firmware\":" + quote(firmware) + + ",\"connected\":" + (connected ? "true" : "false") + + ",\"preview\":" + std::to_string(preview) + + ",\"recording\":" + std::to_string(notifications->recording) + + ",\"stopped_reason\":" + std::to_string(notifications->stopped_reason) + + ",\"function_mode\":" + std::to_string(int(camera->GetCurrentFunctionMode())) + + ",\"codec\":" + std::to_string(int(camera->GetVideoEncodeType())) + + ",\"battery\":"; + result += battery_ok ? "{\"level\":" + std::to_string(battery.battery_level) + + ",\"scale\":" + std::to_string(battery.battery_scale) + + ",\"power_type\":" + std::to_string(int(battery.power_type)) + "}" : "null"; + result += ",\"storage\":"; + result += storage_ok ? "{\"state\":" + std::to_string(int(storage.state)) + + ",\"free_bytes\":" + std::to_string(storage.free_space) + + ",\"total_bytes\":" + std::to_string(storage.total_space) + "}" : "null"; + result += ",\"alerts\":{\"battery_low\":" + std::string(notifications->battery_low ? "true" : "false") + + ",\"storage_full\":" + (notifications->storage_full ? "true" : "false") + + ",\"temperature_high\":" + (notifications->temperature_high ? "true" : "false") + "}}"; + return complete(result); + } + + std::string settings(CameraFunctionMode mode) { + if (!mode_supported(mode)) return INVALID; + if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN; + auto capture = camera->GetCaptureSettings(mode); + auto exposure = camera->GetExposureSettings(mode); + if (!capture || !exposure || !std::isfinite(exposure->ShutterSpeed())) return UNKNOWN; + std::ostringstream out; + out.imbue(std::locale::classic()); + out << "{\"mode\":" << int(mode) + << ",\"photo_modes\":" << numbers(camera->GetSupportedPhotoModes()) + << ",\"video_modes\":" << numbers(camera->GetSupportedVideoModes()) + << ",\"photo_sizes\":" << numbers(camera->GetSupportedPhotoSizes(mode)) + << ",\"video_resolutions\":["; + auto resolutions = camera->GetSupportedVideoResolutions(mode); + if (resolutions.size() > 256) return UNKNOWN; + bool first = true; + for (auto resolution : resolutions) { + if (!first) out << ','; + first = false; + out << "{\"id\":" << int(resolution) << ",\"name\":" << quote(camera->GetVideoResolutionName(resolution)) << '}'; + } + out << "],\"values\":{\"iso\":" << exposure->Iso() + << ",\"shutter_seconds\":" << std::setprecision(17) << exposure->ShutterSpeed() + << ",\"exposure_mode\":" << int(exposure->ExposureMode()) + << ",\"ev_twentieths\":" << exposure->EVBias() + << ",\"white_balance\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_WhiteBalance) + << ",\"video_resolution\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution) + << ",\"photo_size\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_PhotoResolution) + << "},\"attributes\":{"; + auto names = camera->GetSupportedAttrNames(mode); + if (names.size() > 128) return UNKNOWN; + first = true; + for (const auto& name : names) { + if (!first) out << ','; + first = false; + out << quote(name) << ":{\"values\":" << strings(camera->GetSupportedAttrValues(mode, name)) + << ",\"depends_on\":" << strings(camera->GetAttrDependOn(mode, name)) << '}'; + } + return complete(out.str() + "}}"); + } + + std::string apply(CameraFunctionMode mode, const std::string& key, double value) { + if (!mode_supported(mode) || !std::isfinite(value)) return INVALID; + status(); + if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID; + // One parameter per operation: partial application of a multi-setting + // request must never be presented as an atomic success. + if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN; + bool acknowledged = false; + if (key == "function_mode") { + if (value == FUNCTION_MODE_NORMAL_VIDEO && contains(camera->GetSupportedVideoModes(), FUNCTION_MODE_NORMAL_VIDEO)) + acknowledged = camera->SetVideoSubMode(VIDEO_NORMAL); + else if (value == FUNCTION_MODE_NORMAL_IMAGE && contains(camera->GetSupportedPhotoModes(), FUNCTION_MODE_NORMAL_IMAGE)) + acknowledged = camera->SetPhotoSubMode(PHOTO_SINGLE); + else return INVALID; + if (!acknowledged || int(camera->GetCurrentFunctionMode()) != int(value)) return UNKNOWN; + return settings(static_cast(int(value))); + } else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) { + auto resolution = static_cast(int(value)); + if (!contains(camera->GetSupportedVideoResolutions(mode), resolution)) return INVALID; + RecordParams params{}; params.resolution = resolution; + acknowledged = camera->SetVideoCaptureParams(params, mode); + } else if (key == "photo_size" && value == std::floor(value) && value >= 0 && value <= 4096) { + auto size = static_cast(int(value)); + if (!contains(camera->GetSupportedPhotoSizes(mode), size)) return INVALID; + acknowledged = camera->SetPhotoSize(mode, size); + } else { + // Exposure/white-balance ranges must come from this camera's + // capability table. No undocumented enum or guessed write. + const std::string attr = key == "iso" ? "exposure_iso" : key == "white_balance" ? "white_balance" : key == "exposure_mode" ? "exposure_program" : ""; + if (attr.empty() || value != std::floor(value) || value < 0 || value > 65535) return INVALID; + std::string context; + for (const auto& dependency : camera->GetAttrDependOn(mode, attr)) { + // Resolve the current camera state, never a client-provided + // context that could widen a dependent capability branch. + auto current = camera->GetCaptureSettings(mode); + if (!current || dependency != "record_resolution") return INVALID; + if (!context.empty()) context += '|'; + context += camera->GetVideoResolutionName(static_cast(current->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution))); + if (context.empty()) return INVALID; + } + auto values = camera->GetSupportedAttrValues(mode, attr, context); + bool admitted = false; + for (const auto& option : values) { + if (option == std::to_string(int(value)) || camera->GetAttrValueByName(attr, option) == int(value)) admitted = true; + } + if (!admitted) return INVALID; + if (key == "iso") { + auto settings = camera->GetExposureSettings(mode); + if (!settings || (settings->ExposureMode() != MANUAL && settings->ExposureMode() != ISO_PRIORITY)) return INVALID; + settings->SetIso(int(value)); + acknowledged = camera->SetExposureSettings(mode, settings); + } else if (key == "exposure_mode") { + if (value > 5) return INVALID; + auto settings = camera->GetExposureSettings(mode); + if (!settings) return UNKNOWN; + settings->SetExposureMode(static_cast(int(value))); + acknowledged = camera->SetExposureSettings(mode, settings); + } else { + auto settings = camera->GetCaptureSettings(mode); + if (!settings) return UNKNOWN; + settings->ResetSettingTypes(); + // X4 capabilities and the pinned SDK example use Kelvin (0 + // auto) via SetValue, not the legacy 0..5 WB enum. + settings->SetValue(CaptureSettings::CaptureSettings_WhiteBalance, int(value)); + acknowledged = camera->SetCaptureSettings(mode, settings); + } + } + // Returning the actual refreshed values lets the caller distinguish an + // acknowledged request from a setting the camera declined to retain. + return acknowledged ? settings(mode) : UNKNOWN; + } + + std::string call(int action, int mode_number, const std::string& key, double value) { + if (mode_number < 0 || mode_number > 255 || !std::isfinite(value)) return INVALID; + if (!camera->IsConnected()) return UNKNOWN; + auto mode = static_cast(mode_number); + if (action == MC_X4_STATUS) return status(); + if (action == MC_X4_SETTINGS_READ) return settings(mode); + if (action == MC_X4_SETTINGS_APPLY) return apply(mode, key, value); + if (action == MC_X4_PREVIEW_START) { + if (preview == 1) return complete("{\"ok\":true}"); + if (preview == -1) return UNKNOWN; + LiveStreamParam params{}; + params.enable_audio = false; params.enable_gyro = false; params.using_lrv = false; + params.video_resolution = RES_1920_960P30; params.lrv_video_resulution = RES_1920_960P30; + params.video_bitrate = 4000000; + frames->reset(); frames->codec = int(camera->GetVideoEncodeType()); frames->enabled = true; + preview = -1; + bool ok = camera->StartLiveStreaming(params); + frames->codec = int(camera->GetVideoEncodeType()); + if (!ok) { frames->enabled = false; return UNKNOWN; } + preview = 1; + return complete("{\"ok\":true}"); + } + if (action == MC_X4_PREVIEW_STOP) { + if (preview == 0) return complete("{\"ok\":true}"); + preview = -1; + if (!camera->StopLiveStreaming()) return UNKNOWN; + preview = 0; frames->enabled = false; frames->reset(); + return complete("{\"ok\":true}"); + } + if (action == MC_X4_RECORD_START) { + if (camera->CaptureCurrentStatus()) { + notifications->recording = 1; + return complete("{\"recording\":true}"); + } + if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_VIDEO) return INVALID; + uint64_t revision; + { + std::lock_guard lock(notifications->mutex); + revision = notifications->revision; notifications->recording = -1; + } + if (!camera->StartRecording()) return UNKNOWN; + { + std::lock_guard lock(notifications->mutex); + // A storage/temperature stop notification can precede the + // START acknowledgement. Never overwrite that newer fact. + if (notifications->revision == revision) notifications->recording = 1; + if (notifications->recording != 1) return UNKNOWN; + } + return complete("{\"recording\":true}"); + } + if (action == MC_X4_RECORD_STOP) { + uint64_t revision; + { + std::lock_guard lock(notifications->mutex); + revision = notifications->revision; notifications->recording = -1; + } + auto media = camera->StopRecording(); + if (media.Empty()) return UNKNOWN; + { + std::lock_guard lock(notifications->mutex); + if (notifications->revision == revision) notifications->recording = 0; + if (notifications->recording != 0) return UNKNOWN; + } + return complete("{\"recording\":false,\"files\":" + strings(media.OriginUrls()) + '}'); + } + if (action == MC_X4_PHOTO) { + status(); + if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID; + // Capture the operator-selected mode. Initialization never changes + // it or takes a photo as a side effect of verifying live frames. + if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_IMAGE) return INVALID; + auto media = camera->TakePhoto(RawCaptureType::PureShot, 15000); + return media.Empty() ? UNKNOWN : complete("{\"files\":" + strings(media.OriginUrls()) + '}'); + } + if (action == MC_X4_FILES) { + if (value < 0 || value > 100000 || value != std::floor(value)) return INVALID; + auto files = camera->GetCameraFilesList(); + size_t offset = size_t(value), stop = std::min(files.size(), offset + 32); + if (files.size() > 100000 || offset > files.size()) return INVALID; + std::vector page(files.begin() + offset, files.begin() + stop); + return complete("{\"items\":" + strings(page) + ",\"total\":" + std::to_string(files.size()) + + ",\"offset\":" + std::to_string(offset) + '}'); + } + return INVALID; + } +}; + +extern "C" { +int mc_x4_abi(void) { return 1; } +mc_x4* mc_x4_open(const char* serial, const char* log_directory) { + try { + if (!serial || !*serial || std::strlen(serial) > 256 || !log_directory || !*log_directory) return nullptr; + auto handle = std::make_unique(); + SetLogPath(log_directory); + SetLogLevel(LogLevel::ERR); + handle->descriptors = handle->discovery.GetAvailableDevices(); + // The service namespace must expose exactly one physical camera. + // Never take list[0] from a host-wide SDK enumeration. + if (handle->descriptors.size() != 1) return nullptr; + const auto& device = handle->descriptors.front(); + if (device.camera_type != CameraType::Insta360X4 || device.info.connection_type != ConnectionType::USB || device.serial_number != serial) return nullptr; + handle->firmware = device.fw_version; + handle->camera = std::make_shared(device.info); + handle->camera->SetServicePort(9099); // private network namespace per instance + handle->camera->SetTimeout(10000); + handle->opened = handle->camera->Open(); + if (!handle->opened) return nullptr; + auto n = handle->notifications; + handle->camera->SetCaptureStateNotification([n](bool active) { + std::lock_guard lock(n->mutex); + n->recording = active ? 1 : 0; ++n->revision; + }); + handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) { + std::lock_guard lock(n->mutex); + n->recording = 0; n->stopped_reason = reason; ++n->revision; + }); + handle->camera->SetBatteryLowNotification([n](int) { n->battery_low = true; }); + handle->camera->SetStorageFullNotification([n]() { n->storage_full = true; }); + handle->camera->SetTemperatureHighNotification([n]() { n->temperature_high = true; }); + std::shared_ptr delegate = handle->frames; + handle->camera->SetStreamDelegate(delegate); + return handle.release(); + } catch (...) { return nullptr; } +} +const char* mc_x4_call(mc_x4* handle, int action, int mode, const char* key, double value) { + if (!handle) return UNKNOWN; + try { + if (key && std::strlen(key) > 64) return INVALID; + handle->result = handle->call(action, mode, key ? key : "", value); + if (handle->result.size() > MAX_RESULT) handle->result = UNKNOWN; + return handle->result.c_str(); + } catch (...) { return UNKNOWN; } +} +int mc_x4_read_video(mc_x4* handle, mc_x4_video* info, uint8_t* buffer, size_t capacity) { + if (!handle || !info || !buffer) return -1; + try { + std::lock_guard lock(handle->frames->mutex); + if (handle->frames->queue.empty()) return 0; + auto& packet = handle->frames->queue.front(); + if (capacity < packet.bytes.size()) return -1; + *info = packet.info; + std::memcpy(buffer, packet.bytes.data(), packet.bytes.size()); + handle->frames->bytes -= packet.bytes.size(); + handle->frames->queue.pop_front(); + return 1; + } catch (...) { return -1; } +} +void mc_x4_close(mc_x4* handle) { try { delete handle; } catch (...) {} } +} diff --git a/plugins/insta360-x4/native/bridge.h b/plugins/insta360-x4/native/bridge.h new file mode 100644 index 0000000..ba8ad50 --- /dev/null +++ b/plugins/insta360-x4/native/bridge.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +#if defined(__GNUC__) +#define MC_X4_API __attribute__((visibility("default"))) +#else +#define MC_X4_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// Private ABI v1. One handle in each physically isolated camera worker. The +// caller must restrict USB access BEFORE loading this library or discovering. +typedef struct mc_x4 mc_x4; +enum mc_x4_action { + MC_X4_STATUS = 1, MC_X4_SETTINGS_READ = 2, MC_X4_SETTINGS_APPLY = 3, + MC_X4_PREVIEW_START = 4, MC_X4_PREVIEW_STOP = 5, + MC_X4_RECORD_START = 6, MC_X4_RECORD_STOP = 7, MC_X4_PHOTO = 8, + MC_X4_FILES = 9 +}; +typedef struct mc_x4_video { + uint64_t sequence; + uint64_t generation; // changes on queue overflow / preview restart + int64_t timestamp; // camera clock; units are not assumed by the bridge + int32_t stream_index; + int32_t codec; // 0 = H264, 1 = H265 + uint32_t bytes; +} mc_x4_video; + +MC_X4_API int mc_x4_abi(void); +MC_X4_API mc_x4* mc_x4_open(const char* expected_serial, const char* private_log_directory); +// JSON result owned by the handle; copy before the next call. Calls are +// serialized by the worker. No vendor exception or C++ object crosses the ABI. +MC_X4_API const char* mc_x4_call(mc_x4*, int action, int mode, const char* key, double value); +MC_X4_API int mc_x4_read_video(mc_x4*, mc_x4_video*, uint8_t* buffer, size_t capacity); +// Does not call StopRecording or change auto-stop-on-disconnect policy. +MC_X4_API void mc_x4_close(mc_x4*); + +#ifdef __cplusplus +} +#endif diff --git a/plugins/insta360-x4/native/tests/check_abi.py b/plugins/insta360-x4/native/tests/check_abi.py new file mode 100644 index 0000000..cec4eba --- /dev/null +++ b/plugins/insta360-x4/native/tests/check_abi.py @@ -0,0 +1,211 @@ +"""Execute OUR native adapter against synthetic SDK symbols, never vendor code. + +Requires the separately acquired, locked SDK headers and a local C++ compiler. +This does not substitute for Linux linking, USB isolation or hardware tests. +""" + +import ctypes +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class Header(ctypes.Structure): + _fields_ = [ + ("sequence", ctypes.c_uint64), + ("generation", ctypes.c_uint64), + ("timestamp", ctypes.c_int64), + ("stream_index", ctypes.c_int32), + ("codec", ctypes.c_int32), + ("bytes", ctypes.c_uint32), + ] + + +def library(folder): + compiler = shutil.which("clang++") or shutil.which("g++") + if not compiler or not (ROOT / "build/sdk/include/camera/camera.h").exists(): + raise unittest.SkipTest( + "Locked SDK headers and a C++ compiler are required for the native simulator" + ) + target = folder / ("adapter.dylib" if sys.platform == "darwin" else "adapter.so") + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-shared", + "-fPIC", + "-pthread", + "-I", + str(ROOT / "build/sdk/include"), + str(ROOT / "native/bridge.cpp"), + str(ROOT / "native/tests/fake_sdk.cpp"), + "-o", + str(target), + ], + check=True, + timeout=60, + capture_output=True, + ) + api = ctypes.CDLL(str(target)) + api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + api.mc_x4_open.restype = ctypes.c_void_p + api.mc_x4_call.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_double, + ] + api.mc_x4_call.restype = ctypes.c_char_p + api.mc_x4_read_video.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(Header), + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_size_t, + ] + api.mc_x4_read_video.restype = ctypes.c_int + api.mc_x4_close.argtypes = [ctypes.c_void_p] + api.mc_x4_close.restype = None + return api + + +def call(camera, action, mode=0, key="", value=0): + api, handle = camera + return json.loads(api.mc_x4_call(handle, action, mode, key.encode(), value)) + + +def test_discovery_never_selects_first_of_multiple_cameras(library): + library.test_reset() + library.test_discovered(2) + assert not library.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + assert library.test_count(0) == 0 and library.test_count(2) == 1 + library.test_discovered(1) + assert not library.mc_x4_open(b"WRONG-SERIAL", b"/unused") + assert library.test_count(0) == 0 and library.test_count(2) == 2 + + +def test_preview_stop_and_close_do_not_stop_recording(camera): + api, handle = camera + assert call(camera, 6)["state"] == "complete" + assert call(camera, 4)["state"] == "complete" + assert call(camera, 5)["state"] == "complete" + assert call(camera, 1)["result"]["recording"] == 1 + assert api.test_count(5) == 0 and api.test_count(7) == 1 + # Fixture closes the SDK handle after assertions; test the cleanup counter + # independently by closing a second handle in the next dedicated test. + + +def test_close_only_releases_sdk_session(library): + library.test_reset() + handle = library.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + library.test_recording(1) + library.mc_x4_close(handle) + assert library.test_count(1) == 1 and library.test_count(2) == 1 + assert library.test_count(5) == 0 + + +def test_lost_start_ack_is_unknown_and_stop_remains_possible(camera): + api, _ = camera + api.test_ack(0, 0) + assert call(camera, 6)["state"] == "unknown" + assert call(camera, 1)["result"]["recording"] == 1 + assert call(camera, 7)["state"] == "complete" + assert api.test_count(4) == 1 and api.test_count(5) == 1 + api.test_ack(1, 0) + assert call(camera, 4)["state"] == "unknown" + assert call(camera, 4)["state"] == "unknown" + assert api.test_count(6) == 1 + assert call(camera, 5)["state"] == "complete" + assert api.test_count(7) == 1 + + +def test_capabilities_reject_unsupported_values_and_recording_mutations(camera): + api, _ = camera + assert call(camera, 3, 7, "video_resolution", 999)["state"] == "error" + assert ( + call(camera, 3, 7, "iso", 6400)["state"] == "error" + ) # current resolution branch excludes it + assert call(camera, 3, 7, "iso", 400)["state"] == "complete" + result = call(camera, 3, 7, "white_balance", 5000) + assert result["result"]["values"]["white_balance"] == 5000 # Kelvin, not legacy enum + assert api.test_count(3) == 2 + api.test_recording(1) + assert call(camera, 3, 7, "video_resolution", 2)["state"] == "error" + assert api.test_count(3) == 2 + api.test_connected(0) + assert call(camera, 3, 7, "white_balance", 0)["state"] == "unknown" + assert api.test_count(3) == 2 + + +def test_frames_copy_vendor_memory_preserve_stream_and_bound_backlog(camera): + api, handle = camera + call(camera, 4) + api.test_packet(1, 24, 91) + info, buffer = Header(), (ctypes.c_uint8 * 24)() + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1 + assert (info.stream_index, info.timestamp, info.bytes) == (1, 12345, 24) + assert bytes(buffer) == bytes([91]) * 24 + generation = info.generation + for i in range(65): + api.test_packet(0, 24, i) + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1 + assert info.generation > generation # decoder must reset after this loss + assert bytes(buffer) == bytes([64]) * 24 + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 0 + + +def test_camera_files_are_bounded_pages(camera): + first = call(camera, 9)["result"] + last = call(camera, 9, value=64)["result"] + assert len(first["items"]) == 32 and first["total"] == 65 + assert len(last["items"]) == 1 and last["offset"] == 64 + assert call(camera, 9, value=66)["state"] == "error" + + +def test_early_camera_stop_notification_wins_over_start_acknowledgement(camera): + api, _ = camera + api.test_ack(2, 1) + assert call(camera, 6)["state"] == "unknown" + assert call(camera, 1)["result"]["recording"] == 0 + assert api.test_count(4) == 1 + + +class TestNativeBridge(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory(prefix="x4-native-synthetic-", dir=ROOT / "build") + cls.addClassCleanup(cls.folder.cleanup) + cls.api = library(Path(cls.folder.name)) + + +def test_case(function): + def execute(self): + self.api.test_reset() + if function.__code__.co_varnames[0] == "library": + function(self.api) + return + handle = self.api.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + self.assertTrue(handle) + try: + function((self.api, handle)) + finally: + self.api.mc_x4_close(handle) + + return execute + + +for test_name, test_function in list(globals().items()): + if test_name.startswith("test_") and test_name != "test_case": + setattr(TestNativeBridge, test_name, test_case(test_function)) + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/insta360-x4/native/tests/fake_sdk.cpp b/plugins/insta360-x4/native/tests/fake_sdk.cpp new file mode 100644 index 0000000..c73218c --- /dev/null +++ b/plugins/insta360-x4/native/tests/fake_sdk.cpp @@ -0,0 +1,129 @@ +// Test double for the pinned PUBLIC HEADERS. No vendor .so is linked/loaded. +// Deliberately models ambiguous acknowledgements and separate preview/recording. +#include +#include +#include +#include + +namespace { +int discovered = 1, opens = 0, closes = 0, freed = 0, writes = 0; +int record_starts = 0, record_stops = 0, previews = 0, preview_stops = 0; +bool recording = false, start_ack = true, preview_ack = true, connected = true; +bool stop_before_ack = false; +int function_mode = 7; +std::shared_ptr delegate; +ins_camera::CaptureStateCallBack capture_callback; +std::shared_ptr capture; +std::shared_ptr exposure; +} + +namespace ins_camera { +class CameraImpl {}; +class ExposureSettingsPrivate { +public: + int iso = 100, ev = 0; + double shutter = 1.0 / 60; + PhotographyOptions_ExposureMode mode = MANUAL; +}; +void SetLogPath(const std::string&) {} +void SetLogLevel(LogLevel) {} +Camera::Camera(const DeviceConnectionInfo&) {} +bool Camera::Open() const { ++opens; return true; } +void Camera::Close() const { ++closes; delegate.reset(); capture_callback = nullptr; } +void Camera::SetServicePort(int) {} +void Camera::SetTimeout(int) {} +bool Camera::IsConnected() { return connected; } +bool Camera::CaptureCurrentStatus() const { return recording; } +bool Camera::GetBatteryStatus(BatteryStatus& out) { out = {ADAPTER, 80, 100}; return connected; } +bool Camera::GetStorageState(StorageStatus& out, CardLocation) { out = {STOR_CS_PASS, 1000, 2000}; return connected; } +CameraFunctionMode Camera::GetCurrentFunctionMode() const { return static_cast(function_mode); } +VideoEncodeType Camera::GetVideoEncodeType() const { return VideoEncodeType::H264; } +void Camera::SetStreamDelegate(std::shared_ptr& value) { delegate = value; } +void Camera::SetCaptureStateNotification(CaptureStateCallBack cb) { capture_callback = cb; } +void Camera::SetCaptureStoppedNotification(CaptureStoppedCallBack) {} +void Camera::SetBatteryLowNotification(BatteryLowCallBack) {} +void Camera::SetStorageFullNotification(StorageFullCallBack) {} +void Camera::SetTemperatureHighNotification(TemperatureHighCallBack) {} +bool Camera::StartRecording() { + ++record_starts; recording = true; + if (stop_before_ack) { recording = false; if (capture_callback) capture_callback(false); } + return start_ack; +} +MediaUrl Camera::StopRecording() { ++record_stops; recording = false; return MediaUrl({"/DCIM/synthetic.insv"}); } +bool Camera::StartLiveStreaming(const LiveStreamParam& param) { + if (param.enable_audio || param.enable_gyro || param.using_lrv || param.video_resolution != RES_1920_960P30) throw std::runtime_error("unexpected preview profile"); + ++previews; return preview_ack; +} +bool Camera::StopLiveStreaming() { ++preview_stops; return true; } +MediaUrl Camera::TakePhoto(RawCaptureType, int) const { return MediaUrl({"/DCIM/synthetic.insp"}); } +std::vector Camera::GetCameraFilesList() const { + std::vector out; + for (int i = 0; i < 65; ++i) out.push_back("/DCIM/synthetic_" + std::to_string(i) + ".insv"); + return out; +} +std::vector DeviceDiscovery::GetAvailableDevices() { + std::vector out; + for (int i = 0; i < discovered; ++i) out.push_back({CameraType::Insta360X4, "SYNTHETIC-X4", "Insta360 X4", "TEST-FW", {ConnectionType::USB, "Insta360 X4", nullptr}}); + return out; +} +void DeviceDiscovery::FreeDeviceDescriptors(std::vector) { ++freed; } +MediaUrl::MediaUrl(const std::vector& origins, const std::vector& proxies): uris_(origins), lrv_uris_(proxies) {} +bool MediaUrl::Empty() const { return uris_.empty(); } +const std::vector& MediaUrl::OriginUrls() const { return uris_; } + +bool Camera::SyncPhotographyOptions(CameraFunctionMode) { return connected; } +std::shared_ptr Camera::GetCaptureSettings(CameraFunctionMode) const { return capture; } +std::shared_ptr Camera::GetExposureSettings(CameraFunctionMode) const { return exposure; } +bool Camera::SetCaptureSettings(CameraFunctionMode, std::shared_ptr value) { ++writes; capture = value; return true; } +bool Camera::SetExposureSettings(CameraFunctionMode, const std::shared_ptr& value) { ++writes; exposure = value; return true; } +bool Camera::SetVideoCaptureParams(RecordParams value, CameraFunctionMode) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_RecordResolution, int(value.resolution)); return true; } +bool Camera::SetPhotoSize(CameraFunctionMode, const PhotoSize& size) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_PhotoResolution, int(size)); return true; } +bool Camera::SetVideoSubMode(SubVideoMode) { ++writes; function_mode = 7; return true; } +bool Camera::SetPhotoSubMode(SubPhotoMode) { ++writes; function_mode = 6; return true; } +std::vector Camera::GetSupportedVideoModes() const { return {FUNCTION_MODE_NORMAL_VIDEO}; } +std::vector Camera::GetSupportedPhotoModes() const { return {FUNCTION_MODE_NORMAL_IMAGE}; } +std::vector Camera::GetSupportedPhotoSizes(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_IMAGE ? std::vector{Size_5952_2976} : std::vector{}; } +std::vector Camera::GetSupportedVideoResolutions(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_VIDEO ? std::vector{RES_3840_1920P30, RES_1920_960P30} : std::vector{}; } +std::string Camera::GetVideoResolutionName(VideoResolution value) const { return value == RES_1920_960P30 ? "1920_960_30" : "3840_1920_30"; } +std::vector Camera::GetSupportedAttrNames(CameraFunctionMode) const { return {"white_balance", "exposure_iso", "exposure_program"}; } +std::vector Camera::GetAttrDependOn(CameraFunctionMode, const std::string& name) const { return name == "exposure_iso" ? std::vector{"record_resolution"} : std::vector{}; } +std::vector Camera::GetSupportedAttrValues(CameraFunctionMode, const std::string& name, const std::string& context) const { + if (name == "white_balance") return {"0", "5000"}; + if (name == "exposure_program") return {"AUTO", "MANUAL"}; + if (name == "exposure_iso") return context.empty() ? std::vector{"100", "6400"} : std::vector{"100", "400"}; + return {}; +} +int Camera::GetAttrValueByName(const std::string&, const std::string& value) { return value == "AUTO" ? 0 : value == "MANUAL" ? 3 : -1; } +void CaptureSettings::ResetSettingTypes() { types_.clear(); } +void CaptureSettings::SetValue(SettingsType type, int32_t value, bool) { int_values_[type] = value; } +int32_t CaptureSettings::GetIntValue(SettingsType type) const { auto v = int_values_.find(type); return v == int_values_.end() ? 0 : v->second; } +ExposureSettings::ExposureSettings(): private_impl_(std::make_shared()) {} +int32_t ExposureSettings::Iso() const { return private_impl_->iso; } +double ExposureSettings::ShutterSpeed() const { return private_impl_->shutter; } +PhotographyOptions_ExposureMode ExposureSettings::ExposureMode() const { return private_impl_->mode; } +int32_t ExposureSettings::EVBias() const { return private_impl_->ev; } +void ExposureSettings::SetIso(int32_t value) { private_impl_->iso = value; } +void ExposureSettings::SetExposureMode(PhotographyOptions_ExposureMode value) { private_impl_->mode = value; } +} + +extern "C" { +void test_reset() { + discovered = 1; opens = closes = freed = writes = record_starts = record_stops = previews = preview_stops = 0; + recording = stop_before_ack = false; connected = start_ack = preview_ack = true; function_mode = 7; + delegate.reset(); capture_callback = nullptr; + capture = std::make_shared(); exposure = std::make_shared(); +} +int test_count(int which) { + switch (which) { case 0: return opens; case 1: return closes; case 2: return freed; case 3: return writes; case 4: return record_starts; case 5: return record_stops; case 6: return previews; case 7: return preview_stops; default: return -1; } +} +void test_discovered(int count) { discovered = count; } +void test_ack(int type, int value) { if (type == 0) start_ack = value; else if (type == 1) preview_ack = value; else stop_before_ack = value; } +void test_connected(int value) { connected = value; } +void test_recording(int value) { recording = value; if (capture_callback) capture_callback(recording); } +void test_packet(int stream_index, int size, int marker) { + if (!delegate) return; + std::vector packet(size, uint8_t(marker)); + delegate->OnVideoData(packet.data(), packet.size(), 12345, 0, stream_index); + std::fill(packet.begin(), packet.end(), 0); // prove bridge owns copied bytes +} +} diff --git a/plugins/insta360-x4/packaging/50-mission-core-insta360.rules b/plugins/insta360-x4/packaging/50-mission-core-insta360.rules new file mode 100644 index 0000000..b081506 --- /dev/null +++ b/plugins/insta360-x4/packaging/50-mission-core-insta360.rules @@ -0,0 +1,5 @@ +polkit.addRule(function(action, subject) { + if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-insta360-x4-prepare.service" && action.lookup("verb") === "start") { + return polkit.Result.YES; + } +}); diff --git a/plugins/insta360-x4/packaging/70-mission-core-insta360.rules b/plugins/insta360-x4/packaging/70-mission-core-insta360.rules new file mode 100644 index 0000000..4e1bdca --- /dev/null +++ b/plugins/insta360-x4/packaging/70-mission-core-insta360.rules @@ -0,0 +1 @@ +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="2e1a", ATTR{idProduct}=="0002", ATTR{product}=="Insta360 X4", GROUP="mission-core-x4-usb", MODE="0660" diff --git a/plugins/insta360-x4/packaging/bootstrap.py b/plugins/insta360-x4/packaging/bootstrap.py new file mode 100644 index 0000000..04f1c8c --- /dev/null +++ b/plugins/insta360-x4/packaging/bootstrap.py @@ -0,0 +1,32 @@ +"""Isolated, root-owned bootstrap; no dependency on system Python packages.""" + +import os +import sys +from pathlib import Path + +CODE = Path(__file__).resolve().parent +sys.path.insert(0, str(CODE)) +from layout import active, trusted # noqa: E402 + + +def main(): + trusted(CODE, True) + runtime = trusted(active(), True) + trusted(runtime / "python", True) + sys.path[:0] = [str(runtime / "python"), str(CODE)] + os.umask(0o007) + if sys.argv[1:] == ["broker"]: + from runtime.broker import main as run + + run() + elif len(sys.argv) == 3 and sys.argv[1] == "worker": + from runtime.worker import main as run + + os.chdir(runtime / "bin") + run(sys.argv[2], runtime) + else: + raise ValueError("Unsupported runtime entry point") + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/build_deb.py b/plugins/insta360-x4/packaging/build_deb.py new file mode 100644 index 0000000..07d82b2 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_deb.py @@ -0,0 +1,230 @@ +"""Build the optional X4 package from pinned SDK, native output and wheel bytes.""" + +import argparse +import hashlib +import io +import json +import os +import sys +import zipfile +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = ROOT.parents[1] +PACKAGING = ROOT / "packaging" +sys.path.insert(0, str(REPOSITORY / "scripts/packaging")) +from debian import package # noqa: E402 +from fetch_sdk import verify # noqa: E402 + +VERSION = "0.1.3-3" +WHEELS = { + "aiohappyeyeballs", + "aiohttp", + "aioice", + "aiortc", + "aiosignal", + "annotated_types", + "attrs", + "av", + "cffi", + "cryptography", + "dnspython", + "frozenlist", + "google_crc32c", + "idna", + "ifaddr", + "multidict", + "propcache", + "pycparser", + "pydantic", + "pydantic_core", + "pyee", + "pylibsrtp", + "pyopenssl", + "typing_extensions", + "typing_inspection", + "yarl", +} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def archive_bytes(files): + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in sorted(files.items()): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + return stream.getvalue() + + +def runtime_payload(): + sdk = ROOT / "build/sdk" + lock = json.loads((PACKAGING / "sdk-lock.json").read_text()) + verify(sdk, lock) + native = ROOT / "build/native" + provenance = json.loads((native / "provenance.json").read_text()) + for name, expected in provenance["source"].items(): + if digest((ROOT / "native" / name).read_bytes()) != expected: + raise ValueError("Native adapter sources changed since compilation") + if provenance["sdk_lock_sha256"] != digest((PACKAGING / "sdk-lock.json").read_bytes()): + raise ValueError("Native adapter SDK input changed") + binary = (native / "libmissioncore_x4.so").read_bytes() + if digest(binary) != provenance["binary"]["sha256"] or provenance["abi"] != 1: + raise ValueError("Native adapter provenance mismatch") + files = {"lib/libmissioncore_x4.so": binary} + for item in lock["files"]: + if item["path"].startswith(("bin/", "lib/")): + files[item["path"]] = (sdk / item["path"]).read_bytes() + wheels = json.loads((PACKAGING / "python-lock.json").read_text())["wheels"] + for item in wheels: + source = REPOSITORY / "apps/node-agent/build/realsense-wheels" / item["name"] + data = source.read_bytes() + if source.is_symlink() or len(data) != item["bytes"] or digest(data) != item["sha256"]: + raise ValueError("Pinned Python wheel mismatch") + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for entry in archive.infolist(): + if entry.is_dir(): + continue + name = entry.filename + path = PurePosixPath(name) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name + or ".data/" in name + or name.endswith(".pth") + or (entry.external_attr >> 16) & 0o170000 == 0o120000 + ): + raise ValueError("Unsupported Python wheel member") + target = "python/" + name + content = archive.read(entry) + if target in files and files[target] != content: + raise ValueError("Python runtime files collide") + files[target] = content + sdk_python = REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk" + for path in sdk_python.rglob("*.py"): + files["python/missioncore_plugin_sdk/" + path.relative_to(sdk_python).as_posix()] = ( + path.read_bytes() + ) + payload = archive_bytes(files) + result = { + "schema": "missioncore.insta360.runtime-bundle/v1", + "version": VERSION, + "platform": "ubuntu-24.04-amd64", + "python": "3.12", + "sdk_lock_sha256": digest((PACKAGING / "sdk-lock.json").read_bytes()), + "python_lock_sha256": digest((PACKAGING / "python-lock.json").read_bytes()), + "native": provenance, + "runtime_source_sha256": { + path.name: digest(path.read_bytes()) for path in sorted((ROOT / "runtime").glob("*.py")) + }, + "payload_sha256": digest(payload), + "files": { + name: {"bytes": len(data), "sha256": digest(data)} + for name, data in sorted(files.items()) + }, + } + result["revision"] = digest(json.dumps(result, sort_keys=True).encode())[:24] + return payload, result + + +def build(output): + payload, bundle = runtime_payload() + files = [ + ("usr/share/mission-core-node/insta360/payload.zip", payload, 0o644), + ( + "usr/share/mission-core-node/insta360/bundle.json", + (json.dumps(bundle, indent=2) + "\n").encode(), + 0o644, + ), + ] + for name in ("sdk-lock.json", "python-lock.json"): + files.append( + ( + "usr/share/doc/mission-core-insta360-x4/" + name, + (PACKAGING / name).read_bytes(), + 0o644, + ) + ) + for name in ("layout.py", "bootstrap.py", "supervisor.py", "prepare.py"): + files.append( + ("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644) + ) + for path in sorted((ROOT / "runtime").glob("*.py")): + files.append( + ("usr/lib/mission-core-node/insta360/runtime/" + path.name, path.read_bytes(), 0o644) + ) + for path in sorted(PACKAGING.glob("*.service")): + files.append(("usr/lib/systemd/system/" + path.name, path.read_bytes(), 0o644)) + files.extend( + [ + ( + "usr/lib/udev/rules.d/70-mission-core-insta360.rules", + (PACKAGING / "70-mission-core-insta360.rules").read_bytes(), + 0o644, + ), + ( + "usr/share/polkit-1/rules.d/50-mission-core-insta360.rules", + (PACKAGING / "50-mission-core-insta360.rules").read_bytes(), + 0o644, + ), + ] + ) + provenance = { + "package": "mission-core-insta360-x4", + "version": VERSION, + "revision": bundle["revision"], + "files": {name: digest(data) for name, data, _ in files}, + "hardware_qualified": False, + "clean_image_qualified": False, + } + files.append( + ( + "usr/share/doc/mission-core-insta360-x4/provenance.json", + (json.dumps(provenance, indent=2) + "\n").encode(), + 0o644, + ) + ) + control = f"""Package: mission-core-insta360-x4 +Version: {VERSION} +Architecture: amd64 +Maintainer: NODE.DC local build +Section: admin +Priority: optional +Depends: mission-core-node (>= 0.8.16), mission-core-node (<< 0.9.0), + systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), adduser, udev, polkitd, + libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g +Description: Optional Insta360 X4 control and operator camera integration + Private USB instances and pinned offline runtime for Ubuntu 24.04 amd64. +""".encode() + controls = [("control", control, 0o644)] + [ + (name, (PACKAGING / name).read_bytes(), 0o755) + for name in ("preinst", "postinst", "prerm", "postrm") + ] + data = package(controls, files) + output.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + return { + "file": str(output), + "bytes": len(data), + "sha256": digest(data), + "revision": bundle["revision"], + "version": VERSION, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + print(json.dumps(build(args.output))) diff --git a/plugins/insta360-x4/packaging/build_native.py b/plugins/insta360-x4/packaging/build_native.py new file mode 100644 index 0000000..ef8199b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_native.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Build the private X4 adapter through a versioned Linux build artifact. + +Input SDK bytes must already satisfy sdk-lock.json. No network, dependency +installation, SDK execution, device access or mutation of system directories. +""" + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import tempfile +from pathlib import Path + +from fetch_sdk import LOCK, ROOT, inspect_elf, verify + + +def build(sdk, destination): + if platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Native compilation requires Linux x86_64 and the declared compiler") + lock = json.loads(LOCK.read_text()) + verify(sdk, lock) + if destination.exists(): + raise ValueError("Do not replace a previously built adapter") + destination.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=".native-", dir=destination.parent)) + env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "SOURCE_DATE_EPOCH": "1788825600"} + try: + target = stage / "libmissioncore_x4.so" + subprocess.run( + [ + "/usr/bin/g++", + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-fPIC", + "-fvisibility=hidden", + "-shared", + "-pthread", + "-ffile-prefix-map=" + str(ROOT) + "=/source/insta360-x4", + "-I", + str(sdk / "include"), + str(ROOT / "native/bridge.cpp"), + "-L", + str(sdk / "lib"), + "-Wl,-z,defs,-z,relro,-z,now", + "-Wl,-rpath,$ORIGIN", + "-lCameraSDK", + "-o", + str(target), + ], + env=env, + check=True, + timeout=120, + ) + elf = inspect_elf(target) + if "libCameraSDK.so" not in elf["needed"]: + raise ValueError("Built adapter does not depend on the admitted SDK") + report = { + "schema": "missioncore.insta360.native-build/v1", + "abi": 1, + "sdk_lock_sha256": hashlib.sha256(LOCK.read_bytes()).hexdigest(), + "source": { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted((ROOT / "native").glob("*")) + if p.is_file() + }, + "compiler": subprocess.check_output( + ["/usr/bin/g++", "--version"], env=env, text=True + ).splitlines()[0], + "binary": { + "name": target.name, + "bytes": target.stat().st_size, + "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), + }, + "elf": elf, + "sdk_executed": False, + "hardware_tested": False, + } + target.chmod(0o644) + (stage / "provenance.json").write_text(json.dumps(report, indent=2) + "\n") + os.rename(stage, destination) + return report + finally: + if stage.exists(): + shutil.rmtree(stage) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk", type=Path, default=ROOT / "build/sdk") + parser.add_argument("--output", type=Path, default=ROOT / "build/native") + args = parser.parse_args() + print(json.dumps(build(args.sdk.resolve(), args.output.resolve()), indent=2)) diff --git a/plugins/insta360-x4/packaging/build_native_entry.py b/plugins/insta360-x4/packaging/build_native_entry.py new file mode 100644 index 0000000..6568d2b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_native_entry.py @@ -0,0 +1,9 @@ +"""Isolated interpreter bootstrap for the fixed, verified build module.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_native import ROOT, build # noqa: E402 + +build(ROOT / "build/sdk", ROOT / "build/native") diff --git a/plugins/insta360-x4/packaging/build_owner_release.py b/plugins/insta360-x4/packaging/build_owner_release.py new file mode 100644 index 0000000..c7417c1 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_owner_release.py @@ -0,0 +1,67 @@ +"""Wrap the qualified .deb and the fixed local installer into one owned artifact.""" + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def build(qualified): + expected = json.loads((qualified / "package-result.json").read_text()) + version = expected["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid qualified version") + package_name = "mission-core-insta360-x4_" + version + "_amd64.deb" + package = (qualified / package_name).read_bytes() + if ( + len(package) != expected["bytes"] + or hashlib.sha256(package).hexdigest() != expected["sha256"] + ): + raise ValueError("Qualified package changed") + files = { + package_name: package, + **{ + name: (ROOT / "packaging" / name).read_bytes() + for name in ("install", "install_release.py") + }, + } + entrypoint = (ROOT / "packaging/owner_release_entry.py").read_bytes() + manifest = { + "schema": "missioncore.insta360.owner-release/v1", + "version": version, + "runtime_revision": expected["revision"], + "qualification_profile": "sdk-status-and-image-verification", + "qualification": "Ubuntu native build, cold imports, static closure, synthetic tests", + "hardware_qualified": False, + "clean_image_qualified": False, + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(raw).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-install-" + ident + ".pyz") + with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive: + for name, data in {"__main__.py": entrypoint, "release.json": raw, **files}.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "release_id": ident, + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--qualified", type=Path, required=True) + print(json.dumps(build(parser.parse_args().qualified))) diff --git a/plugins/insta360-x4/packaging/build_package_artifact.py b/plugins/insta360-x4/packaging/build_package_artifact.py new file mode 100644 index 0000000..f99135b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_package_artifact.py @@ -0,0 +1,84 @@ +"""Snapshot package inputs for compilation/qualification only on the Ubuntu Mini.""" + +import hashlib +import json +import sys +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_deb import VERSION # noqa: E402 +from fetch_sdk import ROOT, verify # noqa: E402 + +REPOSITORY = ROOT.parents[1] + + +def build(): + sdk = json.loads((ROOT / "packaging/sdk-lock.json").read_text()) + verify(ROOT / "build/sdk", sdk) + paths = list((ROOT / "packaging").glob("*.py")) + paths += [ + p + for pattern in ("*.json", "*.service", "*.rules") + for p in (ROOT / "packaging").glob(pattern) + ] + paths += [ROOT / "packaging" / name for name in ("preinst", "postinst", "prerm", "postrm")] + paths += list((ROOT / "runtime").glob("*.py")) + list((ROOT / "tests").glob("*.py")) + paths += [ROOT / "native" / name for name in ("bridge.cpp", "bridge.h")] + paths += [ROOT / "build/native" / name for name in ("libmissioncore_x4.so", "provenance.json")] + paths += [ROOT / "build/sdk" / entry["path"] for entry in sdk["files"]] + wheels = json.loads((ROOT / "packaging/python-lock.json").read_text())["wheels"] + paths += [ + REPOSITORY / "apps/node-agent/build/realsense-wheels" / entry["name"] for entry in wheels + ] + paths += list((REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk").rglob("*.py")) + paths += [REPOSITORY / "scripts/packaging/debian.py"] + paths += [REPOSITORY / "apps/node-agent/packaging/insta360_profile.py"] + paths += [ + REPOSITORY / name + for name in ( + "src/k1link/__init__.py", + "src/k1link/fleet/__init__.py", + "src/k1link/fleet/sensors.py", + "src/k1link/fleet/trust.py", + ) + ] + files = { + path.relative_to(REPOSITORY).as_posix(): path.read_bytes() for path in sorted(set(paths)) + } + entrypoint = (ROOT / "packaging/native_build_entry.py").read_bytes() + manifest = { + "schema": "missioncore.insta360.build-source/v1", + "version": "0.1.0", + "kind": "package", + "package_version": VERSION, + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(raw).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-package-build-" + ident + ".pyz") + with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive: + for name, data in { + "__main__.py": entrypoint, + "source.json": raw, + **{"source/" + name: data for name, data in files.items()}, + }.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "source_id": ident, + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + + +if __name__ == "__main__": + print(json.dumps(build())) diff --git a/plugins/insta360-x4/packaging/build_source_artifact.py b/plugins/insta360-x4/packaging/build_source_artifact.py new file mode 100644 index 0000000..d9ac9c7 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_source_artifact.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Package exact native sources and locked SDK input for the Ubuntu build step.""" + +import hashlib +import json +import zipfile +from pathlib import Path + +from fetch_sdk import LOCK, ROOT, verify + + +def build(): + lock = json.loads(LOCK.read_text()) + sdk = ROOT / "build/sdk" + verify(sdk, lock) + names = [ + "native/bridge.cpp", + "native/bridge.h", + "native/tests/fake_sdk.cpp", + "native/tests/check_abi.py", + "packaging/build_native.py", + "packaging/build_native_entry.py", + "packaging/fetch_sdk.py", + "packaging/sdk-lock.json", + ] + names += ["build/sdk/" + entry["path"] for entry in lock["files"]] + files = {name: (ROOT / name).read_bytes() for name in sorted(names)} + entrypoint = (Path(__file__).with_name("native_build_entry.py")).read_bytes() + manifest = { + "schema": "missioncore.insta360.build-source/v1", + "version": "0.1.0", + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + content = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(content).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-build-" + ident + ".pyz") + entries = {"__main__.py": entrypoint, "source.json": content} + entries.update({"source/" + name: data for name, data in files.items()}) + with ( + output.open("xb") as stream, + zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive, + ): + for name, data in entries.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + "source_id": ident, + } + + +if __name__ == "__main__": + print(json.dumps(build())) diff --git a/plugins/insta360-x4/packaging/check_package_entry.py b/plugins/insta360-x4/packaging/check_package_entry.py new file mode 100644 index 0000000..7d9d002 --- /dev/null +++ b/plugins/insta360-x4/packaging/check_package_entry.py @@ -0,0 +1,84 @@ +"""Cold Python imports and synthetic installer/control tests, no vendor execution.""" + +import hashlib +import importlib +import io +import json +import shutil +import subprocess +import sys +import tarfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "packaging")] +from build_deb import VERSION # noqa: E402 +from fetch_sdk import inspect_elf # noqa: E402 +from prepare import entries # noqa: E402 + +package = ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb") +data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(package)], timeout=30) +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")) +if hashlib.sha256(payload).hexdigest() != bundle["payload_sha256"]: + raise ValueError("Package payload hash mismatch") +stage = ROOT / "build/cold-runtime" +stage.mkdir(mode=0o700) +try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + for name, content in entries(archive, bundle): + path = stage / name + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_bytes(content) + python = stage / "python" + sys.path.insert(0, str(python)) + versions = {} + for name in ("aiohttp", "aiortc", "av", "pydantic", "cryptography", "cffi"): + module = importlib.import_module(name) + if not Path(module.__file__).is_relative_to(python): + raise RuntimeError("An undeclared system Python package was imported") + versions[name] = getattr(module, "__version__", "available") + libraries = [ + path for path in stage.rglob("*") if path.is_file() and path.read_bytes()[:4] == b"\x7fELF" + ] + provided = {path.name for path in libraries} + system = { + "libc.so.6", + "libm.so.6", + "libmvec.so.1", + "libpthread.so.0", + "libdl.so.2", + "librt.so.1", + "libresolv.so.2", + "libgcc_s.so.1", + "libstdc++.so.6", + "ld-linux-x86-64.so.2", + "libz.so.1", + } + closure = {str(path.relative_to(stage)): inspect_elf(path)["needed"] for path in libraries} + missing = { + name for needed in closure.values() for name in needed if name not in provided | system + } + if missing: + raise RuntimeError("Undeclared OS library dependencies: " + ", ".join(sorted(missing))) + (ROOT / "build/runtime-check.json").write_text( + json.dumps( + { + "python_imports": versions, + "elf_dependencies": closure, + "declared_os_libraries": sorted(system), + "sdk_executed": False, + "clean_os_image_tested": False, + }, + indent=2, + ) + + "\n" + ) + suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="check_*.py") + if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful(): + raise RuntimeError("Package qualification tests failed") +finally: + shutil.rmtree(stage) diff --git a/plugins/insta360-x4/packaging/fetch_sdk.py b/plugins/insta360-x4/packaging/fetch_sdk.py new file mode 100644 index 0000000..e9d297b --- /dev/null +++ b/plugins/insta360-x4/packaging/fetch_sdk.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Build-time SDK acquisition. Never installed or executed on an operator Node. + +Pin every byte from a public mirror; do not execute SDK code to inspect it. +The source lock is provenance, not a claim of vendor authenticity or licensing. +""" + +import argparse +import hashlib +import json +import re +import shutil +import struct +import tempfile +import urllib.request +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +LOCK = Path(__file__).with_name("sdk-lock.json") + + +def entries(lock): + if lock["schema"] != "missioncore.insta360.sdk-source/v1": + raise ValueError("Unsupported SDK lock") + if not re.fullmatch(r"[0-9a-f]{40}", lock["commit"]): + raise ValueError("SDK source must be an immutable commit") + seen = set() + for entry in lock["files"]: + path = PurePosixPath(entry["path"]) + if ( + path.is_absolute() + or ".." in path.parts + or "\\" in entry["path"] + or path.as_posix() != entry["path"] + or entry["path"] in seen + or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]) + or not 0 < entry["bytes"] <= 32 * 1024 * 1024 + ): + raise ValueError("Invalid SDK payload entry") + seen.add(entry["path"]) + yield entry + + +def verified_bytes(data, entry): + if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("SDK checksum mismatch: " + entry["path"]) + return data + + +def inspect_elf(path): + """Parse ELF64 sections without loading the library (never ldd/dlopen).""" + data = path.read_bytes() + if ( + len(data) < 64 + or data[:7] != b"\x7fELF\x02\x01\x01" + or struct.unpack_from(" len(data): + raise ValueError("Invalid ELF section table") + sections = [struct.unpack_from("= count: + raise ValueError("Invalid ELF string table") + strings = sections[section[6]] + table = data[strings[4] : strings[4] + strings[5]] + if section[4] + section[5] > len(data) or section[5] % 16: + raise ValueError("Invalid ELF dynamic table") + for at in range(section[4], section[4] + section[5], 16): + tag, value = struct.unpack_from("&1 | /usr/bin/tee -a "$mc_x4_release_dir/install-output.log" +mc_x4_install_result=${PIPESTATUS[0]} +printf '\nКод завершения: %s\nНажмите Enter, чтобы закрыть окно.\n' "$mc_x4_install_result" +read -r mc_x4_close +exit "$mc_x4_install_result" diff --git a/plugins/insta360-x4/packaging/install_release.py b/plugins/insta360-x4/packaging/install_release.py new file mode 100644 index 0000000..c1e2498 --- /dev/null +++ b/plugins/insta360-x4/packaging/install_release.py @@ -0,0 +1,263 @@ +"""Owner-facing, versioned installer; first SDK Open belongs to fixed prepare. + +Invoke through the release's local Ubuntu terminal. The OS password is never +accepted by this script or sent to Mission Core. No arbitrary commands/URLs. +""" + +import hashlib +import http.client +import json +import os +import re +import socket +import subprocess +import sys +import time +import uuid +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path + +STAGING = Path("/var/tmp/mission-core-x4-installs") +SERVICES = ( + "mission-core-node.service", + "mission-core-k1.service", + "mission-core-realsense.service", +) + + +def private(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if path.is_symlink() or not path.is_dir() or info.st_uid != 0 or info.st_mode & 0o077: + raise RuntimeError("Installer staging is not root-owned and private") + + +def request(path, operation=None): + timeout = 55 if operation else 3 + client = http.client.HTTPConnection("driver", timeout=timeout) + client.sock = socket.socket(socket.AF_UNIX) + client.sock.settimeout(timeout) + try: + client.sock.connect(str(path)) + if operation is None: + client.request("GET", "/snapshot") + else: + client.request( + "POST", "/operation", json.dumps(operation), {"Content-Type": "application/json"} + ) + response = client.getresponse() + content = response.read(65537) + if response.status != 200 or len(content) > 65536: + raise RuntimeError("Camera status is unavailable") + return json.loads(content) + finally: + client.close() + + +def main(): + if os.geteuid() or sys.argv[1:]: + raise RuntimeError("Запустите установщик в локальном окне Ubuntu через sudo.") + os.umask(0o077) + source = Path(__file__).resolve().parent + manifest = json.loads((source / "release.json").read_text()) + if manifest["schema"] != "missioncore.insta360.owner-release/v1": + raise ValueError("Unsupported release") + version = manifest["version"] + revision = manifest["runtime_revision"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version) or not re.fullmatch( + r"[0-9a-f]{24}", revision + ): + raise ValueError("Invalid release version") + if manifest["qualification_profile"] != "sdk-status-and-image-verification": + raise ValueError("Unsupported qualification profile") + package = "mission-core-insta360-x4_" + version + "_amd64.deb" + admitted = manifest["files"] + if set(admitted) != {package, "install_release.py", "install"}: + raise ValueError("Unexpected installer contents") + for name, expected in admitted.items(): + data = (source / name).read_bytes() + if len(data) != expected["bytes"] or hashlib.sha256(data).hexdigest() != expected["sha256"]: + raise ValueError("Установщик повреждён. Контрольная сумма не совпала.") + private(STAGING) + identifier = uuid.uuid4().hex + folder = STAGING / identifier + private(folder) + # Snapshot the exact admitted bytes to root-only staging before APT, so + # it never consumes a mutable package from the operator's Downloads path. + data = (source / package).read_bytes() + if hashlib.sha256(data).hexdigest() != admitted[package]["sha256"]: + raise ValueError("Package changed before staging") + (folder / package).write_bytes(data) + report = { + "schema": "missioncore.insta360.install-run/v1", + "session_id": identifier, + "started_at": datetime.now(UTC).isoformat(), + "monotonic_started": time.monotonic(), + "release_sha256": hashlib.sha256((source / "release.json").read_bytes()).hexdigest(), + "package_sha256": admitted[package]["sha256"], + "state": "running", + "scope": "install-profile-SDK-status-and-bounded-image-verification", + "steps": [], + } + env = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", + "DEBIAN_FRONTEND": "noninteractive", + } + + def publish(): + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + + def run(step, command, timeout=180): + report["steps"].append( + {"id": step, "state": "running", "started_at": datetime.now(UTC).isoformat()} + ) + publish() + result = subprocess.run(command, env=env, capture_output=True, timeout=timeout) + (folder / (step + ".stdout")).write_bytes(result.stdout) + (folder / (step + ".stderr")).write_bytes(result.stderr) + report["steps"][-1].update( + state="complete" if result.returncode == 0 else "error", exit_code=result.returncode + ) + publish() + if result.returncode: + raise RuntimeError("Не завершён этап установки: " + step) + return result.stdout + + publish() + try: + # These remain private to the authenticated owner; the terminal wrapper + # saves stdout with umask 077. No camera identifiers enter public logs. + previous = sorted(STAGING.glob("*/report.json"), key=lambda p: p.stat().st_mtime) + for path in previous[-6:]: + if path.parent != folder: + print("MISSION_CORE_X4_PREVIOUS " + path.read_text().replace("\n", ""), flush=True) + baseline = run( + "baseline", + [ + "/usr/bin/systemctl", + "show", + *SERVICES, + "-p", + "Id", + "-p", + "ActiveState", + "-p", + "NRestarts", + ], + ) + report["existing_services"] = baseline.decode().splitlines() + run( + "apt-plan", + ["/usr/bin/apt-get", "--simulate", "--no-remove", "install", str(folder / package)], + ) + run( + "package", + ["/usr/bin/apt-get", "install", "-y", "--no-remove", str(folder / package)], + timeout=None, + ) + # This is the identical installed model job started by Node's local or + # paired remote prepare command. No SDK demo, root SDK or manual grant. + preparation = Path("/var/lib/mission-core-insta360/preparation.json") + prepared = json.loads(preparation.read_text()) if preparation.exists() else {} + active = Path("/var/lib/mission-core-insta360/active.path") + # An upgrade's postinst already executes this same fixed preparation. + # Do not race its asynchronously connecting workers with a second run. + if not ( + prepared.get("state") == "complete" + and prepared.get("revision") == revision + and active.exists() + and active.read_text().strip() == revision + ): + run( + "prepare", + ["/usr/bin/systemctl", "start", "mission-core-node-insta360-x4-prepare.service"], + ) + deadline = time.monotonic() + 50 + while True: + values = [] + for path in Path("/run/mission-core-x4-instances").glob("instax4_*/driver.sock"): + if not re.fullmatch(r"instax4_[0-9a-f]{32}", path.parent.name): + continue + with suppress(OSError, ValueError, RuntimeError, http.client.HTTPException): + values.append(request(path)) + if values and all(item.get("prepared") and item.get("online") for item in values): + break + if time.monotonic() >= deadline: + report["camera_status"] = values + raise RuntimeError( + "Пакет установлен, но подключение X4 не подтверждено. " + "Проверьте питание и USB-режим камеры." + ) + time.sleep(1) + report["camera_status"] = values + if len(values) != 1: + raise RuntimeError("Для этой аппаратной проверки требуется ровно одна X4.") + camera = values[0] + if not re.fullmatch(r"instax4_[0-9a-f]{32}", camera["id"]): + raise RuntimeError("Camera identity is invalid") + now = datetime.now(UTC) + operation = "op_" + uuid.uuid4().hex + command = { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": operation, + "idempotency_key": operation, + "session": {"device_id": camera["id"], "session_id": camera["session_id"]}, + "action_id": "verify", + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": {}, + } + report["image_verification"] = {"request": command, "state": "running"} + publish() + result = request( + Path("/run/mission-core-x4-instances") / camera["id"] / "driver.sock", command + ) + report["image_verification"].update(state=result.get("state"), result=result) + publish() + if ( + result.get("state") != "complete" + or result.get("result", {}).get("verified") is not True + ): + raise RuntimeError("SDK подключён, но проверка изображения не завершилась успешно.") + after = run( + "existing-services-after", + [ + "/usr/bin/systemctl", + "show", + *SERVICES, + "-p", + "Id", + "-p", + "ActiveState", + "-p", + "NRestarts", + ], + ) + report["existing_services_unchanged"] = after == baseline + report["state"] = "complete" + print("Пакет X4 установлен. SDK подключился; получение изображения подтверждено.") + print("WebRTC и команды записи остаются отдельными проверками.") + except Exception as error: + report["state"] = "error" + report["error"] = str(error)[:300] + raise + finally: + report["finished_at"] = datetime.now(UTC).isoformat() + report["duration_seconds"] = time.monotonic() - report["monotonic_started"] + publish() + # Retain bounded private evidence and remove only the installer-owned + # temporary package copy. Runtime/journals remain owned by the .deb. + (folder / package).unlink() + print("MISSION_CORE_X4_RESULT " + json.dumps(report, ensure_ascii=False), flush=True) + print("Отчёт установки:", folder / "report.json") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/plugins/insta360-x4/packaging/layout.py b/plugins/insta360-x4/packaging/layout.py new file mode 100644 index 0000000..3bcff74 --- /dev/null +++ b/plugins/insta360-x4/packaging/layout.py @@ -0,0 +1,60 @@ +"""Fixed root-owned locations shared by installer and runtime bootstrap.""" + +import json +import os +from pathlib import Path + +CODE = Path("/usr/lib/mission-core-node/insta360") +SHARE = Path("/usr/share/mission-core-node/insta360") +STATE = Path("/var/lib/mission-core-insta360") +CONTROL = Path("/run/mission-core-x4-control") + + +def trusted(path, directory=False): + info = path.lstat() + if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted camera installation path") + if path.is_dir() != directory: + raise RuntimeError("Unexpected camera installation entry") + return path + + +def manifest(): + trusted(SHARE, True) + return json.loads(trusted(SHARE / "bundle.json").read_text()) + + +def active(): + trusted(STATE, True) + ident = trusted(STATE / "active.path").read_text().strip() + if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident): + raise RuntimeError("Invalid installed runtime revision") + return trusted(STATE / "runtime", True) / ident + + +def directory(path, mode=0o755): + path.mkdir(mode=mode, exist_ok=True) + trusted(path, True) + + +def write(path, data, mode=0o644): + import tempfile + + if path.exists() or path.is_symlink(): + trusted(path) + fd, temporary = tempfile.mkstemp(prefix=".x4-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + folder = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(folder) + finally: + os.close(folder) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service b/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service new file mode 100644 index 0000000..82d6bc1 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service @@ -0,0 +1,26 @@ +[Unit] +Description=Mission Core X4 USB instance supervisor +After=systemd-udevd.service +ConditionPathExists=/var/lib/mission-core-insta360/active.path +[Service] +Type=simple +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/supervisor.py +RuntimeDirectory=mission-core-x4-control +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +UMask=0022 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/run/systemd/system +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet= +TasksMax=16 +MemoryMax=96M +Restart=no +[Install] +WantedBy=multi-user.target diff --git a/plugins/insta360-x4/packaging/mission-core-insta360.service b/plugins/insta360-x4/packaging/mission-core-insta360.service new file mode 100644 index 0000000..dc43d93 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-insta360.service @@ -0,0 +1,31 @@ +[Unit] +Description=Mission Core Insta360 model broker +ConditionPathExists=/var/lib/mission-core-insta360/active.path +[Service] +Type=simple +User=mission-core-insta360 +Group=mission-core-node +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py broker +RuntimeDirectory=mission-core-insta360 +RuntimeDirectoryMode=0750 +StateDirectory=mission-core-insta360-broker +StateDirectoryMode=0700 +UMask=0007 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +CapabilityBoundingSet= +TasksMax=192 +MemoryMax=1G +CPUQuota=150% +Restart=on-failure +RestartSec=3 +[Install] +WantedBy=multi-user.target diff --git a/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service b/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service new file mode 100644 index 0000000..2ff8684 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service @@ -0,0 +1,8 @@ +[Unit] +Description=Mission Core fixed Insta360 X4 preparation +After=systemd-udevd.service +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py +TimeoutStartSec=180 +UMask=0022 diff --git a/plugins/insta360-x4/packaging/native_build_entry.py b/plugins/insta360-x4/packaging/native_build_entry.py new file mode 100644 index 0000000..cda60dd --- /dev/null +++ b/plugins/insta360-x4/packaging/native_build_entry.py @@ -0,0 +1,203 @@ +"""Entry point embedded in the versioned, self-verifying Ubuntu build artifact. + +No system install, SDK loading, discovery, camera command or root execution. +All writes belong to this private build directory and its immutable archive. +""" + +import hashlib +import io +import json +import os +import platform +import re +import resource +import shutil +import subprocess +import sys +import tarfile +import time +import zipfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath + +BUILD_ROOT = Path("/var/tmp/mission-core-x4-builds") + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def private_directory(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if ( + path.is_symlink() + or not path.is_dir() + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + ): + raise RuntimeError("Build directory is not private and owned") + + +def main(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Use the unprivileged Ubuntu x86_64 build account") + os.umask(0o077) + started, monotonic = datetime.now(UTC).isoformat(), time.monotonic() + artifact = Path(sys.argv[0]).resolve() + with zipfile.ZipFile(artifact) as archive: + raw_manifest = archive.read("source.json") + manifest = json.loads(raw_manifest) + ident = digest(raw_manifest)[:24] + if manifest["schema"] != "missioncore.insta360.build-source/v1": + raise ValueError("Unsupported build artifact") + if digest(archive.read("__main__.py")) != manifest["entrypoint_sha256"]: + raise ValueError("Build entry point differs from the manifest") + private_directory(BUILD_ROOT) + folder = BUILD_ROOT / ident + private_directory(folder) + output = folder / "result.tar.gz" + if len(sys.argv) > 1: + if sys.argv[1:] != ["--clean"]: + raise ValueError("No build commands or paths are accepted") + shutil.rmtree(folder) + print(json.dumps({"cleaned": ident})) + return + if output.exists(): + print( + json.dumps( + {"result": str(output), "sha256": digest(output.read_bytes()), "reused": True} + ) + ) + return + if (folder / "source").exists(): + raise RuntimeError( + "Incomplete prior attempt; preserve evidence and use --clean before retry" + ) + source = folder / "source" + source.mkdir(mode=0o700) + allowed = {"__main__.py", "source.json"} | {"source/" + p for p in manifest["files"]} + if len(archive.namelist()) != len(allowed) or set(archive.namelist()) != allowed: + raise ValueError("Unexpected source archive entries") + for relative, expected in manifest["files"].items(): + path = PurePosixPath(relative) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != relative + or "\\" in relative + ): + raise ValueError("Unsafe build payload path") + entry = archive.getinfo("source/" + relative) + if entry.file_size != expected["bytes"] or entry.file_size > 100 * 1024 * 1024: + raise ValueError("Invalid source size") + data = archive.read(entry) + if digest(data) != expected["sha256"]: + raise ValueError("Source hash mismatch") + target = source / relative + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + target.write_bytes(data) + (folder / "intent.json").write_text( + json.dumps( + { + "schema": "missioncore.insta360.build-run/v1", + "id": ident, + "started_at": started, + "monotonic_started": monotonic, + "artifact_sha256": digest(artifact.read_bytes()), + "source_manifest_sha256": digest(raw_manifest), + "scope": "package-and-synthetic-tests-only" + if manifest.get("kind") == "package" + else "compile-and-synthetic-tests-only", + "state": "running", + }, + indent=2, + ) + + "\n" + ) + # Bound compiler consumption on the shared Mini. This does not alter OS, + # service, user or shell configuration; limits apply to this process tree. + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + resource.setrlimit(resource.RLIMIT_CPU, (180, 180)) + resource.setrlimit(resource.RLIMIT_AS, (1024 * 1024 * 1024, 1024 * 1024 * 1024)) + os.nice(10) + env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"} + report = json.loads((folder / "intent.json").read_text()) + package_build = manifest.get("kind") == "package" + if package_build: + plugin = source / "plugins/insta360-x4" + version = manifest["package_version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid package build version") + package_name = "mission-core-insta360-x4_" + version + "_amd64.deb" + jobs = ( + ( + "package-build", + ["/usr/bin/python3", "-I", str(plugin / "packaging/package_build_entry.py")], + ), + ( + "package-tests", + ["/usr/bin/python3", "-I", str(plugin / "packaging/check_package_entry.py")], + ), + ) + outputs = [ + ( + plugin / "build" / package_name, + package_name, + ), + (plugin / "build/package-result.json", "package-result.json"), + (plugin / "build/runtime-check.json", "runtime-check.json"), + (folder / "package-tests.stderr", "package-tests.txt"), + ] + else: + jobs = ( + ( + "native-build", + ["/usr/bin/python3", "-I", str(source / "packaging/build_native_entry.py")], + ), + ( + "synthetic-abi-tests", + ["/usr/bin/python3", "-I", str(source / "native/tests/check_abi.py"), "-v"], + ), + ) + outputs = [ + (source / "build/native/libmissioncore_x4.so", "libmissioncore_x4.so"), + (source / "build/native/provenance.json", "provenance.json"), + (folder / "synthetic-abi-tests.stderr", "synthetic-abi-tests.txt"), + ] + outputs.append((folder / "report.json", "build-report.json")) + try: + for name, command in jobs: + result = subprocess.run(command, cwd=source, env=env, capture_output=True, timeout=150) + (folder / (name + ".stdout")).write_bytes(result.stdout) + (folder / (name + ".stderr")).write_bytes(result.stderr) + if result.returncode: + raise RuntimeError(name + " failed; inspect the private build report") + report.update( + state="complete", + finished_at=datetime.now(UTC).isoformat(), + duration_seconds=time.monotonic() - monotonic, + ) + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:gz") as archive: + for path, name in outputs: + archive.add(path, arcname=name, recursive=False) + output.write_bytes(data.getvalue()) + print( + json.dumps( + {"result": str(output), "sha256": digest(output.read_bytes()), "state": "complete"} + ) + ) + except Exception: + report.update( + state="error", + finished_at=datetime.now(UTC).isoformat(), + duration_seconds=time.monotonic() - monotonic, + ) + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + raise + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/owner_release_entry.py b/plugins/insta360-x4/packaging/owner_release_entry.py new file mode 100644 index 0000000..d3a2a1a --- /dev/null +++ b/plugins/insta360-x4/packaging/owner_release_entry.py @@ -0,0 +1,149 @@ +"""Stage, inspect or open the fixed Ubuntu installer; never collect passwords.""" + +import hashlib +import json +import os +import platform +import re +import subprocess +import sys +import zipfile +from pathlib import Path + +PROFILES = { + "missioncore.insta360.owner-release/v1": ( + "mission-core-insta360-x4", + "/var/tmp/mission-core-x4-releases", + "Mission Core · Insta360 X4", + ), + "missioncore.node.owner-release/v1": ( + "mission-core-node", + "/var/tmp/mission-core-node-releases", + "Mission Core Node", + ), +} + + +def private(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise RuntimeError("Release directory is not private and owned") + + +def main(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Откройте установщик обычным пользователем на Ubuntu amd64.") + if sys.argv[1:] not in (["--stage"], ["--plan"], ["--launch"]): + raise ValueError("Use --stage, --plan or --launch") + os.umask(0o077) + with zipfile.ZipFile(sys.argv[0]) as archive: + raw = archive.read("release.json") + manifest = json.loads(raw) + if manifest.get("schema") not in PROFILES: + raise ValueError("Invalid release schema") + package_id, root, title = PROFILES[manifest["schema"]] + version = manifest["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid release version") + package = package_id + "_" + version + "_amd64.deb" + if hashlib.sha256(archive.read("__main__.py")).hexdigest() != manifest["entrypoint_sha256"]: + raise ValueError("Release entry point changed") + allowed = { + "release.json", + "__main__.py", + "install", + "install_release.py", + package, + } + if set(archive.namelist()) != allowed or len(archive.namelist()) != len(allowed): + raise ValueError("Invalid release contents") + if set(manifest["files"]) != allowed - {"release.json", "__main__.py"}: + raise ValueError("Invalid release manifest") + identifier = hashlib.sha256(raw).hexdigest()[:24] + private(Path(root)) + folder = Path(root) / identifier + private(folder) + files = {name: archive.read(name) for name in manifest["files"]} + for name, data in files.items(): + expected = manifest["files"][name] + if ( + len(data) != expected["bytes"] + or hashlib.sha256(data).hexdigest() != expected["sha256"] + ): + raise ValueError("Release payload changed") + files["release.json"] = raw + for name, data in files.items(): + path = folder / name + if path.is_symlink(): + raise ValueError("Unexpected release symlink") + if path.exists(): + if path.read_bytes() != data: + raise ValueError("Existing release was modified") + else: + with path.open("xb") as stream: + stream.write(data) + path.chmod(0o700 if name == "install" else 0o600) + print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True) + if sys.argv[1] == "--plan": + result = subprocess.run( + [ + "/usr/bin/apt-get", + "--simulate", + "--no-remove", + "install", + str(folder / package), + ], + capture_output=True, + text=True, + timeout=45, + ) + (folder / "apt-plan.stdout").write_text(result.stdout) + (folder / "apt-plan.stderr").write_text(result.stderr) + print(result.stdout) + if result.returncode: + raise RuntimeError("APT plan failed; inspect the release's private report") + elif sys.argv[1] == "--launch": + environment = dict(os.environ) + result = subprocess.run( + ["/usr/bin/systemctl", "--user", "show-environment"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + # Only graphical session coordinates are admitted. Other user-service + # environment values are neither passed on nor printed in evidence. + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + if separator and key in { + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "XAUTHORITY", + }: + environment[key] = value + with ( + (folder / "launcher.stdout").open("ab") as out, + (folder / "launcher.stderr").open("ab") as err, + ): + process = subprocess.Popen( + [ + "/usr/bin/gnome-terminal", + "--wait", + "--title=" + title + " · " + version, + "--", + str(folder / "install"), + ], + env=environment, + stdout=out, + stderr=err, + start_new_session=True, + ) + (folder / "launch.json").write_text(json.dumps({"pid": process.pid}) + "\n") + print("Окно установщика открывается в локальном сеансе Ubuntu.") + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/package_build_entry.py b/plugins/insta360-x4/packaging/package_build_entry.py new file mode 100644 index 0000000..564c562 --- /dev/null +++ b/plugins/insta360-x4/packaging/package_build_entry.py @@ -0,0 +1,12 @@ +"""Fixed package-build entry point within the versioned Ubuntu source artifact.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_deb import ROOT, VERSION, build # noqa: E402 + +result = build(ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb")) +(ROOT / "build/package-result.json").write_text(json.dumps(result, indent=2) + "\n") +print(json.dumps(result)) diff --git a/plugins/insta360-x4/packaging/postinst b/plugins/insta360-x4/packaging/postinst new file mode 100644 index 0000000..d3cdcf6 --- /dev/null +++ b/plugins/insta360-x4/packaging/postinst @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +if [ "$1" = configure ]; then + if ! getent group mission-core-x4-usb >/dev/null; then + addgroup --system mission-core-x4-usb + fi + if ! getent passwd mission-core-insta360 >/dev/null; then + adduser --system --home /var/lib/mission-core-insta360 --no-create-home --disabled-login --ingroup mission-core-node mission-core-insta360 + fi + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + if [ -f /var/lib/mission-core-insta360/active.path ]; then + /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py + fi + fi +fi diff --git a/plugins/insta360-x4/packaging/postrm b/plugins/insta360-x4/packaging/postrm new file mode 100644 index 0000000..eb9ac6a --- /dev/null +++ b/plugins/insta360-x4/packaging/postrm @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +case "$1" in + remove|purge) + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + udevadm control --reload-rules + fi + ;; +esac +# Device state and operation receipts are retained. No SD files are removed. diff --git a/plugins/insta360-x4/packaging/preinst b/plugins/insta360-x4/packaging/preinst new file mode 100644 index 0000000..779e137 --- /dev/null +++ b/plugins/insta360-x4/packaging/preinst @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +if [ -f /usr/lib/mission-core-node/insta360/prepare.py ]; then + /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce +fi diff --git a/plugins/insta360-x4/packaging/prepare.py b/plugins/insta360-x4/packaging/prepare.py new file mode 100644 index 0000000..d532fd0 --- /dev/null +++ b/plugins/insta360-x4/packaging/prepare.py @@ -0,0 +1,231 @@ +"""Idempotent fixed profile preparation. All Ubuntu prerequisites have an owner.""" + +import fcntl +import grp +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import time +import uuid +import zipfile +from contextlib import contextmanager +from pathlib import Path, PurePosixPath + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from layout import SHARE, STATE, directory, manifest, trusted, write # noqa: E402 +from runtime.http import request # noqa: E402 + +STEPS = [ + ("platform", "Проверка совместимости системы"), + ("payload", "Проверка встроенного драйвера"), + ("runtime", "Развёртывание драйвера"), + ("access", "Настройка доступа к камере"), + ("service", "Запуск службы камеры"), +] +SOCKET = Path("/run/mission-core-insta360/driver.sock") +INSTANCES = Path("/run/mission-core-x4-instances") + + +def run(*args): + subprocess.run(args, check=True, timeout=45, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +def assert_safe(): + if SOCKET.exists(): + if request(SOCKET, "/prepare-safe", timeout=5).get("safe") is not True: + raise RuntimeError("Остановите просмотр и запись X4 перед обновлением драйвера.") + elif list(INSTANCES.glob("instax4_*")): + raise RuntimeError("Состояние X4 неизвестно. Подготовка не изменяла службы камеры.") + + +def entries(archive, bundle): + expected = bundle["files"] + if len(archive.infolist()) != len(expected) or set(archive.namelist()) != set(expected): + raise RuntimeError("Состав встроенного драйвера изменён.") + for name, info in expected.items(): + path = PurePosixPath(name) + entry = archive.getinfo(name) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name + or entry.is_dir() + or entry.file_size != info["bytes"] + or entry.file_size > 100 * 1024 * 1024 + ): + raise RuntimeError("Недопустимый файл драйвера.") + data = archive.read(entry) + if hashlib.sha256(data).hexdigest() != info["sha256"]: + raise RuntimeError("Контрольная сумма драйвера не совпала.") + yield name, data + + +def install_runtime(bundle): + parent = STATE / "runtime" + directory(parent) + ident = bundle["revision"] + if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident): + raise RuntimeError("Некорректная версия драйвера.") + target, stage = parent / ident, parent / (ident + ".partial") + source = trusted(SHARE / "payload.zip") + if hashlib.sha256(source.read_bytes()).hexdigest() != bundle["payload_sha256"]: + raise RuntimeError("Встроенный драйвер повреждён. Переустановите пакет.") + if stage.exists(): + trusted(stage, True) + shutil.rmtree(stage) + if not target.exists(): + directory(stage) + try: + with zipfile.ZipFile(source) as archive: + for name, data in entries(archive, bundle): + path = stage / name + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + path.write_bytes(data) + path.chmod(0o644) + stage.rename(target) + finally: + if stage.exists(): + shutil.rmtree(stage) + trusted(target, True) + for name, info in bundle["files"].items(): + path = target / name + for parent in path.parents: + if parent == target.parent: + break + trusted(parent, True) + data = trusted(path).read_bytes() + if len(data) != info["bytes"] or hashlib.sha256(data).hexdigest() != info["sha256"]: + raise RuntimeError("Установленный драйвер изменён. Нужна переустановка пакета.") + return ident + + +def prepare(): + directory(STATE) + lock = STATE / "prepare.lock" + with lock.open("a") as handle: + trusted(lock) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + with lifecycle_lock(): + return prepare_locked() + + +@contextmanager +def lifecycle_lock(): + directory(STATE) + path = STATE / "lifecycle.lock" + descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o640) + with os.fdopen(descriptor, "r+b") as handle: + trusted(path) + os.fchown(handle.fileno(), 0, grp.getgrnam("mission-core-node").gr_gid) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + + +def prepare_locked(): + bundle = manifest() + report = { + "schema": "missioncore.node.device-preparation/v1", + "model_id": "insta360.x4", + "revision": bundle["revision"], + "run_id": str(uuid.uuid4()), + "started_at": time.time(), + "monotonic_started": time.monotonic(), + "state": "running", + "steps": [{"id": key, "label": label, "state": "pending"} for key, label in STEPS], + } + + def publish(): + report["updated_at"] = time.time() + write(STATE / "preparation.json", (json.dumps(report, ensure_ascii=False) + "\n").encode()) + + publish() + try: + assert_safe() + for step in report["steps"]: + step["state"] = "running" + publish() + if step["id"] == "platform": + release = platform.freedesktop_os_release() + if ( + release.get("ID"), + release.get("VERSION_ID"), + platform.machine(), + sys.version_info[:2], + ) != ("ubuntu", "24.04", "x86_64", (3, 12)): + raise RuntimeError("Этот пакет поддерживает Ubuntu 24.04 amd64.") + elif step["id"] == "payload": + data = trusted(SHARE / "payload.zip").read_bytes() + if hashlib.sha256(data).hexdigest() != bundle["payload_sha256"]: + raise RuntimeError("Встроенный драйвер повреждён.") + elif step["id"] == "runtime": + ident = install_runtime(bundle) + assert_safe() + write(STATE / "active.path", (ident + "\n").encode()) + elif step["id"] == "access": + run("/usr/bin/udevadm", "control", "--reload-rules") + run( + "/usr/bin/udevadm", + "trigger", + "--action=change", + "--subsystem-match=usb", + "--attr-match=idVendor=2e1a", + "--attr-match=idProduct=0002", + "--attr-match=product=Insta360 X4", + ) + run("/usr/bin/udevadm", "settle", "--timeout=10") + elif step["id"] == "service": + maintenance = STATE / "maintenance" + if maintenance.exists(): + trusted(maintenance).unlink() + run("/usr/bin/systemctl", "daemon-reload") + for service in ( + "mission-core-insta360.service", + "mission-core-insta360-supervisor.service", + ): + run("/usr/bin/systemctl", "enable", service) + run("/usr/bin/systemctl", "start", service) + run("/usr/bin/systemctl", "is-active", "--quiet", service) + step["state"] = "complete" + publish() + report["state"] = "complete" + except ( + OSError, + ValueError, + RuntimeError, + subprocess.SubprocessError, + zipfile.BadZipFile, + ) as error: + report["state"] = "error" + message = ( + str(error) + if isinstance(error, RuntimeError) + else "Не удалось подготовить X4. Повторите действие." + ) + report["message"] = message[:300] + for step in report["steps"]: + if step["state"] == "running": + step.update(state="error", message=message[:300]) + elif step["state"] == "pending": + step["state"] = "blocked" + report["duration_seconds"] = time.monotonic() - report["monotonic_started"] + publish() + return report["state"] == "complete" + + +if __name__ == "__main__": + os.umask(0o022) + if os.geteuid() or sys.argv[1:] not in ([], ["--assert-safe"], ["--quiesce"]): + sys.exit(1) + if sys.argv[1:] == ["--quiesce"]: + with lifecycle_lock(): + assert_safe() + write(STATE / "maintenance", b"package-lifecycle\n") + elif sys.argv[1:]: + assert_safe() + else: + sys.exit(0 if prepare() else 1) diff --git a/plugins/insta360-x4/packaging/prerm b/plugins/insta360-x4/packaging/prerm new file mode 100644 index 0000000..9077918 --- /dev/null +++ b/plugins/insta360-x4/packaging/prerm @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +case "$1" in + remove|upgrade|deconfigure) + /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-x4@*.service' + systemctl stop mission-core-insta360.service + fi + ;; +esac diff --git a/plugins/insta360-x4/packaging/python-lock.json b/plugins/insta360-x4/packaging/python-lock.json new file mode 100644 index 0000000..f2cc006 --- /dev/null +++ b/plugins/insta360-x4/packaging/python-lock.json @@ -0,0 +1,137 @@ +{ + "schema": "missioncore.insta360.python-lock/v1", + "python": "3.12", + "platform": "linux-amd64", + "wheels": [ + { + "name": "aiohappyeyeballs-2.7.1-py3-none-any.whl", + "sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", + "bytes": 15038 + }, + { + "name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", + "bytes": 1719929 + }, + { + "name": "aioice-0.10.2-py3-none-any.whl", + "sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", + "bytes": 24875 + }, + { + "name": "aiortc-1.14.0-py3-none-any.whl", + "sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", + "bytes": 93183 + }, + { + "name": "aiosignal-1.4.0-py3-none-any.whl", + "sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", + "bytes": 7490 + }, + { + "name": "annotated_types-0.8.0-py3-none-any.whl", + "sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", + "bytes": 13427 + }, + { + "name": "attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "bytes": 67548 + }, + { + "name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", + "sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", + "bytes": 41174337 + }, + { + "name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", + "bytes": 221822 + }, + { + "name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", + "bytes": 4712478 + }, + { + "name": "dnspython-2.8.0-py3-none-any.whl", + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "bytes": 331094 + }, + { + "name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", + "bytes": 242411 + }, + { + "name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", + "bytes": 33364 + }, + { + "name": "idna-3.19-py3-none-any.whl", + "sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", + "bytes": 68550 + }, + { + "name": "ifaddr-0.2.0-py3-none-any.whl", + "sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", + "bytes": 12314 + }, + { + "name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", + "bytes": 256322 + }, + { + "name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", + "bytes": 61639 + }, + { + "name": "pycparser-3.0-py3-none-any.whl", + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "bytes": 48172 + }, + { + "name": "pydantic-2.11.7-py3-none-any.whl", + "sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", + "bytes": 444782 + }, + { + "name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", + "bytes": 2002028 + }, + { + "name": "pyee-14.0.0-py3-none-any.whl", + "sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", + "bytes": 15553 + }, + { + "name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", + "bytes": 2434534 + }, + { + "name": "pyopenssl-26.4.0-py3-none-any.whl", + "sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", + "bytes": 56026 + }, + { + "name": "typing_extensions-4.16.0-py3-none-any.whl", + "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "bytes": 45571 + }, + { + "name": "typing_inspection-0.4.4-py3-none-any.whl", + "sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", + "bytes": 14750 + }, + { + "name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", + "bytes": 109835 + } + ] +} diff --git a/plugins/insta360-x4/packaging/sdk-lock.json b/plugins/insta360-x4/packaging/sdk-lock.json new file mode 100644 index 0000000..c5974b0 --- /dev/null +++ b/plugins/insta360-x4/packaging/sdk-lock.json @@ -0,0 +1,114 @@ +{ + "schema": "missioncore.insta360.sdk-source/v1", + "sdk_version": "2.1.8", + "repository": "https://github.com/pdxmusic/insta360sdk", + "commit": "3db9641ba612c639db10591d1402231662a1eb5d", + "directory": "CameraSDK-2.1.8-20260828_171805-linux-x86_64", + "source_kind": "public-third-party-mirror", + "vendor_authenticity": "not-independently-confirmed", + "redistribution_terms": "not-present-in-inspected-sdk-files", + "files": [ + { + "path": "bin/jsons/camera_conf_Insta360_ONE_X2.json", + "bytes": 10160, + "sha256": "058a31c1cc15539b4466452caa6f653c8ad9255fd4a7681d2877fe87463c7260", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_One2.json", + "bytes": 8952, + "sha256": "76b0c9b9c331237ce69129c91629345ff91c5658e7c06cf3842cbf72556dab43", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneR.json", + "bytes": 12177, + "sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneRS_283.json", + "bytes": 12297, + "sha256": "7260649cb20caf3f42ed604220f4c0209db41a4ecbf7cd82350d38cbc2c1fd47", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneRS_577.json", + "bytes": 12177, + "sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X3.json", + "bytes": 14391, + "sha256": "cf756d9155e3d4829e66f34e81d71731913366a4f0aa3677323c9663f112039c", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X4.json", + "bytes": 145665, + "sha256": "b88b647847096dcb012f21255e3dfeaff15dc6f701f257c89c484337ecdb2104", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X4_Air.json", + "bytes": 115749, + "sha256": "da0f7048c3125e1e1e11b20e641f04db4b65e9a23b51558a9ea85f99cff4097b", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X5.json", + "bytes": 152752, + "sha256": "1a7a016439fca88928ad86844e7b4902b3153d9f2553719eb4a81aed98d314c7", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X6.json", + "bytes": 237283, + "sha256": "9325937d5c4a60555f4c556c2e0f69ac0b89012232e4a6022f50c29bbd308aa0", + "lfs": false + }, + { + "path": "include/camera/camera.h", + "bytes": 19842, + "sha256": "1e39bb9923d9ad87173bcb566936b7a84858d63f585a6b7d00046af4225498cf", + "lfs": false + }, + { + "path": "include/camera/device_discovery.h", + "bytes": 1080, + "sha256": "55a03fa3597b2f913b112a4860ac7311f0620d7d01b1a75c6150779178d8b97c", + "lfs": false + }, + { + "path": "include/camera/ins_types.h", + "bytes": 5960, + "sha256": "df13cb17a4972aec2cb05aa01383eb1c0d58d47d9379b9fc53940da581a98eac", + "lfs": false + }, + { + "path": "include/camera/photography_settings.h", + "bytes": 20377, + "sha256": "f19047dde6a7903b9de8099685a92200caba3b279e9308f7563990e3db0b19b5", + "lfs": false + }, + { + "path": "include/stream/stream_delegate.h", + "bytes": 1425, + "sha256": "115f757427a1c8542bcc912aeb10550615a8c538f7c37d0813fa9565d524b465", + "lfs": false + }, + { + "path": "include/stream/stream_types.h", + "bytes": 436, + "sha256": "2791062f6e45603b47f9b26390506c2b49965efcafa7f5748cb71e89a49baaf9", + "lfs": false + }, + { + "path": "lib/libCameraSDK.so", + "bytes": 17031504, + "sha256": "6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373", + "lfs": true + } + ] +} diff --git a/plugins/insta360-x4/packaging/supervisor.py b/plugins/insta360-x4/packaging/supervisor.py new file mode 100644 index 0000000..a6947dc --- /dev/null +++ b/plugins/insta360-x4/packaging/supervisor.py @@ -0,0 +1,157 @@ +"""Root-only USB-to-unit reconciler. No client commands, SDK loading or network.""" + +import json +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from layout import CONTROL, active, directory, trusted, write # noqa: E402 +from runtime.identity import read_binding # noqa: E402 + +UNITS = Path("/run/systemd/system") +PREFIX = "mission-core-x4@" +PATTERN = re.compile(r"instax4_[0-9a-f]{32}") + + +def systemctl(*args): + subprocess.run( + ["/usr/bin/systemctl", *args], + check=True, + timeout=20, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def unit(binding): + runtime = trusted(active(), True) + # Every interpolated value is a verified OS binding or root-owned revision. + return f"""[Unit] +Description=Mission Core isolated Insta360 X4 +After=mission-core-insta360-supervisor.service +[Service] +Type=exec +DynamicUser=yes +User=mcx4-{binding.device_id[-20:]} +Group=mission-core-node +SupplementaryGroups=mission-core-x4-usb +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py worker {binding.port} +WorkingDirectory={runtime}/bin +StateDirectory=mission-core-x4/{binding.device_id} +StateDirectoryMode=0700 +RuntimeDirectory=mission-core-x4-instances/{binding.device_id} +RuntimeDirectoryMode=0750 +UMask=0007 +PrivateDevices=yes +BindPaths={binding.device_path} +DevicePolicy=closed +DeviceAllow={binding.device_path} rw +PrivateNetwork=yes +NoNewPrivileges=yes +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +LockPersonality=yes +SystemCallFilter=~@mount +ReadOnlyPaths={CONTROL} +TasksMax=64 +MemoryMax=384M +CPUQuota=150% +LimitNOFILE=256 +LimitCORE=0 +TimeoutStopSec=8 +KillMode=control-group +Restart=no +StandardOutput=null +StandardError=null +""".encode() + + +def bindings(): + found, duplicates = {}, set() + for path in Path("/sys/bus/usb/devices").iterdir(): + try: + value = read_binding(path.name) + except (OSError, ValueError): + continue + if value.device_id in found: + duplicates.add(value.device_id) + found[value.device_id] = value + return {ident: value for ident, value in found.items() if ident not in duplicates} + + +def main(): + if os.geteuid() or len(sys.argv) != 1: + raise RuntimeError("Use the fixed installed supervisor unit") + directory(CONTROL) + # Captured by root; a worker need not gain ptrace access to PID 1 merely + # to prove it uses a different network namespace. + write(CONTROL / "host-net-inode", str(Path("/proc/self/ns/net").stat().st_ino).encode()) + stopping = False + + def stop(*_): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + known = {} + startup = True + record = CONTROL / "bindings.json" + if record.exists(): + previous = json.loads(trusted(record).read_text()) + for ident, info in previous.items(): + if not PATTERN.fullmatch(ident): + raise RuntimeError("Invalid previous camera binding") + known[ident] = info + while not stopping: + observed = bindings() + current = { + ident: { + "port": item.port, + "bus": item.bus, + "address": item.address, + "revision": active().name, + } + for ident, item in observed.items() + } + changed = False + for ident in list(known): + if current.get(ident) != known[ident]: + systemctl("stop", PREFIX + ident + ".service") + path = UNITS / (PREFIX + ident + ".service") + if path.exists(): + trusted(path).unlink() + del known[ident] + changed = True + additions = [ident for ident in current if ident not in known] + for ident in additions: + item = observed[ident] + if read_binding(item.port) != item: + continue + write(UNITS / (PREFIX + ident + ".service"), unit(item)) + known[ident] = current[ident] + changed = True + if changed: + write(record, json.dumps(known, sort_keys=True).encode()) + systemctl("daemon-reload") + for ident in list(current) if startup else additions: + if ident in known: + systemctl("start", PREFIX + ident + ".service") + startup = False + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/runtime/__init__.py b/plugins/insta360-x4/runtime/__init__.py new file mode 100644 index 0000000..aba7980 --- /dev/null +++ b/plugins/insta360-x4/runtime/__init__.py @@ -0,0 +1 @@ +"""Mission Core's private Insta360 X4 worker implementation.""" diff --git a/plugins/insta360-x4/runtime/broker.py b/plugins/insta360-x4/runtime/broker.py new file mode 100644 index 0000000..52a5165 --- /dev/null +++ b/plugins/insta360-x4/runtime/broker.py @@ -0,0 +1,270 @@ +"""Unprivileged model broker; public authority stays in Node/Core.""" + +import pwd +import re +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from .errors import failure +from .http import Server, request +from .operations import UNKNOWN, Operations + +ROOT = Path("/run/mission-core-insta360") +INSTANCES = Path("/run/mission-core-x4-instances") +IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}") +STATE = Path("/var/lib/mission-core-insta360-broker") + + +def worker_socket(identifier): + if not isinstance(identifier, str) or not IDENTIFIER.fullmatch(identifier): + raise ValueError("Invalid camera identity") + return INSTANCES / identifier / "driver.sock" + + +class Broker: + def __init__(self): + self.instance = "x4broker_" + uuid.uuid4().hex + self.media_lock = threading.Lock() + self.media = None + self.media_operations = {} + self.capture_locks = {} + + 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()) + + def capture_operation(self, identifier, value): + path = worker_socket(identifier) + lock = self.capture_lock(identifier) + with self.media_lock: + if self.media is None: + from .media import Engine + + self.media = Engine() + # Only one camera's START/STOP is serialized here; other devices keep + # independent locks and SDK operation journals. + with lock: + current = request(path, "/snapshot", timeout=2) + session = current["session_id"] + if current.get("id") != identifier or value.get("session") != { + "device_id": identifier, + "session_id": session, + }: + raise ValueError("Camera session changed") + key = (identifier, session) + starting = value["action_id"] == "preview.start" + starting_idle = starting and current.get("status", {}).get("preview") != 1 + if starting: + if not current.get("online"): + raise RuntimeError("Camera state is unavailable") + self.media.capture(key, path, True) + try: + result = request(path, "/operation", value, timeout=55) + except (OSError, RuntimeError, ValueError): + if starting_idle: + self.media.capture(key, path, False) + 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) + return result + + def verify_operation(self, identifier, value): + path = worker_socket(identifier) + # Serialize with preview transitions, preserving one receipt namespace + # whether verification uses the live decoder or an idle camera check. + with self.capture_lock(identifier): + current = request(path, "/snapshot", timeout=2) + if current.get("id") != identifier: + raise ValueError("Camera identity changed") + operation = Operations( + identifier, + current["session_id"], + STATE / identifier / "verification", + VerificationCalls(self, path, value), + ) + return operation.execute(value) + + def media_operation(self, identifier, value): + path = worker_socket(identifier) + current = request(path, "/snapshot", timeout=2) + if current.get("id") != identifier: + raise ValueError("Camera identity changed") + session = current["session_id"] + with self.media_lock: + if self.media is None: + from .media import Engine + + self.media = Engine() + previous = self.media_operations.get(identifier) + if previous is None or previous.session_id != session: + previous = Operations( + identifier, + session, + STATE / identifier, + MediaCalls(self.media, (identifier, session), path), + ) + self.media_operations[identifier] = previous + return previous.execute(value) + + def snapshots(self): + paths = sorted(INSTANCES.glob("instax4_*/driver.sock")) + if len(paths) > 500: + raise RuntimeError("Camera inventory exceeds the protocol limit") + + def read(path): + if not IDENTIFIER.fullmatch(path.parent.name): + return None + try: + value = request(path, "/snapshot", timeout=0.75) + return value if value.get("id") == path.parent.name else None + except (OSError, RuntimeError, ValueError): + return None + + pool = ThreadPoolExecutor(max_workers=8) + pending = [pool.submit(read, path) for path in paths] + values = [] + try: + for future in as_completed(pending, timeout=2): + value = future.result() + if value is not None: + values.append(value) + except TimeoutError: + pass + finally: + pool.shutdown(wait=False, cancel_futures=True) + return sorted(values, key=lambda value: value["id"]) + + def item(self, value, node): + status = value["status"] + preview = status.get("preview") + return { + "id": value["id"], + "name": "Insta360 X4", + "model": "Insta360 X4", + "kind": "insta360.x4", + "initializable": True, + "configured": False, + "prepared": value["prepared"], + "verified": value["verified"], + "online": value["online"], + "preparation_safe": value["preparation_safe"], + "usb": "USB", + "firmware": status.get("firmware"), + "layers": [], + "camera_status": status, + "snapshot": { + "revision": value["revision"], + "observed_at": value["observed_at"], + "context": { + "session_id": value["session_id"], + "opened_at": value["opened_at"], + "device": { + "device_id": value["id"], + "stability": "stable", + "basis": "hardware-identifier", + "model": { + "plugin_id": "missioncore.insta360", + "plugin_version": "0.1.3", + "model_id": "insta360.x4", + }, + }, + "execution": { + "node_id": node, + "agent_instance_id": self.instance, + "platform": "linux", + }, + }, + "acquisition": "streaming" + if preview == 1 + else "idle" + if preview == 0 + else "failed", + "connectivity": "connected" if value["online"] else "offline", + "enrollment": "enrolled" if value["prepared"] else "empty", + "message": value["message"], + }, + } + + def dispatch(self, method, route, value, headers): + if method == "GET" and route == "/prepare-safe": + snapshots = self.snapshots() + expected = list(INSTANCES.glob("instax4_*")) + return { + "safe": len(snapshots) == len(expected) + and all(item["preparation_safe"] for item in snapshots) + } + node = headers.get("X-Node-Id", "") + 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()]} + if method == "POST" and route == "/operation": + identifier = value["session"]["device_id"] + 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"): + return self.media_operation(identifier, value) + if value.get("action_id") == "verify": + return self.verify_operation(identifier, value) + return request(worker_socket(identifier), "/operation", value, timeout=55) + raise ValueError("Unsupported model route") + + +class MediaCalls: + def __init__(self, engine, key, path): + self.engine, self.key, self.path = engine, key, path + + def call(self, action, params): + if action == "offer": + current = request(self.path, "/snapshot", timeout=2) + if ( + current.get("session_id") != self.key[1] + or not current.get("online") + or current.get("status", {}).get("preview") != 1 + ): + raise RuntimeError("Camera preview is not active") + return self.engine.call(self.key, self.path, action, params) + + +class VerificationCalls: + def __init__(self, broker, path, command): + self.broker, self.path, self.command = broker, path, command + + def call(self, action, params): + current = request(self.path, "/snapshot", timeout=2) + session = self.command["session"] + if ( + current.get("id") != session["device_id"] + or current.get("session_id") != session["session_id"] + or not current.get("online") + ): + return dict(UNKNOWN) + status = current.get("status", {}) + if status.get("recording") != 0: + return failure("verification_recording_active") + if status.get("preview") == 1: + engine = self.broker.media + if engine is None: + return failure("preview_restart_required") + return engine.verify((session["device_id"], session["session_id"])) + if status.get("preview") == 0: + # Preserve worker ownership of the bounded temporary idle preview. + return request(self.path, "/operation", self.command, timeout=55) + return dict(UNKNOWN) + + +def main(): + path = ROOT / "driver.sock" + if path.exists(): + path.unlink() + allowed = {0, pwd.getpwnam("mission-core-node").pw_uid} + with Server(path, Broker().dispatch, allowed) as server: + path.chmod(0o660) + server.serve_forever(poll_interval=0.5) diff --git a/plugins/insta360-x4/runtime/errors.py b/plugins/insta360-x4/runtime/errors.py new file mode 100644 index 0000000..6776924 --- /dev/null +++ b/plugins/insta360-x4/runtime/errors.py @@ -0,0 +1,12 @@ +"""Public messages are allowlisted; vendor error text never crosses the boundary.""" + +MESSAGES = { + "unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.", + "verification_recording_active": "Для проверки изображения остановите запись на камере.", + "preview_no_decodable_image": "Камера не передала декодируемое изображение.", + "preview_restart_required": "Не поступают свежие кадры. Остановите и снова начните просмотр.", +} + + +def failure(code): + return {"state": "error", "error": code} diff --git a/plugins/insta360-x4/runtime/frames.py b/plugins/insta360-x4/runtime/frames.py new file mode 100644 index 0000000..4b6b85b --- /dev/null +++ b/plugins/insta360-x4/runtime/frames.py @@ -0,0 +1,191 @@ +"""Bounded per-camera encoded fanout. Readers never consume each other's data.""" + +import json +import re +import struct +import threading +import time +from collections import deque + +MAX_PACKET = 4 * 1024 * 1024 +MAX_BYTES = 8 * 1024 * 1024 +MAX_ENTRIES = 64 +MAX_WIRE = MAX_PACKET + 1028 +MAX_PARAMETERS = 65536 +START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01") + + +class Parameters: + """Retain only codec setup, so a late viewer can decode the next keyframe. + + Parameter sets describe this preview generation, never a previous session. + Media history and camera commands are not retained or replayed. + """ + + def __init__(self): + self.streams = {} + + def packet(self, header, data): + codec = header["codec"] + required = (7, 8) if codec == 0 else (32, 33, 34) + keyframes = (5,) if codec == 0 else (16, 17, 18, 19, 20, 21) + saved = self.streams.setdefault((header["stream_index"], codec), {}) + markers = START_CODE.finditer(data) + marker = next(markers, None) + present, keyframe = set(), False + while marker is not None: + following = next(markers, None) + if marker.end() >= len(data): + break + kind = data[marker.end()] & 31 if codec == 0 else (data[marker.end()] >> 1) & 63 + keyframe |= kind in keyframes + if kind in required: + end = following.start() if following is not None else len(data) + value = data[marker.start() : end] + if len(value) > MAX_PARAMETERS: + saved.clear() + return data + saved[kind] = value + present.add(kind) + marker = following + if keyframe and all(kind in saved for kind in required): + prefix = b"".join(saved[kind] for kind in required if kind not in present) + if len(prefix) + len(data) <= MAX_PACKET: + return prefix + data + return data + + +def encode(value): + if value is None: + return b"" + header, data = value + raw = json.dumps(header, allow_nan=False, separators=(",", ":")).encode() + if len(raw) > 1024 or not 0 < len(data) <= MAX_PACKET: + raise ValueError("Invalid video envelope") + return struct.pack("!I", len(raw)) + raw + data + + +def decode(raw): + if not raw: + return None + if len(raw) < 5 or len(raw) > MAX_WIRE: + raise ValueError("Invalid video envelope") + size = struct.unpack("!I", raw[:4])[0] + if not 0 < size <= 1024 or len(raw) <= 4 + size: + raise ValueError("Invalid video envelope") + header, data = json.loads(raw[4 : 4 + size]), raw[4 + size :] + if ( + not isinstance(header, dict) + or any( + type(header.get(key)) is not int or header[key] < 0 + for key in ("cursor", "generation", "stream_index", "codec") + ) + or header["stream_index"] not in (0, 1) + or header["codec"] not in (0, 1) + or type(header.get("gap")) is not bool + or not 0 < len(data) <= MAX_PACKET + ): + raise ValueError("Invalid video metadata") + return header, data + + +class Feed: + def __init__(self, camera): + self.camera = camera + self.lock = threading.Condition() + self.source_lock = threading.Lock() + self.queue = deque() + self.bytes = self.cursor = self.generation = 0 + self.source_generation = None + self.parameters = Parameters() + self.closed = threading.Event() + self.thread = threading.Thread(target=self.pump, daemon=True) + + def clear(self): + with self.lock: + self.queue.clear() + self.bytes = 0 + self.parameters = Parameters() + self.generation += 1 + self.lock.notify_all() + + def append(self, value): + source, data = value + if not 0 < len(data) <= MAX_PACKET: + self.clear() + return + with self.lock: + if self.source_generation != source["generation"]: + self.queue.clear() + self.bytes = 0 + self.source_generation = source["generation"] + self.generation += 1 + self.parameters = Parameters() + data = self.parameters.packet(source, data) + while self.queue and ( + self.bytes + len(data) > MAX_BYTES or len(self.queue) >= MAX_ENTRIES + ): + _, removed, _ = self.queue.popleft() + self.bytes -= len(removed) + self.cursor += 1 + header = dict(source, cursor=self.cursor, generation=self.generation) + self.queue.append((header, data, time.monotonic())) + self.bytes += len(data) + self.lock.notify_all() + + def read(self, cursor, wait=0.1): + if type(cursor) is not int or not 0 <= cursor < 2**64: + raise ValueError("Invalid video cursor") + deadline = time.monotonic() + min(max(wait, 0), 0.2) + with self.lock: + while True: + for header, data, observed in self.queue: + if header["cursor"] > cursor and time.monotonic() - observed < 2: + return dict( + header, gap=bool(cursor and header["cursor"] != cursor + 1) + ), data + remaining = deadline - time.monotonic() + if remaining <= 0 or self.closed.is_set(): + return None + self.lock.wait(remaining) + + def reader(self): + with self.lock: + cursor = self.cursor + + def read(): + nonlocal cursor + value = self.read(cursor, wait=0) + if value is not None: + cursor = value[0]["cursor"] + return value + + return read + + def change(self, action): + # No packet read from the previous preview may be appended after a + # START/STOP boundary and mistaken for evidence of the new preview. + with self.source_lock: + self.clear() + try: + return action() + finally: + self.clear() + + def pump(self): + while not self.closed.is_set(): + try: + with self.source_lock: + value = self.camera.video() + if value is not None: + self.append(value) + continue + except (OSError, RuntimeError, ValueError): + self.clear() + self.closed.wait(0.01) + + def close(self): + self.closed.set() + self.clear() + if self.thread.is_alive(): + self.thread.join(timeout=1) diff --git a/plugins/insta360-x4/runtime/http.py b/plugins/insta360-x4/runtime/http.py new file mode 100644 index 0000000..e34cb30 --- /dev/null +++ b/plugins/insta360-x4/runtime/http.py @@ -0,0 +1,145 @@ +"""Bounded private HTTP over Unix sockets, never a public device endpoint.""" + +import http.client +import json +import socket +import socketserver +import struct +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler + +LIMIT = 65536 +INVENTORY_LIMIT = 2 * 1024 * 1024 + + +@dataclass(frozen=True) +class Binary: + data: bytes + + +def request(path, route, value=None, timeout=25, binary=False): + client = http.client.HTTPConnection("driver", timeout=timeout) + client.sock = socket.socket(socket.AF_UNIX) + client.sock.settimeout(timeout) + try: + client.sock.connect(str(path)) + body = None if value is None else json.dumps(value, allow_nan=False).encode() + client.request( + "GET" if value is None else "POST", route, body, {"Content-Type": "application/json"} + ) + reply = client.getresponse() + limit = ( + 4 * 1024 * 1024 + 1028 + if binary + else INVENTORY_LIMIT + if route == "/inventory" + else LIMIT + ) + raw = reply.read(limit + 1) + if len(raw) > limit or reply.status != 200: + raise RuntimeError("Camera service request failed") + if binary: + if reply.getheader("Content-Type") != "application/octet-stream": + raise RuntimeError("Invalid video response") + return raw + return json.loads(raw) + except http.client.HTTPException as error: + raise RuntimeError("Camera service response is incomplete") from error + finally: + client.close() + + +class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + block_on_close = False + + def __init__(self, path, dispatch, allowed_uids): + self.dispatch = dispatch + self.allowed_uids = frozenset(allowed_uids) + self.slots = threading.BoundedSemaphore(12) + super().__init__(str(path), Handler) + + def verify_request(self, request, address): + _, uid, _ = struct.unpack( + "3i", request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12) + ) + return uid in self.allowed_uids + + def process_request(self, request, address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, address): + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + +class Handler(BaseHTTPRequestHandler): + def setup(self): + self.request.settimeout(10) + super().setup() + + def log_message(self, *_): + pass + + def do_GET(self): + self.handle_request() + + def do_POST(self): + self.handle_request() + + def handle_request(self): + try: + if self.headers.get("Transfer-Encoding"): + raise ValueError("Chunked commands are not supported") + value = None + if self.command == "POST": + length = int(self.headers.get("Content-Length", "0")) + if ( + not 0 < length <= LIMIT + or self.headers.get("Content-Type") != "application/json" + ): + raise ValueError("Invalid command size") + raw = self.rfile.read(length) + if len(raw) != length: + raise ValueError("Truncated command") + value = json.loads(raw) + result = self.server.dispatch(self.command, self.path, value, self.headers) + status = 200 + except (ValueError, KeyError, TypeError): + status, result = 400, {"error": "Некорректная команда камеры."} + except (RuntimeError, OSError, TimeoutError): + status, result = 409, {"error": "Камера не ответила. Проверьте подключение."} + binary = isinstance(result, Binary) + data = ( + result.data + if binary + else json.dumps(result, ensure_ascii=False, allow_nan=False).encode() + ) + limit = ( + 4 * 1024 * 1024 + 1028 + if binary + else INVENTORY_LIMIT + if self.path == "/inventory" + else LIMIT + ) + if len(data) > limit: + binary = False + status, data = 500, b'{"error":"Camera response exceeds its limit"}' + self.send_response(status) + self.send_header( + "Content-Type", "application/octet-stream" if binary else "application/json" + ) + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) diff --git a/plugins/insta360-x4/runtime/identity.py b/plugins/insta360-x4/runtime/identity.py new file mode 100644 index 0000000..3987478 --- /dev/null +++ b/plugins/insta360-x4/runtime/identity.py @@ -0,0 +1,85 @@ +"""Read-only USB admission, before vendor enumeration or library loading.""" + +import hashlib +import os +import re +import stat +from dataclasses import dataclass +from pathlib import Path + + +def device_id(serial): + return "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32] + + +@dataclass(frozen=True) +class Binding: + device_id: str + port: str + bus: int + address: int + serial: str + + @property + def device_path(self): + return f"/dev/bus/usb/{self.bus:03d}/{self.address:03d}" + + +def read_binding(port, root=Path("/sys/bus/usb/devices")): + if not isinstance(port, str) or not re.fullmatch( + r"[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*", port + ): + raise ValueError("Invalid camera transport binding") + path = root / port + + def read(name): + return (path / name).read_text().strip() + + if (read("idVendor"), read("idProduct"), read("product")) != ("2e1a", "0002", "Insta360 X4"): + raise ValueError("USB device is not the admitted X4 model") + serial = read("serial") + if not serial or len(serial) > 256 or any(ord(c) < 32 for c in serial): + raise ValueError("Camera identity is unavailable") + bus, address = int(read("busnum")), int(read("devnum")) + if not 1 <= bus <= 999 or not 1 <= address <= 127: + raise ValueError("Invalid USB bus address") + return Binding(device_id(serial), port, bus, address, serial) + + +def verify_isolation(binding): + """No override switch. An ordinary host process cannot load the SDK here. + + The root-owned instance service must have PrivateDevices+one BindPaths, + DevicePolicy=closed, NoNewPrivileges, no capabilities, and PrivateNetwork. + Service declarations additionally enforce cgroup access to this USB node. + """ + if os.uname().sysname != "Linux" or os.geteuid() == 0: + raise RuntimeError("X4 requires an unprivileged isolated Linux worker") + current = read_binding(binding.port) + if current != binding: + raise RuntimeError("Camera changed before SDK initialization") + status = dict( + line.split(":", 1) + for line in Path("/proc/self/status").read_text().splitlines() + if ":" in line + ) + if int(status["CapEff"].strip(), 16) or status["NoNewPrivs"].strip() != "1": + raise RuntimeError("Camera process has unexpected privileges") + proof = Path("/run/mission-core-x4-control/host-net-inode") + for path in (proof.parent, proof): + info = path.lstat() + if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted host namespace evidence") + host_network = int(proof.read_text()) + if host_network <= 0 or Path("/proc/self/ns/net").stat().st_ino == host_network: + raise RuntimeError("Camera process has access to the host network") + devices = list(Path("/dev/bus/usb").glob("*/*")) + if devices != [Path(binding.device_path)]: + raise RuntimeError("Camera process can see unrelated USB devices") + info = devices[0].lstat() + expected_minor = (binding.bus - 1) * 128 + binding.address - 1 + if not stat.S_ISCHR(info.st_mode) or (os.major(info.st_rdev), os.minor(info.st_rdev)) != ( + 189, + expected_minor, + ): + raise RuntimeError("Camera device node does not match the transport") diff --git a/plugins/insta360-x4/runtime/lifecycle.py b/plugins/insta360-x4/runtime/lifecycle.py new file mode 100644 index 0000000..6e0d6a7 --- /dev/null +++ b/plugins/insta360-x4/runtime/lifecycle.py @@ -0,0 +1,24 @@ +"""OS lock shared by every SDK effect and exclusive profile activation.""" + +import fcntl +from contextlib import contextmanager +from pathlib import Path + +ROOT = Path("/var/lib/mission-core-insta360") + + +@contextmanager +def acquisition(wait=False): + path = ROOT / "lifecycle.lock" + for item in (ROOT, path): + info = item.lstat() + if item.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted camera lifecycle lock") + with path.open("rb") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_SH | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + raise RuntimeError("Подготовка драйвера ещё выполняется.") from None + if (ROOT / "maintenance").exists(): + raise RuntimeError("Драйвер X4 обновляется. Повторите после подготовки.") + yield diff --git a/plugins/insta360-x4/runtime/media.py b/plugins/insta360-x4/runtime/media.py new file mode 100644 index 0000000..220f6df --- /dev/null +++ b/plugins/insta360-x4/runtime/media.py @@ -0,0 +1,394 @@ +"""Private WebRTC operator view outside the vendor SDK network namespace. + +Opening/closing a peer never sends a camera command. Each source has its own +worker socket, session, decoder and bounded latest-frame slot. No stitching. +""" + +import asyncio +import ipaddress +import json +import logging +import threading +import time +import uuid +from concurrent.futures import TimeoutError as FutureTimeout +from fractions import Fraction + +import aioice.ice +import av +from aiortc import ( + RTCConfiguration, + RTCPeerConnection, + RTCRtpSender, + RTCSessionDescription, + VideoStreamTrack, +) +from aiortc.mediastreams import MediaStreamError + +from .errors import failure +from .frames import decode +from .http import request + +NETWORKS = tuple( + ipaddress.ip_network(value) + for value in ( + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + ) +) + + +def private(value): + try: + address = ipaddress.ip_address(value) + return address.version == 4 and any(address in network for network in NETWORKS) + except ValueError: + return False + + +_host_addresses = aioice.ice.get_host_addresses +aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [ + value for value in _host_addresses(use_ipv4=True, use_ipv6=False) if private(value) +] + + +def admit_sdp(sdp): + if not isinstance(sdp, str) or not 0 < len(sdp) <= 32768: + raise ValueError("Invalid camera offer") + for line in sdp.splitlines(): + if line.startswith("a=candidate:"): + fields = line.split() + if len(fields) < 8 or not (private(fields[4]) or fields[4].endswith(".local")): + raise ValueError("Camera preview requires a private network") + + +class Source: + def __init__(self, path, session): + self.path, self.session = path, session + self.lock = threading.Lock() + self.frame = None + self.sequence = 0 + self.observed = 0.0 + self.started = time.monotonic() + self.closed = threading.Event() + self.thread = threading.Thread(target=self.pump, daemon=True) + + def current(self): + with self.lock: + return self.sequence, self.frame, self.observed + + def pump(self): + cursor, key, decoder, resized_at = 0, None, None, 0.0 + try: + while not self.closed.is_set(): + raw = request( + self.path, + "/video", + {"session_id": self.session, "cursor": cursor}, + timeout=2, + binary=True, + ) + value = decode(raw) + if value is None: + continue + header, data = value + cursor = header["cursor"] + if header["stream_index"] != 0: + continue + current = (header["generation"], header["codec"]) + if decoder is None or key != current or header["gap"]: + decoder = av.CodecContext.create( + "h264" if header["codec"] == 0 else "hevc", "r" + ) + decoder.thread_count = 1 + key = current + try: + for packet in decoder.parse(data): + for frame in decoder.decode(packet): + if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160): + raise RuntimeError("Camera frame exceeds the admitted view profile") + now = time.monotonic() + if now - resized_at < 1 / 15: + continue + width = min(1280, frame.width) // 2 * 2 + height = max(2, round(frame.height * width / frame.width) // 2 * 2) + output = frame.reformat(width=width, height=height, format="yuv420p") + output.pts = round((now - self.started) * 90000) + output.time_base = Fraction(1, 90000) + with self.lock: + self.frame, self.observed = output, now + self.sequence += 1 + resized_at = now + except av.error.FFmpegError: + decoder = None + except (OSError, ValueError, RuntimeError) as error: + logging.getLogger(__name__).warning( + "X4 preview source stopped (%s)", type(error).__name__ + ) + finally: + self.closed.set() + with self.lock: + self.frame = None + + def close(self): + self.closed.set() + + +class Track(VideoStreamTrack): + def __init__(self, source): + super().__init__() + self.source, self.sequence, self.next_at = source, 0, 0.0 + + async def recv(self): + while self.readyState == "live" and not self.source.closed.is_set(): + await asyncio.sleep(max(0, self.next_at - time.monotonic())) + sequence, frame, observed = self.source.current() + if frame is not None and sequence > self.sequence and time.monotonic() - observed < 2: + self.sequence = sequence + self.next_at = time.monotonic() + 1 / 15 + # Distinct AV frames per encoder: force-keyframe decisions in + # one browser must not mutate another browser's frame object. + copy = av.VideoFrame(frame.width, frame.height, "yuv420p") + for target, source in zip(copy.planes, frame.planes, strict=True): + if target.line_size == source.line_size: + target.update(source) + else: + raw = bytes(source) + padded = bytearray(target.buffer_size) + for row in range(target.height): + padded[ + row * target.line_size : row * target.line_size + target.width + ] = raw[row * source.line_size : row * source.line_size + target.width] + target.update(padded) + copy.pts, copy.time_base = frame.pts, frame.time_base + return copy + await asyncio.sleep(0.01) + raise MediaStreamError + + +class Peers: + def __init__(self, source_factory=Source): + self.items, self.sources = {}, {} + self.primed = {} + self.source_factory = source_factory + + def source(self, key, path): + source = self.sources.get(key) + if source is not None and source.closed.is_set(): + raise RuntimeError("Camera video session ended") + if source is None: + if len(self.sources) >= 4: + raise ValueError("Остановите лишний просмотр камеры.") + source = self.source_factory(path, key[1]) + self.sources[key] = source + source.thread.start() + return source + + async def prime(self, key, path): + # Start reading before the explicit camera START. Some previews expose + # an independently decodable beginning only once per capture session. + source = self.source(key, path) + if key in self.primed: + return {"ok": True} + + async def watch(): + started, active, fresh = time.monotonic(), False, time.monotonic() + while key in self.primed: + try: + status = await asyncio.to_thread(request, path, "/snapshot", timeout=2) + preview = status.get("status", {}).get("preview") + ended = status.get("session_id") != key[1] or source.closed.is_set() + if status.get("online"): + fresh = time.monotonic() + ended |= active and preview == 0 + active |= preview == 1 + ended |= active and time.monotonic() - fresh > 15 + except (OSError, RuntimeError, ValueError): + ended = True + if ended or (not active and time.monotonic() - started > 45): + await self.release(key) + return + await asyncio.sleep(1) + + self.primed[key] = asyncio.create_task(watch()) + return {"ok": True} + + async def release(self, key): + task = self.primed.pop(key, None) + if task is not None and task is not asyncio.current_task(): + task.cancel() + for identifier, entry in tuple(self.items.items()): + if entry["key"] == key: + await self.close(key, identifier) + source = self.sources.pop(key, None) + if source is not None: + source.close() + await asyncio.to_thread(source.thread.join, 3) + return {"ok": True} + + async def verify(self, key, timeout=8): + # An active preview may have emitted its only keyframe minutes ago. + # Verify two NEW decoded frames from its existing source, never open a + # competing decoder or accept a retained frame as fresh evidence. + source = self.sources.get(key) + if source is None or source.closed.is_set(): + return failure("preview_restart_required") + initial = source.current()[0] + started = time.monotonic() + while time.monotonic() - started < timeout: + if self.sources.get(key) is not source or source.closed.is_set(): + break + sequence, frame, observed = source.current() + if frame is not None and sequence >= initial + 2 and time.monotonic() - observed < 2: + return { + "state": "complete", + "result": { + "verified": True, + "streams": [ + { + "stream_index": 0, + "width": frame.width, + "height": frame.height, + "frames": sequence - initial, + } + ], + "duration_ms": round((time.monotonic() - started) * 1000), + }, + } + await asyncio.sleep(0.02) + return failure("preview_restart_required") + + async def offer(self, key, path, params): + admit_sdp(params["sdp"]) + # Inventory remains independent; these limits bound active media only. + if len(self.items) >= 4 or sum(item["key"] == key for item in self.items.values()) >= 2: + raise ValueError("Закройте лишний просмотр камеры.") + source = self.source(key, path) + pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) + identifier = "peer_" + uuid.uuid4().hex + entry = {"key": key, "pc": pc, "source": source, "seen": time.monotonic(), "tasks": set()} + self.items[identifier] = entry + + async def telemetry(channel): + while identifier in self.items: + sequence, frame, observed = source.current() + if channel.readyState == "open" and channel.bufferedAmount < 65536: + channel.send( + json.dumps( + { + "frame_sequence": sequence, + "frame_age_ms": round((time.monotonic() - observed) * 1000) + if frame + else None, + "fresh": frame is not None and time.monotonic() - observed < 2, + } + ) + ) + await asyncio.sleep(0.5) + + def task(coroutine): + value = asyncio.create_task(coroutine) + entry["tasks"].add(value) + value.add_done_callback(entry["tasks"].discard) + + @pc.on("datachannel") + def datachannel(channel): + if channel.label != "sensor": + channel.close() + return + + @channel.on("message") + def message(value): + if value == "keepalive": + entry["seen"] = time.monotonic() + + task(telemetry(channel)) + + @pc.on("connectionstatechange") + async def changed(): + if pc.connectionState in ("failed", "closed"): + await self.close(key, identifier) + + async def watchdog(): + while identifier in self.items: + if source.closed.is_set() or time.monotonic() - entry["seen"] > 30: + await self.close(key, identifier) + return + await asyncio.sleep(1) + + try: + sender = pc.addTrack(Track(source)) + transceiver = next(item for item in pc.getTransceivers() if item.sender == sender) + transceiver.setCodecPreferences( + [ + codec + for codec in RTCRtpSender.getCapabilities("video").codecs + if codec.mimeType.lower() == "video/h264" + ] + ) + await pc.setRemoteDescription(RTCSessionDescription(sdp=params["sdp"], type="offer")) + await pc.setLocalDescription(await pc.createAnswer()) + task(watchdog()) + return {"peer_id": identifier, "sdp": pc.localDescription.sdp, "type": "answer"} + except BaseException: + await self.close(key, identifier) + raise + + async def close(self, key, identifier): + entry = self.items.get(identifier) + if entry is None: + return {"ok": True} + if entry["key"] != key: + raise ValueError("Preview belongs to a different camera session") + self.items.pop(identifier) + current = asyncio.current_task() + for task in tuple(entry["tasks"]): + if task is not current: + task.cancel() + await entry["pc"].close() + if key not in self.primed and not any(item["key"] == key for item in self.items.values()): + source = self.sources.pop(key) + source.close() + await asyncio.to_thread(source.thread.join, 3) + return {"ok": True} + + +class Engine: + def __init__(self): + self.loop = asyncio.new_event_loop() + self.peers = Peers() + self.thread = threading.Thread(target=self.loop.run_forever, daemon=True) + self.thread.start() + + def capture(self, key, path, start): + work = self.peers.prime(key, path) if start else self.peers.release(key) + future = asyncio.run_coroutine_threadsafe(work, self.loop) + try: + return future.result(timeout=10) + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera decoder setup timed out") from error + + def call(self, key, path, action, params): + work = ( + self.peers.offer(key, path, params) + if action == "offer" + else self.peers.close(key, params["peer_id"]) + ) + future = asyncio.run_coroutine_threadsafe(work, self.loop) + try: + return {"state": "complete", "result": future.result(timeout=20)} + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera preview timed out") from error + + def verify(self, key): + future = asyncio.run_coroutine_threadsafe(self.peers.verify(key), self.loop) + try: + return future.result(timeout=10) + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera image verification timed out") from error diff --git a/plugins/insta360-x4/runtime/native.py b/plugins/insta360-x4/runtime/native.py new file mode 100644 index 0000000..fadaa49 --- /dev/null +++ b/plugins/insta360-x4/runtime/native.py @@ -0,0 +1,179 @@ +"""Typed private C ABI. Imported safely; vendor code loads only after admission.""" + +import ctypes +import json +import math +import re +import threading + +from .identity import verify_isolation + +ACTIONS = { + "details": 1, + "settings.read": 2, + "settings.apply": 3, + "preview.start": 4, + "preview.stop": 5, + "record.start": 6, + "record.stop": 7, + "photo.capture": 8, + "files.list": 9, +} +MAX_PACKET = 4 * 1024 * 1024 + + +class VideoHeader(ctypes.Structure): + _fields_ = [ + ("sequence", ctypes.c_uint64), + ("generation", ctypes.c_uint64), + ("timestamp", ctypes.c_int64), + ("stream_index", ctypes.c_int32), + ("codec", ctypes.c_int32), + ("bytes", ctypes.c_uint32), + ] + + +def parameters(action, value): + """Validate before any SDK call; parameters never select a file or library.""" + if action == "offer": + if ( + not isinstance(value, dict) + or set(value) != {"sdp", "layer"} + or value["layer"] != "preview" + or not isinstance(value["sdp"], str) + or not 0 < len(value["sdp"]) <= 32768 + ): + raise ValueError("Invalid camera preview offer") + return 0, "", 0.0 + if action == "close-peer": + if ( + not isinstance(value, dict) + or set(value) != {"peer_id"} + or not isinstance(value["peer_id"], str) + or not re.fullmatch(r"peer_[0-9a-f]{32}", value["peer_id"]) + ): + raise ValueError("Invalid camera preview identity") + return 0, "", 0.0 + if action == "verify": + if not isinstance(value, dict) or value: + raise ValueError("Image verification uses the installed fixed profile") + return 0, "", 0.0 + if action not in ACTIONS or not isinstance(value, dict): + raise ValueError("Unsupported camera command") + if action in ("settings.read", "settings.apply"): + required = {"mode"} if action == "settings.read" else {"mode", "key", "value"} + if ( + set(value) != required + or type(value["mode"]) is not int + or not 0 <= value["mode"] <= 255 + ): + raise ValueError("Invalid camera mode") + if action == "settings.read": + return value["mode"], "", 0.0 + if value["key"] not in ( + "function_mode", + "video_resolution", + "photo_size", + "white_balance", + "iso", + "exposure_mode", + ): + raise ValueError("Unsupported camera setting") + number = value["value"] + if ( + type(number) not in (int, float) + or not math.isfinite(number) + or not 0 <= number <= 65535 + ): + raise ValueError("Invalid camera setting") + return value["mode"], value["key"], float(number) + if action == "files.list": + if ( + set(value) - {"offset"} + or type(value.get("offset", 0)) is not int + or not 0 <= value.get("offset", 0) <= 100000 + ): + raise ValueError("Invalid camera file page") + return 0, "", float(value.get("offset", 0)) + if value: + raise ValueError("Unexpected camera parameters") + return 0, "", 0.0 + + +class NativeCamera: + def __init__(self, binding, library_path, log_directory): + # No ctypes.CDLL anywhere above this check: loading a .so can execute + # constructors even before the first explicit SDK function call. + verify_isolation(binding) + self.lock = threading.RLock() + self.video_lock = threading.Lock() + self.api = ctypes.CDLL(str(library_path)) + self.api.mc_x4_abi.restype = ctypes.c_int + if self.api.mc_x4_abi() != 1: + raise RuntimeError("Incompatible camera adapter") + self.api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + self.api.mc_x4_open.restype = ctypes.c_void_p + self.api.mc_x4_call.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_double, + ] + self.api.mc_x4_call.restype = ctypes.c_char_p + self.api.mc_x4_read_video.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(VideoHeader), + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_size_t, + ] + self.api.mc_x4_read_video.restype = ctypes.c_int + self.api.mc_x4_close.argtypes = [ctypes.c_void_p] + self.api.mc_x4_close.restype = None + self.handle = self.api.mc_x4_open(binding.serial.encode(), str(log_directory).encode()) + if not self.handle: + raise RuntimeError("SDK could not open the selected X4") + self.buffer = (ctypes.c_uint8 * MAX_PACKET)() + + def call(self, action, values): + mode, key, number = parameters(action, values) + with self.lock: + if not self.handle: + raise RuntimeError("Camera session is closed") + raw = self.api.mc_x4_call(self.handle, ACTIONS[action], mode, key.encode(), number) + if not raw or len(raw) > 65536: + raise RuntimeError("Camera returned an invalid response") + result = json.loads(raw) + if not isinstance(result, dict) or result.get("state") not in ( + "complete", + "error", + "unknown", + ): + raise RuntimeError("Camera returned an invalid response") + return result + + def video(self): + # SDK control calls may block for seconds. The callback queue has its + # own native mutex and can be drained without taking the command lock. + with self.video_lock: + if not self.handle: + return None + header = VideoHeader() + state = self.api.mc_x4_read_video( + self.handle, ctypes.byref(header), self.buffer, MAX_PACKET + ) + if state < 0 or header.bytes > MAX_PACKET: + raise RuntimeError("Invalid video packet") + if state == 0: + return None + if header.stream_index not in (0, 1) or header.codec not in (0, 1) or not header.bytes: + raise RuntimeError("Unsupported video packet") + return {name: getattr(header, name) for name, _ in VideoHeader._fields_}, bytes( + self.buffer[: header.bytes] + ) + + def close(self): + with self.lock, self.video_lock: + if self.handle: + self.api.mc_x4_close(self.handle) + self.handle = None diff --git a/plugins/insta360-x4/runtime/operations.py b/plugins/insta360-x4/runtime/operations.py new file mode 100644 index 0000000..36693ab --- /dev/null +++ b/plugins/insta360-x4/runtime/operations.py @@ -0,0 +1,105 @@ +"""Per-camera durable effects. A retry retrieves a receipt, never replays START.""" + +import hashlib +import json +import os +import re +import tempfile +import threading +from datetime import UTC, datetime +from pathlib import Path + +from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest + +from .errors import MESSAGES +from .native import parameters + +UNKNOWN = { + "state": "unknown", + "error": "Результат команды не подтверждён. Обновите состояние камеры.", +} +INVALID = {"state": "error", "error": "Параметр или действие недоступны в текущем режиме камеры."} + + +def atomic(path, value): + fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=path.parent) + try: + with os.fdopen(fd, "w") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump(value, stream, ensure_ascii=False, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class Operations: + def __init__(self, device_id, session_id, root, camera): + self.device_id, self.session_id = device_id, session_id + self.root, self.camera = Path(root), camera + self.lock = threading.Lock() + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + + def execute(self, value): + command = OperationRequest.model_validate(value) + if not re.fullmatch(r"op_[0-9a-f]{32}", command.operation_id): + raise ValueError("Invalid operation identity") + if command.session.device_id != self.device_id: + raise ValueError("Command belongs to another camera") + digest = hashlib.sha256( + json.dumps(value, sort_keys=True, allow_nan=False).encode() + ).hexdigest() + path = self.root / (command.operation_id + ".json") + with self.lock: + if path.exists(): + previous = json.loads(path.read_text()) + if previous["digest"] != digest: + raise ValueError("Operation identity is already in use") + return previous["result"] + if command.session.session_id != self.session_id: + raise ValueError("Camera session changed") + if command.deadline_at <= datetime.now(UTC): + raise ValueError("Command deadline expired") + params = dict(command.parameters) + parameters(command.action_id, params) + receipt = {"digest": digest, "result": dict(UNKNOWN)} + # This fsync must complete before the SDK receives the command. + # A crash between dispatch and acknowledgement remains UNKNOWN. + atomic(path, receipt) + try: + result = self.camera.call(command.action_id, params) + if result["state"] == "error": + error = result.get("error") + if not isinstance(error, str): + error = "" + result = { + "state": "error", + "error": error + if error in MESSAGES.values() + else MESSAGES.get(error, "Не удалось выполнить команду камеры."), + } + elif result["state"] == "unknown": + result = dict(UNKNOWN) + # settings.apply must verify the readback, not merely accept + # the SDK's SetXXX acknowledgement or its old cached value. + if command.action_id == "settings.apply" and result["state"] == "complete": + observed = result.get("result", {}) + actual = ( + observed.get("mode") + if params["key"] == "function_mode" + else observed.get("values", {}).get(params["key"]) + ) + if actual != params["value"]: + result = dict(UNKNOWN) + receipt["result"] = result + except (RuntimeError, ValueError, OSError, KeyError, TypeError): + receipt["result"] = dict(UNKNOWN) + atomic(path, receipt) + return receipt["result"] diff --git a/plugins/insta360-x4/runtime/verification.py b/plugins/insta360-x4/runtime/verification.py new file mode 100644 index 0000000..4f3a1b6 --- /dev/null +++ b/plugins/insta360-x4/runtime/verification.py @@ -0,0 +1,114 @@ +"""Bounded image verification; packets alone never count as a working camera.""" + +import time + +from .errors import failure +from .operations import UNKNOWN + + +class Control: + def __init__(self, camera, feed=None): + self.camera = camera + self.feed = feed + self.verified = False + + def call(self, action, values): + if action == "verify": + return self.verify() + if self.feed and action in ("preview.start", "preview.stop"): + # The native adapter treats an already reached state as a no-op. + # Preserve the same feed generation: clearing it would discard the + # only initial keyframe even though the camera sends no new START. + current = self.camera.call("details", {}) + target = 1 if action == "preview.start" else 0 + if ( + current.get("state") == "complete" + and current.get("result", {}).get("connected") is True + and current["result"].get("preview") == target + ): + return self.camera.call(action, values) + return self.feed.change(lambda: self.camera.call(action, values)) + return self.camera.call(action, values) + + def verify(self, timeout=8): + import av + + self.verified = False + result = self.camera.call("details", {}) + status = result.get("result", {}) + if ( + result.get("state") != "complete" + or status.get("connected") is not True + or status.get("recording") != 0 + or status.get("preview") not in (0, 1) + ): + return failure("verification_recording_active") + owned = status["preview"] == 0 + decoders, observed = {}, {} + outcome = failure("preview_no_decodable_image") + start = time.monotonic() + read_video = self.feed.reader() if self.feed else self.camera.video + try: + if owned and self.call("preview.start", {}).get("state") != "complete": + return dict(UNKNOWN) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = read_video() + if value is None: + time.sleep(0.01) + continue + header, data = value + index, codec, generation = ( + header["stream_index"], + header["codec"], + header["generation"], + ) + name = "h264" if codec == 0 else "hevc" if codec == 1 else None + if index not in (0, 1) or name is None: + return dict(UNKNOWN) + key = (index, codec, generation) + if header.get("gap") or index not in decoders or decoders[index][0] != key: + decoder = av.CodecContext.create(name, "r") + decoder.thread_count = 1 + decoders[index] = (key, decoder) + observed.pop(index, None) + decoder = decoders[index][1] + try: + for packet in decoder.parse(data): + for frame in decoder.decode(packet): + if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160): + return dict(UNKNOWN) + current = observed.setdefault( + index, + { + "stream_index": index, + "codec": name, + "width": frame.width, + "height": frame.height, + "frames": 0, + }, + ) + current["frames"] += 1 + except av.error.FFmpegError: + decoders.pop(index, None) + observed.pop(index, None) + continue + if any(item["frames"] >= 2 for item in observed.values()): + outcome = { + "state": "complete", + "result": { + "verified": True, + "streams": list(observed.values()), + "duration_ms": round((time.monotonic() - start) * 1000), + }, + } + break + finally: + if owned: + try: + if self.call("preview.stop", {}).get("state") != "complete": + outcome = dict(UNKNOWN) + except (RuntimeError, ValueError, OSError): + outcome = dict(UNKNOWN) + self.verified = outcome["state"] == "complete" + return outcome diff --git a/plugins/insta360-x4/runtime/worker.py b/plugins/insta360-x4/runtime/worker.py new file mode 100644 index 0000000..b69da4a --- /dev/null +++ b/plugins/insta360-x4/runtime/worker.py @@ -0,0 +1,154 @@ +"""One SDK session per OS-isolated USB instance. No public network listener.""" + +import os +import pwd +import threading +import time +import uuid +from datetime import UTC, datetime +from pathlib import Path + +from .frames import Feed, encode +from .http import Binary, Server +from .identity import read_binding +from .lifecycle import acquisition +from .native import NativeCamera +from .operations import Operations +from .verification import Control + + +class Worker: + def __init__(self, binding, runtime, state): + self.binding = binding + self.session = "x4_" + uuid.uuid4().hex + self.opened_at = datetime.now(UTC).isoformat() + self.status_lock = threading.Lock() + self.status = {} + self.observed = 0.0 + self.observed_at = self.opened_at + self.revision = 0 + self.snapshot_signature = None + self.failure = None + self.ready = False + self.done = threading.Event() + self.camera = None + self.control = None + self.feed = None + self.operations = None + self.runtime, self.state = runtime, state + + def connect(self): + # Bounded startup even if a vendor call never returns. No restart loop: + # a new attempt requires explicit preparation or physical reconnection. + timer = threading.Timer(40, lambda: os._exit(70)) + timer.daemon = True + timer.start() + try: + logs = self.state / "sdk-logs" + logs.mkdir(mode=0o700, exist_ok=True) + # The prepare process starts services before releasing its lock. + # Wait for that bounded transaction without starting the SDK early. + with acquisition(wait=True): + self.camera = NativeCamera( + self.binding, self.runtime / "lib/libmissioncore_x4.so", logs + ) + self.feed = Feed(self.camera) + self.feed.thread.start() + self.control = Control(self.camera, self.feed) + self.operations = Operations( + self.binding.device_id, self.session, self.state / "operations", self.control + ) + self.refresh() + self.ready = True + except (RuntimeError, OSError, ValueError): + self.failure = "Не удалось открыть X4. Проверьте питание и режим USB на камере." + finally: + timer.cancel() + + def refresh(self): + if read_binding(self.binding.port) != self.binding: + raise RuntimeError("Camera transport changed") + response = self.camera.call("details", {}) + if response["state"] != "complete": + raise RuntimeError("Camera status is unconfirmed") + with self.status_lock: + self.status = response["result"] + self.observed = time.monotonic() + self.observed_at = datetime.now(UTC).isoformat() + self.revision += 1 + + def snapshot(self): + with self.status_lock: + state = dict(self.status) + fresh = time.monotonic() - self.observed < 12 + connected = self.ready and fresh and state.get("connected") is True + verified = bool(self.control and self.control.verified) + signature = (self.ready, connected, fresh, verified, self.failure) + if signature != self.snapshot_signature: + self.revision += 1 + self.snapshot_signature = signature + self.observed_at = datetime.now(UTC).isoformat() + revision, observed_at = self.revision, self.observed_at + return { + "id": self.binding.device_id, + "session_id": self.session, + "opened_at": self.opened_at, + "prepared": self.ready, + "online": connected, + "verified": verified, + "revision": revision, + "observed_at": observed_at, + "preparation_safe": connected + and state.get("preview") == 0 + and state.get("recording") == 0, + "status": state if fresh else {}, + "message": self.failure, + } + + def dispatch(self, method, route, value, headers): + if method == "GET" and route == "/snapshot": + return self.snapshot() + if method == "POST" and route == "/video": + if ( + not self.ready + or value.get("session_id") != self.session + or set(value) != {"session_id", "cursor"} + ): + raise ValueError("Camera video session changed") + return Binary(encode(self.feed.read(value["cursor"]))) + if method == "POST" and route == "/operation": + if not self.ready: + raise RuntimeError("Camera is unavailable") + with acquisition(): + result = self.operations.execute(value) + # Poll asynchronously; a lost state read must not change a durable + # acknowledged command result into a different receipt. + if value.get("action_id") not in ("details", "settings.read", "files.list"): + with self.status_lock: + self.observed = 0 + return result + raise ValueError("Unsupported worker route") + + def poll(self): + self.connect() + while self.ready and not self.done.wait(3): + try: + self.refresh() + except (RuntimeError, OSError, ValueError): + with self.status_lock: + self.observed = 0 + + +def main(port, runtime): + binding = read_binding(port) + state = Path(os.environ["STATE_DIRECTORY"]) + socket = Path(os.environ["RUNTIME_DIRECTORY"]) / "driver.sock" + worker = Worker(binding, runtime, state) + thread = threading.Thread(target=worker.poll, daemon=True) + thread.start() + if socket.exists(): + socket.unlink() + allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid} + with Server(socket, worker.dispatch, allowed) as server: + socket.chmod(0o660) + server.serve_forever(poll_interval=0.5) diff --git a/plugins/insta360-x4/tests/check_inventory.py b/plugins/insta360-x4/tests/check_inventory.py new file mode 100644 index 0000000..29d87bd --- /dev/null +++ b/plugins/insta360-x4/tests/check_inventory.py @@ -0,0 +1,75 @@ +"""Actual plugin-SDK and Core inventory validation, with synthetic identities.""" + +import json +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / "src")) +from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot # noqa: E402 +from runtime import broker, identity, worker # noqa: E402 + +from k1link.fleet.sensors import validate_inventory # noqa: E402 + +NODE = "node_" + "a" * 64 + + +class InventoryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=REPO / "plugins/insta360-x4/build") + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + binding = identity.Binding(identity.device_id("SYNTHETIC"), "1-2", 1, 2, "SYNTHETIC") + self.worker = worker.Worker(binding, root, root) + self.broker = broker.Broker() + + def test_idle_active_unknown_and_disconnected_match_real_session_contract(self): + revisions = [] + for ready, preview, recording in ( + (False, None, None), + (True, 0, 0), + (True, 1, 0), + (True, -1, -1), + (False, None, None), + ): + self.worker.ready = ready + self.worker.status = {"connected": ready, "preview": preview, "recording": recording} + self.worker.observed = time.monotonic() if ready else 0 + value = self.broker.item(self.worker.snapshot(), NODE) + snapshot = DeviceSessionSnapshot.model_validate(value["snapshot"]) + revisions.append(snapshot.revision) + validate_inventory({"devices": [value]}, NODE) + self.assertEqual(revisions, sorted(revisions)) + self.assertGreater(revisions[-1], revisions[0]) + + def test_500_distinct_cameras_fit_bounded_carrier_and_501_are_rejected(self): + self.worker.ready = True + self.worker.observed = time.monotonic() + self.worker.status = {"connected": True, "preview": 0, "recording": 0} + snapshot = self.worker.snapshot() + values = [] + for index in range(500): + value = dict(snapshot, id=identity.device_id("SYNTHETIC-" + str(index))) + values.append(self.broker.item(value, NODE)) + state = {"items": values, "operations": []} + payload = {"devices": values, "sensor_state": state} + self.assertIs(validate_inventory(payload, NODE), state) + self.assertLess(len(json.dumps({"items": values}).encode()), 2 * 1024 * 1024) + self.assertLess(len(json.dumps(payload).encode()), 8 * 1024 * 1024) + payload["devices"] = values + [values[0]] + with self.assertRaises(ValueError): + validate_inventory(payload, NODE) + + def test_cross_node_or_duplicate_identity_is_rejected(self): + value = self.broker.item(self.worker.snapshot(), NODE) + with self.assertRaises(ValueError): + validate_inventory({"devices": [value]}, "node_" + "b" * 64) + with self.assertRaises(ValueError): + validate_inventory({"devices": [value, value]}, NODE) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/insta360-x4/tests/check_media.py b/plugins/insta360-x4/tests/check_media.py new file mode 100644 index 0000000..244df8f --- /dev/null +++ b/plugins/insta360-x4/tests/check_media.py @@ -0,0 +1,413 @@ +"""Real codecs/WebRTC over loopback; synthetic frames, no CameraSDK or device.""" + +import asyncio +import os +import re +import tempfile +import threading +import unittest +from fractions import Fraction +from pathlib import Path +from unittest.mock import patch + +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription +from check_runtime import ROOT, VideoCamera, synthetic_video +from runtime import broker, frames, http, media +from runtime.verification import Control + + +def packet(number, data=b"synthetic", generation=1): + return { + "stream_index": 0, + "codec": 0, + "generation": generation, + "sequence": number, + "timestamp": number, + }, data + + +class FrameTests(unittest.TestCase): + def test_repeated_preview_commands_preserve_generation_and_readers(self): + feed = frames.Feed(None) + camera = VideoCamera([], preview=1) + control = Control(camera, feed) + feed.append(packet(1)) + before = feed.read(0, 0) + self.assertEqual(control.call("preview.start", {})["state"], "complete") + self.assertEqual(feed.read(0, 0), before) + control.call("preview.stop", {}) + self.assertIsNone(feed.read(0, 0)) + generation = feed.generation + control.call("preview.stop", {}) + self.assertEqual(feed.generation, generation) + control.call("preview.start", {}) + self.assertGreater(feed.generation, generation) + + def test_uncertain_redundant_start_does_not_release_existing_decoder(self): + value, events = broker.Broker(), [] + identifier = "instax4_" + "a" * 32 + session = {"device_id": identifier, "session_id": "synthetic"} + + class Engine: + def capture(self, key, path, start): + events.append(start) + + value.media = Engine() + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return { + "id": identifier, + "session_id": "synthetic", + "online": True, + "status": {"preview": 1}, + } + return {"state": "unknown"} + + with patch.object(broker, "request", request): + result = value.capture_operation( + identifier, {"session": session, "action_id": "preview.start"} + ) + self.assertEqual(result["state"], "unknown") + self.assertEqual(events, [True]) + + def test_broker_primes_before_sdk_start_and_releases_after_sdk_stop(self): + value = broker.Broker() + events = [] + identifier = "instax4_" + "a" * 32 + session = {"device_id": identifier, "session_id": "synthetic"} + + class Engine: + def capture(self, key, path, start): + events.append("prime" if start else "release") + + value.media = Engine() + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return {"id": identifier, "session_id": "synthetic", "online": True} + events.append(command["action_id"]) + return {"state": "complete"} + + with patch.object(broker, "request", request): + for action in ("preview.start", "preview.stop"): + value.capture_operation(identifier, {"session": session, "action_id": action}) + self.assertEqual(events, ["prime", "preview.start", "preview.stop", "release"]) + + def test_late_decoder_gets_parameters_after_initial_packets_leave_queue(self): + import av + + encoder = av.CodecContext.create("libx264", "w") + encoder.width, encoder.height, encoder.pix_fmt = 32, 16, "yuv420p" + encoder.time_base = Fraction(1, 30) + encoder.options = { + "preset": "ultrafast", + "tune": "zerolatency", + "x264-params": "keyint=6:min-keyint=6:scenecut=0", + } + feed = frames.Feed(None) + for number in range(96): + frame = av.VideoFrame(32, 16, "yuv420p") + frame.pts = number + for plane in frame.planes: + plane.update(bytes([64]) * plane.buffer_size) + for encoded in encoder.encode(frame): + data = bytes(encoded) + if number: + # Model a camera which sends SPS/PPS only at preview start. + units = re.split(b"\x00\x00(?:\x00)?\x01", data) + data = b"".join( + b"\x00\x00\x00\x01" + unit + for unit in units + if unit and unit[0] & 31 not in (7, 8) + ) + feed.append(packet(number, data)) + self.assertEqual(len(feed.queue), frames.MAX_ENTRIES) + self.assertGreater(feed.queue[0][0]["sequence"], 0) + decoder, decoded, cursor = av.CodecContext.create("h264", "r"), [], 0 + while (value := feed.read(cursor, 0)) is not None: + header, data = value + cursor = header["cursor"] + try: + for encoded in decoder.parse(data): + decoded.extend(decoder.decode(encoded)) + except av.error.FFmpegError: + pass + self.assertGreaterEqual(len(decoded), 2) + self.assertEqual((decoded[-1].width, decoded[-1].height), (32, 16)) + + def test_parameter_cache_cannot_cross_stream_device_or_preview_generation(self): + config = b"\x00\x00\x01\x67sps\x00\x00\x01\x68pps" + keyframe = b"\x00\x00\x01\x65frame" + first, second = frames.Feed(None), frames.Feed(None) + first.append(packet(1, config)) + first.append(packet(2, keyframe)) + self.assertEqual(first.queue[-1][1], config + keyframe) + second.append(packet(1, keyframe)) + self.assertEqual(second.queue[-1][1], keyframe) + header, data = packet(3, keyframe) + header["stream_index"] = 1 + first.append((header, data)) + self.assertEqual(first.queue[-1][1], keyframe) + first.append(packet(4, keyframe, generation=2)) + self.assertEqual(first.queue[-1][1], keyframe) + first.append(packet(5, config, generation=2)) + first.clear() + first.append(packet(6, keyframe, generation=2)) + self.assertEqual(first.queue[-1][1], keyframe) + + def test_parameter_cache_is_bounded_and_hevc_parameters_are_distinct(self): + feed = frames.Feed(None) + huge = b"\x00\x00\x01\x67" + b"x" * frames.MAX_PARAMETERS + feed.append(packet(1, huge)) + self.assertFalse(any(feed.parameters.streams.values())) + config = b"".join(b"\x00\x00\x01" + bytes([kind << 1, 1, 2]) for kind in (32, 33, 34)) + keyframe = b"\x00\x00\x01\x26\x01frame" + header, _ = packet(2) + header["codec"] = 1 + feed.append((header, config)) + feed.append((header, keyframe)) + self.assertEqual(feed.queue[-1][1], config + keyframe) + + def test_independent_readers_and_devices_preserve_packet_identity(self): + first, second = frames.Feed(None), frames.Feed(None) + a, b = first.reader(), first.reader() + first.append(packet(1, b"first")) + second.append(packet(1, b"second")) + self.assertEqual(a()[1], b"first") + self.assertEqual(b()[1], b"first") + self.assertEqual(second.read(0, 0)[1], b"second") + self.assertIsNone(a()) + + def test_overrun_and_preview_boundary_are_visible_to_decoder(self): + feed = frames.Feed(None) + for n in range(100): + feed.append(packet(n)) + value = feed.read(1, 0) + self.assertTrue(value[0]["gap"]) + self.assertLessEqual(len(feed.queue), frames.MAX_ENTRIES) + old = value[0]["generation"] + feed.change(lambda: None) + feed.append(packet(101)) + self.assertGreater(feed.read(0, 0)[0]["generation"], old) + + def test_wire_bounds_and_types_reject_corrupted_or_unknown_packets(self): + feed = frames.Feed(None) + feed.append(packet(1)) + value = feed.read(0, 0) + self.assertEqual(frames.decode(frames.encode(value)), value) + for data in (b"x", b"\xff" * 8, b"\0\0\0\1x", frames.encode(value)[:5]): + with self.assertRaises((ValueError, UnicodeError)): + frames.decode(data) + with self.assertRaises(ValueError): + feed.read(True, 0) + + def test_public_ice_candidates_are_rejected_before_network_work(self): + with self.assertRaises(ValueError): + media.admit_sdp("v=0\r\na=candidate:1 1 udp 1 203.0.113.6 1234 typ host\r\n") + media.admit_sdp("v=0\r\na=candidate:1 1 udp 1 127.0.0.1 1234 typ host\r\n") + + +class MediaTests(unittest.IsolatedAsyncioTestCase): + async def test_primed_decoder_survives_late_viewer_and_peer_close(self): + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temporary: + descriptor = os.open(temporary, os.O_RDONLY) + path = Path(f"/proc/self/fd/{descriptor}/video.sock") + feed, stop = frames.Feed(None), threading.Event() + packets = synthetic_video(220) # One initial keyframe; >64-packet queue. + + def publish(): + for number, data in enumerate(packets): + if stop.is_set(): + break + feed.append(packet(number, data)) + stop.wait(1 / 30) + + def dispatch(method, route, value, headers): + if route == "/snapshot": + return {"session_id": "synthetic", "online": True, "status": {"preview": 1}} + if method != "POST" or route != "/video" or value["session_id"] != "synthetic": + raise ValueError("Unexpected source command") + return http.Binary(frames.encode(feed.read(value["cursor"]))) + + server = http.Server(path, dispatch, {os.geteuid()}) + serving = threading.Thread(target=server.serve_forever, daemon=True) + producer = threading.Thread(target=publish, daemon=True) + serving.start() + peers, key = media.Peers(), ("synthetic-camera", "synthetic") + receiver = RTCPeerConnection(RTCConfiguration(iceServers=[])) + received = asyncio.get_running_loop().create_future() + tasks = [] + + @receiver.on("track") + def track(value): + async def observe(): + first, second = await value.recv(), await value.recv() + if not received.done(): + received.set_result((first, second)) + + tasks.append(asyncio.create_task(observe())) + + try: + await peers.prime(key, path) + source = peers.sources[key] + producer.start() + await asyncio.sleep(2.4) + self.assertGreater(feed.queue[0][0]["sequence"], 0) + self.assertGreater(source.current()[0], 2) + with patch.object(media, "_host_addresses", return_value=["127.0.0.1"]): + receiver.addTransceiver("video", direction="recvonly") + receiver.createDataChannel("sensor") + await receiver.setLocalDescription(await receiver.createOffer()) + answer = await peers.offer( + key, path, {"sdp": receiver.localDescription.sdp, "layer": "preview"} + ) + self.assertNotIn("VP8/", answer["sdp"]) + await receiver.setRemoteDescription( + RTCSessionDescription(sdp=answer["sdp"], type="answer") + ) + first, second = await asyncio.wait_for(received, 4) + self.assertEqual((first.width, first.height), (32, 16)) + self.assertGreater(second.pts, first.pts) + # A second UI can send START from its stale idle snapshot. + # No new camera keyframe is produced for this idempotent call. + control = Control(VideoCamera([], preview=1), feed) + control.call("preview.start", {}) + verified = await peers.verify(key, timeout=1) + self.assertEqual(verified["state"], "complete") + self.assertGreaterEqual(verified["result"]["streams"][0]["frames"], 2) + await peers.close(key, answer["peer_id"]) + self.assertIs(peers.sources[key], source) + self.assertFalse(source.closed.is_set()) + finally: + await receiver.close() + await peers.release(key) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + stop.set() + if producer.is_alive(): + producer.join(2) + feed.close() + server.shutdown() + server.server_close() + serving.join(2) + os.close(descriptor) + self.assertFalse(peers.items) + self.assertFalse(peers.sources) + self.assertFalse(peers.primed) + + async def test_verification_never_accepts_retained_frames_or_another_camera(self): + peers = media.Peers() + key = ("synthetic", "one") + + class Retained: + closed = threading.Event() + + def current(self): + return 50, object(), media.time.monotonic() + + retained = Retained() + peers.sources[key] = retained + self.assertEqual((await peers.verify(key, timeout=0.05))["state"], "error") + self.assertEqual((await peers.verify(("another", "one")))["state"], "error") + retained.closed.set() + self.assertEqual((await peers.verify(key))["state"], "error") + + async def test_synthetic_frames_cross_private_ipc_and_real_webrtc_with_cleanup(self): + self.loop_slow_callback_duration = 1 + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temporary: + directory = Path(temporary) + descriptor = os.open(directory, os.O_RDONLY) + path = Path(f"/proc/self/fd/{descriptor}/video.sock") + feed = frames.Feed(None) + stop = threading.Event() + packets = synthetic_video() + + def publish(): + number = 0 + while not stop.is_set(): + feed.append( + packet( + number, + packets[number % len(packets)], + generation=number // len(packets) + 1, + ) + ) + number += 1 + stop.wait(1 / 30) + + def dispatch(method, route, value, headers): + if method != "POST" or route != "/video" or value["session_id"] != "synthetic": + raise ValueError("Wrong camera/session") + return http.Binary(frames.encode(feed.read(value["cursor"]))) + + server = http.Server(path, dispatch, {os.geteuid()}) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + producer = threading.Thread(target=publish, daemon=True) + server_thread.start() + producer.start() + peers = media.Peers() + receiver = RTCPeerConnection(RTCConfiguration(iceServers=[])) + received = asyncio.get_running_loop().create_future() + tasks = [] + identifier = None + key = ("synthetic-camera", "synthetic") + + @receiver.on("track") + def track(value): + async def observe(): + first = await value.recv() + second = await value.recv() + if not received.done(): + received.set_result((first, second)) + + tasks.append(asyncio.create_task(observe())) + + try: + # Both peers use only 127.0.0.1. No STUN, LAN candidates or + # camera packet is admitted to this synthetic qualification. + with patch.object(media, "_host_addresses", return_value=["127.0.0.1"]): + receiver.addTransceiver("video", direction="recvonly") + channel = receiver.createDataChannel("sensor") + + @channel.on("open") + def opened(): + channel.send("keepalive") + + await receiver.setLocalDescription(await receiver.createOffer()) + answer = await peers.offer( + key, path, {"sdp": receiver.localDescription.sdp, "layer": "preview"} + ) + identifier = answer["peer_id"] + await receiver.setRemoteDescription( + RTCSessionDescription(sdp=answer["sdp"], type="answer") + ) + first, second = await asyncio.wait_for(received, 12) + self.assertEqual((first.width, first.height), (32, 16)) + self.assertGreater(second.pts, first.pts) + with self.assertRaises(ValueError): + await peers.close(("another-camera", "synthetic"), identifier) + self.assertIn(identifier, peers.items) + finally: + await receiver.close() + if identifier: + await peers.close(key, identifier) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + stop.set() + feed.close() + server.shutdown() + server.server_close() + server_thread.join(2) + producer.join(2) + os.close(descriptor) + self.assertFalse(peers.items) + self.assertFalse(peers.sources) + self.assertFalse(server_thread.is_alive()) + self.assertFalse(producer.is_alive()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/insta360-x4/tests/check_profile.py b/plugins/insta360-x4/tests/check_profile.py new file mode 100644 index 0000000..43fd4a0 --- /dev/null +++ b/plugins/insta360-x4/tests/check_profile.py @@ -0,0 +1,164 @@ +"""Synthetic cold-host bootstrap transactions; no actual APT/systemd/USB.""" + +import hashlib +import importlib.util +import json +import subprocess +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location( + "x4_node_profile", REPO / "apps/node-agent/packaging/insta360_profile.py" +) +profile = importlib.util.module_from_spec(spec) +spec.loader.exec_module(profile) + + +class ProfileTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=REPO / "plugins/insta360-x4/build") + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + self.share, self.state, self.plugin = (root / name for name in ("share", "state", "plugin")) + for directory in (self.share, self.state, self.plugin): + directory.mkdir() + self.bundle = { + "schema": "missioncore.node.bundled-model/v1", + "version": "0.1.1", + "revision": "a" * 24, + "bytes": 3, + "sha256": hashlib.sha256(b"deb").hexdigest(), + } + (self.share / "profile.json").write_text(json.dumps(self.bundle)) + (self.share / "mission-core-insta360-x4_0.1.1_amd64.deb").write_bytes(b"deb") + self.calls = [] + self.previous = None + self.failure = False + for name, value in ( + ("SHARE", self.share), + ("STATE", self.state), + ("PLUGIN_STATE", self.plugin), + ): + context = patch.object(profile, name, value) + context.start() + self.addCleanup(context.stop) + + # Production checks uid 0. Tests remain unprivileged and only admit the + # three private fixture directories, never paths on the installed OS. + def trusted(path, directory=False): + self.assertTrue(path == root or root in path.parents) + self.assertFalse(path.is_symlink()) + self.assertEqual(path.is_dir(), directory) + return path + + for context in ( + patch.object(profile, "trusted", trusted), + patch.object(profile.platform, "machine", return_value="x86_64"), + patch.object( + profile.platform, + "freedesktop_os_release", + return_value={"ID": "ubuntu", "VERSION_ID": "24.04"}, + ), + patch.object(profile.subprocess, "run", side_effect=self.run_command), + ): + context.start() + self.addCleanup(context.stop) + + def prepared(self): + (self.plugin / "preparation.json").write_text( + json.dumps( + { + "state": "complete", + "revision": self.bundle["revision"], + "started_at": time.time(), + } + ) + ) + + def run_command(self, command, **kwargs): + self.calls.append(command) + name = Path(command[0]).name + if name == "dpkg-query": + return subprocess.CompletedProcess( + command, + 1 if self.previous is None else 0, + "" if self.previous is None else self.previous + "\tinstall ok installed", + "", + ) + if name == "dpkg" and command[1] == "--compare-versions": + return subprocess.CompletedProcess(command, 0 if self.previous == "0.2.0" else 1) + if name == "dpkg" and command[1] == "--install": + if self.failure: + return subprocess.CompletedProcess(command, 100, b"", b"synthetic package refusal") + if self.previous: + self.prepared() # Upgrade postinst owns preparation. + elif name == "systemctl": + self.assertEqual(command, ["/usr/bin/systemctl", "start", profile.UNIT]) + self.prepared() + else: + self.fail("Unexpected OS command") + return subprocess.CompletedProcess(command, 0, b"", b"") + + def names(self): + return [Path(command[0]).name for command in self.calls] + + def test_cold_host_installs_only_bundled_package_then_fixed_prepare(self): + self.assertTrue(profile.prepare()) + self.assertEqual(self.names(), ["dpkg-query", "dpkg", "systemctl"]) + self.assertEqual( + self.calls[1], + [ + "/usr/bin/dpkg", + "--install", + str(self.share / "mission-core-insta360-x4_0.1.1_amd64.deb"), + ], + ) + + def test_existing_version_reuses_package_and_revalidates_runtime(self): + self.previous = "0.1.1" + self.assertTrue(profile.prepare()) + self.assertFalse(any("--install" in command for command in self.calls)) + self.assertIn("systemctl", self.names()) + + def test_debian_revision_remains_a_fixed_local_package(self): + old = self.share / "mission-core-insta360-x4_0.1.1_amd64.deb" + old.rename(self.share / "mission-core-insta360-x4_0.1.1-1_amd64.deb") + self.bundle["version"] = "0.1.1-1" + (self.share / "profile.json").write_text(json.dumps(self.bundle)) + self.assertTrue(profile.prepare()) + self.assertEqual(Path(self.calls[1][-1]).name, "mission-core-insta360-x4_0.1.1-1_amd64.deb") + + def test_upgrade_does_not_race_postinst_with_second_prepare(self): + self.previous = "0.1.0" + self.assertTrue(profile.prepare()) + self.assertTrue(any("--install" in command for command in self.calls)) + self.assertNotIn("systemctl", self.names()) + + def test_corruption_never_reaches_package_manager_or_driver(self): + (self.share / "mission-core-insta360-x4_0.1.1_amd64.deb").write_bytes(b"bad") + self.assertFalse(profile.prepare()) + self.assertEqual(self.calls, []) + + def test_newer_driver_is_preserved(self): + self.previous = "0.2.0" + self.assertFalse(profile.prepare()) + self.assertFalse(any("--install" in command for command in self.calls)) + self.assertNotIn("systemctl", self.names()) + + def test_failed_package_never_opens_camera_and_keeps_error_evidence(self): + self.failure = True + self.assertFalse(profile.prepare()) + self.assertNotIn("systemctl", self.names()) + report = json.loads((self.state / "preparation.json").read_text()) + self.assertEqual(report["steps"][-1]["state"], "blocked") + self.assertEqual( + (self.state / report["run_id"] / "1.stderr").read_bytes(), b"synthetic package refusal" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/insta360-x4/tests/check_runtime.py b/plugins/insta360-x4/tests/check_runtime.py new file mode 100644 index 0000000..af3afb7 --- /dev/null +++ b/plugins/insta360-x4/tests/check_runtime.py @@ -0,0 +1,349 @@ +"""Qualification on Ubuntu using a freshly extracted pinned Python runtime. + +Only fake camera calls are executed. No vendor SDK import, USB or system change. +""" + +import hashlib +import io +import os +import sys +import tempfile +import threading +import time +import unittest +import uuid +import zipfile +from datetime import UTC, datetime, timedelta +from fractions import Fraction +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "packaging")] +import prepare # noqa: E402 +from runtime import broker, http, identity, operations, worker # noqa: E402 +from runtime.verification import Control # noqa: E402 + + +class VideoCamera: + def __init__(self, packets, preview=0, recording=0, stop_unknown=False): + self.packets = list(packets) + self.preview, self.recording = preview, recording + self.stop_unknown = stop_unknown + self.calls = [] + self.sequence = 0 + + def call(self, action, values): + self.calls.append(action) + if action == "details": + return { + "state": "complete", + "result": {"connected": True, "preview": self.preview, "recording": self.recording}, + } + if action == "preview.start": + self.preview = 1 + if action == "preview.stop": + self.preview = 0 + if self.stop_unknown: + return {"state": "unknown"} + return {"state": "complete", "result": {"ok": True}} + + def video(self): + if not self.packets: + return None + self.sequence += 1 + return { + "stream_index": 0, + "codec": 0, + "generation": 1, + "sequence": self.sequence, + }, self.packets.pop(0) + + +def synthetic_video(count=4): + import av + + encoder = av.CodecContext.create("libx264", "w") + encoder.width, encoder.height = 32, 16 + encoder.pix_fmt = "yuv420p" + encoder.time_base = Fraction(1, 30) + encoder.options = {"preset": "ultrafast", "tune": "zerolatency"} + output = [] + for number in range(count): + frame = av.VideoFrame(32, 16, "yuv420p") + frame.pts = number + for index, plane in enumerate(frame.planes): + plane.update(bytes([16 if index == 0 else 128]) * plane.buffer_size) + output.extend(bytes(packet) for packet in encoder.encode(frame)) + output.extend(bytes(packet) for packet in encoder.encode(None)) + return output + + +class Camera: + def __init__(self): + self.calls = [] + + def call(self, action, values): + self.calls.append((action, values)) + return {"state": "complete", "result": {"ok": True}} + + +def command(ident, session, action="record.start"): + now = datetime.now(UTC) + operation = "op_" + uuid.uuid4().hex + return { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": operation, + "idempotency_key": operation, + "session": {"device_id": ident, "session_id": session}, + "action_id": action, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": {}, + } + + +class RuntimeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build") + self.root = Path(self.temporary.name) + + def tearDown(self): + self.temporary.cleanup() + + def test_verification_uses_live_source_and_deduplicates_across_preview_states(self): + value = broker.Broker() + ident, session = identity.device_id("SYNTHETIC-A"), "session_one" + current = { + "id": ident, + "session_id": session, + "online": True, + "status": {"preview": 1, "recording": 0}, + } + calls = [] + + class Engine: + def verify(self, key): + calls.append(key) + return {"state": "complete", "result": {"verified": True}} + + value.media = Engine() + sdk = [] + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return current + sdk.append(command) + return {"state": "complete", "result": {"verified": True}} + + first = command(ident, session, "verify") + with patch.object(broker, "STATE", self.root), patch.object(broker, "request", request): + self.assertEqual(value.verify_operation(ident, first)["state"], "complete") + current["status"]["preview"] = 0 + self.assertEqual(value.verify_operation(ident, first)["state"], "complete") + self.assertEqual(len(calls), 1) + self.assertFalse(sdk) + second = command(ident, session, "verify") + self.assertEqual(value.verify_operation(ident, second)["state"], "complete") + self.assertEqual(sdk, [second]) + current["status"]["recording"] = 1 + refused = value.verify_operation(ident, command(ident, session, "verify")) + self.assertIn("остановите запись", refused["error"]) + self.assertEqual(len(sdk), 1) + + def test_error_translation_preserves_verification_reason_and_redacts_vendor_text(self): + class Failed: + error = "preview_no_decodable_image" + + def call(self, action, params): + return {"state": "error", "error": self.error} + + camera = Failed() + ident = identity.device_id("SYNTHETIC-A") + runtime = operations.Operations(ident, "session_one", self.root, camera) + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result["error"], "Камера не передала декодируемое изображение.") + camera.error = "PRIVATE VENDOR DEVICE IDENTIFIER" + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result["error"], "Не удалось выполнить команду камеры.") + camera.error = "unsupported_camera_parameter" + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result, operations.INVALID) + + def test_receipt_deduplicates_start_after_worker_recreation(self): + camera = Camera() + ident = identity.device_id("SYNTHETIC-A") + original = operations.Operations(ident, "session_one", self.root, camera) + value = command(ident, "session_one") + self.assertEqual(original.execute(value)["state"], "complete") + resumed = operations.Operations(ident, "session_two", self.root, camera) + self.assertEqual(resumed.execute(value)["state"], "complete") + self.assertEqual(len(camera.calls), 1) + + def test_two_instances_never_share_commands_or_receipts(self): + first, second = Camera(), Camera() + a, b = identity.device_id("SYNTHETIC-A"), identity.device_id("SYNTHETIC-B") + left = operations.Operations(a, "session_a", self.root / "a", first) + right = operations.Operations(b, "session_b", self.root / "b", second) + value = command(a, "session_a") + left.execute(value) + with self.assertRaises(ValueError): + right.execute(value) + self.assertEqual(len(first.calls), 1) + self.assertEqual(second.calls, []) + + def test_expired_or_stale_session_has_no_sdk_effect(self): + camera = Camera() + ident = identity.device_id("SYNTHETIC-A") + runtime = operations.Operations(ident, "session_two", self.root, camera) + for value in [command(ident, "session_one"), command(ident, "session_two")]: + if value["session"]["session_id"] == "session_two": + value["requested_at"] = (datetime.now(UTC) - timedelta(seconds=90)).isoformat() + value["deadline_at"] = (datetime.now(UTC) - timedelta(seconds=10)).isoformat() + with self.assertRaises(ValueError): + runtime.execute(value) + self.assertEqual(camera.calls, []) + + def test_sd_recording_and_unknown_status_block_profile_replacement(self): + binding = identity.Binding(identity.device_id("SYNTHETIC"), "1-2", 1, 2, "SYNTHETIC") + instance = worker.Worker(binding, self.root, self.root) + instance.ready = True + for recording, preview, expected in [ + (0, 0, True), + (1, 0, False), + (-1, 0, False), + (0, 1, False), + ]: + instance.status = {"connected": True, "recording": recording, "preview": preview} + instance.observed = time.monotonic() + self.assertEqual(instance.snapshot()["preparation_safe"], expected) + instance.observed = 0 + self.assertFalse(instance.snapshot()["preparation_safe"]) + + def test_installer_fails_closed_when_worker_exists_but_broker_is_missing(self): + instances = self.root / "instances" + instances.mkdir() + (instances / identity.device_id("SYNTHETIC")).mkdir() + with ( + patch.object(prepare, "SOCKET", self.root / "absent.sock"), + patch.object(prepare, "INSTANCES", instances), + self.assertRaises(RuntimeError), + ): + prepare.assert_safe() + + def test_installer_refuses_active_recording_even_if_preview_idle(self): + socket = self.root / "driver.sock" + socket.touch() + with ( + patch.object(prepare, "SOCKET", socket), + patch.object(prepare, "request", return_value={"safe": False}), + self.assertRaises(RuntimeError), + ): + prepare.assert_safe() + + def test_runtime_integrity_rechecks_existing_install(self): + files = {"lib/synthetic.so": b"SYNTHETIC-NOT-AN-ELF", "python/example.py": b"value = 1\n"} + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + payload = data.getvalue() + share, state = self.root / "share", self.root / "state" + share.mkdir() + state.mkdir() + (share / "payload.zip").write_bytes(payload) + bundle = { + "revision": "a" * 24, + "payload_sha256": hashlib.sha256(payload).hexdigest(), + "files": { + name: {"bytes": len(content), "sha256": hashlib.sha256(content).hexdigest()} + for name, content in files.items() + }, + } + + def directory(path): + path.mkdir(exist_ok=True) + + with ( + patch.object(prepare, "STATE", state), + patch.object(prepare, "SHARE", share), + patch.object(prepare, "trusted", side_effect=lambda path, directory=False: path), + patch.object(prepare, "directory", side_effect=directory), + ): + ident = prepare.install_runtime(bundle) + self.assertEqual(prepare.install_runtime(bundle), ident) + (state / "runtime" / ident / "python/example.py").write_bytes(b"modified") + with self.assertRaises(RuntimeError): + prepare.install_runtime(bundle) + + def test_payload_rejects_traversal_before_extraction(self): + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("../escape", b"synthetic") + info = {"bytes": 9, "sha256": hashlib.sha256(b"synthetic").hexdigest()} + with ( + zipfile.ZipFile(io.BytesIO(data.getvalue())) as archive, + self.assertRaises(RuntimeError), + ): + list(prepare.entries(archive, {"files": {"../escape": info}})) + self.assertFalse((self.root.parent / "escape").exists()) + + def test_private_socket_checks_kernel_peer_credentials(self): + # Keep the socket in owned staging through a short directory-FD alias; + # Linux sockaddr_un cannot hold the long build-source pathname. + descriptor = os.open(self.root, os.O_RDONLY) + self.addCleanup(os.close, descriptor) + for allowed, success in [({os.geteuid()}, True), ({os.geteuid() + 10000}, False)]: + path = Path(f"/proc/self/fd/{descriptor}") / ("yes.sock" if success else "no.sock") + server = http.Server(path, lambda *_: {"ok": True}, allowed) + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}) + thread.start() + try: + if success: + self.assertTrue(http.request(path, "/snapshot")["ok"]) + else: + with self.assertRaises((OSError, http.http.client.HTTPException)): + http.request(path, "/snapshot") + finally: + server.shutdown() + server.server_close() + thread.join(2) + self.assertFalse(thread.is_alive()) + + def test_worker_routes_never_accept_arbitrary_device_paths(self): + for value in ("../another.sock", "/run/driver.sock", "instax4_bad", None): + with self.assertRaises(ValueError): + broker.worker_socket(value) + + def test_verification_requires_decoded_images_and_only_closes_own_preview(self): + camera = VideoCamera(synthetic_video()) + control = Control(camera) + result = control.verify(timeout=1) + self.assertEqual(result["state"], "complete") + self.assertTrue(control.verified) + self.assertGreaterEqual(result["result"]["streams"][0]["frames"], 2) + self.assertEqual(result["result"]["streams"][0]["width"], 32) + self.assertEqual(camera.calls, ["details", "preview.start", "preview.stop"]) + existing = VideoCamera(synthetic_video(), preview=1) + self.assertEqual(Control(existing).verify(timeout=1)["state"], "complete") + self.assertEqual(existing.calls, ["details"]) + + def test_packets_and_unconfirmed_preview_cleanup_are_not_verified(self): + broken = VideoCamera([b"SYNTHETIC-NOT-VIDEO"]) + control = Control(broken) + self.assertEqual(control.verify(timeout=0.03)["state"], "error") + self.assertFalse(control.verified) + uncertain = VideoCamera(synthetic_video(), stop_unknown=True) + control = Control(uncertain) + self.assertEqual(control.verify(timeout=1)["state"], "unknown") + self.assertFalse(control.verified) + + def test_image_verification_does_not_interrupt_sd_recording(self): + camera = VideoCamera([], recording=1) + self.assertEqual(Control(camera).verify(timeout=0.03)["state"], "error") + self.assertEqual(camera.calls, ["details"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/src/k1link/fleet/recovery.py b/src/k1link/fleet/recovery.py new file mode 100644 index 0000000..265ab91 --- /dev/null +++ b/src/k1link/fleet/recovery.py @@ -0,0 +1,162 @@ +"""Recover only an existing binding over a previously authenticated tailnet address. + +No peer discovery, invitation secrets, trust replacement, or provider API. The +Node pins the Core CA public key through mTLS; Core pins the saved Node key. +""" + +from __future__ import annotations + +import http.client +import ipaddress +import json +import ssl +import time + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from .trust import PairingError, atomic_private, endpoint, pem, verify_node_certificate + +SCHEMA = "missioncore.node-channel-recovery/v1" + + +def tailnet_address(value): + try: + return ipaddress.IPv4Address(value) in ipaddress.ip_network("100.64.0.0/10") + except (ValueError, TypeError): + return False + + +def node_addresses(row): + addresses = set() + for network in (row.get("inventory") or {}).get("networks", [])[:64]: + if not isinstance(network, dict) or not network.get("up"): + continue + for alias in network.get("addresses", [])[:32]: + if isinstance(alias, str) and tailnet_address(alias.split("/")[0]): + addresses.add(alias.split("/")[0]) + return sorted(addresses)[:2] + + +def client_context(trust): + # Keep the pinned Core key, but distinguish the leaf from its issuer. + # Go rejects same-subject/same-SPKI certificates without SAN as a chain loop. + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mission Core channel recovery")]) + cert = ( + trust.builder(subject, trust.key.public_key(), 1) + .issuer_name(trust.ca.subject) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False) + .sign(trust.key, None) + ) + cert_path, key_path = trust.root / "recovery-client.pem", trust.root / "recovery-key.pem" + atomic_private(cert_path, (pem(cert) + pem(trust.ca)).encode()) + atomic_private( + key_path, + trust.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + # Bootstrap certificate is self-signed by the already pinned Node key. + # verify_node_certificate must succeed before any application request. + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.load_cert_chain(cert_path, key_path) + return context + + +def request(connection, path, body): + connection.request("POST", path, json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + raw = response.read(16385) + if response.status != 200 or len(raw) > 16384: + raise PairingError("БК пока не подтвердил восстановление канала.") + value = json.loads(raw) + if not isinstance(value, dict): + raise PairingError("Некорректное подтверждение канала.") + return value + + +def recover(registry, row, address, context): + if not tailnet_address(address) or address not in node_addresses(row): + raise PairingError("Нет подтверждённого адреса БК.") + connection = http.client.HTTPSConnection(address, 8781, timeout=4, context=context) + try: + connection.connect() + verify_node_certificate(connection.sock.getpeercert(binary_form=True), row["node_id"]) + local = connection.sock.getsockname()[0] + if not tailnet_address(local): + raise PairingError("Обратный адрес Core не относится к частному каналу.") + body = {"schema": SCHEMA, "binding_id": row["binding"]["binding_id"]} + value = request(connection, "/v1/channel/inspect", body) + if ( + value.get("schema") != SCHEMA + or value.get("node_id") != row["node_id"] + or value.get("core_id") != registry.trust.core_id + or value.get("binding_id") != body["binding_id"] + or type(value.get("endpoint_revision")) is not int + or not 0 <= value["endpoint_revision"] < 1000000000 + ): + raise PairingError("Подтверждена другая привязка БК.") + endpoint(value.get("endpoint"), 8782) + with registry.lock: + current = registry.find(row["id"]) + if ( + registry.stop.is_set() + or current["enrollment"] != "paired" + or current["binding"]["binding_id"] != body["binding_id"] + ): + return + registry.listen(local) + desired = f"https://{local}:8782" + if value["endpoint"] != desired: + request( + connection, + "/v1/channel/migrate", + { + **body, + "expected_endpoint": value["endpoint"], + "expected_revision": value["endpoint_revision"], + "endpoint": desired, + }, + ) + # Only a subsequent authenticated heartbeat proves the new endpoint. + # Lost migration acknowledgements are resolved by inspect, never replay. + finally: + connection.close() + + +def reconcile(registry): + attempts = {} + context, refresh = None, 0 + while not registry.stop.is_set(): + with registry.lock: + rows = [r for r in registry.rows() if r["enrollment"] == "paired"] + attempts = {key: value for key, value in attempts.items() if key in {r["id"] for r in rows}} + for row in rows: + if registry.stop.is_set(): + return + now = time.monotonic() + if now < attempts.get(row["id"], 0): + continue + attempts[row["id"]] = now + 30 + if tailnet_address(row["core_address"]) and (row.get("last_seen") or 0) >= max( + registry.started_at, time.time() - 20 + ): + continue + for address in node_addresses(row): + try: + if context is None or now >= refresh: + context, refresh = client_context(registry.trust), now + 12 * 3600 + recover(registry, row, address, context) + break + except Exception: + # Bounded retry, no untrusted peer data or private addresses in logs. + # The ordinary heartbeat remains the connectivity authority. + continue + registry.stop.wait(2) diff --git a/src/k1link/fleet/registry.py b/src/k1link/fleet/registry.py index 04fdda9..ded58d9 100644 --- a/src/k1link/fleet/registry.py +++ b/src/k1link/fleet/registry.py @@ -54,6 +54,7 @@ class FleetRegistry: self.listeners: dict[str, object] = {} self.stop = threading.Event() self.worker: threading.Thread | None = None + self.recovery_worker: threading.Thread | None = None def rows(self): return [ @@ -86,11 +87,22 @@ class FleetRegistry: target=self.reconcile, name="mission-core-fleet", daemon=True ) self.worker.start() + from .recovery import reconcile as recover_channels + + self.recovery_worker = threading.Thread( + target=recover_channels, + args=(self,), + name="mission-core-channel-recovery", + daemon=True, + ) + self.recovery_worker.start() def close(self): self.stop.set() if self.worker: self.worker.join(timeout=20) + if self.recovery_worker: + self.recovery_worker.join(timeout=30) for server in self.listeners.values(): server.shutdown() server.server_close() @@ -341,7 +353,7 @@ class FleetRegistry: self.save(row) self.stop.wait(2) - def receive(self, certificate: bytes, path: str, value: dict): + def receive(self, certificate: bytes, path: str, value: dict, *, address: str | None = None): cert = x509.load_der_x509_certificate(certificate) key = cert.public_key() if not isinstance(key, Ed25519PublicKey): @@ -380,6 +392,25 @@ class FleetRegistry: return 200, {"ok": True} if path != "/v1/node/heartbeat": return 404, {"error": "Unknown operation"} + # Endpoint revisions prevent an old in-flight heartbeat or lost ack + # from reversing a migration. Use the actual listener, not a claimed IP. + from .recovery import tailnet_address + + revision = value.get("endpoint_revision", 0) + if type(revision) is not int or not 0 <= revision <= 1000000000: + return 400, {"error": "Invalid endpoint revision"} + previous_revision = row["binding"].get("endpoint_revision", 0) + if revision < previous_revision: + return 409, {"error": "Endpoint superseded"} + if revision > previous_revision: + if ( + not tailnet_address(address) + or value.get("core_endpoint") != f"https://{address}:8782" + ): + return 400, {"error": "Endpoint does not match authenticated channel"} + row["core_address"] = address + row["binding"]["endpoint"] = value["core_endpoint"] + row["binding"]["endpoint_revision"] = revision binding = ExecutionBinding.model_validate(value["execution_binding"]) if binding.node_id != node_id or binding.platform.value != "linux": return 400, {"error": "Invalid Node inventory"} @@ -417,4 +448,10 @@ class FleetRegistry: except (ValueError, TypeError, KeyError, sqlite3.Error): # Telemetry persistence failure must not block device control. pass - return 200, {"monitor_ack": monitor_ack, "ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response} + return 200, { + "monitor_ack": monitor_ack, + "ok": True, + "client_pem": row["binding"]["client_pem"], + **sensor_response, + **enrollment_response, + } diff --git a/src/k1link/fleet/sensors.py b/src/k1link/fleet/sensors.py index 2b56a8b..37cc3ab 100644 --- a/src/k1link/fleet/sensors.py +++ b/src/k1link/fleet/sensors.py @@ -20,12 +20,22 @@ ACTIONS = { "option", "offer", "close-peer", + "preview.start", + "preview.stop", + "record.start", + "record.stop", + "photo.capture", + "settings.read", + "settings.apply", + "files.list", } +MAX_INVENTORY_ITEMS = 500 +MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024 def validate_inventory(value, node_id): items = value.get("devices", []) - if not isinstance(items, list) or len(items) > 16: + if not isinstance(items, list) or len(items) > MAX_INVENTORY_ITEMS: raise ValueError("Invalid device inventory") seen = set() for item in items: @@ -38,7 +48,11 @@ def validate_inventory(value, node_id): raise ValueError("Invalid device execution binding") seen.add(item["id"]) state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None}) - if state.get("items") != items or len(json.dumps(state)) > 262144: + if ( + not isinstance(state, dict) + or state.get("items") != items + or len(json.dumps(state, ensure_ascii=False).encode()) > MAX_SENSOR_STATE_BYTES + ): raise ValueError("Invalid sensor state") return state diff --git a/src/k1link/fleet/transport.py b/src/k1link/fleet/transport.py index ef0e4c0..ad51aa0 100644 --- a/src/k1link/fleet/transport.py +++ b/src/k1link/fleet/transport.py @@ -57,14 +57,19 @@ class NodeChannelHandler(BaseHTTPRequestHandler): if ( self.headers.get("Content-Type") != "application/json" or self.headers.get("Origin") - or not 0 < size <= 1048576 + # A bounded 500-camera summary is carried both in devices and + # sensor_state for the existing paired heartbeat contract. + or not 0 < size <= 8 * 1024 * 1024 ): raise ValueError value = json.loads(self.rfile.read(size)) if not isinstance(value, dict): raise ValueError status, data = self.server.registry.receive( - self.connection.getpeercert(binary_form=True), self.path, value + self.connection.getpeercert(binary_form=True), + self.path, + value, + address=self.server.address, ) except (ValueError, KeyError, TypeError): self.close_connection = True diff --git a/src/k1link/fleet/trust.py b/src/k1link/fleet/trust.py index 20a19f8..e8a695d 100644 --- a/src/k1link/fleet/trust.py +++ b/src/k1link/fleet/trust.py @@ -206,6 +206,20 @@ class CoreTrust: return context +def verify_node_certificate(raw: bytes, node_id: str) -> Ed25519PublicKey: + cert = x509.load_der_x509_certificate(raw) + key = cert.public_key() + now = datetime.now(UTC) + if ( + not isinstance(key, Ed25519PublicKey) + or public_id("node_", key) != node_id + or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc + ): + raise PairingError("Идентичность БК не совпала с сохранённой.") + key.verify(cert.signature, cert.tbs_certificate_bytes) + return key + + def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25519PublicKey, str]: address, port = endpoint(invitation["endpoint"], 8781) # The one-use secret is sent only AFTER authenticating the Node identity. @@ -217,16 +231,9 @@ def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25 connection = http.client.HTTPSConnection(address, port, timeout=5, context=context) try: connection.connect() - cert = x509.load_der_x509_certificate(connection.sock.getpeercert(binary_form=True)) - key = cert.public_key() - now = datetime.now(UTC) - if ( - not isinstance(key, Ed25519PublicKey) - or public_id("node_", key) != invitation["node_id"] - or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc - ): - raise PairingError("Идентичность БК не совпала с приглашением.") - key.verify(cert.signature, cert.tbs_certificate_bytes) + key = verify_node_certificate( + connection.sock.getpeercert(binary_form=True), invitation["node_id"] + ) local = connection.sock.getsockname()[0] if not private_address(local): raise PairingError("Core не получил частный обратный адрес.") diff --git a/tests/fleet/test_pairing.py b/tests/fleet/test_pairing.py index 93a7339..5e9ea29 100644 --- a/tests/fleet/test_pairing.py +++ b/tests/fleet/test_pairing.py @@ -85,6 +85,148 @@ def cert(row): ) +def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup): + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + old = fleet.find(public["id"]) + payload = { + **heartbeat(old), + "core_endpoint": "https://100.64.20.5:8782", + "endpoint_revision": 1, + } + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="192.168.20.5")[0] == 400 + assert fleet.find(public["id"])["binding"] == old["binding"] + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="100.64.20.5")[0] == 200 + current = fleet.find(public["id"]) + assert current["core_address"] == "100.64.20.5" + assert current["binding"]["binding_id"] == old["binding"]["binding_id"] + assert current["binding"]["client_pem"] == old["binding"]["client_pem"] + assert ( + fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old), address="192.168.20.5")[0] + == 409 + ) + assert fleet.find(public["id"])["binding"] == current["binding"] + fleet.revoke(public["id"]) + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="100.64.20.5")[0] == 410 + + +def test_recovery_only_uses_previously_reported_tailnet_addresses(): + from k1link.fleet.recovery import node_addresses + + assert node_addresses( + { + "inventory": { + "networks": [ + { + "up": True, + "addresses": ["192.168.20.4/24", "8.8.8.8", "100.64.20.4/32", "::1"], + }, + {"up": False, "addresses": ["100.64.1.1/32"]}, + ] + } + } + ) == ["100.64.20.4"] + + +def test_recovery_client_leaf_is_distinct_from_issuer_with_same_pinned_key(setup): + from cryptography.x509.oid import ExtendedKeyUsageOID + + from k1link.fleet.recovery import client_context + + fleet, _, _, _ = setup + client_context(fleet.trust) + leaf, root = x509.load_pem_x509_certificates((fleet.root / "recovery-client.pem").read_bytes()) + assert leaf.subject != root.subject + assert leaf.issuer == root.subject + assert leaf.public_key().public_bytes_raw() == root.public_key().public_bytes_raw() + assert public_id("core_", leaf.public_key()) == fleet.trust.core_id + assert ( + ExtendedKeyUsageOID.CLIENT_AUTH + in leaf.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + ) + leaf.verify_directly_issued_by(root) + + +def test_recovery_pins_node_before_application_request(setup, monkeypatch): + from k1link.fleet import recovery + + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + row = fleet.find(public["id"]) + row["inventory"] = {"networks": [{"up": True, "addresses": ["100.64.20.4/32"]}]} + fleet.save(row) + calls = [] + + class Connection: + sock = None + + def connect(self): + self.sock = self + + def getpeercert(self, **kwargs): + return b"not the saved Node certificate" + + def close(self): + calls.append("closed") + + monkeypatch.setattr(recovery.http.client, "HTTPSConnection", lambda *a, **kw: Connection()) + monkeypatch.setattr(recovery, "request", lambda *a: calls.append("request")) + with pytest.raises(ValueError): + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["closed"] + + +def test_recovery_rechecks_revocation_and_waits_for_heartbeat(setup, monkeypatch): + from k1link.fleet import recovery + + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + row = fleet.find(public["id"]) + row["inventory"] = {"networks": [{"up": True, "addresses": ["100.64.20.4/32"]}]} + fleet.save(row) + calls = [] + + class Connection: + def connect(self): + self.sock = self + + def getpeercert(self, **kwargs): + return b"verified below by test adapter" + + def getsockname(self): + return ("100.64.20.5", 30000) + + def close(self): + pass + + monkeypatch.setattr(recovery.http.client, "HTTPSConnection", lambda *a, **kw: Connection()) + monkeypatch.setattr(recovery, "verify_node_certificate", lambda *a: None) + + def request(connection, path, body): + calls.append(path) + return { + "schema": recovery.SCHEMA, + "node_id": row["node_id"], + "core_id": fleet.trust.core_id, + "binding_id": row["binding"]["binding_id"], + "endpoint": row["binding"]["endpoint"], + "endpoint_revision": 0, + } + + monkeypatch.setattr(recovery, "request", request) + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["/v1/channel/inspect", "/v1/channel/migrate"] + assert fleet.find(public["id"])["binding"] == row["binding"] + assert fleet.public(fleet.find(public["id"]))["connectivity"] == "offline" + calls.clear() + fleet.revoke(public["id"]) + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["/v1/channel/inspect"] + + def test_preview_add_is_durable_and_idempotent(setup): fleet, invite, _, calls = setup public, preview = create(setup) @@ -386,3 +528,53 @@ def test_sensor_cannot_claim_another_board_identity(setup): value["devices"][0]["snapshot"]["context"]["execution"]["node_id"] = "node_wrong" with pytest.raises(ValueError): validate_inventory(value, row["node_id"]) + + +def test_uninitialized_x4_can_be_prepared_remotely_with_instance_progress(setup): + from k1link.fleet import sensors + + public, _ = create(setup) + fleet = setup[0] + row = fleet.find(public["id"]) + inventory = sensor_inventory(row) + item = inventory["devices"][0] + item.update(id="instax4_" + "a" * 32, kind="insta360.x4", prepared=False, configured=False) + item["snapshot"]["context"]["device"].update( + device_id=item["id"], + model={ + "plugin_id": "missioncore.insta360", + "plugin_version": "0.1.0", + "model_id": "insta360.x4", + }, + ) + assert fleet.receive(cert(row), "/v1/node/heartbeat", inventory)[0] == 200 + now = datetime.now(UTC) + command = { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": "op_" + "b" * 32, + "idempotency_key": "op_" + "b" * 32, + "session": {"device_id": item["id"], "session_id": "sensor_test"}, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=350)).isoformat(), + "action_id": "prepare", + "parameters": {}, + } + assert sensors.submit(fleet, row["id"], command)["state"] == "queued" + _, result = fleet.receive(cert(row), "/v1/node/heartbeat", inventory) + assert result["sensor_commands"] == [command] + progress = { + "operation_id": command["operation_id"], + "device_id": item["id"], + "model_id": "insta360.x4", + "phase": "verify", + "state": "running", + "steps": [{"id": "profile", "state": "complete"}, {"id": "verify", "state": "running"}], + } + inventory["sensor_state"]["preparations"] = [progress] + inventory["sensor_results"] = [ + {"command": command, "state": "running", "preparation": progress} + ] + assert fleet.receive(cert(row), "/v1/node/heartbeat", inventory)[0] == 200 + assert sensors.operation(fleet, row["id"], command["operation_id"])["preparation"] == progress + assert fleet.find(row["id"])["sensor_state"]["preparations"] == [progress] diff --git a/tests/test_insta360_control.py b/tests/test_insta360_control.py new file mode 100644 index 0000000..5713464 --- /dev/null +++ b/tests/test_insta360_control.py @@ -0,0 +1,213 @@ +"""Camera command boundaries with a synthetic camera; never load a vendor SDK.""" + +import importlib.util +import json +import sys +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] / "plugins/insta360-x4/runtime" +spec = importlib.util.spec_from_file_location("missioncore_insta360", ROOT / "__init__.py") +package = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = package +spec.loader.exec_module(package) +from missioncore_insta360 import identity, native, operations # noqa: E402 + +ID_A = "instax4_" + "a" * 32 +ID_B = "instax4_" + "b" * 32 + + +def command(action="record.start", *, device=ID_A, session="session_test_a", params=None): + now = datetime.now(UTC) + op = "op_" + uuid.uuid4().hex + return { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": op, + "idempotency_key": op, + "session": {"session_id": session, "device_id": device}, + "action_id": action, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": params or {}, + } + + +class Camera: + def __init__(self): + self.calls = [] + self.answer = {"state": "complete", "result": {"recording": True}} + self.hook = None + + def call(self, action, params): + self.calls.append((action, params)) + if self.hook: + self.hook() + return self.answer + + +def test_concurrent_remote_and_local_retry_dispatches_once(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command() + entered, release = threading.Event(), threading.Event() + + def inspect_receipt(): + receipt = json.loads((tmp_path / (request["operation_id"] + ".json")).read_text()) + assert receipt["result"]["state"] == "unknown" + entered.set() + assert release.wait(3) + + camera.hook = inspect_receipt + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(executor.execute, request) + assert entered.wait(3) + second = pool.submit(executor.execute, request) + release.set() + assert first.result() == second.result() == camera.answer + assert len(camera.calls) == 1 + + +def test_crash_receipt_survives_restart_without_replaying_recording(tmp_path): + camera = Camera() + + def crash(): + raise SystemExit("synthetic crash after dispatch") + + camera.hook = crash + request = command() + with pytest.raises(SystemExit): + operations.Operations(ID_A, "session_test_a", tmp_path, camera).execute(request) + fresh = Camera() + recovered = operations.Operations(ID_A, "session_after_restart", tmp_path, fresh) + assert recovered.execute(request)["state"] == "unknown" + assert not fresh.calls + + +def test_camera_binding_session_deadline_and_parameters_precede_effects(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + wrong = [ + command(device=ID_B), + command(session="session_old"), + command(params={"force": True}), + command("settings.apply", params={"mode": 7, "key": "library_path", "value": 1}), + command("settings.apply", params={"mode": 7, "key": "iso", "value": True}), + ] + expired = command() + expired["requested_at"] = (datetime.now(UTC) - timedelta(minutes=2)).isoformat() + expired["deadline_at"] = (datetime.now(UTC) - timedelta(minutes=1)).isoformat() + wrong.append(expired) + for request in wrong: + with pytest.raises(ValueError): + executor.execute(request) + assert not camera.calls + assert not list(tmp_path.iterdir()) + + +def test_operation_identity_cannot_be_reused_for_another_effect(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command() + executor.execute(request) + request["action_id"] = "record.stop" + with pytest.raises(ValueError, match="already in use"): + executor.execute(request) + assert len(camera.calls) == 1 + + +def test_two_instances_keep_receipts_and_recording_independent(tmp_path): + a, b = Camera(), Camera() + first = operations.Operations(ID_A, "session_test_a", tmp_path / "a", a) + second = operations.Operations(ID_B, "session_test_b", tmp_path / "b", b) + first.execute(command()) + second.execute(command("preview.stop", device=ID_B, session="session_test_b")) + assert [action for action, _ in a.calls] == ["record.start"] + assert [action for action, _ in b.calls] == ["preview.stop"] + + +def test_setting_acknowledgement_requires_camera_readback(tmp_path): + camera = Camera() + camera.answer = {"state": "complete", "result": {"values": {"white_balance": 0}}} + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command("settings.apply", params={"mode": 7, "key": "white_balance", "value": 5000}) + assert executor.execute(request)["state"] == "unknown" + camera.answer = {"state": "complete", "result": {"values": {"white_balance": 5000}}} + # The old command remains uncertain. Only a new explicit command may run. + assert executor.execute(request)["state"] == "unknown" + assert len(camera.calls) == 1 + assert ( + executor.execute(command("settings.apply", params=request["parameters"]))["state"] + == "complete" + ) + + +def test_failed_journal_prevents_sdk_dispatch(tmp_path, monkeypatch): + camera = Camera() + + def full(*_): + raise OSError("synthetic disk full") + + monkeypatch.setattr(operations, "atomic", full) + with pytest.raises(OSError): + operations.Operations(ID_A, "session_test_a", tmp_path, camera).execute(command()) + assert not camera.calls + + +def test_model_binding_uses_product_and_tracks_usb_replug(tmp_path): + usb = tmp_path / "1-2.3" + usb.mkdir() + values = { + "idVendor": "2e1a", + "idProduct": "0002", + "product": "Insta360 X4", + "serial": "SYNTHETIC-X4-A", + "busnum": "1", + "devnum": "7", + } + for key, value in values.items(): + (usb / key).write_text(value) + first = identity.read_binding("1-2.3", tmp_path) + (usb / "devnum").write_text("8") + second = identity.read_binding("1-2.3", tmp_path) + assert first.device_id == second.device_id and first != second + assert second.device_path == "/dev/bus/usb/001/008" + (usb / "product").write_text("Insta360 ONE R") + with pytest.raises(ValueError, match="model"): + identity.read_binding("1-2.3", tmp_path) + with pytest.raises(ValueError, match="binding"): + identity.read_binding("../1-2.3", tmp_path) + + +def test_sdk_library_is_not_loaded_before_isolation_check(monkeypatch): + calls = [] + + def reject(_): + raise RuntimeError("synthetic namespace refusal") + + monkeypatch.setattr(native, "verify_isolation", reject) + monkeypatch.setattr(native.ctypes, "CDLL", lambda value: calls.append(value)) + with pytest.raises(RuntimeError, match="namespace"): + native.NativeCamera(object(), "/unused/library.so", "/unused/logs") + assert not calls + + +def test_video_queue_can_drain_while_a_camera_command_is_blocked(): + # Construct only the Python transport with a fake C ABI. No isolation + # override or vendor library is used by the real NativeCamera constructor. + camera = native.NativeCamera.__new__(native.NativeCamera) + camera.lock, camera.video_lock = threading.RLock(), threading.Lock() + camera.handle, camera.buffer = 1, object() + + class API: + def mc_x4_read_video(self, *_): + return 0 + + camera.api = API() + with ThreadPoolExecutor(max_workers=1) as pool, camera.lock: + assert pool.submit(camera.video).result(timeout=2) is None diff --git a/tests/test_insta360_native.py b/tests/test_insta360_native.py new file mode 100644 index 0000000..9abec28 --- /dev/null +++ b/tests/test_insta360_native.py @@ -0,0 +1,12 @@ +"""Collect the same native checks shipped in the Ubuntu build artifact.""" + +import importlib.util +import sys +from pathlib import Path + +path = Path(__file__).resolve().parents[1] / "plugins/insta360-x4/native/tests/check_abi.py" +spec = importlib.util.spec_from_file_location("missioncore_x4_abi_tests", path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +TestNativeBridge = module.TestNativeBridge diff --git a/tests/test_insta360_sdk_source.py b/tests/test_insta360_sdk_source.py new file mode 100644 index 0000000..11899b8 --- /dev/null +++ b/tests/test_insta360_sdk_source.py @@ -0,0 +1,91 @@ +"""The build must fail before using corrupt, wrong-platform or partial SDK bytes.""" + +import hashlib +import importlib.util +import json +import struct +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "x4_sdk_source", ROOT / "plugins/insta360-x4/packaging/fetch_sdk.py" +) +sdk = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(sdk) + + +def elf(machine=62): + strings = b"\0libc.so.6\0" + table_end = 64 + 3 * 64 + data = bytearray(table_end) + data[:7] = b"\x7fELF\x02\x01\x01" + struct.pack_into("