Group onboard wireless steps and trace journalled Bluetooth failures

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 13:27:21 +03:00
parent a0732386f1
commit d7986cb62f
10 changed files with 217 additions and 27 deletions
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {readFileSync,readdirSync} from 'node:fs';
import {createServer} from 'vite';
let server,api,resolveContribution,wirelessContributions;
let server,api,resolveContribution,wirelessContributions,revealEnrollment;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
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'));
({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.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',()=>{
const alpha={kind:'alpha',Detail:()=>null},beta={kind:'beta',Detail:()=>null};
assert.equal(resolveContribution([],{kind:'alpha'}),undefined);
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.3"
VERSION = "0.8.4"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
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
hashed under `private/acceptance/k1-node083-20260907-core-ui`.
One Ubuntu GUI installer is open and awaiting owner sudo authentication.
The latest observed installed versions are still Node 0.8.2 and K1
0.1.2+private.1, both active. Installation and new UI hardware acceptance must
not be inferred from the package build. Git publication remains pending the
The owner completed Ubuntu authentication; the installer exited with code 0.
Readback confirmed Node 0.8.3 and K1 0.1.2+private.1, both active with zero
restarts, started at 13:08:15 MSK. This confirms the Node-only upgrade restored
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
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 {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
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){
const [state,setState]=useState<EnrollmentState|null>(null);
@@ -15,6 +16,9 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
const [scanned,setScanned]=useState(false);
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
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 running=useRef(false);
@@ -41,7 +45,8 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
const attempt=connectionAttempt(state);
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(()=>{
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
setVerified(null);
@@ -52,6 +57,17 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
const selected=state?.candidates?.some(value=>value.id===device)??false;
const pending=!!busy||waiting;
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'){
if(!state||busy||running.current)return;
@@ -72,10 +88,13 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
if(signal?.aborted)return;
onChange();
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
if(action==='scan'){setDevice('');setScanned(true);}
if(action==='networks')setNetworks(result.networks??[]);
if(action==='connect'||action==='verify')setVerified(enrollmentProof(result,device));
if(action==='connect'&&connectionAttempt(result)?.public_error_code==='network-provision-candidate-not-fresh'){
if(action==='scan'){
setDevice('');setScanned(true);setNeedsScan(false);
setDismissedAttempt(connectionAttempt(result)?.attempt_id??'');show('devices');
}
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('');
}
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
@@ -90,7 +109,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
return renderWindow({busy:pending,
actions:confirmed?<Button variant="primary" disabled={pending}
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
content:<>
content:<div className="sensor-content" ref={content}>
<SettingsCard title={state?.name||'Бортовой компьютер'}
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
@@ -98,38 +117,46 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
</SettingsCard>
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
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'}
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 не найден"
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
{!!state?.candidates?.length&&<ResourceList aria-label="Найденные устройства K1">
{state.candidates.map(value=><li key={value.id}><ResourceRow title={value.name}
actions={<Button disabled={pending||needsScan||device===value.id}
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
actions={<Button disabled={pending||needsScan||bluetoothFailure||device===value.id}
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
</ResourceList>}
{selected&&!confirmed&&<>
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите сеть, доступную бортовому компьютеру." actions={
</div>
{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}
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}
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}/>}
</div>
<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"/>
<ResourceRow title="Проверка подключения" description="Передадим настройки Wi-Fi сканеру K1 и проверим связь с БК." actions={
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
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')&&
<Button disabled={pending} aria-busy={busy==='verify'}
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
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('')}/>
</>,
</div>,
});
}
@@ -116,6 +116,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
}
const code=attempt?.public_error_code??state.command_result?.error_code;
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(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
if(state.connected)return 'K1 подключён к БК.';
@@ -125,6 +126,13 @@ export function enrollmentNotice(state:EnrollmentState):string {
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 {
runtime:string; generation:number|undefined; modeRevision:number|undefined;
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'});
}
+1 -1
View File
@@ -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.2"
VERSION = "0.1.3"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -147,7 +147,20 @@ class NodeBridge:
async def invoke_journalled(self, action: str, payload: dict, identifier: str) -> dict:
try:
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
# or interpret an exception as evidence that a command was unsent.
result = await self.invoke("state.read", {}, identifier + "-observe")
+27
View File
@@ -70,6 +70,33 @@ def bridge():
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():
"""Exercise the actual admission boundary, replacing only the BLE service."""
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict