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

View File

@ -148,9 +148,12 @@ per-scanner profile. The whole pre-START order and START/STOP payloads are
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
application authority without env/file/browser fallback, and a five-batch
orchestrator passes all retained response barriers. The installed publisher
boundary is structurally write-disabled and cannot call its sink. There is no
MQTT publisher, `vendor_writes_enabled` remains false, and no application
command is sent.
boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0
wires a dormant coordinator into the facade: explicit shadow arm holds authority
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
it together with the orchestrator. No emission method or live sink exists, no UI
button claims device control, `vendor_writes_enabled` remains false, and no
application command is sent.
This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from

View File

@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | PAUSE (offline execution gate complete) — exact five-batch response-barrier orchestrator passes the retained 10-request dialogue; the 36-byte application authority loads only from macOS Keychain; current publisher boundary is structurally write-disabled and never touches its sink; runtime wiring, physical START/STOP and durable save remain closed gates |
| K1 application control | PAUSE (dormant runtime gate complete) — plugin v0.4.0 exposes operator-confirmed shadow arm/disarm/state; authority is held in a 15300 s non-exportable lease and revoked on expiry, reprovision, acquisition preparation or shutdown; orchestrator is facade-owned, but no emission method/live sink exists and vendor writes remain false |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the
@ -41,9 +41,14 @@ is separately attested through live DeviceStatus. A fixed macOS Keychain loader
now reads the exact 36-byte authority without environment/file/browser fallback.
The response orchestrator correlates required headers/results and rejects
identity/profile drift. The installed publish boundary is a separate
write-disabled type that cannot call its injected sink. Runtime wiring, a
reviewed write-capable type and durable post-stop save remain unproven. The
physical button remains the known-safe fallback.
write-disabled type that cannot call its injected sink. A dormant coordinator
is now wired into the XGRIDS facade and plugin manifest. Explicit operator arm
loads the Keychain value into a bounded 15300 second process-memory lease;
expiry, disarm, reprovision, acquisition preparation and shutdown drop both the
lease and orchestrator. Public state contains only lease timing/booleans. There
is deliberately no batch-emission method, live MQTT sink or UI button. Keychain
item provisioning, a separately reviewed write-capable type and durable
post-stop save remain unproven. The physical button is the known-safe fallback.
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. The queue holds four preview messages;
@ -262,7 +267,7 @@ useful stream is decoded or structurally identified.
- firmware or internal-Linux analysis;
- physical end-to-end shared-timeline playback for a newly archived session
containing point cloud plus one selected K1 camera;
- operator-owned Keychain provisioning/execution lease, authorized command transport
- operator-owned Keychain item provisioning, separately reviewed emission transport
and stable-artifact proof after the observed stop lifecycle;
- long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging.

View File

@ -204,10 +204,14 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and
wire metadata, uses QoS 2 with retain false, and declares automatic retry
forbidden. It has no MQTT publish dependency and cannot send a command.
Publishing remains deliberately disabled. The exact 36-byte authority now has a
Publishing remains deliberately disabled. The exact 36-byte authority has a
read-only macOS Keychain loader with no environment, file, browser or API
fallback, but it is not wired to the live facade. The current one-shot boundary
is structurally write-disabled and cannot touch its injected sink. The complete
fallback. A facade-owned dormant coordinator can arm it only after explicit
operator confirmation, a connected/attested K1, idle runtime and no active
acquisition. Its non-exportable process-memory lease is limited to 15300
seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
or shutdown. The current one-shot boundary is structurally write-disabled,
exposes no emission method and cannot touch its injected sink. The complete
stop/save sequence has not physically proved a durable vendor project. A
correlated STOP success, stream quiescence, return to
`READY`/steady-green hardware state and appearance of a reusable native project

View File

@ -148,9 +148,10 @@ observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
responses, and classifies live device states without importing MQTT or
publishing anything. One private application-level OpenAPI authority is kept
separate from the transient vendor ID/serial returned by the BLE-selected K1.
The fixed Keychain loader and offline response orchestrator now exist, but live
execution-lease wiring and durable save completion after stop remain open, so
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
The fixed Keychain loader, bounded execution lease and dormant facade
orchestrator now exist. No emission method or live MQTT sink is installed, and
durable save completion after stop remains open, so `vendor_writes_enabled` is
false and the physical-button workflow is canonical.
## Automatic Rerun source and lifecycle
@ -332,8 +333,8 @@ listener and its process memory must be closed unconditionally.
separate generic media path. Historical sessions predating that archive have
no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs reviewed Keychain provisioning/execution-lease wiring,
response-gated one-shot behavior and a proven durable-save completion gate.
publisher needs reviewed Keychain item provisioning, a new write-capable
emission type, response-gated one-shot behavior and durable-save proof.
- No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting

View File

@ -148,9 +148,10 @@ START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
device-status values into an observation-only state machine. The OpenAPI value
is one private application-level authority; vendor ID and serial come from the
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
exists. A fixed Keychain loader and offline response orchestrator are present;
operator execution-lease wiring and stable-artifact proof after STOP remain
unresolved, so `vendor_writes_enabled=false`.
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator
are present, but there is no emission method or live MQTT sink. Keychain item
provisioning and stable-artifact proof after STOP remain unresolved, so
`vendor_writes_enabled=false`.
## Semantic lifecycle
@ -223,7 +224,7 @@ views.
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media
plane. Keep the modeling-command publisher disabled until operator-owned
Keychain execution-lease wiring and durable-save confirmation close its safety
Keychain item provisioning, reviewed emission and durable-save confirmation
gate.
Complex equipment will likely be assembled from separately useful component

View File

@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate.
No retained physical K1 session contains the new canonical camera archive, so a
real point-cloud plus one-camera recorded playback remains an explicit hardware
acceptance test. Automated protocol tests also do not authorize K1 modeling
publishing: operator-owned Keychain execution lease and durable save completion
remain separate physical/security gates.
publishing: operator-owned Keychain item provisioning, reviewed emission and
durable save remain separate physical/security gates.

View File

@ -114,7 +114,8 @@ The operator project name is normalized and validated by the K1 contribution,
stored as display/catalog metadata and never used as a path component. This ADR
does not claim that it reaches the scanner: the inert application-control codec
has no publisher, and automatic K1 writes remain disabled pending reviewed
operator-owned Keychain execution-lease wiring and durable post-stop save evidence.
operator-owned Keychain item provisioning, reviewed emission and durable
post-stop save evidence.
ADR 0011 subsequently places the action control plane behind a versioned
descriptor/handshake/health transport seam. Observation discovery and export

View File

@ -94,13 +94,21 @@ its injected transport sink can be called. Both shadow paths remain blocked by
`vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or
automatic retry path exists.
`application_execution.py` adds the dormant facade boundary. An
operator-confirmed arm may retain authority for 15300 seconds in a
non-exportable process-memory lease and construct the shadow orchestrator. The
lease and orchestrator are dropped together on expiry/disarm and also before
reprovision, acquisition preparation or shutdown. Plugin v0.4.0 exposes only
shadow arm/disarm/state actions and redacted snapshots. The coordinator has no
request-emission method and the installed sink is explicitly uninstalled; this
runtime wiring does not widen write authority.
## Promotion gate
A future publisher requires separate review and an operator-present physical
acceptance:
1. provision the reviewed private authority into the fixed local Keychain item
and load it for one operator-owned execution lease;
1. provision the reviewed private authority into the fixed local Keychain item;
2. confirm battery/storage and select one K1 over BLE;
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
4. confirm READY identity against DeviceInfo and review shadow metadata;

View File

@ -31,6 +31,8 @@ The plugin owns:
- K1-specific operator instructions and compatibility tests.
- the scoped React `device.connection` contribution and its BLE/Wi-Fi and
acquisition pipeline UI.
- the dormant application-control facade boundary: explicit shadow arm/disarm,
a 15300 second Keychain-backed in-memory authority lease, and redacted state.
The plugin does not own:
@ -79,6 +81,12 @@ device I/O with:
uv run python plugins/xgrids-k1/profile_loader.py
```
Plugin v0.4.0 does not widen device authority. Its application-control
coordinator has no request-emission method, no live MQTT sink and no UI control;
`vendor_writes_enabled` remains false. Keychain item provisioning and a separate
review of any future write-capable transport are still required before a
physical command can be considered.
The optional owner-controlled iPhone/LixelGO observation tool lives under
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
separate `uv` environment, writes only ignored evidence sessions, and remains a

View File

