chore(node): preserve pre-canonicalization experiment snapshot

Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
This commit is contained in:
DCCONSTRUCTIONS
2026-09-21 08:45:34 +03:00
parent 020a878915
commit 1c7dd29d8a
89 changed files with 5026 additions and 487 deletions
@@ -2,7 +2,10 @@ from __future__ import annotations
import asyncio
import math
import re
import sys
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, replace
from importlib.metadata import version
from threading import Lock
@@ -33,6 +36,8 @@ BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS = BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS
# low-level retained-handle lifetime.
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS = 5.0
BLE_SCAN_INITIAL_WINDOW_SECONDS = 6.0
BLE_SCAN_DEFAULT_TIMEOUT_SECONDS = 20.0
_runtime_handle_lock = Lock()
_runtime_handles: dict[str, BLEDevice] = {}
@@ -132,6 +137,14 @@ class BleDeviceRecord(TypedDict):
k1_name_candidate: bool
class BleDiscoveryTiming(TypedDict, total=False):
scan_elapsed_ms: int
scanner_start_ms: int
initial_window_ms: int
first_candidate_ms: int
scan_extended: bool
class BleScanResult(TypedDict):
schema_version: int
started_at_utc: str
@@ -141,6 +154,7 @@ class BleScanResult(TypedDict):
bleak_version: str
device_count: int
devices: list[BleDeviceRecord]
discovery_timing: BleDiscoveryTiming
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
@@ -148,7 +162,7 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
normalized_name = (name or "").casefold()
return {
"macos_uuid": device.address,
"id_kind": "corebluetooth_uuid",
"id_kind": "bluez_address" if sys.platform.startswith("linux") else "corebluetooth_uuid",
"name": device.name,
"local_name": advertisement.local_name,
"rssi": advertisement.rssi,
@@ -803,11 +817,34 @@ def connected_device_recovery_name(
return normalized or None
async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDevice | None:
"""Retrieve only the exact cached BlueZ object; no discovery or connection."""
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", address):
return None
try:
from bleak.backends.bluezdbus.manager import get_global_bluez_manager
manager = await asyncio.wait_for(get_global_bluez_manager(), timeout=3)
path = details.get("path") if isinstance(details, dict) else None
if not path:
path = manager.get_default_adapter() + "/dev_" + address.upper().replace(":", "_")
if manager.get_device_address(path).casefold() != address.casefold():
return None
name = manager.get_device_name(path)
return BLEDevice(address, name, details={"path": path, "props": {
"Address": address, "Alias": name, "Adapter": path.rsplit("/", 1)[0],
}})
except Exception:
return None
async def _retrieve_corebluetooth_device(
captured: CapturedDiscoveredDevice,
) -> BLEDevice | None:
"""Retrieve one UUID through the exact CoreBluetooth manager that observed it."""
if sys.platform.startswith("linux"):
return await _retrieve_bluez_device(captured.macos_uuid, captured.device.details)
details = captured.device.details
if not isinstance(details, tuple) or len(details) != 2:
return None
@@ -899,6 +936,20 @@ async def retrieve_known_device_capture_for_status_read(
or runtime["poisoned"]
):
return None
if sys.platform.startswith("linux"):
device = await _retrieve_bluez_device(macos_uuid)
after = ble_runtime_snapshot()
if (device is None or ble_runtime_owner_epoch_for_current_loop() != owner_epoch
or after["owner_epoch"] != owner_epoch
or not after["owner_loop_bound"] or after["poisoned"]
or after["active_operation_kind"] != "status-read"):
return None
now = _freshness_observed_at()
return CapturedDiscoveredDevice(
device=device, macos_uuid=macos_uuid, owner_epoch=owner_epoch,
scan_generation=0, captured_at_monotonic=now.monotonic,
captured_at_suspend_aware=now.suspend_aware, source="retrieved-durable",
)
context = _new_corebluetooth_retrieval_context(macos_uuid)
if context is None:
return None
@@ -977,10 +1028,14 @@ async def discover_known_device_capture_for_status_read(
or runtime["poisoned"]
):
return None
try:
UUID(macos_uuid)
except (TypeError, ValueError):
return None
if sys.platform.startswith("linux"):
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", macos_uuid):
return None
else:
try:
UUID(macos_uuid)
except (TypeError, ValueError):
return None
candidate = await BleakScanner.find_device_by_address(
macos_uuid,
@@ -1148,6 +1203,42 @@ def connected_device_recovery_snapshot(
}
async def _discover_k1_advertisements(
*, timeout: float, diagnostics: BleDiscoveryTiming
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Listen once, extending the initial window only while no K1 is visible.
Every result and native handle comes from this scanner's callbacks. A name
match only ends discovery; it grants no identity or GATT authority.
"""
loop = asyncio.get_running_loop()
started = loop.time()
initial_window = min(timeout, BLE_SCAN_INITIAL_WINDOW_SECONDS)
discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
candidate_seen = asyncio.Event()
def observe(device: BLEDevice, advertisement: AdvertisementData) -> None:
discovered[device.address] = (device, advertisement)
if advertisement_record(device, advertisement)["k1_name_candidate"]:
if not candidate_seen.is_set():
diagnostics["first_candidate_ms"] = round((loop.time() - started) * 1000)
candidate_seen.set()
diagnostics["initial_window_ms"] = round(initial_window * 1000)
diagnostics["scan_extended"] = False
async with BleakScanner(detection_callback=observe):
listening_started = loop.time()
diagnostics["scanner_start_ms"] = round((listening_started - started) * 1000)
await asyncio.sleep(initial_window)
remaining = max(0.0, timeout - (loop.time() - listening_started))
if not candidate_seen.is_set() and remaining > 0:
diagnostics["scan_extended"] = True
with suppress(TimeoutError):
await asyncio.wait_for(candidate_seen.wait(), timeout=remaining)
diagnostics["scan_elapsed_ms"] = round((loop.time() - started) * 1000)
return discovered
async def _scan_impl(
duration_seconds: float,
progress: BleOperationProgress,
@@ -1163,7 +1254,10 @@ async def _scan_impl(
progress.operation_stage = "discovery"
scan_generation = _begin_scan_generation(owner_epoch)
try:
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
discovery_timing: BleDiscoveryTiming = {}
discovered = await _discover_k1_advertisements(
timeout=duration_seconds, diagnostics=discovery_timing
)
handles = {device.address: device for device, _advertisement in discovered.values()}
devices = [
advertisement_record(device, advertisement)
@@ -1181,10 +1275,11 @@ async def _scan_impl(
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"duration_seconds": duration_seconds,
"adapter": "CoreBluetooth",
"adapter": "BlueZ" if sys.platform.startswith("linux") else "CoreBluetooth",
"bleak_version": version("bleak"),
"device_count": len(devices),
"devices": devices,
"discovery_timing": discovery_timing,
}
except BaseException:
_finish_failed_scan(scan_generation)
+11 -4
View File
@@ -303,6 +303,7 @@ class XgridsK1CameraGateway:
repository_root: Path,
plugin_id: str,
*,
evidence_root: Path | None = None,
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
process_fence_descriptor_factory: Callable[[], int] | None = None,
producer_stall_observer: CameraProducerStallObserver | None = None,
@@ -316,6 +317,12 @@ class XgridsK1CameraGateway:
if not 0.01 <= producer_watchdog_interval_seconds <= 60.0:
raise ValueError("camera watchdog interval must be within 0.01..60 seconds")
self._repository_root = repository_root.resolve()
# Runtime evidence may live outside a source checkout (worktrees/Node).
# The composition supplies this trusted root; request paths cannot widen it.
self._evidence_root = (
evidence_root.expanduser().resolve() if evidence_root is not None
else self._repository_root
)
self._plugin_id = plugin_id
self._lock = threading.RLock()
self._lifecycle_lock = threading.Lock()
@@ -674,8 +681,8 @@ class XgridsK1CameraGateway:
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
if not root.is_relative_to(self._evidence_root):
raise ValueError("camera recording root must stay inside the configured evidence root")
reserved_generation: list[int] = []
def reserve() -> bool:
@@ -805,8 +812,8 @@ class XgridsK1CameraGateway:
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
if not root.is_relative_to(self._evidence_root):
raise ValueError("camera recording root must stay inside the configured evidence root")
with self._lifecycle_lock:
with self._lock:
+45 -9
View File
@@ -88,6 +88,7 @@ from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
wait_for_ble_runtime_idle,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS,
CapturedDiscoveredDevice,
capture_discovered_device,
@@ -226,6 +227,7 @@ from k1link.device_plugins.xgrids_k1.viewer.runtime import (
VisualizationRuntime,
new_live_session_dir,
)
from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code
from k1link.host_network import (
HostWifiAssociationIdentityProbe,
HostWifiAssociationIdentityResult,
@@ -1014,7 +1016,7 @@ class _ConfiguredEndpointHostObservation:
class BleScanRequest(StrictRequest):
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
duration_seconds: float = Field(default=BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, ge=1.0, le=60.0)
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
@@ -1476,6 +1478,7 @@ class XgridsK1CompatibilityService:
application_authority_loader: ApplicationAuthorityLoader | None = None,
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
host_wifi_association_probe: HostWifiAssociationProbe | None = None,
visualization_bridge_factory: Callable[..., Any] | None = None,
) -> None:
self.repository_root = repository_root.resolve()
# Every CoreBluetooth entrypoint, including read-only calibration and
@@ -1876,6 +1879,7 @@ class XgridsK1CompatibilityService:
physical_command_coordinator=self._physical_command_coordinator,
)
self.runtime = VisualizationRuntime(
bridge_factory=visualization_bridge_factory,
normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message,
published_envelope_observer=self._observe_published_runtime_envelope,
@@ -1886,6 +1890,7 @@ class XgridsK1CompatibilityService:
self.camera_preview = XgridsK1CameraGateway(
self.repository_root,
XGRIDS_K1_PLUGIN_ID,
evidence_root=self.evidence_root,
committed_segment_observer=self._observe_committed_camera_segment,
process_fence_descriptor_factory=(self._duplicate_camera_process_fence_descriptor),
producer_stall_observer=self._observe_camera_producer_stall,
@@ -10190,19 +10195,27 @@ class XgridsK1CompatibilityService:
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
)
self._operation_phase = None
discovery_result = {
"candidate_count": len(devices),
"likely_k1_candidate_count": sum(item["likely_k1"] is True for item in devices),
"duration_seconds": duration_seconds,
"discovery_generation": scan_generation,
**result.get("discovery_timing", {}),
}
logger.info(
"K1 BLE discovery completed",
extra={
"event_code": "k1_ble_discovery_completed",
"operation_id": operation.operation_id,
**discovery_result,
},
)
self._operations.transition(
operation.operation_id,
"succeeded",
stage_code="completed",
message_code="discovery.scan.completed",
result={
"candidate_count": len(devices),
"likely_k1_candidate_count": sum(
item["likely_k1"] is True for item in devices
),
"duration_seconds": duration_seconds,
"discovery_generation": scan_generation,
},
result=discovery_result,
)
with self._lock:
if (
@@ -12143,6 +12156,14 @@ class XgridsK1CompatibilityService:
safe_to_retry=not device_write_attempted,
host_boundary="corebluetooth" if isinstance(exc, BleakError) else None,
)
station_failure_code = reviewed_station_failure_code(
operation_error,
firmware_version=request.compatibility_attestation.firmware_version,
connection_mode=request.connection_mode,
)
if station_failure_code is not None:
operation_error["transport_error_code"] = operation_error["code"]
operation_error["code"] = station_failure_code
if idempotency_record.stage == "prepared":
cancelled = isinstance(exc, asyncio.CancelledError)
idempotency_record = idempotency_journal.complete(
@@ -12222,6 +12243,7 @@ class XgridsK1CompatibilityService:
"connection_mode": request.connection_mode,
"error_category": operation_error["category"],
"error_code": operation_error["code"],
"transport_error_code": operation_error.get("transport_error_code"),
"safe_to_retry": operation_error["safe_to_retry"],
"side_effect_status": operation_error["side_effect_status"],
"network_change_attempted": device_write_attempted,
@@ -35803,6 +35825,14 @@ def _inspect_host_path(target: str) -> HostPathProbeResult:
if separator:
fields[key] = value.strip()
if sys.platform.startswith("linux"):
from .linux_host import route_fields
try:
fields = route_fields(target)
except (OSError, ValueError, subprocess.SubprocessError):
inspection_reason = "host-route-inspection-unavailable"
interface = fields.get("interface") or None
destination = fields.get("destination", "")
gateway = fields.get("gateway", "")
@@ -35825,6 +35855,10 @@ def _host_route_class(target: str) -> str:
"""Classify the host route without transmitting packets to the target."""
target = validate_private_ipv4(target)
if sys.platform.startswith("linux"):
path = _inspect_host_path(target)
return {"direct": "direct-or-routed", "default": "default-route",
"tunnel": "tunnel"}.get(path.route_class, "unknown")
if sys.platform != "darwin":
return "unknown"
try:
@@ -35874,6 +35908,7 @@ def _classify_host_route(
"wireguard",
"gif",
"stf",
"tailscale",
)
direct_lan_prefixes = (
"en",
@@ -35882,6 +35917,7 @@ def _classify_host_route(
"vlan",
"usb",
"p2p",
"wl",
)
if normalized_interface.startswith(tunnel_prefixes):
return "tunnel", "host-route-tunnel"
@@ -0,0 +1,214 @@
"""Read-only Linux host adapters for the reviewed K1 Bridge workflow.
No host association or subnet discovery is available through this module.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import secrets
import stat
import subprocess
from pathlib import Path
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
ApplicationAuthorityLoadError,
ApplicationAuthoritySourceSnapshot,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
)
def _run(args: list[str], timeout: float = 8) -> str:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode:
# Never forward subprocess diagnostics: SSIDs or other local data may occur.
raise OSError("Network observation unavailable")
if len(result.stdout) > 131072:
raise OSError("Network observation exceeded its bound")
return result.stdout
def nm_fields(line: str) -> list[str]:
"""Parse nmcli terse escaping, including SSIDs containing colons/backslashes."""
fields, value, escaped = [], "", False
for char in line:
if escaped:
value += char
escaped = False
elif char == "\\":
escaped = True
elif char == ":":
fields.append(value)
value = ""
else:
value += char
if escaped:
value += "\\"
return [*fields, value]
def wifi_networks() -> list[dict]:
text = _run(
[
"nmcli",
"-t",
"--escape",
"yes",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"--rescan",
"yes",
],
timeout=20,
)
networks = {}
for line in text.splitlines():
fields = nm_fields(line)
if len(fields) != 3:
continue
ssid, strength, security = fields
if not ssid or not 1 <= len(ssid.encode()) <= 32 or not strength.isdigit():
continue
signal = min(100, max(0, int(strength)))
if signal >= networks.get(ssid, {}).get("signal", -1):
networks[ssid] = {"ssid": ssid, "signal": signal, "security": security}
return sorted(networks.values(), key=lambda v: (-v["signal"], v["ssid"]))[:64]
def route_fields(target: str) -> dict[str, str]:
# fibmatch returns the actual matched route (including "default"), whereas
# an ordinary `route get` resolves dst to the host even for a default route.
routes = json.loads(_run(["ip", "-j", "route", "get", target, "fibmatch"]))
if not isinstance(routes, list) or len(routes) != 1:
raise OSError("Ambiguous kernel route")
route = routes[0]
if route.get("type", "unicast") != "unicast" or not route.get("dev"):
raise OSError("No unicast route")
return {
"interface": str(route["dev"]),
"destination": str(route.get("dst", "")),
"gateway": str(route.get("gateway", "")),
}
class LinuxWifiAssociationProbe:
def __init__(self, sys_net: Path = Path("/sys/class/net")):
self.sys_net = sys_net
self.key = secrets.token_bytes(32)
def observe(self, *, interface_name: str | None, timeout_seconds: float = 30) -> dict:
result = {
"schema_version": 1,
"adapter": "linux-networkmanager",
"wifi_interface": None,
"association_state": "unavailable",
"evidence_quality": "unavailable",
"continuity_proven": False,
"continuity_token": "",
"reason_code": "host-wifi-observation-unavailable",
}
if not interface_name or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", interface_name):
return result
interface = self.sys_net / interface_name
if not interface.exists():
return result
wireless = (interface / "wireless").exists() or (interface / "phy80211").exists()
result["wifi_interface"] = wireless
if not wireless:
result.update(
association_state="not-wifi",
evidence_quality="not-wifi",
continuity_proven=True,
reason_code=None,
)
material = "not-wifi:" + interface_name
else:
try:
lines = _run(
[
"nmcli",
"-t",
"--escape",
"yes",
"-f",
"ACTIVE,SSID,BSSID",
"device",
"wifi",
"list",
"ifname",
interface_name,
"--rescan",
"no",
],
timeout=min(timeout_seconds, 8),
)
active = [nm_fields(line) for line in lines.splitlines() if line.startswith("yes:")]
if len(active) != 1 or len(active[0]) != 3 or not active[0][2]:
return result
except (OSError, subprocess.SubprocessError):
return result
result.update(
association_state="associated",
evidence_quality="ssid+bssid",
continuity_proven=True,
reason_code=None,
)
material = json.dumps([interface_name, *active[0][1:]], ensure_ascii=False)
result["continuity_token"] = hmac.new(
self.key, material.encode(), hashlib.sha256
).hexdigest()
return result
class LinuxApplicationAuthorityLoader:
"""Read only a systemd-delivered credential, never an argv/env secret.
Provisioning of the encrypted credential is a separate privileged install
operation. Missing authority does not prevent discovery but prevents control.
"""
def __init__(self, directory: Path | None = None):
self.directory = directory
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
return ApplicationAuthoritySourceSnapshot(
provider="linux-systemd-credential",
service="mission-core-k1.service",
account="k1-application",
)
def load(self) -> ApplicationControlAuthority:
directory = self.directory or Path(
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
)
buffer = bytearray()
try:
fd = os.open(directory / "k1-application", os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if (
not stat.S_ISREG(info.st_mode)
or info.st_uid != os.geteuid()
or info.st_mode & 0o077
):
raise ValueError("Invalid credential permissions")
buffer.extend(stream.read(1025))
if len(buffer) > 1024:
raise ValueError("Credential too large")
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
except (OSError, ValueError, UnicodeError):
raise ApplicationAuthorityLoadError(
"Служебный ключ K1 не установлен на БК.",
reason_code="application_authority_unavailable",
) from None
finally:
buffer[:] = b"\0" * len(buffer)
@@ -0,0 +1,248 @@
"""Node-owned, Bridge-only adapter around the admitted K1 plugin runtime.
The local Unix worker accepts neither host-Wi-Fi actions nor arbitrary plugin
invocations. Its public projection contains no credentials or raw evidence.
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from missioncore_plugin_sdk.v0alpha2.runtime import RuntimeActionInvocation
from k1link.viewer.node_rerun import NodeRerunHub
from .ble.scanner import BLE_SCAN_DEFAULT_TIMEOUT_SECONDS
from .facade import (
XGRIDS_K1_PLUGIN_ID,
XgridsK1CompatibilityService,
XgridsK1PluginFacade,
_validate_installed_compatibility_profile,
)
from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks
ATTESTATION = {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"verification": "live-device-info",
}
class NodeBridge:
def __init__(self, repository_root: Path, *, service=None):
self.rerun = NodeRerunHub()
if service is None:
_validate_installed_compatibility_profile(repository_root)
service = XgridsK1CompatibilityService(
repository_root,
application_authority_loader=LinuxApplicationAuthorityLoader(),
host_wifi_association_probe=LinuxWifiAssociationProbe(),
visualization_bridge_factory=self.rerun.create,
)
self.service = service
self.facade = XgridsK1PluginFacade(service)
self.lock = asyncio.Lock()
async def state(self) -> dict:
snapshot = await self.invoke("state.read", {}, "state-read")
return self.project(snapshot)
@staticmethod
def project(snapshot: dict) -> dict:
lifecycle = snapshot.get("connection_lifecycle", {})
return {
"available": True,
"model": "XGRIDS K1",
"mode": "bridge",
"runtime_id": snapshot["snapshot_runtime_id"],
"discovery_generation": snapshot.get("ble_discovery_generation", 0),
"mode_revision": snapshot.get("desired_connection_mode_revision", 0),
"candidates": [
{"id": v["device_id"], "name": v.get("name") or "K1", "rssi": v.get("rssi")}
for v in snapshot.get("devices", [])
if v.get("likely_k1")
][:32],
"connected": lifecycle.get("connection_ready") is True
and snapshot.get("active_connection_mode") == "bridge",
"ready_to_start": lifecycle.get("ready_to_start") is True,
"selected_device_id": snapshot.get("selected_device_id"),
"device_session": snapshot.get("device_session"),
"ip": snapshot.get("k1_ip"),
"phase": snapshot.get("phase"),
"observed_at": datetime.now(UTC).isoformat(),
}
async def invoke(self, action: str, parameters: dict, identifier: str) -> dict:
return await self.facade.invoke(
RuntimeActionInvocation(
invocation_id=identifier,
plugin_id=XGRIDS_K1_PLUGIN_ID,
action_id=action,
requested_at=datetime.now(UTC),
parameters=parameters,
)
)
async def execute(self, command: dict) -> dict:
action, identifier = command["action"], command["operation_id"]
parameters = command.get("parameters", {})
if action not in {"scan", "networks", "connect", "verify"}:
raise ValueError("Unsupported Node device action")
async with self.lock:
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise ValueError("Command expired before dispatch")
state = await self.state()
if command.get("runtime_id") != state["runtime_id"]:
raise ValueError("Node runtime changed")
if action == "networks":
return {**state, "networks": await asyncio.to_thread(wifi_networks)}
if action == "scan":
result = await self.invoke(
"discovery.scan",
{
"duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
"operation_id": identifier,
},
identifier,
)
else:
# Do not derive fences at dispatch: they are the exact version
# shown to the operator before credentials were submitted.
if (
parameters.get("discovery_generation") != state["discovery_generation"]
or parameters.get("mode_revision") != state["mode_revision"]
or parameters.get("device_id") not in {v["id"] for v in state["candidates"]}
):
raise ValueError("Device selection changed")
payload = {
"device_id": parameters["device_id"],
"compatibility_attestation": ATTESTATION,
"operation_id": identifier,
"expected_mode_revision": parameters["mode_revision"],
"expected_discovery_generation": parameters["discovery_generation"],
"expected_snapshot_runtime_id": state["runtime_id"],
}
if action == "connect":
payload.update(
connection_mode="bridge",
allow_host_wifi_switch=False,
idempotency_key=identifier,
ssid=parameters.get("ssid"),
password=parameters.get("password"),
)
try:
result = await self.invoke(
"network.provision" if action == "connect" else "connection.verify",
payload,
identifier,
)
finally:
payload.pop("password", None)
parameters.pop("password", None)
return self.project(result)
def create_app(repository_root: Path):
from contextlib import asynccontextmanager
bridge = NodeBridge(repository_root)
from k1link.viewer.node_media import NodeMediaPeers
from .node_sensor import NodeK1Sensor
peers = NodeMediaPeers(bridge.rerun, bridge.service.camera_preview)
sensor = NodeK1Sensor(bridge, peers)
@asynccontextmanager
async def lifespan(_app):
yield
await peers.close_all()
await asyncio.to_thread(bridge.service.close)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
@app.get("/status")
async def status():
return await bridge.state()
@app.get("/inventory")
async def inventory(request: Request):
return await sensor.inventory(request.headers["X-Node-Id"])
@app.get("/prepare-safe")
async def prepare_safe():
state = await sensor.raw_state()
acquisition = state.get("acquisition") or {}
control = state.get("application_control_session") or {}
physical = control.get("physical_command") or state.get("physical_command") or {}
return {
"safe": not bridge.lock.locked()
and state.get("source_mode") == "idle"
and acquisition.get("state") in {None, "completed", "failed", "aborted"}
and not physical.get("requires_reconciliation")
and control.get("state")
not in {"start-requested", "initializing", "scanning", "stop-requested"}
}
@app.post("/sensor-operation")
async def sensor_operation(request: Request):
try:
data = await request.body()
if len(data) > 65536:
raise ValueError("Request too large")
command = await request.json()
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise ValueError("Command expired")
result = await sensor.execute(command, request.headers["X-Node-Id"])
return {"state": "complete", "result": result}
except Exception:
return {
"state": "unknown",
"error": "Действие K1 не подтверждено. Обновите состояние устройства.",
}
@app.post("/operation")
async def operation(request: Request):
# The Go broker admits size/action/binding/deadline and journals the
# operation before calling this private Unix socket. Never serialize
# Pydantic validation errors, exception text or the incoming payload.
body = {}
try:
data = await request.body()
if len(data) > 16384:
raise ValueError("Request too large")
body = await request.json()
return await bridge.execute(body)
except Exception:
return JSONResponse(
{
"error": (
"Действие K1 не подтверждено. Обновите состояние; "
"проверьте питание, Bluetooth и сеть БК."
)
},
status_code=409,
)
finally:
if isinstance(body, dict) and isinstance(body.get("parameters"), dict):
body["parameters"].pop("password", None)
return app
def main():
import os
import uvicorn
os.umask(0o007)
app = create_app(Path("/usr/lib/mission-core-node/k1"))
uvicorn.run(app, uds="/run/mission-core-k1/driver.sock", access_log=False, log_level="warning")
if __name__ == "__main__":
main()
@@ -0,0 +1,255 @@
"""SDK projection and explicit acquisition actions for the Node-owned K1."""
import asyncio
import hashlib
import time
from datetime import UTC, datetime
from .facade import (
XGRIDS_K1_MODEL_ID,
XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION,
ViewerSettingsRequest,
)
from .node_bridge import ATTESTATION
PHYSICAL_ACCEPTANCE_KEYS = (
"operator_present",
"owner_controlled_device",
"lixelgo_closed",
"battery_storage_confirmed",
"expected_physical_state_confirmed",
)
def project_sensor(snapshot, node_id):
session = snapshot.get("device_session")
if not session:
return None
identifier = "k1_" + hashlib.sha256(session["device_id"].encode()).hexdigest()[:32]
lifecycle = snapshot.get("connection_lifecycle", {})
connected = (
lifecycle.get("connection_ready") is True
and snapshot.get("active_connection_mode") == "bridge"
)
acquisition = snapshot.get("acquisition") or {}
acquisition_state = {
"running": "streaming",
"active": "streaming",
"prepared": "preparing",
"starting": "starting",
"stopping": "stopping",
"failed": "failed",
}.get(acquisition.get("state"), "idle")
if snapshot.get("source_mode") == "live":
acquisition_state = "streaming"
now = datetime.now(UTC).isoformat()
context = {
"session_id": session["device_session_id"],
"device": {
"device_id": identifier,
"model": {
"plugin_id": XGRIDS_K1_PLUGIN_ID,
"plugin_version": XGRIDS_K1_PLUGIN_VERSION,
"model_id": XGRIDS_K1_MODEL_ID,
},
"stability": "provisional",
"basis": "plugin-derived",
},
"execution": {
"node_id": node_id,
"agent_instance_id": snapshot["snapshot_runtime_id"],
"platform": "linux",
},
"opened_at": session["opened_at"],
}
control = snapshot.get("application_control_session") or {}
return {
"id": identifier,
"name": "XGRIDS K1",
"model": "XGRIDS K1",
"kind": "k1",
"prepared": True,
"configured": True,
"verified": connected,
"online": connected,
"usb": "",
"connection_label": "Wi-Fi · " + (snapshot.get("k1_ip") or ""),
"layers": ["points", "camera"],
"snapshot": {
"context": context,
"revision": snapshot["snapshot_revision"],
"enrollment": "enrolled",
"connectivity": "connected" if connected else "offline",
"acquisition": acquisition_state,
"observed_at": now,
},
"control": {
"generation": control.get("session_generation"),
"revision": control.get("state_revision"),
"phase": control.get("state"),
"can_start": lifecycle.get("ready_to_start", False),
"can_stop": control.get("state") in {"start-requested", "initializing", "scanning"},
"acquisition_id": acquisition.get("acquisition_id"),
},
"live_settings": snapshot.get("viewer_settings", {}),
"frames": snapshot.get("metrics", {}),
"recordings": [],
}
class NodeK1Sensor:
def __init__(self, bridge, peers):
self.bridge, self.peers = bridge, peers
async def raw_state(self):
return await self.bridge.invoke("state.read", {}, "sensor-state")
async def inventory(self, node_id):
state = await self.raw_state()
item = project_sensor(state, node_id)
return {"items": [item] if item else []}
@staticmethod
def cas(state, *, acquisition=True):
control = state["application_control_session"]
if acquisition:
return {
"expected_control_session_generation": control["session_generation"],
"expected_control_state_revision": control["state_revision"],
}
return {
"expected_session_generation": control["session_generation"],
"expected_state_revision": control["state_revision"],
}
async def execute(self, command, node_id):
state = await self.raw_state()
item = project_sensor(state, node_id)
if (
not item
or command["session"]["device_id"] != item["id"]
or command["session"]["session_id"] != item["snapshot"]["context"]["session_id"]
):
raise ValueError("Device session changed")
action, params, identifier = (
command["action_id"],
command.get("parameters", {}),
command["operation_id"],
)
if action == "details":
return item
if action == "close-peer":
await self.peers.close(params.get("peer_id"))
return {"ok": True}
if action == "offer":
if item["snapshot"]["acquisition"] != "streaming":
raise ValueError("Acquisition is not active")
return await self.peers.offer(params)
async with self.bridge.lock:
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise ValueError("Command expired before dispatch")
state = await self.raw_state()
current = project_sensor(state, node_id)
if (
current is None
or current["snapshot"]["context"]["session_id"] != command["session"]["session_id"]
):
raise ValueError("Device session changed")
runtime = state["snapshot_runtime_id"]
if action == "option":
if (
set(params) != {"profile", "settings"}
or params["profile"] != "live-acquisition"
):
raise ValueError("Only the live profile belongs to this device")
settings = ViewerSettingsRequest.model_validate(params["settings"])
result = await self.bridge.invoke(
"viewer.settings.update", settings.model_dump(), identifier
)
return project_sensor(result, node_id)
if action == "verify":
result = await self.bridge.invoke(
"connection.verify", {"expected_snapshot_runtime_id": runtime}, identifier
)
return project_sensor(result, node_id)
if action not in {"start", "stop"} or params.get("operator_confirmed") is not True:
raise ValueError("Unsupported device action")
if (
params.get("control_generation") != current["control"]["generation"]
or params.get("acquisition_id") != current["control"]["acquisition_id"]
):
raise ValueError("Control session changed")
# Same explicit START/STOP acceptance as the admitted local plugin.
# No confirmation is inferred from polling, discovery or a preview offer.
acceptance = dict.fromkeys(PHYSICAL_ACCEPTANCE_KEYS, True)
if action == "stop":
result = await self.bridge.invoke(
"acquisition.stop",
{
"operation_id": identifier,
"idempotency_key": identifier,
"acquisition_id": params.get("acquisition_id"),
"mode": "graceful",
"physical_acceptance": acceptance,
"expected_snapshot_runtime_id": runtime,
**self.cas(state),
},
identifier,
)
await self.peers.close_all()
return project_sensor(result, node_id)
deadline = min(
datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165
)
dispatched = set()
while time.time() < deadline:
if state["snapshot_runtime_id"] != runtime:
raise ValueError("Runtime changed during START")
control = state.get("application_control_session") or {}
phase = control.get("state")
physical = control.get("physical_command") or state.get("physical_command") or {}
if physical.get("requires_reconciliation"):
raise ValueError("Physical state requires explicit reconciliation")
acquisition = state.get("acquisition") or {}
payload = {"expected_snapshot_runtime_id": runtime}
next_action = None
if phase == "connection-ready":
if control.get("inspection_only"):
next_action = "application-control.session.open"
payload.update(acceptance, timezone_name="UTC")
else:
next_action = "application-control.workspace.enter"
payload.update(self.cas(state, acquisition=False), operator_confirmed=True)
elif phase == "workspace-ready" and acquisition.get("state") != "prepared":
next_action = "acquisition.prepare"
payload.update(
self.cas(state),
operation_id=identifier + "_prepare",
idempotency_key=identifier + "_prepare",
project_name="node-" + identifier[3:15],
compatibility_attestation=ATTESTATION,
)
elif phase == "project-ready" and acquisition.get("state") == "prepared":
next_action = "acquisition.start"
payload.update(
self.cas(state),
operation_id=identifier,
idempotency_key=identifier,
acquisition_id=acquisition["acquisition_id"],
expected_state_revision=acquisition["state_revision"],
physical_acceptance=acceptance,
)
elif phase in {"failed", "idle", "closed", "completed"}:
raise ValueError("K1 control not ready")
elif phase in {"start-requested", "initializing", "scanning"}:
return project_sensor(state, node_id)
if next_action and next_action not in dispatched:
dispatched.add(next_action)
state = await self.bridge.invoke(
next_action, payload, identifier + "_" + str(len(dispatched))
)
else:
await asyncio.sleep(0.5)
state = await self.raw_state()
raise TimeoutError("K1 did not confirm START")
@@ -0,0 +1,33 @@
from __future__ import annotations
from collections.abc import Mapping
def reviewed_station_failure_code(
error: Mapping[str, object], *, firmware_version: str, connection_mode: str
) -> str | None:
"""Interpret the reviewed FW 3.0.2 station callback, retaining ATT evidence.
lixel_nman's wifi_connect return value is passed to the GATT write result.
Its 4 (SSID not found) and 6 (credentials required) collide with standard
ATT names. This is a profile-scoped diagnosis, not network-state proof or
retry authority. See the 2026-09-06 firmware callback audit.
"""
if not (
firmware_version == "3.0.2"
and connection_mode in {"bridge", "direct-connect"}
and error.get("code") == "BleakGATTProtocolError"
and error.get("operation_stage") == "gatt-write"
and error.get("device_write_attempted") is True
and error.get("device_write_confirmed") is False
and error.get("resolved_write_mode") == "with_response"
and error.get("frame_length") == 99
):
return None
code = error.get("ble_att_error_code")
if not isinstance(code, int) or isinstance(code, bool):
return None
return {
4: "k1-wifi-network-not-found",
6: "k1-wifi-credentials-required",
}.get(code)
+173
View File
@@ -0,0 +1,173 @@
"""Bounded, transient delivery of Node-owned wireless device operations.
Unlike ordinary sensor operations these can contain a WLAN password. The
pending payload never enters Fleet's database, listing, event stream or result.
The Node owns the durable, redacted operation journal and at-most-once dispatch.
"""
import copy
import json
import re
import time
from datetime import UTC, datetime
from .trust import PairingError
ACTIONS = {"scan", "networks", "connect", "verify"}
def validate(value):
if not isinstance(value, dict) or set(value) != {
"operation_id",
"node_id",
"runtime_id",
"action",
"deadline_at",
"parameters",
}:
raise PairingError("Неверный запрос подключения устройства.")
if (
not re.fullmatch(r"op_[0-9a-f]{32}", str(value["operation_id"]))
or value["action"] not in ACTIONS
or len(json.dumps(value)) > 16384
or not isinstance(value["runtime_id"], str)
or not value["runtime_id"]
or len(value["runtime_id"]) > 128
):
raise PairingError("Неподдерживаемая операция подключения.")
try:
delta = (datetime.fromisoformat(value["deadline_at"]) - datetime.now(UTC)).total_seconds()
if not 0 < delta <= 180:
raise ValueError
except (ValueError, TypeError):
raise PairingError("Срок запроса истёк. Обновите устройства.") from None
parameters = value["parameters"]
expected = {"device_id", "discovery_generation", "mode_revision"}
if value["action"] == "connect":
expected |= {"ssid", "password"}
elif value["action"] in {"scan", "networks"}:
expected = set()
if not isinstance(parameters, dict) or set(parameters) != expected:
raise PairingError("Неверные параметры подключения.")
if "device_id" in expected and (
not isinstance(parameters["device_id"], str)
or not 1 <= len(parameters["device_id"]) <= 128
or type(parameters["discovery_generation"]) is not int
or type(parameters["mode_revision"]) is not int
or min(parameters["discovery_generation"], parameters["mode_revision"]) < 0
):
raise PairingError("Обновите выбранное устройство.")
if value["action"] == "connect" and any(
not isinstance(parameters[key], str) or not 1 <= len(parameters[key].encode()) <= limit
for key, limit in (("ssid", 32), ("password", 64))
):
raise PairingError("Проверьте название сети и пароль.")
class DeviceEnrollment:
def __init__(self):
# Access only under the enclosing FleetRegistry.lock.
self.pending = {}
def prune(self):
now = time.time()
for key, entry in list(self.pending.items()):
if now - entry["created"] > 600:
del self.pending[key]
elif entry["deadline"] <= now and entry["public"]["state"] in {"queued", "running"}:
entry["payload"] = None
entry["public"] = {
"operation_id": key[1],
"state": "unknown",
"error": "Подтверждение с БК не получено. Обновите состояние K1.",
}
def submit(self, fleet, vehicle_id, value):
validate(value)
with fleet.lock:
self.prune()
row = fleet.find(vehicle_id)
state = row.get("device_enrollment", {})
if (
fleet.public(row)["connectivity"] != "online"
or value["node_id"] != row["node_id"]
or value["runtime_id"] != state.get("runtime_id")
or state.get("available") is not True
):
raise PairingError("БК или служба K1 недоступны. Обновите устройства.")
key = (vehicle_id, value["operation_id"])
existing = self.pending.get(key)
if existing:
# First intent owns this ID; never accept replacement secrets.
return copy.deepcopy(existing["public"])
if len(self.pending) >= 32:
raise PairingError("Слишком много запросов. Повторите позже.")
public = {"operation_id": value["operation_id"], "state": "queued"}
self.pending[key] = {
"public": public,
"payload": copy.deepcopy(value),
"binding": row["binding"]["binding_id"],
"node_id": row["node_id"],
"created": time.time(),
"deadline": datetime.fromisoformat(value["deadline_at"]).timestamp(),
}
return dict(public)
def operation(self, fleet, vehicle_id, identifier):
with fleet.lock:
self.prune()
row = fleet.find(vehicle_id)
entry = self.pending.get((vehicle_id, identifier))
if not entry or entry["binding"] != (row.get("binding") or {}).get("binding_id"):
# A Core restart loses transient delivery; never recreate it
# from browser credentials or imply that a physical write failed.
return {
"operation_id": identifier,
"state": "unknown",
"error": "Результат запроса недоступен. Обновите состояние устройства.",
}
return copy.deepcopy(entry["public"])
def heartbeat(self, row, value):
self.prune()
state = value.get("device_enrollment", {"available": False})
if not isinstance(state, dict) or len(json.dumps(state)) > 32768:
raise ValueError("Invalid device enrollment state")
row["device_enrollment"] = state
acknowledgements = []
results = value.get("enrollment_results", [])
if not isinstance(results, list) or len(results) > 32:
raise ValueError("Invalid enrollment results")
for result in results:
if not isinstance(result, dict) or len(json.dumps(result)) > 65536:
raise ValueError("Invalid enrollment result")
key = (row["id"], result.get("operation_id"))
entry = self.pending.get(key)
if (
entry
and entry["binding"] == row["binding"]["binding_id"]
and result.get("state") in {"running", "complete", "error", "unknown"}
):
entry["payload"] = None # Node's journal now owns the intent.
entry["public"] = {
k: result[k]
for k in ("operation_id", "state", "error", "result")
if k in result
}
if result["state"] != "running":
acknowledgements.append(result["operation_id"])
elif result.get("state") != "running":
acknowledgements.append(result.get("operation_id"))
commands = []
for (vehicle_id, _), entry in self.pending.items():
if vehicle_id != row["id"]:
continue
if entry["binding"] != row["binding"]["binding_id"]:
entry["payload"] = None
entry["public"]["state"] = "unknown"
elif entry["payload"] is not None:
commands.append(entry["payload"])
return {
"enrollment_commands": commands[:2],
"enrollment_acknowledgements": acknowledgements,
}
+5 -1
View File
@@ -30,6 +30,9 @@ class FleetRegistry:
from .events import FleetEvents
self.events = FleetEvents()
from .device_enrollment import DeviceEnrollment
self.device_enrollment = DeviceEnrollment()
self.trust = CoreTrust(root)
path = root / "fleet.sqlite3"
if path.is_symlink():
@@ -378,6 +381,7 @@ class FleetRegistry:
return 400, {"error": "Invalid Node inventory"}
sensor_state = sensors.validate_inventory(value, node_id)
sensor_response = sensors.heartbeat(row, value)
enrollment_response = self.device_enrollment.heartbeat(row, value)
host = value.get("host")
if (
not isinstance(host, dict)
@@ -403,4 +407,4 @@ class FleetRegistry:
}
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
self.save(row)
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response}
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response}
+170
View File
@@ -0,0 +1,170 @@
"""Paired signalling, private ICE candidates and bounded live data channels."""
import asyncio
import ipaddress
import queue
import time
from contextlib import suppress
from uuid import uuid4
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
PRIVATE_NETWORKS = tuple(
ipaddress.ip_network(v)
for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
)
def private(address):
try:
value = ipaddress.ip_address(address)
return value.version == 4 and any(value in network for network in PRIVATE_NETWORKS)
except ValueError:
return False
def admit_sdp(sdp):
if not isinstance(sdp, str) or not 1 <= len(sdp) <= 32768:
raise ValueError("Invalid media invitation")
media = [line for line in sdp.splitlines() if line.startswith("m=")]
if not sdp.startswith("v=0") or len(media) != 1 or not media[0].startswith("m=application "):
raise ValueError("A single data-channel media section is required")
for line in sdp.splitlines():
if line.startswith("a=candidate:"):
parts = line.split()
if (
len(parts) < 8
or parts[7] != "host"
or not (private(parts[4]) or parts[4].endswith(".local"))
):
raise ValueError("Only private host ICE candidates are admitted")
class NodeMediaPeers:
def __init__(self, hub, camera):
self.hub, self.camera, self.items = hub, camera, {}
# Process-local adapter in this dedicated worker; no public/STUN/TURN ICE.
original = aioice.ice.get_host_addresses
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
value for value in original(use_ipv4=True, use_ipv6=False) if private(value)
]
async def offer(self, parameters):
admit_sdp(parameters.get("sdp"))
if len(self.items) >= 2:
raise ValueError("Close another live viewer")
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
identifier = "peer_" + uuid4().hex
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()}
self.items[identifier] = entry
@pc.on("datachannel")
def datachannel(channel):
if channel.label not in {"rrd", "camera"} or channel.label in entry["labels"]:
channel.close()
return
entry["labels"].add(channel.label)
@channel.on("message")
def message(value):
if value == "keepalive":
entry["seen"] = time.monotonic()
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
@pc.on("connectionstatechange")
async def changed():
if pc.connectionState in {"closed", "failed"}:
await self.close(identifier)
try:
await pc.setRemoteDescription(
RTCSessionDescription(sdp=parameters["sdp"], type="offer")
)
await pc.setLocalDescription(await pc.createAnswer())
async def expiry():
await asyncio.sleep(25)
if pc.connectionState != "connected":
await self.close(identifier)
entry["tasks"].append(asyncio.create_task(expiry()))
snapshot = self.camera.snapshot()
return {
"peer_id": identifier,
"sdp": pc.localDescription.sdp,
"type": "answer",
"camera_mime": (snapshot.get("delivery") or {}).get("media_type"),
"profile": "live-acquisition",
"transport": "webrtc-rrd-fmp4",
}
except BaseException:
await self.close(identifier)
raise
async def send(self, channel, payload):
if len(payload) > 8 * 1024 * 1024:
raise RuntimeError("Preview fragment exceeds bound")
for offset in range(0, len(payload), 16384):
deadline = time.monotonic() + 2
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
raise RuntimeError("Preview consumer unavailable")
await asyncio.sleep(0.01)
channel.send(payload[offset : offset + 16384])
await asyncio.sleep(0)
async def deliver(self, identifier, channel):
subscriber = lease = None
try:
entry = self.items[identifier]
if channel.label == "rrd":
subscriber = await asyncio.to_thread(self.hub.subscribe)
else:
generation = self.camera.snapshot().get("generation")
if generation is None:
channel.close()
return
lease = await asyncio.to_thread(self.camera.open_delivery, generation)
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
if subscriber:
payload = await asyncio.to_thread(subscriber.read)
else:
try:
segment = await asyncio.to_thread(lease.segments.get, 0.5)
except queue.Empty:
continue
if segment is None:
break
kind, payload = segment
if kind == "media":
self.camera.mark_streaming(lease)
if payload is None:
break
if payload:
await self.send(channel, payload)
except (Exception, asyncio.CancelledError):
pass
finally:
if subscriber:
subscriber.close()
if lease:
self.camera.release_delivery(lease, client_closed=True)
if channel.label == "camera":
channel.close()
else:
await self.close(identifier)
async def close(self, identifier):
entry = self.items.pop(identifier, None)
if entry:
for task in entry["tasks"]:
if task is not asyncio.current_task():
task.cancel()
with suppress(Exception):
await entry["pc"].close()
async def close_all(self):
for identifier in list(self.items):
await self.close(identifier)
+144
View File
@@ -0,0 +1,144 @@
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
Every viewer receives a fresh native recording including StoreInfo/blueprint.
Latest-value queues discard decoded preview frames before encoding; encoded
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
"""
import queue
import threading
from contextlib import suppress
from uuid import uuid4
from k1link.viewer.rerun_bridge import RerunBridge
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
class RrdSubscriber:
def __init__(self, settings_provider):
self.closed = threading.Event()
self.inputs = queue.Queue(maxsize=2)
self.output = queue.Queue(maxsize=2)
self.settings_provider = settings_provider
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
self.thread.start()
def offer(self, envelope):
if self.closed.is_set():
return
with suppress(queue.Full):
if self.inputs.full():
with suppress(queue.Empty):
self.inputs.get_nowait()
self.inputs.put_nowait(envelope)
def read(self):
if self.closed.is_set():
return None
try:
return self.output.get(timeout=0.5)
except queue.Empty:
return b""
def close(self):
self.closed.set()
def run(self):
binary = None
bridge = None
def output(recording):
nonlocal binary
binary = recording.binary_stream()
return "webrtc+rrd://" + str(uuid4())
try:
bridge = RerunBridge(settings_provider=self.settings_provider, recording_output=output)
bridge.begin_session()
while not self.closed.is_set():
payload = binary.read()
if len(payload) > MAX_ENCODED_CHUNK:
break
if payload:
# Bound both bytes and waiting time. The archive/producer
# never waits for this disposable preview subscription.
self.output.put(payload, timeout=0.5)
try:
envelope = self.inputs.get(timeout=0.1)
except queue.Empty:
continue
bridge.process(envelope)
except Exception:
pass
finally:
self.closed.set()
if bridge is not None:
with suppress(Exception):
bridge.close()
binary.read()
class NodeRerunBridge(RerunBridge):
def __init__(self, **kwargs):
self.lock = threading.Lock()
self.subscribers = []
self.latest = {}
def output(recording):
self.binary = recording.binary_stream()
return "webrtc+rrd://" + str(uuid4())
super().__init__(**kwargs, recording_output=output)
self.binary.read()
def process(self, envelope):
super().process(envelope)
# The primary recording proves native publication and owns metrics.
# It is continuously drained even when no viewer is attached.
self.binary.read()
with self.lock:
self.latest[type(envelope)] = envelope
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
for subscriber in self.subscribers:
subscriber.offer(envelope)
def process_perception(self, frame):
super().process_perception(frame)
self.binary.read()
def subscribe(self):
with self.lock:
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
if self._closed or len(self.subscribers) >= 2:
raise RuntimeError("Live viewer unavailable")
subscriber = RrdSubscriber(self._settings_provider)
for envelope in self.latest.values():
subscriber.offer(envelope)
self.subscribers.append(subscriber)
return subscriber
def close(self):
with self.lock:
for subscriber in self.subscribers:
subscriber.close()
self.subscribers.clear()
self.latest.clear()
super().close()
self.binary.read()
class NodeRerunHub:
def __init__(self):
self.bridge = None
def create(self, **kwargs):
bridge = NodeRerunBridge(**kwargs)
self.bridge = bridge
return bridge
def subscribe(self):
bridge = self.bridge
if bridge is None:
raise RuntimeError("Live acquisition is not active")
return bridge.subscribe()
+23 -24
View File
@@ -118,6 +118,7 @@ class RerunBridge:
settings_provider: SettingsProvider | None = None,
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
recording_output: Callable[[rr.RecordingStream], str] | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
@@ -130,31 +131,29 @@ class RerunBridge:
else:
recording = recording_factory("nodedc_mission_core_spatial")
try:
selected_grpc_port = _select_available_grpc_port(grpc_port)
if selected_grpc_port != grpc_port:
logger.info(
"Mission Core selected a new Rerun port because an earlier "
"viewer still owns the preferred listener",
extra={
"event_code": "rerun_grpc_port_rotated",
"preferred_port": grpc_port,
"selected_port": selected_grpc_port,
},
)
blueprint = _blueprint(self._settings)
url = recording.serve_grpc(
grpc_port=selected_grpc_port,
default_blueprint=blueprint,
# This is a reconnect cushion for the live preview, not the source
# of record. Raw MQTT evidence is persisted independently. A large
# late-client backlog can block the native SDK and freeze preview.
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
# Rerun transport can replay ActivateStore before StoreInfo when an
# evicted buffer is served newest-first, leaving late viewers on the
# welcome screen. Preserve protocol order within the bounded cache.
newest_first=False,
cors_allow_origin=list(cors_allow_origin),
)
if recording_output is not None:
# Node's paired WebRTC delivery uses an RRD sink and never opens
# an unauthenticated gRPC listener on an onboard interface.
url = recording_output(recording)
else:
selected_grpc_port = _select_available_grpc_port(grpc_port)
if selected_grpc_port != grpc_port:
logger.info(
"Mission Core selected a new Rerun port because an earlier viewer "
"still owns the preferred listener",
extra={"event_code": "rerun_grpc_port_rotated", "preferred_port": grpc_port,
"selected_port": selected_grpc_port},
)
url = recording.serve_grpc(
grpc_port=selected_grpc_port,
default_blueprint=blueprint,
# Bounded reconnect cushion, not the source of record.
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
# StoreInfo must precede ActivateStore for late viewers.
newest_first=False,
cors_allow_origin=list(cors_allow_origin),
)
recording.send_blueprint(
blueprint,
make_active=True,
+49
View File
@@ -136,3 +136,52 @@ def sensor_operation(
return operation(fleet, vehicle_id, operation_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.get("/{vehicle_id}/devices/enrollment")
def enrollment_state(
vehicle_id: str, response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
row = fleet.find(vehicle_id)
return {
**row.get("device_enrollment", {"available": False}),
"node_id": row["node_id"],
"name": row["name"],
"fresh": fleet.public(row)["connectivity"] == "online",
}
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.post("/{vehicle_id}/devices/enrollment/operations")
def enrollment_submit(
vehicle_id: str,
body: dict,
response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.device_enrollment.submit(fleet, vehicle_id, body)
except (PairingError, ValueError, TypeError):
# Validation diagnostics must never echo a supplied credential.
raise HTTPException(
409, "Запрос подключения не принят. Обновите БК и проверьте параметры сети."
) from None
@router.get("/{vehicle_id}/devices/enrollment/operations/{operation_id}")
def enrollment_operation(
vehicle_id: str,
operation_id: str,
response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.device_enrollment.operation(fleet, vehicle_id, operation_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
+35
View File
@@ -18,6 +18,7 @@ _EXTRA_FIELDS: Final = (
"connection_mode",
"error_category",
"error_code",
"transport_error_code",
"reason_code",
"failed_phase",
"dialogue_stage",
@@ -32,6 +33,20 @@ _EXTRA_FIELDS: Final = (
"device_write_confirmed",
"ble_att_error_code",
"ble_att_error_name",
"resolved_write_mode",
"max_write_without_response_size",
"mtu_size",
"frame_length",
"bridge_frame_length",
"quick_connect_frame_length",
"scan_elapsed_ms",
"scanner_start_ms",
"initial_window_ms",
"first_candidate_ms",
"scan_extended",
"candidate_count",
"likely_k1_candidate_count",
"discovery_generation",
"helper_stage",
"helper_elapsed_ms",
"status_reconciliation",
@@ -102,6 +117,26 @@ class ScannerDiagnosticJsonFormatter(logging.Formatter):
value = getattr(record, field, None)
if value is not None and isinstance(value, (str, int, bool)):
document[field] = value
if record.exc_info is not None:
exception_type, _exception, traceback = record.exc_info
if exception_type is not None:
document["exception_type"] = exception_type.__name__[:128]
if traceback is not None:
while traceback.tb_next is not None:
traceback = traceback.tb_next
code = traceback.tb_frame.f_code
# Preserve the failure location, never exception text, locals,
# source lines or absolute paths that may contain private data.
document["exception_site"] = (
f"{Path(code.co_filename).name}:{traceback.tb_lineno}:{code.co_name}"
)[:256]
properties = getattr(record, "write_characteristic_properties", None)
if isinstance(properties, (list, tuple)) and all(
isinstance(value, str)
and value in {"read", "write", "write-without-response", "notify", "indicate"}
for value in properties
):
document["write_characteristic_properties"] = sorted(set(properties))
return json.dumps(document, ensure_ascii=False, separators=(",", ":"))