feat(k1): wire dormant control lease

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 14:37:02 +03:00
parent ea811ff370
commit a4accf0fb6
21 changed files with 704 additions and 38 deletions
+96 -2
View File
@@ -45,6 +45,17 @@ from k1link.device_plugins.xgrids_k1.camera import (
)
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
MacOSKeychainApplicationAuthorityLoader,
)
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
ApplicationAuthorityLoader,
DormantApplicationControlCoordinator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
LiveModelingControlSafety,
@@ -74,7 +85,7 @@ from k1link.web.plugin_runtime import (
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_PLUGIN_VERSION = "0.3.0"
XGRIDS_K1_PLUGIN_VERSION = "0.4.0"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
@@ -123,6 +134,9 @@ ACTION_STREAM_STOP = "stream.stop"
ACTION_CAMERA_PREVIEW_SELECT = "camera.preview.select"
ACTION_CAMERA_PREVIEW_STOP = "camera.preview.stop"
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
ACTION_APPLICATION_CONTROL_SHADOW_STATE = "application-control.shadow-state"
ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm"
ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm"
RequestedStreamId = Literal[
"spatial.point-cloud.live",
@@ -247,10 +261,25 @@ class ViewerSettingsRequest(StrictRequest):
show_grid: bool = True
class ShadowApplicationControlArmRequest(StrictRequest):
operator_confirmed: Literal[True]
lease_seconds: float = Field(default=60.0, ge=15.0, le=300.0)
timezone_name: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*$",
)
class XgridsK1CompatibilityService:
"""The proven K1 runtime kept intact behind the plugin facade."""
def __init__(self, repository_root: Path) -> None:
def __init__(
self,
repository_root: Path,
*,
application_authority_loader: ApplicationAuthorityLoader | None = None,
) -> None:
self.repository_root = repository_root.resolve()
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
self._lock = threading.Lock()
@@ -284,6 +313,10 @@ class XgridsK1CompatibilityService:
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self._modeling_control_safety = LiveModelingControlSafety()
self._application_control = DormantApplicationControlCoordinator(
application_authority_loader or MacOSKeychainApplicationAuthorityLoader(),
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
)
self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message,
@@ -295,6 +328,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview)
@@ -386,6 +420,7 @@ class XgridsK1CompatibilityService:
),
},
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
"application_control_execution": application_control,
"device_ref": (
{
"device_id": device_id,
@@ -549,6 +584,7 @@ class XgridsK1CompatibilityService:
self._provisioning_active = True
# A reprovision can replace both the device session and its address.
self._application_control.disarm()
# Revoke preview/producer state only after the active-acquisition
# guard; a rejected network write must never stop evidence capture.
self.camera_preview.stop_current()
@@ -678,8 +714,43 @@ class XgridsK1CompatibilityService:
}
return self.state()
@_serialized_acquisition_access
def arm_application_control_shadow(
self,
request: ShadowApplicationControlArmRequest,
) -> dict[str, Any]:
"""Load a short authority lease without creating a publish path."""
with self._lock:
if self._provisioning_active:
raise RuntimeError("нельзя вооружать shadow control во время настройки Wi-Fi")
if self._k1_ip is None or self._selected_device_id is None:
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
if self._compatibility_attestation is None:
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён")
acquisition = self._acquisition
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
"нельзя вооружать shadow control во время активной acquisition-сессии"
)
if self.runtime.snapshot().get("source_mode") != "idle":
raise RuntimeError("нельзя вооружать shadow control при активном live/replay source")
self._application_control.arm(
ttl_seconds=request.lease_seconds,
epoch_seconds=int(time.time()),
timezone_name=request.timezone_name,
)
return self.state()
@_serialized_acquisition_access
def disarm_application_control_shadow(self) -> dict[str, Any]:
self._application_control.disarm()
return self.state()
@_serialized_acquisition_access
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
self._application_control.disarm()
requested_streams = _validated_requested_streams(request)
target = request.host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
@@ -1266,6 +1337,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def close(self) -> None:
self._application_control.close()
with self._lock:
acquisition = self._acquisition
close_active_acquisition = (
@@ -1784,6 +1856,13 @@ class XgridsK1ServicePort(Protocol):
def verify_connection(self) -> dict[str, Any]: ...
def arm_application_control_shadow(
self,
request: ShadowApplicationControlArmRequest,
) -> dict[str, Any]: ...
def disarm_application_control_shadow(self) -> dict[str, Any]: ...
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: ...
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: ...
@@ -1835,6 +1914,9 @@ class XgridsK1PluginFacade:
ACTION_CAMERA_PREVIEW_SELECT,
ACTION_CAMERA_PREVIEW_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
ACTION_APPLICATION_CONTROL_SHADOW_STATE,
ACTION_APPLICATION_CONTROL_SHADOW_ARM,
ACTION_APPLICATION_CONTROL_SHADOW_DISARM,
}
)
@@ -1881,6 +1963,18 @@ class XgridsK1PluginFacade:
if action_id == ACTION_CONNECTION_VERIFY:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.state)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_ARM:
arm_request = ShadowApplicationControlArmRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.arm_application_control_shadow,
arm_request,
)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.disarm_application_control_shadow)
if action_id == ACTION_ACQUISITION_PREPARE:
prepare_request = PrepareAcquisitionRequest.model_validate(payload)
return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request)
@@ -0,0 +1,211 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Protocol
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
ShadowApplicationBootstrapOrchestrator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
WriteDisabledOneShotPublisher,
)
MIN_AUTHORITY_LEASE_SECONDS = 15.0
MAX_AUTHORITY_LEASE_SECONDS = 300.0
class ApplicationControlExecutionError(RuntimeError):
"""The dormant application-control execution gate failed closed."""
class ApplicationAuthorityLoader(Protocol):
def load(self) -> ApplicationControlAuthority: ...
class MonotonicClock(Protocol):
def __call__(self) -> float: ...
@dataclass(frozen=True, slots=True)
class ApplicationAuthorityLeaseSnapshot:
state: str
remaining_seconds: float
authority_cached: bool
exportable: bool = False
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"remaining_seconds": self.remaining_seconds,
"authority_cached": self.authority_cached,
"exportable": self.exportable,
}
class ApplicationAuthorityLease:
"""Keep authority in process memory only for one short control attempt."""
def __init__(
self,
loader: ApplicationAuthorityLoader,
*,
ttl_seconds: float,
monotonic: MonotonicClock = time.monotonic,
) -> None:
if not isinstance(ttl_seconds, (int, float)) or isinstance(ttl_seconds, bool):
raise ApplicationControlExecutionError("authority lease TTL must be numeric")
if not MIN_AUTHORITY_LEASE_SECONDS <= ttl_seconds <= MAX_AUTHORITY_LEASE_SECONDS:
raise ApplicationControlExecutionError(
"authority lease TTL is outside the reviewed 15-300 second range"
)
self._lock = threading.Lock()
self._monotonic = monotonic
self._authority: ApplicationControlAuthority | None = loader.load()
self._expires_at = monotonic() + float(ttl_seconds)
self._state = "armed"
def authority(self) -> ApplicationControlAuthority:
with self._lock:
self._expire_locked()
if self._state != "armed" or self._authority is None:
raise ApplicationControlExecutionError("application authority lease is not armed")
return self._authority
def snapshot(self) -> ApplicationAuthorityLeaseSnapshot:
with self._lock:
self._expire_locked()
remaining = max(0.0, self._expires_at - self._monotonic())
return ApplicationAuthorityLeaseSnapshot(
state=self._state,
remaining_seconds=round(remaining, 3),
authority_cached=self._authority is not None,
)
def close(self) -> None:
with self._lock:
self._authority = None
if self._state != "expired":
self._state = "closed"
def _expire_locked(self) -> None:
if self._state == "armed" and self._monotonic() >= self._expires_at:
self._authority = None
self._state = "expired"
@dataclass(frozen=True, slots=True)
class DormantApplicationControlSnapshot:
state: str
lease: ApplicationAuthorityLeaseSnapshot | None
orchestrator: dict[str, object] | None
publisher: dict[str, object]
live_transport_installed: bool = False
can_emit_requests: bool = False
def as_dict(self) -> dict[str, object]:
return {
"mode": "dormant-write-disabled",
"state": self.state,
"lease": self.lease.as_dict() if self.lease is not None else None,
"orchestrator": self.orchestrator,
"publisher": self.publisher,
"live_transport_installed": self.live_transport_installed,
"can_emit_requests": self.can_emit_requests,
}
class DormantApplicationControlCoordinator:
"""Facade-owned authority/orchestrator lifecycle with no emission method."""
def __init__(
self,
loader: ApplicationAuthorityLoader,
publisher: WriteDisabledOneShotPublisher,
*,
monotonic: MonotonicClock = time.monotonic,
) -> None:
self._lock = threading.Lock()
self._loader = loader
self._publisher = publisher
self._monotonic = monotonic
self._lease: ApplicationAuthorityLease | None = None
self._orchestrator: ShadowApplicationBootstrapOrchestrator | None = None
self._state = "disarmed"
def arm(
self,
*,
ttl_seconds: float,
epoch_seconds: int,
timezone_name: str,
) -> DormantApplicationControlSnapshot:
with self._lock:
self._expire_locked()
if self._lease is not None or self._orchestrator is not None:
raise ApplicationControlExecutionError(
"application control coordinator is already armed"
)
lease = ApplicationAuthorityLease(
self._loader,
ttl_seconds=ttl_seconds,
monotonic=self._monotonic,
)
try:
orchestrator = ShadowApplicationBootstrapOrchestrator(
lease.authority(),
epoch_seconds=epoch_seconds,
timezone_name=timezone_name,
)
except Exception:
lease.close()
raise
self._lease = lease
self._orchestrator = orchestrator
self._state = "armed-shadow-only"
return self._snapshot_locked()
def disarm(self) -> DormantApplicationControlSnapshot:
with self._lock:
self._close_locked("disarmed")
return self._snapshot_locked()
def snapshot(self) -> DormantApplicationControlSnapshot:
with self._lock:
self._expire_locked()
return self._snapshot_locked()
def close(self) -> None:
with self._lock:
self._close_locked("closed")
def _expire_locked(self) -> None:
if self._lease is None:
return
lease_snapshot = self._lease.snapshot()
if lease_snapshot.state == "expired":
self._close_locked("expired")
def _close_locked(self, state: str) -> None:
if self._lease is not None:
self._lease.close()
self._lease = None
self._orchestrator = None
self._state = state
def _snapshot_locked(self) -> DormantApplicationControlSnapshot:
lease_snapshot = self._lease.snapshot() if self._lease is not None else None
if lease_snapshot is not None and lease_snapshot.state == "expired":
self._close_locked("expired")
lease_snapshot = None
orchestrator_snapshot = (
self._orchestrator.snapshot().as_dict() if self._orchestrator is not None else None
)
return DormantApplicationControlSnapshot(
state=self._state,
lease=lease_snapshot,
orchestrator=orchestrator_snapshot,
publisher=self._publisher.snapshot().as_dict(),
)
@@ -28,6 +28,21 @@ class ApplicationPublishSink(Protocol):
) -> None: ...
class UninstalledApplicationPublishSink:
"""Explicit placeholder proving that no live MQTT sink is installed."""
def publish(
self,
*,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> None:
del topic, payload, qos, retain
raise VendorWritesDisabledError("live application publish sink is not installed")
@dataclass(frozen=True, slots=True)
class OneShotPublishEnvelope:
topic: str