fix(k1): gate control dialogue on live state
This commit is contained in:
parent
a29b38a0d7
commit
36f0c93d2a
28
README.md
28
README.md
|
|
@ -155,13 +155,26 @@ it together with the orchestrator. A separate, uninstalled physical-acceptance
|
||||||
transport now reproduces MQTT 3.1.1 `clean_session=false`, keepalive 60, exact
|
transport now reproduces MQTT 3.1.1 `clean_session=false`, keepalive 60, exact
|
||||||
response subscriptions, QoS2 completion and the five response-gated batches.
|
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||||
It consumes operation keys before publish and poisons unknown outcomes without
|
It consumes operation keys before publish and poisons unknown outcomes without
|
||||||
retry. An interactive Keychain administration command provisions through
|
retry. Neither component is wired to facade/UI; `vendor_writes_enabled` remains
|
||||||
Apple's hidden prompt without putting the authority in argv. Neither component
|
false. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||||
is wired to facade/UI; `vendor_writes_enabled` remains false. The first
|
the first six bootstrap requests, then failed closed while correlating the third
|
||||||
operator-present physical attempt on 2026-07-18 emitted only the first six
|
response batch; START was not emitted. A second one-shot attempt correlated all
|
||||||
bootstrap requests, then failed closed while correlating the third response
|
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
|
||||||
batch. The K1 remained `READY`; the `ModelingRequest` START was never emitted.
|
the whole exchange into 365 ms and ended the control lifecycle before the
|
||||||
The temporary laboratory Keychain item was deleted after the attempt.
|
retained post-START reads. K1 stayed in `SCAN_STARTING` for about 24 seconds,
|
||||||
|
reported `ALGORITHM_ERROR` twice, emitted no point/pose data and showed steady
|
||||||
|
red until an operator power cycle restored steady green. The collapsed executor
|
||||||
|
is now disabled. The offline acceptance executor maps connection requests 1–6,
|
||||||
|
workspace-entry request 7, project-prompt reads 8–10, user-confirmed START 11,
|
||||||
|
immediate status read 12 and state-gated reads 13–14 onto one continuously owned
|
||||||
|
MQTT session. No captured operator delay is a protocol timer: the final reads
|
||||||
|
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
|
||||||
|
for live `READY` plus physical standby confirmation. Standalone START and STOP
|
||||||
|
are both rejected. The same socket is serviced during every operator/UI wait,
|
||||||
|
and the short START/STOP permits are created only at their respective button
|
||||||
|
confirmations rather than at connection time. This executor remains uninstalled and is not wired to the
|
||||||
|
facade/UI; physical command acceptance is still paused. The temporary laboratory
|
||||||
|
Keychain item was deleted.
|
||||||
|
|
||||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||||
standalone release install. The frontend consumes sibling `file:` packages from
|
standalone release install. The frontend consumes sibling `file:` packages from
|
||||||
|
|
@ -287,6 +300,7 @@ present.
|
||||||
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
|
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
|
||||||
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
|
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
|
||||||
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
|
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
|
||||||
|
- [K1 application-control physical acceptance](docs/lab/003_K1_CONTROL_ACCEPTANCE_20260718.redacted.md)
|
||||||
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
|
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
|
||||||
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
|
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
|
||||||
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
|
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
|
||||||
|
|
|
||||||
|
|
@ -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 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 |
|
| 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 |
|
| 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 (first physical bootstrap failed closed before START) — plugin v0.4.0 runtime remains dormant/write-disabled; the uninstalled acceptance transport completed one connection, six QoS2 bootstrap publishes and five correlated responses, then rejected one live batch-3 response; K1 remained READY, START was not emitted, retry is forbidden and the temporary Keychain item was deleted |
|
| K1 application control | PAUSE (accepted START followed by `ALGORITHM_ERROR`) — first live attempt stopped before START on a batch-3 correlation failure; a second attempt correlated all 10 pre-START requests plus START but collapsed UI stages into 365 ms and omitted the retained post-START/control-session lifecycle. The collapsed path and standalone START/STOP are disabled. An uninstalled offline executor now uses explicit UI checkpoints plus live DeviceStatus gates; no further physical write is authorized yet |
|
||||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment 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
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
|
|
@ -61,7 +61,26 @@ required responses through batch 3, but the orchestrator raised
|
||||||
`ApplicationBootstrapError` while correlating that batch. No START was emitted;
|
`ApplicationBootstrapError` while correlating that batch. No START was emitted;
|
||||||
live DeviceStatus stayed READY and the local point/pose counts remained zero.
|
live DeviceStatus stayed READY and the local point/pose counts remained zero.
|
||||||
The local capture was sealed and the temporary Keychain item was deleted.
|
The local capture was sealed and the temporary Keychain item was deleted.
|
||||||
Physical START/STOP therefore remain untested; the button is the safe fallback.
|
The second physical attempt then correlated all ten bootstrap requests and one
|
||||||
|
START success response. It nevertheless did not reproduce the full LixelGO
|
||||||
|
lifecycle: all eleven publishes completed in 365 ms, the process did not retain
|
||||||
|
the control session through initialization, and it omitted the immediate and
|
||||||
|
post-initialization status reads present in both retained LixelGO START captures.
|
||||||
|
K1 reported `SCAN_STARTING` for about 24 seconds, emitted system error
|
||||||
|
`0x32040133` twice (the recovered `ALGORITHM_ERROR` namespace/value), returned
|
||||||
|
to `OTHER_STATUS`, produced zero point/pose frames and showed steady red. The
|
||||||
|
operator restored steady green through a normal power cycle. The legacy
|
||||||
|
`run_bootstrap()` path now rejects before emission. The offline acceptance path
|
||||||
|
maps requests 1–6 to connection, request 7 to explicit scan-workspace entry,
|
||||||
|
requests 8–10 to explicit project-prompt opening and request 11 to the operator's
|
||||||
|
START confirmation. Captured human delays are not replayed or treated as minimum
|
||||||
|
timeouts. After the immediate operation-12 read, operations 13–14 wait for the
|
||||||
|
same bound K1 to report `SCANNING`, a bound project and `init_ready=true`.
|
||||||
|
The same transport is then serviced until an explicit STOP request; post-STOP
|
||||||
|
ownership waits for live `READY` plus physical standby confirmation. System-error
|
||||||
|
and status reports are decoded into redacted safety state. This path is not
|
||||||
|
wired into the plugin runtime/UI and has only synthetic regression coverage;
|
||||||
|
physical STOP/save remains untested and the button is the safe fallback.
|
||||||
|
|
||||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
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;
|
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
||||||
|
|
@ -280,8 +299,9 @@ useful stream is decoded or structurally identified.
|
||||||
- firmware or internal-Linux analysis;
|
- firmware or internal-Linux analysis;
|
||||||
- physical end-to-end shared-timeline playback for a newly archived session
|
- physical end-to-end shared-timeline playback for a newly archived session
|
||||||
containing point cloud plus one selected K1 camera;
|
containing point cloud plus one selected K1 camera;
|
||||||
- OS-independent plugin/edge authority provider, redacted live-response
|
- OS-independent plugin/edge authority provider, PCAP-to-executor transcript
|
||||||
diagnostics for bootstrap batch 3, a newly permitted operator-present full
|
regression, runtime ownership integration, a separately reviewed and newly
|
||||||
START/STOP attempt and stable-artifact proof after STOP;
|
permitted operator-present START/STOP attempt, and stable-artifact proof after
|
||||||
|
STOP;
|
||||||
- long-running large-session WebViewer/WASM memory telemetry;
|
- long-running large-session WebViewer/WASM memory telemetry;
|
||||||
- production retention, replication, encryption and cross-platform packaging.
|
- production retention, replication, encryption and cross-platform packaging.
|
||||||
|
|
|
||||||
|
|
@ -188,14 +188,31 @@ repository shadow bootstrap reproduces all ten retained payloads and their
|
||||||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||||
other nine requests are reads.
|
other nine requests are reads.
|
||||||
|
|
||||||
The retained request/response timing further divides the dialogue into five
|
The first ten requests use five response-barrier batches, but those barriers are
|
||||||
bounded batches. DeviceInfo response establishes identity before later bound
|
not the whole application lifecycle. Two independent owner-operated START
|
||||||
requests. Required response barriers are correlated by topic, exact session,
|
captures contain the same 14-operation order: the ten requests above, START,
|
||||||
vendor identity, application authority and numeric success. The initial
|
one bound `ModelingStatus` read, then a bound `DeviceInfo` plus
|
||||||
ModelingStatus request is intentionally not treated as a required synchronous
|
`ModelingStatus` refresh about 24–25 seconds after START. The first capture
|
||||||
response: the clean cycle continued without one, and readiness belongs to the
|
reached START 202.423 seconds after its initial DeviceInfo; the faster capture
|
||||||
live DeviceStatus gate. The offline orchestrator completes over the retained
|
reached it after 54.130 seconds. This variation is operator/UI time, while the
|
||||||
payloads without substitution or skipped requests.
|
stage order is invariant. In both captures the original control MQTT connection
|
||||||
|
remained open throughout initialization and the later refresh; no DISCONNECT or
|
||||||
|
UNSUBSCRIBE occurred. DeviceInfo response establishes identity before later
|
||||||
|
bound requests. Required response barriers are correlated by topic, exact
|
||||||
|
session, vendor identity, application authority and numeric success. The
|
||||||
|
initial and immediate post-START ModelingStatus reads are intentionally not
|
||||||
|
treated as mandatory synchronous responses. Readiness belongs to live
|
||||||
|
DeviceStatus plus point/pose observation.
|
||||||
|
|
||||||
|
The UI/event correlation is also fixed by the retained capture. Requests 1–6
|
||||||
|
follow successful data connection. Entering the scan workspace causes request
|
||||||
|
7. Opening the record/project-name prompt causes reads 8–10. The project name is
|
||||||
|
not sent by a separate save mutation: it first appears in the START payload when
|
||||||
|
the operator confirms the prompt. K1 then reports `SCAN_STARTING`; completion is
|
||||||
|
the live transition to `SCANNING` with a bound project and `init_ready=true`.
|
||||||
|
Operations 13–14 coincide with that state in both captures. The observed
|
||||||
|
24–25-second initialization duration is therefore evidence/telemetry, not a
|
||||||
|
hard-coded transition delay.
|
||||||
|
|
||||||
The repository now contains an inert bounded encoder/response parser and a
|
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
|
fail-closed device-status state machine for this exact profile. A live-only
|
||||||
|
|
@ -230,7 +247,32 @@ connection and emitted bootstrap ordinals 1–6. All six QoS 2 publishes complet
|
||||||
and five required responses were collected, but exact correlation rejected one
|
and five required responses were collected, but exact correlation rejected one
|
||||||
response in batch 3. The executor stopped before building or publishing START;
|
response in batch 3. The executor stopped before building or publishing START;
|
||||||
K1 remained `READY`, project binding stayed absent and point/pose remained zero.
|
K1 remained `READY`, project binding stayed absent and point/pose remained zero.
|
||||||
No automatic retry was attempted.
|
No automatic retry was attempted. A second explicitly permitted attempt then
|
||||||
|
correlated all ten bootstrap responses and one START success response, with 11
|
||||||
|
QoS2 completions, but it compressed the exchange into 365 ms and ended without
|
||||||
|
the retained post-START reads or durable control-session ownership. K1 remained
|
||||||
|
in `SCAN_STARTING` for about 24 seconds, emitted two identical
|
||||||
|
`report/system_error` values `0x32040133`, returned to `OTHER_STATUS`, produced
|
||||||
|
no point/pose frames and showed steady red. The bounded system-error decoder maps
|
||||||
|
the `0x32040000 + 307` value to the recovered `ALGORITHM_ERROR` state. A normal
|
||||||
|
operator power cycle restored steady green.
|
||||||
|
|
||||||
|
This disproves the earlier assumption that byte-matched pre-START payloads plus
|
||||||
|
a correlated START acknowledgement are sufficient acceptance. The legacy
|
||||||
|
collapsed `run_bootstrap()` executor now rejects before emission. Its
|
||||||
|
replacement is an uninstalled acceptance-only session owner. It requires the
|
||||||
|
observed connection, workspace-entry and project-prompt checkpoints instead of
|
||||||
|
wall-clock floors, sends the immediate post-START ModelingStatus read, services
|
||||||
|
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
|
||||||
|
DeviceInfo/ModelingStatus refresh. It continues servicing that socket until an
|
||||||
|
explicit STOP request and afterwards until live `READY` plus operator-confirmed
|
||||||
|
standby. System-error and DeviceStatus reports are decoded into redacted safety
|
||||||
|
state; a fault forbids further automatic action. Standalone START/STOP are
|
||||||
|
disabled. MQTT keepalive/report processing also continues while the operator is
|
||||||
|
between UI actions; the 15–120-second command permit is created at the actual
|
||||||
|
START or STOP confirmation, not at connection time. This path is
|
||||||
|
synthetic-test-only and remains outside facade/UI, so it
|
||||||
|
does not authorize a new physical attempt.
|
||||||
|
|
||||||
The one-time Keychain administration command uses `security -w` as the final
|
The one-time Keychain administration command uses `security -w` as the final
|
||||||
argument so Apple's own TTY prompt receives the authority. Mission Core never
|
argument so Apple's own TTY prompt receives the authority. Mission Core never
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,14 @@ K1 application control is exact-profile, live-bound and fail-closed.
|
||||||
3. The BLE-selected transport is followed by an unbound DeviceInfo exchange.
|
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
|
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.
|
device binding; a saved profile is optional metadata, not protocol authority.
|
||||||
4. The ten retained pre-START requests keep their exact order and response
|
4. The complete retained START lifecycle keeps its 14-operation order, stage
|
||||||
boundaries. The only mutation is the observed time/timezone sync; the other
|
boundaries and one continuous control MQTT session. The first ten operations
|
||||||
nine requests are reads. No request is batched, skipped or automatically
|
are pre-START; operation 11 is START; operation 12 is a bound ModelingStatus
|
||||||
retried.
|
read; operations 13–14 are the post-initialization DeviceInfo/ModelingStatus
|
||||||
|
refresh. The only preparatory mutation is time/timezone sync. No stage is
|
||||||
|
collapsed, skipped or automatically retried. Captured wall-clock gaps are
|
||||||
|
operator/UI timing, not protocol delays. Stage progression uses explicit UI
|
||||||
|
actions and live K1 state reports.
|
||||||
5. Live DeviceInfo binding must agree with the live DeviceStatus stream before
|
5. Live DeviceInfo binding must agree with the live DeviceStatus stream before
|
||||||
START/STOP planning. Identity drift, malformed data, inactive equipment,
|
START/STOP planning. Identity drift, malformed data, inactive equipment,
|
||||||
profile mismatch or an unexpected lifecycle state fails closed.
|
profile mismatch or an unexpected lifecycle state fails closed.
|
||||||
|
|
@ -55,7 +59,7 @@ K1 application control is exact-profile, live-bound and fail-closed.
|
||||||
quiescence/evidence sealing, READY plus steady green, then independent native
|
quiescence/evidence sealing, READY plus steady green, then independent native
|
||||||
project verification.
|
project verification.
|
||||||
|
|
||||||
## Recovered pre-START sequence
|
## Recovered START sequence
|
||||||
|
|
||||||
The clean cycle contains:
|
The clean cycle contains:
|
||||||
|
|
||||||
|
|
@ -70,6 +74,9 @@ The clean cycle contains:
|
||||||
9. bound `GetRtkAdvanceRequest`;
|
9. bound `GetRtkAdvanceRequest`;
|
||||||
10. bound `DeviceInfoRequest`;
|
10. bound `DeviceInfoRequest`;
|
||||||
11. bound `ModelingRequest` START.
|
11. bound `ModelingRequest` START.
|
||||||
|
12. bound `ModelingStatusRequest`;
|
||||||
|
13. bound `DeviceInfoRequest` after static initialization;
|
||||||
|
14. bound `ModelingStatusRequest` after static initialization.
|
||||||
|
|
||||||
Normal sessions are `${device_id_or_empty}:${MessageType}`. Time sync uses the
|
Normal sessions are `${device_id_or_empty}:${MessageType}`. Time sync uses the
|
||||||
captured special relation
|
captured special relation
|
||||||
|
|
@ -115,9 +122,20 @@ An unknown post-publish outcome can only be reconciled physically/status-wise;
|
||||||
the same operation key can never be attempted again by that transport.
|
the same operation key can never be attempted again by that transport.
|
||||||
|
|
||||||
The acceptance executor requires a 15–120 second single-action permit backed by
|
The acceptance executor requires a 15–120 second single-action permit backed by
|
||||||
five explicit operator confirmations. START cannot skip the five recovered
|
five explicit operator confirmations. After the failed 2026-07-18 physical
|
||||||
bootstrap batches; STOP uses a separate permit. These types are not imported by
|
attempt proved that five response batches alone are insufficient, the collapsed
|
||||||
the facade, declared as plugin actions or represented by UI controls.
|
bootstrap entry point and standalone START/STOP were disabled. The uninstalled
|
||||||
|
replacement binds requests 1–6 to connection, request 7 to scan-workspace entry,
|
||||||
|
requests 8–10 to project-prompt entry and START to a separate operator permit.
|
||||||
|
There is no separate project-name mutation; the validated name is carried by
|
||||||
|
START. Operations 13–14 are gated by bound `SCANNING`, project presence and
|
||||||
|
`init_ready`, not by the observed 24–25-second duration. The same owner services
|
||||||
|
MQTT through explicit STOP and waits for live READY plus operator-confirmed
|
||||||
|
standby. Fault/status observations are redacted and any fault forbids automatic
|
||||||
|
continuation. The socket remains serviced during operator/UI waits, and each
|
||||||
|
short action permit is created at its button confirmation rather than at
|
||||||
|
connection time. These types are synthetic-test-only, not imported by the facade,
|
||||||
|
declared as plugin actions or represented by UI controls.
|
||||||
|
|
||||||
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
|
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
|
||||||
`security add-generic-password ... -w` prompt with `-w` last. The private value
|
`security add-generic-password ... -w` prompt with `-w` last. The private value
|
||||||
|
|
@ -133,10 +151,11 @@ transport:
|
||||||
|
|
||||||
1. provision the reviewed private authority into the fixed local Keychain item;
|
1. provision the reviewed private authority into the fixed local Keychain item;
|
||||||
2. confirm battery/storage and select one K1 over BLE;
|
2. confirm battery/storage and select one K1 over BLE;
|
||||||
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
|
3. run the exact staged response-gated preparation and attest activated FW 3.0.2;
|
||||||
4. confirm READY identity against DeviceInfo and review shadow metadata;
|
4. confirm READY identity against DeviceInfo and review shadow metadata;
|
||||||
5. send one START without retry and correlate its response;
|
5. send one START without retry and correlate its response;
|
||||||
6. observe calibration and first point/pose/camera data;
|
6. preserve the same control MQTT connection, issue the retained post-START
|
||||||
|
reads and observe calibration plus first point/pose/camera data;
|
||||||
7. send one STOP without retry and correlate its response;
|
7. send one STOP without retry and correlate its response;
|
||||||
8. seal local evidence while accepting the bounded stream tail;
|
8. seal local evidence while accepting the bounded stream tail;
|
||||||
9. wait for READY and steady green;
|
9. wait for READY and steady green;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Lab 003 — K1 application-control physical acceptance
|
||||||
|
|
||||||
|
- Date: 2026-07-18
|
||||||
|
- Device scope: one owner-controlled LixelKity K1, firmware `3.0.2`
|
||||||
|
- Result: **PAUSE — START acknowledgement is not lifecycle acceptance**
|
||||||
|
|
||||||
|
This report contains no device identity, application authority, address,
|
||||||
|
credential, raw MQTT payload or project contents. Private evidence remains in
|
||||||
|
ignored session storage.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
The operator was present and confirmed ownership, steady-green standby,
|
||||||
|
battery/storage, exact direct-LAN target and closed LixelGO. Every request used
|
||||||
|
QoS 2, retain false, one connection attempt and no automatic retry. The local
|
||||||
|
point/pose receiver was read-only and independent of the control client.
|
||||||
|
|
||||||
|
## Attempt 1
|
||||||
|
|
||||||
|
The control client emitted bootstrap ordinals 1–6. All six QoS2 transactions
|
||||||
|
completed and five required responses arrived, but the batch-3 response did not
|
||||||
|
pass exact correlation. The executor stopped before START. K1 remained READY
|
||||||
|
and steady green; point/pose counts stayed zero.
|
||||||
|
|
||||||
|
## Attempt 2
|
||||||
|
|
||||||
|
After adding redacted live-response diagnostics, one new explicit permit was
|
||||||
|
used. All ten pre-START requests and START completed QoS2; all required
|
||||||
|
responses, including the START response, passed identity/session/action/result
|
||||||
|
correlation. The eleven publishes completed in approximately `365 ms`.
|
||||||
|
|
||||||
|
The device then reported `SCAN_STARTING` for approximately 24 seconds. No point
|
||||||
|
cloud or pose message arrived. It emitted two identical
|
||||||
|
`lixel/application/report/system_error` messages with code `0x32040133`, reset
|
||||||
|
to `OTHER_STATUS` without a project binding and showed a steady-red indicator.
|
||||||
|
The bounded decoder maps the observed namespace/value as
|
||||||
|
`0x32040000 + 307 = ALGORITHM_ERROR`. A normal operator power cycle restored
|
||||||
|
steady green. No guessed STOP or repeated START was sent.
|
||||||
|
|
||||||
|
## Canonical-diff finding
|
||||||
|
|
||||||
|
Re-audit of two independent owner-operated LixelGO START captures found the
|
||||||
|
same 14-operation order in both:
|
||||||
|
|
||||||
|
1. unbound DeviceInfo;
|
||||||
|
2. unbound ModelingStatus;
|
||||||
|
3. unbound GetRtkAdvance;
|
||||||
|
4. bound time/timezone DeviceConfig;
|
||||||
|
5. bound DeviceInfo;
|
||||||
|
6. bound GetRtkAdvance;
|
||||||
|
7. bound GetNtripProfile;
|
||||||
|
8. bound GetCloudServerConfig;
|
||||||
|
9. bound GetRtkAdvance;
|
||||||
|
10. bound DeviceInfo;
|
||||||
|
11. bound START;
|
||||||
|
12. bound ModelingStatus;
|
||||||
|
13. bound DeviceInfo after initialization;
|
||||||
|
14. bound ModelingStatus after initialization.
|
||||||
|
|
||||||
|
The first retained capture reached START at `+202.423 s`; the faster one at
|
||||||
|
`+54.130 s`. This is variable operator/UI time, not a fixed scanner timeout.
|
||||||
|
Both preserved the stage order, sent operation 12 within about one second of
|
||||||
|
START, sent operations 13–14 about 24–25 seconds after START and kept the same
|
||||||
|
control MQTT connection open throughout. Neither capture disconnected or
|
||||||
|
unsubscribed during initialization.
|
||||||
|
|
||||||
|
The same re-audit shows that those wall-clock gaps are not protocol timers.
|
||||||
|
Requests 1–6 follow connection, request 7 follows explicit workspace entry,
|
||||||
|
requests 8–10 follow opening the project prompt, and START follows the operator's
|
||||||
|
confirmation. No separate project-name save request exists; the name first
|
||||||
|
appears in START. In both captures operations 13–14 coincide with K1's own
|
||||||
|
`SCANNING`, bound-project and `init_ready=true` DeviceStatus transition.
|
||||||
|
|
||||||
|
Attempt 2 matched the first eleven payload/topic operations but violated the
|
||||||
|
larger lifecycle: it collapsed the UI stages, omitted operations 12–14 and did
|
||||||
|
not preserve a long-lived control-session owner. The evidence does not prove
|
||||||
|
which individual deviation caused the device algorithm failure, so none is
|
||||||
|
treated as the sole cause.
|
||||||
|
|
||||||
|
## Code decision
|
||||||
|
|
||||||
|
The legacy collapsed `run_bootstrap()` executor now rejects before emission.
|
||||||
|
The replacement acceptance-only executor uses explicit connection/workspace/
|
||||||
|
project-prompt checkpoints with no captured-delay replay. It owns operations
|
||||||
|
1–14, gates the final refresh on live K1 state, surfaces redacted system errors,
|
||||||
|
keeps the same socket serviced through explicit STOP and waits for READY plus
|
||||||
|
operator-confirmed standby. The socket is also serviced between every UI action;
|
||||||
|
START/STOP permits begin only at their respective confirmations. Standalone
|
||||||
|
START/STOP are rejected. This remains an
|
||||||
|
uninstalled synthetic-test-only boundary; no new physical attempt is authorized
|
||||||
|
by this implementation. Physical double-click remains the canonical fallback.
|
||||||
|
|
@ -5,13 +5,18 @@ import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Collection, Sequence
|
from collections.abc import Callable, Collection, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal, Protocol
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
ApplicationBootstrapError,
|
ApplicationBootstrapError,
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
CanonicalPostStartObservation,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
ShadowApplicationBootstrapOrchestrator,
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
correlate_application_response,
|
||||||
|
decode_and_bind_device_info_response,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
MODELING_RESPONSE_TOPIC,
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
|
@ -32,6 +37,9 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
MIN_ACCEPTANCE_PERMIT_SECONDS = 15.0
|
MIN_ACCEPTANCE_PERMIT_SECONDS = 15.0
|
||||||
MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
||||||
|
|
||||||
|
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class ApplicationAcceptanceError(RuntimeError):
|
class ApplicationAcceptanceError(RuntimeError):
|
||||||
"""The operator-present physical acceptance contract failed closed."""
|
"""The operator-present physical acceptance contract failed closed."""
|
||||||
|
|
@ -45,6 +53,21 @@ class ApplicationBatchExchange(Protocol):
|
||||||
required_response_topics: Collection[str],
|
required_response_topics: Collection[str],
|
||||||
) -> dict[str, bytes]: ...
|
) -> dict[str, bytes]: ...
|
||||||
|
|
||||||
|
def maintain_open_for(
|
||||||
|
self,
|
||||||
|
duration_seconds: float,
|
||||||
|
*,
|
||||||
|
allowed_response_topics: Collection[str] = (),
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def scan_initialization_complete(self, binding: LiveDeviceControlBinding) -> bool: ...
|
||||||
|
|
||||||
|
def pre_start_ready(self, binding: LiveDeviceControlBinding) -> bool: ...
|
||||||
|
|
||||||
|
def standby_complete(self, binding: LiveDeviceControlBinding) -> bool: ...
|
||||||
|
|
||||||
|
def validate_bound_status(self, binding: LiveDeviceControlBinding) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PhysicalAcceptanceChecklist:
|
class PhysicalAcceptanceChecklist:
|
||||||
|
|
@ -71,6 +94,27 @@ class PhysicalAcceptanceChecklist:
|
||||||
raise ApplicationAcceptanceError("acceptance action must be an explicit ModelingAction")
|
raise ApplicationAcceptanceError("acceptance action must be an explicit ModelingAction")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperatorDialogueCheckpoint:
|
||||||
|
"""One explicit LixelGO-equivalent UI transition; never a wall-clock gate."""
|
||||||
|
|
||||||
|
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"]
|
||||||
|
operator_initiated: Literal[True]
|
||||||
|
owner_token: object = field(repr=False, compare=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.event not in {
|
||||||
|
"workspace-entered",
|
||||||
|
"project-prompt-opened",
|
||||||
|
"start-confirmed",
|
||||||
|
}:
|
||||||
|
raise ApplicationAcceptanceError("operator dialogue checkpoint is unknown")
|
||||||
|
if self.operator_initiated is not True:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"operator dialogue checkpoint must be explicitly initiated"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PhysicalAcceptancePermit:
|
class PhysicalAcceptancePermit:
|
||||||
"""Short, single-action capability that is consumed before MQTT publish."""
|
"""Short, single-action capability that is consumed before MQTT publish."""
|
||||||
|
|
||||||
|
|
@ -122,17 +166,37 @@ class PhysicalAcceptancePermit:
|
||||||
|
|
||||||
|
|
||||||
class PhysicalAcceptanceDialogueExecutor:
|
class PhysicalAcceptanceDialogueExecutor:
|
||||||
"""Drive the recovered barriers and exactly one permitted START or STOP."""
|
"""Drive only explicitly staged portions of the recovered K1 dialogue.
|
||||||
|
|
||||||
|
The legacy implementation ran all ten pre-START reads/mutations in one
|
||||||
|
tight loop. That preserved topic order but did not preserve the observed
|
||||||
|
LixelGO lifecycle. The staged methods below intentionally expose the
|
||||||
|
connection, scan-workspace and project-prompt UI boundaries. They do not
|
||||||
|
replay wall-clock gaps from the operator capture. The canonical START/STOP
|
||||||
|
methods keep the same transport owner from operation 1 through the
|
||||||
|
operator-confirmed post-STOP standby boundary. The generic one-command
|
||||||
|
entry point remains disabled so START and STOP cannot be emitted by two
|
||||||
|
unrelated one-shot processes.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transport: ApplicationBatchExchange,
|
transport: ApplicationBatchExchange,
|
||||||
permit: PhysicalAcceptancePermit,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._permit = permit
|
|
||||||
self._bootstrap_complete = False
|
self._bootstrap_complete = False
|
||||||
self._command_complete = False
|
self._command_complete = False
|
||||||
|
self._dialogue_stage = "new"
|
||||||
|
self._start_complete = False
|
||||||
|
self._stop_attempted = False
|
||||||
|
self._stop_complete = False
|
||||||
|
self._active_authority: ApplicationControlAuthority | None = None
|
||||||
|
self._active_binding: LiveDeviceControlBinding | None = None
|
||||||
|
self._prepared_binding: LiveDeviceControlBinding | None = None
|
||||||
|
self._checkpoint_owner = object()
|
||||||
|
self._issued_checkpoint: str | None = None
|
||||||
|
self._start_permit_snapshot: dict[str, object] | None = None
|
||||||
|
self._stop_permit_snapshot: dict[str, object] | None = None
|
||||||
self._response_evidence: list[dict[str, object]] = []
|
self._response_evidence: list[dict[str, object]] = []
|
||||||
self._correlation_failure: dict[str, object] | None = None
|
self._correlation_failure: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
@ -140,60 +204,138 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
self,
|
self,
|
||||||
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
) -> LiveDeviceControlBinding:
|
) -> LiveDeviceControlBinding:
|
||||||
if self._bootstrap_complete or self._command_complete:
|
del orchestrator
|
||||||
raise ApplicationAcceptanceError("physical acceptance bootstrap was already attempted")
|
|
||||||
if self._permit.action is not ModelingAction.START:
|
|
||||||
raise ApplicationAcceptanceError("bootstrap is admitted only by a START permit")
|
|
||||||
|
|
||||||
while not orchestrator.snapshot().bootstrap_complete:
|
|
||||||
batch = orchestrator.next_batch()
|
|
||||||
required_topics = {
|
|
||||||
request.response_topic for request in batch if request.response_required
|
|
||||||
}
|
|
||||||
responses = self._transport.exchange_batch_once(
|
|
||||||
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
|
||||||
required_response_topics=required_topics,
|
|
||||||
)
|
|
||||||
for request in batch:
|
|
||||||
if request.response_required:
|
|
||||||
payload = responses[request.response_topic]
|
|
||||||
self._record_response_evidence(
|
|
||||||
phase="bootstrap",
|
|
||||||
operation_key=(
|
|
||||||
f"bootstrap:{request.ordinal}:{request.message_type}"
|
|
||||||
),
|
|
||||||
response_topic=request.response_topic,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
orchestrator.accept_response(request.response_topic, payload)
|
|
||||||
except ApplicationBootstrapError as exc:
|
|
||||||
self._record_correlation_failure(
|
|
||||||
phase="bootstrap",
|
|
||||||
operation_key=(
|
|
||||||
f"bootstrap:{request.ordinal}:{request.message_type}"
|
|
||||||
),
|
|
||||||
response_topic=request.response_topic,
|
|
||||||
reason=str(exc),
|
|
||||||
)
|
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
"bootstrap response correlation failed for "
|
"collapsed bootstrap is disabled; use the canonical staged dialogue"
|
||||||
f"ordinal {request.ordinal} ({request.message_type}): {exc}"
|
)
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
"""Emit retained ordinals 1-6 at control-session establishment."""
|
||||||
|
|
||||||
|
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
|
||||||
|
raise ApplicationAcceptanceError("connection stage is not admissible now")
|
||||||
|
for expected_batch in (1, 2, 3):
|
||||||
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
|
||||||
binding = orchestrator.binding
|
binding = orchestrator.binding
|
||||||
if binding is None:
|
if binding is None:
|
||||||
raise ApplicationAcceptanceError("bootstrap completed without a live device binding")
|
raise ApplicationAcceptanceError("connection stage produced no live device binding")
|
||||||
|
self._prepared_binding = binding
|
||||||
|
self._dialogue_stage = "connection-ready"
|
||||||
|
return binding
|
||||||
|
|
||||||
|
def wait_for_operator_checkpoint(
|
||||||
|
self,
|
||||||
|
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"],
|
||||||
|
event_observed: Callable[[], bool],
|
||||||
|
) -> OperatorDialogueCheckpoint:
|
||||||
|
"""Service the original socket until one exact operator UI event occurs."""
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"connection-ready": "workspace-entered",
|
||||||
|
"workspace-ready": "project-prompt-opened",
|
||||||
|
"project-ready": "start-confirmed",
|
||||||
|
}.get(self._dialogue_stage)
|
||||||
|
if event != expected or self._issued_checkpoint is not None:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"operator checkpoint does not match the canonical dialogue stage"
|
||||||
|
)
|
||||||
|
binding = self._prepared_binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("canonical preparation binding is unavailable")
|
||||||
|
while not (
|
||||||
|
self._transport.pre_start_ready(binding) and event_observed()
|
||||||
|
):
|
||||||
|
self._transport.maintain_open_for(
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
self._transport.validate_bound_status(binding)
|
||||||
|
self._issued_checkpoint = event
|
||||||
|
return OperatorDialogueCheckpoint(
|
||||||
|
event=event,
|
||||||
|
operator_initiated=True,
|
||||||
|
owner_token=self._checkpoint_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_workspace_entry_stage(
|
||||||
|
self,
|
||||||
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
|
checkpoint: OperatorDialogueCheckpoint,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
"""Emit ordinal 7 only for the observed scan-workspace entry action."""
|
||||||
|
|
||||||
|
if self._dialogue_stage != "connection-ready":
|
||||||
|
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
|
||||||
|
self._consume_checkpoint(checkpoint, expected="workspace-entered")
|
||||||
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||||
|
binding = orchestrator.binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
|
||||||
|
self._dialogue_stage = "workspace-ready"
|
||||||
|
return binding
|
||||||
|
|
||||||
|
def run_project_prompt_stage(
|
||||||
|
self,
|
||||||
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
|
checkpoint: OperatorDialogueCheckpoint,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
"""Emit ordinals 8-10 when the operator opens the project-name prompt."""
|
||||||
|
|
||||||
|
if self._dialogue_stage != "workspace-ready":
|
||||||
|
raise ApplicationAcceptanceError("project prompt requires workspace entry")
|
||||||
|
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
|
||||||
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=5)
|
||||||
|
if not orchestrator.snapshot().bootstrap_complete:
|
||||||
|
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
|
||||||
|
binding = orchestrator.binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("project prompt lost the live device binding")
|
||||||
self._bootstrap_complete = True
|
self._bootstrap_complete = True
|
||||||
|
self._dialogue_stage = "project-ready"
|
||||||
return binding
|
return binding
|
||||||
|
|
||||||
def execute_modeling(self, command: ShadowModelingCommand) -> ModelingResponse:
|
def execute_modeling(self, command: ShadowModelingCommand) -> ModelingResponse:
|
||||||
|
del command
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"standalone modeling commands are disabled; use one canonical START-to-STOP session"
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_canonical_start(
|
||||||
|
self,
|
||||||
|
command: ShadowModelingCommand,
|
||||||
|
post_start: CanonicalPostStartObservation,
|
||||||
|
*,
|
||||||
|
authority: ApplicationControlAuthority,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
permit: PhysicalAcceptancePermit,
|
||||||
|
checkpoint: OperatorDialogueCheckpoint,
|
||||||
|
) -> ModelingResponse:
|
||||||
|
"""Execute retained operations 11-14 on one continuously serviced socket."""
|
||||||
|
|
||||||
|
if command.action is not ModelingAction.START:
|
||||||
|
raise ApplicationAcceptanceError("canonical START executor requires START")
|
||||||
if self._command_complete:
|
if self._command_complete:
|
||||||
raise ApplicationAcceptanceError("physical acceptance command was already attempted")
|
raise ApplicationAcceptanceError("physical acceptance command was already attempted")
|
||||||
if command.action is ModelingAction.START and not self._bootstrap_complete:
|
if not self._bootstrap_complete or self._dialogue_stage != "project-ready":
|
||||||
raise ApplicationAcceptanceError("START requires the complete response-gated bootstrap")
|
raise ApplicationAcceptanceError("START requires the complete staged preparation")
|
||||||
self._permit.consume(command.action)
|
if [request.ordinal for request in post_start.requests] != [12, 13, 14]:
|
||||||
|
raise ApplicationAcceptanceError("post-START transcript ordinals are invalid")
|
||||||
|
self._consume_checkpoint(checkpoint, expected="start-confirmed")
|
||||||
|
self._require_command_identity(command, authority=authority, binding=binding)
|
||||||
|
if not self._transport.pre_start_ready(binding):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"canonical START requires live READY with no bound project"
|
||||||
|
)
|
||||||
|
if permit.action is not ModelingAction.START:
|
||||||
|
raise ApplicationAcceptanceError("canonical START requires a fresh START permit")
|
||||||
|
|
||||||
|
permit.consume(ModelingAction.START)
|
||||||
|
self._start_permit_snapshot = permit.snapshot()
|
||||||
self._command_complete = True
|
self._command_complete = True
|
||||||
|
self._dialogue_stage = "start-attempted"
|
||||||
responses = self._transport.exchange_batch_once(
|
responses = self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||||
|
|
@ -201,29 +343,218 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||||
self._record_response_evidence(
|
self._record_response_evidence(
|
||||||
phase="modeling",
|
phase="modeling",
|
||||||
operation_key=f"modeling:{command.action.name.casefold()}",
|
operation_key="modeling:start",
|
||||||
response_topic=MODELING_RESPONSE_TOPIC,
|
response_topic=MODELING_RESPONSE_TOPIC,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return correlate_modeling_response(payload, command.command)
|
response = correlate_modeling_response(payload, command.command)
|
||||||
except ModelingProtocolError as exc:
|
except ModelingProtocolError as exc:
|
||||||
self._record_correlation_failure(
|
self._record_correlation_failure(
|
||||||
phase="modeling",
|
phase="modeling",
|
||||||
operation_key=f"modeling:{command.action.name.casefold()}",
|
operation_key="modeling:start",
|
||||||
response_topic=MODELING_RESPONSE_TOPIC,
|
response_topic=MODELING_RESPONSE_TOPIC,
|
||||||
reason=str(exc),
|
reason=str(exc),
|
||||||
)
|
)
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
f"modeling response correlation failed for {command.action.name}: {exc}"
|
f"modeling response correlation failed for START: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
immediate = post_start.immediate_modeling_status
|
||||||
|
self._transport.exchange_batch_once(
|
||||||
|
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
|
||||||
|
required_response_topics=(),
|
||||||
|
)
|
||||||
|
self._dialogue_stage = "initializing"
|
||||||
|
while not self._transport.scan_initialization_complete(binding):
|
||||||
|
self._transport.maintain_open_for(
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh = post_start.post_initialization_refresh
|
||||||
|
required_topics = {request.response_topic for request in refresh}
|
||||||
|
refresh_responses = self._transport.exchange_batch_once(
|
||||||
|
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
|
||||||
|
required_response_topics=required_topics,
|
||||||
|
)
|
||||||
|
for request in refresh:
|
||||||
|
refresh_payload = refresh_responses[request.response_topic]
|
||||||
|
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
|
||||||
|
self._record_response_evidence(
|
||||||
|
phase="post-start",
|
||||||
|
operation_key=operation_key,
|
||||||
|
response_topic=request.response_topic,
|
||||||
|
payload=refresh_payload,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if request.message_type == "DeviceInfoRequest":
|
||||||
|
observed = decode_and_bind_device_info_response(
|
||||||
|
refresh_payload,
|
||||||
|
authority,
|
||||||
|
expected_session_id=request.session_id,
|
||||||
|
expected_vendor_device_id=binding.vendor_device_id,
|
||||||
|
)
|
||||||
|
if observed.binding != binding:
|
||||||
|
raise ApplicationBootstrapError(
|
||||||
|
"live DeviceInfo facts changed after START"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
correlate_application_response(
|
||||||
|
refresh_payload,
|
||||||
|
request,
|
||||||
|
authority,
|
||||||
|
live_binding=binding,
|
||||||
|
)
|
||||||
|
except ApplicationBootstrapError as exc:
|
||||||
|
self._record_correlation_failure(
|
||||||
|
phase="post-start",
|
||||||
|
operation_key=operation_key,
|
||||||
|
response_topic=request.response_topic,
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
f"post-START response correlation failed for {operation_key}: {exc}"
|
||||||
|
) from exc
|
||||||
|
self._dialogue_stage = "post-initialization-observed"
|
||||||
|
self._start_complete = True
|
||||||
|
self._active_authority = authority
|
||||||
|
self._active_binding = binding
|
||||||
|
self._prepared_binding = None
|
||||||
|
return response
|
||||||
|
|
||||||
|
def maintain_active_until_stop_requested(
|
||||||
|
self,
|
||||||
|
stop_requested: Callable[[], bool],
|
||||||
|
) -> None:
|
||||||
|
"""Continuously service the original socket until the operator requests STOP."""
|
||||||
|
|
||||||
|
if self._dialogue_stage != "post-initialization-observed" or not self._start_complete:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"active control ownership requires the complete post-START dialogue"
|
||||||
|
)
|
||||||
|
binding = self._active_binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||||
|
while not stop_requested():
|
||||||
|
self._transport.maintain_open_for(
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
if not self._transport.scan_initialization_complete(binding):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"K1 left the bound SCANNING state before canonical STOP"
|
||||||
|
)
|
||||||
|
self._dialogue_stage = "stop-requested"
|
||||||
|
|
||||||
|
def execute_canonical_stop(
|
||||||
|
self,
|
||||||
|
command: ShadowModelingCommand,
|
||||||
|
permit: PhysicalAcceptancePermit,
|
||||||
|
) -> ModelingResponse:
|
||||||
|
"""Emit retained STOP on the same socket and with a separate permit."""
|
||||||
|
|
||||||
|
if command.action is not ModelingAction.STOP:
|
||||||
|
raise ApplicationAcceptanceError("canonical STOP executor requires STOP")
|
||||||
|
if self._dialogue_stage != "stop-requested" or not self._start_complete:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"STOP requires continuous ownership from the canonical START session"
|
||||||
|
)
|
||||||
|
if self._stop_attempted:
|
||||||
|
raise ApplicationAcceptanceError("canonical STOP was already attempted")
|
||||||
|
authority = self._active_authority
|
||||||
|
binding = self._active_binding
|
||||||
|
if authority is None or binding is None:
|
||||||
|
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||||
|
self._require_command_identity(command, authority=authority, binding=binding)
|
||||||
|
if not self._transport.scan_initialization_complete(binding):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"canonical STOP requires the bound K1 to still report SCANNING"
|
||||||
|
)
|
||||||
|
if permit.action is not ModelingAction.STOP:
|
||||||
|
raise ApplicationAcceptanceError("canonical STOP requires a separate STOP permit")
|
||||||
|
|
||||||
|
permit.consume(ModelingAction.STOP)
|
||||||
|
self._stop_permit_snapshot = permit.snapshot()
|
||||||
|
self._stop_attempted = True
|
||||||
|
self._dialogue_stage = "stop-attempted"
|
||||||
|
responses = self._transport.exchange_batch_once(
|
||||||
|
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||||
|
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||||
|
self._record_response_evidence(
|
||||||
|
phase="modeling",
|
||||||
|
operation_key="modeling:stop",
|
||||||
|
response_topic=MODELING_RESPONSE_TOPIC,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = correlate_modeling_response(payload, command.command)
|
||||||
|
except ModelingProtocolError as exc:
|
||||||
|
self._record_correlation_failure(
|
||||||
|
phase="modeling",
|
||||||
|
operation_key="modeling:stop",
|
||||||
|
response_topic=MODELING_RESPONSE_TOPIC,
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
f"modeling response correlation failed for STOP: {exc}"
|
||||||
|
) from exc
|
||||||
|
self._stop_complete = True
|
||||||
|
self._dialogue_stage = "stop-acknowledged"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def maintain_post_stop_until_standby_confirmed(
|
||||||
|
self,
|
||||||
|
standby_confirmed: Callable[[], bool],
|
||||||
|
) -> None:
|
||||||
|
"""Keep servicing control reports through save and physical standby.
|
||||||
|
|
||||||
|
The retained capture does not expose a protocol timer that proves save
|
||||||
|
completion. The session therefore remains owned until an explicit
|
||||||
|
operator/status gate confirms standby and never closes itself merely
|
||||||
|
because a captured wall-clock duration elapsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"post-STOP ownership requires a correlated canonical STOP"
|
||||||
|
)
|
||||||
|
binding = self._active_binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||||
|
while not (
|
||||||
|
self._transport.standby_complete(binding) and standby_confirmed()
|
||||||
|
):
|
||||||
|
self._transport.maintain_open_for(
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
self._dialogue_stage = "standby-confirmed"
|
||||||
|
self._active_authority = None
|
||||||
|
self._active_binding = None
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"mode": "physical-acceptance-only",
|
"mode": "physical-acceptance-only",
|
||||||
"bootstrap_complete": self._bootstrap_complete,
|
"bootstrap_complete": self._bootstrap_complete,
|
||||||
"command_complete": self._command_complete,
|
"command_complete": self._command_complete,
|
||||||
"permit": self._permit.snapshot(),
|
"start_attempted": self._command_complete,
|
||||||
|
"start_complete": self._start_complete,
|
||||||
|
"stop_attempted": self._stop_attempted,
|
||||||
|
"stop_complete": self._stop_complete,
|
||||||
|
"dialogue_stage": self._dialogue_stage,
|
||||||
|
"start_permit": (
|
||||||
|
dict(self._start_permit_snapshot)
|
||||||
|
if self._start_permit_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"stop_permit": (
|
||||||
|
dict(self._stop_permit_snapshot)
|
||||||
|
if self._stop_permit_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"response_evidence": tuple(dict(item) for item in self._response_evidence),
|
"response_evidence": tuple(dict(item) for item in self._response_evidence),
|
||||||
"correlation_failure": (
|
"correlation_failure": (
|
||||||
dict(self._correlation_failure)
|
dict(self._correlation_failure)
|
||||||
|
|
@ -233,10 +564,86 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
"automatic_retry": False,
|
"automatic_retry": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_command_identity(
|
||||||
|
command: ShadowModelingCommand,
|
||||||
|
*,
|
||||||
|
authority: ApplicationControlAuthority,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
) -> None:
|
||||||
|
header = command.command.header
|
||||||
|
if (
|
||||||
|
header.device_id != binding.vendor_device_id
|
||||||
|
or header.openapi_key != authority.openapi_key
|
||||||
|
):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"modeling command identity does not match the live canonical session"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _consume_checkpoint(
|
||||||
|
self,
|
||||||
|
checkpoint: OperatorDialogueCheckpoint,
|
||||||
|
*,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
checkpoint.owner_token is not self._checkpoint_owner
|
||||||
|
or checkpoint.event != expected
|
||||||
|
or self._issued_checkpoint != expected
|
||||||
|
):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"operator checkpoint was not issued by this canonical session"
|
||||||
|
)
|
||||||
|
self._issued_checkpoint = None
|
||||||
|
|
||||||
|
def _exchange_bootstrap_batch(
|
||||||
|
self,
|
||||||
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
|
*,
|
||||||
|
expected_batch: int,
|
||||||
|
) -> None:
|
||||||
|
snapshot = orchestrator.snapshot()
|
||||||
|
if snapshot.current_batch != expected_batch or snapshot.batch_issued:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
f"canonical bootstrap expected batch {expected_batch}"
|
||||||
|
)
|
||||||
|
batch = orchestrator.next_batch()
|
||||||
|
required_topics = {
|
||||||
|
request.response_topic for request in batch if request.response_required
|
||||||
|
}
|
||||||
|
responses = self._transport.exchange_batch_once(
|
||||||
|
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
||||||
|
required_response_topics=required_topics,
|
||||||
|
)
|
||||||
|
for request in batch:
|
||||||
|
if not request.response_required:
|
||||||
|
continue
|
||||||
|
payload = responses[request.response_topic]
|
||||||
|
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||||
|
self._record_response_evidence(
|
||||||
|
phase="bootstrap",
|
||||||
|
operation_key=operation_key,
|
||||||
|
response_topic=request.response_topic,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
orchestrator.accept_response(request.response_topic, payload)
|
||||||
|
except ApplicationBootstrapError as exc:
|
||||||
|
self._record_correlation_failure(
|
||||||
|
phase="bootstrap",
|
||||||
|
operation_key=operation_key,
|
||||||
|
response_topic=request.response_topic,
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"bootstrap response correlation failed for "
|
||||||
|
f"ordinal {request.ordinal} ({request.message_type}): {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
def _record_response_evidence(
|
def _record_response_evidence(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
phase: Literal["bootstrap", "modeling"],
|
phase: Literal["bootstrap", "modeling", "post-start"],
|
||||||
operation_key: str,
|
operation_key: str,
|
||||||
response_topic: str,
|
response_topic: str,
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
|
|
@ -254,7 +661,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
def _record_correlation_failure(
|
def _record_correlation_failure(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
phase: Literal["bootstrap", "modeling"],
|
phase: Literal["bootstrap", "modeling", "post-start"],
|
||||||
operation_key: str,
|
operation_key: str,
|
||||||
response_topic: str,
|
response_topic: str,
|
||||||
reason: str,
|
reason: str,
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class ApplicationRequestHeader:
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EncodedApplicationRequest:
|
class EncodedApplicationRequest:
|
||||||
ordinal: int
|
ordinal: int
|
||||||
phase: Literal["identity-discovery", "bound-preparation"]
|
phase: Literal["identity-discovery", "bound-preparation", "post-start-observation"]
|
||||||
message_type: str
|
message_type: str
|
||||||
topic: str
|
topic: str
|
||||||
response_topic: str
|
response_topic: str
|
||||||
|
|
@ -182,6 +182,21 @@ class ShadowApplicationBootstrap:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CanonicalPostStartObservation:
|
||||||
|
"""Exact retained operations 12-14 following a successful START publish."""
|
||||||
|
|
||||||
|
immediate_modeling_status: EncodedApplicationRequest = field(repr=False)
|
||||||
|
post_initialization_refresh: tuple[
|
||||||
|
EncodedApplicationRequest,
|
||||||
|
EncodedApplicationRequest,
|
||||||
|
] = field(repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requests(self) -> tuple[EncodedApplicationRequest, ...]:
|
||||||
|
return (self.immediate_modeling_status, *self.post_initialization_refresh)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DeviceInfoResponse:
|
class DeviceInfoResponse:
|
||||||
binding: LiveDeviceControlBinding = field(repr=False)
|
binding: LiveDeviceControlBinding = field(repr=False)
|
||||||
|
|
@ -384,6 +399,74 @@ def build_shadow_bootstrap(
|
||||||
return ShadowApplicationBootstrap(tuple(requests))
|
return ShadowApplicationBootstrap(tuple(requests))
|
||||||
|
|
||||||
|
|
||||||
|
def build_canonical_post_start_observation(
|
||||||
|
authority: ApplicationControlAuthority,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
) -> CanonicalPostStartObservation:
|
||||||
|
"""Build retained operations 12-14 without granting publish authority."""
|
||||||
|
|
||||||
|
if not binding.ready_for_reviewed_profile:
|
||||||
|
raise ApplicationBootstrapError(
|
||||||
|
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
def build(
|
||||||
|
ordinal: Literal[12, 13, 14],
|
||||||
|
message_type: Literal["DeviceInfoRequest", "ModelingStatusRequest"],
|
||||||
|
topic: str,
|
||||||
|
response_topic: str,
|
||||||
|
*,
|
||||||
|
response_required: bool,
|
||||||
|
) -> EncodedApplicationRequest:
|
||||||
|
header = ApplicationRequestHeader(
|
||||||
|
message_type=message_type,
|
||||||
|
authority=authority,
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
payload = _bytes_field(1, _encode_header(header))
|
||||||
|
_check_payload(payload, "post-START application request")
|
||||||
|
return EncodedApplicationRequest(
|
||||||
|
ordinal=ordinal,
|
||||||
|
phase="post-start-observation",
|
||||||
|
message_type=message_type,
|
||||||
|
topic=topic,
|
||||||
|
response_topic=response_topic,
|
||||||
|
payload=payload,
|
||||||
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
payload_bytes=len(payload),
|
||||||
|
mutates_device=False,
|
||||||
|
requires_live_binding=True,
|
||||||
|
response_required=response_required,
|
||||||
|
session_id=header.session_id,
|
||||||
|
vendor_device_id=binding.vendor_device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
immediate = build(
|
||||||
|
12,
|
||||||
|
"ModelingStatusRequest",
|
||||||
|
MODELING_STATUS_REQUEST_TOPIC,
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
response_required=False,
|
||||||
|
)
|
||||||
|
refresh = (
|
||||||
|
build(
|
||||||
|
13,
|
||||||
|
"DeviceInfoRequest",
|
||||||
|
DEVICE_INFO_REQUEST_TOPIC,
|
||||||
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
response_required=True,
|
||||||
|
),
|
||||||
|
build(
|
||||||
|
14,
|
||||||
|
"ModelingStatusRequest",
|
||||||
|
MODELING_STATUS_REQUEST_TOPIC,
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
response_required=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return CanonicalPostStartObservation(immediate, refresh)
|
||||||
|
|
||||||
|
|
||||||
def decode_and_bind_device_info_response(
|
def decode_and_bind_device_info_response(
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
authority: ApplicationControlAuthority,
|
authority: ApplicationControlAuthority,
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,17 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
MODELING_STATUS_RESPONSE_TOPIC,
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
LiveDeviceControlBinding,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
OneShotPublishEnvelope,
|
OneShotPublishEnvelope,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
decode_device_status_report,
|
||||||
|
decode_system_error_report,
|
||||||
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
DEVICE_STATUS_TOPIC,
|
||||||
MODELING_REQUEST_TOPIC,
|
MODELING_REQUEST_TOPIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,7 +41,9 @@ CONTROL_KEEPALIVE_SECONDS = 60
|
||||||
CONTROL_CONNECT_TIMEOUT_SECONDS = 10.0
|
CONTROL_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
CONTROL_EXCHANGE_TIMEOUT_SECONDS = 5.0
|
CONTROL_EXCHANGE_TIMEOUT_SECONDS = 5.0
|
||||||
CONTROL_LOOP_INTERVAL_SECONDS = 0.05
|
CONTROL_LOOP_INTERVAL_SECONDS = 0.05
|
||||||
|
MAX_CONTROL_MAINTAIN_SECONDS = 30.0
|
||||||
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
||||||
|
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
|
||||||
|
|
||||||
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
||||||
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
||||||
|
|
@ -47,8 +55,8 @@ CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
||||||
("PrePathArray", 0),
|
("PrePathArray", 0),
|
||||||
("ScanStatus", 0),
|
("ScanStatus", 0),
|
||||||
("lixel/application/report/lio_pose", 0),
|
("lixel/application/report/lio_pose", 0),
|
||||||
("lixel/application/report/device_status", 0),
|
(DEVICE_STATUS_TOPIC, 0),
|
||||||
("lixel/application/report/system_error", 0),
|
(SYSTEM_ERROR_TOPIC, 0),
|
||||||
(DEVICE_INFO_RESPONSE_TOPIC, 0),
|
(DEVICE_INFO_RESPONSE_TOPIC, 0),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -144,6 +152,10 @@ class ApplicationCommandOutcomeUnknown(RuntimeError):
|
||||||
"""A request may have reached the K1 and must never be retried automatically."""
|
"""A request may have reached the K1 and must never be retried automatically."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationControlDeviceFault(RuntimeError):
|
||||||
|
"""Live status made further automatic control inadmissible."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationMqttTransportSnapshot:
|
class ApplicationMqttTransportSnapshot:
|
||||||
state: str
|
state: str
|
||||||
|
|
@ -153,6 +165,14 @@ class ApplicationMqttTransportSnapshot:
|
||||||
qos2_completions: int
|
qos2_completions: int
|
||||||
correlated_responses: int
|
correlated_responses: int
|
||||||
ignored_known_responses: int
|
ignored_known_responses: int
|
||||||
|
device_status_reports: int
|
||||||
|
system_error_reports: int
|
||||||
|
report_decode_errors: int
|
||||||
|
latest_device_session_state: str | None
|
||||||
|
latest_device_project_bound: bool | None
|
||||||
|
latest_device_init_ready: bool | None
|
||||||
|
latest_system_error_code: int | None
|
||||||
|
latest_system_error_state: str | None
|
||||||
operation_keys_consumed: int
|
operation_keys_consumed: int
|
||||||
clean_session: bool = False
|
clean_session: bool = False
|
||||||
keepalive_seconds: int = CONTROL_KEEPALIVE_SECONDS
|
keepalive_seconds: int = CONTROL_KEEPALIVE_SECONDS
|
||||||
|
|
@ -169,6 +189,14 @@ class ApplicationMqttTransportSnapshot:
|
||||||
"qos2_completions": self.qos2_completions,
|
"qos2_completions": self.qos2_completions,
|
||||||
"correlated_responses": self.correlated_responses,
|
"correlated_responses": self.correlated_responses,
|
||||||
"ignored_known_responses": self.ignored_known_responses,
|
"ignored_known_responses": self.ignored_known_responses,
|
||||||
|
"device_status_reports": self.device_status_reports,
|
||||||
|
"system_error_reports": self.system_error_reports,
|
||||||
|
"report_decode_errors": self.report_decode_errors,
|
||||||
|
"latest_device_session_state": self.latest_device_session_state,
|
||||||
|
"latest_device_project_bound": self.latest_device_project_bound,
|
||||||
|
"latest_device_init_ready": self.latest_device_init_ready,
|
||||||
|
"latest_system_error_code": self.latest_system_error_code,
|
||||||
|
"latest_system_error_state": self.latest_system_error_state,
|
||||||
"operation_keys_consumed": self.operation_keys_consumed,
|
"operation_keys_consumed": self.operation_keys_consumed,
|
||||||
"clean_session": self.clean_session,
|
"clean_session": self.clean_session,
|
||||||
"keepalive_seconds": self.keepalive_seconds,
|
"keepalive_seconds": self.keepalive_seconds,
|
||||||
|
|
@ -230,6 +258,17 @@ class ReviewedApplicationMqttTransport:
|
||||||
self._qos2_completions = 0
|
self._qos2_completions = 0
|
||||||
self._correlated_responses = 0
|
self._correlated_responses = 0
|
||||||
self._ignored_known_responses = 0
|
self._ignored_known_responses = 0
|
||||||
|
self._device_status_reports = 0
|
||||||
|
self._system_error_reports = 0
|
||||||
|
self._report_decode_errors = 0
|
||||||
|
self._latest_device_session_state: str | None = None
|
||||||
|
self._latest_device_project_bound: bool | None = None
|
||||||
|
self._latest_device_init_ready: bool | None = None
|
||||||
|
self._latest_device_id: str | None = None
|
||||||
|
self._latest_device_serial: str | None = None
|
||||||
|
self._latest_device_fault = False
|
||||||
|
self._latest_system_error_code: int | None = None
|
||||||
|
self._latest_system_error_state: str | None = None
|
||||||
|
|
||||||
def open(self) -> ApplicationMqttTransportSnapshot:
|
def open(self) -> ApplicationMqttTransportSnapshot:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -267,7 +306,12 @@ class ReviewedApplicationMqttTransport:
|
||||||
required = frozenset(required_response_topics)
|
required = frozenset(required_response_topics)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise ValueError("control exchange batch must not be empty")
|
raise ValueError("control exchange batch must not be empty")
|
||||||
if not required or not required <= APPLICATION_RESPONSE_TOPICS:
|
if not required and any(
|
||||||
|
envelope.topic != "lixel/application/request/modeling_status"
|
||||||
|
for envelope in batch
|
||||||
|
):
|
||||||
|
raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
|
||||||
|
if not required <= APPLICATION_RESPONSE_TOPICS:
|
||||||
raise ValueError("required response topics exceed the reviewed allowlist")
|
raise ValueError("required response topics exceed the reviewed allowlist")
|
||||||
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
||||||
if len(set(operation_keys)) != len(operation_keys):
|
if len(set(operation_keys)) != len(operation_keys):
|
||||||
|
|
@ -333,6 +377,43 @@ class ReviewedApplicationMqttTransport:
|
||||||
self._correlated_responses += len(responses)
|
self._correlated_responses += len(responses)
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
|
def maintain_open_for(
|
||||||
|
self,
|
||||||
|
duration_seconds: float,
|
||||||
|
*,
|
||||||
|
allowed_response_topics: Collection[str] = (),
|
||||||
|
) -> None:
|
||||||
|
"""Continuously service the same control socket across static initialization."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(duration_seconds, (int, float))
|
||||||
|
or isinstance(duration_seconds, bool)
|
||||||
|
or not math.isfinite(duration_seconds)
|
||||||
|
or not 0 < duration_seconds <= MAX_CONTROL_MAINTAIN_SECONDS
|
||||||
|
):
|
||||||
|
raise ValueError("control maintain duration is outside the reviewed bound")
|
||||||
|
allowed = frozenset(allowed_response_topics)
|
||||||
|
if not allowed <= frozenset({MODELING_STATUS_RESPONSE_TOPIC}):
|
||||||
|
raise ValueError("control maintain response allowlist exceeds retained dialogue")
|
||||||
|
with self._lock:
|
||||||
|
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||||
|
raise ApplicationMqttTransportError("control transport is not ready")
|
||||||
|
deadline = self._monotonic() + float(duration_seconds)
|
||||||
|
client = self._require_client()
|
||||||
|
while self._monotonic() < deadline:
|
||||||
|
self._discard_allowed_responses(allowed)
|
||||||
|
with self._lock:
|
||||||
|
callback_error = self._callback_error
|
||||||
|
if callback_error is not None:
|
||||||
|
self._fail_after_publish(callback_error)
|
||||||
|
try:
|
||||||
|
result = client.loop(timeout=CONTROL_LOOP_INTERVAL_SECONDS)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
self._fail_after_publish("control MQTT network loop failed", exc)
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
self._fail_after_publish("control MQTT network loop returned an error")
|
||||||
|
self._discard_allowed_responses(allowed)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._state == "closed":
|
if self._state == "closed":
|
||||||
|
|
@ -355,6 +436,43 @@ class ReviewedApplicationMqttTransport:
|
||||||
if self._state not in {"poisoned", "failed"}:
|
if self._state not in {"poisoned", "failed"}:
|
||||||
self._state = "closed"
|
self._state = "closed"
|
||||||
|
|
||||||
|
def scan_initialization_complete(self, binding: LiveDeviceControlBinding) -> bool:
|
||||||
|
"""Return true only for the bound SCANNING/project/init-ready state."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._require_healthy_bound_status_locked(binding)
|
||||||
|
return (
|
||||||
|
self._latest_device_session_state == "scanning"
|
||||||
|
and self._latest_device_project_bound is True
|
||||||
|
and self._latest_device_init_ready is True
|
||||||
|
)
|
||||||
|
|
||||||
|
def pre_start_ready(self, binding: LiveDeviceControlBinding) -> bool:
|
||||||
|
"""Return true only when the bound K1 is READY with no current project."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._require_healthy_bound_status_locked(binding)
|
||||||
|
return (
|
||||||
|
self._latest_device_session_state == "ready"
|
||||||
|
and self._latest_device_project_bound is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def standby_complete(self, binding: LiveDeviceControlBinding) -> bool:
|
||||||
|
"""Return true only when the same K1 reports unbound READY after STOP."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._require_healthy_bound_status_locked(binding)
|
||||||
|
return (
|
||||||
|
self._latest_device_session_state == "ready"
|
||||||
|
and self._latest_device_project_bound is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_bound_status(self, binding: LiveDeviceControlBinding) -> None:
|
||||||
|
"""Fail closed on report faults or identity drift without advancing state."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._require_healthy_bound_status_locked(binding)
|
||||||
|
|
||||||
def snapshot(self) -> ApplicationMqttTransportSnapshot:
|
def snapshot(self) -> ApplicationMqttTransportSnapshot:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return ApplicationMqttTransportSnapshot(
|
return ApplicationMqttTransportSnapshot(
|
||||||
|
|
@ -365,6 +483,14 @@ class ReviewedApplicationMqttTransport:
|
||||||
qos2_completions=self._qos2_completions,
|
qos2_completions=self._qos2_completions,
|
||||||
correlated_responses=self._correlated_responses,
|
correlated_responses=self._correlated_responses,
|
||||||
ignored_known_responses=self._ignored_known_responses,
|
ignored_known_responses=self._ignored_known_responses,
|
||||||
|
device_status_reports=self._device_status_reports,
|
||||||
|
system_error_reports=self._system_error_reports,
|
||||||
|
report_decode_errors=self._report_decode_errors,
|
||||||
|
latest_device_session_state=self._latest_device_session_state,
|
||||||
|
latest_device_project_bound=self._latest_device_project_bound,
|
||||||
|
latest_device_init_ready=self._latest_device_init_ready,
|
||||||
|
latest_system_error_code=self._latest_system_error_code,
|
||||||
|
latest_system_error_state=self._latest_system_error_state,
|
||||||
operation_keys_consumed=len(self._consumed_operation_keys),
|
operation_keys_consumed=len(self._consumed_operation_keys),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -449,10 +575,12 @@ class ReviewedApplicationMqttTransport:
|
||||||
if len(payload) > MAX_CONTROL_RESPONSE_BYTES:
|
if len(payload) > MAX_CONTROL_RESPONSE_BYTES:
|
||||||
self._set_callback_error("control MQTT response exceeds the reviewed bound")
|
self._set_callback_error("control MQTT response exceeds the reviewed bound")
|
||||||
return
|
return
|
||||||
with self._lock:
|
|
||||||
if message.topic in CONTROL_REPORT_TOPICS:
|
if message.topic in CONTROL_REPORT_TOPICS:
|
||||||
|
self._observe_control_report(message.topic, payload)
|
||||||
|
with self._lock:
|
||||||
self._ignored_known_responses += 1
|
self._ignored_known_responses += 1
|
||||||
else:
|
else:
|
||||||
|
with self._lock:
|
||||||
self._messages.append((message.topic, payload))
|
self._messages.append((message.topic, payload))
|
||||||
|
|
||||||
def on_disconnect(
|
def on_disconnect(
|
||||||
|
|
@ -474,6 +602,77 @@ class ReviewedApplicationMqttTransport:
|
||||||
client.on_message = on_message
|
client.on_message = on_message
|
||||||
client.on_disconnect = on_disconnect
|
client.on_disconnect = on_disconnect
|
||||||
|
|
||||||
|
def _observe_control_report(self, topic: str, payload: bytes) -> None:
|
||||||
|
if topic == DEVICE_STATUS_TOPIC:
|
||||||
|
try:
|
||||||
|
device_report = decode_device_status_report(payload)
|
||||||
|
except ValueError:
|
||||||
|
with self._lock:
|
||||||
|
self._report_decode_errors += 1
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._device_status_reports += 1
|
||||||
|
self._latest_device_session_state = (
|
||||||
|
device_report.session_state.name.casefold()
|
||||||
|
if device_report.session_state is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._latest_device_project_bound = bool(device_report.project_id)
|
||||||
|
self._latest_device_init_ready = device_report.init_ready
|
||||||
|
self._latest_device_id = (
|
||||||
|
device_report.header.device_id
|
||||||
|
if device_report.header is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._latest_device_serial = device_report.device_sn
|
||||||
|
self._latest_device_fault = bool(
|
||||||
|
device_report.session_state is not None
|
||||||
|
and device_report.session_state.is_fault
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if topic == SYSTEM_ERROR_TOPIC:
|
||||||
|
try:
|
||||||
|
system_error_report = decode_system_error_report(payload)
|
||||||
|
except ValueError:
|
||||||
|
with self._lock:
|
||||||
|
self._report_decode_errors += 1
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._system_error_reports += 1
|
||||||
|
self._latest_system_error_code = system_error_report.error_code
|
||||||
|
self._latest_system_error_state = (
|
||||||
|
system_error_report.session_state.name.casefold()
|
||||||
|
if system_error_report.session_state is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_healthy_bound_status_locked(
|
||||||
|
self,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
) -> None:
|
||||||
|
if self._report_decode_errors:
|
||||||
|
raise ApplicationControlDeviceFault(
|
||||||
|
"K1 control report decoding failed; no further automatic action is admissible"
|
||||||
|
)
|
||||||
|
if self._system_error_reports or self._latest_device_fault:
|
||||||
|
state = self._latest_system_error_state or self._latest_device_session_state
|
||||||
|
raise ApplicationControlDeviceFault(
|
||||||
|
f"K1 reported a control fault state: {state or 'unknown'}"
|
||||||
|
)
|
||||||
|
if self._device_status_reports == 0:
|
||||||
|
return
|
||||||
|
if self._latest_device_id is None or self._latest_device_serial is None:
|
||||||
|
raise ApplicationControlDeviceFault(
|
||||||
|
"K1 control status omitted the live device identity"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self._latest_device_id != binding.vendor_device_id
|
||||||
|
or self._latest_device_serial != binding.device_serial
|
||||||
|
):
|
||||||
|
raise ApplicationControlDeviceFault(
|
||||||
|
"K1 control status identity drifted from the live DeviceInfo binding"
|
||||||
|
)
|
||||||
|
|
||||||
def _subscribe_next_group(self, client: mqtt.Client) -> None:
|
def _subscribe_next_group(self, client: mqtt.Client) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
group_index = self._subscription_group_index
|
group_index = self._subscription_group_index
|
||||||
|
|
@ -552,6 +751,23 @@ class ReviewedApplicationMqttTransport:
|
||||||
self.close()
|
self.close()
|
||||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||||
|
|
||||||
|
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
||||||
|
ambiguous_message: str | None = None
|
||||||
|
with self._lock:
|
||||||
|
while self._messages:
|
||||||
|
topic, _payload = self._messages.popleft()
|
||||||
|
if topic in allowed:
|
||||||
|
self._ignored_known_responses += 1
|
||||||
|
continue
|
||||||
|
self._poison_locked("unexpected response during retained control hold")
|
||||||
|
ambiguous_message = (
|
||||||
|
"unexpected control response during retained control-session hold"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if ambiguous_message is not None:
|
||||||
|
self.close()
|
||||||
|
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||||
|
|
||||||
def _set_callback_error(self, message: str) -> None:
|
def _set_callback_error(self, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._callback_error is None:
|
if self._callback_error is None:
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,23 @@ class OneShotPublishEnvelope:
|
||||||
retain=request.retain,
|
retain=request.retain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dialogue_request(
|
||||||
|
cls,
|
||||||
|
request: EncodedApplicationRequest,
|
||||||
|
) -> OneShotPublishEnvelope:
|
||||||
|
if request.phase != "post-start-observation" or request.ordinal not in {12, 13, 14}:
|
||||||
|
raise ValueError("application request is not a retained post-START operation")
|
||||||
|
return cls(
|
||||||
|
operation_key=f"dialogue:{request.ordinal}:{request.message_type}",
|
||||||
|
topic=request.topic,
|
||||||
|
payload=request.payload,
|
||||||
|
payload_sha256=request.payload_sha256,
|
||||||
|
payload_bytes=request.payload_bytes,
|
||||||
|
qos=request.qos,
|
||||||
|
retain=request.retain,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_modeling_command(
|
def from_modeling_command(
|
||||||
cls,
|
cls,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ MODELING_SESSION_SUFFIX = ":ModelingRequest"
|
||||||
OPENAPI_RESULT_BASE = 302_252_032
|
OPENAPI_RESULT_BASE = 302_252_032
|
||||||
OPENAPI_SUCCESS = OPENAPI_RESULT_BASE + 1
|
OPENAPI_SUCCESS = OPENAPI_RESULT_BASE + 1
|
||||||
MODELING_STATE_BASE = 302_252_032
|
MODELING_STATE_BASE = 302_252_032
|
||||||
|
SYSTEM_ERROR_STATE_BASE = 0x3204_0000
|
||||||
|
|
||||||
|
|
||||||
class ModelingProtocolError(ValueError):
|
class ModelingProtocolError(ValueError):
|
||||||
|
|
@ -202,6 +203,15 @@ class DeviceStatusReport:
|
||||||
rtk_status_present: bool
|
rtk_status_present: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SystemErrorReport:
|
||||||
|
"""Bounded K1 system-error report with identity kept out of repr."""
|
||||||
|
|
||||||
|
header: ObservedHeader | None = field(repr=False)
|
||||||
|
error_code: int
|
||||||
|
session_state: SessionState | None
|
||||||
|
|
||||||
|
|
||||||
def encode_modeling_start(
|
def encode_modeling_start(
|
||||||
header: CommandHeaderIdentity,
|
header: CommandHeaderIdentity,
|
||||||
*,
|
*,
|
||||||
|
|
@ -391,6 +401,57 @@ def session_state_from_code(code: int) -> SessionState | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_system_error_report(
|
||||||
|
payload: bytes, *, max_payload_bytes: int = MAX_CONTROL_PAYLOAD_BYTES
|
||||||
|
) -> SystemErrorReport:
|
||||||
|
"""Decode the observed ``report/system_error`` envelope without guessing text."""
|
||||||
|
|
||||||
|
_check_payload_bound(payload, max_payload_bytes, "SystemErrorReport")
|
||||||
|
header: ObservedHeader | None = None
|
||||||
|
error_code: int | None = None
|
||||||
|
seen: set[int] = set()
|
||||||
|
try:
|
||||||
|
for proto_field in iter_fields(payload, max_fields=64):
|
||||||
|
if proto_field.number == 1:
|
||||||
|
_mark_once(seen, 1, "system_error.header")
|
||||||
|
header = _decode_observed_header(
|
||||||
|
_bytes_value(proto_field, "system_error.header"), require_identity=False
|
||||||
|
)
|
||||||
|
elif proto_field.number == 15:
|
||||||
|
_mark_once(seen, 15, "system_error.error")
|
||||||
|
nested_seen: set[int] = set()
|
||||||
|
for nested in iter_fields(
|
||||||
|
_bytes_value(proto_field, "system_error.error"), max_fields=8
|
||||||
|
):
|
||||||
|
if nested.number == 1:
|
||||||
|
_mark_once(nested_seen, 1, "system_error.error.code")
|
||||||
|
error_code = _uint_value(
|
||||||
|
nested,
|
||||||
|
"system_error.error.code",
|
||||||
|
max_value=0xFFFF_FFFF,
|
||||||
|
)
|
||||||
|
except ProtobufWireError as exc:
|
||||||
|
raise ModelingProtocolError(f"invalid SystemErrorReport: {exc}") from exc
|
||||||
|
if error_code is None:
|
||||||
|
raise ModelingProtocolError("SystemErrorReport has no error code")
|
||||||
|
return SystemErrorReport(
|
||||||
|
header=header,
|
||||||
|
error_code=error_code,
|
||||||
|
session_state=system_error_state_from_code(error_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def system_error_state_from_code(code: int) -> SessionState | None:
|
||||||
|
"""Map the observed system-error namespace while preserving unknown codes."""
|
||||||
|
|
||||||
|
if code < SYSTEM_ERROR_STATE_BASE:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return SessionState(code - SYSTEM_ERROR_STATE_BASE)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _encode_command_header(header: CommandHeaderIdentity) -> bytes:
|
def _encode_command_header(header: CommandHeaderIdentity) -> bytes:
|
||||||
# seq/stamp/scaler were default-valued and omitted in the reviewed command
|
# seq/stamp/scaler were default-valued and omitted in the reviewed command
|
||||||
# requests. The three identities must be supplied explicitly by a caller.
|
# requests. The three identities must be supplied explicitly by a caller.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||||
ApplicationAcceptanceError,
|
ApplicationAcceptanceError,
|
||||||
|
OperatorDialogueCheckpoint,
|
||||||
PhysicalAcceptanceChecklist,
|
PhysicalAcceptanceChecklist,
|
||||||
PhysicalAcceptanceDialogueExecutor,
|
PhysicalAcceptanceDialogueExecutor,
|
||||||
PhysicalAcceptancePermit,
|
PhysicalAcceptancePermit,
|
||||||
|
|
@ -15,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
ApplicationControlAuthority,
|
ApplicationControlAuthority,
|
||||||
ShadowApplicationBootstrapOrchestrator,
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
build_canonical_post_start_observation,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
MODELING_RESPONSE_TOPIC,
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
|
@ -91,8 +93,15 @@ def _modeling_response(action: ModelingAction) -> bytes:
|
||||||
|
|
||||||
|
|
||||||
class SyntheticAcceptanceTransport:
|
class SyntheticAcceptanceTransport:
|
||||||
def __init__(self) -> None:
|
def __init__(self, clock: FakeClock | None = None) -> None:
|
||||||
self.batches: list[tuple[str, ...]] = []
|
self.batches: list[tuple[str, ...]] = []
|
||||||
|
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
||||||
|
self.clock = clock
|
||||||
|
self.pre_start_maintain_calls = 0
|
||||||
|
self.active_maintain_calls = 0
|
||||||
|
self.post_stop_maintain_calls = 0
|
||||||
|
self.start_emitted = False
|
||||||
|
self.stop_emitted = False
|
||||||
|
|
||||||
def exchange_batch_once(
|
def exchange_batch_once(
|
||||||
self,
|
self,
|
||||||
|
|
@ -109,11 +118,15 @@ class SyntheticAcceptanceTransport:
|
||||||
else ModelingAction.STOP
|
else ModelingAction.STOP
|
||||||
)
|
)
|
||||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||||
|
if action is ModelingAction.START:
|
||||||
|
self.start_emitted = True
|
||||||
|
else:
|
||||||
|
self.stop_emitted = True
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
for envelope in envelopes:
|
for envelope in envelopes:
|
||||||
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
|
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
|
||||||
assert prefix == "bootstrap"
|
assert prefix in {"bootstrap", "dialogue"}
|
||||||
ordinal = int(ordinal_text)
|
ordinal = int(ordinal_text)
|
||||||
if message_type == "DeviceInfoRequest":
|
if message_type == "DeviceInfoRequest":
|
||||||
session = (
|
session = (
|
||||||
|
|
@ -134,6 +147,36 @@ class SyntheticAcceptanceTransport:
|
||||||
responses[topic] = response
|
responses[topic] = response
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
|
def maintain_open_for(
|
||||||
|
self,
|
||||||
|
duration_seconds: float,
|
||||||
|
*,
|
||||||
|
allowed_response_topics: Collection[str] = (),
|
||||||
|
) -> None:
|
||||||
|
self.maintain_calls.append(
|
||||||
|
(duration_seconds, tuple(sorted(allowed_response_topics)))
|
||||||
|
)
|
||||||
|
if self.clock is not None:
|
||||||
|
self.clock.now += duration_seconds
|
||||||
|
if self.stop_emitted:
|
||||||
|
self.post_stop_maintain_calls += 1
|
||||||
|
elif self.start_emitted:
|
||||||
|
self.active_maintain_calls += 1
|
||||||
|
else:
|
||||||
|
self.pre_start_maintain_calls += 1
|
||||||
|
|
||||||
|
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||||
|
return self.active_maintain_calls >= 2
|
||||||
|
|
||||||
|
def pre_start_ready(self, _binding: object) -> bool:
|
||||||
|
return not self.start_emitted
|
||||||
|
|
||||||
|
def standby_complete(self, _binding: object) -> bool:
|
||||||
|
return self.stop_emitted and self.post_stop_maintain_calls >= 2
|
||||||
|
|
||||||
|
def validate_bound_status(self, _binding: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _response_suffix(message_type: str) -> str:
|
def _response_suffix(message_type: str) -> str:
|
||||||
values = {
|
values = {
|
||||||
|
|
@ -158,17 +201,56 @@ def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
|
class FakeClock:
|
||||||
transport = SyntheticAcceptanceTransport()
|
def __init__(self) -> None:
|
||||||
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
|
self.now = 100.0
|
||||||
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
transport = SyntheticAcceptanceTransport(clock)
|
||||||
|
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
epoch_seconds=1_752_680_000,
|
epoch_seconds=1_752_680_000,
|
||||||
timezone_name="Europe/Moscow",
|
timezone_name="Europe/Moscow",
|
||||||
)
|
)
|
||||||
|
|
||||||
binding = executor.run_bootstrap(orchestrator)
|
with pytest.raises(ApplicationAcceptanceError, match="collapsed bootstrap is disabled"):
|
||||||
|
executor.run_bootstrap(orchestrator)
|
||||||
|
|
||||||
|
binding = executor.run_connection_stage(orchestrator)
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="not issued by this canonical session"):
|
||||||
|
executor.run_workspace_entry_stage(
|
||||||
|
orchestrator,
|
||||||
|
OperatorDialogueCheckpoint(
|
||||||
|
event="workspace-entered",
|
||||||
|
operator_initiated=True,
|
||||||
|
owner_token=object(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
|
||||||
|
workspace_checks = iter((False, False, True))
|
||||||
|
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"workspace-entered",
|
||||||
|
lambda: next(workspace_checks),
|
||||||
|
)
|
||||||
|
executor.run_workspace_entry_stage(
|
||||||
|
orchestrator,
|
||||||
|
workspace_checkpoint,
|
||||||
|
)
|
||||||
|
project_checks = iter((False, False, True))
|
||||||
|
project_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"project-prompt-opened",
|
||||||
|
lambda: next(project_checks),
|
||||||
|
)
|
||||||
|
executor.run_project_prompt_stage(
|
||||||
|
orchestrator,
|
||||||
|
project_checkpoint,
|
||||||
|
)
|
||||||
command = ShadowModelingCommand.from_command(
|
command = ShadowModelingCommand.from_command(
|
||||||
encode_modeling_start(
|
encode_modeling_start(
|
||||||
CommandHeaderIdentity(
|
CommandHeaderIdentity(
|
||||||
|
|
@ -181,23 +263,82 @@ def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit(
|
||||||
mount_type=MountType.HANDHELD,
|
mount_type=MountType.HANDHELD,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = executor.execute_modeling(command)
|
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||||
|
executor.execute_modeling(command)
|
||||||
|
|
||||||
|
start_wait_polls = 0
|
||||||
|
|
||||||
|
def start_confirmed() -> bool:
|
||||||
|
nonlocal start_wait_polls
|
||||||
|
start_wait_polls += 1
|
||||||
|
return start_wait_polls > 202
|
||||||
|
|
||||||
|
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"start-confirmed",
|
||||||
|
start_confirmed,
|
||||||
|
)
|
||||||
|
# Human/UI time is serviced on the socket and precedes the command permit.
|
||||||
|
start_permit = PhysicalAcceptancePermit(
|
||||||
|
_checklist(ModelingAction.START),
|
||||||
|
ttl_seconds=120.0,
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
post_start = build_canonical_post_start_observation(
|
||||||
|
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
|
binding,
|
||||||
|
)
|
||||||
|
response = executor.execute_canonical_start(
|
||||||
|
command,
|
||||||
|
post_start,
|
||||||
|
authority=ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
|
binding=binding,
|
||||||
|
permit=start_permit,
|
||||||
|
checkpoint=start_checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
assert response.action is ModelingAction.START
|
assert response.action is ModelingAction.START
|
||||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
|
stop_checks = iter((False, False, True))
|
||||||
assert permit.snapshot()["consumed"] is True
|
executor.maintain_active_until_stop_requested(lambda: next(stop_checks))
|
||||||
|
stop_permit = PhysicalAcceptancePermit(
|
||||||
|
_checklist(ModelingAction.STOP),
|
||||||
|
ttl_seconds=120.0,
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
stop_command = ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_stop(
|
||||||
|
CommandHeaderIdentity(
|
||||||
|
device_id=binding.vendor_device_id,
|
||||||
|
openapi_key=APPLICATION_KEY,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
|
||||||
|
standby_checks = iter((False, False, True))
|
||||||
|
executor.maintain_post_stop_until_standby_confirmed(
|
||||||
|
lambda: next(standby_checks)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stop_response.action is ModelingAction.STOP
|
||||||
|
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1]
|
||||||
|
assert len(transport.maintain_calls) == 214
|
||||||
|
assert set(transport.maintain_calls) == {
|
||||||
|
(1.0, ("lixel/application/response/modeling_status",))
|
||||||
|
}
|
||||||
|
assert start_permit.snapshot()["consumed"] is True
|
||||||
|
assert stop_permit.snapshot()["consumed"] is True
|
||||||
assert executor.snapshot()["bootstrap_complete"] is True
|
assert executor.snapshot()["bootstrap_complete"] is True
|
||||||
assert executor.snapshot()["command_complete"] is True
|
assert executor.snapshot()["command_complete"] is True
|
||||||
|
assert executor.snapshot()["start_complete"] is True
|
||||||
|
assert executor.snapshot()["stop_complete"] is True
|
||||||
|
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
|
||||||
response_evidence = executor.snapshot()["response_evidence"]
|
response_evidence = executor.snapshot()["response_evidence"]
|
||||||
assert isinstance(response_evidence, tuple)
|
assert isinstance(response_evidence, tuple)
|
||||||
assert len(response_evidence) == 10
|
assert len(response_evidence) == 13
|
||||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||||
assert response_evidence[-1]["operation_key"] == "modeling:start"
|
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
||||||
assert all("payload" not in item for item in response_evidence)
|
assert all("payload" not in item for item in response_evidence)
|
||||||
assert APPLICATION_KEY not in str(executor.snapshot())
|
assert APPLICATION_KEY not in str(executor.snapshot())
|
||||||
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
||||||
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
|
|
||||||
executor.execute_modeling(command)
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
||||||
|
|
@ -220,7 +361,6 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
|
|
||||||
executor = PhysicalAcceptanceDialogueExecutor(
|
executor = PhysicalAcceptanceDialogueExecutor(
|
||||||
CorruptDeviceConfigTransport(),
|
CorruptDeviceConfigTransport(),
|
||||||
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
|
||||||
)
|
)
|
||||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
|
|
@ -232,7 +372,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
ApplicationAcceptanceError,
|
ApplicationAcceptanceError,
|
||||||
match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation",
|
match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation",
|
||||||
):
|
):
|
||||||
executor.run_bootstrap(orchestrator)
|
executor.run_connection_stage(orchestrator)
|
||||||
|
|
||||||
snapshot = executor.snapshot()
|
snapshot = executor.snapshot()
|
||||||
failure = snapshot["correlation_failure"]
|
failure = snapshot["correlation_failure"]
|
||||||
|
|
@ -253,12 +393,9 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
assert VENDOR_DEVICE_ID not in str(snapshot)
|
assert VENDOR_DEVICE_ID not in str(snapshot)
|
||||||
|
|
||||||
|
|
||||||
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
|
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
||||||
transport = SyntheticAcceptanceTransport()
|
transport = SyntheticAcceptanceTransport()
|
||||||
start = PhysicalAcceptanceDialogueExecutor(
|
start = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
transport,
|
|
||||||
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
|
||||||
)
|
|
||||||
start_command = ShadowModelingCommand.from_command(
|
start_command = ShadowModelingCommand.from_command(
|
||||||
encode_modeling_start(
|
encode_modeling_start(
|
||||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
|
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
|
||||||
|
|
@ -268,20 +405,18 @@ def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() ->
|
||||||
mount_type=MountType.HANDHELD,
|
mount_type=MountType.HANDHELD,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
with pytest.raises(ApplicationAcceptanceError, match="requires.*bootstrap"):
|
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||||
start.execute_modeling(start_command)
|
start.execute_modeling(start_command)
|
||||||
|
|
||||||
stop = PhysicalAcceptanceDialogueExecutor(
|
stop = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
transport,
|
|
||||||
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
|
|
||||||
)
|
|
||||||
stop_command = ShadowModelingCommand.from_command(
|
stop_command = ShadowModelingCommand.from_command(
|
||||||
encode_modeling_stop(
|
encode_modeling_stop(
|
||||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
|
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
|
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||||
assert transport.batches[-1] == ("modeling:stop",)
|
stop.execute_modeling(stop_command)
|
||||||
|
assert transport.batches == []
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None:
|
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
ApplicationControlAuthority,
|
ApplicationControlAuthority,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
ShadowApplicationBootstrapOrchestrator,
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
build_canonical_post_start_observation,
|
||||||
build_shadow_bootstrap,
|
build_shadow_bootstrap,
|
||||||
decode_and_bind_device_info_response,
|
decode_and_bind_device_info_response,
|
||||||
)
|
)
|
||||||
|
|
@ -148,6 +149,21 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
||||||
|
plan = build_canonical_post_start_observation(_authority(), _binding())
|
||||||
|
|
||||||
|
assert [request.ordinal for request in plan.requests] == [12, 13, 14]
|
||||||
|
assert [request.message_type for request in plan.requests] == [
|
||||||
|
"ModelingStatusRequest",
|
||||||
|
"DeviceInfoRequest",
|
||||||
|
"ModelingStatusRequest",
|
||||||
|
]
|
||||||
|
assert [request.payload_bytes for request in plan.requests] == [139, 135, 139]
|
||||||
|
assert [request.response_required for request in plan.requests] == [False, True, True]
|
||||||
|
assert all(request.phase == "post-start-observation" for request in plan.requests)
|
||||||
|
assert all(not request.mutates_device for request in plan.requests)
|
||||||
|
|
||||||
|
|
||||||
def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None:
|
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())
|
response = decode_and_bind_device_info_response(_device_info_response(), _authority())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,28 @@ import pytest
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
LiveDeviceControlBinding,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
CONTROL_SUBSCRIPTION_GROUPS,
|
CONTROL_SUBSCRIPTION_GROUPS,
|
||||||
|
SYSTEM_ERROR_TOPIC,
|
||||||
ApplicationCommandOutcomeUnknown,
|
ApplicationCommandOutcomeUnknown,
|
||||||
|
ApplicationControlDeviceFault,
|
||||||
ReviewedApplicationMqttTransport,
|
ReviewedApplicationMqttTransport,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
OneShotPublishEnvelope,
|
OneShotPublishEnvelope,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
MODELING_STATE_BASE,
|
||||||
|
SYSTEM_ERROR_STATE_BASE,
|
||||||
|
SessionState,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import DEVICE_STATUS_TOPIC
|
||||||
|
|
||||||
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
DEVICE_SERIAL = "K1SERIAL01"
|
||||||
|
|
||||||
|
|
||||||
class FakeReasonCode:
|
class FakeReasonCode:
|
||||||
|
|
@ -35,7 +48,13 @@ class FakeClock:
|
||||||
|
|
||||||
|
|
||||||
class FakeControlClient:
|
class FakeControlClient:
|
||||||
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
emit_exchange: bool = True,
|
||||||
|
clock: FakeClock | None = None,
|
||||||
|
response_topic: str = DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
) -> None:
|
||||||
self.on_connect: Any = None
|
self.on_connect: Any = None
|
||||||
self.on_subscribe: Any = None
|
self.on_subscribe: Any = None
|
||||||
self.on_publish: Any = None
|
self.on_publish: Any = None
|
||||||
|
|
@ -43,6 +62,7 @@ class FakeControlClient:
|
||||||
self.on_disconnect: Any = None
|
self.on_disconnect: Any = None
|
||||||
self.emit_exchange = emit_exchange
|
self.emit_exchange = emit_exchange
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
self.response_topic = response_topic
|
||||||
self.events: deque[tuple[str, object]] = deque()
|
self.events: deque[tuple[str, object]] = deque()
|
||||||
self.connect_calls: list[tuple[str, int, int]] = []
|
self.connect_calls: list[tuple[str, int, int]] = []
|
||||||
self.subscribe_calls: list[list[tuple[str, int]]] = []
|
self.subscribe_calls: list[list[tuple[str, int]]] = []
|
||||||
|
|
@ -74,7 +94,7 @@ class FakeControlClient:
|
||||||
self.next_mid += 1
|
self.next_mid += 1
|
||||||
if self.emit_exchange:
|
if self.emit_exchange:
|
||||||
self.events.append(("publish", mid))
|
self.events.append(("publish", mid))
|
||||||
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
|
self.events.append(("message", (self.response_topic, b"response")))
|
||||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||||
|
|
||||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||||
|
|
@ -124,6 +144,70 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
||||||
|
payload = b"synthetic-reviewed-modeling-status"
|
||||||
|
return OneShotPublishEnvelope(
|
||||||
|
operation_key="dialogue:12:ModelingStatusRequest",
|
||||||
|
topic="lixel/application/request/modeling_status",
|
||||||
|
payload=payload,
|
||||||
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
payload_bytes=len(payload),
|
||||||
|
qos=2,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _device_status(state: SessionState, *, project_bound: bool, init_ready: bool) -> bytes:
|
||||||
|
header = _text(4, VENDOR_DEVICE_ID)
|
||||||
|
fields = [
|
||||||
|
_bytes(1, header),
|
||||||
|
_uint(2, MODELING_STATE_BASE + state),
|
||||||
|
_text(3, DEVICE_SERIAL),
|
||||||
|
]
|
||||||
|
if project_bound:
|
||||||
|
fields.append(_text(4, "project-present"))
|
||||||
|
if init_ready:
|
||||||
|
fields.append(_uint(6, 1))
|
||||||
|
return b"".join(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_error(state: SessionState) -> bytes:
|
||||||
|
return _bytes(15, _uint(1, SYSTEM_ERROR_STATE_BASE + state))
|
||||||
|
|
||||||
|
|
||||||
|
def _binding() -> LiveDeviceControlBinding:
|
||||||
|
return LiveDeviceControlBinding(
|
||||||
|
vendor_device_id=VENDOR_DEVICE_ID,
|
||||||
|
device_serial=DEVICE_SERIAL,
|
||||||
|
software_version="V3.0.2-20260101-release",
|
||||||
|
system_version="V3.0.2",
|
||||||
|
device_model="LixelKity K1",
|
||||||
|
device_type="K1",
|
||||||
|
is_activated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
|
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
|
||||||
with pytest.raises(ValueError, match="not a direct-LAN"):
|
with pytest.raises(ValueError, match="not a direct-LAN"):
|
||||||
ReviewedApplicationMqttTransport("192.168.56.1")
|
ReviewedApplicationMqttTransport("192.168.56.1")
|
||||||
|
|
@ -198,6 +282,96 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
|
||||||
assert len(fake.publish_calls) == 1
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
fake = FakeControlClient(
|
||||||
|
clock=clock,
|
||||||
|
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[_modeling_status_envelope()],
|
||||||
|
required_response_topics=(),
|
||||||
|
)
|
||||||
|
transport.maintain_open_for(
|
||||||
|
2.0,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert responses == {}
|
||||||
|
assert transport.snapshot().state == "ready"
|
||||||
|
assert transport.snapshot().qos2_completions == 1
|
||||||
|
assert transport.snapshot().ignored_known_responses == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_owner_uses_live_state_gates_and_surfaces_system_error() -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
fake = FakeControlClient(clock=clock)
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
|
||||||
|
fake.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(
|
||||||
|
DEVICE_STATUS_TOPIC,
|
||||||
|
_device_status(
|
||||||
|
SessionState.READY,
|
||||||
|
project_bound=False,
|
||||||
|
init_ready=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.maintain_open_for(1.0)
|
||||||
|
assert transport.pre_start_ready(_binding()) is True
|
||||||
|
|
||||||
|
fake.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(
|
||||||
|
DEVICE_STATUS_TOPIC,
|
||||||
|
_device_status(
|
||||||
|
SessionState.SCANNING,
|
||||||
|
project_bound=True,
|
||||||
|
init_ready=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.maintain_open_for(1.0)
|
||||||
|
|
||||||
|
assert transport.scan_initialization_complete(_binding()) is True
|
||||||
|
assert transport.pre_start_ready(_binding()) is False
|
||||||
|
assert transport.standby_complete(_binding()) is False
|
||||||
|
|
||||||
|
fake.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(SYSTEM_ERROR_TOPIC, _system_error(SessionState.ALGORITHM_ERROR)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.maintain_open_for(1.0)
|
||||||
|
|
||||||
|
snapshot = transport.snapshot().as_dict()
|
||||||
|
assert snapshot["device_status_reports"] == 2
|
||||||
|
assert snapshot["system_error_reports"] == 1
|
||||||
|
assert snapshot["latest_system_error_code"] == 0x32040133
|
||||||
|
assert snapshot["latest_system_error_state"] == "algorithm_error"
|
||||||
|
with pytest.raises(ApplicationControlDeviceFault, match="algorithm_error"):
|
||||||
|
transport.scan_initialization_complete(_binding())
|
||||||
|
|
||||||
|
|
||||||
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
fake = FakeControlClient(emit_exchange=False, clock=clock)
|
fake = FakeControlClient(emit_exchange=False, clock=clock)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
MODELING_STATE_BASE,
|
MODELING_STATE_BASE,
|
||||||
OPENAPI_SUCCESS,
|
OPENAPI_SUCCESS,
|
||||||
|
SYSTEM_ERROR_STATE_BASE,
|
||||||
CommandHeaderIdentity,
|
CommandHeaderIdentity,
|
||||||
ModelingAction,
|
ModelingAction,
|
||||||
ModelingCommandRejected,
|
ModelingCommandRejected,
|
||||||
|
|
@ -21,6 +22,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
correlate_modeling_response,
|
correlate_modeling_response,
|
||||||
decode_device_status_report,
|
decode_device_status_report,
|
||||||
decode_modeling_response,
|
decode_modeling_response,
|
||||||
|
decode_system_error_report,
|
||||||
encode_modeling_start,
|
encode_modeling_start,
|
||||||
encode_modeling_stop,
|
encode_modeling_stop,
|
||||||
)
|
)
|
||||||
|
|
@ -277,6 +279,22 @@ def test_device_status_maps_base_offset_states_and_preserves_unknown() -> None:
|
||||||
decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))
|
decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_error_maps_observed_algorithm_error_without_exposing_identity() -> None:
|
||||||
|
identity = _identity()
|
||||||
|
payload = _bytes(1, _header(identity)) + _bytes(
|
||||||
|
15,
|
||||||
|
_uint(1, SYSTEM_ERROR_STATE_BASE + SessionState.ALGORITHM_ERROR),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = decode_system_error_report(payload)
|
||||||
|
|
||||||
|
assert report.error_code == 0x3204_0133
|
||||||
|
assert report.session_state is SessionState.ALGORITHM_ERROR
|
||||||
|
assert "synthetic" not in repr(report)
|
||||||
|
with pytest.raises(ModelingProtocolError, match="no error code"):
|
||||||
|
decode_system_error_report(_bytes(1, _header(identity)))
|
||||||
|
|
||||||
|
|
||||||
def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
|
def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
|
||||||
machine = DeviceAcquisitionStateMachine()
|
machine = DeviceAcquisitionStateMachine()
|
||||||
assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
|
assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue