Fix Linux route probe contract and expose K1 verification after Wi-Fi apply
This commit is contained in:
@@ -88,6 +88,17 @@ test('Bluetooth failure before dispatch asks for discovery without blaming Wi-Fi
|
|||||||
assert.equal(api.enrollmentBluetoothFailure(unknown),false);
|
assert.equal(api.enrollmentBluetoothFailure(unknown),false);
|
||||||
assert.doesNotMatch(api.enrollmentNotice(unknown),/не были отправлены/);
|
assert.doesNotMatch(api.enrollmentNotice(unknown),/не были отправлены/);
|
||||||
});
|
});
|
||||||
|
test('applied and restored network states admit read-only verification without credentials',async()=>{
|
||||||
|
const f=fixture();
|
||||||
|
const applied={...initial,allowed_actions:['observe-configured-device-network'],connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'previous',status:'failed',stage:'control-bootstrap-failed',phase:'network_applied',side_effect_status:'applied',public_error_code:'TypeError'}};
|
||||||
|
assert.equal(api.enrollmentAllowed(applied,'verify'),true);
|
||||||
|
assert.match(api.enrollmentNotice(applied),/Проверить состояние K1/);
|
||||||
|
assert.equal(api.enrollmentAllowed({...applied,connection_attempt:null},'verify'),true);
|
||||||
|
await api.enroll(f.transport,applied,'verify',{device_id:'synthetic-ble'},f.observer);
|
||||||
|
assert.equal(f.request().action,'verify');
|
||||||
|
assert.deepEqual(f.request().parameters,{device_id:'synthetic-ble'});
|
||||||
|
assert.equal(f.counts().posts,1);
|
||||||
|
});
|
||||||
test('new workflow content scrolls only the containing viewport without stealing focus',()=>{
|
test('new workflow content scrolls only the containing viewport without stealing focus',()=>{
|
||||||
const original={document:globalThis.document,window:globalThis.window,getComputedStyle:globalThis.getComputedStyle};
|
const original={document:globalThis.document,window:globalThis.window,getComputedStyle:globalThis.getComputedStyle};
|
||||||
const calls=[];let reduced=false;
|
const calls=[];let reduced=false;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from build_deb import build, VERSION, BRAND_SHA256
|
from build_deb import build, VERSION, BRAND_SHA256
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad"
|
DG_COMMIT = "5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc"
|
||||||
|
|
||||||
|
|
||||||
def guideline_sources():
|
def guideline_sources():
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.4"
|
VERSION = "0.8.5"
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Applied Wi-Fi and Linux control-path contract R7
|
||||||
|
|
||||||
|
The owner UI attempt on R6 confirmed K1 station Wi-Fi with an assigned private
|
||||||
|
address. The network operation succeeded; its owned read-only control bootstrap
|
||||||
|
then failed with TypeError. The terminal attempt is network_applied,
|
||||||
|
side_effect_status=applied, stage=control-bootstrap-failed. This differs from
|
||||||
|
the preceding pre-dispatch Bluetooth failure. A repeat provisioning write is
|
||||||
|
not the appropriate next action.
|
||||||
|
|
||||||
|
## Reproduced implementation defect
|
||||||
|
|
||||||
|
HostWifiAssociationProbe defines observe(interface_name, *, timeout_seconds).
|
||||||
|
The real service _sample_host_path calls that positional interface contract.
|
||||||
|
LinuxWifiAssociationProbe instead declared interface_name keyword-only.
|
||||||
|
Consequently every real Linux service path observation raised TypeError before
|
||||||
|
examining Ethernet/Wi-Fi or probing the K1 control endpoint. The earlier adapter
|
||||||
|
test called observe by keyword and therefore missed this consumer boundary.
|
||||||
|
|
||||||
|
A new test invokes the actual service host-path and correlated TCP observation
|
||||||
|
with the real Linux adapter and synthetic OS route/sysfs/TCP endpoints. Before
|
||||||
|
the fix it reproduces TypeError at facade.py:_sample_host_path. The Linux adapter
|
||||||
|
now accepts the existing protocol signature. Stable Ethernet reaches a single
|
||||||
|
TCP probe without nmcli or host association; a route change across that probe
|
||||||
|
still fails closed. The caller, route fingerprints, supervisor, Wi-Fi protocol,
|
||||||
|
application authority and MQTT/Rerun profiles are unchanged.
|
||||||
|
|
||||||
|
## Operator continuation and messages
|
||||||
|
|
||||||
|
The modal previously displayed read-only verification only for
|
||||||
|
network_outcome_unknown, leaving an applied network without its prescribed
|
||||||
|
next action. It now exposes Check K1 state whenever the backend permits that
|
||||||
|
read-only action for the explicitly selected device, including an applied or
|
||||||
|
restored configured network. No credentials are required and the existing
|
||||||
|
verification contract remains fenced by runtime/mode/discovery/target.
|
||||||
|
The ordinary check still submits one explicit provisioning intent; final
|
||||||
|
Connect still only completes a currently verified dialog.
|
||||||
|
|
||||||
|
Messages in the modal use SettingsCard align=center. The canonical Design
|
||||||
|
Guideline change is 5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc, including registry,
|
||||||
|
documentation and a catalog example. Centered cards omit an empty body and
|
||||||
|
center their content on both axes; ordinary settings groups keep their existing
|
||||||
|
start layout. No local CSS override, fixed height, new colors or decoration.
|
||||||
|
Node packaging pins this exact DG source.
|
||||||
|
|
||||||
|
## Validation and pending physical acceptance
|
||||||
|
|
||||||
|
The failing reproduction is retained privately. After the fix: 18 NodeBridge /
|
||||||
|
package lifecycle checks passed, four applied-network bootstrap lifecycle checks
|
||||||
|
passed, 23 focused frontend/architecture checks passed and 794 full Core
|
||||||
|
frontend tests passed. Changed Linux/backend files pass Ruff. DG ui-react build,
|
||||||
|
catalog typecheck and registry validation passed. Production package and
|
||||||
|
installation results follow separately.
|
||||||
|
|
||||||
|
Node 0.8.5 and K1 0.1.4 are reserved for this source. The next owner UI test must
|
||||||
|
clear browser cache, select K1 and use read-only Check K1 state to observe the
|
||||||
|
already applied network. It must prove DeviceInfo/control readiness before
|
||||||
|
claiming Bridge or live acceptance. Private screenshot, exact operation,
|
||||||
|
UTC/monotonic manifest and hashed logs are retained under
|
||||||
|
private/acceptance/k1-node085-20260907-core-ui. No agent CLI BLE/MQTT commands
|
||||||
|
were sent. Publication of prior installation reports remains blocked by the
|
||||||
|
earlier automatic review decision; no remote push workaround is used.
|
||||||
@@ -115,14 +115,14 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
||||||
<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':'Подключение недоступно'}</StatusBadge>}/>
|
<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':'Подключение недоступно'}</StatusBadge>}/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
|
{busy!=='loading'&&!ready?<SettingsCard align="center" title="Служба подключения устройств на БК недоступна"
|
||||||
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
||||||
<SettingsCard eyebrow="Шаг 01" title="Bluetooth" description="Найдите K1 рядом с бортовым компьютером и выберите его из списка." 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">
|
<div data-enrollment-reveal="devices">
|
||||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
|
{scanned&&!state?.candidates?.length&&<SettingsCard align="center" 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}
|
||||||
@@ -130,7 +130,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
|
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
|
||||||
</ResourceList>}
|
</ResourceList>}
|
||||||
</div>
|
</div>
|
||||||
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
||||||
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
|
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
|
||||||
@@ -147,15 +147,15 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
<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')&&
|
{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>}
|
{notice&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
||||||
</SettingsCard></div>}
|
</SettingsCard></div>}
|
||||||
</>}
|
</>}
|
||||||
{notice&&!selected&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
{notice&&!selected&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" 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>,
|
</div>,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
|||||||
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 подключён к БК.';
|
||||||
if(attempt?.phase==='network_applied')return 'Настройки Wi-Fi применены. Связь с K1 пока не подтверждена; проверьте текущее подключение.';
|
if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
|
||||||
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
||||||
if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.';
|
if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.';
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -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.3"
|
VERSION = "0.1.4"
|
||||||
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",
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class LinuxWifiAssociationProbe:
|
|||||||
self.sys_net = sys_net
|
self.sys_net = sys_net
|
||||||
self.key = secrets.token_bytes(32)
|
self.key = secrets.token_bytes(32)
|
||||||
|
|
||||||
def observe(self, *, interface_name: str | None, timeout_seconds: float = 30) -> dict:
|
def observe(self, interface_name: str | None, *, timeout_seconds: float = 30) -> dict:
|
||||||
result = {
|
result = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"adapter": "linux-networkmanager",
|
"adapter": "linux-networkmanager",
|
||||||
|
|||||||
@@ -351,6 +351,58 @@ def test_wired_board_bridge_does_not_require_host_wifi_association(tmp_path, mon
|
|||||||
assert value["reason_code"] is None
|
assert value["reason_code"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("route_changes", [False, True])
|
||||||
|
def test_wired_board_real_service_correlates_route_and_control_endpoint(
|
||||||
|
tmp_path, monkeypatch, route_changes,
|
||||||
|
):
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1 import facade, linux_host
|
||||||
|
|
||||||
|
sys_net = tmp_path / "sys-net"
|
||||||
|
(sys_net / "enp1s0").mkdir(parents=True)
|
||||||
|
probe = linux_host.LinuxWifiAssociationProbe(sys_net)
|
||||||
|
service = facade.XgridsK1CompatibilityService(
|
||||||
|
tmp_path / "runtime", host_wifi_association_probe=probe,
|
||||||
|
)
|
||||||
|
path = facade.HostPathProbeResult(
|
||||||
|
available=True, fingerprint="synthetic-route", interface="enp1s0",
|
||||||
|
source_ipv4="192.168.1.2", route_class="direct", reason_code=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(facade, "_inspect_host_path", lambda _target: path)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def tcp(_target):
|
||||||
|
calls.append("tcp")
|
||||||
|
if route_changes:
|
||||||
|
monkeypatch.setattr(facade, "_inspect_host_path", lambda _target: replace(
|
||||||
|
path, fingerprint="new-route", source_ipv4="192.168.1.3",
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade, "_control_endpoint_reachable", tcp)
|
||||||
|
|
||||||
|
def forbidden(*_args, **_kwargs):
|
||||||
|
raise AssertionError("Wired control bootstrap must not request host Wi-Fi")
|
||||||
|
|
||||||
|
monkeypatch.setattr(linux_host, "_run", forbidden)
|
||||||
|
try:
|
||||||
|
observed = service._sample_host_path("192.168.1.7")
|
||||||
|
assert observed.available is True
|
||||||
|
assert observed.interface == "enp1s0"
|
||||||
|
assert observed.reason_code is None
|
||||||
|
assert observed.fingerprint != path.fingerprint
|
||||||
|
assert service._sample_host_path("192.168.1.7").fingerprint == observed.fingerprint
|
||||||
|
endpoint = service._probe_control_endpoint("192.168.1.7")
|
||||||
|
assert calls == ["tcp"]
|
||||||
|
assert endpoint.reachable is (not route_changes)
|
||||||
|
assert endpoint.reason_code == (
|
||||||
|
"host-path-changed-during-tcp-probe" if route_changes else None
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
def test_linux_kernel_route_is_matched_route_not_resolved_host(monkeypatch):
|
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 import linux_host
|
||||||
from k1link.device_plugins.xgrids_k1.facade import _classify_host_route
|
from k1link.device_plugins.xgrids_k1.facade import _classify_host_route
|
||||||
|
|||||||
Reference in New Issue
Block a user