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:
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user