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,
};