feat(k1): recover exact application bootstrap
This commit is contained in:
parent
af9319a33b
commit
d7749208a7
13
README.md
13
README.md
|
|
@ -139,11 +139,14 @@ stopped by the verified double-click.
|
|||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are implemented as inert protocol components.
|
||||
The command header derives `${device_id}:ModelingRequest` from an explicit ASCII
|
||||
device ID and requires an explicit ASCII OpenAPI key, but the key's legitimate
|
||||
provenance/provisioning and durable post-stop save evidence remain unresolved.
|
||||
There is no MQTT publisher in this path, `vendor_writes_enabled` remains false,
|
||||
and no modeling command is sent.
|
||||
The plugin also contains the exact ten-request pre-START bootstrap recovered
|
||||
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
|
||||
the response supplies live vendor identity, serial, model, activation and
|
||||
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
||||
as one private application-level value in the observed client, not a manual
|
||||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||
byte-matched offline, but there is no MQTT publisher, `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,23 +21,25 @@ 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 (shadow gate implemented) — the exact captured codec is byte-matched, live status binds vendor identity plus serial, and START/STOP can be planned without publish authority; device-bound OpenAPI enrollment, one-shot publisher, physical save acceptance and recovery policy remain closed gates |
|
||||
| K1 application control | PAUSE (exact shadow dialogue implemented) — all 10 retained pre-START requests and START/STOP are byte-matched; BLE-selected live DeviceInfo supplies per-scanner identity while one private application-level OpenAPI authority remains outside Git/Ops; publisher, physical save acceptance and recovery policy remain closed gates |
|
||||
| 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
|
||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||
start/stop mapping and RTSP camera transport. The exact start/stop protobuf
|
||||
encoder, response correlator and device-status state machine now exist without a
|
||||
publisher. Header construction is known: an explicit ASCII device ID and ASCII
|
||||
OpenAPI key are required, while the session ID is derived exactly as
|
||||
`${device_id}:ModelingRequest`. MQTT control publishing remains deliberately
|
||||
disabled. A live-only shadow safety layer now observes the device-reported
|
||||
vendor identity and serial, rejects identity drift or malformed status, and can
|
||||
form a non-executable START only from unbound `READY` or STOP only from a bound
|
||||
`SCANNING` state. Its output contains a digest and wire metadata, never the
|
||||
device identity, serial, OpenAPI value or payload. Legitimate per-device OpenAPI
|
||||
provisioning, an authorized one-shot publisher and a durable post-stop save gate
|
||||
are not proven. The physical button remains the known-safe fallback.
|
||||
publisher. Retained PCAP plus client static analysis prove that OpenAPI is one
|
||||
private application-level value in this LixelGO build, not a per-scanner
|
||||
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
||||
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
||||
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
||||
10 observed pre-START requests byte-for-byte: three unbound discovery reads,
|
||||
one response-bound time/timezone sync and six further reads. A live-only safety
|
||||
layer rejects identity drift or malformed status, and can form a non-executable
|
||||
START only from unbound `READY` or STOP only from bound `SCANNING`. Public output
|
||||
contains only digests and wire metadata. Secure application-authority loading,
|
||||
an authorized response-gated one-shot publisher and durable post-stop save are
|
||||
not yet proven. The physical button remains 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;
|
||||
|
|
@ -256,7 +258,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;
|
||||
- legitimate K1 OpenAPI credential provisioning, authorized command transport
|
||||
- private application-level OpenAPI authority loading, authorized command 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.
|
||||
|
|
|
|||
|
|
@ -166,14 +166,27 @@ separated four values which must never be substituted for one another:
|
|||
belongs in a vendor request;
|
||||
- the vendor device ID comes from live K1 status and occupies the request header;
|
||||
- the K1 serial is a separate live device-binding value;
|
||||
- the OpenAPI value is a private, device-bound command credential and is not
|
||||
present in the status report.
|
||||
- the OpenAPI value is private application-level material embedded by the
|
||||
observed LixelGO build; it is shared across all retained request types and is
|
||||
not derived from the current scanner while the application value is present.
|
||||
|
||||
Consequently, support for another K1 cannot replay this scanner's captured
|
||||
payload or OpenAPI value. It requires a separate owner-authorized enrollment
|
||||
whose vendor device ID and serial are attested against live status under the
|
||||
same exact firmware/topology profile. Any mismatch, identity drift, malformed
|
||||
status, replay-only evidence or unexpected lifecycle state fails closed.
|
||||
Consequently, support for another K1 never replays captured payload bytes and
|
||||
does not require a manually maintained scanner profile. BLE selection and its
|
||||
provisioning response bind the transport to that unit; an initial
|
||||
`DeviceInfoRequest` without device ID returns the live vendor ID, serial, model,
|
||||
activation and version facts. Those values bind later requests and must agree
|
||||
with the live status stream. Any mismatch, identity drift, malformed response,
|
||||
replay-only evidence or unexpected lifecycle state fails closed.
|
||||
|
||||
The clean LixelGO cycle contains exactly ten requests before START, all QoS 2
|
||||
and retain false: `DeviceInfo`, `ModelingStatus`, `GetRtkAdvance`, a bound
|
||||
`DeviceConfig` time/timezone sync, then `DeviceInfo`, `GetRtkAdvance`,
|
||||
`GetNtripProfile`, `GetCloudServerConfig`, `GetRtkAdvance`, `DeviceInfo`. The
|
||||
special time-sync session is exactly
|
||||
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The
|
||||
repository shadow bootstrap reproduces all ten retained payloads and their
|
||||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||
other nine requests are reads.
|
||||
|
||||
The repository now contains an inert bounded encoder/response parser and a
|
||||
fail-closed device-status state machine for this exact profile. A live-only
|
||||
|
|
@ -182,9 +195,9 @@ 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: secure per-device OpenAPI enrollment
|
||||
is unresolved, and the complete stop/save sequence has not physically proved a
|
||||
durable vendor project. A correlated STOP success, stream quiescence, return to
|
||||
Publishing remains deliberately disabled: the private application authority
|
||||
still needs a secure runtime loader and 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
|
||||
are separate gates. The verified physical double-click remains the acquisition
|
||||
control until a separately reviewed one-shot write gate closes.
|
||||
|
|
|
|||
|
|
@ -143,14 +143,13 @@ per-message metadata and a hash summary. Repository-level
|
|||
the current writer. The connector subscribes to the fixed report-topic
|
||||
allowlist and does not publish an application request or modeling command.
|
||||
|
||||
The recovered command substrate is intentionally inert. It can encode exact
|
||||
start/stop request bytes, correlate response identity/action/result and classify
|
||||
observed device states without importing MQTT or publishing anything. The header
|
||||
requires explicit ASCII `device_id` and OpenAPI key and derives the session ID
|
||||
exactly as `${device_id}:ModelingRequest`. Legitimate OpenAPI key
|
||||
provenance/provisioning and durable save completion after stop are unresolved,
|
||||
so `vendor_writes_enabled` is false and the physical-button workflow remains
|
||||
canonical.
|
||||
The recovered command substrate is intentionally inert. It byte-matches the ten
|
||||
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.
|
||||
Secure authority loading and durable save completion after stop remain open, so
|
||||
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
||||
|
|
@ -332,8 +331,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 a separately reviewed state-changing profile with legitimate
|
||||
OpenAPI key provisioning and a proven durable-save completion gate.
|
||||
publisher needs reviewed private application-authority loading, response-gated
|
||||
one-shot behavior and a proven durable-save completion gate.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -142,16 +142,14 @@ transport mutation. Application-command publishing remains disabled; the
|
|||
separately reviewed BLE Wi-Fi provisioning write retains its own explicit
|
||||
operator gate.
|
||||
|
||||
An exact but inert K1 modeling-control substrate now lives inside the vendor
|
||||
plugin. It encodes the recovered start/stop protobuf shapes, strictly correlates
|
||||
response identity/action/numeric result and maps bounded device-status values
|
||||
into an observation-only state machine. Header construction is no longer
|
||||
guessed: callers provide explicit ASCII device ID and ASCII OpenAPI key, and the
|
||||
session ID is derived exactly as `${device_id}:ModelingRequest`. No MQTT import
|
||||
or publisher exists in that substrate. Legitimate OpenAPI key
|
||||
provenance/provisioning and stable-artifact proof after the stop lifecycle are
|
||||
still unresolved, so `vendor_writes_enabled=false` and every state-machine
|
||||
snapshot remains false for durable save completion.
|
||||
An exact but inert K1 application-control substrate now lives inside the vendor
|
||||
plugin. It byte-matches the retained ten-request pre-START dialogue plus
|
||||
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. Secure authority loading and stable-artifact proof after STOP remain
|
||||
unresolved, so `vendor_writes_enabled=false`.
|
||||
|
||||
## Semantic lifecycle
|
||||
|
||||
|
|
@ -223,8 +221,8 @@ views.
|
|||
4. Physically accept a newly archived left/right K1 session, then package the
|
||||
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 legitimate OpenAPI
|
||||
key provisioning and durable-save confirmation close its separate safety
|
||||
plane. Keep the modeling-command publisher disabled until private application
|
||||
authority loading and durable-save confirmation close its separate safety
|
||||
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: legitimate OpenAPI key provisioning and durable save completion
|
||||
publishing: private application-authority loading and durable save completion
|
||||
remain separate physical/security gates.
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ plugin-owned.
|
|||
|
||||
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 modeling-control codec has
|
||||
no publisher, and automatic K1 writes remain disabled pending legitimate OpenAPI
|
||||
credential provisioning and durable post-stop save evidence.
|
||||
does not claim that it reaches the scanner: the inert application-control codec
|
||||
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
||||
private application-authority loading 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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# ADR 0012: device-bound K1 command authority
|
||||
# ADR 0012: live-bound K1 control with application-level authority
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-18
|
||||
|
|
@ -6,89 +6,113 @@
|
|||
|
||||
## Context
|
||||
|
||||
Owner-controlled LixelGO traffic proves the K1 modeling START/STOP protobuf
|
||||
shape, MQTT topic, QoS and correlated success response for one K1 running the
|
||||
exact `xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. A bounded offline
|
||||
audit also proves that Mission Core's encoder reproduces both retained requests
|
||||
byte-for-byte. This is sufficient to describe the current device dialogue, but
|
||||
not to replay a captured request or authorize writes to any K1.
|
||||
Owner-controlled LixelGO traffic proves the K1 preparation and modeling
|
||||
dialogue for one activated K1 running the exact
|
||||
`xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. Offline regression proves
|
||||
that Mission Core reproduces all ten requests before START, plus START and STOP,
|
||||
byte-for-byte. This describes the current device dialogue but does not authorize
|
||||
writes to any K1.
|
||||
|
||||
The protocol exposes several identities with different authority. Mission
|
||||
Core's provisional device UUID is local inventory identity. The vendor device
|
||||
ID is reported by the live K1 and occupies the request header. The K1 serial is
|
||||
a separate device-binding value. The OpenAPI value is private command material
|
||||
which is absent from live status. LixelGO derives the command session relation
|
||||
from the vendor device ID and request type; it is not a reusable caller session.
|
||||
The protocol exposes identities with different ownership. Mission Core's
|
||||
inventory UUID and the macOS CoreBluetooth UUID are local transport identities.
|
||||
The vendor device ID and serial identify the live scanner. The OpenAPI value is
|
||||
private application material: retained traffic uses one value across every
|
||||
request type, and decompiled LixelGO uses its embedded application value before
|
||||
the serial-derived fallback. It is therefore not a manual per-scanner profile.
|
||||
|
||||
Native project durability is also not equivalent to command acceptance. STOP
|
||||
success, stream quiescence, return to READY/steady green and a reusable project
|
||||
on the scanner are separate observations.
|
||||
The selected BLE peripheral returns the LAN address of that same unit during
|
||||
the reviewed Wi-Fi provisioning exchange. On MQTT, LixelGO first sends
|
||||
`DeviceInfoRequest` without a device ID. `DeviceInfoResponse` supplies vendor ID,
|
||||
serial, model, activation and version facts; subsequent headers bind to that
|
||||
identity. Native project durability remains separate from command acceptance.
|
||||
|
||||
## Decision
|
||||
|
||||
K1 application control is exact-profile, device-bound and fail-closed.
|
||||
K1 application control is exact-profile, live-bound and fail-closed.
|
||||
|
||||
1. Mission Core never sends its local inventory UUID, BLE identifier or a K1
|
||||
serial in place of the vendor request identity.
|
||||
2. Each K1 requires an owner-authorized enrollment containing its vendor device
|
||||
ID, serial and private OpenAPI value. Enrollment secrets are not committed,
|
||||
placed in Ops, returned by APIs or written to logs.
|
||||
3. Before a command can be considered, live MQTT status must attest the same
|
||||
vendor device ID and serial under the exact firmware/topology profile.
|
||||
Replay evidence cannot establish current command authority.
|
||||
4. Identity drift, missing identity, malformed status, profile mismatch or an
|
||||
unexpected lifecycle state blocks command planning.
|
||||
5. START retains the observed LixelGO parameters: record-and-calculate mode 2,
|
||||
LCC scan mode 1, handheld mount mode 0 and the operator's validated project
|
||||
name. STOP contains only the same bound header and action.
|
||||
6. MQTT command semantics remain QoS 2, retain false. Automatic retry is
|
||||
forbidden: an unknown outcome must be reconciled from live status and
|
||||
physical evidence before another command.
|
||||
7. No firmware, activation, account, update or vendor-cloud endpoint is part of
|
||||
the control path. Local direct-LAN MQTT is the only reviewed transport.
|
||||
8. STOP completion has four gates: correlated command result, local stream
|
||||
quiescence and evidence sealing, READY/steady-green device state, then native
|
||||
project verification. Earlier gates must not claim the later ones.
|
||||
1. Mission Core never substitutes its inventory UUID, BLE UUID or serial for
|
||||
the vendor request identity.
|
||||
2. One private application-level OpenAPI authority is loaded outside Git, Ops,
|
||||
browser state, APIs and logs. It is not copied into scanner profiles.
|
||||
3. The BLE-selected transport is followed by an unbound DeviceInfo exchange.
|
||||
Its live vendor ID, serial, activation and FW 3.0.2 facts form the transient
|
||||
device binding; a saved profile is optional metadata, not protocol authority.
|
||||
4. The ten retained pre-START requests keep their exact order and response
|
||||
boundaries. The only mutation is the observed time/timezone sync; the other
|
||||
nine requests are reads. No request is batched, skipped or automatically
|
||||
retried.
|
||||
5. Live DeviceInfo binding must agree with the live DeviceStatus stream before
|
||||
START/STOP planning. Identity drift, malformed data, inactive equipment,
|
||||
profile mismatch or an unexpected lifecycle state fails closed.
|
||||
6. START retains record-and-calculate mode 2, LCC mode 1, handheld mount 0 and
|
||||
the validated project name. STOP contains only bound header and action.
|
||||
7. MQTT application semantics remain QoS 2, retain false. An unknown outcome is
|
||||
reconciled from correlated response, status and physical evidence before any
|
||||
new operator-authorized attempt.
|
||||
8. No firmware, activation, account, update or vendor-cloud mutation belongs to
|
||||
this path. `GetCloudServerConfig` is a local K1 read.
|
||||
9. STOP completion has separate gates: correlated result, local stream
|
||||
quiescence/evidence sealing, READY plus steady green, then independent native
|
||||
project verification.
|
||||
|
||||
## Recovered pre-START sequence
|
||||
|
||||
The clean cycle contains:
|
||||
|
||||
1. unbound `DeviceInfoRequest`;
|
||||
2. unbound `ModelingStatusRequest`;
|
||||
3. unbound `GetRtkAdvanceRequest`;
|
||||
4. bound `DeviceConfigRequest` time/timezone sync;
|
||||
5. bound `DeviceInfoRequest`;
|
||||
6. bound `GetRtkAdvanceRequest`;
|
||||
7. bound `GetNtripProfileRequest`;
|
||||
8. bound `GetCloudServerConfigRequest`;
|
||||
9. bound `GetRtkAdvanceRequest`;
|
||||
10. bound `DeviceInfoRequest`;
|
||||
11. bound `ModelingRequest` START.
|
||||
|
||||
Normal sessions are `${device_id_or_empty}:${MessageType}`. Time sync uses the
|
||||
captured special relation
|
||||
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`.
|
||||
|
||||
## Current implementation boundary
|
||||
|
||||
`LiveModelingControlSafety` observes only live device-status messages. It binds
|
||||
vendor identity and serial, tracks lifecycle state and creates non-executable
|
||||
shadow START/STOP plans. Public state and plan output contain only booleans,
|
||||
counts, action, topic, QoS, retain flag, payload length and digest. The encoded
|
||||
payload and enrolled values remain private in memory.
|
||||
`application_bootstrap.py` provides the bounded application authority type,
|
||||
DeviceInfo response correlation/decoding and the exact non-executable ten-step
|
||||
shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic
|
||||
order. `LiveModelingControlSafety` independently binds that DeviceInfo identity
|
||||
to live status and creates non-executable START/STOP plans.
|
||||
|
||||
The shadow plan is blocked by both `vendor-writes-disabled` and
|
||||
`publisher-not-installed`. There is no MQTT publisher, enrollment endpoint or
|
||||
automatic retry path. The physical K1 control remains the production fallback.
|
||||
Public state contains only booleans, counts and wire metadata. Private identity,
|
||||
authority and payload bytes stay out of repr/API output. Both shadow paths are
|
||||
blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT
|
||||
publisher or automatic retry path exists.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
A future publisher requires a separate review and an operator-present physical
|
||||
acceptance on the enrolled K1:
|
||||
A future publisher requires separate review and an operator-present physical
|
||||
acceptance:
|
||||
|
||||
1. confirm exact firmware/profile, steady-green state, battery and storage;
|
||||
2. close LixelGO and arm raw evidence plus the intended camera receiver;
|
||||
3. attest live READY identity against the device enrollment;
|
||||
4. review the shadow command metadata;
|
||||
5. send one START, without retry, and correlate its exact response;
|
||||
1. load the private application authority through a reviewed local secret
|
||||
mechanism;
|
||||
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;
|
||||
5. send one START without retry and correlate its response;
|
||||
6. observe calibration and first point/pose/camera data;
|
||||
7. send one STOP, without retry, and correlate its exact response;
|
||||
7. send one STOP without retry and correlate its response;
|
||||
8. seal local evidence while accepting the bounded stream tail;
|
||||
9. wait for READY and steady green before power-off or another scan;
|
||||
10. verify the native project independently through the vendor-supported
|
||||
workflow.
|
||||
9. wait for READY and steady green;
|
||||
10. verify the native project through the vendor-supported workflow.
|
||||
|
||||
Any unknown response, identity change, activation state, fault, low battery or
|
||||
unexpected lifecycle transition pauses the test. It does not trigger a guessed
|
||||
recovery command.
|
||||
Any unknown response, identity change, fault, low battery/storage or unexpected
|
||||
transition pauses the test. It never triggers a guessed recovery command.
|
||||
|
||||
## Consequences
|
||||
|
||||
Adding another K1 means creating another private enrollment and proving its
|
||||
exact compatibility profile; it never means reusing captured bytes from the
|
||||
first scanner. The design preserves native record-and-calculate behavior while
|
||||
keeping command authority inside the XGRIDS plugin and out of generic Mission
|
||||
Core. It intentionally postpones convenience automation until identity,
|
||||
credential provenance, one-shot transport behavior and durable save semantics
|
||||
have all passed physical acceptance.
|
||||
Adding another identical K1 means selecting it over BLE and deriving a fresh
|
||||
live DeviceInfo binding, not creating or copying a command profile. A second
|
||||
physical K1 remains a useful portability acceptance, but another packet capture
|
||||
is not required before implementing the reviewed current profile. The design
|
||||
preserves native record-and-calculate behavior while keeping vendor control in
|
||||
the XGRIDS plugin and out of generic Mission Core.
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
|
|||
if mapping.get("header_contract") != {
|
||||
"device_id": "explicit-observed-identity",
|
||||
"session_id": "{device_id}:ModelingRequest",
|
||||
"openapi_key": "explicit-observed-value-with-unresolved-provenance",
|
||||
"openapi_key": "private-application-level-runtime-authority",
|
||||
}:
|
||||
raise CompatibilityProfileError(
|
||||
f"{action_id} header contract differs from retained evidence"
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ is observed, but its media decoder/replay flags remain independent.
|
|||
The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
|
||||
capture verifies the `ModelingRequest` topic, action values, field layout,
|
||||
literal `{device_id}:ModelingRequest` session relation, retained start settings
|
||||
and numeric success code. The bounded codec accepts an OpenAPI value only as an
|
||||
explicit secret; its provenance and secure provisioning are unresolved, as are
|
||||
durable save completion, timeout and rollback behavior. Acquisition therefore
|
||||
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
||||
one private application-level value rather than a per-scanner profile. Its
|
||||
secure runtime loading remains unresolved, as do durable save completion,
|
||||
timeout and rollback behavior. 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
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@
|
|||
"header_contract": {
|
||||
"device_id": "explicit-observed-identity",
|
||||
"session_id": "{device_id}:ModelingRequest",
|
||||
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
|
||||
"openapi_key": "private-application-level-runtime-authority"
|
||||
},
|
||||
"request_fields": {
|
||||
"project_name": "required-operator-value",
|
||||
|
|
@ -359,7 +359,7 @@
|
|||
},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"OpenAPI credential provenance and secure provisioning",
|
||||
"secure private application-authority loading",
|
||||
"authorization policy for any setting outside the retained request",
|
||||
"timeout, rejection and rollback contract"
|
||||
],
|
||||
|
|
@ -401,12 +401,12 @@
|
|||
"header_contract": {
|
||||
"device_id": "explicit-observed-identity",
|
||||
"session_id": "{device_id}:ModelingRequest",
|
||||
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
|
||||
"openapi_key": "private-application-level-runtime-authority"
|
||||
},
|
||||
"request_fields": {},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"OpenAPI credential provenance and secure provisioning",
|
||||
"secure private application-authority loading",
|
||||
"save-completion and final-standby state mapping",
|
||||
"timeout and rollback contract"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,502 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
|
||||
MAX_HEADER_BYTES = 4 * 1024
|
||||
MAX_TEXT_BYTES = 4 * 1024
|
||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
|
||||
DEVICE_INFO_REQUEST_TOPIC = "lixel/application/request/device_info"
|
||||
DEVICE_INFO_RESPONSE_TOPIC = "lixel/application/response/device_info"
|
||||
MODELING_STATUS_REQUEST_TOPIC = "lixel/application/request/modeling_status"
|
||||
MODELING_STATUS_RESPONSE_TOPIC = "lixel/application/response/modeling_status"
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC = "lixel/application/request/get_rtk_advance"
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC = "lixel/application/response/get_rtk_advance"
|
||||
DEVICE_CONFIG_REQUEST_TOPIC = "lixel/application/request/device_config"
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC = "lixel/application/response/device_config"
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC = "lixel/application/request/get_ntrip_profile"
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC = "lixel/application/response/get_ntrip_profile"
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC = "lixel/application/request/get_cloud_server_config"
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC = "lixel/application/response/get_cloud_server_config"
|
||||
|
||||
|
||||
class ApplicationBootstrapError(ValueError):
|
||||
"""The recovered K1 application bootstrap contract was violated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationControlAuthority:
|
||||
"""Private authority belonging to the reviewed LixelGO application profile.
|
||||
|
||||
The retained application and wire evidence show one OpenAPI value shared by
|
||||
all request types. It is not a scanner serial or a per-device enrollment.
|
||||
"""
|
||||
|
||||
openapi_key: str = field(repr=False)
|
||||
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
|
||||
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
|
||||
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.source != APPLICATION_AUTHORITY_SOURCE
|
||||
or self.compatibility_profile_id != COMPATIBILITY_PROFILE_ID
|
||||
):
|
||||
raise ApplicationBootstrapError(
|
||||
"application authority does not match the reviewed K1 profile"
|
||||
)
|
||||
_validate_ascii_identity(self.openapi_key, "openapi_key")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveDeviceControlBinding:
|
||||
"""Identity and compatibility facts learned from the selected live K1."""
|
||||
|
||||
vendor_device_id: str = field(repr=False)
|
||||
device_serial: str = field(repr=False)
|
||||
software_version: str = field(repr=False)
|
||||
system_version: str = field(repr=False)
|
||||
device_model: str = field(repr=False)
|
||||
device_type: str = field(repr=False)
|
||||
is_activated: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_ascii_identity(self.vendor_device_id, "vendor_device_id")
|
||||
_validate_ascii_identity(self.device_serial, "device_serial")
|
||||
for name, value in (
|
||||
("software_version", self.software_version),
|
||||
("system_version", self.system_version),
|
||||
("device_model", self.device_model),
|
||||
("device_type", self.device_type),
|
||||
):
|
||||
_validate_text(value, name)
|
||||
|
||||
@property
|
||||
def matches_reviewed_firmware(self) -> bool:
|
||||
return _contains_version(self.software_version, "3.0.2") and _contains_version(
|
||||
self.system_version, "3.0.2"
|
||||
)
|
||||
|
||||
@property
|
||||
def ready_for_reviewed_profile(self) -> bool:
|
||||
return self.is_activated and self.matches_reviewed_firmware
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationRequestHeader:
|
||||
message_type: str
|
||||
authority: ApplicationControlAuthority = field(repr=False)
|
||||
binding: LiveDeviceControlBinding | None = field(default=None, repr=False)
|
||||
context: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_ascii_identity(self.message_type, "message_type")
|
||||
if self.context is not None:
|
||||
_validate_ascii_identity(self.context, "context")
|
||||
if len(self.session_id.encode("ascii")) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError("derived session_id exceeds configured limit")
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
device_id = self.binding.vendor_device_id if self.binding is not None else ""
|
||||
session = f"{device_id}:{self.message_type}"
|
||||
if self.context is not None:
|
||||
session = f"{session}:{self.context}"
|
||||
return session
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncodedApplicationRequest:
|
||||
ordinal: int
|
||||
phase: Literal["identity-discovery", "bound-preparation"]
|
||||
message_type: str
|
||||
topic: str
|
||||
response_topic: str
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
mutates_device: bool
|
||||
requires_live_binding: bool
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"ordinal": self.ordinal,
|
||||
"phase": self.phase,
|
||||
"message_type": self.message_type,
|
||||
"topic": self.topic,
|
||||
"response_topic": self.response_topic,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"mutates_device": self.mutates_device,
|
||||
"requires_live_binding": self.requires_live_binding,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ShadowApplicationBootstrap:
|
||||
"""Exact observed pre-START request order, with no execution authority."""
|
||||
|
||||
requests: tuple[EncodedApplicationRequest, ...] = field(repr=False)
|
||||
executable: bool = False
|
||||
blockers: tuple[str, ...] = SHADOW_BLOCKERS
|
||||
retry_policy: Literal["never-automatic"] = "never-automatic"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"request_count": len(self.requests),
|
||||
"mutation_count": sum(request.mutates_device for request in self.requests),
|
||||
"requests": [request.as_dict() for request in self.requests],
|
||||
"executable": self.executable,
|
||||
"blockers": list(self.blockers),
|
||||
"retry_policy": self.retry_policy,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceInfoResponse:
|
||||
binding: LiveDeviceControlBinding = field(repr=False)
|
||||
session_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
result_code: int
|
||||
|
||||
|
||||
def build_shadow_bootstrap(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
epoch_seconds: int,
|
||||
timezone_name: str,
|
||||
) -> ShadowApplicationBootstrap:
|
||||
"""Build the ten pre-START requests observed in the clean LixelGO cycle.
|
||||
|
||||
The first three requests intentionally omit the vendor device ID. The
|
||||
remaining requests are bound to identity returned by ``DeviceInfoResponse``.
|
||||
The only preparatory mutation is the observed clock/timezone sync.
|
||||
"""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
||||
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
||||
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
||||
raise ApplicationBootstrapError("epoch_seconds is outside recovered int64 range")
|
||||
_validate_text(timezone_name, "timezone_name")
|
||||
|
||||
specs = (
|
||||
(
|
||||
"identity-discovery",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
"ModelingStatusRequest",
|
||||
MODELING_STATUS_REQUEST_TOPIC,
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceConfigRequest",
|
||||
DEVICE_CONFIG_REQUEST_TOPIC,
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_TIME_CONTEXT,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetNtripProfileRequest",
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC,
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetCloudServerConfigRequest",
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC,
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
)
|
||||
requests: list[EncodedApplicationRequest] = []
|
||||
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
|
||||
specs, start=1
|
||||
):
|
||||
request_binding = None if ordinal <= 3 else binding
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=message_type,
|
||||
authority=authority,
|
||||
binding=request_binding,
|
||||
context=context,
|
||||
)
|
||||
body = b""
|
||||
if message_type == "DeviceConfigRequest":
|
||||
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
|
||||
body = _bytes_field(4, time_config)
|
||||
payload = _bytes_field(1, _encode_header(header)) + body
|
||||
_check_payload(payload, "application request")
|
||||
requests.append(
|
||||
EncodedApplicationRequest(
|
||||
ordinal=ordinal,
|
||||
phase=phase, # type: ignore[arg-type]
|
||||
message_type=message_type,
|
||||
topic=topic,
|
||||
response_topic=response_topic,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=mutates,
|
||||
requires_live_binding=ordinal > 3,
|
||||
)
|
||||
)
|
||||
return ShadowApplicationBootstrap(tuple(requests))
|
||||
|
||||
|
||||
def decode_and_bind_device_info_response(
|
||||
payload: bytes,
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> DeviceInfoResponse:
|
||||
"""Correlate the bootstrap response and derive a live per-device binding."""
|
||||
|
||||
_check_payload(payload, "DeviceInfoResponse")
|
||||
top = _unique_fields(payload, "DeviceInfoResponse", max_fields=64)
|
||||
header = _unique_fields(_required_bytes(top, 1, "response.header"), "header", max_fields=32)
|
||||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if session_id != ":DeviceInfoRequest":
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
|
||||
|
||||
error = _unique_fields(_required_bytes(top, 15, "response.error"), "error", max_fields=8)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise ApplicationBootstrapError(
|
||||
f"DeviceInfoResponse rejected with result code {result_code}"
|
||||
)
|
||||
|
||||
device_info = _unique_fields(
|
||||
_required_bytes(top, 2, "response.device_info"), "device_info", max_fields=32
|
||||
)
|
||||
base_info = _unique_fields(
|
||||
_required_bytes(device_info, 2, "device_info.base_info"),
|
||||
"base_info",
|
||||
max_fields=32,
|
||||
allow_repeated={11},
|
||||
)
|
||||
working_status = _unique_fields(
|
||||
_required_bytes(device_info, 7, "device_info.working_status"),
|
||||
"working_status",
|
||||
max_fields=16,
|
||||
)
|
||||
binding = LiveDeviceControlBinding(
|
||||
vendor_device_id=device_id,
|
||||
device_serial=_required_text(base_info, 7, "base_info.device_sn"),
|
||||
software_version=_required_text(base_info, 2, "base_info.software_version"),
|
||||
system_version=_required_text(base_info, 3, "base_info.system_version"),
|
||||
device_model=_required_text(base_info, 6, "base_info.device_model"),
|
||||
device_type=_required_text(base_info, 8, "base_info.device_type"),
|
||||
is_activated=bool(_required_bool(working_status, 1, "working_status.is_activated")),
|
||||
)
|
||||
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def _encode_header(header: ApplicationRequestHeader) -> bytes:
|
||||
parts: list[bytes] = []
|
||||
if header.binding is not None:
|
||||
parts.append(_text_field(4, header.binding.vendor_device_id))
|
||||
parts.extend(
|
||||
(
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, header.authority.openapi_key),
|
||||
)
|
||||
)
|
||||
payload = b"".join(parts)
|
||||
if len(payload) > MAX_HEADER_BYTES:
|
||||
raise ApplicationBootstrapError("encoded request header exceeds configured limit")
|
||||
return payload
|
||||
|
||||
|
||||
def _contains_version(value: str, expected: str) -> bool:
|
||||
return re.search(rf"(?<!\d){re.escape(expected)}(?!\d)", value) is not None
|
||||
|
||||
|
||||
def _validate_ascii_identity(value: object, name: str) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
|
||||
try:
|
||||
encoded = value.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ApplicationBootstrapError(f"{name} must use printable ASCII") from exc
|
||||
if len(encoded) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise ApplicationBootstrapError(f"{name} must use printable ASCII without spaces")
|
||||
|
||||
|
||||
def _validate_text(value: object, name: str) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
|
||||
encoded = value.encode("utf-8")
|
||||
if len(encoded) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
|
||||
raise ApplicationBootstrapError(f"{name} contains a control character")
|
||||
|
||||
|
||||
def _check_payload(payload: bytes, name: str) -> None:
|
||||
if len(payload) > MAX_APPLICATION_PAYLOAD_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
|
||||
|
||||
def _unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
max_fields: int,
|
||||
allow_repeated: set[int] | None = None,
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
repeated = allow_repeated or set()
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number in result and item.number not in repeated:
|
||||
raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated")
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_text(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
raw = _required_bytes(fields, number, name)
|
||||
if len(raw) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ApplicationBootstrapError(f"{name} is not valid UTF-8") from exc
|
||||
_validate_text(value, name)
|
||||
return value
|
||||
|
||||
|
||||
def _identity_field(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
value = _required_text(fields, number, name)
|
||||
_validate_ascii_identity(value, name)
|
||||
return value
|
||||
|
||||
|
||||
def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 0 or not isinstance(item.value, int):
|
||||
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_bool(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
value = _required_uint(fields, number, name)
|
||||
if value not in (0, 1):
|
||||
raise ApplicationBootstrapError(f"{name} is not a protobuf boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _key(number: int, wire_type: int) -> bytes:
|
||||
return _varint((number << 3) | wire_type)
|
||||
|
||||
|
||||
def _varint_field(number: int, value: int) -> bytes:
|
||||
return _key(number, 0) + _varint(value)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
return _key(number, 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8"))
|
||||
|
|
@ -5,12 +5,15 @@ import threading
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
CommandHeaderIdentity,
|
||||
DeviceStatusReport,
|
||||
EncodedModelingCommand,
|
||||
ModelingAction,
|
||||
ModelingEncodeError,
|
||||
MountType,
|
||||
RecordMode,
|
||||
ScanMode,
|
||||
|
|
@ -25,59 +28,12 @@ from k1link.viewer.metrics import BridgeMetrics
|
|||
DEVICE_STATUS_TOPIC = "lixel/application/report/device_status"
|
||||
MODELING_REQUEST_TOPIC = "lixel/application/request/modeling"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
CONTROL_ENROLLMENT_SOURCE = "owner-captured-device-bound"
|
||||
CONTROL_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
CONTROL_FIRMWARE_VERSION = "3.0.2"
|
||||
CONTROL_TOPOLOGY = "direct-lan"
|
||||
|
||||
|
||||
class ModelingControlSafetyError(RuntimeError):
|
||||
"""A live K1 could not be bound to reviewed device-specific control input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceBoundControlEnrollment:
|
||||
"""Private, device-specific values learned from owner-controlled evidence.
|
||||
|
||||
Mission Core's provisional device UUID, BLE transport UUID and K1 serial are
|
||||
not interchangeable with the vendor header identity. An enrollment binds
|
||||
all three vendor inputs explicitly and keeps them out of repr/API output.
|
||||
"""
|
||||
|
||||
vendor_device_id: str = field(repr=False)
|
||||
device_serial: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
source: Literal["owner-captured-device-bound"] = "owner-captured-device-bound"
|
||||
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
|
||||
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
)
|
||||
firmware_version: Literal["3.0.2"] = "3.0.2"
|
||||
topology: Literal["direct-lan"] = "direct-lan"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.source != CONTROL_ENROLLMENT_SOURCE
|
||||
or self.compatibility_profile_id != CONTROL_COMPATIBILITY_PROFILE_ID
|
||||
or self.firmware_version != CONTROL_FIRMWARE_VERSION
|
||||
or self.topology != CONTROL_TOPOLOGY
|
||||
):
|
||||
raise ModelingControlSafetyError(
|
||||
"control enrollment does not match the exact reviewed K1 profile"
|
||||
)
|
||||
CommandHeaderIdentity(
|
||||
device_id=self.vendor_device_id,
|
||||
openapi_key=self.openapi_key,
|
||||
)
|
||||
_validate_device_serial(self.device_serial)
|
||||
|
||||
@property
|
||||
def command_header(self) -> CommandHeaderIdentity:
|
||||
return CommandHeaderIdentity(
|
||||
device_id=self.vendor_device_id,
|
||||
openapi_key=self.openapi_key,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelingControlSafetySnapshot:
|
||||
status_reports_observed: int
|
||||
|
|
@ -215,13 +171,14 @@ class LiveModelingControlSafety:
|
|||
|
||||
def plan_start(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
project_name: str,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.READY, project_bound=False)
|
||||
self._require_bound(binding, required_state=SessionState.READY, project_bound=False)
|
||||
command = encode_modeling_start(
|
||||
enrollment.command_header,
|
||||
_command_header(authority, binding),
|
||||
project_name=project_name,
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
|
|
@ -231,14 +188,17 @@ class LiveModelingControlSafety:
|
|||
|
||||
def plan_stop(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.SCANNING, project_bound=True)
|
||||
return ShadowModelingCommand.from_command(encode_modeling_stop(enrollment.command_header))
|
||||
self._require_bound(binding, required_state=SessionState.SCANNING, project_bound=True)
|
||||
return ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(_command_header(authority, binding))
|
||||
)
|
||||
|
||||
def _require_bound(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
required_state: SessionState,
|
||||
project_bound: bool,
|
||||
|
|
@ -250,11 +210,15 @@ class LiveModelingControlSafety:
|
|||
if report is None or self._vendor_device_id is None or self._device_serial is None:
|
||||
raise ModelingControlSafetyError("live K1 identity/status evidence is missing")
|
||||
if (
|
||||
enrollment.vendor_device_id != self._vendor_device_id
|
||||
or enrollment.device_serial != self._device_serial
|
||||
binding.vendor_device_id != self._vendor_device_id
|
||||
or binding.device_serial != self._device_serial
|
||||
):
|
||||
raise ModelingControlSafetyError(
|
||||
"device-bound control enrollment does not match the live K1"
|
||||
"DeviceInfo binding does not match the live K1 status stream"
|
||||
)
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ModelingControlSafetyError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if report.session_state is not required_state:
|
||||
raise ModelingControlSafetyError(
|
||||
|
|
@ -264,12 +228,11 @@ class LiveModelingControlSafety:
|
|||
raise ModelingControlSafetyError("live K1 project binding is not in the safe state")
|
||||
|
||||
|
||||
def _validate_device_serial(value: object) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ModelingEncodeError("device_serial must be a non-empty string")
|
||||
try:
|
||||
encoded = value.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ModelingEncodeError("device_serial must use printable ASCII") from exc
|
||||
if len(encoded) > 256 or any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise ModelingEncodeError("device_serial must use bounded printable ASCII without spaces")
|
||||
def _command_header(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> CommandHeaderIdentity:
|
||||
return CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=authority.openapi_key,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
build_shadow_bootstrap,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
||||
|
||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _uint(number: int, value: int) -> bytes:
|
||||
return _varint(number << 3) + _varint(value)
|
||||
|
||||
|
||||
def _bytes(number: int, value: bytes) -> bytes:
|
||||
return _varint((number << 3) | 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text(number: int, value: str) -> bytes:
|
||||
return _bytes(number, value.encode())
|
||||
|
||||
|
||||
def _authority() -> ApplicationControlAuthority:
|
||||
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
|
||||
|
||||
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
values = {
|
||||
"vendor_device_id": VENDOR_DEVICE_ID,
|
||||
"device_serial": "K1SERIAL01",
|
||||
"software_version": "V3.0.2-20260101-release",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "K1",
|
||||
"is_activated": True,
|
||||
}
|
||||
values.update(changes)
|
||||
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
base_info = b"".join(
|
||||
(
|
||||
_text(1, "2026-01-01T00:00:00"),
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(4, "V1.2.3"),
|
||||
_text(5, "V1.0"),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, "K1"),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1) + _uint(2, 0)
|
||||
device_info = _bytes(2, base_info) + _bytes(7, working_status)
|
||||
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
||||
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
||||
|
||||
|
||||
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
_binding(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
assert [request.message_type for request in plan.requests] == [
|
||||
"DeviceInfoRequest",
|
||||
"ModelingStatusRequest",
|
||||
"GetRtkAdvanceRequest",
|
||||
"DeviceConfigRequest",
|
||||
"DeviceInfoRequest",
|
||||
"GetRtkAdvanceRequest",
|
||||
"GetNtripProfileRequest",
|
||||
"GetCloudServerConfigRequest",
|
||||
"GetRtkAdvanceRequest",
|
||||
"DeviceInfoRequest",
|
||||
]
|
||||
assert [request.payload_bytes for request in plan.requests] == [
|
||||
60,
|
||||
64,
|
||||
63,
|
||||
195,
|
||||
135,
|
||||
138,
|
||||
140,
|
||||
145,
|
||||
138,
|
||||
135,
|
||||
]
|
||||
assert sum(request.mutates_device for request in plan.requests) == 1
|
||||
assert plan.requests[3].mutates_device
|
||||
assert plan.executable is False
|
||||
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
||||
assert plan.retry_policy == "never-automatic"
|
||||
assert APPLICATION_KEY not in repr(plan)
|
||||
assert APPLICATION_KEY not in str(plan.as_dict())
|
||||
|
||||
|
||||
def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None:
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
_binding(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
first_header = next(iter_fields(plan.requests[0].payload)).value
|
||||
assert isinstance(first_header, bytes)
|
||||
first_fields = {field.number: field.value for field in iter_fields(first_header)}
|
||||
assert 4 not in first_fields
|
||||
assert first_fields[5] == b":DeviceInfoRequest"
|
||||
|
||||
config_header = next(iter_fields(plan.requests[3].payload)).value
|
||||
assert isinstance(config_header, bytes)
|
||||
config_fields = {field.number: field.value for field in iter_fields(config_header)}
|
||||
assert config_fields[4] == VENDOR_DEVICE_ID.encode()
|
||||
assert (
|
||||
config_fields[5]
|
||||
== (f"{VENDOR_DEVICE_ID}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime").encode()
|
||||
)
|
||||
|
||||
|
||||
def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None:
|
||||
response = decode_and_bind_device_info_response(_device_info_response(), _authority())
|
||||
|
||||
assert response.result_code == OPENAPI_SUCCESS
|
||||
assert response.binding.ready_for_reviewed_profile
|
||||
assert response.binding.matches_reviewed_firmware
|
||||
assert response.binding.is_activated
|
||||
assert VENDOR_DEVICE_ID not in repr(response)
|
||||
assert APPLICATION_KEY not in repr(response)
|
||||
|
||||
|
||||
def test_device_info_response_correlation_and_profile_attestation_fail_closed() -> None:
|
||||
with pytest.raises(ApplicationBootstrapError, match="session correlation"):
|
||||
decode_and_bind_device_info_response(
|
||||
_device_info_response(session_id=":AnotherRequest"),
|
||||
_authority(),
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_shadow_bootstrap(
|
||||
_authority(),
|
||||
_binding(system_version="V3.0.3"),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
|
@ -2,6 +2,10 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
MODELING_STATE_BASE,
|
||||
ModelingAction,
|
||||
|
|
@ -9,7 +13,6 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
|||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
DEVICE_STATUS_TOPIC,
|
||||
DeviceBoundControlEnrollment,
|
||||
LiveModelingControlSafety,
|
||||
ModelingControlSafetyError,
|
||||
)
|
||||
|
|
@ -61,14 +64,22 @@ def _status_message(
|
|||
)
|
||||
|
||||
|
||||
def _enrollment(**changes: str) -> DeviceBoundControlEnrollment:
|
||||
def _authority() -> ApplicationControlAuthority:
|
||||
return ApplicationControlAuthority(openapi_key="application-private-key")
|
||||
|
||||
|
||||
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
values = {
|
||||
"vendor_device_id": "vendor-device-001",
|
||||
"device_serial": "serial-001",
|
||||
"openapi_key": "device-bound-private-key",
|
||||
"software_version": "V3.0.2-build.1",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "K1",
|
||||
"is_activated": True,
|
||||
}
|
||||
values.update(changes)
|
||||
return DeviceBoundControlEnrollment(**values)
|
||||
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_live_status_identity_is_observed_without_accepting_replay() -> None:
|
||||
|
|
@ -95,7 +106,7 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
|||
safety = LiveModelingControlSafety()
|
||||
safety.observe(_status_message(SessionState.READY), BridgeMetrics())
|
||||
|
||||
plan = safety.plan_start(_enrollment(), project_name="SAFE_PROJECT")
|
||||
plan = safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
||||
fields = {field.number: field for field in iter_fields(plan.command.payload, max_fields=16)}
|
||||
|
||||
assert plan.action is ModelingAction.START
|
||||
|
|
@ -109,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
|||
assert plan.executable is False
|
||||
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
||||
assert plan.retry_policy == "never-automatic"
|
||||
assert "device-bound-private-key" not in repr(plan)
|
||||
assert "device-bound-private-key" not in str(plan.as_dict())
|
||||
assert "application-private-key" not in repr(plan)
|
||||
assert "application-private-key" not in str(plan.as_dict())
|
||||
|
||||
|
||||
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
||||
|
|
@ -120,13 +131,13 @@ def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None
|
|||
BridgeMetrics(),
|
||||
)
|
||||
|
||||
plan = safety.plan_stop(_enrollment())
|
||||
plan = safety.plan_stop(_authority(), _binding())
|
||||
fields = list(iter_fields(plan.command.payload, max_fields=8))
|
||||
assert plan.action is ModelingAction.STOP
|
||||
assert [field.number for field in fields] == [1, 2]
|
||||
|
||||
with pytest.raises(ModelingControlSafetyError, match="does not match"):
|
||||
safety.plan_stop(_enrollment(device_serial="another-serial"))
|
||||
safety.plan_stop(_authority(), _binding(device_serial="another-serial"))
|
||||
|
||||
|
||||
def test_identity_drift_and_decode_failure_fail_closed() -> None:
|
||||
|
|
@ -139,7 +150,7 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None:
|
|||
)
|
||||
assert safety.snapshot().identity_conflict
|
||||
with pytest.raises(ModelingControlSafetyError, match="conflicted"):
|
||||
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT")
|
||||
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
||||
|
||||
safety.reset()
|
||||
malformed = StreamMessage(
|
||||
|
|
@ -153,23 +164,31 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None:
|
|||
assert safety.observe(malformed, metrics)
|
||||
assert safety.snapshot().decode_errors == 1
|
||||
with pytest.raises(ModelingControlSafetyError, match="conflicted"):
|
||||
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT")
|
||||
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
||||
|
||||
|
||||
def test_shadow_plan_requires_exact_safe_lifecycle_state() -> None:
|
||||
safety = LiveModelingControlSafety()
|
||||
safety.observe(_status_message(SessionState.SCAN_STARTING), BridgeMetrics())
|
||||
with pytest.raises(ModelingControlSafetyError, match="must be ready"):
|
||||
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT")
|
||||
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
||||
with pytest.raises(ModelingControlSafetyError, match="must be scanning"):
|
||||
safety.plan_stop(_enrollment())
|
||||
safety.plan_stop(_authority(), _binding())
|
||||
|
||||
|
||||
def test_enrollment_rejects_runtime_profile_substitution() -> None:
|
||||
with pytest.raises(ModelingControlSafetyError, match="exact reviewed K1 profile"):
|
||||
DeviceBoundControlEnrollment(
|
||||
vendor_device_id="vendor-device-001",
|
||||
device_serial="serial-001",
|
||||
openapi_key="device-bound-private-key",
|
||||
firmware_version="3.0.3", # type: ignore[arg-type]
|
||||
def test_device_info_binding_rejects_unreviewed_or_inactive_profile() -> None:
|
||||
safety = LiveModelingControlSafety()
|
||||
safety.observe(_status_message(SessionState.READY), BridgeMetrics())
|
||||
|
||||
with pytest.raises(ModelingControlSafetyError, match="does not attest"):
|
||||
safety.plan_start(
|
||||
_authority(),
|
||||
_binding(software_version="V3.0.3"),
|
||||
project_name="SAFE_PROJECT",
|
||||
)
|
||||
with pytest.raises(ModelingControlSafetyError, match="does not attest"):
|
||||
safety.plan_start(
|
||||
_authority(),
|
||||
_binding(is_activated=False),
|
||||
project_name="SAFE_PROJECT",
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue