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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=(",", ":"))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user