fix(node): clarify wireless enrollment and restore active K1 after upgrade

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 13:02:28 +03:00
parent ef1c1f50de
commit 5c4546a2a6
10 changed files with 229 additions and 18 deletions
@@ -130,3 +130,29 @@ test('a confirmed scan with zero candidates remains a valid empty result',async(
})};
assert.deepEqual((await api.enroll(transport,initial,'scan')).candidates,[]);
});
test('finishing enrollment requires a verified current device session and survives no stale fence',()=>{
const connected={...initial,connected:true,discovery_generation:3,mode_revision:2,
device_session:{device_session_id:'session-one'},command_result:{status:'succeeded'}};
assert.equal(api.enrollmentProof(initial,'synthetic-ble'),null);
const proof=api.enrollmentProof(connected,'synthetic-ble');
assert.ok(proof);
assert.equal(api.enrollmentProofCurrent(proof,connected,'synthetic-ble'),true);
for(const patch of [{fresh:false},{available:false},{connected:false},{runtime_id:'other-runtime'},
{discovery_generation:4},{mode_revision:3},{selected_device_id:'other-device'},
{device_session:{device_session_id:'other-session'}},{command_result:{status:'rejected'}}]){
assert.equal(api.enrollmentProofCurrent(proof,{...connected,...patch},'synthetic-ble'),false);
}
assert.equal(api.enrollmentProofCurrent(null,connected,'synthetic-ble'),false);
assert.equal(api.enrollmentProofCurrent(proof,connected,'other-device'),false);
});
test('lost BLE candidate tells the operator to rescan without blaming Ethernet or Wi-Fi credentials',()=>{
const notice=api.enrollmentNotice({...initial,connection_attempt:{
schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',
public_error_code:'network-provision-candidate-not-fresh',
}});
assert.match(notice,/Найдите K1 ещё раз/);
assert.match(notice,/Настройки Wi-Fi не были отправлены/);
assert.doesNotMatch(notice,/пароль|Ethernet/);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.2"
VERSION = "0.8.3"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
+8
View File
@@ -15,6 +15,14 @@ case "$1" in
systemctl enable mission-core-node.service
systemctl restart mission-core-node.service
systemctl try-restart mission-core-realsense.service
if [ -f /run/mission-core-node-k1-upgrade-active ]; then
# A jointly upgraded plugin starts itself after its own configuration.
# Restore it here only when that package is already configured.
if [ "$(dpkg-query -W -f='${Status}' mission-core-xgrids-k1 2>/dev/null || true)" = "install ok installed" ]; then
systemctl start mission-core-k1.service
fi
rm -f /run/mission-core-node-k1-upgrade-active
fi
fi
;;
esac
+4 -1
View File
@@ -28,7 +28,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
esac
# End the admitted idle worker before dpkg replaces its Python modules.
# New UI commands now fail unavailable instead of racing the package copy.
if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then
if systemctl is-active --quiet mission-core-k1.service; then
# Preserve an optional worker's prior running state across a Node-only
# update. /run is root-owned; the plugin still owns initial activation.
(umask 077; : > /run/mission-core-node-k1-upgrade-active)
systemctl stop mission-core-k1.service
fi
fi
@@ -0,0 +1,65 @@
# Wireless enrollment sequence R5
The owner confirmed onboard Bluetooth discovery and requested visible device
rows, no redundant bottom Close button, and no Connect footer before a
successful connection check. The board is connected to its router by Ethernet;
only K1 needs that router's Wi-Fi. LinuxWifiAssociationProbe already represents
Ethernet as a valid not-wifi path with continuity evidence. No host Wi-Fi
association or network switching is added.
## Observed R4 connection result
The operation completed at 09:44:26 UTC with action=connect, status=failed,
error_code=network-provision-candidate-not-fresh, phase=network_not_applied and
side_effect_status=none. The selected native BLE handle was no longer usable;
this attempt did not transmit the network settings. The exact preceding event
that invalidated that handle is not established by the available projection.
Do not describe this as a wrong Wi-Fi password, an Ethernet failure or a K1
station refusal. Private journal inspection without interactive sudo was
unavailable; no authentication bypass was used.
## Interface sequence
WirelessEnrollmentWindow keeps the top close action and omits the footer when
there are no contributed actions. The supported-model selector remains first.
The K1 contribution renders discovered devices as canonical ResourceList /
ResourceRow entries with explicit Select actions. Wi-Fi appears after selection.
Check connection explicitly says it sends the entered Wi-Fi settings to K1 and
checks communication with the board. It uses the existing single provision
intent and observes its owned bootstrap; it does not issue a second command.
The separate read-only state check is reserved for an unconfirmed network
outcome, preserving recovery without another provisioning write.
Connect appears only after the dialog obtains a successful current connection
proof, including the exact runtime, discovery generation, mode revision,
selected device and device session. Closing with this button finishes the
dialog and refreshes inventory; it does not provision again or create another
backend enrollment phase. A restart, outage, new scan, changed mode/device or
changed session invalidates the displayed completion action. The ordinary
backend session is established by the verified connection itself.
A candidate-not-fresh result explicitly asks for a new Bluetooth scan and says
that Wi-Fi settings were not sent. The failed selection is cleared and cannot
be reselected from that same invalid generation in the open dialog. No runtime
fence or BLE safety rule was relaxed. The device protocol, optional K1 backend,
Rerun profiles and recovery supervisors are unchanged.
## Standalone Node upgrade
Inspection found that Node preinst stopped K1, while Node postinst left it
stopped during a Node-only update. Preinst now records a previously active K1
worker in a root-owned /run marker; postinst restores it if its optional package
is already configured. A jointly upgraded plugin starts itself after its own
configuration. An inactive plugin is not started by this path.
The real shell scripts were executed against isolated OS paths and fake OS
commands for active/configured, inactive/configured and active/unpacked cases.
The focused Python suite has 21 passing tests, including Ethernet without a
host Wi-Fi request and retention of a usable native BLE capture after scanning.
The Core frontend suite has 791 passing tests; Core TypeScript and production
build passed. Ruff, shell syntax and diff checks passed.
Node 0.8.3 is the replacement UI/host package. Installed optional K1
0.1.2+private.1 is retained. Exact artifact and installation acceptance follows;
a new fresh-cache UI connection, live streams and recovery remain pending.
@@ -1,5 +1,5 @@
import {useState} from 'react';
import {Button,Select,Window,WindowFooterActions} from '@nodedc/ui-react';
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{
@@ -11,7 +11,7 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
footer={<WindowFooterActions><Button onClick={onClose}>Закрыть</Button>{actions}</WindowFooterActions>}>
footer={actions?<WindowFooterActions>{actions}</WindowFooterActions>:undefined}>
<div className="sensor-content">
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
@@ -1,9 +1,9 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,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 {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,type EnrollmentState} from './enrollment';
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorEnrollmentProps){
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
const [state,setState]=useState<EnrollmentState|null>(null);
const [device,setDevice]=useState('');
const [ssid,setSSID]=useState('');
@@ -13,6 +13,8 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
const [busy,setBusy]=useState('loading');
const [error,setError]=useState('');
const [scanned,setScanned]=useState(false);
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
const [needsScan,setNeedsScan]=useState(false);
const lifetime=useRef<AbortController|null>(null);
const running=useRef(false);
@@ -42,17 +44,21 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
const notice=state?enrollmentNotice(state):'';
useEffect(()=>{
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
setVerified(null);
setNeedsScan(false);
},[state?.runtime_id,state?.discovery_generation,state?.mode_revision]);
useEffect(()=>{setScanned(false);},[state?.runtime_id,state?.mode_revision]);
const ready=state?.available&&state.fresh!==false&&!!state.runtime_id;
const selected=state?.candidates?.some(value=>value.id===device)??false;
const pending=!!busy||waiting;
const confirmed=enrollmentProofCurrent(verified,state,device);
async function run(action:'scan'|'networks'|'connect'|'verify'){
if(!state||busy||running.current)return;
running.current=true;
const signal=lifetime.current?.signal;
setBusy(action);setError('');
if(action!=='networks')setVerified(null);
if(action==='scan')setScanned(false);
const parameters=action==='connect'||action==='verify'?{
device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,
@@ -68,6 +74,10 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
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'){
setNeedsScan(true);setDevice('');
}
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
}catch(cause){
if(!signal?.aborted)setError(cause instanceof Error?cause.message:'Не удалось выполнить действие K1.');
@@ -78,12 +88,11 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
}
return renderWindow({busy:pending,
actions:selected?<Button variant="primary" disabled={!ready||pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)}
aria-busy={busy==='connect'} icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('connect')}>{busy==='connect'?'Подключаем K1':'Подключить'}</Button>:undefined,
actions:confirmed?<Button variant="primary" disabled={pending}
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
content:<>
<SettingsCard title={state?.name||'Бортовой компьютер'}
description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':'Подключение недоступно'}</StatusBadge>}/>
</SettingsCard>
@@ -95,10 +104,12 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}</Button>}/>
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
{!!state?.candidates?.length&&<Select label="Устройство K1" value={device}
options={[{value:'',label:'Выберите K1',disabled:true},...state.candidates.map(value=>({value:value.id,label:value.name}))]}
onChange={value=>{setDevice(value);setPassword('');}} disabled={pending}/>}
{selected&&<>
{!!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>)}
</ResourceList>}
{selected&&!confirmed&&<>
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите сеть, доступную бортовому компьютеру." actions={
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}/>
@@ -107,9 +118,14 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>}
<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"/>
<Button disabled={pending||!enrollmentAllowed(state,'verify')} aria-busy={busy==='verify'}
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем подключение':'Проверить текущее подключение'}</Button>
<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>}/>
{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&&<SettingsCard title={notice}/>}
@@ -115,6 +115,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
}
const code=attempt?.public_error_code??state.command_result?.error_code;
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
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 подключён к БК.';
@@ -124,6 +125,25 @@ export function enrollmentNotice(state:EnrollmentState):string {
return '';
}
export interface EnrollmentProof {
runtime:string; generation:number|undefined; modeRevision:number|undefined;
device:string; session:string;
}
export function enrollmentProof(state:EnrollmentState,device:string):EnrollmentProof|null {
if(!state.available||state.fresh===false||!state.connected||!state.runtime_id||
state.selected_device_id!==device||!state.device_session?.device_session_id||
['failed','rejected'].includes(state.command_result?.status??''))return null;
return {runtime:state.runtime_id,generation:state.discovery_generation,modeRevision:state.mode_revision,
device,session:state.device_session.device_session_id};
}
export function enrollmentProofCurrent(proof:EnrollmentProof|null,state:EnrollmentState|null,device:string):boolean {
if(!proof||!state)return false;
const current=enrollmentProof(state,device);
return !!current&&Object.entries(proof).every(([key,value])=>current[key as keyof EnrollmentProof]===value);
}
export function bridgeFormValid(ssid:string,password:string):boolean{
const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64;
}
+17
View File
@@ -165,6 +165,8 @@ def test_node_scan_real_service_preserves_operation_identity_and_replay(
"action": "scan", "parameters": {},
}
result = await device.deliver(command)
if not radio_failure:
assert scanner.capture_discovered_device("AA:BB:CC:DD:EE:FF") is not None
repeated = await device.deliver(command)
assert len(calls) == 1
assert result["discovery_generation"] == 1
@@ -307,6 +309,21 @@ def test_networkmanager_ssids_are_not_split_at_escaped_colons():
assert nm_fields(r"field\:network\\name:88:WPA2") == ["field:network\\name", "88", "WPA2"]
def test_wired_board_bridge_does_not_require_host_wifi_association(tmp_path, monkeypatch):
from k1link.device_plugins.xgrids_k1 import linux_host
(tmp_path / "enp1s0").mkdir()
def forbidden(*_args, **_kwargs):
raise AssertionError("Ethernet must not request Wi-Fi association")
monkeypatch.setattr(linux_host, "_run", forbidden)
value = linux_host.LinuxWifiAssociationProbe(tmp_path).observe(interface_name="enp1s0")
assert value["association_state"] == "not-wifi"
assert value["continuity_proven"] is True
assert value["reason_code"] is None
def test_linux_kernel_route_is_matched_route_not_resolved_host(monkeypatch):
from k1link.device_plugins.xgrids_k1 import linux_host
from k1link.device_plugins.xgrids_k1.facade import _classify_host_route
+56
View File
@@ -0,0 +1,56 @@
"""Execute the real maintainer scripts with isolated OS paths and commands."""
import os
import subprocess
from pathlib import Path
import pytest
PACKAGING = Path(__file__).resolve().parents[1] / "apps/node-agent/packaging"
@pytest.mark.parametrize("active,configured", [(True, True), (False, True), (True, False)])
def test_node_update_restores_only_previously_running_configured_plugin(
tmp_path, active, configured,
):
binary = tmp_path / "bin"
binary.mkdir()
run = tmp_path / "run"
(run / "systemd/system").mkdir(parents=True)
release = tmp_path / "os-release"
release.write_text("ID=ubuntu\nVERSION_ID=24.04\n")
state = tmp_path / "active"
if active:
state.touch()
events = tmp_path / "events"
systemctl = binary / "systemctl"
systemctl.write_text('''#!/bin/sh
printf '%s\\n' "$*" >> "$TEST_EVENTS"
case "$1" in
is-active) [ -f "$TEST_ACTIVE" ]; exit $? ;;
show) echo inactive ;;
stop) rm -f "$TEST_ACTIVE" ;;
start) touch "$TEST_ACTIVE" ;;
esac
''')
(binary / "getent").write_text("#!/bin/sh\nexit 0\n")
(binary / "dpkg-query").write_text(
"#!/bin/sh\necho 'install ok " + ("installed" if configured else "unpacked") + "'\n"
)
for file in binary.iterdir():
file.chmod(0o700)
env = dict(os.environ, PATH=str(binary) + os.pathsep + os.environ["PATH"],
TEST_EVENTS=str(events), TEST_ACTIVE=str(state))
for name, args in [("preinst", ["upgrade", "0.8.2"]), ("postinst", ["configure", "0.8.2"])]:
script = tmp_path / name
script.write_text((PACKAGING / name).read_text()
.replace("/run/", str(run) + "/")
.replace("/etc/os-release", str(release)))
subprocess.run(["/bin/sh", str(script), *args], env=env, check=True,
capture_output=True, text=True)
if name == "preinst":
assert not state.exists()
calls = events.read_text().splitlines()
assert ("start mission-core-k1.service" in calls) is (active and configured)
assert state.exists() is (active and configured)
assert not (run / "mission-core-node-k1-upgrade-active").exists()