feat(k1): wire dormant control lease
This commit is contained in:
parent
ea811ff370
commit
a4accf0fb6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 15–300 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 15–300 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.
|
||||
|
|
|
|||
|
|
@ -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 15–300
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 15–300 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;
|
||||
|
|
|
|||
|
|
@ -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 15–300 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue