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]
|
||||
VERSION = "0.8.6"
|
||||
VERSION = "0.8.7"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
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 {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 {sensorStatus} from './sensorStatus';
|
||||
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);};
|
||||
},[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 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 connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||
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 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 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>}
|
||||
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>}{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>}
|
||||
</>}
|
||||
<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('')}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import {useState} from 'react';
|
||||
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
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[];
|
||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||
onComplete:SensorEnrollmentProps['onComplete'];
|
||||
}) {
|
||||
const [selected,setSelected]=useState('');
|
||||
const supported=wirelessContributions(contributions);
|
||||
@@ -21,5 +22,5 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
|
||||
</div>
|
||||
</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 SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||
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>;
|
||||
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};
|
||||
@@ -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}[];
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface SensorDetailProps {
|
||||
}
|
||||
export interface SensorEnrollmentProps {
|
||||
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
|
||||
onComplete:(sessionId:string,signal?:AbortSignal)=>Promise<void>;
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
@@ -19,6 +20,8 @@ export interface SensorUiContribution {
|
||||
icon:IconName;
|
||||
retainOffline:boolean;
|
||||
supportsPreparation:boolean;
|
||||
supportsRenaming?:boolean;
|
||||
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||
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 {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 [device,setDevice]=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,
|
||||
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}>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'}
|
||||
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
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}){
|
||||
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 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);},[]);
|
||||
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();}
|
||||
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'}>
|
||||
<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>
|
||||
<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>
|
||||
<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="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||
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>
|
||||
{settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost}/>}
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{!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>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
|
||||
@@ -12,7 +12,7 @@ function privateCandidate(sdp:string):string{
|
||||
}).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 [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
||||
useEffect(()=>{
|
||||
@@ -59,5 +59,5 @@ export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;tra
|
||||
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);};
|
||||
},[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(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
|
||||
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(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
|
||||
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||
import {K1Detail} from './K1Detail';
|
||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
import {k1Status} from './presentation';
|
||||
|
||||
export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
supportsRenaming:false,status:k1Status,
|
||||
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 runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.5"
|
||||
VERSION = "0.1.6"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
|
||||
@@ -191,14 +191,31 @@ class LinuxApplicationAuthorityLoader:
|
||||
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
|
||||
)
|
||||
buffer = bytearray()
|
||||
directory_fd = None
|
||||
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:
|
||||
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 (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_uid != os.geteuid()
|
||||
or info.st_mode & 0o077
|
||||
or info.st_uid not in {0, os.geteuid()}
|
||||
or info.st_mode & (0o037 if acl_delivered else 0o077)
|
||||
or info.st_mode & 0o111
|
||||
):
|
||||
raise ValueError("Invalid credential permissions")
|
||||
buffer.extend(stream.read(1025))
|
||||
@@ -207,8 +224,10 @@ class LinuxApplicationAuthorityLoader:
|
||||
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"Служебный ключ K1 не установлен на БК.",
|
||||
"Службе K1 на БК не удалось загрузить служебный ключ.",
|
||||
reason_code="application_authority_unavailable",
|
||||
) from None
|
||||
finally:
|
||||
if directory_fd is not None:
|
||||
os.close(directory_fd)
|
||||
buffer[:] = b"\0" * len(buffer)
|
||||
|
||||
@@ -29,6 +29,7 @@ from .facade import (
|
||||
_validate_installed_compatibility_profile,
|
||||
)
|
||||
from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks
|
||||
from .protocol.application_authority import ApplicationAuthorityLoadError
|
||||
|
||||
ATTESTATION = {
|
||||
"firmware_version": "3.0.2",
|
||||
@@ -94,11 +95,20 @@ def node_operation_id(plugin_id: str | None) -> str | None:
|
||||
class NodeBridge:
|
||||
def __init__(self, repository_root: Path, *, service=None):
|
||||
self.rerun = NodeRerunHub()
|
||||
self.application_authority_available = None
|
||||
if service is None:
|
||||
_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(
|
||||
repository_root,
|
||||
application_authority_loader=LinuxApplicationAuthorityLoader(),
|
||||
application_authority_loader=loader,
|
||||
host_wifi_association_probe=LinuxWifiAssociationProbe(),
|
||||
visualization_bridge_factory=self.rerun.create,
|
||||
)
|
||||
@@ -108,7 +118,10 @@ class NodeBridge:
|
||||
|
||||
async def state(self) -> dict:
|
||||
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
|
||||
def project(snapshot: dict) -> dict:
|
||||
@@ -343,7 +356,11 @@ def create_app(repository_root: Path):
|
||||
raise ValueError("Command expired")
|
||||
result = await sensor.execute(command, request.headers["X-Node-Id"])
|
||||
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 {
|
||||
"state": "unknown",
|
||||
"error": "Действие K1 не подтверждено. Обновите состояние устройства.",
|
||||
|
||||
@@ -64,6 +64,7 @@ def project_sensor(snapshot, node_id):
|
||||
"opened_at": session["opened_at"],
|
||||
}
|
||||
control = snapshot.get("application_control_session") or {}
|
||||
attempt = snapshot.get("connection_attempt") or {}
|
||||
return {
|
||||
"id": identifier,
|
||||
"name": "XGRIDS K1",
|
||||
@@ -90,6 +91,12 @@ def project_sensor(snapshot, node_id):
|
||||
"phase": control.get("state"),
|
||||
"can_start": lifecycle.get("ready_to_start", False),
|
||||
"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"),
|
||||
},
|
||||
"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
|
||||
|
||||
|
||||
@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):
|
||||
from bleak.exc import BleakError
|
||||
|
||||
@@ -352,6 +385,27 @@ def test_sensor_projection_binds_native_sdk_to_board():
|
||||
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():
|
||||
async def run():
|
||||
device = bridge()
|
||||
|
||||
Reference in New Issue
Block a user