fix(k1): translate onboard operation identities at plugin boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 12:34:48 +03:00
parent 3dffe39c89
commit d2dbfc9c85
9 changed files with 264 additions and 34 deletions
@@ -109,3 +109,24 @@ test('delayed operation result cannot clear an observed board outage',()=>{
assert.equal(result.fresh,false); assert.equal(result.fresh,false);
assert.equal(api.mergeEnrollmentState(result,{...initial,fresh:true,snapshot_revision:3}).fresh,true); assert.equal(api.mergeEnrollmentState(result,{...initial,fresh:true,snapshot_revision:3}).fresh,true);
}); });
test('failed or unknown Bluetooth search is never presented as an empty successful scan',async()=>{
for(const state of ['failed','rejected','unknown']){
let posts=0;
const transport={submit:async command=>{posts++;return {
operation_id:command.operation_id,state:state==='unknown'?'unknown':'complete',
error:'unconfirmed device command',
result:{...initial,candidates:[],command_result:{operation_id:command.operation_id,action:'scan',status:state}},
};}};
await assert.rejects(api.enroll(transport,initial,'scan'),/Не удалось выполнить поиск Bluetooth на БК/);
assert.equal(posts,1);
}
assert.equal(api.enrollmentNotice({...initial,command_result:{action:'scan',status:'failed'}}),'');
});
test('a confirmed scan with zero candidates remains a valid empty result',async()=>{
const transport={submit:async command=>({operation_id:command.operation_id,state:'complete',
result:{...initial,candidates:[],command_result:{operation_id:command.operation_id,action:'scan',status:'succeeded'}},
})};
assert.deepEqual((await api.enroll(transport,initial,'scan')).candidates,[]);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.1" VERSION = "0.8.2"
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,64 @@
# Node K1 operation identity R4
The owner's next Core Fleet UI attempt at 12:19 MSK failed on installed Node
0.8.1 / K1 0.1.1+private.1. The newly added journal diagnostic recorded
`action=scan exception=ValueError`. The board still had discovery generation
zero. Its BlueZ controller was powered and not rfkill-blocked. This was an
execution failure before discovery, not evidence that K1 was absent.
## Reproduced cause
The Node protocol requires `op_` followed by 32 lowercase hexadecimal digits.
The K1 OperationJournal accepts a UUID, optionally prefixed by `op-`. Passing
the Node ID unchanged fails in device_lifecycle._identifier before any BLE
call. R3 corrected the missing runtime fence but its service stub hid this
second boundary mismatch. The new regression reproduced the exact ValueError
with the real NodeBridge, facade, compatibility service and operation journal.
NodeBridge now translates the host ID into the plugin UUID format. The mapping
is deterministic and preserves all 128 bits. Host IDs remain unchanged in the
Go broker, durable Node journals and HTTP responses. The plugin's validators,
LAB callers and lifecycle authority are unchanged. Connection attempt and
recovery IDs are projected back to host IDs so continued UI observation can
still match the exact request after the Wi-Fi acknowledgement.
The same conversion covers scan, provision, verify, START and STOP. The
acquisition prepare child uses a deterministic UUID derived from the host
intent, distinct from its START operation. It no longer appends an invalid
`_prepare` suffix to an operation ID. Repeated intent IDs reach the same journal
row; no random replacement IDs, automatic provisioning replay or migration of
old unknown operations were introduced.
## Error evidence and UI
An invocation failure reads only its exact existing plugin journal row. A
recorded scan failure reaches the caller as failed; an exception before any
row exists remains an unconfirmed execution. Neither is a successful empty
search. Both produce a Bluetooth search error in the shared enrollment UI,
while a confirmed zero-candidate result still displays K1 not found. Search
failures do not display the Wi-Fi connection notice.
Unexpected worker failures log admitted action, exception class and source
function/line locations only. Exception text, source text, local variables,
request payloads and credentials are excluded.
## Validation and limits
The scan regression keeps the actual service, journal, BLE arbiter and scanner
logic, replacing only BleakScanner's OS radio endpoint. It verifies successful
discovery and a failed radio start, correct host result correlation, and one
radio invocation when the exact command is repeated. Acquisition IDs are
checked against the actual OperationJournal. Projection does not mutate the
plugin's native state. Frontend tests distinguish failed, rejected, unknown
and confirmed empty scans and retain network/bootstrap/recovery regressions.
Focused Python bridge, installer, Fleet enrollment, BLE scanner and operation
journal suite: 73 passed. Core frontend suite: 789 passed, no failures or skips.
Build and installation acceptance is recorded below when completed.
Release versions are Node 0.8.2 and optional K1 0.1.2+private.1. Existing private
release bytes and application material are retained. This is a Node adapter
correction; no Rerun profile, protocol frame, MQTT recovery state machine or
shared lifecycle validator was modified. Physical Bluetooth discovery, Bridge,
live camera/LiDAR and interruption recovery still require fresh-cache UI
acceptance. No agent CLI hardware command is part of this test.
+1 -1
View File
@@ -5,7 +5,7 @@ export interface EnrollmentState {
networks?:{ssid:string;signal:number;security:string}[]; networks?:{ssid:string;signal:number;security:string}[];
connected?:boolean; ready_to_start?:boolean; selected_device_id?:string; ip?:string; connected?:boolean; ready_to_start?:boolean; selected_device_id?:string; ip?:string;
snapshot_revision?:number; runtime_started_at?:string; allowed_actions?:string[]; snapshot_revision?:number; runtime_started_at?:string; allowed_actions?:string[];
connection_attempt?:unknown; command_result?:{operation_id:string;status:string;error_code?:string}; connection_attempt?:unknown; command_result?:{operation_id:string;action?:string;status:string;error_code?:string};
device_session?:{device_session_id:string;device_id:string}; observed_at?:string; device_session?:{device_session_id:string;device_id:string}; observed_at?:string;
} }
export interface EnrollmentCommand { export interface EnrollmentCommand {
@@ -45,6 +45,7 @@ export interface EnrollmentObserver {
pause?:()=>Promise<void>; pause?:()=>Promise<void>;
} }
const unknownResult='Результат подключения пока не подтверждён. Обновите состояние K1 перед новой попыткой.'; const unknownResult='Результат подключения пока не подтверждён. Обновите состояние K1 перед новой попыткой.';
const scanFailure='Не удалось выполнить поиск Bluetooth на БК. Проверьте Bluetooth на бортовом компьютере и повторите поиск.';
/** Submit once. HTTP delivery and the device's network/control result are separate. */ /** Submit once. HTTP delivery and the device's network/control result are separate. */
export async function enroll(transport:EnrollmentTransport,initial:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={},observer:EnrollmentObserver={}):Promise<EnrollmentState>{ export async function enroll(transport:EnrollmentTransport,initial:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={},observer:EnrollmentObserver={}):Promise<EnrollmentState>{
@@ -76,6 +77,9 @@ export async function enroll(transport:EnrollmentTransport,initial:EnrollmentSta
try{operation=await transport.operation(command.operation_id);}catch{operation=null;} try{operation=await transport.operation(command.operation_id);}catch{operation=null;}
} }
if(action==='scan'||action==='networks'){ if(action==='scan'||action==='networks'){
if(action==='scan'&&(operation.state!=='complete'||!operation.result||
(state.command_result?.operation_id===command.operation_id&&state.command_result.status!=='succeeded')))
throw new Error(scanFailure);
if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||unknownResult); if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||unknownResult);
return state; return state;
} }
@@ -106,6 +110,7 @@ export async function enroll(transport:EnrollmentTransport,initial:EnrollmentSta
export function enrollmentNotice(state:EnrollmentState):string { export function enrollmentNotice(state:EnrollmentState):string {
const attempt=connectionAttempt(state); const attempt=connectionAttempt(state);
if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.'; if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.';
if(state.command_result?.action==='scan')return ''; // Scan failures belong to the search toast, not a Wi-Fi notice.
if(attempt?.status==='accepted'||attempt?.status==='running'){ if(attempt?.status==='accepted'||attempt?.status==='running'){
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.'; return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
} }
+1 -1
View File
@@ -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.1" VERSION = "0.1.2"
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",
@@ -8,8 +8,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
import traceback
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid5
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -42,6 +45,29 @@ class NodeEnrollmentRejected(ValueError):
super().__init__(code) super().__init__(code)
def plugin_operation_id(node_id: str, *, stage: str | None = None) -> str:
"""Translate the host's opaque ID without changing either journal contract.
The same host intent always reaches the same plugin row. A composite action
uses deterministic child UUIDs so prepare and start cannot alias each other.
"""
if not isinstance(node_id, str) or re.fullmatch(r"op_[0-9a-f]{32}", node_id) is None:
raise NodeEnrollmentRejected("invalid-operation-id")
identifier = UUID(hex=node_id[3:])
if stage is not None:
identifier = uuid5(identifier, stage)
return f"op-{identifier}"
def node_operation_id(plugin_id: str | None) -> str | None:
"""Project only canonical plugin UUIDs back to the host correlation ID."""
if isinstance(plugin_id, str) and re.fullmatch(
r"op-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", plugin_id
):
return "op_" + UUID(plugin_id[3:]).hex
return plugin_id
class NodeBridge: class NodeBridge:
def __init__(self, repository_root: Path, *, service=None): def __init__(self, repository_root: Path, *, service=None):
self.rerun = NodeRerunHub() self.rerun = NodeRerunHub()
@@ -64,6 +90,11 @@ class NodeBridge:
@staticmethod @staticmethod
def project(snapshot: dict) -> dict: def project(snapshot: dict) -> dict:
lifecycle = snapshot.get("connection_lifecycle", {}) lifecycle = snapshot.get("connection_lifecycle", {})
attempt = compact_connection_attempt(snapshot.get("connection_attempt"))
if attempt is not None:
for key in ("attempt_id", "recovery_operation_id"):
if key in attempt:
attempt[key] = node_operation_id(attempt[key])
return { return {
"available": True, "available": True,
"model": "XGRIDS K1", "model": "XGRIDS K1",
@@ -81,7 +112,7 @@ class NodeBridge:
"connected": lifecycle.get("connection_ready") is True "connected": lifecycle.get("connection_ready") is True
and snapshot.get("active_connection_mode") == "bridge", and snapshot.get("active_connection_mode") == "bridge",
"ready_to_start": lifecycle.get("ready_to_start") is True, "ready_to_start": lifecycle.get("ready_to_start") is True,
"connection_attempt": compact_connection_attempt(snapshot.get("connection_attempt")), "connection_attempt": attempt,
"allowed_actions": [ "allowed_actions": [
action action
for action in lifecycle.get("allowed_actions", []) for action in lifecycle.get("allowed_actions", [])
@@ -113,6 +144,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:
# 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")
if not any(
item.get("operation_id") == payload["operation_id"]
for item in result.get("operations", [])
):
raise
return result
async def execute(self, command: dict) -> dict: async def execute(self, command: dict) -> dict:
try: try:
return await self._execute(command) return await self._execute(command)
@@ -128,6 +173,7 @@ class NodeBridge:
result = await self.state() result = await self.state()
result["command_result"] = { result["command_result"] = {
"operation_id": command["operation_id"], "operation_id": command["operation_id"],
"action": command["action"],
"status": "rejected", "status": "rejected",
"error_code": error.code, "error_code": error.code,
} }
@@ -135,6 +181,7 @@ class NodeBridge:
async def _execute(self, command: dict) -> dict: async def _execute(self, command: dict) -> dict:
action, identifier = command["action"], command["operation_id"] action, identifier = command["action"], command["operation_id"]
plugin_identifier = plugin_operation_id(identifier)
parameters = command.get("parameters", {}) parameters = command.get("parameters", {})
if action not in {"scan", "networks", "connect", "verify"}: if action not in {"scan", "networks", "connect", "verify"}:
raise NodeEnrollmentRejected("unsupported-action") raise NodeEnrollmentRejected("unsupported-action")
@@ -147,11 +194,11 @@ class NodeBridge:
if action == "networks": if action == "networks":
return {**state, "networks": await asyncio.to_thread(wifi_networks)} return {**state, "networks": await asyncio.to_thread(wifi_networks)}
if action == "scan": if action == "scan":
result = await self.invoke( result = await self.invoke_journalled(
"discovery.scan", "discovery.scan",
{ {
"duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, "duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
"operation_id": identifier, "operation_id": plugin_identifier,
"expected_snapshot_runtime_id": command["runtime_id"], "expected_snapshot_runtime_id": command["runtime_id"],
}, },
identifier, identifier,
@@ -168,7 +215,7 @@ class NodeBridge:
payload = { payload = {
"device_id": parameters["device_id"], "device_id": parameters["device_id"],
"compatibility_attestation": ATTESTATION, "compatibility_attestation": ATTESTATION,
"operation_id": identifier, "operation_id": plugin_identifier,
"expected_mode_revision": parameters["mode_revision"], "expected_mode_revision": parameters["mode_revision"],
"expected_discovery_generation": parameters["discovery_generation"], "expected_discovery_generation": parameters["discovery_generation"],
"expected_snapshot_runtime_id": state["runtime_id"], "expected_snapshot_runtime_id": state["runtime_id"],
@@ -177,26 +224,16 @@ class NodeBridge:
payload.update( payload.update(
connection_mode="bridge", connection_mode="bridge",
allow_host_wifi_switch=False, allow_host_wifi_switch=False,
idempotency_key=identifier, idempotency_key=plugin_identifier,
ssid=parameters.get("ssid"), ssid=parameters.get("ssid"),
password=parameters.get("password"), password=parameters.get("password"),
) )
try: try:
result = await self.invoke( result = await self.invoke_journalled(
"network.provision" if action == "connect" else "connection.verify", "network.provision" if action == "connect" else "connection.verify",
payload, payload,
identifier, identifier,
) )
except Exception:
# A rejected/failed invocation may already have a durable
# result. Read that exact journal row; never resend the
# network command and never serialize exception/payload text.
result = await self.invoke("state.read", {}, identifier + "-observe")
if not any(
item.get("operation_id") == identifier
for item in result.get("operations", [])
):
raise
finally: finally:
payload.pop("password", None) payload.pop("password", None)
parameters.pop("password", None) parameters.pop("password", None)
@@ -205,7 +242,7 @@ class NodeBridge:
( (
item item
for item in reversed(result.get("operations", [])) for item in reversed(result.get("operations", []))
if item.get("operation_id") == identifier if item.get("operation_id") == plugin_identifier
), ),
None, None,
) )
@@ -213,6 +250,7 @@ class NodeBridge:
error = operation.get("error") or {} error = operation.get("error") or {}
projected["command_result"] = { projected["command_result"] = {
"operation_id": identifier, "operation_id": identifier,
"action": action,
"status": operation.get("status"), "status": operation.get("status"),
"error_code": error.get("code"), "error_code": error.get("code"),
} }
@@ -291,15 +329,19 @@ def create_app(repository_root: Path):
body = await request.json() body = await request.json()
return await bridge.deliver(body) return await bridge.deliver(body)
except Exception as error: except Exception as error:
# Class and admitted action are sufficient for transport diagnosis. # Source locations identify the failing boundary without exception
# Never log the exception text, incoming payload or credentials. # text, source lines, local variables, payloads or credentials.
action = body.get("action") if isinstance(body, dict) else None action = body.get("action") if isinstance(body, dict) else None
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"K1 operation failed: action=%s exception=%s", "K1 operation failed: action=%s exception=%s locations=%s",
action action
if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"} if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"}
else "invalid", else "invalid",
type(error).__name__, type(error).__name__,
";".join(
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
for frame in traceback.extract_tb(error.__traceback__)[-8:]
),
) )
return JSONResponse( return JSONResponse(
{ {
@@ -11,7 +11,7 @@ from .facade import (
XGRIDS_K1_PLUGIN_VERSION, XGRIDS_K1_PLUGIN_VERSION,
ViewerSettingsRequest, ViewerSettingsRequest,
) )
from .node_bridge import ATTESTATION from .node_bridge import ATTESTATION, plugin_operation_id
PHYSICAL_ACCEPTANCE_KEYS = ( PHYSICAL_ACCEPTANCE_KEYS = (
"operator_present", "operator_present",
@@ -137,6 +137,7 @@ class NodeK1Sensor:
command.get("parameters", {}), command.get("parameters", {}),
command["operation_id"], command["operation_id"],
) )
plugin_identifier = plugin_operation_id(identifier)
if action == "details": if action == "details":
return item return item
if action == "close-peer": if action == "close-peer":
@@ -170,7 +171,9 @@ class NodeK1Sensor:
return project_sensor(result, node_id) return project_sensor(result, node_id)
if action == "verify": if action == "verify":
result = await self.bridge.invoke( result = await self.bridge.invoke(
"connection.verify", {"expected_snapshot_runtime_id": runtime}, identifier "connection.verify",
{"expected_snapshot_runtime_id": runtime, "operation_id": plugin_identifier},
identifier,
) )
return project_sensor(result, node_id) return project_sensor(result, node_id)
if action not in {"start", "stop"} or params.get("operator_confirmed") is not True: if action not in {"start", "stop"} or params.get("operator_confirmed") is not True:
@@ -187,8 +190,8 @@ class NodeK1Sensor:
result = await self.bridge.invoke( result = await self.bridge.invoke(
"acquisition.stop", "acquisition.stop",
{ {
"operation_id": identifier, "operation_id": plugin_identifier,
"idempotency_key": identifier, "idempotency_key": plugin_identifier,
"acquisition_id": params.get("acquisition_id"), "acquisition_id": params.get("acquisition_id"),
"mode": "graceful", "mode": "graceful",
"physical_acceptance": acceptance, "physical_acceptance": acceptance,
@@ -223,10 +226,11 @@ class NodeK1Sensor:
payload.update(self.cas(state, acquisition=False), operator_confirmed=True) payload.update(self.cas(state, acquisition=False), operator_confirmed=True)
elif phase == "workspace-ready" and acquisition.get("state") != "prepared": elif phase == "workspace-ready" and acquisition.get("state") != "prepared":
next_action = "acquisition.prepare" next_action = "acquisition.prepare"
prepare_identifier = plugin_operation_id(identifier, stage=next_action)
payload.update( payload.update(
self.cas(state), self.cas(state),
operation_id=identifier + "_prepare", operation_id=prepare_identifier,
idempotency_key=identifier + "_prepare", idempotency_key=prepare_identifier,
project_name="node-" + identifier[3:15], project_name="node-" + identifier[3:15],
compatibility_attestation=ATTESTATION, compatibility_attestation=ATTESTATION,
) )
@@ -234,8 +238,8 @@ class NodeK1Sensor:
next_action = "acquisition.start" next_action = "acquisition.start"
payload.update( payload.update(
self.cas(state), self.cas(state),
operation_id=identifier, operation_id=plugin_identifier,
idempotency_key=identifier, idempotency_key=plugin_identifier,
acquisition_id=acquisition["acquisition_id"], acquisition_id=acquisition["acquisition_id"],
expected_state_revision=acquisition["state_revision"], expected_state_revision=acquisition["state_revision"],
physical_acceptance=acceptance, physical_acceptance=acceptance,
+97 -3
View File
@@ -9,7 +9,7 @@ import pytest
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
from k1link.device_plugins.xgrids_k1.linux_host import nm_fields, route_fields from k1link.device_plugins.xgrids_k1.linux_host import nm_fields, route_fields
from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge, plugin_operation_id
from k1link.device_plugins.xgrids_k1.node_sensor import NodeK1Sensor, project_sensor from k1link.device_plugins.xgrids_k1.node_sensor import NodeK1Sensor, project_sensor
from k1link.viewer.node_rerun import NodeRerunHub from k1link.viewer.node_rerun import NodeRerunHub
@@ -107,16 +107,99 @@ def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
} }
output = await device.deliver(command) output = await device.deliver(command)
assert service.fences == ["runtime-one"] assert service.fences == ["runtime-one"]
assert service.scans == [command["operation_id"]] assert service.scans == [plugin_operation_id(command["operation_id"])]
assert len(output["candidates"]) == 1 assert len(output["candidates"]) == 1
command["runtime_id"] = "retired-runtime" command["runtime_id"] = "retired-runtime"
rejected = await device.deliver(command) rejected = await device.deliver(command)
assert rejected["command_result"]["status"] == "rejected" assert rejected["command_result"]["status"] == "rejected"
assert service.scans == [command["operation_id"]] assert service.scans == [plugin_operation_id(command["operation_id"])]
asyncio.run(run()) asyncio.run(run())
@pytest.mark.parametrize("radio_failure", [False, True])
def test_node_scan_real_service_preserves_operation_identity_and_replay(
tmp_path, monkeypatch, radio_failure,
):
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from k1link.device_plugins.xgrids_k1 import facade
from k1link.device_plugins.xgrids_k1.ble import scanner
calls = []
class Radio:
def __init__(self, detection_callback):
self.callback = detection_callback
async def __aenter__(self):
calls.append("scan")
if radio_failure:
raise RuntimeError("synthetic radio unavailable")
self.callback(
BLEDevice("AA:BB:CC:DD:EE:FF", "XGR-TEST", None),
AdvertisementData(
local_name="XGR-TEST", manufacturer_data={}, service_data={},
service_uuids=[], tx_power=None, rssi=-50, platform_data=(),
),
)
return self
async def __aexit__(self, *_args):
pass
# Keep the actual Node adapter, facade, service, journal and BLE arbiter.
# Replace only the OS radio endpoint and shorten its observation window.
monkeypatch.setattr(scanner, "BleakScanner", Radio)
monkeypatch.setattr(scanner, "BLE_SCAN_INITIAL_WINDOW_SECONDS", 0.01)
service = facade.XgridsK1CompatibilityService(tmp_path)
async def run():
device = NodeBridge(tmp_path, service=service)
current = await device.state()
command = {
"operation_id": "op_" + "b" * 32,
"runtime_id": current["runtime_id"],
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"action": "scan", "parameters": {},
}
result = await device.deliver(command)
repeated = await device.deliver(command)
assert len(calls) == 1
assert result["discovery_generation"] == 1
if radio_failure:
assert result["candidates"] == []
else:
assert result["candidates"][0]["id"] == "AA:BB:CC:DD:EE:FF"
assert result["command_result"] == repeated["command_result"] == {
"operation_id": command["operation_id"],
"action": "scan",
"status": "failed" if radio_failure else "succeeded",
"error_code": "RuntimeError" if radio_failure else None,
}
try:
asyncio.run(run())
finally:
service.close()
def test_node_attempt_projection_keeps_host_correlation_without_mutating_plugin_state():
current = state()
identifier = "op_" + "a" * 32
current["connection_attempt"] = {
"schema_version": "missioncore.xgrids-k1-connection-attempt/v1",
"attempt_id": plugin_operation_id(identifier),
"recovery_operation_id": plugin_operation_id("op_" + "b" * 32),
"status": "failed", "public_error_code": "wifi-ssid-not-found",
}
result = NodeBridge.project(current)
assert result["connection_attempt"]["attempt_id"] == identifier
assert result["connection_attempt"]["recovery_operation_id"] == "op_" + "b" * 32
assert current["connection_attempt"]["attempt_id"] == plugin_operation_id(identifier)
def test_node_bridge_forces_reviewed_bridge_and_clears_input_secret(): def test_node_bridge_forces_reviewed_bridge_and_clears_input_secret():
async def run(): async def run():
device = bridge() device = bridge()
@@ -200,6 +283,17 @@ def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
"acquisition.prepare", "acquisition.prepare",
"acquisition.start", "acquisition.start",
] ]
from k1link.web.device_lifecycle import OperationJournal
journal = OperationJournal()
for action, payload in device.facade.actions:
if action in {"acquisition.prepare", "acquisition.start"}:
row, created = journal.begin(
action, operation_id=payload["operation_id"],
idempotency_key=payload["idempotency_key"],
)
assert created
assert journal.begin(action, operation_id=row.operation_id)[1] is False
assert result["snapshot"]["acquisition"] == "streaming" assert result["snapshot"]["acquisition"] == "streaming"
command["parameters"]["control_generation"] = 2 command["parameters"]["control_generation"] = 2
with pytest.raises(ValueError): with pytest.raises(ValueError):