wip(k1): checkpoint connection recovery rewrite

Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 deletions
+13 -1
View File
@@ -14,7 +14,12 @@ def utc_now_iso() -> str:
def write_json_atomic(path: Path, payload: Any) -> None:
"""Write JSON without exposing a partially written artifact."""
"""Write JSON without exposing or acknowledging a partial commit.
Flushing the temporary file protects its contents, but a crash can still
lose the directory entry created by ``replace``. The parent directory is
therefore flushed after the atomic rename as the second durability edge.
"""
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -34,6 +39,13 @@ def write_json_atomic(path: Path, payload: Any) -> None:
stream.flush()
os.fsync(stream.fileno())
Path(temp_name).replace(path)
directory_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
directory_flags |= getattr(os, "O_DIRECTORY", 0)
directory_descriptor = os.open(path.parent, directory_flags)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
finally:
if temp_name is not None:
Path(temp_name).unlink(missing_ok=True)
@@ -142,7 +142,10 @@ def build_l34a_assisted_yolox_error_audit(
},
"limitations": [
"the review was seeded from the same frozen candidate and is not independent truth",
"precision, recall and F1 are assisted diagnostic alignment metrics, not acceptance metrics",
(
"precision, recall and F1 are assisted diagnostic alignment metrics, "
"not acceptance metrics"
),
"custom labels are retained as proposed ontology terms and were not adjudicated",
"the result is source-scoped to 32 RAVNOVES00 right-camera frames",
],
+61 -4
View File
@@ -10,7 +10,7 @@ import threading
import time
import zlib
from collections import deque
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from hashlib import sha256
from typing import Any, Final, Literal
@@ -33,7 +33,7 @@ LiveIngressModality = Literal[
LIVE_INGRESS_SCHEMA: Final = "missioncore.live-perception-ingress/v1"
LIVE_INGRESS_WIRE_SCHEMA: Final = "missioncore.live-perception-wire/v1"
LIVE_RESULT_WIRE_SCHEMA: Final = "missioncore.live-perception-result-wire/v1"
LIVE_RESULT_WIRE_SCHEMA: Final = "missioncore.live-perception-result-wire/v2"
LIVE_RESULT_MAGIC: Final = b"MCPR"
LIVE_RESULT_MAX_HEADER_BYTES: Final = 256 * 1024
LIVE_RESULT_MAX_PAYLOAD_BYTES: Final = 2 * 1024 * 1024
@@ -41,6 +41,8 @@ LIVE_RESULT_MAX_PAYLOAD_BYTES: Final = 2 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class LivePerceptionResultFrame:
session_id: str
session_generation: int
frame_index: int
source_frame_index: int
session_seconds: float
@@ -53,6 +55,8 @@ class LivePerceptionResultFrame:
def encode_live_perception_result(
*,
session_id: str,
session_generation: int,
frame_index: int,
source_frame_index: int,
session_seconds: float,
@@ -65,7 +69,11 @@ def encode_live_perception_result(
"""Encode one bounded, non-authoritative worker-to-viewer result frame."""
if (
frame_index < 0
not session_id
or len(session_id) > 160
or isinstance(session_generation, bool)
or session_generation < 1
or frame_index < 0
or source_frame_index < 0
or captured_at_epoch_ns < 0
or not math.isfinite(session_seconds)
@@ -91,6 +99,8 @@ def encode_live_perception_result(
raise ValueError("live perception result payload exceeds the bound")
header = {
"schema_version": LIVE_RESULT_WIRE_SCHEMA,
"session_id": session_id,
"session_generation": session_generation,
"frame_index": frame_index,
"source_frame_index": source_frame_index,
"session_seconds": session_seconds,
@@ -192,11 +202,19 @@ def decode_live_perception_result(encoded: bytes) -> LivePerceptionResultFrame:
mask = np.frombuffer(raw_mask, dtype=np.uint8).reshape((600, 800)).copy()
normalized_objects = tuple(_normalize_live_result_object(value) for value in objects)
frame_index = header.get("frame_index")
session_id = header.get("session_id")
session_generation = header.get("session_generation")
source_frame_index = header.get("source_frame_index")
session_seconds = header.get("session_seconds")
captured_at_epoch_ns = header.get("captured_at_epoch_ns")
if (
not isinstance(frame_index, int)
not isinstance(session_id, str)
or not session_id
or len(session_id) > 160
or not isinstance(session_generation, int)
or isinstance(session_generation, bool)
or session_generation < 1
or not isinstance(frame_index, int)
or isinstance(frame_index, bool)
or frame_index < 0
or not isinstance(source_frame_index, int)
@@ -212,6 +230,8 @@ def decode_live_perception_result(encoded: bytes) -> LivePerceptionResultFrame:
):
raise ValueError("live perception result time identity is invalid")
return LivePerceptionResultFrame(
session_id=session_id,
session_generation=session_generation,
frame_index=frame_index,
source_frame_index=source_frame_index,
session_seconds=float(session_seconds),
@@ -297,6 +317,7 @@ class LiveIngressEvent:
ingress_sequence: int
session_id: str
session_generation: int
modality: LiveIngressModality
source_id: str
source_sequence: int
@@ -310,6 +331,7 @@ class LiveIngressEvent:
"schema_version": LIVE_INGRESS_WIRE_SCHEMA,
"ingress_sequence": self.ingress_sequence,
"session_id": self.session_id,
"session_generation": self.session_generation,
"modality": self.modality,
"source_id": self.source_id,
"source_sequence": self.source_sequence,
@@ -382,9 +404,13 @@ class LivePerceptionIngress:
}
self._ingress_sequence = 0
self._session_id: str | None = None
self._session_generation = 0
self._active = False
self._closed = False
self._consumer_id: str | None = None
self._results_accepted = 0
self._results_rejected_stale = 0
self._results_rejected_receiver = 0
def begin_session(self, session_id: str) -> None:
if not session_id or len(session_id) > 160:
@@ -402,6 +428,7 @@ class LivePerceptionIngress:
for queue in self._queues.values():
queue.items.clear()
self._session_id = session_id
self._session_generation += 1
self._active = True
self._publish_locked(
modality="control",
@@ -470,6 +497,31 @@ class LivePerceptionIngress:
self._consumer_id = None
self._condition.notify_all()
def admit_result(
self,
*,
session_id: str,
session_generation: int,
receiver: Callable[[], bool],
) -> bool:
"""Atomically reject a late worker result before viewer admission."""
with self._condition:
if (
self._closed
or not self._active
or self._session_id != session_id
or self._session_generation != session_generation
):
self._results_rejected_stale += 1
return False
accepted = receiver()
if accepted:
self._results_accepted += 1
else:
self._results_rejected_receiver += 1
return accepted
def take_next(
self,
consumer_id: str,
@@ -509,7 +561,11 @@ class LivePerceptionIngress:
"mode": "shadow-diagnostic-only",
"active": self._active,
"session_id": self._session_id,
"session_generation": self._session_generation,
"consumer_connected": self._consumer_id is not None,
"results_accepted": self._results_accepted,
"results_rejected_stale": self._results_rejected_stale,
"results_rejected_receiver": self._results_rejected_receiver,
"commands_enabled": False,
"navigation_or_safety_accepted": False,
"closed": self._closed,
@@ -548,6 +604,7 @@ class LivePerceptionIngress:
event = LiveIngressEvent(
ingress_sequence=self._ingress_sequence,
session_id=session_id,
session_generation=self._session_generation,
modality=modality,
source_id=source_id,
source_sequence=source_sequence,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
from __future__ import annotations
import fcntl
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from k1link.sessions.store import resolve_missioncore_data_dir
APPLICATION_CONTROL_LOCK_FILENAME = ".application-control.lock"
class ApplicationControlProcessLeaseError(RuntimeError):
"""The process-wide K1 control fence cannot be trusted."""
reason_code = "application-control-process-lease-error"
class ApplicationControlProcessLeaseUnavailable(ApplicationControlProcessLeaseError):
"""Another Mission Core process owns the canonical K1 control dialogue."""
reason_code = "application-control-process-lease-unavailable"
ApplicationControlProcessLeaseReleaseState = Literal["owned", "released", "ambiguous"]
ApplicationControlProcessLeaseReleaseDisposition = Literal[
"released",
"already-released",
]
class ApplicationControlProcessLeaseReleaseAmbiguous(ApplicationControlProcessLeaseError):
"""Neither strict unlock nor close proved the OS fence disposition."""
reason_code = "application-control-process-lease-release-ambiguous"
@dataclass(frozen=True, slots=True)
class ApplicationControlProcessLeaseReleaseOutcome:
"""Terminal release result, including non-retryable syscall diagnostics."""
disposition: ApplicationControlProcessLeaseReleaseDisposition
unlock_error_code: str | None = None
close_error_code: str | None = None
@dataclass(slots=True)
class ApplicationControlProcessLease:
"""Non-persistent ownership of the one canonical K1 control dialogue.
The lock file is deliberately stable while ownership lives only in the OS
lock attached to ``_descriptor``. A process crash normally releases
control admission without manufacturing any K1 command or durable recovery
claim. A target-owning child may deliberately inherit a duplicate of that
exact description; after a parent crash the kernel then preserves the fence
until the child itself exits.
"""
path: Path
_descriptor: int
_identity: tuple[int, int]
_released: bool = False
_release_ambiguous: bool = False
@property
def release_state(self) -> ApplicationControlProcessLeaseReleaseState:
if self._release_ambiguous:
return "ambiguous"
return "released" if self._released else "owned"
@classmethod
def acquire(cls, repository_root: Path) -> ApplicationControlProcessLease:
data_dir = resolve_missioncore_data_dir(repository_root)
lock_dir = data_dir / "xgrids-k1"
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(lock_dir, parents=False)
path = lock_dir / APPLICATION_CONTROL_LOCK_FILENAME
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be opened safely"
) from exc
locked = False
try:
opened = os.fstat(descriptor)
_validate_private_lock_file(opened)
try:
current = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock identity is unavailable"
) from exc
_validate_private_lock_file(current)
identity = (opened.st_dev, opened.st_ino)
if identity != (current.st_dev, current.st_ino):
raise ApplicationControlProcessLeaseError(
"application control process lock changed while opening"
)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
except BlockingIOError as exc:
raise ApplicationControlProcessLeaseUnavailable(
"another Mission Core process owns K1 application control"
) from exc
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be acquired safely"
) from exc
try:
locked_path = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock disappeared after acquisition"
) from exc
_validate_private_lock_file(locked_path)
if identity != (locked_path.st_dev, locked_path.st_ino):
raise ApplicationControlProcessLeaseError(
"application control process lock changed during acquisition"
)
return cls(path=path, _descriptor=descriptor, _identity=identity)
except BaseException:
if locked:
_unlock_descriptor(descriptor)
os.close(descriptor)
raise
def release(self) -> ApplicationControlProcessLeaseReleaseOutcome:
if self._released:
return ApplicationControlProcessLeaseReleaseOutcome(
disposition="already-released"
)
if self._release_ambiguous:
raise ApplicationControlProcessLeaseReleaseAmbiguous(
"application control process lock release remains ambiguous"
)
unlock_error: OSError | None = None
close_error: OSError | None = None
try:
fcntl.flock(self._descriptor, fcntl.LOCK_UN)
except OSError as exc:
unlock_error = exc
try:
os.close(self._descriptor)
except OSError as exc:
close_error = exc
if unlock_error is not None and close_error is not None:
# There is no portable ownership answer after two failing syscalls.
# Never retry either syscall on this descriptor: quarantine the
# process-local object and require process restart.
self._release_ambiguous = True
error = ApplicationControlProcessLeaseReleaseAmbiguous(
"application control process lock release outcome is ambiguous"
)
error.add_note(
"explicit unlock failed: "
f"{type(unlock_error).__name__}: {unlock_error}"
)
error.add_note(
"descriptor close failed: "
f"{type(close_error).__name__}: {close_error}"
)
raise error from close_error
# Either strict LOCK_UN or close proved that the flock can no longer
# be reused through this object. A diagnostic on the other syscall is
# terminal, not a retry instruction.
self._released = True
return ApplicationControlProcessLeaseReleaseOutcome(
disposition="released",
unlock_error_code=_os_error_code(unlock_error),
close_error_code=_os_error_code(close_error),
)
def duplicate_descriptor_for_child(self) -> int:
"""Duplicate the live flock description for one target-owning child.
``flock`` ownership follows the open file description across ``dup``
and ``exec``. A camera adapter that inherits this duplicate therefore
keeps the K1 lifecycle fence after an abrupt parent-process exit. The
caller must pass the returned descriptor through ``pass_fds`` and
close its parent-side duplicate immediately after spawning.
Normal shutdown must still terminate and reap the child before calling
:meth:`release`: an explicit ``LOCK_UN`` on any duplicate unlocks the
shared description for every process.
"""
if self._released or self._release_ambiguous:
raise ApplicationControlProcessLeaseError(
"released or quarantined application control lease cannot be inherited"
)
try:
descriptor = os.dup(self._descriptor)
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be duplicated safely"
) from exc
try:
metadata = os.fstat(descriptor)
_validate_private_lock_file(metadata)
if (metadata.st_dev, metadata.st_ino) != self._identity:
raise ApplicationControlProcessLeaseError(
"duplicated application control process lock changed identity"
)
return descriptor
except BaseException:
os.close(descriptor)
raise
def __enter__(self) -> ApplicationControlProcessLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def _ensure_private_directory(path: Path, *, parents: bool) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
metadata = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock directory is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise ApplicationControlProcessLeaseError(
"application control process lock directory is not private"
)
def _validate_private_lock_file(metadata: os.stat_result) -> None:
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_nlink != 1
):
raise ApplicationControlProcessLeaseError(
"application control process lock is not a private regular file"
)
def _os_error_code(error: OSError | None) -> str | None:
if error is None:
return None
return f"{type(error).__name__}:{error.errno if error.errno is not None else 'unknown'}"
def _unlock_descriptor(descriptor: int) -> None:
try:
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from importlib.metadata import version
from time import monotonic
@@ -11,7 +11,19 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device_selection
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation_session,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
CapturedDiscoveredDevice,
captured_device_handle,
connected_device_capture,
demote_connected_device_handle_after_gatt_failure,
discovered_device_selection,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
)
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4,
SERVICE_UUID,
@@ -23,6 +35,7 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
WifiStatus,
WriteMode,
_annotate_ble_operation_error,
_optional_int_attribute,
parse_wifi_status,
)
@@ -30,6 +43,7 @@ PROFILE_ID = "xgrids-k1-fw3-quick-connect-ap-v1"
FRAME_LENGTH = 100
COMMAND_OFFSET = 99
ENABLE_AP_COMMAND = 1
BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS = 25.0
ApActivationOutcome = Literal[
"already_active",
@@ -56,7 +70,7 @@ class ApActivationResult(TypedDict):
write_performed: bool
write_mode: ResolvedWriteMode | None
write_without_response_advertised: bool
max_write_without_response_size: int
max_write_without_response_size: int | None
frame_length: int
baseline_status: WifiStatus
observations: list[StatusObservation]
@@ -100,11 +114,16 @@ def _outcome(
@asynccontextmanager
async def device_ap_activation_session(
async def _device_ap_activation_session_impl(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None,
progress: BleOperationProgress,
) -> AsyncIterator[ApActivationResult]:
"""Keep BLE connected around one reviewed Quick Connect AP-enable write.
@@ -130,6 +149,12 @@ async def device_ap_activation_session(
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
resolved_write_mode_for_error: ResolvedWriteMode | None = None
write_characteristic_properties: tuple[str, ...] | None = None
max_without_response: int | None = None
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
progress.operation_stage = operation_stage
try:
try:
@@ -137,9 +162,44 @@ async def device_ap_activation_session(
# Keep the explicit scan and AP activation in one CoreBluetooth
# lifecycle. Re-looking up the UUID here lost a physically present
# K1 during acceptance, while the retained BLEDevice connected.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
if captured_device is not None:
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Retrieval is part of the same serialized ap-enable
# lease as connect, baseline read and the one command.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
):
# Preserve a bounded fallback for non-UI callers that did not
# establish a fresh explicit scan lease.
device = await BleakScanner.find_device_by_address(
@@ -157,21 +217,34 @@ async def device_ap_activation_session(
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
async with AsyncExitStack() as client_stack:
operation_stage = "connect"
progress.operation_stage = operation_stage
try:
client = await client_stack.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
except Exception as exc:
if active_captured_device is not None:
demote_connected_device_handle_after_gatt_failure(
active_captured_device
)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
@@ -179,6 +252,7 @@ async def device_ap_activation_session(
async with asyncio.timeout(timeout_seconds + 10.0):
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(
WRITE_CHARACTERISTIC_UUID
@@ -210,7 +284,11 @@ async def device_ap_activation_session(
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = write_characteristic.max_write_without_response_size
write_characteristic_properties = tuple(sorted(properties))
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
@@ -226,17 +304,32 @@ async def device_ap_activation_session(
)
resolved_write_mode = "with_response"
else:
resolved_write_mode = "without_response"
resolved_write_mode_for_error = resolved_write_mode
if resolved_write_mode == "without_response":
if max_without_response is None:
raise ValueError(
"Negotiated write-without-response size is unavailable"
)
if len(frame) > max_without_response:
raise ValueError(
"AP activation frame exceeds the negotiated "
"write-without-response size"
)
resolved_write_mode = "without_response"
operation_stage = "baseline-read"
progress.operation_stage = operation_stage
baseline = parse_wifi_status(
bytes(await client.read_gatt_char(status_characteristic))
)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before AP validation"
)
gatt_baseline_validated = True
# WIFI_AP is a control-mode status, not proof that the radio is
# still beaconing. A physical run found the exact SSID shortly
# after AP-enable, then found no beacon while 7f02 continued to
@@ -246,17 +339,26 @@ async def device_ap_activation_session(
# a stale-ready status. There is still no automatic retry.
operation_stage = "gatt-write"
# Persist the write barrier before handing the frame to
# CoreBluetooth. The callback is deliberately synchronous
# and secret-free; failure here prevents the device write.
if on_write_dispatch is not None:
on_write_dispatch(baseline, resolved_write_mode)
device_write_attempted = True
progress.operation_stage = operation_stage
progress.device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
progress.device_write_confirmed = device_write_confirmed
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
progress.operation_stage = operation_stage
while monotonic() < deadline:
try:
status = parse_wifi_status(
@@ -306,11 +408,19 @@ async def device_ap_activation_session(
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(
active_captured_device
)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
@@ -321,14 +431,62 @@ async def device_ap_activation_session(
# annotated as BLE failures when they are thrown back through yield.
yield result
finally:
# The runtime arbiter enforces a hard deadline by cancellation. Even
# when that bypasses the normal exception annotator, a captured
# recovery object that never passed baseline GATT validation must be
# discarded.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
frame[:] = b"\x00" * len(frame)
@asynccontextmanager
async def device_ap_activation_session(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> AsyncIterator[ApActivationResult]:
"""Own one process BLE lease through AP-ready and host Wi-Fi handoff."""
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
progress = BleOperationProgress(operation_stage="resolution")
async with run_ble_operation_session(
"ap-enable",
hard_setup_timeout_seconds=(timeout_seconds + BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS),
operation=lambda operation_progress: _device_ap_activation_session_impl(
device_macos_uuid,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
progress=operation_progress,
),
progress=progress,
) as result:
yield result
async def activate_device_ap_once(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> ApActivationResult:
"""Run one AP activation and release BLE immediately after its result.
@@ -341,5 +499,8 @@ async def activate_device_ap_once(
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
) as result:
return result
+60 -45
View File
@@ -8,6 +8,10 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
class DescriptorRecord(TypedDict):
@@ -48,57 +52,68 @@ async def dump_metadata(device_macos_uuid: str, timeout_seconds: float) -> GattD
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
async def perform(progress: BleOperationProgress) -> GattDumpResult:
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
progress.operation_stage = "gatt-metadata-discovery"
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
services: list[ServiceRecord] = []
for service in client.services:
characteristics: list[CharacteristicRecord] = []
for characteristic in service.characteristics:
descriptors: list[DescriptorRecord] = []
for descriptor in characteristic.descriptors:
descriptors.append(
progress.operation_stage = "gatt-metadata-connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
progress.operation_stage = "gatt-metadata-enumeration"
services: list[ServiceRecord] = []
for service in client.services:
characteristics: list[CharacteristicRecord] = []
for characteristic in service.characteristics:
descriptors: list[DescriptorRecord] = []
for descriptor in characteristic.descriptors:
descriptors.append(
{
"uuid": descriptor.uuid,
"handle": descriptor.handle,
"description": descriptor.description,
}
)
characteristics.append(
{
"uuid": descriptor.uuid,
"handle": descriptor.handle,
"description": descriptor.description,
"uuid": characteristic.uuid,
"handle": characteristic.handle,
"description": characteristic.description,
"properties": sorted(characteristic.properties),
"descriptors": descriptors,
}
)
characteristics.append(
services.append(
{
"uuid": characteristic.uuid,
"handle": characteristic.handle,
"description": characteristic.description,
"properties": sorted(characteristic.properties),
"descriptors": descriptors,
"uuid": service.uuid,
"handle": service.handle,
"description": service.description,
"characteristics": characteristics,
}
)
services.append(
{
"uuid": service.uuid,
"handle": service.handle,
"description": service.description,
"characteristics": characteristics,
}
)
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"metadata_only": True,
"services": services,
}
progress.operation_stage = "gatt-metadata-complete"
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"metadata_only": True,
"services": services,
}
return await run_ble_operation(
"status-read",
hard_timeout_seconds=timeout_seconds + 5.0,
operation=perform,
)
@@ -8,6 +8,10 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
class CharacteristicReadResult(TypedDict):
@@ -33,36 +37,47 @@ async def read_characteristic_once(
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
async def perform(progress: BleOperationProgress) -> CharacteristicReadResult:
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
progress.operation_stage = "gatt-characteristic-discovery"
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
characteristic = client.services.get_characteristic(characteristic_uuid)
if characteristic is None:
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
if "read" not in characteristic.properties:
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
value = bytes(await client.read_gatt_char(characteristic))
progress.operation_stage = "gatt-characteristic-connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
characteristic = client.services.get_characteristic(characteristic_uuid)
if characteristic is None:
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
if "read" not in characteristic.properties:
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
progress.operation_stage = "gatt-characteristic-read"
value = bytes(await client.read_gatt_char(characteristic))
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"characteristic_uuid": characteristic.uuid,
"operation": "single_gatt_read_no_pair_no_write",
"value_length": len(value),
"value_hex": value.hex(),
}
progress.operation_stage = "gatt-characteristic-complete"
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"characteristic_uuid": characteristic.uuid,
"operation": "single_gatt_read_no_pair_no_write",
"value_length": len(value),
"value_hex": value.hex(),
}
return await run_ble_operation(
"status-read",
hard_timeout_seconds=timeout_seconds + 5.0,
operation=perform,
)
@@ -0,0 +1,745 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import AsyncIterator, Callable, Coroutine, Iterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypedDict
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
APPLICATION_CONTROL_LOCK_FILENAME,
ApplicationControlProcessLease,
)
from k1link.sessions.store import resolve_missioncore_data_dir
BleOperationKind = Literal["scan", "status-read", "wifi-provision", "ap-enable"]
BleRuntimeIdleCallbackDisposition = Literal["released", "deferred", "poisoned"]
class BleRuntimeBusy(RuntimeError):
"""Another process-owned CoreBluetooth operation still owns the adapter."""
def __init__(
self,
*,
active_operation_kind: BleOperationKind,
cleanup_pending: bool,
) -> None:
message = (
"предыдущая BLE-операция ещё завершает безопасную очистку"
if cleanup_pending
else "другая BLE-операция уже выполняется в этом процессе"
)
super().__init__(message)
self.reason_code = "ble-runtime-cleanup-pending" if cleanup_pending else "ble-runtime-busy"
self.active_operation_kind = active_operation_kind
self.cleanup_pending = cleanup_pending
class BleRuntimeOwnerLoopConflict(RuntimeError):
"""The process BLE runtime is still owned by another live event loop."""
def __init__(self) -> None:
super().__init__("BLE runtime уже привязан к другому активному event loop")
self.reason_code = "ble-runtime-owner-loop-conflict"
class BleRuntimePoisoned(RuntimeError):
"""A closed owner loop abandoned an operation whose cleanup is unproved."""
def __init__(self) -> None:
super().__init__(
"BLE runtime требует перезапуска: предыдущая очистка CoreBluetooth не подтверждена"
)
self.reason_code = "ble-runtime-restart-required"
class BleRuntimeProcessLeaseNotConfigured(RuntimeError):
"""Low-level BLE was entered before its canonical OS lock was configured."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease repository root is not configured")
self.reason_code = "ble-runtime-process-lease-not-configured"
class BleRuntimeProcessLeaseConfigurationConflict(RuntimeError):
"""The canonical OS lock target changed while BLE ownership was live."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease configuration cannot change while active")
self.reason_code = "ble-runtime-process-lease-configuration-conflict"
class BleRuntimeProcessLeaseBorrowInvalid(RuntimeError):
"""An explicit higher-level OS lease borrow is stale or mismatched."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease borrow is not active for this lock target")
self.reason_code = "ble-runtime-process-lease-borrow-invalid"
@dataclass(slots=True)
class BleOperationProgress:
"""Non-secret facts copied onto a hard-deadline exception."""
operation_stage: str = "pending"
device_write_attempted: bool = False
device_write_confirmed: bool = False
owner_epoch: int = 0
class BleOperationHardTimeout(TimeoutError):
"""A hard caller deadline elapsed while cleanup continues in the background."""
def __init__(
self,
operation_kind: BleOperationKind,
progress: BleOperationProgress,
) -> None:
super().__init__("BLE-операция не завершилась в отведённое время")
self.reason_code = {
"scan": "ble-discovery-timeout",
"status-read": "ble-status-read-timeout",
"wifi-provision": "ble-provisioning-timeout",
"ap-enable": "ble-ap-enable-timeout",
}[operation_kind]
self.operation_kind = operation_kind
self.operation_stage = progress.operation_stage
self.device_write_attempted = progress.device_write_attempted
self.device_write_confirmed = progress.device_write_confirmed
class BleRuntimeSnapshot(TypedDict):
owner_epoch: int
owner_loop_bound: bool
active_operation_kind: BleOperationKind | None
cleanup_pending: bool
poisoned: bool
@dataclass(slots=True)
class BleRuntimeProcessLeaseBorrowToken:
"""An opaque, context-local proof that a higher layer owns the OS lease."""
repository_root: Path
lock_path: Path
configuration_epoch: int
_lease: ApplicationControlProcessLease
_active: bool = True
@dataclass(slots=True)
class _ActiveLease:
token: int
operation_kind: BleOperationKind
owner_epoch: int
cleanup_pending: bool = False
task: asyncio.Task[Any] | None = None
owned_process_lease: ApplicationControlProcessLease | None = None
borrowed_process_lease: ApplicationControlProcessLease | None = None
class _ProcessBleRuntimeArbiter:
"""One fail-fast process lease shared by every supported BLE entrypoint."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._owner_loop: asyncio.AbstractEventLoop | None = None
self._owner_epoch = 0
self._next_token = 0
self._active: _ActiveLease | None = None
self._poisoned = False
self._idle_callbacks: list[Callable[[], None]] = []
self._process_lease_repository_root: Path | None = None
self._process_lease_lock_path: Path | None = None
self._process_lease_configuration_epoch = 0
def configure_process_lease(self, repository_root: Path) -> int:
"""Pin every low-level BLE entrypoint to one canonical OS lock file."""
resolved_root = repository_root.expanduser().resolve()
lock_path = _process_lease_path(resolved_root)
with self._lock:
if (
self._process_lease_repository_root == resolved_root
and self._process_lease_lock_path == lock_path
):
return self._process_lease_configuration_epoch
if self._active is not None or self._poisoned or self._idle_callbacks:
raise BleRuntimeProcessLeaseConfigurationConflict()
self._process_lease_configuration_epoch += 1
self._process_lease_repository_root = resolved_root
self._process_lease_lock_path = lock_path
return self._process_lease_configuration_epoch
def create_process_lease_borrow(
self,
lease: ApplicationControlProcessLease,
) -> BleRuntimeProcessLeaseBorrowToken:
"""Validate an already-held higher-level lease before context borrowing."""
with self._lock:
repository_root = self._process_lease_repository_root
lock_path = self._process_lease_lock_path
if repository_root is None or lock_path is None:
raise BleRuntimeProcessLeaseNotConfigured()
if lease.path != lock_path or lease.release_state != "owned":
raise BleRuntimeProcessLeaseBorrowInvalid()
return BleRuntimeProcessLeaseBorrowToken(
repository_root=repository_root,
lock_path=lock_path,
configuration_epoch=self._process_lease_configuration_epoch,
_lease=lease,
)
def deactivate_process_lease_borrow(
self,
borrow: BleRuntimeProcessLeaseBorrowToken,
) -> None:
with self._lock:
borrow._active = False
def bind_owner_loop(self, loop: asyncio.AbstractEventLoop) -> int:
if loop.is_closed():
raise BleRuntimeOwnerLoopConflict()
with self._lock:
current = self._owner_loop
if current is loop:
# Facade dispatch binds every action so state/STOP/close remain
# available after an unproved BLE teardown. Only the next BLE
# lease acquisition is rejected while this loop stays owner.
return self._owner_epoch
if self._poisoned:
raise BleRuntimePoisoned()
if current is not None and not current.is_closed():
raise BleRuntimeOwnerLoopConflict()
if self._active is not None:
# Its done callback cannot be trusted after the owning loop has
# closed. Never admit a replacement native CoreBluetooth task.
self._poisoned = True
raise BleRuntimePoisoned()
self._owner_epoch += 1
self._owner_loop = loop
return self._owner_epoch
def owner_epoch_for_loop(self, loop: asyncio.AbstractEventLoop) -> int | None:
with self._lock:
if self._poisoned or self._owner_loop is not loop or loop.is_closed():
return None
return self._owner_epoch
def invalidate_owner_loop(
self,
expected_loop: asyncio.AbstractEventLoop | None,
) -> int:
with self._lock:
if expected_loop is not None and self._owner_loop is not expected_loop:
return self._owner_epoch
self._owner_epoch += 1
self._owner_loop = None
if self._active is not None:
self._poisoned = True
return self._owner_epoch
def acquire(
self,
operation_kind: BleOperationKind,
*,
owner_epoch: int,
process_lease_borrow: BleRuntimeProcessLeaseBorrowToken | None,
) -> int:
with self._lock:
if self._poisoned:
raise BleRuntimePoisoned()
if self._owner_epoch != owner_epoch:
raise BleRuntimeOwnerLoopConflict()
if self._active is not None:
raise BleRuntimeBusy(
active_operation_kind=self._active.operation_kind,
cleanup_pending=self._active.cleanup_pending,
)
repository_root = self._process_lease_repository_root
lock_path = self._process_lease_lock_path
if repository_root is None or lock_path is None:
raise BleRuntimeProcessLeaseNotConfigured()
owned_process_lease: ApplicationControlProcessLease | None = None
borrowed_process_lease: ApplicationControlProcessLease | None = None
if process_lease_borrow is None:
# Detect an environment-driven data-root change before opening
# or creating a lock at a different path than configuration.
if _process_lease_path(repository_root) != lock_path:
raise BleRuntimeProcessLeaseConfigurationConflict()
owned_process_lease = ApplicationControlProcessLease.acquire(repository_root)
if owned_process_lease.path != lock_path:
owned_process_lease.release()
raise BleRuntimeProcessLeaseConfigurationConflict()
else:
if (
not process_lease_borrow._active
or process_lease_borrow.configuration_epoch
!= self._process_lease_configuration_epoch
or process_lease_borrow.repository_root != repository_root
or process_lease_borrow.lock_path != lock_path
or process_lease_borrow._lease.path != lock_path
or process_lease_borrow._lease.release_state != "owned"
):
raise BleRuntimeProcessLeaseBorrowInvalid()
borrowed_process_lease = process_lease_borrow._lease
self._next_token += 1
token = self._next_token
self._active = _ActiveLease(
token=token,
operation_kind=operation_kind,
owner_epoch=owner_epoch,
owned_process_lease=owned_process_lease,
borrowed_process_lease=borrowed_process_lease,
)
return token
def attach_task(self, token: int, task: asyncio.Task[Any]) -> None:
with self._lock:
if self._active is None or self._active.token != token:
raise RuntimeError("BLE runtime lease was invalidated before task attachment")
self._active.task = task
def mark_cleanup_pending(self, token: int) -> None:
with self._lock:
if self._active is not None and self._active.token == token:
self._active.cleanup_pending = True
def release(self, token: int) -> None:
"""Finish one lease and drain callbacks before admitting another lease.
The completed task remains represented by ``_active`` while callbacks
run. Consequently a concurrent local BLE acquisition still fails
closed until the external lifecycle barriers retained by those
callbacks have actually been released. Callbacks must be bounded and
must not call back into this arbiter.
"""
while True:
with self._lock:
active = self._active
if active is None or active.token != token or self._poisoned:
return
callbacks = tuple(self._idle_callbacks)
self._idle_callbacks.clear()
if not callbacks:
process_lease = active.owned_process_lease
if process_lease is not None:
try:
# This is a non-blocking unlock/close. Keep the
# arbiter lock held so neither a local acquisition
# nor a newly registered external release can race
# the final OS ownership transition.
process_lease.release()
except Exception:
active.cleanup_pending = True
self._poisoned = True
return
self._active = None
return
for index, callback in enumerate(callbacks):
try:
callback()
except Exception:
# An external release that cannot be proven complete is a
# process-wide safety failure. Keep this lease and every
# unexecuted callback retained until process restart; never
# retry a callback whose partial effects are unknowable.
with self._lock:
current = self._active
if current is not None and current.token == token:
current.cleanup_pending = True
self._poisoned = True
self._idle_callbacks[0:0] = callbacks[index:]
return
def poison_cleanup_failure(self, token: int) -> None:
"""Retain ownership when native session teardown is not proven clean."""
with self._lock:
if self._active is not None and self._active.token == token:
self._active.cleanup_pending = True
self._poisoned = True
def defer_until_idle(
self,
callback: Callable[[], None],
) -> BleRuntimeIdleCallbackDisposition:
"""Run a bounded callback now or at the next proven-idle transition.
Registration and the idle decision are serialized with acquisition and
release. When a BLE operation is active, its lease stays active while
the callback runs, so another local operation cannot enter the native
runtime between teardown and release of an external process lease.
Poisoned runtimes retain callbacks without ever invoking them: only a
process restart may safely release the corresponding OS ownership.
The callback must be quick, non-blocking, and must not call this arbiter.
"""
with self._lock:
if self._poisoned:
self._idle_callbacks.append(callback)
return "poisoned"
if self._active is not None:
self._idle_callbacks.append(callback)
return "deferred"
try:
callback()
except Exception:
# There is no caller-independent way to know whether an
# external release partially succeeded. Preserve the callback
# and prohibit a later BLE admission until process restart.
self._poisoned = True
self._idle_callbacks.append(callback)
raise
return "released"
def snapshot(self) -> BleRuntimeSnapshot:
with self._lock:
active = self._active
return {
"owner_epoch": self._owner_epoch,
"owner_loop_bound": (
self._owner_loop is not None and not self._owner_loop.is_closed()
),
"active_operation_kind": (active.operation_kind if active is not None else None),
"cleanup_pending": bool(active is not None and active.cleanup_pending),
"poisoned": self._poisoned,
}
def reset_for_tests(self) -> None:
"""Forget singleton state without letting an old callback release a new lease."""
with self._lock:
owned_process_lease = (
self._active.owned_process_lease if self._active is not None else None
)
self._owner_epoch += 1
self._next_token += 1
self._owner_loop = None
self._active = None
self._poisoned = False
self._idle_callbacks.clear()
self._process_lease_configuration_epoch += 1
self._process_lease_repository_root = None
self._process_lease_lock_path = None
if owned_process_lease is not None:
# Test reset deliberately models process exit for synthetic poison.
owned_process_lease.release()
_BLE_PROCESS_ARBITER = _ProcessBleRuntimeArbiter()
_BLE_PROCESS_LEASE_BORROW_CONTEXT: ContextVar[
BleRuntimeProcessLeaseBorrowToken | None
] = ContextVar("ble_runtime_process_lease_borrow", default=None)
def _process_lease_path(repository_root: Path) -> Path:
return (
resolve_missioncore_data_dir(repository_root)
/ "xgrids-k1"
/ APPLICATION_CONTROL_LOCK_FILENAME
)
def _annotate_cancellation(
exc: asyncio.CancelledError,
progress: BleOperationProgress,
) -> None:
"""Preserve side-effect facts when caller cancellation crosses BLE I/O."""
exc.operation_stage = progress.operation_stage # type: ignore[attr-defined]
exc.device_write_attempted = progress.device_write_attempted # type: ignore[attr-defined]
exc.device_write_confirmed = progress.device_write_confirmed # type: ignore[attr-defined]
def bind_ble_runtime_owner_loop(
loop: asyncio.AbstractEventLoop | None = None,
) -> int:
"""Bind process CoreBluetooth ownership to the current persistent loop."""
return _BLE_PROCESS_ARBITER.bind_owner_loop(loop or asyncio.get_running_loop())
def ble_runtime_owner_epoch_for_current_loop() -> int | None:
"""Return the owner epoch only when called on the bound live loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return None
return _BLE_PROCESS_ARBITER.owner_epoch_for_loop(loop)
def invalidate_ble_runtime_owner_loop(
loop: asyncio.AbstractEventLoop | None = None,
) -> int:
"""Invalidate process loop ownership without releasing an active BLE lease."""
return _BLE_PROCESS_ARBITER.invalidate_owner_loop(loop)
def ble_runtime_snapshot() -> BleRuntimeSnapshot:
"""Expose a non-secret process snapshot for diagnostics and focused tests."""
return _BLE_PROCESS_ARBITER.snapshot()
def reset_ble_runtime_arbiter_for_tests() -> None:
"""Reset process-global state between synthetic tests only."""
_BLE_PROCESS_ARBITER.reset_for_tests()
_BLE_PROCESS_LEASE_BORROW_CONTEXT.set(None)
def configure_ble_runtime_process_lease(repository_root: Path) -> int:
"""Configure the canonical OS lifecycle lock used by all BLE entrypoints."""
return _BLE_PROCESS_ARBITER.configure_process_lease(repository_root)
@contextmanager
def borrow_ble_runtime_process_lease(
lease: ApplicationControlProcessLease,
) -> Iterator[BleRuntimeProcessLeaseBorrowToken]:
"""Borrow a higher-level lifecycle lease without a second flock attempt.
The caller must keep ``lease`` owned until every BLE operation admitted in
this context has reached proven idle. A copied context cannot admit a new
operation after this manager exits because the opaque token is invalidated.
"""
borrow = _BLE_PROCESS_ARBITER.create_process_lease_borrow(lease)
context_token = _BLE_PROCESS_LEASE_BORROW_CONTEXT.set(borrow)
try:
yield borrow
finally:
_BLE_PROCESS_ARBITER.deactivate_process_lease_borrow(borrow)
_BLE_PROCESS_LEASE_BORROW_CONTEXT.reset(context_token)
def defer_until_ble_runtime_idle(
callback: Callable[[], None],
) -> BleRuntimeIdleCallbackDisposition:
"""Release an external barrier only after native BLE ownership is idle.
``"released"`` means the callback ran synchronously because no BLE lease
existed. ``"deferred"`` means it is owned by the active lease and will run
exactly once after native task completion. ``"poisoned"`` means it is
intentionally retained without execution until process restart.
"""
return _BLE_PROCESS_ARBITER.defer_until_idle(callback)
async def wait_for_ble_runtime_idle(timeout_seconds: float = 1.0) -> bool:
"""Wait without blocking the owner loop until the active lease is released."""
if timeout_seconds < 0:
raise ValueError("timeout_seconds must be non-negative")
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_seconds
while ble_runtime_snapshot()["active_operation_kind"] is not None:
if loop.time() >= deadline:
return False
await asyncio.sleep(min(0.01, max(0.0, deadline - loop.time())))
return True
async def run_ble_operation[T](
operation_kind: BleOperationKind,
*,
hard_timeout_seconds: float,
operation: Callable[[BleOperationProgress], Coroutine[Any, Any, T]],
progress: BleOperationProgress | None = None,
) -> T:
"""Run one BLE task behind a process lease and a non-blocking hard deadline."""
if hard_timeout_seconds <= 0:
raise ValueError("hard_timeout_seconds must be positive")
loop = asyncio.get_running_loop()
owner_epoch = bind_ble_runtime_owner_loop(loop)
operation_progress = progress or BleOperationProgress()
operation_progress.owner_epoch = owner_epoch
token = _BLE_PROCESS_ARBITER.acquire(
operation_kind,
owner_epoch=owner_epoch,
process_lease_borrow=_BLE_PROCESS_LEASE_BORROW_CONTEXT.get(),
)
try:
task: asyncio.Task[T] = loop.create_task(operation(operation_progress))
except BaseException:
_BLE_PROCESS_ARBITER.release(token)
raise
_BLE_PROCESS_ARBITER.attach_task(token, task)
cleanup_requested = False
def release_after_completion(completed: asyncio.Future[T]) -> None:
cleanup_failed = False
try:
completed_exception = None if completed.cancelled() else completed.exception()
cleanup_failed = cleanup_requested and completed_exception is not None
except BaseException:
# The caller observes the original result/exception. This callback
# only consumes detached cleanup outcomes and releases ownership.
cleanup_failed = cleanup_requested
finally:
if cleanup_failed:
_BLE_PROCESS_ARBITER.poison_cleanup_failure(token)
else:
_BLE_PROCESS_ARBITER.release(token)
task.add_done_callback(release_after_completion)
try:
completed, _ = await asyncio.wait({task}, timeout=hard_timeout_seconds)
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
if not task.done():
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
cleanup_requested = True
task.cancel()
raise
if completed:
return task.result()
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
cleanup_requested = True
task.cancel()
raise BleOperationHardTimeout(operation_kind, operation_progress)
@asynccontextmanager
async def run_ble_operation_session[T](
operation_kind: BleOperationKind,
*,
hard_setup_timeout_seconds: float,
hard_cleanup_timeout_seconds: float = 5.0,
operation: Callable[
[BleOperationProgress],
AbstractAsyncContextManager[T],
],
progress: BleOperationProgress | None = None,
) -> AsyncIterator[T]:
"""Hold one process BLE lease across setup, caller work, and cleanup.
Only setup is subject to the hard deadline. Once the inner session yields,
its native BLE client remains alive until the caller leaves this context.
Cancellation never frees the process lease early: a detached native cleanup
continues to own the lease until its task has actually completed.
"""
if hard_setup_timeout_seconds <= 0:
raise ValueError("hard_setup_timeout_seconds must be positive")
if hard_cleanup_timeout_seconds <= 0:
raise ValueError("hard_cleanup_timeout_seconds must be positive")
loop = asyncio.get_running_loop()
owner_epoch = bind_ble_runtime_owner_loop(loop)
operation_progress = progress or BleOperationProgress()
operation_progress.owner_epoch = owner_epoch
token = _BLE_PROCESS_ARBITER.acquire(
operation_kind,
owner_epoch=owner_epoch,
process_lease_borrow=_BLE_PROCESS_LEASE_BORROW_CONTEXT.get(),
)
ready: asyncio.Future[T] = loop.create_future()
release_requested = asyncio.Event()
setup_cleanup_requested = False
async def session_task() -> None:
async with operation(operation_progress) as value:
if not ready.done():
ready.set_result(value)
await release_requested.wait()
try:
task = loop.create_task(session_task())
except BaseException:
_BLE_PROCESS_ARBITER.release(token)
raise
_BLE_PROCESS_ARBITER.attach_task(token, task)
def release_after_completion(completed: asyncio.Future[None]) -> None:
cleanup_failed = False
try:
completed_exception = None if completed.cancelled() else completed.exception()
cleanup_failed = bool(
(
ready.done()
and not ready.cancelled()
and release_requested.is_set()
and (completed.cancelled() or completed_exception is not None)
)
or (setup_cleanup_requested and completed_exception is not None)
)
except BaseException:
# The active caller observes setup/cleanup failures directly. This
# callback only consumes detached outcomes before releasing ownership.
cleanup_failed = setup_cleanup_requested or bool(
ready.done() and not ready.cancelled() and release_requested.is_set()
)
finally:
if cleanup_failed:
_BLE_PROCESS_ARBITER.poison_cleanup_failure(token)
else:
_BLE_PROCESS_ARBITER.release(token)
task.add_done_callback(release_after_completion)
setup_waiters: set[asyncio.Future[Any]] = {ready, task}
try:
completed, _ = await asyncio.wait(
setup_waiters,
timeout=hard_setup_timeout_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
if not task.done():
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
setup_cleanup_requested = True
task.cancel()
raise
if ready not in completed:
if task in completed:
# Setup failed before the inner session became available.
task.result()
raise RuntimeError("BLE session ended before setup completed")
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
setup_cleanup_requested = True
task.cancel()
raise BleOperationHardTimeout(operation_kind, operation_progress)
try:
try:
yield ready.result()
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
raise
finally:
# From this point the setup deadline no longer applies. The caller may
# perform a bounded host-side handoff while the same BLE client remains
# connected. On exit, ownership is retained until __aexit__ really ends.
# A wedged native disconnect must not hold the HTTP caller forever:
# detach it after the cleanup deadline while the task and process lease
# remain quarantined until CoreBluetooth actually acknowledges cleanup.
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
release_requested.set()
completed, _ = await asyncio.wait(
{task},
timeout=hard_cleanup_timeout_seconds,
)
if completed:
task.result()
# Do not cancel a disconnect already in progress. A second cancellation
# can make an otherwise responsive ``__aexit__`` finish as cancelled
# without proving that CoreBluetooth acknowledged the native teardown.
# The detached task therefore keeps the process lease until its natural
# completion callback releases it.
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,8 @@ from __future__ import annotations
import asyncio
import ipaddress
import math
from collections.abc import Callable
from importlib.metadata import version
from time import monotonic
from typing import Literal, TypedDict
@@ -10,8 +12,20 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError, BleakGATTProtocolError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
CapturedDiscoveredDevice,
captured_device_handle,
connected_device_capture,
demote_connected_device_handle_after_gatt_failure,
discover_known_device_capture_for_status_read,
discovered_device_selection,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read,
)
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
@@ -22,6 +36,8 @@ FRAME_LENGTH = 99
SSID_SLOT_LENGTH = 32
PASSWORD_SLOT_LENGTH = 64
AP_FALLBACK_IPV4 = "192.168.56.1"
BLE_STATUS_HARD_TIMEOUT_GRACE_SECONDS = 5.0
BLE_PROVISION_HARD_TIMEOUT_GRACE_SECONDS = 25.0
ProvisioningOutcome = Literal[
"lan_address_observed",
"status_changed",
@@ -38,11 +54,28 @@ BleOperationStage = Literal[
"gatt-write",
"status-poll",
]
StatusReadOperationStage = Literal[
"resolution",
"exact-uuid-scan",
"connect",
"gatt-contract",
"status-read",
]
_STATUS_READ_OPERATION_STAGES: frozenset[str] = frozenset(
{
"resolution",
"exact-uuid-scan",
"connect",
"gatt-contract",
"status-read",
}
)
class WifiStatus(TypedDict):
value_length: int
mode: str | None
network_name: str | None
ipv4: str | None
status_code: int
reserved: int | None
@@ -70,7 +103,7 @@ class WifiProvisioningResult(TypedDict):
operation: str
write_mode: ResolvedWriteMode
write_without_response_advertised: bool
max_write_without_response_size: int
max_write_without_response_size: int | None
frame_length: int
baseline_status: WifiStatus
observations: list[StatusObservation]
@@ -86,6 +119,10 @@ class WifiStatusReadResult(TypedDict):
device_macos_uuid: str
device_name: str
service_uuid: str
write_characteristic_uuid: str
write_characteristic_properties: list[str]
max_write_without_response_size: int | None
mtu_size: int | None
status_characteristic_uuid: str
operation: Literal["single_reviewed_wifi_status_read"]
write_performed: Literal[False]
@@ -98,17 +135,61 @@ def _annotate_ble_operation_error(
operation_stage: BleOperationStage,
device_write_attempted: bool,
device_write_confirmed: bool,
resolved_write_mode: ResolvedWriteMode | None,
write_characteristic_properties: tuple[str, ...] | None,
max_write_without_response_size: int | None,
frame_length: int,
) -> None:
"""Attach non-secret transport facts while preserving the exception type."""
exc.operation_stage = operation_stage # type: ignore[attr-defined]
exc.device_write_attempted = device_write_attempted # type: ignore[attr-defined]
exc.device_write_confirmed = device_write_confirmed # type: ignore[attr-defined]
exc.resolved_write_mode = resolved_write_mode # type: ignore[attr-defined]
exc.write_characteristic_properties = ( # type: ignore[attr-defined]
write_characteristic_properties
)
exc.max_write_without_response_size = ( # type: ignore[attr-defined]
max_write_without_response_size
)
exc.frame_length = frame_length # type: ignore[attr-defined]
if isinstance(exc, BleakGATTProtocolError):
exc.att_error_code = int(exc.code) # type: ignore[attr-defined]
exc.att_error_name = exc.code.name # type: ignore[attr-defined]
def _annotate_status_read_error(exc: Exception, operation_stage: str) -> None:
"""Attach only one sanitized read-only stage to a transport failure."""
try:
existing_stage = getattr(exc, "operation_stage", None)
except Exception:
existing_stage = None
if existing_stage in _STATUS_READ_OPERATION_STAGES:
return
sanitized_stage = (
operation_stage
if operation_stage in _STATUS_READ_OPERATION_STAGES
else "resolution"
)
try:
exc.operation_stage = sanitized_stage # type: ignore[attr-defined]
except Exception:
# A third-party exception may forbid dynamic attributes. Preserve its
# original type and traceback rather than replacing the BLE failure.
return
def _optional_int_attribute(source: object, name: str) -> int | None:
"""Read optional backend metadata without making diagnostics operationally required."""
try:
value = getattr(source, name, None)
except Exception:
return None
return value if isinstance(value, int) and not isinstance(value, bool) else None
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
ssid_bytes = ssid.encode("utf-8")
@@ -133,17 +214,40 @@ def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
def parse_wifi_status(value: bytes) -> WifiStatus:
"""Parse the non-secret status frame returned by the K1 read characteristic."""
"""Parse the status frame returned by the K1 read characteristic.
The first 32-byte text slot is not a mode enum in station mode. Physical
K1 FW 3.0.2 evidence shows that it contains the joined Wi-Fi network name
(for example a lab SSID), while AP mode uses the control literal
``WIFI_AP``. Keep ``mode`` as the normalized semantic family so callers do
not have to mistake an operator network name for a protocol enum, and
expose ``network_name`` only for exact, in-process target comparison.
"""
if len(value) < 51:
raise ValueError("K1 Wi-Fi status must contain at least 51 bytes")
mode_length = value[0]
if mode_length > SSID_SLOT_LENGTH:
raise ValueError("K1 Wi-Fi status mode length is invalid")
text_length = value[0]
if text_length > SSID_SLOT_LENGTH:
raise ValueError("K1 Wi-Fi status text length is invalid")
try:
mode = value[1 : 1 + mode_length].decode("utf-8") if mode_length else None
status_text = (
value[1 : 1 + text_length].decode("utf-8") if text_length else None
)
except UnicodeDecodeError as exc:
raise ValueError("K1 Wi-Fi status mode is not valid UTF-8") from exc
raise ValueError("K1 Wi-Fi status text is not valid UTF-8") from exc
if status_text == "WIFI_AP":
mode = "WIFI_AP"
network_name = None
elif status_text:
# Older synthetic fixtures and possible legacy firmware may still
# report the literal WIFI_CLIENT. It identifies the station family
# but supplies no exact network discriminator.
mode = "WIFI_CLIENT"
network_name = None if status_text == "WIFI_CLIENT" else status_text
else:
mode = None
network_name = None
address_length = value[33]
address_start = 34
@@ -163,6 +267,7 @@ def parse_wifi_status(value: bytes) -> WifiStatus:
return {
"value_length": len(value),
"mode": mode,
"network_name": network_name,
"ipv4": ipv4,
"status_code": value[50],
"reserved": value[51] if len(value) > 51 else None,
@@ -186,25 +291,95 @@ def _outcome(
return "no_status_change_before_timeout"
async def read_wifi_status_once(
async def _read_wifi_status_impl(
device_macos_uuid: str,
*,
timeout_seconds: float = 20.0,
rediscover: bool = False,
timeout_seconds: float,
exact_scan_timeout_seconds: float,
rediscover: bool,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
allow_known_device_retrieval: bool,
on_gatt_validated: Callable[[CapturedDiscoveredDevice], None] | None,
progress: BleOperationProgress,
) -> WifiStatusReadResult:
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
async with asyncio.timeout(timeout_seconds + 5.0):
# A still-live explicit scan lease is authoritative even for a caller
# requesting recovery. Physical acceptance proved that immediately
# looking the same CoreBluetooth UUID up again can lose a present K1.
# ``rediscover`` therefore permits fallback only after that short lease
# has expired; it never discards a fresh retained BLEDevice.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
try:
progress.operation_stage = "resolution"
if captured_device is not None:
# Explicit recovery is fail-closed: only the exact retrieved
# CoreBluetooth object may be used. Never replace it with a scan,
# UUID lookup, or automatic retry behind the operator's back.
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Same-process recovery is resolved inside the status-read BLE
# lease. Retrieval, connect, reviewed GATT contract and 7f02 are
# therefore one serialized operation; no scan row or UUID lookup
# can race between those steps.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
elif allow_known_device_retrieval:
# Durable physical recovery deliberately bypasses public scan
# generations. ``rediscover`` means one primary, unfiltered
# advertisement wait for this exact CoreBluetooth UUID; it never
# attempts the potentially stale cached peripheral first and never
# falls back to it after a timeout or failed connect.
if rediscover:
progress.operation_stage = "exact-uuid-scan"
active_captured_device = (
await discover_known_device_capture_for_status_read(
device_macos_uuid,
timeout_seconds=exact_scan_timeout_seconds,
)
)
else:
active_captured_device = (
await retrieve_known_device_capture_for_status_read(
device_macos_uuid,
)
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
# A still-live explicit scan lease is authoritative. Physical
# acceptance proved that immediately looking the same UUID up
# again can lose a present K1.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
and not allow_known_device_retrieval
):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=timeout_seconds,
@@ -212,23 +387,61 @@ async def read_wifi_status_once(
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
"Exact BLE device is unavailable; run an explicit recovery or scan.",
)
progress.operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
progress.operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
status_characteristic = client.services.get_characteristic(STATUS_CHARACTERISTIC_UUID)
write_characteristic = client.services.get_characteristic(
WRITE_CHARACTERISTIC_UUID
)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
"Reviewed K1 write characteristic not found: "
f"{WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
"Reviewed K1 status characteristic not found: "
f"{STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError(
"K1 write characteristic is attached to an unexpected service"
)
if status_characteristic.service_uuid != service.uuid:
raise ValueError("K1 status characteristic is attached to an unexpected service")
raise ValueError(
"K1 status characteristic is attached to an unexpected service"
)
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
write_properties = sorted(
{str(item) for item in write_characteristic.properties}
)
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
mtu_size = _optional_int_attribute(client, "mtu_size")
progress.operation_stage = "status-read"
value = bytes(await client.read_gatt_char(status_characteristic))
status = parse_wifi_status(value)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before status validation"
)
gatt_baseline_validated = True
if active_captured_device is not None and on_gatt_validated is not None:
on_gatt_validated(active_captured_device)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
@@ -238,11 +451,325 @@ async def read_wifi_status_once(
"device_macos_uuid": device_macos_uuid,
"device_name": client.name or "",
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"write_characteristic_properties": write_properties,
"max_write_without_response_size": max_without_response,
"mtu_size": mtu_size,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_status_read",
"write_performed": False,
"status": parse_wifi_status(value),
"status": status,
}
except Exception as exc:
_annotate_status_read_error(exc, progress.operation_stage)
raise
finally:
# Connection/contract/read failure revokes only this transport lease;
# the process-scoped UUID/session token remains available for another
# explicit CoreBluetooth retrieval attempt.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
async def read_wifi_status_once(
device_macos_uuid: str,
*,
timeout_seconds: float = 20.0,
exact_scan_timeout_seconds: float = 30.0,
rediscover: bool = False,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
allow_known_device_retrieval: bool = False,
on_gatt_validated: Callable[[CapturedDiscoveredDevice], None] | None = None,
) -> WifiStatusReadResult:
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
if recovery_device_session_id is not None and allow_known_device_retrieval:
raise ValueError(
"same-process recovery and durable known-device retrieval are mutually exclusive"
)
use_exact_uuid_scan = bool(
rediscover
and allow_known_device_retrieval
and captured_device is None
and recovery_device_session_id is None
)
if use_exact_uuid_scan and (
not math.isfinite(exact_scan_timeout_seconds)
or exact_scan_timeout_seconds <= 0
):
raise ValueError("exact_scan_timeout_seconds must be positive and finite")
exact_scan_budget = exact_scan_timeout_seconds if use_exact_uuid_scan else 0.0
return await run_ble_operation(
"status-read",
hard_timeout_seconds=(
exact_scan_budget
+ timeout_seconds
+ BLE_STATUS_HARD_TIMEOUT_GRACE_SECONDS
),
operation=lambda progress: _read_wifi_status_impl(
device_macos_uuid,
timeout_seconds=timeout_seconds,
exact_scan_timeout_seconds=exact_scan_timeout_seconds,
rediscover=rediscover,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
allow_known_device_retrieval=allow_known_device_retrieval,
on_gatt_validated=on_gatt_validated,
progress=progress,
),
progress=BleOperationProgress(operation_stage="resolution"),
)
async def _provision_wifi_impl(
device_macos_uuid: str,
ssid: str,
password: str,
*,
timeout_seconds: float,
poll_interval_seconds: float,
write_mode: WriteMode,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None,
progress: BleOperationProgress,
) -> WifiProvisioningResult:
frame = build_wifi_provisioning_frame(ssid, password)
started_at = utc_now_iso()
observations: list[StatusObservation] = []
disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
resolved_write_mode_for_error: ResolvedWriteMode | None = None
write_characteristic_properties: tuple[str, ...] | None = None
max_without_response: int | None = None
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
try:
progress.operation_stage = operation_stage
# The explicit UI scan and its selected network action are one
# CoreBluetooth lifecycle. A supplied capture is fail-closed: never
# replace an expired/mismatched object with a scan or UUID lookup.
if captured_device is not None:
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Resolve an exact retained UUID/session token only after the
# wifi-provision arbiter lease has been admitted. This prevents a
# concurrent scan/status operation and removes the preflight-to-
# write lease gap.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
):
# Non-UI callers without a current explicit scan retain the
# bounded lookup fallback. A fresh scan missing this device is
# authoritative and must not be silently replaced here.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
operation_stage = "connect"
progress.operation_stage = operation_stage
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(STATUS_CHARACTERISTIC_UUID)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
if status_characteristic.service_uuid != service.uuid:
raise ValueError("K1 status characteristic is attached to an unexpected service")
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
write_characteristic_properties = tuple(sorted(properties))
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
resolved_write_mode = "without_response"
elif "write" in properties:
resolved_write_mode = "with_response"
else:
raise ValueError("Reviewed K1 characteristic is not writable")
elif write_mode == "with_response":
if "write" not in properties:
raise ValueError(
"Reviewed K1 characteristic does not advertise writes with response"
)
resolved_write_mode = "with_response"
else:
resolved_write_mode = "without_response"
resolved_write_mode_for_error = resolved_write_mode
if resolved_write_mode == "without_response":
if max_without_response is None:
raise ValueError("Negotiated write-without-response size is unavailable")
if len(frame) > max_without_response:
raise ValueError(
"Provisioning frame exceeds the negotiated write-without-response size"
)
operation_stage = "baseline-read"
progress.operation_stage = operation_stage
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before provisioning validation"
)
gatt_baseline_validated = True
operation_stage = "gatt-write"
# This callback is the durable side-effect boundary. It must finish
# before CoreBluetooth receives the frame, so a process crash can
# only create a conservative false-positive fence, never an unsafe
# forgotten write. It receives no SSID, password, or frame bytes.
if on_write_dispatch is not None:
on_write_dispatch(baseline, resolved_write_mode)
device_write_attempted = True
progress.operation_stage = operation_stage
progress.device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
progress.device_write_confirmed = device_write_confirmed
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
progress.operation_stage = operation_stage
while monotonic() < deadline:
try:
value = bytes(await client.read_gatt_char(status_characteristic))
except BleakError:
if not client.is_connected:
disconnected = True
break
raise
status = parse_wifi_status(value)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(),
"seconds_after_write": round(monotonic() - write_completed, 3),
"status": status,
}
if not observations or status != observations[-1]["status"]:
observations.append(observation)
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
break
await asyncio.sleep(poll_interval_seconds)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": device_name,
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,
"observations": observations,
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
finally:
# Hard-timeout cancellation may bypass ``except Exception``. The
# failed live transport lease must still be demoted when the exact
# captured object never completed the reviewed baseline read; the
# UUID/session recovery token itself remains available for a later
# explicit attempt.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
frame[:] = b"\x00" * len(frame)
async def provision_wifi_once(
@@ -252,160 +779,40 @@ async def provision_wifi_once(
timeout_seconds: float = 45.0,
poll_interval_seconds: float = 1.0,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> WifiProvisioningResult:
"""Perform one reviewed provisioning write and poll the K1 status characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if poll_interval_seconds <= 0:
raise ValueError("poll_interval_seconds must be positive")
if write_mode not in ("auto", "with_response", "without_response"):
raise ValueError(f"Unsupported write mode: {write_mode}")
frame = build_wifi_provisioning_frame(ssid, password)
started_at = utc_now_iso()
observations: list[StatusObservation] = []
disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
try:
async with asyncio.timeout(timeout_seconds + 25.0):
# The explicit UI scan and its selected network action are one
# CoreBluetooth lifecycle. Physical acceptance proved that a
# second UUID lookup can fail moments after a successful scan, so
# use the exact retained handle while its short lease is fresh.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
# Non-UI callers without a current explicit scan retain the
# bounded lookup fallback. A fresh scan missing this device is
# authoritative and must not be silently replaced here.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
if status_characteristic.service_uuid != service.uuid:
raise ValueError(
"K1 status characteristic is attached to an unexpected service"
)
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = write_characteristic.max_write_without_response_size
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
resolved_write_mode = "without_response"
elif "write" in properties:
resolved_write_mode = "with_response"
else:
raise ValueError("Reviewed K1 characteristic is not writable")
elif write_mode == "with_response":
if "write" not in properties:
raise ValueError(
"Reviewed K1 characteristic does not advertise writes with response"
)
resolved_write_mode = "with_response"
else:
if len(frame) > max_without_response:
raise ValueError(
"Provisioning frame exceeds the negotiated write-without-response size"
)
resolved_write_mode = "without_response"
operation_stage = "baseline-read"
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value)
operation_stage = "gatt-write"
device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
while monotonic() < deadline:
try:
value = bytes(await client.read_gatt_char(status_characteristic))
except BleakError:
if not client.is_connected:
disconnected = True
break
raise
status = parse_wifi_status(value)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(),
"seconds_after_write": round(monotonic() - write_completed, 3),
"status": status,
}
if not observations or status != observations[-1]["status"]:
observations.append(observation)
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
break
await asyncio.sleep(poll_interval_seconds)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": device_name,
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,
"observations": observations,
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
raise
finally:
frame[:] = b"\x00" * len(frame)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
progress = BleOperationProgress(operation_stage="resolution")
return await run_ble_operation(
"wifi-provision",
hard_timeout_seconds=(timeout_seconds + BLE_PROVISION_HARD_TIMEOUT_GRACE_SECONDS),
operation=lambda operation_progress: _provision_wifi_impl(
device_macos_uuid,
ssid,
password,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
progress=operation_progress,
),
progress=progress,
)
File diff suppressed because it is too large Load Diff
+411 -31
View File
@@ -1,13 +1,21 @@
from __future__ import annotations
import asyncio
import fcntl
import http.client
import json
import os
import platform
import shutil
import stat
import subprocess
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, TypedDict
from typing import Annotated, Literal, TypedDict
from uuid import UUID
import typer
import uvicorn
@@ -35,8 +43,17 @@ from k1link.device_plugins.xgrids_k1.analyze import (
run_calibrated_overlay_experiment,
summarize_mqtt_streams,
)
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
ApplicationControlProcessLease,
ApplicationControlProcessLeaseError,
)
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
borrow_ble_runtime_process_lease,
configure_ble_runtime_process_lease,
defer_until_ble_runtime_idle,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
PROFILE_ID,
@@ -54,6 +71,10 @@ from k1link.device_plugins.xgrids_k1.mqtt import (
capture_mqtt,
)
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
from k1link.device_plugins.xgrids_k1.physical_command_ledger import (
PhysicalCommandLedger,
active_operator_retirements,
)
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
ApplicationAuthorityLoadError,
MacOSKeychainApplicationAuthorityProvisioner,
@@ -93,6 +114,243 @@ app.add_typer(compute_app, name="compute")
app.add_typer(lab_app, name="lab")
app.add_typer(artifact_app, name="artifact")
_CANONICAL_MISSION_CORE_PORT = 8000
_MISSION_CORE_SERVE_LOCK_FILENAME = ".serve.lock"
class _MissionCoreServeLeaseError(RuntimeError):
"""The canonical backend singleton lock cannot be trusted."""
class _MissionCoreServeLeaseUnavailable(_MissionCoreServeLeaseError):
"""Another live process owns canonical backend startup or runtime."""
@dataclass(slots=True)
class _MissionCoreServeLease:
"""Stable OS-owned lease for the complete canonical Uvicorn lifetime."""
path: Path
_descriptor: int
_identity: tuple[int, int]
_released: bool = False
@classmethod
def acquire(cls, repository_root: Path) -> _MissionCoreServeLease:
runtime_dir = repository_root.expanduser().resolve() / ".runtime"
_ensure_serve_runtime_directory(runtime_dir)
lock_dir = runtime_dir / "mission-core"
_ensure_private_serve_lock_directory(lock_dir)
path = lock_dir / _MISSION_CORE_SERVE_LOCK_FILENAME
common_flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
common_flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, common_flags | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
try:
descriptor = os.open(path, common_flags)
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be opened safely"
) from exc
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be created safely"
) from exc
locked = False
try:
opened = os.fstat(descriptor)
_validate_private_serve_lock_file(opened)
try:
current = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock identity is unavailable"
) from exc
_validate_private_serve_lock_file(current)
identity = (opened.st_dev, opened.st_ino)
if identity != (current.st_dev, current.st_ino):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock changed while opening"
)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
except BlockingIOError as exc:
raise _MissionCoreServeLeaseUnavailable(
"another process owns Mission Core startup or runtime"
) from exc
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be acquired safely"
) from exc
try:
locked_path = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock disappeared after acquisition"
) from exc
_validate_private_serve_lock_file(locked_path)
if identity != (locked_path.st_dev, locked_path.st_ino):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock changed during acquisition"
)
return cls(path=path, _descriptor=descriptor, _identity=identity)
except BaseException:
if locked:
_unlock_serve_descriptor(descriptor)
os.close(descriptor)
raise
def release(self) -> None:
if self._released:
return
try:
_unlock_serve_descriptor(self._descriptor)
finally:
os.close(self._descriptor)
self._released = True
def __enter__(self) -> _MissionCoreServeLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def _ensure_serve_runtime_directory(path: Path) -> None:
try:
path.mkdir(mode=0o700, parents=False, exist_ok=False)
except FileExistsError:
pass
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory is unavailable"
) from exc
try:
metadata = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory identity is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode):
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory is not a regular directory"
)
def _ensure_private_serve_lock_directory(path: Path) -> None:
try:
path.mkdir(mode=0o700, parents=False, exist_ok=False)
except FileExistsError:
pass
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory is unavailable"
) from exc
try:
metadata = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory identity is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory is not private"
)
def _validate_private_serve_lock_file(metadata: os.stat_result) -> None:
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_nlink != 1
):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock is not a private regular file"
)
def _unlock_serve_descriptor(descriptor: int) -> None:
try:
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
def _acquire_mission_core_serve_lease(repository_root: Path) -> _MissionCoreServeLease:
return _MissionCoreServeLease.acquire(repository_root)
def _configure_ble_process_lease() -> Path:
"""Pin every standalone CLI BLE call to Mission Core's global K1 lock."""
repository_root = Path(__file__).resolve().parents[4]
configure_ble_runtime_process_lease(repository_root)
return repository_root
class _CliWifiProvisioningBlocked(RuntimeError):
"""The standalone mutating BLE command lacks safe physical authority."""
def _canonical_corebluetooth_uuid(value: str) -> str:
try:
return str(UUID(str(value).strip())).upper()
except (AttributeError, ValueError) as exc:
raise _CliWifiProvisioningBlocked(
"BLE device target is not a canonical CoreBluetooth UUID"
) from exc
def _require_cli_wifi_target_not_retired(
repository_root: Path,
device: str,
) -> str:
canonical_device = _canonical_corebluetooth_uuid(device)
snapshot = PhysicalCommandLedger(repository_root).snapshot()
if snapshot.status == "corrupt":
raise _CliWifiProvisioningBlocked(
"physical command audit is unavailable; Wi-Fi write is blocked"
)
record = snapshot.record
if record is not None:
for retirement in active_operator_retirements(record):
retired_device = _canonical_corebluetooth_uuid(
retirement.retired_transport_ref
)
if retired_device == canonical_device:
raise _CliWifiProvisioningBlocked(
"the selected BLE UUID belongs to an operator-retired K1"
)
return canonical_device
@contextmanager
def _cli_wifi_mutation_lease(device: str) -> Iterator[str]:
"""Fence one explicit CLI Wi-Fi write against retirement and Mission Core."""
repository_root = _configure_ble_process_lease()
lease = ApplicationControlProcessLease.acquire(repository_root)
try:
# This durable check is intentionally after acquiring the same global
# flock as the backend retirement action. Retirement either commits
# first and this write is denied, or this explicit write owns the fence
# through credential entry and the complete native BLE lifecycle.
canonical_device = _require_cli_wifi_target_not_retired(
repository_root,
device,
)
with borrow_ble_runtime_process_lease(lease):
yield canonical_device
finally:
# A hard CoreBluetooth timeout may return before its native cleanup
# task is terminal. Never expose the retirement boundary until the
# shared BLE arbiter proves idle; poison intentionally keeps it held
# until process restart.
defer_until_ble_runtime_idle(lease.release)
class ToolStatus(TypedDict):
name: str
@@ -837,25 +1095,133 @@ def publish_e26_lab(
def serve_console(
port: Annotated[
int,
typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."),
typer.Option(
min=1024,
max=65535,
help="Canonical loopback HTTP port; only 8000 is accepted.",
),
] = 8000,
) -> None:
"""Serve the built Mission Core Control Station and loopback control API."""
frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
if not frontend.is_dir():
if port != _CANONICAL_MISSION_CORE_PORT:
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/control-station."
f"[red]Mission Core запускается только на каноническом порту "
f"{_CANONICAL_MISSION_CORE_PORT}.[/red]"
)
console.print("Другой локальный backend не создан.")
raise typer.Exit(code=2)
console.print(f"NODEDC MISSION CORE: http://127.0.0.1:{port}")
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=port,
log_level="info",
access_log=True,
repository_root = Path(__file__).resolve().parents[4]
try:
lease = _acquire_mission_core_serve_lease(repository_root)
except _MissionCoreServeLeaseUnavailable:
local_server = _local_server_status(_CANONICAL_MISSION_CORE_PORT)
if local_server == "mission-core":
_print_existing_mission_core(_CANONICAL_MISSION_CORE_PORT)
return
if local_server == "free":
console.print(
"[yellow]Mission Core уже запускается или завершает работу.[/yellow]"
)
else:
console.print(
"[red]Запуск Mission Core уже выполняется, но канонический health endpoint "
"пока не подтверждён.[/red]"
)
console.print(
"Второй backend не создан. Повторите запуск после завершения текущего перехода."
)
raise typer.Exit(code=2) from None
except _MissionCoreServeLeaseError as exc:
console.print(
"[red]Не удалось безопасно получить блокировку канонического Mission Core.[/red]"
)
console.print(f"Второй backend не создан: {exc}")
raise typer.Exit(code=2) from exc
with lease:
local_server = _local_server_status(_CANONICAL_MISSION_CORE_PORT)
if local_server == "mission-core":
_print_existing_mission_core(_CANONICAL_MISSION_CORE_PORT)
return
if local_server == "occupied":
console.print(
f"[red]Порт 127.0.0.1:{_CANONICAL_MISSION_CORE_PORT} уже занят другим "
"или неготовым процессом.[/red]"
)
console.print(
"Mission Core не стал создавать второй backend. Остановите точный "
"процесс-владелец порта и повторите запуск."
)
raise typer.Exit(code=2)
frontend = repository_root / "apps" / "control-station" / "dist"
if not frontend.is_dir():
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/control-station."
)
raise typer.Exit(code=2)
console.print(
f"NODEDC MISSION CORE: http://127.0.0.1:{_CANONICAL_MISSION_CORE_PORT}"
)
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=_CANONICAL_MISSION_CORE_PORT,
log_level="info",
access_log=True,
)
def _print_existing_mission_core(port: int) -> None:
console.print(
f"[green]NODEDC MISSION CORE уже запущен:[/green] http://127.0.0.1:{port}"
)
console.print("Используется единственный локальный backend; второй процесс не создан.")
def _local_server_status(port: int) -> Literal["free", "mission-core", "occupied"]:
"""Classify the loopback listener before starting the singleton backend.
A healthy Mission Core listener makes ``k1link serve`` idempotent. Any
other listener fails with a short operator-facing error instead of letting
Uvicorn start application resources and then emit an opaque ``Errno 48``.
The check is observational only and never kills or replaces a process.
"""
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=0.75)
try:
connection.request(
"GET",
"/api/health",
headers={"Accept": "application/json", "Connection": "close"},
)
response = connection.getresponse()
if response.status != 200:
return "occupied"
body = response.read(16_385)
if len(body) > 16_384:
return "occupied"
payload = json.loads(body.decode("utf-8"))
except ConnectionRefusedError:
return "free"
except (
OSError,
TimeoutError,
UnicodeDecodeError,
json.JSONDecodeError,
http.client.HTTPException,
):
return "occupied"
finally:
connection.close()
return (
"mission-core"
if isinstance(payload, dict)
and payload.get("service") == "mission-core-control-plane"
else "occupied"
)
@@ -871,6 +1237,7 @@ def ble_scan(
] = 30.0,
) -> None:
"""Discover BLE advertisements without connecting or changing device configuration."""
_configure_ble_process_lease()
try:
result = asyncio.run(scan(duration))
except (BleakError, OSError, ValueError) as exc:
@@ -908,6 +1275,7 @@ def ble_gatt_dump(
] = 45.0,
) -> None:
"""Enumerate GATT metadata only: no characteristic reads, subscriptions or writes."""
_configure_ble_process_lease()
console.print(
"Connecting for service discovery only; no characteristic values will be read or written."
)
@@ -963,28 +1331,40 @@ def ble_wifi_configure(
)
raise typer.Exit(code=2)
console.print(
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
"The password is never printed, logged, or written to the result file; "
"the K1 may echo the SSID in the ignored sensitive status result."
)
ssid = ""
password = ""
try:
ssid, password = prompt_wifi_credentials()
with _cli_wifi_mutation_lease(device) as canonical_device:
console.print(
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
"The password is never printed, logged, or written to the result file; "
"the K1 may echo the SSID in the ignored sensitive status result."
)
ssid, password = prompt_wifi_credentials()
console.print(
"Credentials accepted locally. Starting the single reviewed BLE write."
)
result = asyncio.run(
provision_wifi_once(
canonical_device,
ssid,
password,
timeout_seconds=timeout,
write_mode=write_mode,
)
)
except CredentialDialogError as exc:
console.print(f"[red]Credential entry failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
console.print("Credentials accepted locally. Starting the single reviewed BLE write.")
try:
result = asyncio.run(
provision_wifi_once(
device,
ssid,
password,
timeout_seconds=timeout,
write_mode=write_mode,
)
except (
_CliWifiProvisioningBlocked,
ApplicationControlProcessLeaseError,
) as exc:
console.print(
"[red]Wi-Fi provisioning blocked before credential or device access:[/red] "
f"{exc}"
)
raise typer.Exit(code=2) from exc
except (BleakError, OSError, TimeoutError, ValueError) as exc:
console.print(f"[red]Wi-Fi provisioning failed:[/red] {type(exc).__name__}: {exc}")
console.print("No automatic retry was attempted.")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
from __future__ import annotations
import fcntl
import hmac
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
DEVICE_IDENTITY_PIN_SCHEMA = "missioncore.xgrids-k1-device-identity-pins/v1"
DEVICE_IDENTITY_PIN_FILENAME = "device-identity-pins.json"
DEVICE_IDENTITY_PIN_LOCK_FILENAME = ".device-identity-pins.lock"
DEVICE_IDENTITY_PIN_MAX_BYTES = 64 * 1024
DEVICE_IDENTITY_PIN_MAX_COUNT = 256
DEVICE_IDENTITY_PIN_MAX_REVISION = (1 << 63) - 1
DeviceIdentityPinStoreStatus = Literal["empty", "available", "corrupt"]
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_MAX_VENDOR_DEVICE_ID_BYTES = 4 * 1024
class DeviceIdentityPinStoreError(RuntimeError):
"""Base error for the durable BLE-to-K1 identity binding."""
reason_code = "device-identity-pin-store-error"
class DeviceIdentityPinStoreCorrupt(DeviceIdentityPinStoreError):
"""The private identity store cannot be trusted and fails closed."""
reason_code = "device-identity-pin-store-corrupt"
class DeviceIdentityPinMismatch(DeviceIdentityPinStoreError):
"""Live DeviceInfo identity does not match the first-contact pin."""
reason_code = "device-identity-pin-mismatch"
def __init__(
self,
*,
transport_ref: str,
expected_vendor_device_id: str,
observed_vendor_device_id: str,
expected_compatibility_profile_id: str,
observed_compatibility_profile_id: str,
) -> None:
super().__init__(
"live K1 identity/profile does not match the durable BLE transport pin"
)
self.transport_ref = transport_ref
self.expected_vendor_device_id = expected_vendor_device_id
self.observed_vendor_device_id = observed_vendor_device_id
self.expected_compatibility_profile_id = expected_compatibility_profile_id
self.observed_compatibility_profile_id = observed_compatibility_profile_id
@dataclass(frozen=True, slots=True)
class DeviceIdentityPin:
"""Immutable first-contact binding for one CoreBluetooth transport."""
transport_ref: str
vendor_device_id: str
compatibility_profile_id: str
def as_dict(self) -> dict[str, str]:
return {
"transport_ref": self.transport_ref,
"vendor_device_id": self.vendor_device_id,
"compatibility_profile_id": self.compatibility_profile_id,
}
@dataclass(frozen=True, slots=True)
class DeviceIdentityPinDecision:
pin: DeviceIdentityPin
created: bool
revision: int
@dataclass(frozen=True, slots=True)
class DeviceIdentityPinStoreSnapshot:
status: DeviceIdentityPinStoreStatus
revision: int | None
pins: tuple[DeviceIdentityPin, ...]
reason_code: str | None
def for_transport(self, transport_ref: str) -> DeviceIdentityPin | None:
return next(
(pin for pin in self.pins if pin.transport_ref == transport_ref),
None,
)
def as_public_dict(self) -> dict[str, object]:
"""Expose store health without publishing durable device identifiers."""
return {
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
"status": self.status,
"revision": self.revision,
"pin_count": len(self.pins),
"reason_code": self.reason_code,
}
class DeviceIdentityPinStore:
"""Private, atomic first-contact K1 identity pins.
A pin binds one exact BLE ``transport_ref`` to the logical vendor
``device_id`` proved by DeviceInfo and the reviewed compatibility profile.
An IP address is intentionally absent: TCP reachability can never substitute
for this identity. The stable flock serializes the complete
reload/check/publish transaction across Mission Core processes.
"""
def __init__(self, repository_root: Path) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / DEVICE_IDENTITY_PIN_FILENAME
self._lock_path = data_dir / "xgrids-k1" / DEVICE_IDENTITY_PIN_LOCK_FILENAME
self._data_dir = data_dir
self._thread_lock = threading.RLock()
self._revision = 0
self._pins: dict[str, DeviceIdentityPin] = {}
self._corrupt = False
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> DeviceIdentityPinStoreSnapshot:
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return DeviceIdentityPinStoreSnapshot(
status="corrupt",
revision=None,
pins=(),
reason_code=DeviceIdentityPinStoreCorrupt.reason_code,
)
pins = tuple(self._pins[key] for key in sorted(self._pins))
if not pins:
return DeviceIdentityPinStoreSnapshot(
status="empty",
revision=None,
pins=(),
reason_code=None,
)
return DeviceIdentityPinStoreSnapshot(
status="available",
revision=self._revision,
pins=pins,
reason_code=None,
)
def pin_or_match(
self,
*,
transport_ref: str,
vendor_device_id: str,
compatibility_profile_id: str,
) -> DeviceIdentityPinDecision:
"""Create the first pin or verify an exact existing pin.
Matching an existing pin is read-only and does not bump the revision or
rewrite the file. Any identity or profile mismatch is a typed,
fail-closed error and preserves the original bytes.
"""
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_vendor_device_id(vendor_device_id)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store is corrupt; live K1 identity was not adopted"
)
current = self._pins.get(transport_ref)
if current is not None:
vendor_matches = hmac.compare_digest(
current.vendor_device_id,
vendor_device_id,
)
profile_matches = hmac.compare_digest(
current.compatibility_profile_id,
compatibility_profile_id,
)
if not vendor_matches or not profile_matches:
raise DeviceIdentityPinMismatch(
transport_ref=transport_ref,
expected_vendor_device_id=current.vendor_device_id,
observed_vendor_device_id=vendor_device_id,
expected_compatibility_profile_id=(
current.compatibility_profile_id
),
observed_compatibility_profile_id=compatibility_profile_id,
)
return DeviceIdentityPinDecision(
pin=current,
created=False,
revision=self._revision,
)
if len(self._pins) >= DEVICE_IDENTITY_PIN_MAX_COUNT:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store reached its bounded pin count"
)
if self._revision >= DEVICE_IDENTITY_PIN_MAX_REVISION:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store revision is exhausted"
)
pin = DeviceIdentityPin(
transport_ref=transport_ref,
vendor_device_id=vendor_device_id,
compatibility_profile_id=compatibility_profile_id,
)
next_pins = dict(self._pins)
next_pins[transport_ref] = pin
revision = self._revision + 1
self._persist_locked(revision=revision, pins=next_pins)
return DeviceIdentityPinDecision(
pin=pin,
created=True,
revision=revision,
)
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
if data_dir_created:
_fsync_directory(self._data_dir.parent)
store_dir_created = _ensure_private_directory(self.path.parent, parents=False)
if store_dir_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._lock_path, flags, 0o600)
except OSError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock cannot be opened safely"
) from exc
stream: IO[bytes] | None = None
try:
try:
_validate_private_open_file(
descriptor,
self._lock_path,
label="device identity pin lock",
require_empty=True,
)
except ValueError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock is not a stable private file"
) from exc
stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
try:
_validate_private_open_file(
stream.fileno(),
self._lock_path,
label="device identity pin lock",
require_empty=True,
)
except ValueError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock changed while being acquired"
) from exc
_fsync_directory(self.path.parent)
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
finally:
if stream is not None:
stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _persist_locked(
self,
*,
revision: int,
pins: Mapping[str, DeviceIdentityPin],
) -> None:
ordered = tuple(pins[key] for key in sorted(pins))
payload: dict[str, object] = {
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
"revision": revision,
"pins": [pin.as_dict() for pin in ordered],
}
_write_private_json_atomic(
self.path,
payload,
data_dir=self._data_dir,
)
self._revision = revision
self._pins = dict(pins)
self._corrupt = False
def _reload_locked(self) -> None:
try:
payload = _read_private_json(self.path)
except FileNotFoundError:
self._revision = 0
self._pins = {}
self._corrupt = False
return
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._revision = 0
self._pins = {}
self._corrupt = True
return
try:
revision, pins = _document_from_mapping(payload)
except (TypeError, ValueError):
self._revision = 0
self._pins = {}
self._corrupt = True
return
self._revision = revision
self._pins = {pin.transport_ref: pin for pin in pins}
self._corrupt = False
def _read_private_json(path: Path) -> object:
try:
initial = path.lstat()
except FileNotFoundError:
raise
except OSError as exc:
raise ValueError("device identity pin file cannot be inspected safely") from exc
_validate_private_metadata(
initial,
label="device identity pin file",
require_empty=False,
)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ValueError("device identity pin file cannot be opened safely") from exc
try:
metadata = _validate_private_open_file(
descriptor,
path,
label="device identity pin file",
require_empty=False,
)
if (initial.st_dev, initial.st_ino) != (metadata.st_dev, metadata.st_ino):
raise ValueError("device identity pin file changed while opening")
if metadata.st_size > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin file exceeds the bounded size")
chunks: list[bytes] = []
remaining = DEVICE_IDENTITY_PIN_MAX_BYTES + 1
while remaining > 0:
chunk = os.read(descriptor, min(remaining, 64 * 1024))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
if len(raw) > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin file exceeds the bounded size")
finally:
os.close(descriptor)
return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
+ "\n"
).encode("utf-8")
if len(serialized) > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin payload exceeds the bounded size")
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(path.parent, parents=False)
previous_identity = _existing_private_file_identity(path)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_require_unchanged_existing_path(path, previous_identity)
os.replace(temp_path, path)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _existing_private_file_identity(path: Path) -> tuple[int, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
_validate_private_metadata(
metadata,
label="device identity pin file",
require_empty=False,
)
return metadata.st_dev, metadata.st_ino
def _require_unchanged_existing_path(
path: Path,
expected: tuple[int, int] | None,
) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
if expected is None:
return
raise ValueError("device identity pin file disappeared during publication") from None
_validate_private_metadata(
metadata,
label="device identity pin file",
require_empty=False,
)
observed = metadata.st_dev, metadata.st_ino
if expected is None or observed != expected:
raise ValueError("device identity pin file changed during publication")
def _validate_private_open_file(
descriptor: int,
path: Path,
*,
label: str,
require_empty: bool,
) -> os.stat_result:
metadata = os.fstat(descriptor)
_validate_private_metadata(metadata, label=label, require_empty=require_empty)
try:
path_metadata = path.lstat()
except OSError as exc:
raise ValueError(f"{label} path cannot be verified") from exc
if (path_metadata.st_dev, path_metadata.st_ino) != (
metadata.st_dev,
metadata.st_ino,
):
raise ValueError(f"{label} path does not reference the opened inode")
_validate_private_metadata(path_metadata, label=label, require_empty=False)
return metadata
def _validate_private_metadata(
metadata: os.stat_result,
*,
label: str,
require_empty: bool,
) -> None:
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f"{label} is not a regular file")
if stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError(f"{label} is not private")
if metadata.st_nlink != 1:
raise ValueError(f"{label} has an unsafe hard link")
if require_empty and metadata.st_size != 0:
raise ValueError(f"{label} must remain empty")
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
except OSError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode):
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory is not a regular private directory"
)
if stat.S_IMODE(metadata.st_mode) != 0o700:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory permissions are not private"
)
return False
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("device identity pin store contains duplicate fields")
document[key] = value
return document
def _document_from_mapping(value: object) -> tuple[int, tuple[DeviceIdentityPin, ...]]:
document = _exact_mapping(
value,
{"schema_version", "revision", "pins"},
label="device identity pin document",
)
if document["schema_version"] != DEVICE_IDENTITY_PIN_SCHEMA:
raise ValueError("unsupported device identity pin schema")
revision = _positive_revision(document["revision"])
raw_pins = document["pins"]
if not isinstance(raw_pins, list) or not 1 <= len(raw_pins) <= DEVICE_IDENTITY_PIN_MAX_COUNT:
raise ValueError("device identity pin list has an invalid bounded size")
pins: list[DeviceIdentityPin] = []
seen_transport_refs: set[str] = set()
for raw_pin in raw_pins:
pin_document = _exact_mapping(
raw_pin,
{"transport_ref", "vendor_device_id", "compatibility_profile_id"},
label="device identity pin",
)
transport_ref = _required_string(
pin_document["transport_ref"],
field_name="transport_ref",
)
vendor_device_id = _required_string(
pin_document["vendor_device_id"],
field_name="vendor_device_id",
)
compatibility_profile_id = _required_string(
pin_document["compatibility_profile_id"],
field_name="compatibility_profile_id",
)
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_vendor_device_id(vendor_device_id)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
if transport_ref in seen_transport_refs:
raise ValueError("device identity pin transport_ref is duplicated")
seen_transport_refs.add(transport_ref)
pins.append(
DeviceIdentityPin(
transport_ref=transport_ref,
vendor_device_id=vendor_device_id,
compatibility_profile_id=compatibility_profile_id,
)
)
if pins != sorted(pins, key=lambda pin: pin.transport_ref):
raise ValueError("device identity pins are not in canonical order")
if revision != len(pins):
raise ValueError("device identity pin revision does not match immutable pin count")
return revision, tuple(pins)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _validate_identifier(value: str, *, field_name: str) -> None:
if _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_vendor_device_id(value: str) -> None:
try:
encoded = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError("vendor_device_id must use printable ASCII") from exc
if not encoded or len(encoded) > _MAX_VENDOR_DEVICE_ID_BYTES:
raise ValueError("vendor_device_id is outside the bounded identity schema")
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
raise ValueError("vendor_device_id must use printable ASCII without spaces")
def _positive_revision(value: object) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 1
or value > DEVICE_IDENTITY_PIN_MAX_REVISION
):
raise ValueError("device identity pin revision must be a bounded positive integer")
return value
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,429 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, Literal
from bleak.exc import (
BleakBluetoothNotAvailableError,
BleakBluetoothNotAvailableReason,
)
HostDiagnosticBoundary = Literal[
"corebluetooth",
"corewlan",
"keychain",
"route",
"tcp",
"mqtt",
"filesystem",
]
HostDiagnosticDomain = HostDiagnosticBoundary
HostDiagnosticCode = Literal[
"host.bluetooth.permission-denied",
"host.bluetooth.adapter-powered-off",
"host.bluetooth.adapter-unavailable",
"host.bluetooth.runtime-unavailable",
"host.bluetooth.operation-timeout",
"host.wifi.permission-denied",
"host.wifi.adapter-powered-off",
"host.wifi.interface-unavailable",
"host.wifi.ssid-unavailable",
"host.wifi.operation-timeout",
"host.wifi.association-failed",
"host.keychain.interaction-required",
"host.keychain.permission-denied",
"host.keychain.unavailable",
"host.route.unavailable",
"host.tcp.connection-refused",
"host.tcp.connection-timeout",
"host.tcp.endpoint-unavailable",
"host.mqtt.connection-timeout",
"host.mqtt.connection-refused",
"host.mqtt.transport-unavailable",
"host.filesystem.permission-denied",
"host.filesystem.ledger-unavailable",
]
HostDiagnosticImpact = Literal[
"discovery",
"host-network",
"control",
"durable-safety",
]
HostDiagnosticAction = Literal[
"grant-bluetooth-permission",
"power-on-bluetooth",
"restore-bluetooth-adapter",
"grant-wifi-permission",
"power-on-wifi",
"restore-wifi-interface",
"unlock-or-authorize-keychain",
"review-keychain-access",
"join-expected-network",
"inspect-host-route",
"verify-broker-endpoint",
"inspect-local-storage",
"restart-local-service",
"explicit-retry",
]
@dataclass(frozen=True, slots=True)
class HostFailureDiagnostic:
"""Secret-free operator diagnostic for one host-side failure boundary."""
code: HostDiagnosticCode
domain: HostDiagnosticDomain
impact: HostDiagnosticImpact
operator_action: HostDiagnosticAction
def as_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.host-failure-diagnostic/v1",
"code": self.code,
"domain": self.domain,
"impact": self.impact,
"operator_action": self.operator_action,
"automatic_retry": False,
"redacted": True,
}
@dataclass(frozen=True, slots=True)
class _DiagnosticSpec:
code: HostDiagnosticCode
domain: HostDiagnosticDomain
impact: HostDiagnosticImpact
operator_action: HostDiagnosticAction
def diagnostic(self) -> HostFailureDiagnostic:
return HostFailureDiagnostic(
code=self.code,
domain=self.domain,
impact=self.impact,
operator_action=self.operator_action,
)
_BLUETOOTH_PERMISSION = _DiagnosticSpec(
"host.bluetooth.permission-denied",
"corebluetooth",
"discovery",
"grant-bluetooth-permission",
)
_BLUETOOTH_POWERED_OFF = _DiagnosticSpec(
"host.bluetooth.adapter-powered-off",
"corebluetooth",
"discovery",
"power-on-bluetooth",
)
_BLUETOOTH_UNAVAILABLE = _DiagnosticSpec(
"host.bluetooth.adapter-unavailable",
"corebluetooth",
"discovery",
"restore-bluetooth-adapter",
)
_BLUETOOTH_RUNTIME_UNAVAILABLE = _DiagnosticSpec(
"host.bluetooth.runtime-unavailable",
"corebluetooth",
"discovery",
"restart-local-service",
)
_BLUETOOTH_TIMEOUT = _DiagnosticSpec(
"host.bluetooth.operation-timeout",
"corebluetooth",
"discovery",
"explicit-retry",
)
_WIFI_PERMISSION = _DiagnosticSpec(
"host.wifi.permission-denied",
"corewlan",
"host-network",
"grant-wifi-permission",
)
_WIFI_POWERED_OFF = _DiagnosticSpec(
"host.wifi.adapter-powered-off",
"corewlan",
"host-network",
"power-on-wifi",
)
_WIFI_UNAVAILABLE = _DiagnosticSpec(
"host.wifi.interface-unavailable",
"corewlan",
"host-network",
"restore-wifi-interface",
)
_WIFI_SSID_UNAVAILABLE = _DiagnosticSpec(
"host.wifi.ssid-unavailable",
"corewlan",
"host-network",
"join-expected-network",
)
_WIFI_TIMEOUT = _DiagnosticSpec(
"host.wifi.operation-timeout",
"corewlan",
"host-network",
"explicit-retry",
)
_WIFI_ASSOCIATION_FAILED = _DiagnosticSpec(
"host.wifi.association-failed",
"corewlan",
"host-network",
"join-expected-network",
)
_KEYCHAIN_INTERACTION_REQUIRED = _DiagnosticSpec(
"host.keychain.interaction-required",
"keychain",
"control",
"unlock-or-authorize-keychain",
)
_KEYCHAIN_PERMISSION = _DiagnosticSpec(
"host.keychain.permission-denied",
"keychain",
"control",
"review-keychain-access",
)
_KEYCHAIN_UNAVAILABLE = _DiagnosticSpec(
"host.keychain.unavailable",
"keychain",
"control",
"unlock-or-authorize-keychain",
)
_ROUTE_UNAVAILABLE = _DiagnosticSpec(
"host.route.unavailable",
"route",
"host-network",
"inspect-host-route",
)
_TCP_REFUSED = _DiagnosticSpec(
"host.tcp.connection-refused",
"tcp",
"control",
"verify-broker-endpoint",
)
_TCP_TIMEOUT = _DiagnosticSpec(
"host.tcp.connection-timeout",
"tcp",
"control",
"verify-broker-endpoint",
)
_TCP_UNAVAILABLE = _DiagnosticSpec(
"host.tcp.endpoint-unavailable",
"tcp",
"control",
"verify-broker-endpoint",
)
_MQTT_TIMEOUT = _DiagnosticSpec(
"host.mqtt.connection-timeout",
"mqtt",
"control",
"verify-broker-endpoint",
)
_MQTT_REFUSED = _DiagnosticSpec(
"host.mqtt.connection-refused",
"mqtt",
"control",
"verify-broker-endpoint",
)
_MQTT_UNAVAILABLE = _DiagnosticSpec(
"host.mqtt.transport-unavailable",
"mqtt",
"control",
"verify-broker-endpoint",
)
_FILESYSTEM_PERMISSION = _DiagnosticSpec(
"host.filesystem.permission-denied",
"filesystem",
"durable-safety",
"inspect-local-storage",
)
_LEDGER_UNAVAILABLE = _DiagnosticSpec(
"host.filesystem.ledger-unavailable",
"filesystem",
"durable-safety",
"inspect-local-storage",
)
_REASON_SPECS: Final[dict[str, _DiagnosticSpec]] = {
"ble-permission-denied": _BLUETOOTH_PERMISSION,
"ble-adapter-powered-off": _BLUETOOTH_POWERED_OFF,
"ble-adapter-unavailable": _BLUETOOTH_UNAVAILABLE,
"ble-runtime-owner-loop-conflict": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"ble-runtime-restart-required": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"connection-verify-runtime-loop-unavailable": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"ble-discovery-timeout": _BLUETOOTH_TIMEOUT,
"ble-status-read-timeout": _BLUETOOTH_TIMEOUT,
"ble-provisioning-timeout": _BLUETOOTH_TIMEOUT,
"ble-ap-enable-timeout": _BLUETOOTH_TIMEOUT,
"corewlan-permission-denied": _WIFI_PERMISSION,
"corewlan-authorization-denied": _WIFI_PERMISSION,
"wifi-interface-inactive": _WIFI_POWERED_OFF,
"wifi-interface-unavailable": _WIFI_UNAVAILABLE,
"network-not-found": _WIFI_SSID_UNAVAILABLE,
"host-wifi-operation-timeout": _WIFI_TIMEOUT,
"corewlan-error": _WIFI_ASSOCIATION_FAILED,
"keychain-authorization-required": _KEYCHAIN_INTERACTION_REQUIRED,
"keychain-authorization-denied": _KEYCHAIN_PERMISSION,
"keychain-authorization-cancelled": _KEYCHAIN_PERMISSION,
"keychain-access-failed": _KEYCHAIN_UNAVAILABLE,
"application_authority_unavailable": _KEYCHAIN_UNAVAILABLE,
"host-route-unavailable": _ROUTE_UNAVAILABLE,
"host-path-unavailable": _ROUTE_UNAVAILABLE,
"host-route-interface-unavailable": _ROUTE_UNAVAILABLE,
"association-identity-unavailable": _ROUTE_UNAVAILABLE,
"host-path-observation-stale": _ROUTE_UNAVAILABLE,
"host-path-epoch-changed": _ROUTE_UNAVAILABLE,
"host-path-probe-error": _ROUTE_UNAVAILABLE,
"host-path-recheck-error": _ROUTE_UNAVAILABLE,
"connection-monitor-start-failed": _ROUTE_UNAVAILABLE,
"tcp-connection-refused": _TCP_REFUSED,
"tcp-connection-timeout": _TCP_TIMEOUT,
"tcp-endpoint-unreachable": _TCP_UNAVAILABLE,
"tcp-route-lost": _TCP_UNAVAILABLE,
"tcp-probe-error": _TCP_UNAVAILABLE,
"endpoint-unreachable": _TCP_UNAVAILABLE,
"endpoint-observation-stale": _TCP_UNAVAILABLE,
"quick_connect_endpoint_unreachable": _TCP_UNAVAILABLE,
"connection_lease_endpoint_unreachable_after_provision": _TCP_UNAVAILABLE,
"connection_lease_recovered_endpoint_unreachable": _TCP_UNAVAILABLE,
"mqtt_connection_timeout": _MQTT_TIMEOUT,
"mqtt_connect_call_failed": _MQTT_UNAVAILABLE,
"mqtt_connect_rejected": _MQTT_REFUSED,
"mqtt_broker_rejected_connection": _MQTT_REFUSED,
"mqtt_network_loop_failed": _MQTT_UNAVAILABLE,
"mqtt_connection_ended": _MQTT_UNAVAILABLE,
"mqtt_client_unavailable": _MQTT_UNAVAILABLE,
"mqtt_transport_failure": _MQTT_UNAVAILABLE,
"mqtt-control-loop-lost": _MQTT_UNAVAILABLE,
"control-proof-observation-stale": _MQTT_UNAVAILABLE,
"network-mutation-ledger-error": _LEDGER_UNAVAILABLE,
"network-mutation-ledger-corrupt": _LEDGER_UNAVAILABLE,
"network-provisioning-idempotency-error": _LEDGER_UNAVAILABLE,
"network-provisioning-idempotency-corrupt": _LEDGER_UNAVAILABLE,
"physical-command-ledger-error": _LEDGER_UNAVAILABLE,
"physical-command-ledger-corrupt": _LEDGER_UNAVAILABLE,
"semantic-topology-store-error": _LEDGER_UNAVAILABLE,
"semantic-topology-store-corrupt": _LEDGER_UNAVAILABLE,
"device-identity-pin-store-error": _LEDGER_UNAVAILABLE,
"device-identity-pin-store-corrupt": _LEDGER_UNAVAILABLE,
"application-control-process-lease-error": _LEDGER_UNAVAILABLE,
"application-control-process-lease-unavailable": _LEDGER_UNAVAILABLE,
}
def host_diagnostic_for_reason(reason_code: object) -> HostFailureDiagnostic | None:
"""Map only reviewed reason codes; never reflect arbitrary input."""
if not isinstance(reason_code, str):
return None
spec = _REASON_SPECS.get(reason_code)
return spec.diagnostic() if spec is not None else None
def host_diagnostic_for_exception(
exc: BaseException,
*,
boundary: HostDiagnosticBoundary | None = None,
) -> HostFailureDiagnostic | None:
"""Classify a host exception into a redacted diagnostic whitelist."""
explicit = host_diagnostic_for_reason(getattr(exc, "reason_code", None))
if explicit is not None:
return explicit
if isinstance(exc, ConnectionRefusedError):
if boundary == "tcp":
return _TCP_REFUSED.diagnostic()
if boundary == "mqtt":
return _MQTT_REFUSED.diagnostic()
return None
if isinstance(exc, PermissionError):
if boundary == "corebluetooth":
return _BLUETOOTH_PERMISSION.diagnostic()
if boundary == "corewlan":
return _WIFI_PERMISSION.diagnostic()
if boundary == "keychain":
return _KEYCHAIN_PERMISSION.diagnostic()
if boundary == "filesystem":
return _FILESYSTEM_PERMISSION.diagnostic()
return None
if isinstance(exc, TimeoutError):
if boundary == "corebluetooth":
return _BLUETOOTH_TIMEOUT.diagnostic()
if boundary == "corewlan":
return _WIFI_TIMEOUT.diagnostic()
if boundary == "tcp":
return _TCP_TIMEOUT.diagnostic()
if boundary == "mqtt":
return _MQTT_TIMEOUT.diagnostic()
return None
if boundary == "filesystem" and isinstance(exc, OSError):
return _LEDGER_UNAVAILABLE.diagnostic()
if boundary == "corebluetooth" and isinstance(
exc,
BleakBluetoothNotAvailableError,
):
spec = {
BleakBluetoothNotAvailableReason.POWERED_OFF: _BLUETOOTH_POWERED_OFF,
BleakBluetoothNotAvailableReason.DENIED_BY_USER: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.DENIED_BY_SYSTEM: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.DENIED_BY_UNKNOWN: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.NO_BLUETOOTH: _BLUETOOTH_UNAVAILABLE,
BleakBluetoothNotAvailableReason.NO_BLE_CENTRAL_ROLE: _BLUETOOTH_UNAVAILABLE,
BleakBluetoothNotAvailableReason.UNKNOWN: _BLUETOOTH_UNAVAILABLE,
}[exc.reason]
return spec.diagnostic()
# Older CoreBluetooth surfaces still expose several failures only as a
# human string. Match a narrow, reviewed vocabulary and export none of it.
message = str(exc).casefold()
if boundary == "corebluetooth":
if any(
fragment in message
for fragment in (
"not authorized",
"permission denied",
"access denied",
"bluetooth permission",
)
):
return _BLUETOOTH_PERMISSION.diagnostic()
if any(
fragment in message
for fragment in ("powered off", "power off", "bluetooth is off", "poweredoff")
):
return _BLUETOOTH_POWERED_OFF.diagnostic()
if any(
fragment in message
for fragment in (
"adapter unavailable",
"bluetooth unavailable",
"no bluetooth adapter",
)
):
return _BLUETOOTH_UNAVAILABLE.diagnostic()
if boundary == "keychain" or "authorityloaderror" in type(exc).__name__.casefold():
return _KEYCHAIN_UNAVAILABLE.diagnostic()
return None
def host_diagnostics_for_reasons(*reason_codes: object) -> tuple[HostFailureDiagnostic, ...]:
diagnostics: list[HostFailureDiagnostic] = []
seen: set[str] = set()
for reason_code in reason_codes:
diagnostic = host_diagnostic_for_reason(reason_code)
if diagnostic is None or diagnostic.code in seen:
continue
seen.add(diagnostic.code)
diagnostics.append(diagnostic)
return tuple(diagnostics)
__all__ = [
"HostDiagnosticBoundary",
"HostDiagnosticCode",
"HostFailureDiagnostic",
"host_diagnostic_for_exception",
"host_diagnostic_for_reason",
"host_diagnostics_for_reasons",
]
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Header, HTTPException, WebSocket, WebSocketDisconnect
from k1link.device_plugins.xgrids_k1.facade import (
ACTION_DISCOVERY_SCAN,
@@ -36,23 +36,41 @@ def build_xgrids_k1_legacy_router(runtime: DevicePluginRuntimeTransport) -> APIR
return await invoke_device_plugin_runtime(runtime, ACTION_STATE_READ, {})
@router.post("/api/ble/scan", deprecated=True)
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
async def scan_ble(
request: BleScanRequest,
expected_snapshot_runtime_id: str = Header(
...,
alias="X-Mission-Core-Snapshot-Runtime-Id",
),
) -> dict[str, Any]:
try:
return await invoke_device_plugin_runtime(
runtime,
ACTION_DISCOVERY_SCAN,
request.model_dump(),
{
**request.model_dump(),
"expected_snapshot_runtime_id": expected_snapshot_runtime_id,
},
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@router.post("/api/connect", deprecated=True)
async def connect(request: ConnectRequest) -> dict[str, Any]:
async def connect(
request: ConnectRequest,
expected_snapshot_runtime_id: str = Header(
...,
alias="X-Mission-Core-Snapshot-Runtime-Id",
),
) -> dict[str, Any]:
try:
return await invoke_device_plugin_runtime(
runtime,
ACTION_NETWORK_PROVISION,
request.model_dump(),
{
**request.model_dump(),
"expected_snapshot_runtime_id": expected_snapshot_runtime_id,
},
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(
@@ -13,16 +13,42 @@ from uuid import uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.compute.live_perception import LivePerceptionIngress
from k1link.compute.live_perception import (
LivePerceptionIngress,
LivePerceptionResultFrame,
decode_live_perception_result,
)
TOKEN_BYTES = 32
TOKEN_FILE_NAME = "shadow-worker.token"
def build_live_perception_result_receiver(
ingress: LivePerceptionIngress,
publish_frame: Callable[[LivePerceptionResultFrame], bool],
) -> Callable[[bytes], bool]:
"""Bind worker results to the exact active ingress session atomically."""
def receive(encoded: bytes) -> bool:
frame = decode_live_perception_result(encoded)
return ingress.admit_result(
session_id=frame.session_id,
session_generation=frame.session_generation,
receiver=lambda: publish_frame(frame),
)
return receive
def ensure_live_shadow_token(repository_root: Path) -> tuple[Path, str]:
"""Load or create the private bearer used only through the SSH tunnel."""
token_root = repository_root.resolve() / ".runtime" / "live-perception"
configured_data_root = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
token_root = (
Path(configured_data_root).expanduser().resolve() / "live-perception"
if configured_data_root
else repository_root.resolve() / ".runtime" / "live-perception"
)
token_root.mkdir(mode=0o700, parents=True, exist_ok=True)
with suppress(OSError):
token_root.chmod(0o700)
@@ -59,9 +85,7 @@ def build_live_perception_shadow_router(
router = APIRouter(include_in_schema=False)
@router.websocket(
f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow"
)
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow")
async def live_perception_shadow(websocket: WebSocket) -> None:
authorization = websocket.headers.get("authorization", "")
supplied = authorization.removeprefix("Bearer ")
@@ -103,13 +127,19 @@ def build_live_perception_shadow_router(
)
return
try:
await asyncio.to_thread(result_receiver, result)
accepted = await asyncio.to_thread(result_receiver, result)
except (RuntimeError, ValueError):
await websocket.close(
code=1008,
reason="Shadow result contract is invalid",
)
return
if not accepted:
await websocket.close(
code=1008,
reason="Shadow result session is stale",
)
return
client_event = asyncio.create_task(websocket.receive())
if ingress_event in completed:
event = ingress_event.result()
@@ -8,10 +8,11 @@ import os
import re
import stat
import struct
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Literal, TypedDict
@@ -29,6 +30,19 @@ REPORT_TOPICS: tuple[str, ...] = (
"DeviceStatus",
)
# A fresh subscription proves only broker transport. Recovery of the live
# scene requires one non-retained point-cloud report from that exact MQTT
# client to establish a candidate sequence fence. Pose, DeviceStatus and
# heartbeat remain useful transport/control evidence, but none proves that the
# visible acquisition cloud has resumed. Only the downstream post-publish
# observer can confirm that candidate.
RECOVERY_POINT_CLOUD_TOPICS = frozenset(
{
"RealtimePointcloud",
"lixel/application/report/lio_pcl",
}
)
DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
MAX_CONFIGURABLE_MESSAGE_BYTES = 256 * 1024 * 1024
MAX_TOPIC_BYTES = 65_535
@@ -40,6 +54,8 @@ GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
CAPTURE_CLOCK_FILENAME = "mqtt.timeline.json"
CAPTURE_CLOCK_ORIGIN_FILENAME = "mqtt.timeline.origin.json"
RECOVERY_GAPS_FILENAME = "mqtt.recovery.jsonl"
RECOVERY_GAP_SCHEMA_VERSION = 1
CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION = 1
CAPTURE_CLOCK_SEALED_PATTERN = re.compile(r"^mqtt\.timeline\.session-[a-f0-9]{64}\.json$")
CAPTURE_CLOCK_SCHEMA_VERSION = 1
@@ -61,14 +77,18 @@ StopReason = Literal[
"message_too_large",
"connection_failed",
"connection_lost",
"recovery_standby",
"subscription_failed",
"capture_error",
]
RecoveryDecision = Literal["retry", "resume", "standby", "fault", "blocked"]
RecoveryGapOutcome = Literal["recovered", "standby", "fault", "blocked", "interrupted"]
class ArtifactPaths(TypedDict):
raw: str
metadata_jsonl: str
recovery_gaps_jsonl: str
capture_clock_origin: str
capture_clock: str
summary: str
@@ -77,6 +97,7 @@ class ArtifactPaths(TypedDict):
class ArtifactHashes(TypedDict):
raw_sha256: str
metadata_jsonl_sha256: str
recovery_gaps_jsonl_sha256: str
capture_clock_origin_sha256: str
capture_clock_sha256: str
@@ -87,6 +108,17 @@ class RawFormat(TypedDict):
frame_layout: str
class RecoveryGapSummary(TypedDict):
gap_index: int
started_at_utc: str
started_monotonic_ns: int
ended_at_utc: str
ended_monotonic_ns: int
duration_seconds: float
recovery_attempt: int
outcome: RecoveryGapOutcome
class CaptureSummary(TypedDict):
schema_version: int
created_at_utc: str
@@ -99,6 +131,11 @@ class CaptureSummary(TypedDict):
subscription_qos: int
clean_session: bool
reconnect_enabled: bool
recovery_attempts: int
successful_recoveries: int
recovery_point_cloud_candidates: int
recovery_blocked: bool
recovery_gaps: list[RecoveryGapSummary]
publishing_enabled: bool
subscriptions: list[str]
requested_duration_seconds: float | None
@@ -195,6 +232,13 @@ class _CaptureState:
subscription_mid: int | None = None
stop_reason: StopReason = "capture_error"
error: str | None = None
connection_lost: bool = False
connection_lost_message: str | None = None
recovery_attempts: int = 0
successful_recoveries: int = 0
recovery_point_cloud_candidates: int = 0
recovery_blocked: bool = False
recovery_gaps: list[RecoveryGapSummary] = field(default_factory=list)
class _CaptureWriter:
@@ -202,6 +246,7 @@ class _CaptureWriter:
self.out_dir = out_dir.expanduser().resolve()
self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
self.recovery_gaps_path = self.out_dir / RECOVERY_GAPS_FILENAME
self.capture_clock_origin_path = self.out_dir / CAPTURE_CLOCK_ORIGIN_FILENAME
self.capture_clock_path = self.out_dir / CAPTURE_CLOCK_FILENAME
self.summary_path = self.out_dir / "mqtt.summary.json"
@@ -212,6 +257,8 @@ class _CaptureWriter:
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._recovery_gaps: IO[str] | None = None
self._recovery_gaps_lock = threading.Lock()
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
@@ -223,6 +270,7 @@ class _CaptureWriter:
artifact_paths = (
self.raw_path,
self.metadata_path,
self.recovery_gaps_path,
self.capture_clock_origin_path,
self.capture_clock_path,
self.summary_path,
@@ -236,6 +284,7 @@ class _CaptureWriter:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
self._recovery_gaps = _open_text_exclusive(self.recovery_gaps_path)
_fsync_directory(self.out_dir)
# This is the earliest durable point from which the capture can
# accept evidence. The camera is armed only after this writer is
@@ -364,9 +413,36 @@ class _CaptureWriter:
except OSError as exc:
if first_error is None:
first_error = exc
with self._recovery_gaps_lock:
recovery_stream = self._recovery_gaps
if recovery_stream is not None and not recovery_stream.closed:
try:
recovery_stream.flush()
os.fsync(recovery_stream.fileno())
except OSError as exc:
if first_error is None:
first_error = exc
finally:
try:
recovery_stream.close()
except OSError as exc:
if first_error is None:
first_error = exc
if first_error is not None:
raise first_error
def record_recovery_gap_event(self, record: dict[str, object]) -> None:
"""Append and fsync one recovery boundary without touching frame metadata."""
payload = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
with self._recovery_gaps_lock:
stream = self._recovery_gaps
if stream is None or stream.closed:
raise RuntimeError("recovery gap journal is not open")
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
def finalize_capture_clock(self) -> CaptureClockEnvelope:
"""Publish the exact capture envelope after every producer is sealed."""
@@ -748,6 +824,20 @@ def seal_capture_clock(capture_root: Path) -> CaptureClockEnvelope:
raise CaptureError(f"could not seal session capture clock: {exc}") from exc
def _recovery_backoff_seconds(attempt: int) -> float:
"""Return the canonical capped delay without evaluating an unbounded power."""
if attempt <= 1:
return 0.5
if attempt == 2:
return 1.0
if attempt == 3:
return 2.0
if attempt == 4:
return 4.0
return 5.0
def capture_mqtt(
host: str,
out_dir: Path,
@@ -759,9 +849,23 @@ def capture_mqtt(
on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None,
on_connection_lost: Callable[[str], None] | None = None,
consume_connection_recovery_request: Callable[[], str | None] | None = None,
recover_connection: Callable[[int], RecoveryDecision] | None = None,
on_recovery_point_cloud_candidate: Callable[[int, int], None] | None = None,
on_recovery_confirmer_ready: Callable[[Callable[[int], bool]], None] | None = None,
_client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
"""Capture fixed K1 reports with an optional externally fenced reconnect.
The capture layer never decides that an endpoint is still the same K1. A
caller may supply ``recover_connection`` only when a higher-level owner can
revalidate the exact route, DeviceInfo identity and physical SCANNING
lineage. ``consume_connection_recovery_request`` lets that same owner wake
this existing capture before the MQTT keepalive notices a short host-path
outage. The callback returns the only admitted next step; this function
itself never publishes, scans BLE, changes Wi-Fi, or retries START/STOP.
"""
target_ipv4 = validate_private_ipv4(host)
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")
@@ -774,16 +878,18 @@ def capture_mqtt(
f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
client = (
_client_factory()
if _client_factory is not None
else mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
clean_session=True,
protocol=mqtt.MQTTv311,
reconnect_on_failure=False,
def make_client() -> mqtt.Client:
return (
_client_factory()
if _client_factory is not None
else mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
clean_session=True,
protocol=mqtt.MQTTv311,
reconnect_on_failure=False,
)
)
)
writer = _CaptureWriter(out_dir, max_message_bytes)
try:
writer.open()
@@ -798,12 +904,119 @@ def capture_mqtt(
operation_started = time.monotonic()
capture_started: float | None = None
failure: CaptureError | None = None
disconnect_expected = False
recovering = False
pending_recovery_attempt: int | None = None
pending_recovery_confirmation_attempt: int | None = None
active_recovery_gap: tuple[int, str, int] | None = None
recovery_lock = threading.Lock()
active_client: mqtt.Client | None = None
def fail(reason: StopReason, message: str) -> None:
if state.error is None:
state.stop_reason = reason
state.error = message
def finish_recovery_gap_locked(
outcome: RecoveryGapOutcome,
attempt: int,
) -> bool:
nonlocal active_recovery_gap, pending_recovery_confirmation_attempt
gap = active_recovery_gap
if gap is None:
return False
gap_index, started_at_utc, started_monotonic_ns = gap
ended_at_utc = utc_now_iso()
ended_monotonic_ns = time.monotonic_ns()
summary: RecoveryGapSummary = {
"gap_index": gap_index,
"started_at_utc": started_at_utc,
"started_monotonic_ns": started_monotonic_ns,
"ended_at_utc": ended_at_utc,
"ended_monotonic_ns": ended_monotonic_ns,
"duration_seconds": round(
max(ended_monotonic_ns - started_monotonic_ns, 0) / 1_000_000_000,
9,
),
"recovery_attempt": attempt,
"outcome": outcome,
}
try:
writer.record_recovery_gap_event(
{
"schema_version": RECOVERY_GAP_SCHEMA_VERSION,
"record_type": "recovery_gap_ended",
**summary,
}
)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery gap journal failed: {type(exc).__name__}: {exc}",
)
return False
state.recovery_gaps.append(summary)
if outcome == "recovered":
state.successful_recoveries += 1
active_recovery_gap = None
pending_recovery_confirmation_attempt = None
return True
def begin_recovery_gap() -> bool:
nonlocal active_recovery_gap, pending_recovery_confirmation_attempt
with recovery_lock:
# A raw candidate from a client that has already failed can no
# longer prove the still-live scene. Keep the original outage open
# and fence that late downstream callback.
pending_recovery_confirmation_attempt = None
if active_recovery_gap is not None:
return True
gap_index = len(state.recovery_gaps) + 1
started_at_utc = utc_now_iso()
started_monotonic_ns = time.monotonic_ns()
try:
writer.record_recovery_gap_event(
{
"schema_version": RECOVERY_GAP_SCHEMA_VERSION,
"record_type": "recovery_gap_started",
"gap_index": gap_index,
"started_at_utc": started_at_utc,
"started_monotonic_ns": started_monotonic_ns,
}
)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery gap journal failed: {type(exc).__name__}: {exc}",
)
return False
active_recovery_gap = (gap_index, started_at_utc, started_monotonic_ns)
return True
def confirm_recovery(attempt: int) -> bool:
"""Consume one exact post-publication proof for the current open gap."""
with recovery_lock:
if (
isinstance(attempt, bool)
or attempt < 1
or attempt != pending_recovery_confirmation_attempt
or active_recovery_gap is None
):
return False
return finish_recovery_gap_locked("recovered", attempt)
def fail_transport_handshake(reason: StopReason, message: str) -> None:
if recovering:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = message
return
fail(reason, message)
def on_connect(
callback_client: mqtt.Client,
_userdata: object,
@@ -811,129 +1024,347 @@ def capture_mqtt(
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if callback_client is not active_client:
return
if reason_code.is_failure:
fail("connection_failed", f"broker rejected connection: {reason_code}")
fail_transport_handshake(
"connection_failed",
f"broker rejected connection: {reason_code}",
)
return
state.connected = True
try:
result, mid = callback_client.subscribe([(topic, 0) for topic in REPORT_TOPICS])
except (OSError, RuntimeError, ValueError) as exc:
fail(
fail_transport_handshake(
"subscription_failed",
f"subscribe failed: {type(exc).__name__}: {exc}",
)
return
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
fail("subscription_failed", f"subscribe failed: {mqtt.error_string(result)}")
fail_transport_handshake(
"subscription_failed",
f"subscribe failed: {mqtt.error_string(result)}",
)
return
state.subscription_mid = mid
def on_subscribe(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
mid: int,
reason_codes: list[ReasonCode],
_properties: Properties | None,
) -> None:
if callback_client is not active_client:
return
if mid != state.subscription_mid:
fail("subscription_failed", f"unexpected SUBACK message id: {mid}")
fail_transport_handshake(
"subscription_failed",
f"unexpected SUBACK message id: {mid}",
)
return
if len(reason_codes) != len(REPORT_TOPICS) or any(
reason_code.is_failure for reason_code in reason_codes
):
fail("subscription_failed", "broker rejected one or more fixed subscriptions")
fail_transport_handshake(
"subscription_failed",
"broker rejected one or more fixed subscriptions",
)
return
state.subscribed = True
def on_message(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
message: mqtt.MQTTMessage,
) -> None:
if state.error is not None:
nonlocal pending_recovery_attempt, pending_recovery_confirmation_attempt, recovering
if callback_client is not active_client or state.error is not None:
return
try:
recorded = writer.record(message)
except MessageTooLargeError as exc:
fail("message_too_large", str(exc))
return
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
return
recovery_candidate: tuple[int, int] | None = None
if (
pending_recovery_attempt is not None
and not recorded.retain
and recorded.topic in RECOVERY_POINT_CLOUD_TOPICS
):
recovered_attempt = pending_recovery_attempt
pending_recovery_attempt = None
recovering = False
with recovery_lock:
if active_recovery_gap is not None:
pending_recovery_confirmation_attempt = recovered_attempt
state.recovery_point_cloud_candidates += 1
recovery_candidate = (recovered_attempt, recorded.sequence)
# Arm the provisional sequence fence after the raw frame is durable but
# before the preview queue can publish it on another thread. This is
# deliberately not a recovery-success edge: only a later normalized,
# non-empty Rerun publication may consume the candidate.
if recovery_candidate is not None and on_recovery_point_cloud_candidate is not None:
try:
on_recovery_point_cloud_candidate(*recovery_candidate)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery candidate callback failed: {type(exc).__name__}: {exc}",
)
return
if on_message_recorded is not None:
try:
on_message_recorded(recorded)
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")
return
def on_disconnect(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
_flags: mqtt.DisconnectFlags,
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if not state.stopping:
fail("connection_lost", f"broker connection ended: {reason_code}")
if callback_client is not active_client or state.stopping or disconnect_expected:
return
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = f"broker connection ended: {reason_code}"
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_message = on_message
client.on_disconnect = on_disconnect
def externally_stopped_or_elapsed() -> bool:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
return True
if (
capture_started is not None
and duration_seconds is not None
and now - capture_started >= duration_seconds
):
state.stop_reason = "duration_elapsed"
return True
return False
def consume_owner_recovery_request() -> str | None:
if consume_connection_recovery_request is None:
return None
try:
reason = consume_connection_recovery_request()
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
"connection recovery request failed: "
f"{type(exc).__name__}: {exc}",
)
return None
if reason is None:
return None
if not isinstance(reason, str) or not reason.strip():
fail("capture_error", "connection recovery request returned an invalid reason")
return None
return reason.strip()
def wait_recovery_backoff(attempt: int) -> bool:
delay = _recovery_backoff_seconds(attempt)
deadline = time.monotonic() + delay
while time.monotonic() < deadline:
if externally_stopped_or_elapsed():
return False
time.sleep(min(0.1, max(deadline - time.monotonic(), 0.0)))
return True
if on_recovery_confirmer_ready is not None:
try:
on_recovery_confirmer_ready(confirm_recovery)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery confirmer registration failed: {type(exc).__name__}: {exc}",
)
connect_attempted = False
try:
connect_attempted = True
connect_result = client.connect(
target_ipv4,
port=port,
keepalive=KEEPALIVE_SECONDS,
)
if connect_result != mqtt.MQTT_ERR_SUCCESS:
fail("connection_failed", f"connect failed: {mqtt.error_string(connect_result)}")
while state.error is None:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
if recovering and externally_stopped_or_elapsed():
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
on_ready()
if (
capture_started is not None
and duration_seconds is not None
and now - capture_started >= duration_seconds
):
state.stop_reason = "duration_elapsed"
break
if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
fail("connection_failed", "timed out waiting for CONNACK/SUBACK")
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
state.connected = False
state.subscribed = False
state.subscription_mid = None
state.connection_lost = False
state.connection_lost_message = None
client = make_client()
active_client = client
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_message = on_message
client.on_disconnect = on_disconnect
connect_attempted = False
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
connect_attempted = True
connect_result = client.connect(
target_ipv4,
port=port,
keepalive=KEEPALIVE_SECONDS,
)
if connect_result != mqtt.MQTT_ERR_SUCCESS:
message = f"connect failed: {mqtt.error_string(connect_result)}"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
connect_started = time.monotonic()
while state.error is None and not state.connection_lost:
now = time.monotonic()
if externally_stopped_or_elapsed():
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
on_ready()
if not state.subscribed and now - connect_started >= CONNECT_TIMEOUT_SECONDS:
message = "timed out waiting for CONNACK/SUBACK"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
owner_recovery_reason = consume_owner_recovery_request()
try:
writer.maybe_commit()
except OSError as exc:
fail(
"capture_error",
f"group commit failed: {type(exc).__name__}: {exc}",
)
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = (
f"MQTT network loop failed: {mqtt.error_string(loop_result)}"
)
if owner_recovery_reason is not None and not state.connection_lost:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = (
"guarded recovery requested by the connection owner: "
f"{owner_recovery_reason}"
)
except KeyboardInterrupt:
state.stop_reason = "keyboard_interrupt"
except (OSError, RuntimeError, ValueError) as exc:
message = f"MQTT capture failed: {type(exc).__name__}: {exc}"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
finally:
disconnect_expected = True
active_client = None
if connect_attempted:
try:
client.disconnect()
except (OSError, RuntimeError, ValueError) as exc:
if state.error is None and not state.connection_lost:
fail(
"capture_error",
f"disconnect failed: {type(exc).__name__}: {exc}",
)
disconnect_expected = False
if (
state.stop_reason
in {
"external_stop",
"duration_elapsed",
"keyboard_interrupt",
}
or state.error is not None
):
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
if not state.connection_lost:
break
# A guarded ``resume`` authorizes exactly one fresh MQTT client.
# If that client never delivers a fresh point-cloud report, consume the
# authorization before inspecting the device again; a later
# READY/SCAN_OVER must be able to end recovery without another
# data-plane connect.
pending_recovery_attempt = None
if not begin_recovery_gap():
break
if recover_connection is None:
fail(
"connection_lost",
f"MQTT network loop failed: {mqtt.error_string(loop_result)}",
state.connection_lost_message or "MQTT connection was lost",
)
except KeyboardInterrupt:
state.stop_reason = "keyboard_interrupt"
except (OSError, RuntimeError, ValueError) as exc:
fail("connection_failed", f"MQTT capture failed: {type(exc).__name__}: {exc}")
break
if not recovering and on_connection_lost is not None:
on_connection_lost(state.connection_lost_message or "MQTT connection was lost")
recovering = True
while state.error is None:
if externally_stopped_or_elapsed():
break
state.recovery_attempts += 1
attempt = state.recovery_attempts
try:
decision = recover_connection(attempt)
except (OSError, RuntimeError, ValueError):
decision = "retry"
if decision == "resume":
pending_recovery_attempt = attempt
break
if decision == "standby":
state.stop_reason = "recovery_standby"
break
if decision == "fault":
fail(
"connection_lost",
"exact active-stream recovery rejected the remote state",
)
break
if decision == "blocked":
state.recovery_blocked = True
while not externally_stopped_or_elapsed():
time.sleep(LOOP_INTERVAL_SECONDS)
break
if decision != "retry":
fail("capture_error", "invalid active-stream recovery decision")
break
if not wait_recovery_backoff(attempt):
break
if pending_recovery_attempt is not None:
continue
break
finally:
state.stopping = True
if connect_attempted:
try:
client.disconnect()
except (OSError, RuntimeError, ValueError) as exc:
if state.error is None:
fail("capture_error", f"disconnect failed: {type(exc).__name__}: {exc}")
with recovery_lock:
if active_recovery_gap is not None:
if state.stop_reason == "recovery_standby":
gap_outcome: RecoveryGapOutcome = "standby"
elif state.recovery_blocked:
gap_outcome = "blocked"
elif state.error is not None:
gap_outcome = "fault"
else:
gap_outcome = "interrupted"
finish_recovery_gap_locked(gap_outcome, state.recovery_attempts)
try:
writer.close()
except OSError as exc:
@@ -958,6 +1389,7 @@ def capture_mqtt(
created_at_utc=created_at_utc,
capture_clock=capture_clock,
state=state,
reconnect_enabled=recover_connection is not None,
)
try:
_write_summary_exclusive(writer.summary_path, summary)
@@ -983,6 +1415,7 @@ def _build_summary(
created_at_utc: str,
capture_clock: CaptureClockEnvelope,
state: _CaptureState,
reconnect_enabled: bool,
) -> CaptureSummary:
capture_clock_origin = read_capture_clock_origin(writer.capture_clock_origin_path)
return {
@@ -996,7 +1429,12 @@ def _build_summary(
"mqtt_protocol": "3.1.1",
"subscription_qos": 0,
"clean_session": True,
"reconnect_enabled": False,
"reconnect_enabled": reconnect_enabled,
"recovery_attempts": state.recovery_attempts,
"successful_recoveries": state.successful_recoveries,
"recovery_point_cloud_candidates": state.recovery_point_cloud_candidates,
"recovery_blocked": state.recovery_blocked,
"recovery_gaps": list(state.recovery_gaps),
"publishing_enabled": False,
"subscriptions": list(REPORT_TOPICS),
"requested_duration_seconds": duration_seconds,
@@ -1021,6 +1459,7 @@ def _build_summary(
"artifacts": {
"raw": writer.raw_path.name,
"metadata_jsonl": writer.metadata_path.name,
"recovery_gaps_jsonl": writer.recovery_gaps_path.name,
"capture_clock_origin": writer.capture_clock_origin_path.name,
"capture_clock": writer.capture_clock_path.name,
"summary": writer.summary_path.name,
@@ -1028,6 +1467,7 @@ def _build_summary(
"artifact_hashes": {
"raw_sha256": _sha256_file(writer.raw_path),
"metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
"recovery_gaps_jsonl_sha256": _sha256_file(writer.recovery_gaps_path),
"capture_clock_origin_sha256": capture_clock_origin.artifact_sha256,
"capture_clock_sha256": capture_clock.artifact_sha256,
},
@@ -0,0 +1,958 @@
from __future__ import annotations
import fcntl
import ipaddress
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
from typing import IO, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
NETWORK_MUTATION_LEDGER_SCHEMA = "missioncore.xgrids-k1-network-mutation/v2"
NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA = "missioncore.xgrids-k1-network-mutation/v1"
NETWORK_MUTATION_LEDGER_FILENAME = "network-mutation.json"
NETWORK_MUTATION_LEDGER_LOCK_FILENAME = ".network-mutation.lock"
NETWORK_MUTATION_LEDGER_MAX_BYTES = 64 * 1024
NetworkConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
NetworkMutationStage = Literal["prepared", "dispatching", "observing", "resolved"]
NetworkMutationWriteMode = Literal["with_response", "without_response"]
NetworkMutationResolution = Literal[
"not-dispatched",
"target-observed",
"interrupted",
"superseded",
]
NetworkMutationLedgerStatus = Literal["empty", "unresolved", "resolved", "corrupt"]
_CONNECTION_MODES = frozenset({"bridge", "quick-connect", "direct-connect"})
_STAGES = frozenset({"prepared", "dispatching", "observing", "resolved"})
_WRITE_MODES = frozenset({"with_response", "without_response"})
_RESOLUTIONS = frozenset(
{"not-dispatched", "target-observed", "interrupted", "superseded"}
)
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_SAFE_STATUS_MODE = re.compile(r"^[A-Z][A-Z0-9_-]{0,31}$")
class NetworkMutationLedgerError(RuntimeError):
"""Base error for the durable K1 network-mutation fence."""
reason_code = "network-mutation-ledger-error"
class NetworkMutationBlocked(NetworkMutationLedgerError):
"""A prior durable record prevents admission of another device write."""
reason_code = "network-mutation-reconciliation-required"
class NetworkMutationLedgerCorrupt(NetworkMutationBlocked):
"""The durable fence cannot be trusted and therefore fails closed."""
reason_code = "network-mutation-ledger-corrupt"
class NetworkMutationTransitionError(NetworkMutationLedgerError):
"""A caller attempted an invalid ledger state transition."""
reason_code = "network-mutation-ledger-transition-invalid"
@dataclass(frozen=True, slots=True)
class NetworkStatusEvidence:
"""The bounded, non-secret subset of one decoded K1 7f02 status."""
mode: str | None
ipv4: str | None
status_code: int
reserved: int | None
def __post_init__(self) -> None:
if self.mode is not None and _SAFE_STATUS_MODE.fullmatch(self.mode) is None:
raise ValueError("network status mode is outside the secret-free schema")
if self.ipv4 is not None:
try:
parsed = ipaddress.ip_address(self.ipv4)
except ValueError as exc:
raise ValueError("network status address must be an IPv4 address") from exc
if not isinstance(parsed, ipaddress.IPv4Address) or str(parsed) != self.ipv4:
raise ValueError("network status address must be canonical IPv4")
_validate_byte(self.status_code, field_name="status_code")
if self.reserved is not None:
_validate_byte(self.reserved, field_name="reserved")
def as_dict(self) -> dict[str, object]:
return {
"mode": self.mode,
"ipv4": self.ipv4,
"status_code": self.status_code,
"reserved": self.reserved,
}
@dataclass(frozen=True, slots=True)
class PreviousConnectionEvidence:
"""Last admitted topology retained as evidence, never as live reachability."""
transport_ref: str
mode: NetworkConnectionMode
ipv4: str | None
device_session_id: str | None
def __post_init__(self) -> None:
_validate_identifier(self.transport_ref, field_name="transport_ref")
_validate_connection_mode(self.mode)
if self.ipv4 is not None:
try:
parsed = ipaddress.ip_address(self.ipv4)
except ValueError as exc:
raise ValueError("previous connection address must be an IPv4 address") from exc
if not isinstance(parsed, ipaddress.IPv4Address) or str(parsed) != self.ipv4:
raise ValueError("previous connection address must be canonical IPv4")
if self.device_session_id is not None:
_validate_identifier(self.device_session_id, field_name="device_session_id")
def as_dict(self) -> dict[str, object]:
return {
"transport_ref": self.transport_ref,
"mode": self.mode,
"ipv4": self.ipv4,
"device_session_id": self.device_session_id,
}
@dataclass(frozen=True, slots=True)
class NetworkMutationRecord:
schema_version: str
revision: int
operation_id: str
transport_ref: str
intended_mode: NetworkConnectionMode
stage: NetworkMutationStage
write_mode: NetworkMutationWriteMode
baseline_status: NetworkStatusEvidence
previous_connection: PreviousConnectionEvidence | None
write_confirmed: bool | None
last_observation: NetworkStatusEvidence | None
resolution: NetworkMutationResolution | None
created_at_utc: str
updated_at_utc: str
@property
def unresolved(self) -> bool:
return self.stage != "resolved"
def as_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"revision": self.revision,
"operation_id": self.operation_id,
"transport_ref": self.transport_ref,
"intended_mode": self.intended_mode,
"stage": self.stage,
"write_mode": self.write_mode,
"baseline_status": self.baseline_status.as_dict(),
"previous_connection": (
self.previous_connection.as_dict() if self.previous_connection is not None else None
),
"write_confirmed": self.write_confirmed,
"last_observation": (
self.last_observation.as_dict() if self.last_observation is not None else None
),
"resolution": self.resolution,
"created_at_utc": self.created_at_utc,
"updated_at_utc": self.updated_at_utc,
}
@dataclass(frozen=True, slots=True)
class NetworkMutationLedgerSnapshot:
status: NetworkMutationLedgerStatus
record: NetworkMutationRecord | None
reason_code: str | None
@property
def mutation_allowed(self) -> bool:
return self.status in {"empty", "resolved"}
class NetworkMutationLedger:
"""One durable, secret-free fence around K1 network side effects.
The ledger is intentionally independent from the BLE helpers. Integration
writes ``dispatching`` durably immediately before entering
``write_gatt_char``. A process crash can therefore create a conservative
false-positive fence, but can never silently authorize a second write.
"""
def __init__(
self,
repository_root: Path,
*,
clock: Callable[[], datetime] | None = None,
) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / NETWORK_MUTATION_LEDGER_FILENAME
self._process_lock_path = data_dir / "xgrids-k1" / NETWORK_MUTATION_LEDGER_LOCK_FILENAME
self._data_dir = data_dir
self._clock = clock or (lambda: datetime.now(UTC))
self._lock = threading.RLock()
self._record: NetworkMutationRecord | None = None
self._corrupt = False
with self._lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> NetworkMutationLedgerSnapshot:
with self._lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return NetworkMutationLedgerSnapshot(
status="corrupt",
record=None,
reason_code=NetworkMutationLedgerCorrupt.reason_code,
)
if self._record is None:
return NetworkMutationLedgerSnapshot(status="empty", record=None, reason_code=None)
return NetworkMutationLedgerSnapshot(
status="unresolved" if self._record.unresolved else "resolved",
record=self._record,
reason_code=(
NetworkMutationBlocked.reason_code if self._record.unresolved else None
),
)
def require_mutation_allowed(self) -> None:
with self._lock, self._process_lock_locked():
self._reload_locked()
self._require_mutation_allowed_locked()
def prepare(
self,
*,
operation_id: str,
transport_ref: str,
intended_mode: NetworkConnectionMode,
write_mode: NetworkMutationWriteMode,
baseline_status: NetworkStatusEvidence,
previous_connection: PreviousConnectionEvidence | None = None,
) -> NetworkMutationRecord:
"""Persist a pre-write record after the live baseline has been read."""
_validate_identifier(operation_id, field_name="operation_id")
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_connection_mode(intended_mode)
_validate_write_mode(write_mode)
if not isinstance(baseline_status, NetworkStatusEvidence):
raise TypeError("baseline_status must be NetworkStatusEvidence")
if previous_connection is not None and not isinstance(
previous_connection, PreviousConnectionEvidence
):
raise TypeError("previous_connection must be PreviousConnectionEvidence")
with self._lock, self._process_lock_locked():
self._reload_locked()
self._require_mutation_allowed_locked()
previous_revision = self._record.revision if self._record is not None else 0
now = _nondecreasing_audit_timestamp(
self._clock(),
floor=(self._record.updated_at_utc if self._record is not None else None),
)
record = NetworkMutationRecord(
schema_version=NETWORK_MUTATION_LEDGER_SCHEMA,
revision=previous_revision + 1,
operation_id=operation_id,
transport_ref=transport_ref,
intended_mode=intended_mode,
stage="prepared",
write_mode=write_mode,
baseline_status=baseline_status,
previous_connection=previous_connection,
write_confirmed=None,
last_observation=None,
resolution=None,
created_at_utc=now,
updated_at_utc=now,
)
self._persist_locked(record)
return record
def mark_dispatching(
self,
operation_id: str,
*,
expected_revision: int,
) -> NetworkMutationRecord:
"""Durably cross the side-effect boundary before the BLE write call."""
_positive_int(expected_revision, field_name="expected_revision")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage != "prepared":
raise NetworkMutationTransitionError(
"network mutation may dispatch only from prepared"
)
return self._transition_locked(current, stage="dispatching")
def confirm_dispatching_after_uncertain_return(
self,
operation_id: str,
*,
expected_prepared: NetworkMutationRecord,
) -> NetworkMutationRecord:
"""Re-fsync one exact DISPATCHING commit after its return path failed.
Reading a replaced file is not sufficient proof that its directory
entry reached stable storage. This recovery primitive verifies the
complete immutable predecessor/operation tuple under the store lock,
then republishes the identical record through the normal file+parent
fsync path. It never advances revision or creates dispatch authority
for a merely PREPARED row.
"""
_validate_identifier(operation_id, field_name="operation_id")
if not isinstance(expected_prepared, NetworkMutationRecord):
raise TypeError("expected_prepared must be NetworkMutationRecord")
if (
expected_prepared.operation_id != operation_id
or expected_prepared.stage != "prepared"
or expected_prepared.write_confirmed is not None
or expected_prepared.last_observation is not None
or expected_prepared.resolution is not None
):
raise NetworkMutationTransitionError(
"dispatch confirmation requires the exact PREPARED predecessor"
)
with self._lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt or self._record is None:
raise NetworkMutationLedgerCorrupt(
"uncertain network dispatch record cannot be trusted"
)
current = self._record
if not (
current.operation_id == expected_prepared.operation_id
and current.transport_ref == expected_prepared.transport_ref
and current.intended_mode == expected_prepared.intended_mode
and current.write_mode == expected_prepared.write_mode
and current.baseline_status == expected_prepared.baseline_status
and current.previous_connection == expected_prepared.previous_connection
and current.created_at_utc == expected_prepared.created_at_utc
and current.stage == "dispatching"
and current.revision == expected_prepared.revision + 1
and current.write_confirmed is None
and current.last_observation is None
and current.resolution is None
):
raise NetworkMutationTransitionError(
"uncertain network dispatch does not match its predecessor"
)
self._persist_locked(current)
return current
def mark_observing(
self,
operation_id: str,
*,
expected_revision: int,
write_confirmed: bool,
observation: NetworkStatusEvidence | None = None,
) -> NetworkMutationRecord:
"""Record transport acknowledgement and bounded post-write evidence."""
_positive_int(expected_revision, field_name="expected_revision")
if not isinstance(write_confirmed, bool):
raise TypeError("write_confirmed must be bool")
if observation is not None and not isinstance(observation, NetworkStatusEvidence):
raise TypeError("observation must be NetworkStatusEvidence")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
"network mutation may observe only after dispatch"
)
if current.write_confirmed is True and not write_confirmed:
raise NetworkMutationTransitionError(
"network mutation write confirmation cannot regress"
)
return self._transition_locked(
current,
stage="observing",
write_confirmed=write_confirmed,
last_observation=observation,
)
def resolve(
self,
operation_id: str,
*,
expected_revision: int,
resolution: NetworkMutationResolution,
observation: NetworkStatusEvidence | None = None,
) -> NetworkMutationRecord:
"""Terminalize one mutation without authorizing an automatic retry.
``interrupted`` and ``superseded`` are audit outcomes. They make no
claim about the device-side result of a previously dispatched write;
they only state that the old host session no longer owns the next
explicit operator action.
"""
_positive_int(expected_revision, field_name="expected_revision")
_validate_resolution(resolution)
if observation is not None and not isinstance(observation, NetworkStatusEvidence):
raise TypeError("observation must be NetworkStatusEvidence")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage == "resolved":
if current.resolution == resolution:
return current
raise NetworkMutationTransitionError(
"resolved network mutation cannot change its resolution"
)
if resolution == "not-dispatched":
if current.stage != "prepared":
raise NetworkMutationTransitionError(
"not-dispatched resolution requires prepared stage"
)
if observation is not None:
raise NetworkMutationTransitionError(
"not-dispatched resolution cannot attach post-write evidence"
)
elif resolution == "target-observed":
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
"target-observed resolution requires a dispatched mutation"
)
observation = observation or current.last_observation
if observation is None:
raise NetworkMutationTransitionError(
"target-observed resolution requires bounded status evidence"
)
else:
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
f"{resolution} resolution requires a dispatched mutation"
)
# Session termination is not device-state evidence. Retain a
# previously captured bounded observation when one exists, but
# never require a read-only reconciliation before allowing a
# later explicit action.
observation = observation or current.last_observation
return self._transition_locked(
current,
stage="resolved",
last_observation=observation,
resolution=resolution,
)
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
"""Serialize one complete ledger transaction across local processes.
Atomic replacement protects the JSON from partial publication, but it
does not make the preceding read/check/write sequence atomic. A stable,
separately opened lock inode fences that whole sequence so two backend
processes cannot both admit a device mutation from the same revision.
"""
# The shared Mission Core data root may predate this plugin and is
# normalized by the same helper used by atomic publication. The
# ledger-specific directory must already be private or fail closed.
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
parent_created = _ensure_private_lock_directory(self.path.parent, parents=True)
if data_dir_created or parent_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._process_lock_path, flags, 0o600)
except OSError as exc:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger lock cannot be opened safely"
) from exc
lock_stream: IO[bytes] | None = None
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger lock is not a private regular file"
)
lock_stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
finally:
if lock_stream is not None:
lock_stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _current_operation_locked(self, operation_id: str) -> NetworkMutationRecord:
_validate_identifier(operation_id, field_name="operation_id")
self._reload_locked()
if self._corrupt:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger is corrupt; device writes remain blocked"
)
current = self._record
if current is None or current.operation_id != operation_id:
raise NetworkMutationTransitionError("network mutation operation does not match ledger")
return current
def _require_mutation_allowed_locked(self) -> None:
if self._corrupt:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger is corrupt; device writes remain blocked"
)
if self._record is not None and self._record.unresolved:
raise NetworkMutationBlocked(
"previous network mutation is unresolved; another device write is blocked"
)
@staticmethod
def _require_expected_revision(
record: NetworkMutationRecord,
expected_revision: int,
) -> None:
if record.revision != expected_revision:
raise NetworkMutationTransitionError(
"network mutation transition used a stale record revision"
)
def _transition_locked(
self,
current: NetworkMutationRecord,
*,
stage: NetworkMutationStage,
write_confirmed: bool | None = None,
last_observation: NetworkStatusEvidence | None = None,
resolution: NetworkMutationResolution | None = None,
) -> NetworkMutationRecord:
record = replace(
current,
revision=current.revision + 1,
stage=stage,
write_confirmed=(
write_confirmed if write_confirmed is not None else current.write_confirmed
),
last_observation=(
last_observation if last_observation is not None else current.last_observation
),
resolution=resolution,
updated_at_utc=_nondecreasing_audit_timestamp(
self._clock(),
floor=current.updated_at_utc,
),
)
self._persist_locked(record)
return record
def _persist_locked(self, record: NetworkMutationRecord) -> None:
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
self._record = record
self._corrupt = False
def _reload_locked(self) -> None:
try:
metadata = self.path.lstat()
except FileNotFoundError:
self._record = None
self._corrupt = False
return
except OSError:
self._record = None
self._corrupt = True
return
try:
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError("ledger file is not a private regular file")
parent_metadata = self.path.parent.lstat()
if (
not stat.S_ISDIR(parent_metadata.st_mode)
or stat.S_IMODE(parent_metadata.st_mode) != 0o700
):
raise ValueError("ledger directory is not private")
if metadata.st_size > NETWORK_MUTATION_LEDGER_MAX_BYTES:
raise ValueError("ledger file exceeds the bounded size")
payload = json.loads(
self.path.read_text(encoding="utf-8"),
object_pairs_hook=_unique_json_object,
)
legacy_schema = (
isinstance(payload, dict)
and payload.get("schema_version") == NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA
)
record = _record_from_mapping(payload)
if legacy_schema:
# v1 was briefly published with two shapes: the original
# previous_connection lacked transport_ref, while the final
# in-tree shape already carried it. Only the latter can be
# migrated without inventing which physical K1 owned the
# previous topology. _record_from_mapping deliberately
# rejects the ambiguous shape and preserves it fail-closed.
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._record = None
self._corrupt = True
return
self._record = record
self._corrupt = False
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
).encode("utf-8")
if len(serialized) > NETWORK_MUTATION_LEDGER_MAX_BYTES:
raise ValueError("network mutation ledger exceeds the bounded size")
data_dir_created = _ensure_private_directory(data_dir, parents=True)
parent_created = _ensure_private_directory(path.parent, parents=True)
if data_dir_created or parent_created:
_fsync_directory(data_dir)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_path, path)
path.chmod(0o600)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
# A peer process may have created the shared private directory
# between lstat and mkdir. Validate that winner below instead of
# failing a safe concurrent ledger acquisition.
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode):
raise NetworkMutationLedgerCorrupt(
"network mutation ledger directory is not a private directory"
)
path.chmod(0o700)
return False
def _ensure_private_lock_directory(path: Path, *, parents: bool) -> bool:
"""Create a lock parent privately or reject an unsafe existing parent."""
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise NetworkMutationLedgerCorrupt("network mutation ledger lock directory is not private")
return False
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("network mutation ledger contains duplicate fields")
document[key] = value
return document
def _record_from_mapping(value: object) -> NetworkMutationRecord:
document = _exact_mapping(
value,
{
"schema_version",
"revision",
"operation_id",
"transport_ref",
"intended_mode",
"stage",
"write_mode",
"baseline_status",
"previous_connection",
"write_confirmed",
"last_observation",
"resolution",
"created_at_utc",
"updated_at_utc",
},
label="ledger",
)
if document["schema_version"] not in {
NETWORK_MUTATION_LEDGER_SCHEMA,
NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA,
}:
raise ValueError("unsupported network mutation ledger schema")
revision = _positive_int(document["revision"], field_name="revision")
operation_id = _required_string(document["operation_id"], field_name="operation_id")
transport_ref = _required_string(document["transport_ref"], field_name="transport_ref")
_validate_identifier(operation_id, field_name="operation_id")
_validate_identifier(transport_ref, field_name="transport_ref")
intended_mode_raw = _required_string(document["intended_mode"], field_name="intended_mode")
_validate_connection_mode(intended_mode_raw)
intended_mode = cast(NetworkConnectionMode, intended_mode_raw)
stage_raw = _required_string(document["stage"], field_name="stage")
if stage_raw not in _STAGES:
raise ValueError("unsupported network mutation stage")
stage = cast(NetworkMutationStage, stage_raw)
write_mode_raw = _required_string(document["write_mode"], field_name="write_mode")
_validate_write_mode(write_mode_raw)
write_mode = cast(NetworkMutationWriteMode, write_mode_raw)
baseline_status = _status_from_mapping(document["baseline_status"])
previous_raw = document["previous_connection"]
previous_connection = (
None if previous_raw is None else _previous_connection_from_mapping(previous_raw)
)
write_confirmed_raw = document["write_confirmed"]
if write_confirmed_raw is not None and not isinstance(write_confirmed_raw, bool):
raise ValueError("write_confirmed must be bool or null")
observation_raw = document["last_observation"]
last_observation = None if observation_raw is None else _status_from_mapping(observation_raw)
resolution_raw = document["resolution"]
if resolution_raw is None:
resolution = None
else:
resolution_string = _required_string(resolution_raw, field_name="resolution")
_validate_resolution(resolution_string)
resolution = cast(NetworkMutationResolution, resolution_string)
created_at = _validated_timestamp(document["created_at_utc"], field_name="created_at_utc")
updated_at = _validated_timestamp(document["updated_at_utc"], field_name="updated_at_utc")
if updated_at < created_at:
raise ValueError("ledger update precedes creation")
if stage == "prepared":
if (
write_confirmed_raw is not None
or last_observation is not None
or resolution is not None
):
raise ValueError("prepared ledger contains post-write fields")
elif stage in {"dispatching", "observing"}:
if resolution is not None:
raise ValueError("unresolved ledger contains a resolution")
if stage == "dispatching" and (
write_confirmed_raw is not None or last_observation is not None
):
raise ValueError("dispatching ledger contains observation fields")
if stage == "observing" and write_confirmed_raw is None:
raise ValueError("observing ledger lacks write acknowledgement status")
else:
if resolution is None:
raise ValueError("resolved ledger lacks a resolution")
if resolution == "not-dispatched" and (
write_confirmed_raw is not None or last_observation is not None
):
raise ValueError("not-dispatched resolution contains post-write fields")
if resolution == "target-observed" and last_observation is None:
raise ValueError("target-observed resolution lacks status evidence")
return NetworkMutationRecord(
schema_version=NETWORK_MUTATION_LEDGER_SCHEMA,
revision=revision,
operation_id=operation_id,
transport_ref=transport_ref,
intended_mode=intended_mode,
stage=stage,
write_mode=write_mode,
baseline_status=baseline_status,
previous_connection=previous_connection,
write_confirmed=write_confirmed_raw,
last_observation=last_observation,
resolution=resolution,
created_at_utc=_timestamp(created_at),
updated_at_utc=_timestamp(updated_at),
)
def _status_from_mapping(value: object) -> NetworkStatusEvidence:
document = _exact_mapping(
value,
{"mode", "ipv4", "status_code", "reserved"},
label="network status",
)
mode = _optional_string(document["mode"], field_name="mode")
ipv4 = _optional_string(document["ipv4"], field_name="ipv4")
status_code = _byte(document["status_code"], field_name="status_code")
reserved_raw = document["reserved"]
reserved = None if reserved_raw is None else _byte(reserved_raw, field_name="reserved")
return NetworkStatusEvidence(
mode=mode,
ipv4=ipv4,
status_code=status_code,
reserved=reserved,
)
def _previous_connection_from_mapping(value: object) -> PreviousConnectionEvidence:
document = _exact_mapping(
value,
{"transport_ref", "mode", "ipv4", "device_session_id"},
label="previous connection",
)
mode_raw = _required_string(document["mode"], field_name="mode")
_validate_connection_mode(mode_raw)
return PreviousConnectionEvidence(
transport_ref=_required_string(document["transport_ref"], field_name="transport_ref"),
mode=cast(NetworkConnectionMode, mode_raw),
ipv4=_optional_string(document["ipv4"], field_name="ipv4"),
device_session_id=_optional_string(
document["device_session_id"], field_name="device_session_id"
),
)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _validate_identifier(value: str, *, field_name: str) -> None:
if _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_connection_mode(value: str) -> None:
if value not in _CONNECTION_MODES:
raise ValueError("unsupported network connection mode")
def _validate_write_mode(value: str) -> None:
if value not in _WRITE_MODES:
raise ValueError("unsupported BLE write mode")
def _validate_resolution(value: str) -> None:
if value not in _RESOLUTIONS:
raise ValueError("unsupported network mutation resolution")
def _validate_byte(value: int, *, field_name: str) -> None:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255:
raise ValueError(f"{field_name} must be an unsigned byte")
def _byte(value: object, *, field_name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255:
raise ValueError(f"{field_name} must be an unsigned byte")
return value
def _positive_int(value: object, *, field_name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"{field_name} must be a positive integer")
return value
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _optional_string(value: object, *, field_name: str) -> str | None:
if value is None:
return None
return _required_string(value, field_name=field_name)
def _validated_timestamp(value: object, *, field_name: str) -> datetime:
raw = _required_string(value, field_name=field_name)
if not raw.endswith("Z"):
raise ValueError(f"{field_name} must be UTC")
try:
parsed = datetime.fromisoformat(raw.removesuffix("Z") + "+00:00")
except ValueError as exc:
raise ValueError(f"{field_name} is invalid") from exc
if parsed.tzinfo is None or parsed.utcoffset() != UTC.utcoffset(parsed):
raise ValueError(f"{field_name} must be UTC")
return parsed.astimezone(UTC)
def _timestamp(value: datetime) -> str:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("network mutation ledger clock must be timezone-aware")
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _nondecreasing_audit_timestamp(value: datetime, *, floor: str | None) -> str:
"""Canonicalize wall time without using it as transition authority.
Serialized revision and stage checks order ledger transitions. A host clock
may move backwards (NTP correction, RTC repair, suspend/resume), so the
human-readable audit timestamp is clamped to the prior durable value rather
than allowing a valid transition to publish a record that fails its own
``updated >= created`` structural check after restart.
"""
candidate = _validated_timestamp(_timestamp(value), field_name="ledger clock")
if floor is None:
return _timestamp(candidate)
floor_value = _validated_timestamp(floor, field_name="audit timestamp floor")
return _timestamp(max(candidate, floor_value))
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import stat
import threading
from collections.abc import Mapping
@@ -41,8 +42,16 @@ XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution:
"""Compose every K1 evidence root behind the generic observation ABI."""
configured_legacy_root = os.environ.get(
"MISSIONCORE_LEGACY_SESSIONS_DIR", ""
).strip()
legacy_root = (
Path(configured_legacy_root).expanduser().resolve()
if configured_legacy_root
else repository_root.resolve() / "sessions"
)
roots = (
("xgrids-k1.viewer-live.repository", repository_root.resolve() / "sessions"),
("xgrids-k1.viewer-live.repository", legacy_root),
(
"xgrids-k1.viewer-live.evidence",
resolve_missioncore_evidence_dir(repository_root),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
ApplicationMqttTransportError,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
@@ -65,6 +66,8 @@ class ApplicationBatchExchange(Protocol):
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_operation_keys: Collection[str],
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
dispatch_admission_commit: Callable[[], None] | None = None,
) -> dict[str, bytes]: ...
def maintain_open_for(
@@ -129,8 +132,31 @@ class OperatorDialogueCheckpoint:
)
class PhysicalAcceptancePermitReservation:
"""One commit right that must still be fresh at dispatch admission."""
def __init__(self, permit: PhysicalAcceptancePermit, token: object) -> None:
self._permit = permit
self._token = token
self._finished = False
def commit(self) -> None:
if self._finished:
raise ApplicationAcceptanceError(
"physical acceptance reservation was already finished"
)
self._permit._commit_reservation(self._token) # noqa: SLF001
self._finished = True
def release(self) -> None:
if self._finished:
return
self._permit._release_reservation(self._token) # noqa: SLF001
self._finished = True
class PhysicalAcceptancePermit:
"""Short, single-action capability that is consumed before MQTT publish."""
"""Short, single-action capability committed at physical dispatch admission."""
def __init__(
self,
@@ -152,20 +178,54 @@ class PhysicalAcceptancePermit:
self._monotonic = monotonic
self._expires_at = monotonic() + float(ttl_seconds)
self._consumed = False
self._reservation: object | None = None
@property
def action(self) -> ModelingAction:
return self._checklist.action
def consume(self, action: ModelingAction) -> None:
reservation = self.reserve(action)
reservation.commit()
def reserve(
self,
action: ModelingAction,
) -> PhysicalAcceptancePermitReservation:
with self._lock:
if self._consumed:
raise ApplicationAcceptanceError("physical acceptance permit was already consumed")
if self._reservation is not None:
raise ApplicationAcceptanceError("physical acceptance permit is already reserved")
if self._monotonic() >= self._expires_at:
raise ApplicationAcceptanceError("physical acceptance permit expired")
if action is not self._checklist.action:
raise ApplicationAcceptanceError("physical acceptance permit action mismatch")
token = object()
self._reservation = token
return PhysicalAcceptancePermitReservation(self, token)
def _commit_reservation(self, token: object) -> None:
with self._lock:
if self._consumed or self._reservation is not token:
raise ApplicationAcceptanceError(
"physical acceptance reservation is no longer current"
)
if self._monotonic() >= self._expires_at:
self._reservation = None
raise ApplicationMqttTransportError(
"physical acceptance permit expired before dispatch admission",
reason_code=(
"physical-acceptance-permit-expired-before-dispatch"
),
)
self._consumed = True
self._reservation = None
def _release_reservation(self, token: object) -> None:
with self._lock:
if self._reservation is token and not self._consumed:
self._reservation = None
def snapshot(self) -> dict[str, object]:
with self._lock:
@@ -218,6 +278,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._command_complete = False
self._dialogue_stage = "new"
self._start_complete = False
self._active_session_adopted = False
self._stop_attempted = False
self._stop_complete = False
self._active_authority: ApplicationControlAuthority | None = None
@@ -246,13 +307,48 @@ class PhysicalAcceptanceDialogueExecutor:
) -> LiveDeviceControlBinding:
"""Emit retained ordinals 1-6 at control-session establishment."""
binding = self.run_read_only_inspection_stage(orchestrator)
return self.complete_connection_stage(orchestrator, expected_binding=binding)
def run_read_only_inspection_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
) -> LiveDeviceControlBinding:
"""Emit only ordinal-1 DeviceInfo for an explicit passive Verify.
ModelingStatus and the time-setting DeviceConfig request belong to the
canonical preparation dialogue. They are deliberately excluded from
this stage so a recovery Verify cannot cross a device-mutation edge.
A later explicit workspace action may promote this same socket through
:meth:`complete_connection_stage`.
"""
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
raise ApplicationAcceptanceError("connection stage is not admissible now")
for expected_batch in (1, 2):
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
raise ApplicationAcceptanceError("inspection stage is not admissible now")
self._exchange_bootstrap_batch(orchestrator, expected_batch=1)
binding = orchestrator.binding
if binding is None:
raise ApplicationAcceptanceError("connection stage produced no live device binding")
raise ApplicationAcceptanceError("inspection stage produced no live device binding")
self._prepared_binding = binding
self._dialogue_stage = "inspection-ready"
return binding
def complete_connection_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
*,
expected_binding: LiveDeviceControlBinding,
) -> LiveDeviceControlBinding:
"""Promote an ordinal-1 inspection into the canonical ordinals 2-6."""
if self._dialogue_stage != "inspection-ready":
raise ApplicationAcceptanceError("connection completion requires inspection-ready")
if self._prepared_binding != expected_binding:
raise ApplicationAcceptanceError("inspection binding changed before completion")
self._exchange_bootstrap_batch(orchestrator, expected_batch=2)
binding = orchestrator.binding
if binding is None or binding != expected_binding:
raise ApplicationAcceptanceError("connection stage changed the inspected identity")
self._prepared_binding = binding
self._dialogue_stage = "connection-ready"
return binding
@@ -261,10 +357,13 @@ class PhysicalAcceptanceDialogueExecutor:
self,
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"],
event_observed: Callable[[], bool],
) -> OperatorDialogueCheckpoint:
*,
reconciled_active_observed: Callable[[], bool] | None = None,
) -> OperatorDialogueCheckpoint | None:
"""Service the original socket until one exact operator UI event occurs."""
expected = {
"inspection-ready": "workspace-entered",
"connection-ready": "workspace-entered",
"workspace-ready": "project-prompt-opened",
"project-ready": "start-confirmed",
@@ -276,9 +375,19 @@ class PhysicalAcceptanceDialogueExecutor:
binding = self._prepared_binding
if binding is None:
raise ApplicationAcceptanceError("canonical preparation binding is unavailable")
while not (
self._transport.pre_start_ready(binding) and event_observed()
):
if reconciled_active_observed is not None and event != "workspace-entered":
raise ApplicationAcceptanceError(
"active recovery may replace only the workspace-entry checkpoint"
)
while True:
if reconciled_active_observed is not None and reconciled_active_observed():
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"active recovery requires fresh bound SCANNING state"
)
return None
if self._transport.pre_start_ready(binding) and event_observed():
break
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
@@ -291,16 +400,52 @@ class PhysicalAcceptanceDialogueExecutor:
owner_token=self._checkpoint_owner,
)
def adopt_reconciled_scanning(
self,
*,
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> None:
"""Adopt externally observed SCANNING without inventing a new START.
The caller has already committed an exact read-only physical-ledger
reconciliation for this control generation. This method performs no
publish; it only gives the existing socket enough local state to wait
for one later operator-confirmed STOP.
"""
if self._dialogue_stage not in {"inspection-ready", "connection-ready"}:
raise ApplicationAcceptanceError(
"active recovery requires an inspected pre-START control session"
)
if self._prepared_binding != binding:
raise ApplicationAcceptanceError("active recovery binding changed")
if self._command_complete or self._start_complete or self._stop_attempted:
raise ApplicationAcceptanceError("active recovery cannot replace a command attempt")
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"active recovery requires the bound K1 to report SCANNING"
)
self._active_authority = authority
self._active_binding = binding
self._prepared_binding = None
self._active_session_adopted = True
self._dialogue_stage = "post-initialization-observed"
def run_workspace_entry_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
*,
dispatch_guard: Callable[[], None] | None = None,
) -> LiveDeviceControlBinding:
"""Emit ordinal 7 only for the observed scan-workspace entry action."""
if self._dialogue_stage != "connection-ready":
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
self._consume_checkpoint(checkpoint, expected="workspace-entered")
if dispatch_guard is not None:
dispatch_guard()
self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
binding = orchestrator.binding
if binding is None:
@@ -312,12 +457,16 @@ class PhysicalAcceptanceDialogueExecutor:
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
*,
dispatch_guard: Callable[[], None] | None = None,
) -> LiveDeviceControlBinding:
"""Emit ordinals 8-10 when the operator opens the project-name prompt."""
if self._dialogue_stage != "workspace-ready":
raise ApplicationAcceptanceError("project prompt requires workspace entry")
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
if dispatch_guard is not None:
dispatch_guard()
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
if not orchestrator.snapshot().bootstrap_complete:
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
@@ -343,6 +492,7 @@ class PhysicalAcceptanceDialogueExecutor:
binding: LiveDeviceControlBinding,
permit: PhysicalAcceptancePermit,
checkpoint: OperatorDialogueCheckpoint,
dispatch_guard: Callable[[], None] | None = None,
) -> ModelingResponse:
"""Execute retained operations 11-14 on one continuously serviced socket."""
@@ -363,6 +513,8 @@ class PhysicalAcceptanceDialogueExecutor:
if permit.action is not ModelingAction.START:
raise ApplicationAcceptanceError("canonical START requires a fresh START permit")
if dispatch_guard is not None:
dispatch_guard()
permit.consume(ModelingAction.START)
self._start_permit_snapshot = permit.snapshot()
self._command_complete = True
@@ -392,6 +544,8 @@ class PhysicalAcceptanceDialogueExecutor:
) from exc
immediate = post_start.immediate_modeling_status
if dispatch_guard is not None:
dispatch_guard()
self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
required_response_operation_keys=(),
@@ -414,6 +568,8 @@ class PhysicalAcceptanceDialogueExecutor:
required_operations = {
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
}
if dispatch_guard is not None:
dispatch_guard()
refresh_responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
required_response_operation_keys=required_operations,
@@ -469,7 +625,9 @@ class PhysicalAcceptanceDialogueExecutor:
) -> None:
"""Continuously service the original socket until the operator requests STOP."""
if self._dialogue_stage != "post-initialization-observed" or not self._start_complete:
if self._dialogue_stage != "post-initialization-observed" or not (
self._start_complete or self._active_session_adopted
):
raise ApplicationAcceptanceError(
"active control ownership requires the complete post-START dialogue"
)
@@ -491,12 +649,17 @@ class PhysicalAcceptanceDialogueExecutor:
self,
command: ShadowModelingCommand,
permit: PhysicalAcceptancePermit,
*,
dispatch_guard: Callable[[], None] | None = None,
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
) -> ModelingResponse:
"""Emit retained STOP on the same socket and with a separate permit."""
if command.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP executor requires STOP")
if self._dialogue_stage != "stop-requested" or not self._start_complete:
if self._dialogue_stage != "stop-requested" or not (
self._start_complete or self._active_session_adopted
):
raise ApplicationAcceptanceError(
"STOP requires continuous ownership from the canonical START session"
)
@@ -507,21 +670,66 @@ class PhysicalAcceptanceDialogueExecutor:
if authority is None or binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available")
self._require_command_identity(command, authority=authority, binding=binding)
if not self._transport.scan_initialization_complete(binding):
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
scanning = self._transport.scan_initialization_complete(binding)
# The status projection can wait on the transport lock. Deadline
# admission is therefore sampled again before its result can advance
# the physical STOP dialogue.
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
if not scanning:
raise ApplicationAcceptanceError(
"canonical STOP requires the bound K1 to still report SCANNING"
)
if permit.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP requires a separate STOP permit")
permit.consume(ModelingAction.STOP)
self._stop_permit_snapshot = permit.snapshot()
if dispatch_guard is not None:
dispatch_guard()
# Route/control validation is read-only but may block. It cannot
# authorize a publish whose operation deadline elapsed meanwhile.
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
permit_reservation = permit.reserve(ModelingAction.STOP)
self._stop_attempted = True
self._dialogue_stage = "stop-attempted"
responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)],
required_response_operation_keys={"modeling:stop"},
)
# Keep source-compatible test/integration transports on the legacy
# call shape unless a real operation deadline was supplied.
stop_envelope = OneShotPublishEnvelope.from_modeling_command(command)
try:
if dispatch_admission_deadline_reached is None:
responses = self._transport.exchange_batch_once(
[stop_envelope],
required_response_operation_keys={"modeling:stop"},
dispatch_admission_commit=permit_reservation.commit,
)
else:
responses = self._transport.exchange_batch_once(
[stop_envelope],
required_response_operation_keys={"modeling:stop"},
dispatch_admission_deadline_reached=(
dispatch_admission_deadline_reached
),
dispatch_admission_commit=permit_reservation.commit,
)
except ApplicationMqttTransportError as exc:
if exc.reason_code in {
"physical-command-dispatch-deadline-expired",
"physical-acceptance-permit-expired-before-dispatch",
}:
# Atomic physical admission rejected either the operation
# deadline or the still-fresh permit before durable
# DISPATCHING. Preserve that stronger zero-attempt fact.
permit_reservation.release()
self._stop_attempted = False
self._dialogue_stage = "stop-requested"
raise
finally:
self._stop_permit_snapshot = permit.snapshot()
payload = responses["modeling:stop"]
self._record_response_evidence(
phase="modeling",
@@ -545,6 +753,19 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-acknowledged"
return response
@staticmethod
def _require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached: Callable[[], bool] | None,
) -> None:
if (
dispatch_admission_deadline_reached is not None
and dispatch_admission_deadline_reached()
):
raise ApplicationMqttTransportError(
"control command dispatch deadline expired before publish admission",
reason_code="physical-command-dispatch-deadline-expired",
)
def maintain_post_stop_until_standby(self) -> None:
"""Keep servicing control reports through save and protocol standby.
@@ -577,6 +798,7 @@ class PhysicalAcceptanceDialogueExecutor:
"command_complete": self._command_complete,
"start_attempted": self._command_complete,
"start_complete": self._start_complete,
"active_session_adopted": self._active_session_adopted,
"stop_attempted": self._stop_attempted,
"stop_complete": self._stop_complete,
"dialogue_stage": self._dialogue_stage,
@@ -17,11 +17,35 @@ KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
KEYCHAIN_TIMEOUT_SECONDS = 5.0
KEYCHAIN_INTERACTIVE_TIMEOUT_SECONDS = 300.0
_ERR_SEC_USER_CANCELED = -128
_ERR_SEC_AUTH_FAILED = -25293
_ERR_SEC_INTERACTION_NOT_ALLOWED = -25308
class ApplicationAuthorityLoadError(RuntimeError):
"""The private application authority could not be loaded safely."""
def __init__(
self,
message: str,
*,
reason_code: str = "application_authority_unavailable",
) -> None:
super().__init__(message)
self.reason_code = reason_code
def _keychain_authority_reason_code(status: int) -> str:
"""Reduce an OSStatus to a reviewed, secret-free operator class."""
if status == _ERR_SEC_INTERACTION_NOT_ALLOWED:
return "keychain-authorization-required"
if status == _ERR_SEC_AUTH_FAILED:
return "keychain-authorization-denied"
if status == _ERR_SEC_USER_CANCELED:
return "keychain-authorization-cancelled"
return "application_authority_unavailable"
class CommandRunner(Protocol):
def __call__(
@@ -97,8 +121,12 @@ def _read_keychain_secret_via_security_framework(*, service: str, account: str)
if not isinstance(result, tuple) or len(result) != 2:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
status, secret_data = result
if int(status) != 0 or secret_data is None:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
status_code = int(status)
if status_code != 0 or secret_data is None:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority is unavailable",
reason_code=_keychain_authority_reason_code(status_code),
)
try:
return bytes(secret_data)
except Exception as exc:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,667 @@
from __future__ import annotations
import fcntl
import ipaddress
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import IO, Final, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
SEMANTIC_TOPOLOGY_SCHEMA: Final = "missioncore.xgrids-k1-semantic-topology/v1"
SEMANTIC_TOPOLOGY_FILENAME = "semantic-topology.json"
SEMANTIC_TOPOLOGY_LOCK_FILENAME = ".semantic-topology.lock"
SEMANTIC_TOPOLOGY_MAX_BYTES = 16 * 1024
SEMANTIC_TOPOLOGY_MAX_REVISION = (1 << 63) - 1
TopologyConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
TopologyEvidenceSource = Literal["ble-post-write-status", "ble-read-only-status"]
SemanticTopologyStoreStatus = Literal["empty", "available", "corrupt"]
_CONNECTION_MODES = frozenset({"bridge", "quick-connect", "direct-connect"})
_EVIDENCE_SOURCES = frozenset({"ble-post-write-status", "ble-read-only-status"})
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_SAFE_FIRMWARE_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+() -]{0,63}$")
class SemanticTopologyStoreError(RuntimeError):
"""Base error for durable, non-authoritative K1 topology evidence."""
reason_code = "semantic-topology-store-error"
class SemanticTopologyStoreCorrupt(SemanticTopologyStoreError):
"""The on-disk evidence cannot be trusted and must not be adopted."""
reason_code = "semantic-topology-store-corrupt"
class StaleSemanticTopologyObservation(SemanticTopologyStoreError):
"""An asynchronous writer no longer descends from the durable revision."""
reason_code = "semantic-topology-observation-stale"
@dataclass(frozen=True, slots=True)
class SemanticTopologyRecord:
"""Last topology proved by an exact K1 BLE status read.
This record proves only what the K1 reported at ``observed_at_utc``. It is
deliberately not a host-route, TCP, MQTT, connection, or acquisition lease.
A record loaded after restart is configured/offline evidence until all live
connection-supervisor gates are proved again.
"""
schema_version: Literal["missioncore.xgrids-k1-semantic-topology/v1"]
revision: int
transport_ref: str
connection_mode: TopologyConnectionMode
ipv4: str
compatibility_profile_id: str
firmware_version: str
source: TopologyEvidenceSource
observed_at_utc: str
@property
def live_connection_authority(self) -> Literal[False]:
return False
def as_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"revision": self.revision,
"transport_ref": self.transport_ref,
"connection_mode": self.connection_mode,
"ipv4": self.ipv4,
"compatibility_profile_id": self.compatibility_profile_id,
"firmware_version": self.firmware_version,
"source": self.source,
"observed_at_utc": self.observed_at_utc,
}
@dataclass(frozen=True, slots=True)
class SemanticTopologySnapshot:
status: SemanticTopologyStoreStatus
record: SemanticTopologyRecord | None
reason_code: str | None
@property
def configured_offline_evidence(self) -> bool:
return self.status == "available" and self.record is not None
@property
def live_connection_authority(self) -> Literal[False]:
return False
def as_dict(self) -> dict[str, object]:
"""Project durable evidence without implying a live connection."""
return {
"schema_version": SEMANTIC_TOPOLOGY_SCHEMA,
"status": self.status,
"configured_offline_evidence": self.configured_offline_evidence,
"live_connection_authority": self.live_connection_authority,
"reason_code": self.reason_code,
"record": self.record.as_dict() if self.record is not None else None,
}
class SemanticTopologyStore:
"""Private, atomic store for the last semantically proved K1 topology.
The stable flock file serializes the complete reload/check/publish
transaction across backend processes. The store remains independent from
the network-mutation ledger: the ledger fences possible writes, while this
store retains only a successfully decoded, secret-free status observation.
"""
def __init__(self, repository_root: Path) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / SEMANTIC_TOPOLOGY_FILENAME
self._lock_path = data_dir / "xgrids-k1" / SEMANTIC_TOPOLOGY_LOCK_FILENAME
self._data_dir = data_dir
self._thread_lock = threading.RLock()
self._record: SemanticTopologyRecord | None = None
self._corrupt = False
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> SemanticTopologySnapshot:
"""Return configured/offline evidence; never return live authority."""
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return SemanticTopologySnapshot(
status="corrupt",
record=None,
reason_code=SemanticTopologyStoreCorrupt.reason_code,
)
if self._record is None:
return SemanticTopologySnapshot(status="empty", record=None, reason_code=None)
return SemanticTopologySnapshot(
status="available",
record=self._record,
reason_code=None,
)
def commit(
self,
*,
transport_ref: str,
connection_mode: TopologyConnectionMode,
ipv4: str,
compatibility_profile_id: str,
firmware_version: str,
source: TopologyEvidenceSource,
observed_at_utc: str,
predecessor_revision: int | None = None,
) -> SemanticTopologyRecord:
"""Atomically publish one exact status observation.
Callers must invoke this only after the decoded BLE status has proved
the topology and after the selected transport's compatibility profile
has been attested. ``observed_at_utc`` is audit metadata and never
orders writes; serialized revision publication does. An asynchronous
caller that needs a stale-writer fence supplies the revision from which
its observation descends. No network mutation is performed here.
"""
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_connection_mode(connection_mode)
canonical_ipv4 = _canonical_ipv4(ipv4)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
_validate_firmware_version(firmware_version)
_validate_source(source)
observed_at = _validated_timestamp(observed_at_utc, field_name="observed_at_utc")
canonical_observed_at = _timestamp(observed_at)
if predecessor_revision is not None:
_nonnegative_revision(predecessor_revision, field_name="predecessor_revision")
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
raise SemanticTopologyStoreCorrupt(
"semantic topology store is corrupt; persisted evidence was not replaced"
)
current = self._record
current_revision = current.revision if current is not None else 0
if (
predecessor_revision is not None
and predecessor_revision != current_revision
):
raise StaleSemanticTopologyObservation(
"semantic topology predecessor revision is no longer current"
)
if current is not None and current.revision >= SEMANTIC_TOPOLOGY_MAX_REVISION:
raise SemanticTopologyStoreCorrupt(
"semantic topology revision is exhausted"
)
revision = 1 if current is None else current.revision + 1
record = SemanticTopologyRecord(
schema_version=SEMANTIC_TOPOLOGY_SCHEMA,
revision=revision,
transport_ref=transport_ref,
connection_mode=connection_mode,
ipv4=canonical_ipv4,
compatibility_profile_id=compatibility_profile_id,
firmware_version=firmware_version,
source=source,
observed_at_utc=canonical_observed_at,
)
self._persist_locked(record)
return record
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
if data_dir_created:
_fsync_directory(self._data_dir.parent)
store_dir_created = _ensure_private_directory(self.path.parent, parents=False)
if store_dir_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._lock_path, flags)
lock_created = False
except FileNotFoundError:
try:
descriptor = os.open(
self._lock_path,
flags | os.O_CREAT | os.O_EXCL,
0o600,
)
lock_created = True
except FileExistsError:
# A peer may win the create race. Open and validate exactly
# that stable inode instead of introducing a second lock.
try:
descriptor = os.open(self._lock_path, flags)
lock_created = False
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be opened safely"
) from exc
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be created safely"
) from exc
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be opened safely"
) from exc
stream: IO[bytes] | None = None
try:
try:
_validate_private_open_file(
descriptor,
self._lock_path,
label="semantic topology lock",
require_empty=True,
)
except ValueError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock is not a stable private file"
) from exc
stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
try:
_validate_private_open_file(
stream.fileno(),
self._lock_path,
label="semantic topology lock",
require_empty=True,
)
except ValueError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock changed while being acquired"
) from exc
if lock_created:
# The lock file is never replaced. Publish its first
# directory entry before relying on it after a restart.
_fsync_directory(self.path.parent)
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
finally:
if stream is not None:
stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _persist_locked(self, record: SemanticTopologyRecord) -> None:
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
self._record = record
self._corrupt = False
def _reload_locked(self) -> None:
try:
payload = _read_private_json(self.path)
except FileNotFoundError:
self._record = None
self._corrupt = False
return
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._record = None
self._corrupt = True
return
try:
record = _record_from_mapping(payload)
except (TypeError, ValueError):
self._record = None
self._corrupt = True
return
self._record = record
self._corrupt = False
def _read_private_json(path: Path) -> object:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
raise
except OSError as exc:
raise ValueError("semantic topology file cannot be opened safely") from exc
try:
metadata = _validate_private_open_file(
descriptor,
path,
label="semantic topology file",
require_empty=False,
)
if metadata.st_size > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology file exceeds the bounded size")
chunks: list[bytes] = []
remaining = SEMANTIC_TOPOLOGY_MAX_BYTES + 1
while remaining > 0:
chunk = os.read(descriptor, min(remaining, 64 * 1024))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
if len(raw) > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology file exceeds the bounded size")
finally:
os.close(descriptor)
return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
+ "\n"
).encode("utf-8")
if len(serialized) > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology payload exceeds the bounded size")
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(path.parent, parents=False)
previous_identity = _existing_private_file_identity(path)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_require_unchanged_existing_path(path, previous_identity)
os.replace(temp_path, path)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _existing_private_file_identity(path: Path) -> tuple[int, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
_validate_private_metadata(metadata, label="semantic topology file", require_empty=False)
return metadata.st_dev, metadata.st_ino
def _require_unchanged_existing_path(
path: Path,
expected: tuple[int, int] | None,
) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
if expected is None:
return
raise ValueError("semantic topology file disappeared during publication") from None
_validate_private_metadata(metadata, label="semantic topology file", require_empty=False)
observed = (metadata.st_dev, metadata.st_ino)
if expected is None or observed != expected:
raise ValueError("semantic topology file changed during publication")
def _validate_private_open_file(
descriptor: int,
path: Path,
*,
label: str,
require_empty: bool,
) -> os.stat_result:
metadata = os.fstat(descriptor)
_validate_private_metadata(metadata, label=label, require_empty=require_empty)
_validate_open_path_identity(descriptor, path, metadata, label=label)
return metadata
def _validate_open_path_identity(
descriptor: int,
path: Path,
metadata: os.stat_result,
*,
label: str,
) -> None:
del descriptor # metadata already came from this exact descriptor
try:
path_metadata = path.lstat()
except OSError as exc:
raise ValueError(f"{label} path cannot be verified") from exc
if (path_metadata.st_dev, path_metadata.st_ino) != (metadata.st_dev, metadata.st_ino):
raise ValueError(f"{label} path does not reference the opened inode")
_validate_private_metadata(path_metadata, label=label, require_empty=False)
def _validate_private_metadata(
metadata: os.stat_result,
*,
label: str,
require_empty: bool,
) -> None:
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f"{label} is not a regular file")
if stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError(f"{label} is not private")
if metadata.st_nlink != 1:
raise ValueError(f"{label} has an unsafe hard link")
if require_empty and metadata.st_size != 0:
raise ValueError(f"{label} must remain empty")
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode):
raise SemanticTopologyStoreCorrupt(
"semantic topology directory is not a regular private directory"
)
if stat.S_IMODE(metadata.st_mode) != 0o700:
raise SemanticTopologyStoreCorrupt(
"semantic topology directory permissions are not private"
)
return False
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("semantic topology store contains duplicate fields")
document[key] = value
return document
def _record_from_mapping(value: object) -> SemanticTopologyRecord:
document = _exact_mapping(
value,
{
"schema_version",
"revision",
"transport_ref",
"connection_mode",
"ipv4",
"compatibility_profile_id",
"firmware_version",
"source",
"observed_at_utc",
},
label="semantic topology",
)
if document["schema_version"] != SEMANTIC_TOPOLOGY_SCHEMA:
raise ValueError("unsupported semantic topology schema")
revision = _positive_revision(document["revision"])
transport_ref = _required_string(document["transport_ref"], field_name="transport_ref")
_validate_identifier(transport_ref, field_name="transport_ref")
mode_raw = _required_string(document["connection_mode"], field_name="connection_mode")
_validate_connection_mode(mode_raw)
source_raw = _required_string(document["source"], field_name="source")
_validate_source(source_raw)
compatibility_profile_id = _required_string(
document["compatibility_profile_id"],
field_name="compatibility_profile_id",
)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
firmware_version = _required_string(
document["firmware_version"],
field_name="firmware_version",
)
_validate_firmware_version(firmware_version)
observed_at = _validated_timestamp(
document["observed_at_utc"],
field_name="observed_at_utc",
)
observed_at_utc = _timestamp(observed_at)
if document["observed_at_utc"] != observed_at_utc:
raise ValueError("observed_at_utc is not canonical")
return SemanticTopologyRecord(
schema_version=SEMANTIC_TOPOLOGY_SCHEMA,
revision=revision,
transport_ref=transport_ref,
connection_mode=cast(TopologyConnectionMode, mode_raw),
ipv4=_canonical_ipv4(_required_string(document["ipv4"], field_name="ipv4")),
compatibility_profile_id=compatibility_profile_id,
firmware_version=firmware_version,
source=cast(TopologyEvidenceSource, source_raw),
observed_at_utc=observed_at_utc,
)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _validate_identifier(value: str, *, field_name: str) -> None:
if not isinstance(value, str) or _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_firmware_version(value: str) -> None:
if not isinstance(value, str) or _SAFE_FIRMWARE_VERSION.fullmatch(value) is None:
raise ValueError("firmware_version is outside the bounded version schema")
def _validate_connection_mode(value: str) -> None:
if not isinstance(value, str) or value not in _CONNECTION_MODES:
raise ValueError("unsupported semantic topology connection mode")
def _validate_source(value: str) -> None:
if not isinstance(value, str) or value not in _EVIDENCE_SOURCES:
raise ValueError("unsupported semantic topology evidence source")
def _canonical_ipv4(value: str) -> str:
if not isinstance(value, str) or len(value) > 15:
raise ValueError("semantic topology address must be canonical IPv4")
try:
address = ipaddress.ip_address(value)
except ValueError as exc:
raise ValueError("semantic topology address must be an IPv4 address") from exc
if not isinstance(address, ipaddress.IPv4Address) or str(address) != value:
raise ValueError("semantic topology address must be canonical IPv4")
return value
def _positive_revision(value: object) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 1
or value > SEMANTIC_TOPOLOGY_MAX_REVISION
):
raise ValueError("revision must be a bounded positive integer")
return value
def _nonnegative_revision(value: object, *, field_name: str) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 0
or value > SEMANTIC_TOPOLOGY_MAX_REVISION
):
raise ValueError(f"{field_name} must be a bounded nonnegative integer")
return value
def _validated_timestamp(value: object, *, field_name: str) -> datetime:
raw = _required_string(value, field_name=field_name)
if len(raw) > 32 or not raw.endswith("Z"):
raise ValueError(f"{field_name} must be UTC")
try:
parsed = datetime.fromisoformat(raw.removesuffix("Z") + "+00:00")
except ValueError as exc:
raise ValueError(f"{field_name} is invalid") from exc
if parsed.tzinfo is None or parsed.utcoffset() != UTC.utcoffset(parsed):
raise ValueError(f"{field_name} must be UTC")
return parsed.astimezone(UTC)
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
@@ -13,3 +13,8 @@ class StreamMessage:
received_at_epoch_ns: int
received_monotonic_ns: int | None = None
source: str = "replay"
# Assigned by VisualizationRuntime at the acquisition boundary. A raw
# replay/capture message may be unbound, but an observer must never treat a
# message from an older producer generation as evidence for the current
# device session.
producer_generation: int | None = None
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1,10 +1,14 @@
"""Host-owned network adapters used by device plugins."""
from k1link.host_network.wifi import (
HostWifiAssociationIdentityProbe,
HostWifiAssociationIdentityResult,
HostWifiCredentialMaterialAvailabilityResult,
HostWifiCredentialMaterialStoreResult,
HostWifiProfileError,
associate_with_ephemeral_wifi_credentials_once,
associate_with_wifi_profile_once,
bind_route_fingerprint_to_wifi_association,
check_wifi_credential_material,
check_wifi_profile,
ensure_wifi_profile_from_credential_source,
@@ -12,10 +16,14 @@ from k1link.host_network.wifi import (
)
__all__ = [
"HostWifiAssociationIdentityProbe",
"HostWifiAssociationIdentityResult",
"HostWifiCredentialMaterialAvailabilityResult",
"HostWifiCredentialMaterialStoreResult",
"HostWifiProfileError",
"associate_with_ephemeral_wifi_credentials_once",
"associate_with_wifi_profile_once",
"bind_route_fingerprint_to_wifi_association",
"check_wifi_credential_material",
"check_wifi_profile",
"ensure_wifi_profile_from_credential_source",
+564 -236
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
import hashlib
import hmac
import json
import os
import stat
import secrets
import signal
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager, suppress
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import Any, Literal, TypedDict
@@ -35,6 +37,7 @@ class HostWifiProfileAvailabilityResult(TypedDict):
schema_version: int
adapter: str
available: bool
credential_source: str | None
class HostWifiProfileEnsureResult(TypedDict):
@@ -57,6 +60,24 @@ class HostWifiCredentialMaterialAvailabilityResult(TypedDict):
available: bool
class HostWifiAssociationIdentityResult(TypedDict):
"""Secret-free continuity evidence for one host network interface.
``continuity_token`` is scoped to one probe instance and is safe to fold
into another local route fingerprint. It is not an SSID/BSSID digest that
can be compared between Mission Core processes.
"""
schema_version: int
adapter: str
wifi_interface: bool | None
association_state: Literal["associated", "not-wifi", "unavailable"]
evidence_quality: Literal["ssid+bssid", "bssid-only", "not-wifi", "unavailable"]
continuity_proven: bool
continuity_token: str
reason_code: str | None
_ERROR_MESSAGES = {
"unsupported-platform": (
"для этой ОС ещё не установлен адаптер системных Wi-Fi-профилей"
@@ -68,22 +89,28 @@ _ERROR_MESSAGES = {
"credential provider точной версии прошивки не установлен на этом устройстве"
),
"network-not-found": (
"точка доступа выбранного K1 не найдена; проверьте режим K1 и питание"
"K1 подтвердил режим точки доступа, но macOS не увидела её Wi-Fi-сеть "
"за отведённое время"
),
"profile-ssid-mismatch": "сохранённый профиль принадлежит другой точке доступа",
"credential-entry-cancelled": "ввод пароля точки доступа K1 отменён оператором",
"credential-invalid": "пароль точки доступа K1 имеет недопустимую длину",
"profile-credential-source-mismatch": (
"сохранённый профиль K1 не связан с точной версией firmware provider"
),
"host-wifi-operation-timeout": (
"оператор не завершил системное подключение Wi-Fi за отведённое время"
),
"host-wifi-helper-build-timeout": (
"локальный Wi-Fi helper не успел скомпилироваться за отведённое время"
),
"host-wifi-helper-build-failed": "локальный Wi-Fi helper не удалось скомпилировать",
"host-wifi-helper-compiler-unavailable": "компилятор локального Wi-Fi helper недоступен",
"host-wifi-helper-cache-unavailable": "кэш локального Wi-Fi helper недоступен",
"host-wifi-helper-missing": "исходный файл локального Wi-Fi helper не найден",
"host-wifi-helper-unavailable": "локальный Wi-Fi helper недоступен",
"keychain-authorization-required": (
"локальный профиль K1 требует отдельного разрешения связки ключей"
),
"keychain-authorization-denied": "доступ к локальному профилю K1 запрещён",
"keychain-authorization-cancelled": "разрешение связки ключей отменено оператором",
"keychain-access-failed": "локальный профиль K1 недоступен в связке ключей",
"corewlan-authorization-denied": (
"macOS не разрешила локальному сервису выполнять системную Wi-Fi-операцию"
),
"wifi-interface-inactive": "системный Wi-Fi-интерфейс выключен или неактивен",
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
}
@@ -111,7 +138,11 @@ class HostWifiProfileError(RuntimeError):
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
REVIEWED_MACOS_HELPER_COMMAND_PREFIX = ("/usr/bin/xcrun", "swift")
_ASSOCIATION_INSPECTION_PROFILE_ID = "host-association-inspection.v1"
_EPHEMERAL_ASSOCIATION_PROFILE_ID = "bridge-operation-memory.v1"
_ASSOCIATION_IDENTITY_HEX_LENGTH = 64
MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS = 0.75
def _validate_profile_id(profile_id: str) -> None:
@@ -124,232 +155,45 @@ def _validate_profile_id(profile_id: str) -> None:
raise ValueError("host Wi-Fi profile id contains unsupported characters")
def _helper_cache_directory(helper_path: Path) -> Path:
"""Resolve the process-local helper cache without an environment override."""
source = helper_path.expanduser().resolve()
for ancestor in source.parents:
if ancestor.name != "plugins":
continue
try:
relative = source.relative_to(ancestor)
except ValueError: # pragma: no cover - guarded by Path.parents
continue
if relative.parts[:2] == ("xgrids-k1", "macos"):
return ancestor.parent / ".runtime" / "mission-core" / "helpers"
# Tests and separately packaged adapters still get a stable cache beside
# their source tree. The repository layout above is the production path.
return source.parent / ".runtime" / "mission-core" / "helpers"
def _compiled_macos_helper_path(helper_path: Path) -> Path:
source = helper_path.expanduser().resolve()
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
return _helper_cache_directory(source) / f"{source.stem}-{source_sha256}"
def _is_ready_executable(path: Path) -> bool:
try:
metadata = path.lstat()
except OSError:
return False
return (
stat.S_ISREG(metadata.st_mode)
and metadata.st_size > 0
and metadata.st_mode & 0o111 != 0
)
@contextmanager
def _exclusive_helper_build_lock(
path: Path,
*,
timeout_seconds: float,
) -> Iterator[None]:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
try:
import fcntl
except ImportError as exc: # pragma: no cover - production target is macOS
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = False
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise HostWifiProfileError("host-wifi-helper-cache-unavailable")
lock_started = time.monotonic()
while True:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError as exc:
elapsed_seconds = max(0.0, time.monotonic() - lock_started)
if elapsed_seconds >= timeout_seconds:
raise HostWifiProfileError(
"host-wifi-helper-build-timeout",
helper_stage="compile-lock",
helper_elapsed_ms=int(elapsed_seconds * 1000),
) from exc
time.sleep(min(0.05, timeout_seconds - elapsed_seconds))
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = True
yield
finally:
if locked:
with suppress(OSError):
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _ensure_compiled_macos_helper(
helper_path: Path,
*,
build_timeout_seconds: float,
runner: RunProcess,
) -> Path:
"""Build the source-hash-addressed helper once and reuse it thereafter."""
if build_timeout_seconds <= 0:
raise ValueError("build_timeout_seconds must be positive")
source = helper_path.expanduser().resolve()
if not source.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
try:
executable = _compiled_macos_helper_path(source)
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
if _is_ready_executable(executable):
return executable
build_started_ns = time.monotonic_ns()
build_deadline = time.monotonic() + build_timeout_seconds
def build_error(
reason_code: str,
*,
helper_stage: str = "compile",
) -> HostWifiProfileError:
elapsed_ms = max(0, (time.monotonic_ns() - build_started_ns) // 1_000_000)
return HostWifiProfileError(
reason_code,
helper_stage=helper_stage,
helper_elapsed_ms=elapsed_ms,
)
lock_path = executable.with_name(f".{executable.name}.lock")
with _exclusive_helper_build_lock(
lock_path,
timeout_seconds=max(0.001, build_deadline - time.monotonic()),
):
if _is_ready_executable(executable):
return executable
try:
descriptor, staging_name = tempfile.mkstemp(
prefix=f".{executable.name}.",
suffix=".tmp",
dir=executable.parent,
)
os.close(descriptor)
staging = Path(staging_name)
staging.unlink()
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
try:
remaining_build_seconds = build_deadline - time.monotonic()
if remaining_build_seconds <= 0:
raise build_error("host-wifi-helper-build-timeout")
try:
completed = runner(
[
"/usr/bin/xcrun",
"swiftc",
str(source),
"-o",
str(staging),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=remaining_build_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise build_error("host-wifi-helper-build-timeout") from exc
except OSError as exc:
raise build_error("host-wifi-helper-compiler-unavailable") from exc
if completed.returncode != 0 or not _is_ready_executable(staging):
raise build_error("host-wifi-helper-build-failed")
try:
staging.chmod(0o700)
with staging.open("rb") as stream:
os.fsync(stream.fileno())
os.replace(staging, executable)
_fsync_directory(executable.parent)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
finally:
with suppress(OSError):
staging.unlink(missing_ok=True)
return executable
def _run_macos_helper(
helper_path: Path,
request: dict[str, object],
*,
timeout_seconds: float,
runner: RunProcess,
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
process_fence_descriptor_factory: Callable[[], int] | None = None,
) -> dict[str, Any]:
if sys.platform != "darwin":
raise HostWifiProfileError("unsupported-platform")
source = helper_path.expanduser().resolve()
if not source.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
executable = _ensure_compiled_macos_helper(
helper_path,
build_timeout_seconds=build_timeout_seconds,
runner=runner,
)
request_bytes = bytearray(
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
try:
completed = runner(
[str(executable)],
input=request_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
command = [*REVIEWED_MACOS_HELPER_COMMAND_PREFIX, str(source)]
completed = (
_run_macos_helper_with_process_fence(
command,
request_bytes=request_bytes,
timeout_seconds=timeout_seconds,
process_fence_descriptor_factory=(
process_fence_descriptor_factory
),
)
if process_fence_descriptor_factory is not None
else runner(
command,
input=request_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
)
except subprocess.TimeoutExpired as exc:
raise HostWifiProfileError("host-wifi-operation-timeout") from exc
@@ -389,22 +233,422 @@ def _run_macos_helper(
return response
def _run_macos_helper_with_process_fence(
command: list[str],
*,
request_bytes: bytearray,
timeout_seconds: float,
process_fence_descriptor_factory: Callable[[], int],
) -> subprocess.CompletedProcess[bytes]:
"""Run one mutating helper in a fenced, killable POSIX process group."""
if os.name != "posix":
raise HostWifiProfileError("host-wifi-helper-unavailable")
inherited_descriptor = process_fence_descriptor_factory()
process: subprocess.Popen[bytes] | None = None
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
pass_fds=(inherited_descriptor,),
)
finally:
# Popen has either inherited the descriptor into the child or failed.
# The parent-side duplicate must never extend ownership on its own.
os.close(inherited_descriptor)
try:
stdout, stderr = process.communicate(
# subprocess accepts any bytes-like object at runtime. Keep the
# mutable buffer so the caller can zero credentials in-place.
input=request_bytes, # type: ignore[arg-type]
timeout=timeout_seconds,
)
except BaseException:
_kill_and_reap_process_group(process)
raise
return subprocess.CompletedProcess(
args=command,
returncode=process.returncode,
stdout=stdout,
stderr=stderr,
)
def _kill_and_reap_process_group(process: subprocess.Popen[bytes]) -> None:
"""Prove the helper and every descendant are gone before dropping a fence."""
with suppress(ProcessLookupError, PermissionError):
os.killpg(process.pid, signal.SIGKILL)
try:
process.communicate(timeout=5.0)
except subprocess.TimeoutExpired:
with suppress(OSError):
process.kill()
process.wait(timeout=5.0)
class HostWifiAssociationIdentityProbe:
"""Observe macOS Wi-Fi association continuity without exporting its name.
The Swift helper HMACs its in-process SSID/BSSID evidence with a random key
supplied over stdin. This object retains that key only for its own process
lifetime, so equal networks cannot be correlated across service restarts.
When CoreWLAN cannot expose an association (for example because privacy
authorization hides both identifiers), observations for the same
interface/failure scope receive one process-scoped fallback token. The
token is not association proof and callers must keep that distinction, but
it prevents an unchanged kernel route from being misclassified as a new
network on every monitor poll. Route loss, interface/source changes,
recovery of exact BSSID evidence, or a service restart still changes the
combined route fingerprint.
"""
def __init__(
self,
helper_path: Path,
*,
runner: RunProcess = subprocess.run,
continuity_key: bytes | None = None,
max_cache_age_seconds: float = MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS,
monotonic_clock: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
) -> None:
key = secrets.token_bytes(32) if continuity_key is None else bytes(continuity_key)
if len(key) != 32:
raise ValueError("host Wi-Fi continuity key must contain exactly 32 bytes")
if not 0 <= max_cache_age_seconds <= MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS:
raise ValueError(
"host Wi-Fi association cache age must be between 0 and 0.75 seconds"
)
self._helper_path = helper_path
self._runner = runner
self._continuity_key = key
self._max_cache_age_seconds = max_cache_age_seconds
self._monotonic_clock = monotonic_clock
self._wall_clock = wall_clock
self._cached_observations: dict[
str,
tuple[float, float, HostWifiAssociationIdentityResult],
] = {}
self._last_interface_name: str | None = None
self._fallback_tokens: dict[str, str] = {}
self._lock = threading.Lock()
def observe(
self,
interface_name: str | None,
*,
timeout_seconds: float = 30.0,
) -> HostWifiAssociationIdentityResult:
"""Return one opaque association token, failing closed when uncertain."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if interface_name is not None and (
not 1 <= len(interface_name) <= 32
or not all(
character.isascii()
and (character.isalnum() or character in "._-")
for character in interface_name
)
):
raise ValueError("host Wi-Fi interface name contains unsupported characters")
deadline = time.monotonic() + timeout_seconds
if not self._lock.acquire(timeout=timeout_seconds):
return self._fallback_without_lock(
adapter="unavailable",
wifi_interface=None,
reason_code="host-wifi-operation-timeout",
scope=f"interface:{interface_name or 'none'}",
)
try:
if interface_name is None:
self._invalidate_for_interface_transition_locked(None)
return self._fallback_locked(
adapter="unavailable",
wifi_interface=None,
reason_code="host-route-interface-unavailable",
scope="interface:none",
)
self._invalidate_for_interface_transition_locked(interface_name)
cached = self._cached_observation_locked(interface_name)
if cached is not None:
return cached
remaining_seconds = deadline - time.monotonic()
if remaining_seconds <= 0:
return self._remember_observation_locked(
interface_name,
self._fallback_locked(
adapter="unavailable",
wifi_interface=None,
reason_code="host-wifi-operation-timeout",
scope=f"interface:{interface_name}",
),
)
try:
response = _run_macos_helper(
self._helper_path,
{
"action": "inspect-association",
"profile_id": _ASSOCIATION_INSPECTION_PROFILE_ID,
"interface_name": interface_name,
"continuity_key_hex": self._continuity_key.hex(),
},
timeout_seconds=remaining_seconds,
runner=self._runner,
)
except HostWifiProfileError as exc:
return self._remember_observation_locked(
interface_name,
self._fallback_locked(
adapter="unavailable",
wifi_interface=None,
reason_code=exc.reason_code,
scope=f"interface:{interface_name}",
),
)
adapter = response.get("adapter")
wifi_interface = response.get("wifi_interface")
association_identity = response.get("association_identity")
evidence_quality = response.get("association_evidence")
reason_code = response.get("reason_code")
if (
not isinstance(adapter, str)
or not adapter
or not isinstance(wifi_interface, bool)
or evidence_quality
not in {"ssid+bssid", "bssid-only", "not-wifi", "unavailable"}
or (reason_code is not None and not isinstance(reason_code, str))
):
return self._remember_observation_locked(
interface_name,
self._fallback_locked(
adapter="unavailable",
wifi_interface=None,
reason_code="host-wifi-response-invalid",
scope=f"interface:{interface_name}",
),
)
continuity_proven = evidence_quality in {
"ssid+bssid",
"bssid-only",
"not-wifi",
}
if not continuity_proven:
return self._remember_observation_locked(
interface_name,
self._fallback_locked(
adapter=adapter,
wifi_interface=wifi_interface,
reason_code=reason_code or "association-identity-unavailable",
scope=f"interface:{interface_name}",
),
)
if (
not isinstance(association_identity, str)
or len(association_identity) != _ASSOCIATION_IDENTITY_HEX_LENGTH
or any(character not in "0123456789abcdef" for character in association_identity)
):
return self._remember_observation_locked(
interface_name,
self._fallback_locked(
adapter="unavailable",
wifi_interface=wifi_interface,
reason_code="host-wifi-response-invalid",
scope=f"interface:{interface_name}",
),
)
return self._remember_observation_locked(
interface_name,
{
"schema_version": 1,
"adapter": adapter,
"wifi_interface": wifi_interface,
"association_state": (
"not-wifi" if evidence_quality == "not-wifi" else "associated"
),
"evidence_quality": evidence_quality,
"continuity_proven": True,
"continuity_token": association_identity,
"reason_code": None,
},
)
finally:
self._lock.release()
def _invalidate_for_interface_transition_locked(
self,
interface_name: str | None,
) -> None:
"""Make an interface transition an immediate cache barrier."""
if interface_name == self._last_interface_name:
return
self._cached_observations.clear()
self._last_interface_name = interface_name
def _cached_observation_locked(
self,
interface_name: str,
) -> HostWifiAssociationIdentityResult | None:
"""Reuse only a sub-second read-only association observation.
Runtime compilation is deliberately not used here: the field-approved
credential/CoreWLAN path relies on Apple's signed Swift interpreter
identity. The cache is therefore limited to the read-only association
result, never a credential or mutation result. Both clocks must remain
monotonic within the 750 ms safety window; sleep, a wall-clock jump or
an interface transition forces a fresh source-runner observation before
the caller can publish.
"""
cached = self._cached_observations.get(interface_name)
if cached is None or self._max_cache_age_seconds == 0:
return None
observed_monotonic, observed_wall, observation = cached
monotonic_age = self._monotonic_clock() - observed_monotonic
wall_age = self._wall_clock() - observed_wall
if (
monotonic_age < 0
or wall_age < 0
or monotonic_age > self._max_cache_age_seconds
or wall_age > self._max_cache_age_seconds
):
self._cached_observations.pop(interface_name, None)
return None
return observation.copy()
def _remember_observation_locked(
self,
interface_name: str,
observation: HostWifiAssociationIdentityResult,
) -> HostWifiAssociationIdentityResult:
if self._max_cache_age_seconds > 0:
self._cached_observations[interface_name] = (
self._monotonic_clock(),
self._wall_clock(),
observation.copy(),
)
return observation.copy()
def _fallback_locked(
self,
*,
adapter: str,
wifi_interface: bool | None,
reason_code: str,
scope: str,
) -> HostWifiAssociationIdentityResult:
token_scope = "\x1f".join(
(
scope,
adapter,
"unknown" if wifi_interface is None else str(wifi_interface).lower(),
reason_code,
)
)
token = self._fallback_tokens.get(token_scope)
if token is None:
material = (
"mission-core/host-wifi-association-fallback/v2\x1f"
f"{token_scope}"
).encode()
token = hmac.new(self._continuity_key, material, hashlib.sha256).hexdigest()
self._fallback_tokens[token_scope] = token
return {
"schema_version": 1,
"adapter": adapter,
"wifi_interface": wifi_interface,
"association_state": "unavailable",
"evidence_quality": "unavailable",
"continuity_proven": False,
"continuity_token": token,
"reason_code": reason_code,
}
def _fallback_without_lock(
self,
*,
adapter: str,
wifi_interface: bool | None,
reason_code: str,
scope: str,
) -> HostWifiAssociationIdentityResult:
"""Build the deterministic process-local fallback under lock contention."""
token_scope = "\x1f".join(
(
scope,
adapter,
"unknown" if wifi_interface is None else str(wifi_interface).lower(),
reason_code,
)
)
material = (
"mission-core/host-wifi-association-fallback/v2\x1f"
f"{token_scope}"
).encode()
return {
"schema_version": 1,
"adapter": adapter,
"wifi_interface": wifi_interface,
"association_state": "unavailable",
"evidence_quality": "unavailable",
"continuity_proven": False,
"continuity_token": hmac.new(
self._continuity_key,
material,
hashlib.sha256,
).hexdigest(),
"reason_code": reason_code,
}
def bind_route_fingerprint_to_wifi_association(
route_fingerprint: str,
association: HostWifiAssociationIdentityResult,
) -> str:
"""Bind route facts to one opaque association observation."""
if not route_fingerprint:
raise ValueError("route fingerprint must be nonblank")
token = association["continuity_token"]
if (
len(token) != _ASSOCIATION_IDENTITY_HEX_LENGTH
or any(character not in "0123456789abcdef" for character in token)
):
raise ValueError("association continuity token is invalid")
material = (
"mission-core/host-route+wifi-association/v1\x1f"
f"{route_fingerprint}\x1f{token}"
)
return hashlib.sha256(material.encode()).hexdigest()
def associate_with_wifi_profile_once(
helper_path: Path,
profile_id: str,
expected_ssid: str,
*,
scan_timeout_seconds: float = 15.0,
scan_timeout_seconds: float = 30.0,
timeout_seconds: float = 180.0,
runner: RunProcess = subprocess.run,
process_fence_descriptor_factory: Callable[[], int] | None = None,
) -> HostWifiProfileAssociationResult:
"""Associate through one device-scoped OS profile without exposing its secret.
The selected device supplies the expected, operator-visible AP SSID. The
The selected device supplies the expected, operator-visible AP SSID. The
platform helper performs bounded exact-SSID discovery followed by at most
one association. It resolves a device-scoped secret from the OS credential
stores, or asks for it through a native secure prompt on first use. The
secret never crosses the helper boundary.
one association using the already materialized Mission Core profile. It
never falls back to another system credential or a post-write password
prompt, and the secret never crosses the helper boundary.
"""
_validate_profile_id(profile_id)
@@ -422,6 +666,7 @@ def associate_with_wifi_profile_once(
},
timeout_seconds=timeout_seconds,
runner=runner,
process_fence_descriptor_factory=process_fence_descriptor_factory,
)
already_associated = response.get("already_associated")
profile_enrolled = response.get("profile_enrolled")
@@ -439,12 +684,7 @@ def associate_with_wifi_profile_once(
or type(scan_elapsed_ms) is not int
or scan_elapsed_ms < 0
or not isinstance(credential_source, str)
or credential_source not in {
"mission-core-keychain",
"system-wifi-keychain",
"native-secure-prompt",
"exact-firmware-profile",
}
or credential_source != "exact-firmware-profile"
):
raise HostWifiProfileError("host-wifi-response-invalid")
return {
@@ -459,6 +699,78 @@ def associate_with_wifi_profile_once(
}
def associate_with_ephemeral_wifi_credentials_once(
helper_path: Path,
expected_ssid: str,
password: str,
*,
scan_timeout_seconds: float = 30.0,
timeout_seconds: float = 180.0,
runner: RunProcess = subprocess.run,
process_fence_descriptor_factory: Callable[[], int] | None = None,
) -> HostWifiProfileAssociationResult:
"""Join one operator-selected Bridge network without persisting its secret.
This host-side handoff is used after K1 has already applied the Bridge
topology and the Mac no longer has a direct route to it (most notably when
switching from the K1 access point back to the shared network). The
password crosses only the helper's stdin, is zeroed from the mutable Python
request buffer, and is neither stored in the Mission Core Keychain profile
store nor returned in evidence.
The call performs at most one CoreWLAN association after a bounded exact
SSID scan. It never repeats the preceding BLE/device mutation.
"""
if not 1 <= len(expected_ssid.encode("utf-8")) <= 32:
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
if not 1 <= len(password.encode("utf-8")) <= 64:
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
if not 0 <= scan_timeout_seconds <= 60:
raise ValueError("scan_timeout_seconds must be between 0 and 60")
response = _run_macos_helper(
helper_path,
{
"action": "associate-ephemeral",
"profile_id": _EPHEMERAL_ASSOCIATION_PROFILE_ID,
"ssid": expected_ssid,
"password": password,
"scan_timeout_seconds": scan_timeout_seconds,
},
timeout_seconds=timeout_seconds,
runner=runner,
process_fence_descriptor_factory=process_fence_descriptor_factory,
)
already_associated = response.get("already_associated")
profile_enrolled = response.get("profile_enrolled")
scan_attempt_count = response.get("scan_attempt_count")
scan_elapsed_ms = response.get("scan_elapsed_ms")
credential_source = response.get("credential_source")
adapter = response.get("adapter")
if (
not isinstance(already_associated, bool)
or profile_enrolled is not False
or not isinstance(adapter, str)
or not adapter
or type(scan_attempt_count) is not int
or scan_attempt_count < 1
or type(scan_elapsed_ms) is not int
or scan_elapsed_ms < 0
or credential_source != "operation-memory"
):
raise HostWifiProfileError("host-wifi-response-invalid")
return {
"schema_version": 1,
"adapter": adapter,
"outcome": "already-associated" if already_associated else "associated",
"already_associated": already_associated,
"profile_enrolled": False,
"scan_attempt_count": scan_attempt_count,
"scan_elapsed_ms": scan_elapsed_ms,
"credential_source": "operation-memory",
}
def check_wifi_profile(
helper_path: Path,
profile_id: str,
@@ -483,13 +795,27 @@ def check_wifi_profile(
runner=runner,
)
available = response.get("profile_available")
credential_source = response.get("credential_source")
adapter = response.get("adapter")
if not isinstance(available, bool) or not isinstance(adapter, str) or not adapter:
if (
not isinstance(available, bool)
or not isinstance(adapter, str)
or not adapter
or (
available
and credential_source != "exact-firmware-profile"
)
or (
not available
and credential_source is not None
)
):
raise HostWifiProfileError("host-wifi-response-invalid")
return {
"schema_version": 1,
"adapter": adapter,
"available": available,
"credential_source": credential_source,
}
@@ -501,6 +827,7 @@ def ensure_wifi_profile_from_credential_source(
*,
timeout_seconds: float = 30.0,
runner: RunProcess = subprocess.run,
process_fence_descriptor_factory: Callable[[], int] | None = None,
) -> HostWifiProfileEnsureResult:
"""Materialize one device profile from a firmware-scoped secure-store item.
@@ -522,6 +849,7 @@ def ensure_wifi_profile_from_credential_source(
},
timeout_seconds=timeout_seconds,
runner=runner,
process_fence_descriptor_factory=process_fence_descriptor_factory,
)
available = response.get("profile_available")
profile_enrolled = response.get("profile_enrolled")
+20 -6
View File
@@ -30,6 +30,7 @@ class ActiveSessionLease:
_descriptor: int
_marker_identity: tuple[int, int]
_released: bool = False
_marker_removed: bool = False
@classmethod
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
@@ -72,18 +73,21 @@ class ActiveSessionLease:
if self._released:
return
marker = self.sessions_root / ACTIVE_SESSION_MARKER
try:
if not self._marker_removed:
try:
metadata = marker.lstat()
except FileNotFoundError:
metadata = None
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
marker.unlink()
_fsync_directory(self.sessions_root)
finally:
_unlock_descriptor(self._descriptor)
os.close(self._descriptor)
self._released = True
self._marker_removed = True
# Keep the descriptor/lock owned until marker retirement is durably
# synchronized. If unlink or fsync fails, a later cleanup-only retry
# resumes this staged release without reopening command authority.
_fsync_directory_strict(self.sessions_root)
_unlock_descriptor(self._descriptor)
os.close(self._descriptor)
self._released = True
def __enter__(self) -> ActiveSessionLease:
return self
@@ -170,3 +174,13 @@ def _fsync_directory(path: Path) -> None:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _fsync_directory_strict(path: Path) -> None:
"""Synchronize a lease transition or fail while retaining its lock."""
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+14 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import errno
import logging
import math
import socket
@@ -86,8 +87,19 @@ def _select_available_grpc_port(
# Rerun binds all local interfaces. Probe the same address class
# so a previous recording retained by a late viewer is detected.
probe.bind(("0.0.0.0", candidate))
except OSError:
continue
except OSError as exc:
if exc.errno == errno.EADDRINUSE:
continue
if exc.errno in {errno.EACCES, errno.EPERM}:
raise PermissionError(
exc.errno,
"Permission denied while probing local Rerun gRPC "
f"port {candidate}",
) from exc
raise RuntimeError(
"Could not probe local Rerun gRPC "
f"port {candidate}: {type(exc).__name__}: {exc}"
) from exc
return candidate
raise RuntimeError(
"No local Rerun gRPC port is available in "
+51 -15
View File
@@ -11,7 +11,6 @@ from typing import Any
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from starlette.middleware.gzip import GZipMiddleware
@@ -76,6 +75,7 @@ from k1link.web.e46j_raw_fisheye_realtime_api import (
)
from k1link.web.e47_semantic_slam_api import build_e47_semantic_slam_router
from k1link.web.environment_api import build_environment_router
from k1link.web.frontend_assets import ControlStationStaticFiles, frontend_build_id
from k1link.web.l3_pointpillars_visual_api import (
build_l3_pointpillars_visual_router,
)
@@ -385,7 +385,7 @@ async def _recording_preparation_reconciler() -> None:
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
reconciler: asyncio.Task[None] | None = None
try:
configure_scanner_diagnostics(REPOSITORY_ROOT / ".runtime" / "mission-core" / "logs")
configure_scanner_diagnostics(session_store.data_dir / "logs")
session_recording_preparation_manager.start()
# Recovery is intentionally a one-shot startup phase. The archive
# helper owns a cross-process lease, while ordinary catalog requests
@@ -480,11 +480,36 @@ async def invoke_device_plugin_action(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PluginExecutionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
raise HTTPException(status_code=exc.http_status_code, detail=str(exc)) from exc
except PluginRuntimeUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
_CLOSED_WEBSOCKET_SEND_ERRORS = frozenset(
{
"handler is closed",
'Cannot call "send" once a close message has been sent.',
(
"Unexpected ASGI message 'websocket.send', after sending "
"'websocket.close' or response already completed."
),
}
)
def _is_closed_websocket_send_error(exc: RuntimeError) -> bool:
message = " ".join(str(exc).split())
if message in _CLOSED_WEBSOCKET_SEND_ERRORS:
return True
# uvloop raises this after the browser has already torn down the TCP
# transport, before Starlette can translate the failed send into a
# WebSocketDisconnect. Match both stable parts so an unrelated RuntimeError
# containing only "closed" is never swallowed.
return message.startswith("unable to perform operation on ") and message.endswith(
"; the handler is closed"
)
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
await websocket.accept()
@@ -493,10 +518,17 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
while True:
state = await plugin_dispatcher.invoke(plugin_id, STATE_READ_ACTION_ID, {})
sequence += 1
await websocket.send_json({"pluginId": plugin_id, "sequence": sequence, "state": state})
try:
await websocket.send_json(
{"pluginId": plugin_id, "sequence": sequence, "state": state}
)
except WebSocketDisconnect:
return
except RuntimeError as exc:
if _is_closed_websocket_send_error(exc):
return
raise
await asyncio.sleep(0.5)
except WebSocketDisconnect:
return
except (PluginNotFoundError, PluginActionNotFoundError):
await websocket.close(code=1008, reason="Device plugin is not available")
except (PluginExecutionError, PluginRuntimeUnavailableError):
@@ -781,11 +813,7 @@ app.include_router(
app.include_router(
build_e47_semantic_slam_router(
root_provider=lambda: (
REPOSITORY_ROOT
/ ".runtime"
/ "compute-experiments"
/ "e47"
/ "semantic-slam-results"
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e47" / "semantic-slam-results"
),
)
)
@@ -1114,9 +1142,17 @@ app.include_router(
root_provider=lambda: REPOSITORY_ROOT / ".runtime" / "system",
)
)
app.include_router(build_viewer_diagnostics_router())
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
app.include_router(
build_viewer_diagnostics_router(
expected_ui_build_id=lambda: frontend_build_id(frontend_dist),
)
)
if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
app.mount(
"/",
ControlStationStaticFiles(directory=frontend_dist, html=True),
name="frontend",
)
+55
View File
@@ -82,6 +82,8 @@ class OperationRecord:
result: dict[str, Any] | None = None
error: dict[str, Any] | None = None
evidence_refs: tuple[str, ...] = ()
context: dict[str, Any] = field(default_factory=dict)
events: list[dict[str, Any]] = field(default_factory=list)
# A keyed, non-reversible digest supplied by the service. It is deliberately
# excluded from API snapshots: callers only need mismatch detection, while
# the journal must never retain action inputs or secret material.
@@ -108,6 +110,8 @@ class OperationRecord:
"result": dict(self.result) if self.result is not None else None,
"error": dict(self.error) if self.error is not None else None,
"evidence_refs": list(self.evidence_refs),
"context": dict(self.context),
"events": [dict(event) for event in self.events],
}
@@ -144,6 +148,7 @@ class OperationJournal:
deadline_seconds: float | None = None,
cancellable: bool = False,
request_fingerprint: str | None = None,
context: Mapping[str, Any] | None = None,
) -> tuple[OperationRecord, bool]:
action = action.strip()
if not action:
@@ -189,7 +194,9 @@ class OperationJournal:
),
cancellable=cancellable,
request_fingerprint=request_fingerprint,
context=dict(context or {}),
)
self._append_event_locked(record)
self._records[resolved_id] = record
self._order.append(resolved_id)
if idempotency_key is not None:
@@ -225,6 +232,7 @@ class OperationJournal:
if status in TERMINAL_OPERATION_STATUSES:
record.completed_at = self._clock()
self._trim_locked()
self._append_event_locked(record)
return record
def request_cancel(self, operation_id: str) -> OperationRecord:
@@ -239,6 +247,7 @@ class OperationJournal:
record.state_revision += 1
record.stage_code = "cancellation-requested"
record.message_code = "operation.cancellation_requested"
self._append_event_locked(record)
return record
def transition_if_pending(
@@ -276,12 +285,32 @@ class OperationJournal:
if status in TERMINAL_OPERATION_STATUSES:
record.completed_at = self._clock()
self._trim_locked()
self._append_event_locked(record)
return record
def get(self, operation_id: str) -> OperationRecord:
with self._lock:
return self._require_locked(operation_id)
def deadline_reached(self, operation_id: str | None) -> bool:
"""Evaluate one operation deadline on the journal-owned server clock.
Device lifecycle reducers use this instead of browser timers or a
second wall-clock source. Terminal records deliberately retain the
same answer so an idempotent local-cleanup retry can continue after the
operation outcome itself has already been sealed.
"""
if operation_id is None:
return False
with self._lock:
record = self._records.get(operation_id)
return bool(
record is not None
and record.deadline_at is not None
and self._clock() >= record.deadline_at
)
def latest(self) -> OperationRecord | None:
with self._lock:
return self._records[self._order[-1]] if self._order else None
@@ -298,6 +327,32 @@ class OperationJournal:
except KeyError as exc:
raise KeyError(f"unknown operation: {operation_id}") from exc
def _append_event_locked(self, record: OperationRecord) -> None:
"""Append one bounded, secret-free stage fact for operator diagnosis."""
error = record.error or {}
result = record.result or {}
side_effect_status = error.get("side_effect_status", result.get("side_effect_status"))
event: dict[str, Any] = {
"schema_version": "missioncore.operation-event/v1",
"sequence": record.sequence,
"status": record.status,
"stage_code": record.stage_code,
"message_code": record.message_code,
"observed_at": _iso(self._clock()),
"side_effect_status": (
side_effect_status if isinstance(side_effect_status, str) else None
),
"error_code": error.get("code") if isinstance(error.get("code"), str) else None,
"safe_to_retry": (
error.get("safe_to_retry")
if isinstance(error.get("safe_to_retry"), bool)
else None
),
"automatic_retry": False,
}
record.events.append(event)
def _trim_locked(self) -> None:
while len(self._order) > self._max_records:
oldest_id = next(
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import re
from pathlib import Path
from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from starlette.types import Scope
HTML_NO_STORE = "no-store"
HASHED_ASSET_IMMUTABLE = "public, max-age=31536000, immutable"
_MODULE_SCRIPT = re.compile(
r'<script\b[^>]*\bsrc=["\'](?P<src>/assets/[^"\']+)["\'][^>]*>',
re.IGNORECASE,
)
_HASHED_ASSET = re.compile(
r"^assets/(?:.+)-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$",
)
def frontend_build_id(frontend_root: Path) -> str | None:
"""Return the exact content-hashed module loaded by the current index."""
try:
index = (frontend_root / "index.html").read_text(encoding="utf-8")
except OSError:
return None
for match in _MODULE_SCRIPT.finditer(index):
source = match.group("src")
if _HASHED_ASSET.fullmatch(source.removeprefix("/")):
return source
return None
class ControlStationStaticFiles(StaticFiles):
"""Serve the SPA shell fresh while retaining immutable hashed assets."""
async def get_response(self, path: str, scope: Scope) -> Response:
response = await super().get_response(path, scope)
if response.status_code >= 400:
return response
content_type = response.headers.get("content-type", "").lower()
normalized = path.lstrip("/")
if content_type.startswith("text/html"):
response.headers["Cache-Control"] = HTML_NO_STORE
elif _HASHED_ASSET.fullmatch(normalized):
response.headers["Cache-Control"] = HASHED_ASSET_IMMUTABLE
return response
+15 -6
View File
@@ -68,6 +68,19 @@ class PluginActionNotFoundError(LookupError):
class PluginExecutionError(RuntimeError):
"""A validated plugin action failed while talking to its device/runtime."""
def __init__(
self,
message: str,
*,
http_status_code: int = 502,
reason_code: str | None = None,
) -> None:
super().__init__(message)
if not 400 <= http_status_code <= 599:
raise ValueError("plugin execution HTTP status must be an error response")
self.http_status_code = http_status_code
self.reason_code = reason_code
class PluginRuntimeCompatibilityError(RuntimeError):
"""A plugin runtime cannot satisfy the reviewed manifest/host contract."""
@@ -124,9 +137,7 @@ class InProcessDevicePluginRuntime:
raise PluginRuntimeCompatibilityError(
"Runtime does not support the manifest host API version"
)
if frozenset(request.required_action_ids) != frozenset(
self.descriptor.action_ids
):
if frozenset(request.required_action_ids) != frozenset(self.descriptor.action_ids):
raise PluginRuntimeCompatibilityError(
"Runtime handshake actions do not match the manifest"
)
@@ -225,9 +236,7 @@ class DevicePluginDispatcher:
@property
def health_snapshots(self) -> dict[str, RuntimeHealthSnapshot]:
return {
plugin_id: runtime.health() for plugin_id, runtime in self._runtimes.items()
}
return {plugin_id: runtime.health() for plugin_id, runtime in self._runtimes.items()}
async def invoke(
self,
+4
View File
@@ -55,6 +55,10 @@ _EXTRA_FIELDS: Final = (
"start_checkpoint_released",
"stale_session_retired",
"stream_id",
"ui_build_id",
"document_instance_id",
"viewer_instance_id",
"lifecycle_generation",
"backend_activity_sequence",
"viewer_range_max_ns",
"stalled_for_ms",
+87 -4
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Literal
from fastapi import APIRouter, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
logger = logging.getLogger("k1link.device_plugins.xgrids_k1.viewer_receiver")
@@ -23,12 +25,31 @@ LiveViewerFailureStage = Literal[
"receiver-stalled",
]
LIVE_VIEWER_DIAGNOSTIC_SCHEMA = "missioncore.live-viewer-diagnostic/v2"
LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA = "missioncore.live-viewer-client-contract/v1"
UI_BUILD_HEADER = "X-MissionCore-UI-Build"
_INSTANCE_ID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
_UI_BUILD_ID_PATTERN = r"^(?:development|/assets/[A-Za-z0-9._/-]+)$"
class LiveViewerDiagnosticEvent(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
schema_version: Literal["missioncore.live-viewer-diagnostic/v1"]
schema_version: Literal["missioncore.live-viewer-diagnostic/v2"]
event_code: LiveViewerEventCode
ui_build_id: str = Field(min_length=1, max_length=256, pattern=_UI_BUILD_ID_PATTERN)
document_instance_id: str = Field(
min_length=36,
max_length=36,
pattern=_INSTANCE_ID_PATTERN,
)
viewer_instance_id: str = Field(
min_length=36,
max_length=36,
pattern=_INSTANCE_ID_PATTERN,
)
lifecycle_generation: int = Field(ge=1)
failure_stage: LiveViewerFailureStage | None = None
stream_id: str | None = Field(
default=None,
@@ -42,17 +63,79 @@ class LiveViewerDiagnosticEvent(BaseModel):
recovery_attempt: int | None = Field(default=None, ge=1, le=3)
def build_viewer_diagnostics_router() -> APIRouter:
def build_viewer_diagnostics_router(
*,
expected_ui_build_id: Callable[[], str | None] = lambda: "development",
) -> APIRouter:
router = APIRouter(prefix="/api/v1/viewer", tags=["viewer"])
@router.get("/client-contract")
def get_live_viewer_client_contract() -> JSONResponse:
expected = expected_ui_build_id()
if expected is None:
return JSONResponse(
status_code=503,
content={
"schema_version": LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA,
"status": "frontend-build-unavailable",
},
headers={"Cache-Control": "no-store"},
)
return JSONResponse(
content={
"schema_version": LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA,
"status": "ready",
"ui_build_id": expected,
"diagnostic_schema_version": LIVE_VIEWER_DIAGNOSTIC_SCHEMA,
},
headers={
"Cache-Control": "no-store",
UI_BUILD_HEADER: expected,
},
)
@router.post("/live-diagnostics", status_code=204)
def record_live_diagnostic(event: LiveViewerDiagnosticEvent) -> Response:
expected = expected_ui_build_id()
if expected is None:
return JSONResponse(
status_code=503,
content={"detail": "frontend-build-unavailable"},
headers={"Cache-Control": "no-store"},
)
# Vite's unhashed development entry intentionally has no deploy
# identity and the browser-side monitor is disabled for it. Keep local
# diagnostics usable without turning a dist build into a reload loop;
# every production document carries the exact hashed entry below.
if event.ui_build_id not in {expected, "development"}:
return JSONResponse(
status_code=409,
content={
"detail": "stale-ui-build",
"expected_ui_build_id": expected,
},
headers={
"Cache-Control": "no-store",
UI_BUILD_HEADER: expected,
},
)
logger.info(
"Mission Core live Rerun receiver diagnostic: event=%s stream=%s",
(
"Mission Core live Rerun receiver diagnostic: event=%s stream=%s "
"document=%s viewer=%s lifecycle=%s build=%s"
),
event.event_code,
event.stream_id,
event.document_instance_id,
event.viewer_instance_id,
event.lifecycle_generation,
event.ui_build_id,
extra={
"event_code": event.event_code,
"ui_build_id": event.ui_build_id,
"document_instance_id": event.document_instance_id,
"viewer_instance_id": event.viewer_instance_id,
"lifecycle_generation": event.lifecycle_generation,
"failure_stage": event.failure_stage,
"stream_id": event.stream_id,
"backend_activity_sequence": event.backend_activity_sequence,
@@ -61,6 +144,6 @@ def build_viewer_diagnostics_router() -> APIRouter:
"recovery_attempt": event.recovery_attempt,
},
)
return Response(status_code=204)
return Response(status_code=204, headers={UI_BUILD_HEADER: expected})
return router