feat(k1): wire dormant control lease
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user