Accept systemd credential delivery and clarify manual K1 control
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {before,after,test} from 'node:test';
|
||||||
|
import {createElement} from 'react';
|
||||||
|
import {renderToStaticMarkup} from 'react-dom/server';
|
||||||
|
import {createServer} from 'vite';
|
||||||
|
|
||||||
|
let server,Detail,presentation,enrolledDevice;
|
||||||
|
before(async()=>{
|
||||||
|
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||||
|
({K1Detail:Detail}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/K1Detail.tsx'));
|
||||||
|
presentation=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/presentation.ts');
|
||||||
|
({enrolledDevice}=await server.ssrLoadModule('../../packages/sensor-ui/src/contracts.ts'));
|
||||||
|
});
|
||||||
|
after(async()=>{await server?.close();});
|
||||||
|
const device={id:'synthetic',kind:'k1',name:'K1 test',online:true,verified:true,prepared:true,
|
||||||
|
control:{can_start:true,can_stop:false,can_verify:true,generation:1,revision:1,acquisition_id:null},
|
||||||
|
snapshot:{context:{session_id:'exact-session',execution:{node_id:'test'}},acquisition:'idle'}};
|
||||||
|
const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{device:value,enabled,
|
||||||
|
transport:{},back:()=>{},refresh:async()=>{},failure:()=>{},createRerunHost:()=>assert.fail('SSR must not start a viewer or hardware')}));
|
||||||
|
|
||||||
|
test('manual K1 surface admits one START only after current control proof',()=>{
|
||||||
|
const markup=render(device);
|
||||||
|
assert.match(markup,/Инициировать запуск/);
|
||||||
|
assert.match(markup,/Настройки устройства/);
|
||||||
|
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
|
||||||
|
const waiting={...device,online:false,verified:false,control:{...device.control,can_start:false,network_applied:true,reason_code:'application_authority_unavailable'}};
|
||||||
|
const pending=render(waiting);
|
||||||
|
assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
|
||||||
|
assert.match(pending,/Проверить состояние K1/);
|
||||||
|
assert.match(pending,/авторизовать/);
|
||||||
|
assert.equal(presentation.k1Status(waiting,true).label,'Wi-Fi настроен · нет управления');
|
||||||
|
assert.equal(presentation.k1ManualState(device,false).canStart,false);
|
||||||
|
});
|
||||||
|
test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||||
|
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
|
||||||
|
const markup=render(active);
|
||||||
|
assert.match(markup,/Остановить устройство/);
|
||||||
|
assert.match(markup,/sensor-live-layout/);
|
||||||
|
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
||||||
|
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
||||||
|
assert.equal(presentation.k1ManualState(active,false).showStop,true);
|
||||||
|
});
|
||||||
|
test('enrollment handoff opens only the verified current session',()=>{
|
||||||
|
const inventory={fresh:true,items:[device],operations:[]};
|
||||||
|
assert.equal(enrolledDevice(inventory,'exact-session'),device);
|
||||||
|
assert.equal(enrolledDevice(inventory,'other-session'),null);
|
||||||
|
assert.equal(enrolledDevice({...inventory,fresh:false},'exact-session'),null);
|
||||||
|
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
|
||||||
|
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.6"
|
VERSION = "0.8.7"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
from debian import package
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Systemd authority and manual K1 control R9
|
||||||
|
|
||||||
|
The owner's new attempt on installed Node 0.8.6 / K1 0.1.5+private.1 passed
|
||||||
|
Bluetooth provisioning and reported network_applied. DeviceInfo then failed
|
||||||
|
with application_authority_unavailable, before the MQTT transport opened.
|
||||||
|
The inventory row represents the admitted device session and known address;
|
||||||
|
it is not proof of a healthy control channel or permission to issue START.
|
||||||
|
Private screenshots, exact operation and a timestamped/hash manifest are in
|
||||||
|
private/acceptance/k1-node086-20260907-manual-control. Cache clearing for the
|
||||||
|
owner attempt has not been independently confirmed. No agent hardware writes
|
||||||
|
or CLI Bluetooth/MQTT tests were performed.
|
||||||
|
|
||||||
|
## Credential delivery contract
|
||||||
|
|
||||||
|
The installed systemd 255 credential directory is root-owned, mode 0550,
|
||||||
|
with named service-user rx ACL, group permissions empty and other permissions
|
||||||
|
empty. The ordinary SSH user cannot traverse it to inspect the credential
|
||||||
|
leaf, and no permission bypass was attempted.
|
||||||
|
|
||||||
|
[systemd v255 write_credential](https://github.com/systemd/systemd/blob/v255/src/core/exec-credential.c#L150)
|
||||||
|
creates root-owned files and preferentially grants the service UID a read ACL;
|
||||||
|
ownership transfer is a fallback. The ACL mask appears as group-read in stat,
|
||||||
|
as also documented in [systemd issue 29435](https://github.com/systemd/systemd/issues/29435).
|
||||||
|
The previous Linux loader required service ownership and rejected every group
|
||||||
|
mode bit, so it rejected this valid delivery model. A local regression with
|
||||||
|
real bounded file reads and synthetic kernel ownership/mode reproduces that
|
||||||
|
rejection before the fix. The observed directory matches the systemd scheme;
|
||||||
|
successful loading on the actual installed service remains an acceptance gate.
|
||||||
|
|
||||||
|
The loader now opens a non-symlink credential directory and opens the fixed
|
||||||
|
leaf relative to its pinned directory descriptor. Root-owned systemd delivery
|
||||||
|
may use the read ACL mask; service-owned private fallback remains supported.
|
||||||
|
Untrusted ownership, group-write, world permissions, execute bits, symlinks,
|
||||||
|
nonregular files and oversized input are rejected. No chmod, chown, ACL change,
|
||||||
|
new key, network action or alternate secret source is used. The existing key
|
||||||
|
is retained. A cached startup availability boolean reports whether the
|
||||||
|
immutable service credential could be loaded; it grants no device authority.
|
||||||
|
|
||||||
|
## Manual operation surface
|
||||||
|
|
||||||
|
The approved device-detail composition now has one Initiate start action when
|
||||||
|
current control permits it. Settings uses the canonical SettingsCard actions
|
||||||
|
slot at the right. STOP is presented for an active acquisition; initial idle
|
||||||
|
STOP and Refresh viewer buttons are absent. A pending launch indicates progress,
|
||||||
|
and the existing live Rerun view mounts once the acquisition is streaming. If
|
||||||
|
the preview transport fails, its local recovery action is available in that
|
||||||
|
failure state and does not repeat physical START.
|
||||||
|
|
||||||
|
The enrollment completion action is Configure device. The generic host fetches
|
||||||
|
fresh inventory and opens only the uniquely matching verified session; stale,
|
||||||
|
ambiguous and disconnected sessions cannot complete this handoff. The K1
|
||||||
|
contribution supplies its own network/control status and disables the generic
|
||||||
|
rename affordance, which this driver does not implement. A pending Wi-Fi
|
||||||
|
session is labelled Wi-Fi configured / no control and explains authorization
|
||||||
|
failure. Controls remain unavailable before connection proof. A read-only
|
||||||
|
Check K1 state action is available where the backend permits recovery.
|
||||||
|
|
||||||
|
No Rerun profile, live blueprint, playback or LAB configuration changed.
|
||||||
|
The existing plugin control-operation budget, journal identities, physical
|
||||||
|
acceptance and restart/link-recovery fences remain in place. Sensor-operation
|
||||||
|
failures now use the same secret-free causal logger as enrollment failures.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
36 Python checks cover the credential contract, startup availability without
|
||||||
|
control authority, NodeBridge projection/actions and package lifecycle.
|
||||||
|
26 focused UI/architecture checks and all 797 Core frontend tests passed;
|
||||||
|
Core TypeScript and production build passed. The new UI tests exercise the
|
||||||
|
actual detail renderer for waiting, ready and streaming states, and the exact
|
||||||
|
session handoff. Scoped Ruff and diff whitespace checks passed.
|
||||||
|
|
||||||
|
Node 0.8.7 / optional K1 0.1.6 are reserved for this source. Packaging, installed
|
||||||
|
credential loading, DeviceInfo, physical START/STOP and live stream verification
|
||||||
|
must be recorded separately as observed. The connected browser tooling exposes
|
||||||
|
only an empty in-app browser, not the owner's Chrome or its cache controls;
|
||||||
|
fresh-cache physical UI acceptance remains an owner step.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useCallback,useEffect,useState} from 'react';
|
import {useCallback,useEffect,useState} from 'react';
|
||||||
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||||
import {SensorDetail} from './SensorDetail';
|
import {SensorDetail} from './SensorDetail';
|
||||||
import {sensorStatus} from './sensorStatus';
|
import {sensorStatus} from './sensorStatus';
|
||||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||||
@@ -22,6 +22,14 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
||||||
},[transport,enabled,refresh]);
|
},[transport,enabled,refresh]);
|
||||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
||||||
|
async function completeEnrollment(sessionId:string,signal?:AbortSignal){
|
||||||
|
if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.');
|
||||||
|
const value=await transport.inventory();
|
||||||
|
if(signal?.aborted)return;
|
||||||
|
const target=enrolledDevice(value,sessionId);
|
||||||
|
if(!target)throw new Error('Связь с устройством пока не подтверждена. Проверьте его состояние.');
|
||||||
|
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
|
||||||
|
}
|
||||||
const device=inventory?.items.find(v=>v.id===selected);
|
const device=inventory?.items.find(v=>v.id===selected);
|
||||||
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||||
@@ -33,12 +41,12 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||||
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||||
</>}
|
</>}
|
||||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
|
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import {useState} from 'react';
|
|||||||
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||||
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
||||||
|
|
||||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{
|
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
|
||||||
contributions:readonly SensorUiContribution[];
|
contributions:readonly SensorUiContribution[];
|
||||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||||
|
onComplete:SensorEnrollmentProps['onComplete'];
|
||||||
}) {
|
}) {
|
||||||
const [selected,setSelected]=useState('');
|
const [selected,setSelected]=useState('');
|
||||||
const supported=wirelessContributions(contributions);
|
const supported=wirelessContributions(contributions);
|
||||||
@@ -21,5 +22,5 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
|
|||||||
</div>
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} renderWindow={renderWindow}/>:renderWindow({content:null});
|
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
||||||
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||||
export interface Sensor {
|
export interface Sensor {
|
||||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null};
|
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null};
|
||||||
live_settings?: Record<string,unknown>;
|
live_settings?: Record<string,unknown>;
|
||||||
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||||
@@ -13,6 +13,12 @@ export interface SensorInventory {
|
|||||||
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
|
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
|
||||||
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||||
}
|
}
|
||||||
|
export function enrolledDevice(inventory:SensorInventory,sessionId:string):Sensor|null {
|
||||||
|
if(inventory.fresh===false||!sessionId)return null;
|
||||||
|
const matches=inventory.items.filter(item=>item.snapshot.context.session_id===sessionId);
|
||||||
|
const item=matches.length===1?matches[0]:null;
|
||||||
|
return item?.online&&item.verified&&item.prepared?item:null;
|
||||||
|
}
|
||||||
export interface SensorCommand {
|
export interface SensorCommand {
|
||||||
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
|
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
|
||||||
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
|
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface SensorDetailProps {
|
|||||||
}
|
}
|
||||||
export interface SensorEnrollmentProps {
|
export interface SensorEnrollmentProps {
|
||||||
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
|
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
|
||||||
|
onComplete:(sessionId:string,signal?:AbortSignal)=>Promise<void>;
|
||||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||||
}
|
}
|
||||||
export interface SensorUiContribution {
|
export interface SensorUiContribution {
|
||||||
@@ -19,6 +20,8 @@ export interface SensorUiContribution {
|
|||||||
icon:IconName;
|
icon:IconName;
|
||||||
retainOffline:boolean;
|
retainOffline:boolean;
|
||||||
supportsPreparation:boolean;
|
supportsPreparation:boolean;
|
||||||
|
supportsRenaming?:boolean;
|
||||||
|
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk';
|
|||||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
|
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
|
||||||
import {revealEnrollment} from './revealEnrollment';
|
import {revealEnrollment} from './revealEnrollment';
|
||||||
|
|
||||||
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
|
export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWindow}:SensorEnrollmentProps){
|
||||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||||
const [device,setDevice]=useState('');
|
const [device,setDevice]=useState('');
|
||||||
const [ssid,setSSID]=useState('');
|
const [ssid,setSSID]=useState('');
|
||||||
@@ -106,9 +106,19 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function configure(){
|
||||||
|
if(pending||!enrollmentProofCurrent(verified,state,device)||!verified)return;
|
||||||
|
setBusy('opening');setError('');
|
||||||
|
const signal=lifetime.current?.signal;
|
||||||
|
try{await onComplete(verified.session,signal);}
|
||||||
|
catch(cause){if(!signal?.aborted)setError(cause instanceof Error?cause.message:'Не удалось открыть устройство.');}
|
||||||
|
finally{if(!signal?.aborted)setBusy('');}
|
||||||
|
}
|
||||||
|
|
||||||
return renderWindow({busy:pending,
|
return renderWindow({busy:pending,
|
||||||
actions:confirmed?<Button variant="primary" disabled={pending}
|
actions:confirmed?<Button variant="primary" disabled={pending}
|
||||||
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
|
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
|
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
|
||||||
content:<div className="sensor-content" ref={content}>
|
content:<div className="sensor-content" ref={content}>
|
||||||
<SettingsCard title={state?.name||'Бортовой компьютер'}
|
<SettingsCard title={state?.name||'Бортовой компьютер'}
|
||||||
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
|
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import {useEffect,useState} from 'react';
|
import {useEffect,useRef,useState} from 'react';
|
||||||
import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||||
import {K1LiveView} from './K1LiveView';
|
import {K1LiveView} from './K1LiveView';
|
||||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||||
import {K1LiveSettings} from './K1LiveSettings';
|
import {K1LiveSettings} from './K1LiveSettings';
|
||||||
|
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
|
||||||
|
|
||||||
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
||||||
const [busy,setBusy]=useState(false),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
|
const [busy,setBusy]=useState(''),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
|
||||||
|
const running=useRef(false);
|
||||||
const streaming=device.snapshot.acquisition==='streaming';
|
const streaming=device.snapshot.acquisition==='streaming';
|
||||||
|
const manual=k1ManualState(device,enabled),status=k1Status(device,enabled);
|
||||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||||
async function act(action:'start'|'stop'|'verify'){
|
async function act(action:'start'|'stop'|'verify'){
|
||||||
if(busy)return;setBusy(true);failure(null);
|
if(running.current||!enabled)return;
|
||||||
|
running.current=true;setBusy(action);failure(null);
|
||||||
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
||||||
catch(e){failure(e);}finally{setBusy(false);}
|
catch(e){failure(e);}finally{running.current=false;setBusy('');}
|
||||||
}
|
}
|
||||||
return <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
|
return <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={streaming&&enabled?'success':'neutral'}>{streaming?'Живой просмотр':'K1 · Bridge'}</StatusBadge><IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={status.tone}>{status.label}</StatusBadge>{streaming&&<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}</div></div>
|
||||||
<SettingsCard title={device.name} description="Камера и лидар работают на бортовом компьютере. Исходные данные записи сохраняются на БК.">
|
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||||
<div className="sensor-actions"><Button disabled={!enabled||busy||streaming||!device.control?.can_start} onClick={()=>void act('start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!streaming&&!device.control?.can_stop)} onClick={()=>void act('stop')}>Остановить</Button><Button disabled={!enabled||busy} onClick={()=>{void refresh();setGeneration(v=>v+1);}}>Обновить просмотр</Button><IconButton label="Настройки живого просмотра" onClick={()=>setSettings(v=>!v)}><Icon name="settings"/></IconButton></div>
|
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!busy} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||||
|
{manual.connected&&!manual.showStop&&<Button variant="primary" disabled={!!busy||!manual.canStart} aria-busy={busy==='start'}
|
||||||
|
icon={busy==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{busy==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||||
|
{manual.showStop&&<Button disabled={!manual.connected||!!busy||(!streaming&&!device.control?.can_stop)} aria-busy={busy==='stop'}
|
||||||
|
icon={busy==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{busy==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||||
|
{!manual.connected&&<SettingsCard align="center" title={k1ConnectionNotice(device,enabled)}>
|
||||||
|
{device.control?.can_verify&&<Button disabled={!enabled||!!busy} aria-busy={busy==='verify'} icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
|
||||||
|
</SettingsCard>}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost}/>}
|
{!streaming&&(busy==='start'||manual.active)&&<ActivityIndicator label="Запускаем K1 и ожидаем живые данные"/>}
|
||||||
|
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost} onReconnect={()=>setGeneration(value=>value+1)}/>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useEffect,useRef,useState} from 'react';
|
import {useEffect,useRef,useState} from 'react';
|
||||||
import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,SettingsCard} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ function privateCandidate(sdp:string):string{
|
|||||||
}).join('\r\n');
|
}).join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory}){
|
export function K1LiveView({device,transport,createRerunHost,onReconnect}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onReconnect:()=>void}){
|
||||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||||
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -59,5 +59,5 @@ export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;tra
|
|||||||
void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.'));
|
void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.'));
|
||||||
return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);};
|
return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);};
|
||||||
},[device.snapshot.context.session_id,transport,createRerunHost]);
|
},[device.snapshot.context.session_id,transport,createRerunHost]);
|
||||||
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
|
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}{(error||cameraError)&&<Button onClick={onReconnect}>Восстановить просмотр</Button>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
|||||||
if(enrollmentBluetoothFailure(state))return 'Не удалось установить связь с K1 по Bluetooth. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и выберите его из списка.';
|
if(enrollmentBluetoothFailure(state))return 'Не удалось установить связь с K1 по Bluetooth. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и выберите его из списка.';
|
||||||
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
|
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
|
||||||
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
|
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
|
||||||
|
if(code==='application_authority_unavailable')return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
||||||
if(state.connected)return 'K1 подключён к БК.';
|
if(state.connected)return 'K1 подключён к БК.';
|
||||||
if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
|
if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
|
||||||
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||||
import {K1Detail} from './K1Detail';
|
import {K1Detail} from './K1Detail';
|
||||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||||
|
import {k1Status} from './presentation';
|
||||||
|
|
||||||
export const xgridsK1SensorUi:SensorUiContribution={
|
export const xgridsK1SensorUi:SensorUiContribution={
|
||||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||||
|
supportsRenaming:false,status:k1Status,
|
||||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type {Sensor} from './runtime';
|
||||||
|
|
||||||
|
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||||
|
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
||||||
|
if(!device.online||!device.verified)return device.control?.network_applied
|
||||||
|
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
|
||||||
|
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
|
||||||
|
if(device.snapshot.acquisition==='streaming')return {label:'Идёт захват',tone:'success'};
|
||||||
|
if(['preparing','starting'].includes(device.snapshot.acquisition))return {label:'Запускается',tone:'neutral'};
|
||||||
|
if(device.snapshot.acquisition==='stopping')return {label:'Останавливается',tone:'neutral'};
|
||||||
|
return {label:device.control?.can_start?'Готов к запуску':'Подключён',tone:'success'};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
|
||||||
|
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
|
||||||
|
if(device.control?.reason_code==='application_authority_unavailable')
|
||||||
|
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
||||||
|
return device.control?.network_applied
|
||||||
|
?'Wi-Fi настроен. Канал управления K1 пока не подтверждён. Проверьте состояние устройства.'
|
||||||
|
:'Связь с K1 пока не подтверждена. Проверьте питание устройства и подключение к сети.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function k1ManualState(device:Sensor,fresh:boolean) {
|
||||||
|
const active=['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition);
|
||||||
|
const connected=fresh&&device.online&&device.verified;
|
||||||
|
return {active,connected,canStart:connected&&!active&&device.control?.can_start===true,
|
||||||
|
showStop:active||device.control?.can_stop===true};
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
|||||||
from debian import package # noqa: E402
|
from debian import package # noqa: E402
|
||||||
from runtime_payload import files as runtime_files # noqa: E402
|
from runtime_payload import files as runtime_files # noqa: E402
|
||||||
|
|
||||||
VERSION = "0.1.5"
|
VERSION = "0.1.6"
|
||||||
RESOURCES = (
|
RESOURCES = (
|
||||||
"plugins/xgrids-k1/profile_loader.py",
|
"plugins/xgrids-k1/profile_loader.py",
|
||||||
"plugins/xgrids-k1/plugin.manifest.json",
|
"plugins/xgrids-k1/plugin.manifest.json",
|
||||||
|
|||||||
@@ -191,14 +191,31 @@ class LinuxApplicationAuthorityLoader:
|
|||||||
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
|
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
|
||||||
)
|
)
|
||||||
buffer = bytearray()
|
buffer = bytearray()
|
||||||
|
directory_fd = None
|
||||||
try:
|
try:
|
||||||
fd = os.open(directory / "k1-application", os.O_RDONLY | os.O_NOFOLLOW)
|
directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||||
|
directory_info = os.fstat(directory_fd)
|
||||||
|
if (
|
||||||
|
directory_info.st_uid not in {0, os.geteuid()}
|
||||||
|
or directory_info.st_mode & 0o027
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid credential directory")
|
||||||
|
fd = os.open(
|
||||||
|
"k1-application", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
|
||||||
|
dir_fd=directory_fd,
|
||||||
|
)
|
||||||
with os.fdopen(fd, "rb") as stream:
|
with os.fdopen(fd, "rb") as stream:
|
||||||
info = os.fstat(stream.fileno())
|
info = os.fstat(stream.fileno())
|
||||||
|
# systemd prefers root ownership + a service-UID read ACL.
|
||||||
|
# Its ACL mask appears as group-read in st_mode (0440).
|
||||||
|
# The trusted root-owned directory/file and kernel access check
|
||||||
|
# own that authorization; do not mistake the mask for exposure.
|
||||||
|
acl_delivered = info.st_uid == 0 and directory_info.st_uid == 0
|
||||||
if (
|
if (
|
||||||
not stat.S_ISREG(info.st_mode)
|
not stat.S_ISREG(info.st_mode)
|
||||||
or info.st_uid != os.geteuid()
|
or info.st_uid not in {0, os.geteuid()}
|
||||||
or info.st_mode & 0o077
|
or info.st_mode & (0o037 if acl_delivered else 0o077)
|
||||||
|
or info.st_mode & 0o111
|
||||||
):
|
):
|
||||||
raise ValueError("Invalid credential permissions")
|
raise ValueError("Invalid credential permissions")
|
||||||
buffer.extend(stream.read(1025))
|
buffer.extend(stream.read(1025))
|
||||||
@@ -207,8 +224,10 @@ class LinuxApplicationAuthorityLoader:
|
|||||||
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
|
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
|
||||||
except (OSError, ValueError, UnicodeError):
|
except (OSError, ValueError, UnicodeError):
|
||||||
raise ApplicationAuthorityLoadError(
|
raise ApplicationAuthorityLoadError(
|
||||||
"Служебный ключ K1 не установлен на БК.",
|
"Службе K1 на БК не удалось загрузить служебный ключ.",
|
||||||
reason_code="application_authority_unavailable",
|
reason_code="application_authority_unavailable",
|
||||||
) from None
|
) from None
|
||||||
finally:
|
finally:
|
||||||
|
if directory_fd is not None:
|
||||||
|
os.close(directory_fd)
|
||||||
buffer[:] = b"\0" * len(buffer)
|
buffer[:] = b"\0" * len(buffer)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .facade import (
|
|||||||
_validate_installed_compatibility_profile,
|
_validate_installed_compatibility_profile,
|
||||||
)
|
)
|
||||||
from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks
|
from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks
|
||||||
|
from .protocol.application_authority import ApplicationAuthorityLoadError
|
||||||
|
|
||||||
ATTESTATION = {
|
ATTESTATION = {
|
||||||
"firmware_version": "3.0.2",
|
"firmware_version": "3.0.2",
|
||||||
@@ -94,11 +95,20 @@ def node_operation_id(plugin_id: str | None) -> str | None:
|
|||||||
class NodeBridge:
|
class NodeBridge:
|
||||||
def __init__(self, repository_root: Path, *, service=None):
|
def __init__(self, repository_root: Path, *, service=None):
|
||||||
self.rerun = NodeRerunHub()
|
self.rerun = NodeRerunHub()
|
||||||
|
self.application_authority_available = None
|
||||||
if service is None:
|
if service is None:
|
||||||
_validate_installed_compatibility_profile(repository_root)
|
_validate_installed_compatibility_profile(repository_root)
|
||||||
|
loader = LinuxApplicationAuthorityLoader()
|
||||||
|
# systemd's credential mount is immutable for this service lifetime.
|
||||||
|
# This startup check proves loading, not K1 authentication or control.
|
||||||
|
try:
|
||||||
|
loader.load()
|
||||||
|
self.application_authority_available = True
|
||||||
|
except ApplicationAuthorityLoadError:
|
||||||
|
self.application_authority_available = False
|
||||||
service = XgridsK1CompatibilityService(
|
service = XgridsK1CompatibilityService(
|
||||||
repository_root,
|
repository_root,
|
||||||
application_authority_loader=LinuxApplicationAuthorityLoader(),
|
application_authority_loader=loader,
|
||||||
host_wifi_association_probe=LinuxWifiAssociationProbe(),
|
host_wifi_association_probe=LinuxWifiAssociationProbe(),
|
||||||
visualization_bridge_factory=self.rerun.create,
|
visualization_bridge_factory=self.rerun.create,
|
||||||
)
|
)
|
||||||
@@ -108,7 +118,10 @@ class NodeBridge:
|
|||||||
|
|
||||||
async def state(self) -> dict:
|
async def state(self) -> dict:
|
||||||
snapshot = await self.invoke("state.read", {}, "state-read")
|
snapshot = await self.invoke("state.read", {}, "state-read")
|
||||||
return self.project(snapshot)
|
result = self.project(snapshot)
|
||||||
|
if self.application_authority_available is not None:
|
||||||
|
result["application_authority_available"] = self.application_authority_available
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def project(snapshot: dict) -> dict:
|
def project(snapshot: dict) -> dict:
|
||||||
@@ -343,7 +356,11 @@ def create_app(repository_root: Path):
|
|||||||
raise ValueError("Command expired")
|
raise ValueError("Command expired")
|
||||||
result = await sensor.execute(command, request.headers["X-Node-Id"])
|
result = await sensor.execute(command, request.headers["X-Node-Id"])
|
||||||
return {"state": "complete", "result": result}
|
return {"state": "complete", "result": result}
|
||||||
except Exception:
|
except Exception as error:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"K1 sensor operation failed: exception=%s chain=%s",
|
||||||
|
type(error).__name__, failure_locations(error),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"state": "unknown",
|
"state": "unknown",
|
||||||
"error": "Действие K1 не подтверждено. Обновите состояние устройства.",
|
"error": "Действие K1 не подтверждено. Обновите состояние устройства.",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def project_sensor(snapshot, node_id):
|
|||||||
"opened_at": session["opened_at"],
|
"opened_at": session["opened_at"],
|
||||||
}
|
}
|
||||||
control = snapshot.get("application_control_session") or {}
|
control = snapshot.get("application_control_session") or {}
|
||||||
|
attempt = snapshot.get("connection_attempt") or {}
|
||||||
return {
|
return {
|
||||||
"id": identifier,
|
"id": identifier,
|
||||||
"name": "XGRIDS K1",
|
"name": "XGRIDS K1",
|
||||||
@@ -90,6 +91,12 @@ def project_sensor(snapshot, node_id):
|
|||||||
"phase": control.get("state"),
|
"phase": control.get("state"),
|
||||||
"can_start": lifecycle.get("ready_to_start", False),
|
"can_start": lifecycle.get("ready_to_start", False),
|
||||||
"can_stop": control.get("state") in {"start-requested", "initializing", "scanning"},
|
"can_stop": control.get("state") in {"start-requested", "initializing", "scanning"},
|
||||||
|
"can_verify": any(action in lifecycle.get("allowed_actions", []) for action in (
|
||||||
|
"verify-control-read-only", "observe-current-device-network",
|
||||||
|
"observe-configured-device-network", "observe-fresh-device-network",
|
||||||
|
)),
|
||||||
|
"network_applied": connected or attempt.get("phase") == "network_applied",
|
||||||
|
"reason_code": None if connected else attempt.get("public_error_code"),
|
||||||
"acquisition_id": acquisition.get("acquisition_id"),
|
"acquisition_id": acquisition.get("acquisition_id"),
|
||||||
},
|
},
|
||||||
"live_settings": snapshot.get("viewer_settings", {}),
|
"live_settings": snapshot.get("viewer_settings", {}),
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.linux_host import LinuxApplicationAuthorityLoader
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
ApplicationAuthorityLoadError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("owner,mode,allowed", [
|
||||||
|
("root", 0o440, True), # systemd v255: root-owned file and service UID read ACL
|
||||||
|
("root", 0o400, True),
|
||||||
|
("service", 0o400, True),
|
||||||
|
("service", 0o600, True),
|
||||||
|
("root", 0o444, False),
|
||||||
|
("root", 0o460, False),
|
||||||
|
("service", 0o440, False),
|
||||||
|
("other", 0o400, False),
|
||||||
|
])
|
||||||
|
def test_systemd_credential_ownership_contract(tmp_path, monkeypatch, owner, mode, allowed):
|
||||||
|
secret = secrets.token_hex(18)
|
||||||
|
path = tmp_path / "k1-application"
|
||||||
|
path.write_text(secret)
|
||||||
|
path.chmod(0o600)
|
||||||
|
file_inode, directory_inode = path.stat().st_ino, tmp_path.stat().st_ino
|
||||||
|
original = os.fstat
|
||||||
|
|
||||||
|
def delivered_metadata(fd):
|
||||||
|
result = original(fd)
|
||||||
|
fields = list(result)
|
||||||
|
if result.st_ino == file_inode:
|
||||||
|
fields[0] = stat.S_IFREG | mode
|
||||||
|
fields[4] = {"root": 0, "service": os.geteuid(), "other": os.geteuid()+1}[owner]
|
||||||
|
elif result.st_ino == directory_inode:
|
||||||
|
fields[0] = stat.S_IFDIR | 0o550
|
||||||
|
fields[4] = 0
|
||||||
|
return os.stat_result(fields)
|
||||||
|
|
||||||
|
# Read actual bounded file bytes; replace only kernel metadata unavailable
|
||||||
|
# on macOS. This is the documented systemd credential ACL representation.
|
||||||
|
monkeypatch.setattr(os, "fstat", delivered_metadata)
|
||||||
|
loader = LinuxApplicationAuthorityLoader(tmp_path)
|
||||||
|
if allowed:
|
||||||
|
assert loader.load().openapi_key == secret
|
||||||
|
else:
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError) as failure:
|
||||||
|
loader.load()
|
||||||
|
assert secret not in str(failure.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind", ["missing", "file-symlink", "directory-symlink", "fifo", "oversized"],
|
||||||
|
)
|
||||||
|
def test_credential_loader_rejects_nonregular_or_unbounded_input(tmp_path, kind):
|
||||||
|
directory = tmp_path / "credentials"
|
||||||
|
directory.mkdir(mode=0o700)
|
||||||
|
path = directory / "k1-application"
|
||||||
|
if kind == "file-symlink":
|
||||||
|
target = tmp_path / "target"
|
||||||
|
target.write_text(secrets.token_hex(18))
|
||||||
|
path.symlink_to(target)
|
||||||
|
elif kind == "directory-symlink":
|
||||||
|
path.write_text(secrets.token_hex(18))
|
||||||
|
path.chmod(0o600)
|
||||||
|
link = tmp_path / "link"
|
||||||
|
link.symlink_to(directory, target_is_directory=True)
|
||||||
|
directory = link
|
||||||
|
elif kind == "fifo":
|
||||||
|
os.mkfifo(path, 0o600)
|
||||||
|
elif kind == "oversized":
|
||||||
|
path.write_text(secrets.token_hex(1025))
|
||||||
|
path.chmod(0o600)
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError):
|
||||||
|
LinuxApplicationAuthorityLoader(directory).load()
|
||||||
@@ -70,6 +70,39 @@ def bridge():
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("available", [False, True])
|
||||||
|
def test_startup_credential_check_does_not_authorize_control(monkeypatch, available):
|
||||||
|
from k1link.device_plugins.xgrids_k1 import node_bridge
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
ApplicationAuthorityLoadError,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Loader:
|
||||||
|
def load(self):
|
||||||
|
calls.append("load")
|
||||||
|
if not available:
|
||||||
|
raise ApplicationAuthorityLoadError("Unavailable")
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(node_bridge, "_validate_installed_compatibility_profile", lambda _: None)
|
||||||
|
monkeypatch.setattr(node_bridge, "LinuxApplicationAuthorityLoader", Loader)
|
||||||
|
monkeypatch.setattr(node_bridge, "XgridsK1CompatibilityService", lambda *a, **kw: object())
|
||||||
|
device = NodeBridge(Path.cwd())
|
||||||
|
device.facade = Facade()
|
||||||
|
device.facade.current["connection_lifecycle"] = {"connection_ready": False}
|
||||||
|
|
||||||
|
async def read():
|
||||||
|
for _ in range(2):
|
||||||
|
snapshot = await device.state()
|
||||||
|
assert snapshot["application_authority_available"] is available
|
||||||
|
assert snapshot["connected"] is False
|
||||||
|
|
||||||
|
asyncio.run(read())
|
||||||
|
assert calls == ["load"]
|
||||||
|
|
||||||
|
|
||||||
def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplog):
|
def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplog):
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
@@ -352,6 +385,27 @@ def test_sensor_projection_binds_native_sdk_to_board():
|
|||||||
assert value["kind"] == "k1"
|
assert value["kind"] == "k1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_wifi_does_not_grant_control_or_start_authority():
|
||||||
|
current = state()
|
||||||
|
current["connection_lifecycle"] = {
|
||||||
|
"connection_ready": False, "ready_to_start": False,
|
||||||
|
"allowed_actions": ["verify-control-read-only"],
|
||||||
|
}
|
||||||
|
current["connection_attempt"] = {
|
||||||
|
"phase": "network_applied", "public_error_code": "application_authority_unavailable",
|
||||||
|
}
|
||||||
|
item = project_sensor(current, "node-test")
|
||||||
|
assert not item["online"] and not item["verified"]
|
||||||
|
assert item["control"]["network_applied"]
|
||||||
|
assert item["control"]["reason_code"] == "application_authority_unavailable"
|
||||||
|
assert not item["control"]["can_start"]
|
||||||
|
assert item["control"]["can_verify"]
|
||||||
|
current["connection_lifecycle"].update(connection_ready=True, ready_to_start=True)
|
||||||
|
item = project_sensor(current, "node-test")
|
||||||
|
assert item["online"] and item["verified"] and item["control"]["can_start"]
|
||||||
|
assert item["control"]["reason_code"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
|
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
|
||||||
async def run():
|
async def run():
|
||||||
device = bridge()
|
device = bridge()
|
||||||
|
|||||||
Reference in New Issue
Block a user