Add packaged Insta360 X4 integration and recover paired Node channels

Discover independent camera instances and prepare their versioned runtime
from Node or remote Core. Add isolated SDK workers, camera controls, raw
dual-fisheye WebRTC preview, and shared action/region loading states.

Recover existing Node bindings over known Tailscale addresses after a Core
LAN address change. Preserve identities and trust, pin both peers, migrate
endpoints with revision checks, and require real heartbeats for online status.
Fix the Python client certificate profile for Go X509 verification.

Pin Design Guideline 8c53f73 and retain installer/build/acceptance history.
Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending.

Validation: qualified DG/Node builds and Go race tests; 31 fleet tests;
Python-to-Go certificate interoperability and live tailnet recovery with five
fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance.
Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 09:21:24 +03:00
parent 54a85fdf50
commit a3c15e11e9
125 changed files with 11916 additions and 251 deletions
@@ -0,0 +1,19 @@
import {Select,SettingsCard} from '@nodedc/ui-react';
import {type CameraSettings as Settings,resolutionLabel} from './model';
const PHOTO_SIZES:Record<number,string>={9:'2976 × 2976',10:'5984 × 5984',11:'11968 × 5984',12:'5952 × 2976'};
// Pinned CameraSDK 2.1.8 PhotographyOptions_ExposureMode. A choice is shown
// only when this camera's capability response includes the corresponding name.
const EXPOSURE:Record<string,{id:number;label:string}>={AUTO:{id:0,label:'Автоматическая'},ISO_PRIORITY:{id:1,label:'Приоритет ISO'},SHUTTER_PRIORITY:{id:2,label:'Приоритет выдержки'},MANUAL:{id:3,label:'Ручная'},ADAPTIVE:{id:4,label:'Адаптивная'},FULL_AUTO:{id:5,label:'Полностью автоматическая'}};
export function CameraSettings({value,disabled,apply}:{value:Settings;disabled:boolean;apply:(key:string,value:number)=>void}){
const modeChoices=[...(value.video_modes.includes(7)?[{value:'7',label:'Видео'}]:[]),...(value.photo_modes.includes(6)?[{value:'6',label:'Фото'}]:[])];
const exposure=(value.attributes.exposure_program?.values??[]).flatMap(name=>EXPOSURE[name]?[{value:String(EXPOSURE[name].id),label:EXPOSURE[name].label}]:[]);
const whiteBalance=(value.attributes.white_balance?.values??[]).filter(item=>/^\d+$/.test(item)).map(item=>({value:item,label:Number(item)===0?'Автоматический':`${item} K`}));
return <SettingsCard title="Настройки камеры"><div className="sensor-fields">
<Select label="Режим съёмки" value={String(value.mode)} options={modeChoices} disabled={disabled} onChange={next=>apply('function_mode',Number(next))}/>
{value.mode===7&&<Select label="Разрешение и частота записи" value={String(value.values.video_resolution)} options={value.video_resolutions.map(item=>({value:String(item.id),label:resolutionLabel(item.name)}))} disabled={disabled} onChange={next=>apply('video_resolution',Number(next))}/>}
{value.mode===6&&<Select label="Размер фотографии" value={String(value.values.photo_size)} options={value.photo_sizes.filter(size=>PHOTO_SIZES[size]).map(size=>({value:String(size),label:PHOTO_SIZES[size]}))} disabled={disabled} onChange={next=>apply('photo_size',Number(next))}/>}
{exposure.length>0&&<Select label="Экспозиция" value={String(value.values.exposure_mode)} options={exposure} disabled={disabled} onChange={next=>apply('exposure_mode',Number(next))}/>}
{whiteBalance.length>0&&<Select label="Баланс белого" value={String(value.values.white_balance)} options={whiteBalance} disabled={disabled} onChange={next=>apply('white_balance',Number(next))}/>}
</div><dl className="sensor-facts"><div><dt>ISO</dt><dd>{value.values.iso||'Авто'}</dd></div><div><dt>Выдержка</dt><dd>{value.values.shutter_seconds>0?`${value.values.shutter_seconds.toFixed(4)} с`:'Авто'}</dd></div></dl></SettingsCard>;
}
@@ -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<Settings|null>(null);
const [files,setFiles]=useState<CameraFiles|null>(null);
const [pending,setPending]=useState<string|null>(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<Settings>(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<string,unknown>={},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<unknown>(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 <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
<SettingsCard title={device.name} description={`${device.model}${device.firmware?' · '+device.firmware:''}`} actions={<IconButton label="Обновить состояние камеры" loading={pending==='refresh'} disabled={!enabled||busy} onClick={()=>void action('refresh')}><Icon name="refresh"/></IconButton>}>
{!available&&<p>Нет свежей связи с камерой.</p>}
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!available||busy||status.preview===0} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
{status.alerts?.temperature_high&&<p>Камера сообщает о перегреве.</p>}
{status.alerts?.storage_full&&<p>На карте камеры закончилось место.</p>}
{status.alerts?.battery_low&&<p>Камера сообщает о низком заряде.</p>}
</SettingsCard>
<LiveViewport device={viewDevice} layer="preview" title="Изображение X4" note="Запись сохраняется на карте памяти камеры." inactiveMessage="Нажмите «Начать просмотр», чтобы получить изображение." transport={transport} failure={failure}/>
<SettingsCard title="Съёмка" description="Фото и видеозапись сохраняются на карте памяти X4." actions={<StatusBadge tone={available&&status.recording===1?'success':status.recording===0?'neutral':'warning'}>{available?recordLabel:'Состояние недоступно'}</StatusBadge>}>
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
{available&&status.recording===1&&<p>Запись продолжится после закрытия просмотра.</p>}
</SettingsCard>
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применяем настройку':'Получаем настройки камеры'}>
{settings?<CameraSettings value={settings} disabled={!available||busy||status.recording!==0||status.preview!==0} apply={(key,value)=>void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?<p>Настройки недоступны. Обновите состояние камеры.</p>:null}
</LoadingRegion>
<SettingsCard title="Файлы на камере" actions={<Button disabled={!available||busy} loading={pending==='files.refresh'} onClick={()=>void action('files.list',{offset:0},'files.refresh')}>Обновить список</Button>}>
{files?files.items.length?<><ResourceList aria-label="Файлы на карте X4">{files.items.map((item,index)=><li key={`${files.offset+index}:${item}`}><ResourceRow title={fileName(item)} icon={<Icon name="file"/>}/></li>)}</ResourceList><div className="sensor-actions"><Button disabled={!available||busy||files.offset===0} loading={pending==='files.previous'} onClick={()=>void action('files.list',{offset:Math.max(0,files.offset-32)},'files.previous')}>Назад</Button><span>{files.offset+1}{files.offset+files.items.length} из {files.total}</span><Button disabled={!available||busy||files.offset+files.items.length>=files.total} loading={pending==='files.next'} onClick={()=>void action('files.list',{offset:files.offset+files.items.length},'files.next')}>Далее</Button></div></>:<p>Файлов нет.</p>:<p>Обновите список, чтобы увидеть записи на карте камеры.</p>}
</SettingsCard>
</div>;
}
+41
View File
@@ -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<string,number>;
attributes:Record<string,{values:string[];depends_on:string[]}>;
}
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;}
}
@@ -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,
};
+429
View File
@@ -0,0 +1,429 @@
#include "bridge.h"
#include <camera/camera.h>
#include <camera/device_discovery.h>
#include <stream/stream_delegate.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstring>
#include <deque>
#include <iomanip>
#include <locale>
#include <memory>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
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 <typename T> bool contains(const std::vector<T>& values, T value) {
return std::find(values.begin(), values.end(), value) != values.end();
}
template <typename T> std::string numbers(const std::vector<T>& 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<std::string>& 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<uint8_t> bytes; };
struct Frames final : StreamDelegate {
std::mutex mutex;
std::deque<Packet> queue;
size_t bytes = 0;
uint64_t generation = 0, sequence = 0;
std::atomic<int> codec{0};
std::atomic<bool> enabled{false};
void reset() {
std::lock_guard<std::mutex> 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<std::mutex> 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<GyroData>&) override {}
void OnExposureData(const ExposureData&) override {}
};
struct Notifications {
std::mutex mutex;
std::atomic<int> recording{-1};
std::atomic<uint64_t> revision{0};
std::atomic<int> stopped_reason{-1};
std::atomic<bool> battery_low{false}, storage_full{false}, temperature_high{false};
};
}
struct mc_x4 {
DeviceDiscovery discovery;
std::vector<DeviceDescriptor> descriptors;
std::shared_ptr<Camera> camera;
std::shared_ptr<Frames> frames = std::make_shared<Frames>();
std::shared_ptr<Notifications> notifications = std::make_shared<Notifications>();
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<std::mutex> 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<CameraFunctionMode>(int(value)));
} else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) {
auto resolution = static_cast<VideoResolution>(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<PhotoSize>(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<VideoResolution>(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<PhotographyOptions_ExposureMode>(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<CameraFunctionMode>(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<std::mutex> lock(notifications->mutex);
revision = notifications->revision; notifications->recording = -1;
}
if (!camera->StartRecording()) return UNKNOWN;
{
std::lock_guard<std::mutex> 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<std::mutex> lock(notifications->mutex);
revision = notifications->revision; notifications->recording = -1;
}
auto media = camera->StopRecording();
if (media.Empty()) return UNKNOWN;
{
std::lock_guard<std::mutex> 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<std::string> 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<mc_x4>();
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<Camera>(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<std::mutex> lock(n->mutex);
n->recording = active ? 1 : 0; ++n->revision;
});
handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) {
std::lock_guard<std::mutex> 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<StreamDelegate> 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<std::mutex> 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 (...) {} }
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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
@@ -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()
@@ -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 <camera/camera.h>
#include <camera/device_discovery.h>
#include <algorithm>
#include <stdexcept>
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<ins_camera::StreamDelegate> delegate;
ins_camera::CaptureStateCallBack capture_callback;
std::shared_ptr<ins_camera::CaptureSettings> capture;
std::shared_ptr<ins_camera::ExposureSettings> 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<CameraFunctionMode>(function_mode); }
VideoEncodeType Camera::GetVideoEncodeType() const { return VideoEncodeType::H264; }
void Camera::SetStreamDelegate(std::shared_ptr<StreamDelegate>& 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<std::string> Camera::GetCameraFilesList() const {
std::vector<std::string> out;
for (int i = 0; i < 65; ++i) out.push_back("/DCIM/synthetic_" + std::to_string(i) + ".insv");
return out;
}
std::vector<DeviceDescriptor> DeviceDiscovery::GetAvailableDevices() {
std::vector<DeviceDescriptor> 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<DeviceDescriptor>) { ++freed; }
MediaUrl::MediaUrl(const std::vector<std::string>& origins, const std::vector<std::string>& proxies): uris_(origins), lrv_uris_(proxies) {}
bool MediaUrl::Empty() const { return uris_.empty(); }
const std::vector<std::string>& MediaUrl::OriginUrls() const { return uris_; }
bool Camera::SyncPhotographyOptions(CameraFunctionMode) { return connected; }
std::shared_ptr<CaptureSettings> Camera::GetCaptureSettings(CameraFunctionMode) const { return capture; }
std::shared_ptr<ExposureSettings> Camera::GetExposureSettings(CameraFunctionMode) const { return exposure; }
bool Camera::SetCaptureSettings(CameraFunctionMode, std::shared_ptr<CaptureSettings> value) { ++writes; capture = value; return true; }
bool Camera::SetExposureSettings(CameraFunctionMode, const std::shared_ptr<ExposureSettings>& 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<CameraFunctionMode> Camera::GetSupportedVideoModes() const { return {FUNCTION_MODE_NORMAL_VIDEO}; }
std::vector<CameraFunctionMode> Camera::GetSupportedPhotoModes() const { return {FUNCTION_MODE_NORMAL_IMAGE}; }
std::vector<PhotoSize> Camera::GetSupportedPhotoSizes(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_IMAGE ? std::vector<PhotoSize>{Size_5952_2976} : std::vector<PhotoSize>{}; }
std::vector<VideoResolution> Camera::GetSupportedVideoResolutions(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_VIDEO ? std::vector<VideoResolution>{RES_3840_1920P30, RES_1920_960P30} : std::vector<VideoResolution>{}; }
std::string Camera::GetVideoResolutionName(VideoResolution value) const { return value == RES_1920_960P30 ? "1920_960_30" : "3840_1920_30"; }
std::vector<std::string> Camera::GetSupportedAttrNames(CameraFunctionMode) const { return {"white_balance", "exposure_iso", "exposure_program"}; }
std::vector<std::string> Camera::GetAttrDependOn(CameraFunctionMode, const std::string& name) const { return name == "exposure_iso" ? std::vector<std::string>{"record_resolution"} : std::vector<std::string>{}; }
std::vector<std::string> 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<std::string>{"100", "6400"} : std::vector<std::string>{"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<ExposureSettingsPrivate>()) {}
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<ins_camera::CaptureSettings>(); exposure = std::make_shared<ins_camera::ExposureSettings>();
}
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<uint8_t> 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
}
}
@@ -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;
}
});
@@ -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"
@@ -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()
+230
View File
@@ -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 <noreply@example.invalid>
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)))
@@ -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))
@@ -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")
@@ -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)))
@@ -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()))
@@ -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()))
@@ -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)
+146
View File
@@ -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("<H", data, 18)[0] != 62
):
raise ValueError("SDK is not Linux x86_64 ELF64")
offset = struct.unpack_from("<Q", data, 40)[0]
size, count = struct.unpack_from("<HH", data, 58)
if size != 64 or not 0 < count < 4096 or offset + count * size > len(data):
raise ValueError("Invalid ELF section table")
sections = [struct.unpack_from("<IIQQQQIIQQ", data, offset + i * size) for i in range(count)]
needed = []
for section in sections:
if section[1] != 6: # SHT_DYNAMIC
continue
if section[6] >= 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("<qQ", data, at)
if tag == 1: # DT_NEEDED
end = table.find(b"\0", value)
if end < value:
raise ValueError("Invalid ELF dependency name")
needed.append(table[value:end].decode("ascii"))
versions = sorted(
set(x.decode() for x in re.findall(rb"(?:GLIBCXX|GLIBC|CXXABI)_[0-9.]+", data))
)
return {"architecture": "linux-x86_64", "needed": sorted(needed), "abi_versions": versions}
def verify(folder, lock):
for entry in entries(lock):
path = folder / entry["path"]
if any(p.is_symlink() for p in (path, *path.parents)):
raise ValueError("SDK payload cannot contain symlinks")
verified_bytes(path.read_bytes(), entry)
return inspect_elf(folder / "lib/libCameraSDK.so")
def fetch(destination, source=None):
lock = json.loads(LOCK.read_text())
if destination.exists():
return verify(destination, lock)
destination.parent.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix=".sdk-", dir=destination.parent))
try:
for entry in entries(lock):
if source:
path = source / entry["path"]
if path.is_symlink():
raise ValueError("SDK source cannot be a symlink")
data = path.read_bytes()
else:
host = (
"https://media.githubusercontent.com/media/"
if entry["lfs"]
else "https://raw.githubusercontent.com/"
)
repo = lock["repository"].removeprefix("https://github.com/")
url = host + "/".join((repo, lock["commit"], lock["directory"], entry["path"]))
with urllib.request.urlopen(url, timeout=45) as response:
data = response.read(entry["bytes"] + 1)
target = stage / entry["path"]
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(verified_bytes(data, entry))
target.chmod(0o644)
report = verify(stage, lock)
stage.rename(destination)
return report
finally:
if stage.exists():
shutil.rmtree(stage)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--destination", type=Path, default=ROOT / "build/sdk")
parser.add_argument(
"--source", type=Path, help="Import an already acquired SDK using the same lock"
)
parser.add_argument(
"--check", action="store_true", help="Verify existing payload without network access"
)
args = parser.parse_args()
result = (
verify(args.destination, json.loads(LOCK.read_text()))
if args.check
else fetch(args.destination, args.source)
)
print(json.dumps(result, indent=2))
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
umask 077
mc_x4_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if [ ! -t 0 ]; then
exec /usr/bin/gnome-terminal --wait --title="Mission Core · Insta360 X4" -- "$mc_x4_release_dir/install"
fi
printf '%s\n' 'Mission Core · Insta360 X4' 'Установка драйвера и проверка подключения SDK на этом Ubuntu-компьютере.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
set +e
/usr/bin/sudo /usr/bin/python3 -I "$mc_x4_release_dir/install_release.py" 2>&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"
@@ -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)
+60
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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()
@@ -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))
+16
View File
@@ -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
+11
View File
@@ -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.
+5
View File
@@ -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
+231
View File
@@ -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)
+12
View File
@@ -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
@@ -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
}
]
}
+114
View File
@@ -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
}
]
}
+157
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Mission Core's private Insta360 X4 worker implementation."""
+270
View File
@@ -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)
+12
View File
@@ -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}
+191
View File
@@ -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)
+145
View File
@@ -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)
+85
View File
@@ -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")
+24
View File
@@ -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
+394
View File
@@ -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
+179
View File
@@ -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
+105
View File
@@ -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"]
+114
View File
@@ -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
+154
View File
@@ -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)
@@ -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)
+413
View File
@@ -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)
+164
View File
@@ -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()
+349
View File
@@ -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)