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:
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
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user