Group onboard wireless steps and trace journalled Bluetooth failures
This commit is contained in:
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
|
|||||||
import {before,after,test} from 'node:test';
|
import {before,after,test} from 'node:test';
|
||||||
import {readFileSync,readdirSync} from 'node:fs';
|
import {readFileSync,readdirSync} from 'node:fs';
|
||||||
import {createServer} from 'vite';
|
import {createServer} from 'vite';
|
||||||
let server,api,resolveContribution,wirelessContributions;
|
let server,api,resolveContribution,wirelessContributions,revealEnrollment;
|
||||||
before(async()=>{
|
before(async()=>{
|
||||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||||
api=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/enrollment.ts');
|
api=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/enrollment.ts');
|
||||||
|
({revealEnrollment}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/revealEnrollment.ts'));
|
||||||
({sensorContribution:resolveContribution}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
({sensorContribution:resolveContribution}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||||
({wirelessContributions}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
({wirelessContributions}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||||
});
|
});
|
||||||
@@ -78,6 +79,33 @@ test('station refusal is the same message in onboard and LAB presentations',()=>
|
|||||||
assert.match(api.enrollmentNotice(state),/Проверьте название сети и пароль/);
|
assert.match(api.enrollmentNotice(state),/Проверьте название сети и пароль/);
|
||||||
assert.doesNotMatch(api.enrollmentNotice(state),/Bluetooth.*ошиб/);
|
assert.doesNotMatch(api.enrollmentNotice(state),/Bluetooth.*ошиб/);
|
||||||
});
|
});
|
||||||
|
test('Bluetooth failure before dispatch asks for discovery without blaming Wi-Fi',()=>{
|
||||||
|
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',stage:'connect-failed',phase:'network_not_applied',public_error_code:'BleakError',side_effect_status:'none'}};
|
||||||
|
assert.equal(api.enrollmentBluetoothFailure(state),true);
|
||||||
|
assert.match(api.enrollmentNotice(state),/Bluetooth.*не были отправлены/s);
|
||||||
|
assert.doesNotMatch(api.enrollmentNotice(state),/Проверьте.*пароль/);
|
||||||
|
const unknown={...state,connection_attempt:{...state.connection_attempt,phase:'network_outcome_unknown',side_effect_status:'unknown'}};
|
||||||
|
assert.equal(api.enrollmentBluetoothFailure(unknown),false);
|
||||||
|
assert.doesNotMatch(api.enrollmentNotice(unknown),/не были отправлены/);
|
||||||
|
});
|
||||||
|
test('new workflow content scrolls only the containing viewport without stealing focus',()=>{
|
||||||
|
const original={document:globalThis.document,window:globalThis.window,getComputedStyle:globalThis.getComputedStyle};
|
||||||
|
const calls=[];let reduced=false;
|
||||||
|
const body={};
|
||||||
|
const scroller={parentElement:body,scrollHeight:1600,clientHeight:500,scrollTop:100,
|
||||||
|
getBoundingClientRect:()=>({top:100,bottom:600}),scrollTo:value=>calls.push(value)};
|
||||||
|
globalThis.document={body,documentElement:{}};
|
||||||
|
globalThis.window={matchMedia:()=>({matches:reduced})};
|
||||||
|
globalThis.getComputedStyle=element=>({overflowY:element===scroller?'auto':'visible'});
|
||||||
|
const target=(top,height)=>({parentElement:scroller,getBoundingClientRect:()=>({top,bottom:top+height,height}),focus:()=>assert.fail('focus must remain with the operator')});
|
||||||
|
try{
|
||||||
|
revealEnrollment(target(120,250));assert.equal(calls.length,0);
|
||||||
|
revealEnrollment(target(620,100));assert.deepEqual(calls.pop(),{top:220,behavior:'smooth'});
|
||||||
|
reduced=true;
|
||||||
|
revealEnrollment(target(620,800));assert.deepEqual(calls.pop(),{top:620,behavior:'instant'});
|
||||||
|
revealEnrollment({...target(700,100),parentElement:body});assert.equal(calls.length,0);
|
||||||
|
}finally{Object.assign(globalThis,original);}
|
||||||
|
});
|
||||||
test('sensor host can resolve zero or unrelated integrations and rejects ambiguity',()=>{
|
test('sensor host can resolve zero or unrelated integrations and rejects ambiguity',()=>{
|
||||||
const alpha={kind:'alpha',Detail:()=>null},beta={kind:'beta',Detail:()=>null};
|
const alpha={kind:'alpha',Detail:()=>null},beta={kind:'beta',Detail:()=>null};
|
||||||
assert.equal(resolveContribution([],{kind:'alpha'}),undefined);
|
assert.equal(resolveContribution([],{kind:'alpha'}),undefined);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.3"
|
VERSION = "0.8.4"
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ The release is retained in `private/releases/mission-core-node-20260907-r5`.
|
|||||||
Owner screenshots, redacted UTC/monotonic manifest and validation logs are
|
Owner screenshots, redacted UTC/monotonic manifest and validation logs are
|
||||||
hashed under `private/acceptance/k1-node083-20260907-core-ui`.
|
hashed under `private/acceptance/k1-node083-20260907-core-ui`.
|
||||||
|
|
||||||
One Ubuntu GUI installer is open and awaiting owner sudo authentication.
|
The owner completed Ubuntu authentication; the installer exited with code 0.
|
||||||
The latest observed installed versions are still Node 0.8.2 and K1
|
Readback confirmed Node 0.8.3 and K1 0.1.2+private.1, both active with zero
|
||||||
0.1.2+private.1, both active. Installation and new UI hardware acceptance must
|
restarts, started at 13:08:15 MSK. This confirms the Node-only upgrade restored
|
||||||
not be inferred from the package build. Git publication remains pending the
|
the existing plugin. The next owner UI attempt found K1 but failed at Bluetooth
|
||||||
|
connect before Wi-Fi dispatch; see the R6 report. Fresh-cache Bridge/live
|
||||||
|
acceptance remains open. Git publication remains pending the
|
||||||
explicit owner reply to the prior automatic approval rejection of deployment
|
explicit owner reply to the prior automatic approval rejection of deployment
|
||||||
reports; no push workaround was used.
|
reports; no push workaround was used.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Wireless workflow groups and failure observability R6
|
||||||
|
|
||||||
|
The owner requested separate Bluetooth and Wi-Fi steps and automatic reveal of
|
||||||
|
new content in the bounded wireless-enrollment modal. This is a composition of
|
||||||
|
existing SettingsCard, ResourceList, ResourceRow, Button and field primitives,
|
||||||
|
with no new Design Guideline entity, surface decoration or navigation change.
|
||||||
|
Both Core and Node consume the same plugin-owned form.
|
||||||
|
|
||||||
|
Bluetooth discovery and selectable candidates belong to step 01. Step 02 appears
|
||||||
|
after explicit selection and groups the K1 Wi-Fi network, credentials, connection
|
||||||
|
check and its result. The check description is fully visible in the card header;
|
||||||
|
the action is below the fields. The final Connect action still requires the
|
||||||
|
current successful proof and sends no second physical command.
|
||||||
|
|
||||||
|
Explicit discovery results, selected-device Wi-Fi, network choices and changed
|
||||||
|
connection results reveal themselves inside the existing nearest scrollable
|
||||||
|
surface. Poll revisions and typing do not trigger scrolling. Fully visible
|
||||||
|
content does not move, tall steps reveal their heading, reduced-motion uses an
|
||||||
|
instant move, and keyboard focus stays with the operator. The page behind the
|
||||||
|
modal is never scrolled. A completed rescan dismisses the preceding attempt's
|
||||||
|
message without suppressing a later outage or new result.
|
||||||
|
|
||||||
|
## Actual failed attempt
|
||||||
|
|
||||||
|
The owner UI attempt at 10:09:50.958 UTC completed at 10:09:51.021 UTC with
|
||||||
|
BleakError, stage connect-failed, phase network_not_applied and side-effect
|
||||||
|
status none. Wi-Fi settings were not sent. Installed BlueZ is 5.72; both Node
|
||||||
|
0.8.3 and optional K1 0.1.2 services were active without restarts. This is not
|
||||||
|
evidence of a wrong network name/password or an Ethernet routing fault.
|
||||||
|
|
||||||
|
The exact Bluetooth exception origin is not recoverable from this deployment's
|
||||||
|
public projection or system journal. The facade records structured fields, but
|
||||||
|
the default service logger prints only the fixed message; Node's outer HTTP
|
||||||
|
exception handler never sees journalled failures returned as normal results.
|
||||||
|
R6 logs a validated operation ID, internal action, exception class and source
|
||||||
|
file/function/line locations at that existing catch boundary. It never logs
|
||||||
|
exception text, source lines, local values, credentials or payloads. The
|
||||||
|
invocation still runs once and then reads only its exact journal result.
|
||||||
|
|
||||||
|
An explicit pre-dispatch Bluetooth connection failure is now shown in step 01,
|
||||||
|
says Wi-Fi settings were not sent and requests a fresh scan. Its failed native
|
||||||
|
selection cannot be reused in the dialog. Unknown write outcomes retain their
|
||||||
|
existing read-only reconciliation path and are never described as unsent.
|
||||||
|
The next clean-cache owner UI attempt is needed to identify the precise
|
||||||
|
Bluetooth backend failure; this increment does not claim that physical
|
||||||
|
connection failure is fixed.
|
||||||
|
|
||||||
|
## Scope and validation
|
||||||
|
|
||||||
|
No BLE framing, mutation/retry policy, runtime fences, MQTT supervisor, host
|
||||||
|
network switching, LAB connection flow or Rerun profile changes. The board may
|
||||||
|
remain on Ethernet while K1 joins the router's Wi-Fi. The optional backend change
|
||||||
|
is confined to NodeBridge diagnostics; the local LAB facade is unchanged.
|
||||||
|
|
||||||
|
Focused architecture/enrollment checks: 22 passed. Full Core frontend suite:
|
||||||
|
793 passed. Focused NodeBridge and real package-maintainer lifecycle tests:
|
||||||
|
16 passed, including secret-free failure logging without a second invocation.
|
||||||
|
Core TypeScript and production build passed. Changed backend files pass Ruff;
|
||||||
|
the Node package builder has 17 pre-existing lint violations outside the
|
||||||
|
version-only edit. Repository-wide lint success is not claimed.
|
||||||
|
|
||||||
|
Node 0.8.4 and optional K1 0.1.3 are reserved for this increment. Exact build,
|
||||||
|
installation and owner UI acceptance must be recorded separately. Private
|
||||||
|
screenshots, observations, UTC/monotonic manifest and hashes are retained under
|
||||||
|
private/acceptance/k1-node084-20260907-core-ui. Cache clearance is not yet
|
||||||
|
explicitly confirmed for the preceding owner observation.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import {useEffect,useRef,useState} from 'react';
|
import {useEffect,useRef,useState} from 'react';
|
||||||
import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||||
import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk';
|
import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk';
|
||||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,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';
|
||||||
|
|
||||||
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
|
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
|
||||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||||
@@ -15,6 +16,9 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
const [scanned,setScanned]=useState(false);
|
const [scanned,setScanned]=useState(false);
|
||||||
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
|
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
|
||||||
const [needsScan,setNeedsScan]=useState(false);
|
const [needsScan,setNeedsScan]=useState(false);
|
||||||
|
const [dismissedAttempt,setDismissedAttempt]=useState('');
|
||||||
|
const [reveal,setReveal]=useState({target:'',revision:0});
|
||||||
|
const content=useRef<HTMLDivElement>(null);
|
||||||
const lifetime=useRef<AbortController|null>(null);
|
const lifetime=useRef<AbortController|null>(null);
|
||||||
const running=useRef(false);
|
const running=useRef(false);
|
||||||
|
|
||||||
@@ -41,7 +45,8 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
|
|
||||||
const attempt=connectionAttempt(state);
|
const attempt=connectionAttempt(state);
|
||||||
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
|
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
|
||||||
const notice=state?enrollmentNotice(state):'';
|
const notice=state&&(!state.available||state.fresh===false||attempt?.attempt_id!==dismissedAttempt)?enrollmentNotice(state):'';
|
||||||
|
const bluetoothFailure=!!notice&&enrollmentBluetoothFailure(state);
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
|
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
|
||||||
setVerified(null);
|
setVerified(null);
|
||||||
@@ -52,6 +57,17 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
const selected=state?.candidates?.some(value=>value.id===device)??false;
|
const selected=state?.candidates?.some(value=>value.id===device)??false;
|
||||||
const pending=!!busy||waiting;
|
const pending=!!busy||waiting;
|
||||||
const confirmed=enrollmentProofCurrent(verified,state,device);
|
const confirmed=enrollmentProofCurrent(verified,state,device);
|
||||||
|
function show(target:string){setReveal(current=>({target,revision:current.revision+1}));}
|
||||||
|
useEffect(()=>{
|
||||||
|
if(notice&&(scanned||device))show('result');
|
||||||
|
},[notice,confirmed]);
|
||||||
|
useEffect(()=>{
|
||||||
|
const frame=requestAnimationFrame(()=>{
|
||||||
|
const target=content.current?.querySelector<HTMLElement>(`[data-enrollment-reveal="${reveal.target}"]`);
|
||||||
|
if(target)revealEnrollment(target);
|
||||||
|
});
|
||||||
|
return()=>cancelAnimationFrame(frame);
|
||||||
|
},[reveal]);
|
||||||
|
|
||||||
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
||||||
if(!state||busy||running.current)return;
|
if(!state||busy||running.current)return;
|
||||||
@@ -72,10 +88,13 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
if(signal?.aborted)return;
|
if(signal?.aborted)return;
|
||||||
onChange();
|
onChange();
|
||||||
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
|
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
|
||||||
if(action==='scan'){setDevice('');setScanned(true);}
|
if(action==='scan'){
|
||||||
if(action==='networks')setNetworks(result.networks??[]);
|
setDevice('');setScanned(true);setNeedsScan(false);
|
||||||
if(action==='connect'||action==='verify')setVerified(enrollmentProof(result,device));
|
setDismissedAttempt(connectionAttempt(result)?.attempt_id??'');show('devices');
|
||||||
if(action==='connect'&&connectionAttempt(result)?.public_error_code==='network-provision-candidate-not-fresh'){
|
}
|
||||||
|
if(action==='networks'){setNetworks(result.networks??[]);show('networks');}
|
||||||
|
if(action==='connect'||action==='verify'){setVerified(enrollmentProof(result,device));setDismissedAttempt('');}
|
||||||
|
if(action==='connect'&&enrollmentBluetoothFailure(result)){
|
||||||
setNeedsScan(true);setDevice('');
|
setNeedsScan(true);setDevice('');
|
||||||
}
|
}
|
||||||
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
|
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
|
||||||
@@ -90,7 +109,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
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,
|
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
|
||||||
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; сеть компьютера оператора может отличаться.">
|
||||||
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
||||||
@@ -98,38 +117,46 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
|
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
|
||||||
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
||||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={
|
<SettingsCard eyebrow="Шаг 01" title="Bluetooth" description="Найдите K1 рядом с бортовым компьютером и выберите его из списка." actions={
|
||||||
<Button disabled={pending||!enrollmentAllowed(state,'scan')} aria-busy={busy==='scan'}
|
<Button disabled={pending||!enrollmentAllowed(state,'scan')} aria-busy={busy==='scan'}
|
||||||
icon={busy==='scan'?<ActivityIndicator size="compact"/>:undefined}
|
icon={busy==='scan'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}</Button>}/>
|
onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}</Button>}>
|
||||||
|
<div data-enrollment-reveal="devices">
|
||||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
|
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
|
||||||
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||||
{!!state?.candidates?.length&&<ResourceList aria-label="Найденные устройства K1">
|
{!!state?.candidates?.length&&<ResourceList aria-label="Найденные устройства K1">
|
||||||
{state.candidates.map(value=><li key={value.id}><ResourceRow title={value.name}
|
{state.candidates.map(value=><li key={value.id}><ResourceRow title={value.name}
|
||||||
actions={<Button disabled={pending||needsScan||device===value.id}
|
actions={<Button disabled={pending||needsScan||bluetoothFailure||device===value.id}
|
||||||
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
|
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
|
||||||
</ResourceList>}
|
</ResourceList>}
|
||||||
{selected&&!confirmed&&<>
|
</div>
|
||||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите сеть, доступную бортовому компьютеру." actions={
|
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||||
|
</SettingsCard>
|
||||||
|
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
||||||
|
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
|
||||||
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}/>
|
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}>
|
||||||
|
{!confirmed&&<>
|
||||||
|
<div data-enrollment-reveal="networks">
|
||||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
|
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
|
||||||
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
|
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
|
||||||
onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>}
|
onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>}
|
||||||
|
</div>
|
||||||
<TextField label="Название сети Wi-Fi" value={ssid} onChange={event=>{setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/>
|
<TextField label="Название сети Wi-Fi" value={ssid} onChange={event=>{setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/>
|
||||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
|
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
|
||||||
<ResourceRow title="Проверка подключения" description="Передадим настройки Wi-Fi сканеру K1 и проверим связь с БК." actions={
|
|
||||||
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
|
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
|
||||||
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
|
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}</Button>}/>
|
onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}</Button>
|
||||||
{attempt&&attempt.phase==='network_outcome_unknown'&&enrollmentAllowed(state,'verify')&&
|
{attempt&&attempt.phase==='network_outcome_unknown'&&enrollmentAllowed(state,'verify')&&
|
||||||
<Button disabled={pending} aria-busy={busy==='verify'}
|
<Button disabled={pending} aria-busy={busy==='verify'}
|
||||||
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
|
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
|
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
|
||||||
</>}
|
</>}
|
||||||
|
{notice&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||||
|
</SettingsCard></div>}
|
||||||
</>}
|
</>}
|
||||||
{notice&&<SettingsCard title={notice}/>}
|
{notice&&!selected&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||||
<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
</>,
|
</div>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
|||||||
}
|
}
|
||||||
const code=attempt?.public_error_code??state.command_result?.error_code;
|
const code=attempt?.public_error_code??state.command_result?.error_code;
|
||||||
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
|
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
|
||||||
|
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(state.connected)return 'K1 подключён к БК.';
|
if(state.connected)return 'K1 подключён к БК.';
|
||||||
@@ -125,6 +126,13 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function enrollmentBluetoothFailure(state:EnrollmentState|null):boolean {
|
||||||
|
const attempt=connectionAttempt(state);
|
||||||
|
return !!attempt&&attempt.status==='failed'&&attempt.side_effect_status==='none'&&
|
||||||
|
(attempt.public_error_code==='network-provision-candidate-not-fresh'||
|
||||||
|
(attempt.stage==='connect-failed'&&['BleakError','BleakDBusError','BleakDeviceNotFoundError','TimeoutError'].includes(attempt.public_error_code??'')));
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnrollmentProof {
|
export interface EnrollmentProof {
|
||||||
runtime:string; generation:number|undefined; modeRevision:number|undefined;
|
runtime:string; generation:number|undefined; modeRevision:number|undefined;
|
||||||
device:string; session:string;
|
device:string; session:string;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Reveal a newly available workflow block inside its existing scroll surface.
|
||||||
|
* Never scroll the page behind the modal or move keyboard focus.
|
||||||
|
*/
|
||||||
|
export function revealEnrollment(target:HTMLElement):void {
|
||||||
|
let scroller=target.parentElement;
|
||||||
|
while(scroller){
|
||||||
|
if(/auto|scroll/.test(getComputedStyle(scroller).overflowY)&&scroller.scrollHeight>scroller.clientHeight)break;
|
||||||
|
scroller=scroller.parentElement;
|
||||||
|
}
|
||||||
|
if(!scroller||scroller===document.body||scroller===document.documentElement)return;
|
||||||
|
const viewport=scroller.getBoundingClientRect();
|
||||||
|
const block=target.getBoundingClientRect();
|
||||||
|
const visible=block.top>=viewport.top&&Math.min(block.bottom,block.top+scroller.clientHeight)<=viewport.bottom;
|
||||||
|
if(visible)return;
|
||||||
|
// Large steps align their heading; short results move only enough to become visible.
|
||||||
|
const delta=block.height>scroller.clientHeight||block.top<viewport.top
|
||||||
|
?block.top-viewport.top:block.bottom-viewport.bottom;
|
||||||
|
scroller.scrollTo({top:scroller.scrollTop+delta,behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});
|
||||||
|
}
|
||||||
@@ -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.2"
|
VERSION = "0.1.3"
|
||||||
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",
|
||||||
|
|||||||
@@ -147,7 +147,20 @@ class NodeBridge:
|
|||||||
async def invoke_journalled(self, action: str, payload: dict, identifier: str) -> dict:
|
async def invoke_journalled(self, action: str, payload: dict, identifier: str) -> dict:
|
||||||
try:
|
try:
|
||||||
return await self.invoke(action, payload, identifier)
|
return await self.invoke(action, payload, identifier)
|
||||||
except Exception:
|
except Exception as error:
|
||||||
|
# Journalled device failures are returned as normal operation
|
||||||
|
# results, so the HTTP exception logger never sees them. Record
|
||||||
|
# only source locations here; exception text may contain a frame.
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"K1 journalled invocation failed: action=%s operation=%s exception=%s locations=%s",
|
||||||
|
action,
|
||||||
|
identifier,
|
||||||
|
type(error).__name__,
|
||||||
|
";".join(
|
||||||
|
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
|
||||||
|
for frame in traceback.extract_tb(error.__traceback__)[-10:]
|
||||||
|
),
|
||||||
|
)
|
||||||
# Read the exact result after a failure; never repeat an invocation
|
# Read the exact result after a failure; never repeat an invocation
|
||||||
# or interpret an exception as evidence that a command was unsent.
|
# or interpret an exception as evidence that a command was unsent.
|
||||||
result = await self.invoke("state.read", {}, identifier + "-observe")
|
result = await self.invoke("state.read", {}, identifier + "-observe")
|
||||||
|
|||||||
@@ -70,6 +70,33 @@ def bridge():
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplog):
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
|
secret = secrets.token_hex(20)
|
||||||
|
device = bridge()
|
||||||
|
calls = []
|
||||||
|
identifier = "op_" + "c" * 32
|
||||||
|
native = plugin_operation_id(identifier)
|
||||||
|
|
||||||
|
async def invoke(action, payload, _identifier):
|
||||||
|
calls.append(action)
|
||||||
|
if action == "network.provision":
|
||||||
|
raise BleakError(secret)
|
||||||
|
return {"operations": [{"operation_id": native, "status": "failed"}]}
|
||||||
|
|
||||||
|
device.invoke = invoke
|
||||||
|
result = asyncio.run(device.invoke_journalled(
|
||||||
|
"network.provision", {"operation_id": native, "password": secret}, identifier,
|
||||||
|
))
|
||||||
|
assert result["operations"][0]["status"] == "failed"
|
||||||
|
assert calls == ["network.provision", "state.read"]
|
||||||
|
assert "exception=BleakError" in caplog.text
|
||||||
|
assert "test_node_k1_bridge.py:invoke:" in caplog.text
|
||||||
|
assert identifier in caplog.text
|
||||||
|
assert secret not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
||||||
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
||||||
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
||||||
|
|||||||
Reference in New Issue
Block a user