@ -12,6 +12,8 @@ The contribution contains:
start/stop and compatibility file replay;
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
observation-source mapping and scoped styles;
- typed v0.4.0 shadow application-control state/arm/disarm contracts. No button
is rendered because the plugin has no live command transport;
- `plugin.ts`, which binds the manifest `device.connection` component key to
the runtime provider and connection view.

View File

@ -82,6 +82,28 @@ export interface XgridsModelingControlSafety {
publisher_installed: false;
}
export interface XgridsApplicationControlExecution {
mode: "dormant-write-disabled";
state: "disarmed" | "armed-shadow-only" | "expired" | "closed";
lease?: {
state: "armed" | "expired" | "closed";
remaining_seconds: number;
authority_cached: boolean;
exportable: false;
} | null;
orchestrator?: Record<string, unknown> | null;
publisher: {
mode: "write-disabled";
denied_attempts: number;
transport_calls: 0;
vendor_writes_enabled: false;
publisher_armed: false;
automatic_retry: false;
};
live_transport_installed: false;
can_emit_requests: false;
}
export interface XgridsAcquisition {
schema_version?: string;
acquisition_id: string;
@ -205,6 +227,7 @@ export interface XgridsK1State {
metrics?: XgridsK1Metrics;
compatibility?: XgridsCompatibilityState | null;
modeling_control_safety?: XgridsModelingControlSafety | null;
application_control_execution?: XgridsApplicationControlExecution | null;
device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null;
acquisition?: XgridsAcquisition | null;
@ -306,6 +329,12 @@ export interface StopCameraPreviewRequest {
generation: number;
}
export interface ShadowApplicationControlArmRequest {
operator_confirmed: true;
lease_seconds?: number;
timezone_name: string;
}
export class ApiError extends Error {
readonly status: number;
@ -453,6 +482,20 @@ export const xgridsK1Api = {
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
},
getShadowApplicationControlState(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowState);
},
armShadowApplicationControl(
body: ShadowApplicationControlArmRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowArm, body);
},
disarmShadowApplicationControl(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowDisarm);
},
};
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";

View File

@ -30,4 +30,16 @@ export const xgridsK1Actions = Object.freeze({
cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"),
cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
applicationControlShadowState: requirePluginAction(
xgridsK1Manifest,
"application-control.shadow-state",
),
applicationControlShadowArm: requirePluginAction(
xgridsK1Manifest,
"application-control.shadow-arm",
),
applicationControlShadowDisarm: requirePluginAction(
xgridsK1Manifest,
"application-control.shadow-disarm",
),
});

View File

@ -3,7 +3,7 @@
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.3.0",
"version": "0.4.0",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
@ -25,7 +25,8 @@
"network.mqtt.subscribe-private-lan",
"network.rtsp.read-private-lan",
"media.publish-local-browser",
"evidence.write-session-artifacts"
"evidence.write-session-artifacts",
"credential.keychain.read-local"
],
"actions": [
{ "id": "state.read", "mutating": false, "secretFields": [] },
@ -45,7 +46,10 @@
{ "id": "stream.stop", "mutating": true, "secretFields": [] },
{ "id": "camera.preview.select", "mutating": true, "secretFields": [] },
{ "id": "camera.preview.stop", "mutating": true, "secretFields": [] },
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] }
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] },
{ "id": "application-control.shadow-state", "mutating": false, "secretFields": [] },
{ "id": "application-control.shadow-arm", "mutating": true, "secretFields": [] },
{ "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] }
],
"models": [
{

View File

@ -39,10 +39,11 @@ capture verifies the `ModelingRequest` topic, action values, field layout,
literal `{device_id}:ModelingRequest` session relation, retained start settings
and numeric success code. Retained client/wire evidence identifies OpenAPI as
one private application-level value rather than a per-scanner profile. A fixed
read-only macOS Keychain loader and offline response orchestrator now exist;
operator-owned provisioning/execution-lease wiring, durable save completion,
timeout and rollback behavior remain unresolved. Acquisition therefore
stays `operator-manual` through the verified physical double-click.
read-only macOS Keychain loader, bounded in-memory lease and dormant facade
orchestrator now exist. No request-emission method or live MQTT sink exists;
operator-owned Keychain item provisioning, reviewed emission, durable save
completion, timeout and rollback behavior remain unresolved. Acquisition
therefore stays `operator-manual` through the verified physical double-click.
The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future
publisher must enforce the exact profile mapping (`2/1/0`, omitted

View File

@ -359,7 +359,7 @@
},
"success_result_code": 302252033,
"required_unresolved_context": [
"operator-owned Keychain authority provisioning and execution-lease wiring",
"operator-owned Keychain item provisioning and reviewed request emission",
"authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract"
],
@ -406,7 +406,7 @@
"request_fields": {},
"success_result_code": 302252033,
"required_unresolved_context": [
"operator-owned Keychain authority provisioning and execution-lease wiring",
"operator-owned Keychain item provisioning and reviewed request emission",
"save-completion and final-standby state mapping",
"timeout and rollback contract"
],

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)

View File

@ -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(),
)

