Unify K1 discovery ownership and verification across enrollment paths

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 20:04:46 +03:00
parent 9c4d70b0e9
commit 762d77ef95
27 changed files with 615 additions and 191 deletions
@@ -3,7 +3,7 @@ import { Button } from "@nodedc/ui-react";
import type { XgridsConnectionAttempt } from "../api";
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
import { BLE_DISCOVERY_FAILURE_MESSAGES, STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
const connectionAttemptStageLabels: Record<string, string> = {
accepted: "Запрос принят",
@@ -97,6 +97,7 @@ function publicConnectionErrorLabel(
structured: ReturnType<typeof hostFailureDiagnosticPresentation>,
): string {
const publicCode = attempt?.public_error_code?.trim();
if (attempt?.side_effect_status === "none" && publicCode && BLE_DISCOVERY_FAILURE_MESSAGES[publicCode]) return BLE_DISCOVERY_FAILURE_MESSAGES[publicCode];
if (publicCode && publicConnectionErrorLabels[publicCode]) {
return publicConnectionErrorLabels[publicCode];
}
@@ -13,13 +13,14 @@ import {
Button,
GlassSurface,
Icon,
IconButton,
Select,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import {K1WifiPasswordField} from "./K1WifiPasswordField";
import type {
BleDevice,
ConnectionVerifyRequest,
@@ -1291,12 +1292,6 @@ export function K1ProvisioningPipeline({
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
if (password.length === 0 && passwordVisible) {
setPasswordVisible(false);
}
}, [password, passwordVisible]);
const connectionMode = desiredMode;
const [successfulLocalConnect, setSuccessfulLocalConnect] =
useState<CompletedLocalNetworkIntent | null>(null);
@@ -2729,7 +2724,6 @@ export function K1ProvisioningPipeline({
setExplicitProvisioningDraft(null);
setSsid("");
setPassword("");
setPasswordVisible(false);
setConnectionAttemptPresentation(null);
setCandidateUnavailableMessage(null);
setReadOnlyReconnectPresentation(null);
@@ -3107,31 +3101,13 @@ export function K1ProvisioningPipeline({
spellCheck={false}
placeholder={modeCopy.ssidPlaceholder}
/>
<div className="password-field-row">
<TextField
label="Пароль WiFi"
hint={connectionAttemptOwnsDraft
? "Удалён после отправки"
: "Только в оперативной памяти"}
type={passwordVisible ? "text" : "password"}
value={connectionAttemptOwnsDraft ? "" : password}
onChange={(event) => setPassword(event.target.value)}
disabled={provisioningFieldsDisabled}
autoComplete="off"
placeholder={connectionAttemptOwnsDraft
? "Пароль передан"
: "Введите пароль"}
/>
<IconButton
label={passwordVisible ? "Скрыть пароль" : "Показать пароль"}
shape="rounded"
disabled={provisioningFieldsDisabled || password.length === 0}
aria-pressed={passwordVisible}
onClick={() => setPasswordVisible((visible) => !visible)}
>
<Icon name={passwordVisible ? "eye-off" : "eye"} />
</IconButton>
</div>
<K1WifiPasswordField
value={connectionAttemptOwnsDraft ? "" : password}
onChange={setPassword}
disabled={provisioningFieldsDisabled}
hint={connectionAttemptOwnsDraft ? "Удалён после отправки" : "Только в оперативной памяти"}
placeholder={connectionAttemptOwnsDraft ? "Пароль передан" : "Введите пароль"}
/>
{applyAction}
</div>
)}
@@ -0,0 +1,7 @@
.password-field-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.55rem;
}
@@ -0,0 +1,21 @@
import {useEffect, useState} from 'react';
import {Icon, IconButton, TextField} from '@nodedc/ui-react';
import './K1WifiPasswordField.css';
/** The same Wi-Fi entry composition for direct and onboard K1 enrollment. */
export function K1WifiPasswordField({value, onChange, disabled, hint, placeholder}: {
value:string; onChange:(value:string)=>void; disabled?:boolean;
hint?:string; placeholder?:string;
}) {
const [visible,setVisible]=useState(false);
useEffect(()=>{if(!value)setVisible(false);},[value]);
return <div className="password-field-row">
<TextField label="Пароль WiFi" type={visible?'text':'password'} value={value}
onChange={event=>onChange(event.target.value)} disabled={disabled}
hint={hint} placeholder={placeholder} autoComplete="off" spellCheck={false}/>
<IconButton label={visible?'Скрыть пароль':'Показать пароль'} shape="rounded"
disabled={disabled||!value} aria-pressed={visible} onClick={()=>setVisible(current=>!current)}>
<Icon name={visible?'eye-off':'eye'}/>
</IconButton>
</div>;
}
@@ -5,3 +5,15 @@ export const STATION_WIFI_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
"k1-wifi-credentials-required":
"K1 не смог подключиться к сети Wi‑Fi: устройство запросило учётные данные сети. Проверьте название сети и пароль, затем укажите сеть заново.",
};
/** These failures precede Connect and the Wi-Fi write, on either host. */
export const BLE_DISCOVERY_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
'ble-discovery-busy':
'Bluetooth-поиск уже выполняется. Дождитесь его завершения и повторите подключение. Настройки Wi-Fi не были отправлены.',
'ble-adapter-unavailable':
'Bluetooth недоступен на компьютере, к которому подключается K1. Включите Bluetooth и повторите подключение. Настройки Wi-Fi не были отправлены.',
'ble-discovery-failed':
'Служба Bluetooth не смогла выполнить поиск K1. Повторите поиск устройства. Настройки Wi-Fi не были отправлены.',
'ble-selected-device-unavailable':
'Выбранный K1 не найден по Bluetooth. Убедитесь, что он включён рядом с компьютером, и найдите K1 ещё раз. Настройки Wi-Fi не были отправлены.',
};
@@ -1,7 +1,7 @@
import { ApiError, type ConnectRequest, type XgridsK1State, type XgridsOperation } from "./api";
import { operationByIdempotencyKey } from "./lifecycle";
import { selectMonotonicXgridsState } from "./stateOrdering";
import { STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation";
import { BLE_DISCOVERY_FAILURE_MESSAGES, STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation";
const CONTROL_STATE_READ_INTERVAL_MS = 250;
const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000;
@@ -165,6 +165,7 @@ export function networkProvisionFailureMessage(
if (!operation || operation.status !== "failed") return null;
const code = operation.error?.code;
if (typeof code !== "string") return null;
if (operation.error?.side_effect_status === "none" && BLE_DISCOVERY_FAILURE_MESSAGES[code]) return BLE_DISCOVERY_FAILURE_MESSAGES[code];
if (STATION_WIFI_FAILURE_MESSAGES[code]) return STATION_WIFI_FAILURE_MESSAGES[code];
if (code === "BleakGATTProtocolError") {
@@ -2,6 +2,7 @@ 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,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
import {K1WifiPasswordField} from '../components/K1WifiPasswordField';
import {revealEnrollment} from './revealEnrollment';
export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWindow}:SensorEnrollmentProps){
@@ -157,7 +158,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
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"/>
<K1WifiPasswordField value={password} onChange={setPassword} disabled={pending}/>
<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'?'Применяем Wi-Fi и проверяем':'Применить Wi-Fi и проверить'}</Button>
@@ -1,6 +1,6 @@
import type {EnrollmentState,EnrollmentCommand,EnrollmentOperation,EnrollmentTransport} from '@mission-core/sensor-sdk';
import type {XgridsConnectionAttemptSummary} from '../connectionAttempt';
import {STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation';
import {BLE_DISCOVERY_FAILURE_MESSAGES,STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation';
export type {EnrollmentState,EnrollmentTransport} from '@mission-core/sensor-sdk';
export function connectionAttempt(state:EnrollmentState|null):XgridsConnectionAttemptSummary|null {
@@ -115,6 +115,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
return ''; // The pending action is already shown inside its button.
}
const code=attempt?.public_error_code??state.command_result?.error_code;
if(attempt?.side_effect_status==='none'&&code&&BLE_DISCOVERY_FAILURE_MESSAGES[code])return BLE_DISCOVERY_FAILURE_MESSAGES[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 заново.';
@@ -123,6 +124,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
if(state.connected)return 'K1 подключён к БК.';
if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
if(attempt?.side_effect_status==='none'&&attempt.stage==='resolution-failed')return 'Не удалось подготовить Bluetooth-соединение с K1. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и повторите подключение.';
if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.';
return '';
}
@@ -131,7 +133,8 @@ 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??'')));
attempt.public_error_code==='ble-selected-device-unavailable'||
(['resolution-failed','connect-failed','gatt-contract-failed','baseline-read-failed'].includes(attempt.stage)&&['BleakError','BleakDBusError','BleakDeviceNotFoundError','TimeoutError'].includes(attempt.public_error_code??'')));
}
export interface EnrollmentProof {
@@ -502,14 +502,6 @@ box-sizing: border-box;
gap: 0.85rem;
}
.password-field-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.55rem;
}
.connection-recovery-choice {
display: grid;
min-width: 0;
+2 -2
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.11"
VERSION = "0.1.12"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -135,7 +135,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: mission-core-node (>= 0.8.11), mission-core-node (<< 0.9.0),
Depends: mission-core-node (>= 0.8.12), mission-core-node (<< 0.9.0),
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
Breaks: mission-core-node (<< 0.8.0)
Replaces: mission-core-node (<< 0.8.0)