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 {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);
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user