View File

@ -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

View File

@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
)
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
assert plugin["metadata"]["version"] == "0.3.0"
assert plugin["metadata"]["version"] == "0.4.0"
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
assert plugin["spec"]["compatibilityProfiles"] == [
{
@ -116,10 +116,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
"camera.preview.select",
"camera.preview.stop",
"viewer.settings.update",
"application-control.shadow-state",
"application-control.shadow-arm",
"application-control.shadow-disarm",
} <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.3.0",
"pluginVersion": "0.4.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",

View File

@ -17,10 +17,14 @@ from k1link.device_plugins.xgrids_k1.facade import (
CompatibilityAttestationRequest,
ConnectRequest,
PrepareAcquisitionRequest,
ShadowApplicationControlArmRequest,
StartAcquisitionRequest,
StopAcquisitionRequest,
XgridsK1CompatibilityService,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
)
ATTESTATION = CompatibilityAttestationRequest(
firmware_version="3.0.2",
@ -30,6 +34,16 @@ ATTESTATION = CompatibilityAttestationRequest(
PRIMARY_TEST_CREDENTIAL = "x" * 24
SECONDARY_TEST_CREDENTIAL = "y" * 24
PROJECT_NAME = "K1 lifecycle test"
PRIVATE_APPLICATION_AUTHORITY = "11111111-2222-3333-4444-555555555555"
class FakeApplicationAuthorityLoader:
def __init__(self) -> None:
self.calls = 0
def load(self) -> ApplicationControlAuthority:
self.calls += 1
return ApplicationControlAuthority(openapi_key=PRIVATE_APPLICATION_AUTHORITY)
class FakeVisualizationRuntime:
@ -148,6 +162,104 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
)
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
tmp_path: Path,
) -> None:
loader = FakeApplicationAuthorityLoader()
service = XgridsK1CompatibilityService(
tmp_path,
application_authority_loader=loader,
)
runtime = FakeVisualizationRuntime()
service.runtime = runtime # type: ignore[assignment]
service._selected_device_id = "test-ble-transport" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._compatibility_attestation = { # noqa: SLF001
"firmware_version": "3.0.2",
"topology": "direct-lan",
"basis": "operator-attested",
"observed_at": "2026-07-18T00:00:00Z",
}
state = service.arm_application_control_shadow(
ShadowApplicationControlArmRequest(
operator_confirmed=True,
lease_seconds=60.0,
timezone_name="Europe/Moscow",
)
)
execution = state["application_control_execution"]
assert loader.calls == 1
assert execution["state"] == "armed-shadow-only"
assert execution["lease"]["authority_cached"] is True
assert execution["can_emit_requests"] is False
assert execution["live_transport_installed"] is False
assert execution["publisher"]["vendor_writes_enabled"] is False
assert execution["publisher"]["transport_calls"] == 0
assert PRIVATE_APPLICATION_AUTHORITY not in str(state)
disarmed = service.disarm_application_control_shadow()
assert disarmed["application_control_execution"]["state"] == "disarmed"
assert disarmed["application_control_execution"]["lease"] is None
def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read(
tmp_path: Path,
) -> None:
loader = FakeApplicationAuthorityLoader()
service = XgridsK1CompatibilityService(
tmp_path,
application_authority_loader=loader,
)
service.runtime = FakeVisualizationRuntime() # type: ignore[assignment]
with pytest.raises(RuntimeError, match="подключите K1"):
service.arm_application_control_shadow(
ShadowApplicationControlArmRequest(
operator_confirmed=True,
timezone_name="Europe/Moscow",
)
)
assert loader.calls == 0
def test_shadow_arm_contract_requires_explicit_operator_confirmation() -> None:
with pytest.raises(ValidationError):
ShadowApplicationControlArmRequest.model_validate({"timezone_name": "Europe/Moscow"})
def test_prepare_acquisition_revokes_existing_shadow_authority_lease(tmp_path: Path) -> None:
loader = FakeApplicationAuthorityLoader()
service = XgridsK1CompatibilityService(
tmp_path,
application_authority_loader=loader,
)
service.runtime = FakeVisualizationRuntime() # type: ignore[assignment]
service._selected_device_id = "test-ble-transport" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
service.arm_application_control_shadow(
ShadowApplicationControlArmRequest(
operator_confirmed=True,
timezone_name="Europe/Moscow",
)
)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
assert prepared["application_control_execution"]["state"] == "disarmed"
assert prepared["application_control_execution"]["lease"] is None
def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(

View File

@ -0,0 +1,138 @@
from __future__ import annotations
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
)
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
ApplicationAuthorityLease,
ApplicationControlExecutionError,
DormantApplicationControlCoordinator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
WriteDisabledOneShotPublisher,
)
PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555"
class FakeClock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
class FakeLoader:
def __init__(self) -> None:
self.calls = 0
def load(self) -> ApplicationControlAuthority:
self.calls += 1
return ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY)
class ForbiddenSink:
def publish(
self,
*,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> None:
del topic, payload, qos, retain
raise AssertionError("write-disabled coordinator touched its sink")
def test_authority_lease_expires_and_drops_its_cached_reference() -> None:
clock = FakeClock()
loader = FakeLoader()
lease = ApplicationAuthorityLease(loader, ttl_seconds=30.0, monotonic=clock)
assert loader.calls == 1
assert lease.snapshot().state == "armed"
assert lease.snapshot().authority_cached
assert PRIVATE_AUTHORITY not in repr(lease.snapshot())
clock.now = 130.0
snapshot = lease.snapshot()
assert snapshot.state == "expired"
assert not snapshot.authority_cached
assert snapshot.remaining_seconds == 0.0
with pytest.raises(ApplicationControlExecutionError, match="not armed"):
lease.authority()
@pytest.mark.parametrize("ttl_seconds", [14.999, 300.001, True])
def test_authority_lease_rejects_unreviewed_ttl(ttl_seconds: object) -> None:
with pytest.raises(ApplicationControlExecutionError):
ApplicationAuthorityLease( # type: ignore[arg-type]
FakeLoader(),
ttl_seconds=ttl_seconds,
)
def test_dormant_coordinator_arms_once_and_exposes_no_emission_path() -> None:
clock = FakeClock()
loader = FakeLoader()
coordinator = DormantApplicationControlCoordinator(
loader,
WriteDisabledOneShotPublisher(ForbiddenSink()),
monotonic=clock,
)
armed = coordinator.arm(
ttl_seconds=60.0,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
public = armed.as_dict()
assert public["state"] == "armed-shadow-only"
assert public["can_emit_requests"] is False
assert public["live_transport_installed"] is False
assert public["publisher"] == {
"mode": "write-disabled",
"denied_attempts": 0,
"transport_calls": 0,
"vendor_writes_enabled": False,
"publisher_armed": False,
"automatic_retry": False,
}
assert PRIVATE_AUTHORITY not in repr(armed)
assert PRIVATE_AUTHORITY not in str(public)
with pytest.raises(ApplicationControlExecutionError, match="already armed"):
coordinator.arm(
ttl_seconds=60.0,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
disarmed = coordinator.disarm().as_dict()
assert disarmed["state"] == "disarmed"
assert disarmed["lease"] is None
assert disarmed["orchestrator"] is None
assert loader.calls == 1
def test_coordinator_expiry_drops_lease_and_orchestrator_together() -> None:
clock = FakeClock()
coordinator = DormantApplicationControlCoordinator(
FakeLoader(),
WriteDisabledOneShotPublisher(ForbiddenSink()),
monotonic=clock,
)
coordinator.arm(
ttl_seconds=15.0,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
clock.now = 115.0
snapshot = coordinator.snapshot().as_dict()
assert snapshot["state"] == "expired"
assert snapshot["lease"] is None
assert snapshot["orchestrator"] is None
assert snapshot["can_emit_requests"] is False