feat(k1): add physical acceptance transport
This commit is contained in:
parent
a4accf0fb6
commit
eddc09008e
11
README.md
11
README.md
|
|
@ -151,9 +151,14 @@ orchestrator passes all retained response barriers. The installed publisher
|
||||||
boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0
|
boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0
|
||||||
wires a dormant coordinator into the facade: explicit shadow arm holds authority
|
wires a dormant coordinator into the facade: explicit shadow arm holds authority
|
||||||
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
|
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
|
||||||
it together with the orchestrator. No emission method or live sink exists, no UI
|
it together with the orchestrator. A separate, uninstalled physical-acceptance
|
||||||
button claims device control, `vendor_writes_enabled` remains false, and no
|
transport now reproduces MQTT 3.1.1 `clean_session=false`, keepalive 60, exact
|
||||||
application command is sent.
|
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||||
|
It consumes operation keys before publish and poisons unknown outcomes without
|
||||||
|
retry. An interactive Keychain administration command provisions through
|
||||||
|
Apple's hidden prompt without putting the authority in argv. Neither component
|
||||||
|
is wired to facade/UI; `vendor_writes_enabled` remains false and no application
|
||||||
|
command has been sent.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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 (dormant runtime gate complete) — plugin v0.4.0 exposes operator-confirmed shadow arm/disarm/state; authority is held in a 15–300 s non-exportable lease and revoked on expiry, reprovision, acquisition preparation or shutdown; orchestrator is facade-owned, but no emission method/live sink exists and vendor writes remain false |
|
| K1 application control | PAUSE (physical-acceptance software gate complete) — plugin v0.4.0 runtime remains dormant/write-disabled; a separate uninstalled transport now enforces the retained MQTT session, exact response subscriptions, QoS2 one-shot operation keys, five response-gated batches and poison-on-unknown behavior; Keychain provisioning and physical START/STOP remain unexecuted |
|
||||||
| 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
|
||||||
|
|
@ -46,9 +46,15 @@ is now wired into the XGRIDS facade and plugin manifest. Explicit operator arm
|
||||||
loads the Keychain value into a bounded 15–300 second process-memory lease;
|
loads the Keychain value into a bounded 15–300 second process-memory lease;
|
||||||
expiry, disarm, reprovision, acquisition preparation and shutdown drop both the
|
expiry, disarm, reprovision, acquisition preparation and shutdown drop both the
|
||||||
lease and orchestrator. Public state contains only lease timing/booleans. There
|
lease and orchestrator. Public state contains only lease timing/booleans. There
|
||||||
is deliberately no batch-emission method, live MQTT sink or UI button. Keychain
|
is deliberately no batch-emission method, live MQTT sink or UI button in the
|
||||||
item provisioning, a separately reviewed write-capable type and durable
|
installed runtime. A separate physical-acceptance-only transport and dialogue
|
||||||
post-stop save remain unproven. The physical button is the known-safe fallback.
|
executor now implement the reviewed MQTT exchange without facade/UI wiring.
|
||||||
|
Each operation key is consumed before publish, automatic reconnect and
|
||||||
|
application retry are forbidden, and any unknown outcome poisons the transport.
|
||||||
|
The one-time `k1link authority provision` command delegates secret entry to the
|
||||||
|
macOS Keychain TTY prompt and validates through the production loader without
|
||||||
|
receiving the value in argv/environment/file/browser state. The command has not
|
||||||
|
been run. Physical START/STOP remain untested; 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;
|
||||||
|
|
@ -267,7 +273,7 @@ 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;
|
||||||
- operator-owned Keychain item provisioning, separately reviewed emission transport
|
- operator-owned Keychain item provisioning, operator-present physical use of
|
||||||
and stable-artifact proof after the observed stop lifecycle;
|
the reviewed acceptance transport 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.
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,26 @@ operator confirmation, a connected/attested K1, idle runtime and no active
|
||||||
acquisition. Its non-exportable process-memory lease is limited to 15–300
|
acquisition. Its non-exportable process-memory lease is limited to 15–300
|
||||||
seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
|
seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
|
||||||
or shutdown. The current one-shot boundary is structurally write-disabled,
|
or shutdown. The current one-shot boundary is structurally write-disabled,
|
||||||
exposes no emission method and cannot touch its injected sink. The complete
|
exposes no emission method and cannot touch its injected sink.
|
||||||
|
|
||||||
|
A separate physical-acceptance-only implementation now exists outside the
|
||||||
|
facade/runtime. Retained-PCAP re-audit fixes its MQTT contract at 3.1.1,
|
||||||
|
`clean_session=false`, keepalive 60, the three ordered 9/5/42-topic control
|
||||||
|
subscription groups, separation from the point-cloud client, QoS 2
|
||||||
|
request/PUBCOMP completion and
|
||||||
|
`lixel/application/response/modeling`. It performs one connection attempt,
|
||||||
|
consumes every ordinal/action operation key before publish, never reconnects or
|
||||||
|
retries, rejects stale/duplicate/unexpected responses and poisons every unknown
|
||||||
|
post-publish outcome. A 15–120 second permit requires explicit operator-present,
|
||||||
|
owner-controlled-device, LixelGO-closed, battery/storage and expected-state
|
||||||
|
confirmations. START additionally requires the complete five-batch bootstrap;
|
||||||
|
STOP requires its own separate permit. This code is not installed as a plugin
|
||||||
|
action and has never emitted to the K1.
|
||||||
|
|
||||||
|
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
|
||||||
|
accepts it through CLI argv and validates the resulting fixed item through the
|
||||||
|
production loader. The command has not yet been run. The complete
|
||||||
stop/save sequence has not physically proved a durable vendor project. A
|
stop/save sequence has not physically proved a durable vendor project. A
|
||||||
correlated STOP success, stream quiescence, return to
|
correlated STOP success, stream quiescence, return to
|
||||||
`READY`/steady-green hardware state and appearance of a reusable native project
|
`READY`/steady-green hardware state and appearance of a reusable native project
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,9 @@ publishing anything. One private application-level OpenAPI authority is kept
|
||||||
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
||||||
The fixed Keychain loader, bounded execution lease and dormant facade
|
The fixed Keychain loader, bounded execution lease and dormant facade
|
||||||
orchestrator now exist. No emission method or live MQTT sink is installed, and
|
orchestrator now exist. No emission method or live MQTT sink is installed, and
|
||||||
durable save completion after stop remains open, so `vendor_writes_enabled` is
|
an acceptance-only transport exists outside the facade. Keychain provisioning,
|
||||||
false and the physical-button workflow is canonical.
|
physical START/STOP and durable save completion remain open, so
|
||||||
|
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
||||||
|
|
||||||
## Automatic Rerun source and lifecycle
|
## Automatic Rerun source and lifecycle
|
||||||
|
|
||||||
|
|
@ -333,8 +334,8 @@ listener and its process memory must be closed unconditionally.
|
||||||
separate generic media path. Historical sessions predating that archive have
|
separate generic media path. Historical sessions predating that archive have
|
||||||
no recoverable video.
|
no recoverable video.
|
||||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||||
publisher needs reviewed Keychain item provisioning, a new write-capable
|
promotion needs reviewed Keychain item provisioning, operator-present use of
|
||||||
emission type, response-gated one-shot behavior and durable-save proof.
|
the uninstalled response-gated transport and durable-save proof.
|
||||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||||
mission planner or vehicle control is implemented by this viewer milestone.
|
mission planner or vehicle control is implemented by this viewer milestone.
|
||||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,9 @@ is one private application-level authority; vendor ID and serial come from the
|
||||||
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
||||||
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator
|
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator
|
||||||
are present, but there is no emission method or live MQTT sink. Keychain item
|
are present, but there is no emission method or live MQTT sink. Keychain item
|
||||||
provisioning and stable-artifact proof after STOP remain unresolved, so
|
provisioning and stable-artifact proof after STOP remain unresolved. A separate
|
||||||
`vendor_writes_enabled=false`.
|
acceptance-only transport is not imported by the facade, so
|
||||||
|
`vendor_writes_enabled=false` remains authoritative.
|
||||||
|
|
||||||
## Semantic lifecycle
|
## Semantic lifecycle
|
||||||
|
|
||||||
|
|
@ -224,8 +225,8 @@ views.
|
||||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||||
plane. Keep the modeling-command publisher disabled until operator-owned
|
plane. Keep the modeling-command publisher disabled until operator-owned
|
||||||
Keychain item provisioning, reviewed emission and durable-save confirmation
|
Keychain item provisioning, physical acceptance of the reviewed uninstalled
|
||||||
gate.
|
transport and durable-save confirmation gate.
|
||||||
|
|
||||||
Complex equipment will likely be assembled from separately useful component
|
Complex equipment will likely be assembled from separately useful component
|
||||||
plugins into configured hardware packs, while standalone equipment remains
|
plugins into configured hardware packs, while standalone equipment remains
|
||||||
|
|
|
||||||
|
|
@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate.
|
||||||
No retained physical K1 session contains the new canonical camera archive, so a
|
No retained physical K1 session contains the new canonical camera archive, so a
|
||||||
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
||||||
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
||||||
publishing: operator-owned Keychain item provisioning, reviewed emission and
|
publishing: operator-owned Keychain item provisioning, operator-present
|
||||||
durable save remain separate physical/security gates.
|
physical acceptance and durable save remain separate physical/security gates.
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,8 @@ The operator project name is normalized and validated by the K1 contribution,
|
||||||
stored as display/catalog metadata and never used as a path component. This ADR
|
stored as display/catalog metadata and never used as a path component. This ADR
|
||||||
does not claim that it reaches the scanner: the inert application-control codec
|
does not claim that it reaches the scanner: the inert application-control codec
|
||||||
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
||||||
operator-owned Keychain item provisioning, reviewed emission and durable
|
operator-owned Keychain item provisioning, physical acceptance of the
|
||||||
post-stop save evidence.
|
uninstalled transport and durable post-stop save evidence.
|
||||||
|
|
||||||
ADR 0011 subsequently places the action control plane behind a versioned
|
ADR 0011 subsequently places the action control plane behind a versioned
|
||||||
descriptor/handshake/health transport seam. Observation discovery and export
|
descriptor/handshake/health transport seam. Observation discovery and export
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,33 @@ shadow arm/disarm/state actions and redacted snapshots. The coordinator has no
|
||||||
request-emission method and the installed sink is explicitly uninstalled; this
|
request-emission method and the installed sink is explicitly uninstalled; this
|
||||||
runtime wiring does not widen write authority.
|
runtime wiring does not widen write authority.
|
||||||
|
|
||||||
|
`application_mqtt.py` and `application_acceptance.py` provide the next, still
|
||||||
|
uninstalled physical-acceptance boundary. The MQTT type repeats the retained
|
||||||
|
3.1.1 session (`clean_session=false`, keepalive 60), subscribes only to exact
|
||||||
|
known response topics, admits only reviewed request topics and QoS2/retain-false
|
||||||
|
envelopes, and consumes explicit bootstrap ordinals/modeling actions before the
|
||||||
|
first publish call. It performs one connect attempt with no reconnect. PUBCOMP
|
||||||
|
and every required application response are separate barriers; stale,
|
||||||
|
duplicate, unexpected, oversized or timed-out responses poison the transport.
|
||||||
|
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 acceptance executor requires a 15–120 second single-action permit backed by
|
||||||
|
five explicit operator confirmations. START cannot skip the five recovered
|
||||||
|
bootstrap batches; STOP uses a separate permit. These types are not imported by
|
||||||
|
the facade, declared as plugin actions or represented by UI controls.
|
||||||
|
|
||||||
|
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
|
||||||
|
`security add-generic-password ... -w` prompt with `-w` last. The private value
|
||||||
|
therefore never enters Mission Core argv, environment, files, logs or browser
|
||||||
|
state, and the resulting item is immediately validated by the fixed loader.
|
||||||
|
The repository CLI exposes this as an explicit one-time admin command, but the
|
||||||
|
command has not been executed in this milestone.
|
||||||
|
|
||||||
## Promotion gate
|
## Promotion gate
|
||||||
|
|
||||||
A future publisher requires separate review and an operator-present physical
|
Promotion now requires operator-present physical acceptance of the uninstalled
|
||||||
acceptance:
|
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;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,20 @@ Observed K1 transports:
|
||||||
|
|
||||||
No LixelGO UDP flow was observed.
|
No LixelGO UDP flow was observed.
|
||||||
|
|
||||||
|
The retained clean-cycle control connection was subsequently re-audited without
|
||||||
|
capturing new traffic. MQTT CONNECT used protocol level 4 / MQTT 3.1.1,
|
||||||
|
`clean_session=false` and keepalive 60. Control and point-cloud subscriptions
|
||||||
|
used separate 19-byte client IDs; their values remain private. The control
|
||||||
|
client issued three ordered subscription groups containing 9, 5 and 42 topics
|
||||||
|
before issuing requests. The separate point-cloud client then issued groups of
|
||||||
|
1 and 1 topic; their values are the already documented legacy/current point
|
||||||
|
channels. The control group order, topic names and requested QoS values are now
|
||||||
|
locked by public synthetic regression without exposing client IDs or payloads.
|
||||||
|
The modeling acknowledgement topic is
|
||||||
|
`lixel/application/response/modeling`; request QoS was 2 while the K1 delivered
|
||||||
|
the observed application response PUBLISH packets at QoS 0. These redacted
|
||||||
|
facts bound the acceptance transport but do not authorize it.
|
||||||
|
|
||||||
## Start and static initialization
|
## Start and static initialization
|
||||||
|
|
||||||
The application start transaction is now physical wire evidence rather than
|
The application start transaction is now physical wire evidence rather than
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,12 @@ uv run python plugins/xgrids-k1/profile_loader.py
|
||||||
|
|
||||||
Plugin v0.4.0 does not widen device authority. Its application-control
|
Plugin v0.4.0 does not widen device authority. Its application-control
|
||||||
coordinator has no request-emission method, no live MQTT sink and no UI control;
|
coordinator has no request-emission method, no live MQTT sink and no UI control;
|
||||||
`vendor_writes_enabled` remains false. Keychain item provisioning and a separate
|
`vendor_writes_enabled` remains false. A separate uninstalled acceptance-only
|
||||||
review of any future write-capable transport are still required before a
|
transport now implements exact subscriptions, QoS2 completion, response
|
||||||
physical command can be considered.
|
barriers and no-retry poisoning. The admin CLI can provision the fixed Keychain
|
||||||
|
item through Apple's hidden prompt without accepting the value as an argument.
|
||||||
|
Neither path is imported by the facade; Keychain provisioning and a controlled
|
||||||
|
physical START/STOP acceptance are still required.
|
||||||
|
|
||||||
The optional owner-controlled iPhone/LixelGO observation tool lives under
|
The optional owner-controlled iPhone/LixelGO observation tool lives under
|
||||||
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
|
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,13 @@ literal `{device_id}:ModelingRequest` session relation, retained start settings
|
||||||
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
||||||
one private application-level value rather than a per-scanner profile. A fixed
|
one private application-level value rather than a per-scanner profile. A fixed
|
||||||
read-only macOS Keychain loader, bounded in-memory lease and dormant facade
|
read-only macOS Keychain loader, bounded in-memory lease and dormant facade
|
||||||
orchestrator now exist. No request-emission method or live MQTT sink exists;
|
orchestrator now exist. The installed runtime has no request-emission method or
|
||||||
operator-owned Keychain item provisioning, reviewed emission, durable save
|
live MQTT sink. A separate uninstalled acceptance transport implements the
|
||||||
completion, timeout and rollback behavior remain unresolved. Acquisition
|
retained MQTT session, exact response subscriptions and poison-on-unknown
|
||||||
therefore stays `operator-manual` through the verified physical double-click.
|
one-shot behavior. Operator-owned Keychain item provisioning, physical
|
||||||
|
acceptance, durable save completion and rollback evidence remain unresolved.
|
||||||
|
Acquisition therefore stays `operator-manual` through the verified physical
|
||||||
|
double-click.
|
||||||
The standalone encoder models the recovered wire schema, including enum values
|
The standalone encoder models the recovered wire schema, including enum values
|
||||||
outside the retained request. It is not an authorization policy: any future
|
outside the retained request. It is not an authorization policy: any future
|
||||||
publisher must enforce the exact profile mapping (`2/1/0`, omitted
|
publisher must enforce the exact profile mapping (`2/1/0`, omitted
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@
|
||||||
},
|
},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"operator-owned Keychain item provisioning and reviewed request emission",
|
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||||
"authorization policy for any setting outside the retained request",
|
"authorization policy for any setting outside the retained request",
|
||||||
"timeout, rejection and rollback contract"
|
"timeout, rejection and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
@ -406,7 +406,7 @@
|
||||||
"request_fields": {},
|
"request_fields": {},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"operator-owned Keychain item provisioning and reviewed request emission",
|
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||||
"save-completion and final-standby state mapping",
|
"save-completion and final-standby state mapping",
|
||||||
"timeout and rollback contract"
|
"timeout and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||||
capture_mqtt,
|
capture_mqtt,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
|
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
ApplicationAuthorityLoadError,
|
||||||
|
MacOSKeychainApplicationAuthorityProvisioner,
|
||||||
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.usb.snapshot import snapshot as usb_snapshot
|
from k1link.device_plugins.xgrids_k1.usb.snapshot import snapshot as usb_snapshot
|
||||||
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
||||||
|
|
||||||
|
|
@ -49,10 +53,15 @@ ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_a
|
||||||
net_app = typer.Typer(help="Read-only local network observation commands.", no_args_is_help=True)
|
net_app = typer.Typer(help="Read-only local network observation commands.", no_args_is_help=True)
|
||||||
usb_app = typer.Typer(help="Read-only macOS USB metadata commands.", no_args_is_help=True)
|
usb_app = typer.Typer(help="Read-only macOS USB metadata commands.", no_args_is_help=True)
|
||||||
analyze_app = typer.Typer(help="Bounded offline evidence analysis commands.", no_args_is_help=True)
|
analyze_app = typer.Typer(help="Bounded offline evidence analysis commands.", no_args_is_help=True)
|
||||||
|
authority_app = typer.Typer(
|
||||||
|
help="One-time local authority administration; never sends a K1 command.",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
app.add_typer(ble_app, name="ble")
|
app.add_typer(ble_app, name="ble")
|
||||||
app.add_typer(net_app, name="net")
|
app.add_typer(net_app, name="net")
|
||||||
app.add_typer(usb_app, name="usb")
|
app.add_typer(usb_app, name="usb")
|
||||||
app.add_typer(analyze_app, name="analyze")
|
app.add_typer(analyze_app, name="analyze")
|
||||||
|
app.add_typer(authority_app, name="authority")
|
||||||
|
|
||||||
|
|
||||||
class ToolStatus(TypedDict):
|
class ToolStatus(TypedDict):
|
||||||
|
|
@ -101,6 +110,39 @@ def _command_output(args: list[str]) -> str | None:
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@authority_app.command("provision")
|
||||||
|
def authority_provision(
|
||||||
|
confirm_reviewed_authority: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--confirm-reviewed-authority",
|
||||||
|
help="Confirm this is the reviewed private LixelGO FW 3.0.2 authority.",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Prompt in macOS Keychain without receiving or printing the authority."""
|
||||||
|
|
||||||
|
if not confirm_reviewed_authority:
|
||||||
|
console.print(
|
||||||
|
"[red]Authority provisioning not confirmed.[/red] "
|
||||||
|
"Add --confirm-reviewed-authority only for the reviewed private value."
|
||||||
|
)
|
||||||
|
raise typer.Exit(code=2)
|
||||||
|
console.print(
|
||||||
|
"macOS Keychain will prompt for the reviewed authority. Mission Core will not "
|
||||||
|
"receive it through argv, environment, a file, browser state or logs."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
source = MacOSKeychainApplicationAuthorityProvisioner().provision_interactively()
|
||||||
|
except ApplicationAuthorityLoadError as exc:
|
||||||
|
console.print(f"[red]Authority provisioning failed:[/red] {exc}")
|
||||||
|
raise typer.Exit(code=2) from exc
|
||||||
|
console.print(
|
||||||
|
f"[green]Keychain item validated.[/green] service={source.service!r}; "
|
||||||
|
f"account={source.account!r}. No K1 command was sent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _default_route_interface() -> str | None:
|
def _default_route_interface() -> str | None:
|
||||||
output = _command_output(["route", "-n", "get", "default"])
|
output = _command_output(["route", "-n", "get", "default"])
|
||||||
if output is None:
|
if output is None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Collection, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
LiveDeviceControlBinding,
|
||||||
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
|
OneShotPublishEnvelope,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
ModelingAction,
|
||||||
|
ModelingResponse,
|
||||||
|
correlate_modeling_response,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
ShadowModelingCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
MIN_ACCEPTANCE_PERMIT_SECONDS = 15.0
|
||||||
|
MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationAcceptanceError(RuntimeError):
|
||||||
|
"""The operator-present physical acceptance contract failed closed."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationBatchExchange(Protocol):
|
||||||
|
def exchange_batch_once(
|
||||||
|
self,
|
||||||
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
|
*,
|
||||||
|
required_response_topics: Collection[str],
|
||||||
|
) -> dict[str, bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PhysicalAcceptanceChecklist:
|
||||||
|
action: ModelingAction
|
||||||
|
operator_present: Literal[True]
|
||||||
|
owner_controlled_device: Literal[True]
|
||||||
|
lixelgo_closed: Literal[True]
|
||||||
|
battery_storage_confirmed: Literal[True]
|
||||||
|
expected_physical_state_confirmed: Literal[True]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
confirmations = (
|
||||||
|
self.operator_present,
|
||||||
|
self.owner_controlled_device,
|
||||||
|
self.lixelgo_closed,
|
||||||
|
self.battery_storage_confirmed,
|
||||||
|
self.expected_physical_state_confirmed,
|
||||||
|
)
|
||||||
|
if any(value is not True for value in confirmations):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"every physical acceptance confirmation must be explicitly true"
|
||||||
|
)
|
||||||
|
if type(self.action) is not ModelingAction:
|
||||||
|
raise ApplicationAcceptanceError("acceptance action must be an explicit ModelingAction")
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicalAcceptancePermit:
|
||||||
|
"""Short, single-action capability that is consumed before MQTT publish."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
checklist: PhysicalAcceptanceChecklist,
|
||||||
|
*,
|
||||||
|
ttl_seconds: float = 60.0,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(ttl_seconds, (int, float)) or isinstance(ttl_seconds, bool):
|
||||||
|
raise ApplicationAcceptanceError("acceptance permit TTL must be numeric")
|
||||||
|
if not math.isfinite(ttl_seconds) or not (
|
||||||
|
MIN_ACCEPTANCE_PERMIT_SECONDS <= ttl_seconds <= MAX_ACCEPTANCE_PERMIT_SECONDS
|
||||||
|
):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"acceptance permit TTL is outside the reviewed 15-120 second range"
|
||||||
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._checklist = checklist
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._expires_at = monotonic() + float(ttl_seconds)
|
||||||
|
self._consumed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action(self) -> ModelingAction:
|
||||||
|
return self._checklist.action
|
||||||
|
|
||||||
|
def consume(self, action: ModelingAction) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._consumed:
|
||||||
|
raise ApplicationAcceptanceError("physical acceptance permit was already consumed")
|
||||||
|
if self._monotonic() >= self._expires_at:
|
||||||
|
raise ApplicationAcceptanceError("physical acceptance permit expired")
|
||||||
|
if action is not self._checklist.action:
|
||||||
|
raise ApplicationAcceptanceError("physical acceptance permit action mismatch")
|
||||||
|
self._consumed = True
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
remaining = max(0.0, self._expires_at - self._monotonic())
|
||||||
|
return {
|
||||||
|
"mode": "operator-present-physical-acceptance",
|
||||||
|
"action": self._checklist.action.name.casefold(),
|
||||||
|
"consumed": self._consumed,
|
||||||
|
"remaining_seconds": round(remaining, 3),
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicalAcceptanceDialogueExecutor:
|
||||||
|
"""Drive the recovered barriers and exactly one permitted START or STOP."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: ApplicationBatchExchange,
|
||||||
|
permit: PhysicalAcceptancePermit,
|
||||||
|
) -> None:
|
||||||
|
self._transport = transport
|
||||||
|
self._permit = permit
|
||||||
|
self._bootstrap_complete = False
|
||||||
|
self._command_complete = False
|
||||||
|
|
||||||
|
def run_bootstrap(
|
||||||
|
self,
|
||||||
|
orchestrator: ShadowApplicationBootstrapOrchestrator,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
if self._bootstrap_complete or self._command_complete:
|
||||||
|
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:
|
||||||
|
orchestrator.accept_response(
|
||||||
|
request.response_topic,
|
||||||
|
responses[request.response_topic],
|
||||||
|
)
|
||||||
|
|
||||||
|
binding = orchestrator.binding
|
||||||
|
if binding is None:
|
||||||
|
raise ApplicationAcceptanceError("bootstrap completed without a live device binding")
|
||||||
|
self._bootstrap_complete = True
|
||||||
|
return binding
|
||||||
|
|
||||||
|
def execute_modeling(self, command: ShadowModelingCommand) -> ModelingResponse:
|
||||||
|
if self._command_complete:
|
||||||
|
raise ApplicationAcceptanceError("physical acceptance command was already attempted")
|
||||||
|
if command.action is ModelingAction.START and not self._bootstrap_complete:
|
||||||
|
raise ApplicationAcceptanceError("START requires the complete response-gated bootstrap")
|
||||||
|
self._permit.consume(command.action)
|
||||||
|
self._command_complete = True
|
||||||
|
responses = self._transport.exchange_batch_once(
|
||||||
|
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||||
|
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
return correlate_modeling_response(
|
||||||
|
responses[MODELING_RESPONSE_TOPIC],
|
||||||
|
command.command,
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"mode": "physical-acceptance-only",
|
||||||
|
"bootstrap_complete": self._bootstrap_complete,
|
||||||
|
"command_complete": self._command_complete,
|
||||||
|
"permit": self._permit.snapshot(),
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
|
KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
|
||||||
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
|
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
|
||||||
KEYCHAIN_TIMEOUT_SECONDS = 5.0
|
KEYCHAIN_TIMEOUT_SECONDS = 5.0
|
||||||
|
KEYCHAIN_INTERACTIVE_TIMEOUT_SECONDS = 300.0
|
||||||
|
|
||||||
|
|
||||||
class ApplicationAuthorityLoadError(RuntimeError):
|
class ApplicationAuthorityLoadError(RuntimeError):
|
||||||
|
|
@ -31,6 +33,16 @@ class CommandRunner(Protocol):
|
||||||
) -> subprocess.CompletedProcess[bytes]: ...
|
) -> subprocess.CompletedProcess[bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveCommandRunner(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
check: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationAuthoritySourceSnapshot:
|
class ApplicationAuthoritySourceSnapshot:
|
||||||
provider: str = "macos-keychain"
|
provider: str = "macos-keychain"
|
||||||
|
|
@ -116,3 +128,63 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||||
finally:
|
finally:
|
||||||
for index in range(len(secret_buffer)):
|
for index in range(len(secret_buffer)):
|
||||||
secret_buffer[index] = 0
|
secret_buffer[index] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MacOSKeychainApplicationAuthorityProvisioner:
|
||||||
|
"""Create or replace the fixed item through Apple's own hidden prompt.
|
||||||
|
|
||||||
|
``security add-generic-password`` receives ``-w`` as its final option and
|
||||||
|
prompts on the controlling TTY. The authority therefore never enters a
|
||||||
|
Python string, subprocess argv, environment variable, file, log or browser
|
||||||
|
request. Provisioning is an explicit one-time operator/admin action and is
|
||||||
|
intentionally unavailable without an interactive terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
runner: InteractiveCommandRunner = subprocess.run,
|
||||||
|
loader: MacOSKeychainApplicationAuthorityLoader | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._runner = runner
|
||||||
|
self._loader = loader or MacOSKeychainApplicationAuthorityLoader()
|
||||||
|
|
||||||
|
def provision_interactively(self) -> ApplicationAuthoritySourceSnapshot:
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"application authority provisioning is supported only through macOS Keychain"
|
||||||
|
)
|
||||||
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"application authority provisioning requires an interactive terminal"
|
||||||
|
)
|
||||||
|
security = shutil.which("security")
|
||||||
|
if security != "/usr/bin/security":
|
||||||
|
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
completed = self._runner(
|
||||||
|
[
|
||||||
|
security,
|
||||||
|
"add-generic-password",
|
||||||
|
"-U",
|
||||||
|
"-s",
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
"-a",
|
||||||
|
KEYCHAIN_ACCOUNT,
|
||||||
|
"-w",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
timeout=KEYCHAIN_INTERACTIVE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"macOS Keychain authority provisioning failed"
|
||||||
|
) from exc
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority was not provisioned")
|
||||||
|
|
||||||
|
# Re-open through the production loader so a wrong value fails before
|
||||||
|
# any Mission Core control lease can be armed.
|
||||||
|
self._loader.load()
|
||||||
|
return self._loader.snapshot()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,587 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Collection, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from paho.mqtt.enums import CallbackAPIVersion
|
||||||
|
from paho.mqtt.properties import Properties
|
||||||
|
from paho.mqtt.reasoncodes import ReasonCode
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||||
|
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
|
OneShotPublishEnvelope,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
MODELING_REQUEST_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODELING_RESPONSE_TOPIC = "lixel/application/response/modeling"
|
||||||
|
CONTROL_KEEPALIVE_SECONDS = 60
|
||||||
|
CONTROL_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
|
CONTROL_EXCHANGE_TIMEOUT_SECONDS = 5.0
|
||||||
|
CONTROL_LOOP_INTERVAL_SECONDS = 0.05
|
||||||
|
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
||||||
|
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
||||||
|
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
||||||
|
(
|
||||||
|
("RealtimePath", 0),
|
||||||
|
("RtkLioFusion", 0),
|
||||||
|
("RtkPoseFusion", 0),
|
||||||
|
("PrePathArray", 0),
|
||||||
|
("ScanStatus", 0),
|
||||||
|
("lixel/application/report/lio_pose", 0),
|
||||||
|
("lixel/application/report/device_status", 0),
|
||||||
|
("lixel/application/report/system_error", 0),
|
||||||
|
(DEVICE_INFO_RESPONSE_TOPIC, 0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("SystemStatus", 1),
|
||||||
|
("DeviceStatus", 1),
|
||||||
|
("StatisticalData", 1),
|
||||||
|
("lixel/application/report/modeling", 1),
|
||||||
|
("lixel/application/report/upgrade", 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("AbnormalStatus", 2),
|
||||||
|
("ActionApp2DeviceResponse", 2),
|
||||||
|
("ActionAppSyncTimeResponse", 2),
|
||||||
|
("DeviceInfoResponse", 2),
|
||||||
|
("DeviceActivateResponse", 2),
|
||||||
|
("CumulativeResponse", 2),
|
||||||
|
("DebugStatusResponse", 2),
|
||||||
|
("ModelingInfoResponse", 2),
|
||||||
|
("ControlPointResponse", 2),
|
||||||
|
("GetNtripConfigResponse", 2),
|
||||||
|
("SetNtripConfigResponse", 2),
|
||||||
|
("ProjectOperationResponse", 2),
|
||||||
|
("PotreeConvertResponse", 2),
|
||||||
|
("SetProjectNoteResponse", 2),
|
||||||
|
("GetAppConfigResponse", 2),
|
||||||
|
("SetAppConfigResponse", 2),
|
||||||
|
("GetMqttConfigResponse", 2),
|
||||||
|
("SetMqttConfigResponse", 2),
|
||||||
|
("lixel/application/report/heartbeat", 2),
|
||||||
|
("lixel/calibration/response/lidar", 2),
|
||||||
|
("lixel/application/response/cameraConfig", 2),
|
||||||
|
("lixel/application/response/service", 2),
|
||||||
|
("lixel/application/report/pgo_progress", 2),
|
||||||
|
("lixel/application/response/rtk/set_satellite_system", 2),
|
||||||
|
("lixel/application/report/modeling/status", 2),
|
||||||
|
("lixel/application/response/project_file", 2),
|
||||||
|
(GET_CLOUD_CONFIG_RESPONSE_TOPIC, 2),
|
||||||
|
("lixel/application/response/set_cloud_server_config", 2),
|
||||||
|
(GET_NTRIP_PROFILE_RESPONSE_TOPIC, 2),
|
||||||
|
("lixel/application/response/set_ntrip_profile", 2),
|
||||||
|
(GET_RTK_ADVANCE_RESPONSE_TOPIC, 2),
|
||||||
|
("lixel/application/response/set_rtk_advance", 2),
|
||||||
|
(MODELING_STATUS_RESPONSE_TOPIC, 2),
|
||||||
|
("lixel/application/response/control_point", 2),
|
||||||
|
("lixel/application/response/service_control", 2),
|
||||||
|
("lixel/application/response/project_list", 2),
|
||||||
|
("lixel/application/response/device_control", 2),
|
||||||
|
(DEVICE_CONFIG_RESPONSE_TOPIC, 2),
|
||||||
|
(MODELING_RESPONSE_TOPIC, 2),
|
||||||
|
("lixel/application/report/control_point", 2),
|
||||||
|
("lixel/application/response/measure_point", 2),
|
||||||
|
("lixel/application/response/upgrade", 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLICATION_RESPONSE_TOPICS = frozenset(
|
||||||
|
{
|
||||||
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
|
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||||
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTROL_REPORT_TOPICS = frozenset(
|
||||||
|
topic
|
||||||
|
for group in CONTROL_SUBSCRIPTION_GROUPS
|
||||||
|
for topic, _qos in group
|
||||||
|
if "Response" not in topic and "/response/" not in topic
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLICATION_REQUEST_ALLOWLIST = frozenset(
|
||||||
|
{
|
||||||
|
"lixel/application/request/device_info",
|
||||||
|
"lixel/application/request/modeling_status",
|
||||||
|
"lixel/application/request/get_rtk_advance",
|
||||||
|
"lixel/application/request/device_config",
|
||||||
|
"lixel/application/request/get_ntrip_profile",
|
||||||
|
"lixel/application/request/get_cloud_server_config",
|
||||||
|
MODELING_REQUEST_TOPIC,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationMqttTransportError(RuntimeError):
|
||||||
|
"""The reviewed control transport failed before a request was attempted."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationCommandOutcomeUnknown(RuntimeError):
|
||||||
|
"""A request may have reached the K1 and must never be retried automatically."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApplicationMqttTransportSnapshot:
|
||||||
|
state: str
|
||||||
|
connect_attempts: int
|
||||||
|
subscribe_attempts: int
|
||||||
|
publish_attempts: int
|
||||||
|
qos2_completions: int
|
||||||
|
correlated_responses: int
|
||||||
|
ignored_known_responses: int
|
||||||
|
operation_keys_consumed: int
|
||||||
|
clean_session: bool = False
|
||||||
|
keepalive_seconds: int = CONTROL_KEEPALIVE_SECONDS
|
||||||
|
automatic_reconnect: bool = False
|
||||||
|
automatic_retry: bool = False
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"mode": "physical-acceptance-only",
|
||||||
|
"state": self.state,
|
||||||
|
"connect_attempts": self.connect_attempts,
|
||||||
|
"subscribe_attempts": self.subscribe_attempts,
|
||||||
|
"publish_attempts": self.publish_attempts,
|
||||||
|
"qos2_completions": self.qos2_completions,
|
||||||
|
"correlated_responses": self.correlated_responses,
|
||||||
|
"ignored_known_responses": self.ignored_known_responses,
|
||||||
|
"operation_keys_consumed": self.operation_keys_consumed,
|
||||||
|
"clean_session": self.clean_session,
|
||||||
|
"keepalive_seconds": self.keepalive_seconds,
|
||||||
|
"automatic_reconnect": self.automatic_reconnect,
|
||||||
|
"automatic_retry": self.automatic_retry,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewedApplicationMqttTransport:
|
||||||
|
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
|
||||||
|
|
||||||
|
This type is deliberately not installed in the facade or plugin runtime.
|
||||||
|
It performs one connection attempt, never reconnects, consumes every
|
||||||
|
operation key before calling ``publish``, and permanently poisons itself
|
||||||
|
after any unknown post-publish outcome.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
*,
|
||||||
|
port: int = 1883,
|
||||||
|
connect_timeout_seconds: float = CONTROL_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
exchange_timeout_seconds: float = CONTROL_EXCHANGE_TIMEOUT_SECONDS,
|
||||||
|
client_factory: Callable[[], mqtt.Client] | None = None,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._target_ipv4 = validate_private_ipv4(host)
|
||||||
|
if self._target_ipv4 == AP_FALLBACK_IPV4:
|
||||||
|
raise ValueError("K1 access-point fallback address is not a direct-LAN control target")
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
raise ValueError("port must be between 1 and 65535")
|
||||||
|
for name, value in (
|
||||||
|
("connect_timeout_seconds", connect_timeout_seconds),
|
||||||
|
("exchange_timeout_seconds", exchange_timeout_seconds),
|
||||||
|
):
|
||||||
|
if not math.isfinite(value) or value <= 0:
|
||||||
|
raise ValueError(f"{name} must be finite and greater than zero")
|
||||||
|
self._port = port
|
||||||
|
self._connect_timeout_seconds = connect_timeout_seconds
|
||||||
|
self._exchange_timeout_seconds = exchange_timeout_seconds
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._client_factory = client_factory
|
||||||
|
self._client: mqtt.Client | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._state = "new"
|
||||||
|
self._connected = False
|
||||||
|
self._subscribed = False
|
||||||
|
self._closing = False
|
||||||
|
self._subscription_mid: int | None = None
|
||||||
|
self._subscription_group_index = 0
|
||||||
|
self._completed_publish_mids: set[int] = set()
|
||||||
|
self._messages: deque[tuple[str, bytes]] = deque()
|
||||||
|
self._callback_error: str | None = None
|
||||||
|
self._consumed_operation_keys: set[str] = set()
|
||||||
|
self._connect_attempts = 0
|
||||||
|
self._subscribe_attempts = 0
|
||||||
|
self._publish_attempts = 0
|
||||||
|
self._qos2_completions = 0
|
||||||
|
self._correlated_responses = 0
|
||||||
|
self._ignored_known_responses = 0
|
||||||
|
|
||||||
|
def open(self) -> ApplicationMqttTransportSnapshot:
|
||||||
|
with self._lock:
|
||||||
|
if self._state != "new":
|
||||||
|
raise ApplicationMqttTransportError("control transport can be opened only once")
|
||||||
|
self._state = "connecting"
|
||||||
|
self._connect_attempts = 1
|
||||||
|
client = self._new_client()
|
||||||
|
self._install_callbacks(client)
|
||||||
|
self._client = client
|
||||||
|
try:
|
||||||
|
result = client.connect(
|
||||||
|
self._target_ipv4,
|
||||||
|
port=self._port,
|
||||||
|
keepalive=CONTROL_KEEPALIVE_SECONDS,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
self._fail_before_publish("control MQTT connect call failed", exc)
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
self._fail_before_publish("control MQTT connect call was rejected")
|
||||||
|
|
||||||
|
deadline = self._monotonic() + self._connect_timeout_seconds
|
||||||
|
self._drive_until(lambda: self._subscribed, deadline, post_publish=False)
|
||||||
|
with self._lock:
|
||||||
|
self._state = "ready"
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def exchange_batch_once(
|
||||||
|
self,
|
||||||
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
|
*,
|
||||||
|
required_response_topics: Collection[str],
|
||||||
|
) -> dict[str, bytes]:
|
||||||
|
batch = tuple(envelopes)
|
||||||
|
required = frozenset(required_response_topics)
|
||||||
|
if not batch:
|
||||||
|
raise ValueError("control exchange batch must not be empty")
|
||||||
|
if not required or not required <= APPLICATION_RESPONSE_TOPICS:
|
||||||
|
raise ValueError("required response topics exceed the reviewed allowlist")
|
||||||
|
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
||||||
|
if len(set(operation_keys)) != len(operation_keys):
|
||||||
|
raise ValueError("control exchange batch repeats an operation key")
|
||||||
|
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
|
||||||
|
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
|
||||||
|
allowed_non_barrier_responses = (
|
||||||
|
frozenset({MODELING_STATUS_RESPONSE_TOPIC})
|
||||||
|
if any(
|
||||||
|
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch
|
||||||
|
)
|
||||||
|
else frozenset()
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._consumed_operation_keys.intersection(operation_keys):
|
||||||
|
raise ApplicationCommandOutcomeUnknown(
|
||||||
|
"control operation key was already consumed; retry is forbidden"
|
||||||
|
)
|
||||||
|
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||||
|
raise ApplicationMqttTransportError("control transport is not ready")
|
||||||
|
stale_response = bool(self._messages)
|
||||||
|
if stale_response:
|
||||||
|
self._poison_locked("stale response preceded the request batch")
|
||||||
|
self._consumed_operation_keys.update(operation_keys)
|
||||||
|
if stale_response:
|
||||||
|
self.close()
|
||||||
|
raise ApplicationCommandOutcomeUnknown(
|
||||||
|
"stale control response makes the next command outcome ambiguous"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
publish_mids: set[int] = set()
|
||||||
|
for envelope in batch:
|
||||||
|
with self._lock:
|
||||||
|
self._publish_attempts += 1
|
||||||
|
try:
|
||||||
|
info = client.publish(
|
||||||
|
envelope.topic,
|
||||||
|
payload=envelope.payload,
|
||||||
|
qos=envelope.qos,
|
||||||
|
retain=envelope.retain,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
self._fail_after_publish("control MQTT publish call failed", exc)
|
||||||
|
if info.rc != mqtt.MQTT_ERR_SUCCESS or info.mid is None:
|
||||||
|
self._fail_after_publish("control MQTT publish call returned an unsafe result")
|
||||||
|
if info.mid in publish_mids:
|
||||||
|
self._fail_after_publish("control MQTT reused a packet identifier")
|
||||||
|
publish_mids.add(info.mid)
|
||||||
|
|
||||||
|
responses: dict[str, bytes] = {}
|
||||||
|
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||||
|
|
||||||
|
def complete() -> bool:
|
||||||
|
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||||
|
with self._lock:
|
||||||
|
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
|
||||||
|
|
||||||
|
self._drive_until(complete, deadline, post_publish=True)
|
||||||
|
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||||
|
with self._lock:
|
||||||
|
self._correlated_responses += len(responses)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._state == "closed":
|
||||||
|
return
|
||||||
|
self._closing = True
|
||||||
|
client = self._client
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
if self._subscribed:
|
||||||
|
client.unsubscribe(
|
||||||
|
[topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group]
|
||||||
|
)
|
||||||
|
client.disconnect()
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self._connected = False
|
||||||
|
self._subscribed = False
|
||||||
|
self._messages.clear()
|
||||||
|
if self._state not in {"poisoned", "failed"}:
|
||||||
|
self._state = "closed"
|
||||||
|
|
||||||
|
def snapshot(self) -> ApplicationMqttTransportSnapshot:
|
||||||
|
with self._lock:
|
||||||
|
return ApplicationMqttTransportSnapshot(
|
||||||
|
state=self._state,
|
||||||
|
connect_attempts=self._connect_attempts,
|
||||||
|
subscribe_attempts=self._subscribe_attempts,
|
||||||
|
publish_attempts=self._publish_attempts,
|
||||||
|
qos2_completions=self._qos2_completions,
|
||||||
|
correlated_responses=self._correlated_responses,
|
||||||
|
ignored_known_responses=self._ignored_known_responses,
|
||||||
|
operation_keys_consumed=len(self._consumed_operation_keys),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _new_client(self) -> mqtt.Client:
|
||||||
|
if self._client_factory is not None:
|
||||||
|
return self._client_factory()
|
||||||
|
client_id = f"mck1-{secrets.token_hex(7)}"
|
||||||
|
return mqtt.Client(
|
||||||
|
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=client_id,
|
||||||
|
clean_session=False,
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
reconnect_on_failure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _install_callbacks(self, client: mqtt.Client) -> None:
|
||||||
|
def on_connect(
|
||||||
|
callback_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_flags: mqtt.ConnectFlags,
|
||||||
|
reason_code: ReasonCode,
|
||||||
|
_properties: Properties | None,
|
||||||
|
) -> None:
|
||||||
|
if reason_code.is_failure:
|
||||||
|
self._set_callback_error("control MQTT broker rejected connection")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._connected = True
|
||||||
|
self._subscribe_next_group(callback_client)
|
||||||
|
|
||||||
|
def on_subscribe(
|
||||||
|
_callback_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
mid: int,
|
||||||
|
reason_codes: list[ReasonCode],
|
||||||
|
_properties: Properties | None,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
expected_mid = self._subscription_mid
|
||||||
|
group_index = self._subscription_group_index
|
||||||
|
if group_index >= len(CONTROL_SUBSCRIPTION_GROUPS):
|
||||||
|
self._set_callback_error("control MQTT received a surplus SUBACK")
|
||||||
|
return
|
||||||
|
expected_group = CONTROL_SUBSCRIPTION_GROUPS[group_index]
|
||||||
|
if mid != expected_mid or len(reason_codes) != len(expected_group):
|
||||||
|
self._set_callback_error("control MQTT received an unexpected SUBACK")
|
||||||
|
return
|
||||||
|
if any(reason_code.is_failure for reason_code in reason_codes):
|
||||||
|
self._set_callback_error("control MQTT broker rejected a response subscription")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._subscription_group_index += 1
|
||||||
|
complete = self._subscription_group_index == len(CONTROL_SUBSCRIPTION_GROUPS)
|
||||||
|
self._subscribed = complete
|
||||||
|
if not complete:
|
||||||
|
self._subscribe_next_group(_callback_client)
|
||||||
|
|
||||||
|
def on_publish(
|
||||||
|
_callback_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
mid: int,
|
||||||
|
reason_code: ReasonCode,
|
||||||
|
_properties: Properties | None,
|
||||||
|
) -> None:
|
||||||
|
if reason_code.is_failure:
|
||||||
|
self._set_callback_error("control MQTT QoS2 transaction failed")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._completed_publish_mids.add(mid)
|
||||||
|
self._qos2_completions += 1
|
||||||
|
|
||||||
|
def on_message(
|
||||||
|
_callback_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
message: mqtt.MQTTMessage,
|
||||||
|
) -> None:
|
||||||
|
allowed = {topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group}
|
||||||
|
if message.topic not in allowed:
|
||||||
|
self._set_callback_error("control MQTT received an unreviewed subscribed topic")
|
||||||
|
return
|
||||||
|
payload = bytes(message.payload)
|
||||||
|
if len(payload) > MAX_CONTROL_RESPONSE_BYTES:
|
||||||
|
self._set_callback_error("control MQTT response exceeds the reviewed bound")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if message.topic in CONTROL_REPORT_TOPICS:
|
||||||
|
self._ignored_known_responses += 1
|
||||||
|
else:
|
||||||
|
self._messages.append((message.topic, payload))
|
||||||
|
|
||||||
|
def on_disconnect(
|
||||||
|
_callback_client: mqtt.Client,
|
||||||
|
_userdata: object,
|
||||||
|
_flags: mqtt.DisconnectFlags,
|
||||||
|
reason_code: ReasonCode,
|
||||||
|
_properties: Properties | None,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
expected = self._closing
|
||||||
|
self._connected = False
|
||||||
|
if not expected or reason_code.is_failure:
|
||||||
|
self._set_callback_error("control MQTT connection ended unexpectedly")
|
||||||
|
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_subscribe = on_subscribe
|
||||||
|
client.on_publish = on_publish
|
||||||
|
client.on_message = on_message
|
||||||
|
client.on_disconnect = on_disconnect
|
||||||
|
|
||||||
|
def _subscribe_next_group(self, client: mqtt.Client) -> None:
|
||||||
|
with self._lock:
|
||||||
|
group_index = self._subscription_group_index
|
||||||
|
if group_index < len(CONTROL_SUBSCRIPTION_GROUPS):
|
||||||
|
group = CONTROL_SUBSCRIPTION_GROUPS[group_index]
|
||||||
|
self._subscribe_attempts += 1
|
||||||
|
else:
|
||||||
|
group = None
|
||||||
|
if group is None:
|
||||||
|
self._set_callback_error("control MQTT subscription sequence overflowed")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result, mid = client.subscribe(list(group))
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
self._set_callback_error("control MQTT response subscription failed")
|
||||||
|
return
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
|
||||||
|
self._set_callback_error("control MQTT response subscription was rejected")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._subscription_mid = mid
|
||||||
|
|
||||||
|
def _drive_until(
|
||||||
|
self,
|
||||||
|
predicate: Callable[[], bool],
|
||||||
|
deadline: float,
|
||||||
|
*,
|
||||||
|
post_publish: bool,
|
||||||
|
) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
while not predicate():
|
||||||
|
with self._lock:
|
||||||
|
callback_error = self._callback_error
|
||||||
|
if callback_error is not None:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish(callback_error)
|
||||||
|
self._fail_before_publish(callback_error)
|
||||||
|
if self._monotonic() >= deadline:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish("control MQTT response barrier timed out")
|
||||||
|
self._fail_before_publish("control MQTT connection/subscription timed out")
|
||||||
|
try:
|
||||||
|
result = client.loop(timeout=CONTROL_LOOP_INTERVAL_SECONDS)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish("control MQTT network loop failed", exc)
|
||||||
|
self._fail_before_publish("control MQTT network loop failed", exc)
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish("control MQTT network loop returned an error")
|
||||||
|
self._fail_before_publish("control MQTT network loop returned an error")
|
||||||
|
|
||||||
|
def _drain_responses(
|
||||||
|
self,
|
||||||
|
required: frozenset[str],
|
||||||
|
allowed_non_barrier: frozenset[str],
|
||||||
|
responses: dict[str, bytes],
|
||||||
|
) -> None:
|
||||||
|
ambiguous_message: str | None = None
|
||||||
|
with self._lock:
|
||||||
|
while self._messages:
|
||||||
|
topic, payload = self._messages.popleft()
|
||||||
|
if topic not in required:
|
||||||
|
if topic in allowed_non_barrier:
|
||||||
|
self._ignored_known_responses += 1
|
||||||
|
continue
|
||||||
|
self._poison_locked("unexpected response made correlation ambiguous")
|
||||||
|
ambiguous_message = "unexpected control response made command outcome ambiguous"
|
||||||
|
break
|
||||||
|
if topic in responses:
|
||||||
|
self._poison_locked("duplicate required response made correlation ambiguous")
|
||||||
|
ambiguous_message = "duplicate control response made command outcome ambiguous"
|
||||||
|
break
|
||||||
|
responses[topic] = payload
|
||||||
|
if ambiguous_message is not None:
|
||||||
|
self.close()
|
||||||
|
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||||
|
|
||||||
|
def _set_callback_error(self, message: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._callback_error is None:
|
||||||
|
self._callback_error = message
|
||||||
|
|
||||||
|
def _fail_before_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._state = "failed"
|
||||||
|
self.close()
|
||||||
|
error = ApplicationMqttTransportError(message)
|
||||||
|
if cause is not None:
|
||||||
|
raise error from cause
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def _fail_after_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._poison_locked(message)
|
||||||
|
self.close()
|
||||||
|
error = ApplicationCommandOutcomeUnknown(
|
||||||
|
f"{message}; automatic retry is forbidden until physical/status reconciliation"
|
||||||
|
)
|
||||||
|
if cause is not None:
|
||||||
|
raise error from cause
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def _poison_locked(self, _reason: str) -> None:
|
||||||
|
self._state = "poisoned"
|
||||||
|
|
||||||
|
def _require_client(self) -> mqtt.Client:
|
||||||
|
client = self._client
|
||||||
|
if client is None:
|
||||||
|
raise ApplicationMqttTransportError("control MQTT client is not installed")
|
||||||
|
return client
|
||||||
|
|
@ -45,6 +45,7 @@ class UninstalledApplicationPublishSink:
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class OneShotPublishEnvelope:
|
class OneShotPublishEnvelope:
|
||||||
|
operation_key: str
|
||||||
topic: str
|
topic: str
|
||||||
payload: bytes = field(repr=False)
|
payload: bytes = field(repr=False)
|
||||||
payload_sha256: str
|
payload_sha256: str
|
||||||
|
|
@ -59,6 +60,7 @@ class OneShotPublishEnvelope:
|
||||||
request: EncodedApplicationRequest,
|
request: EncodedApplicationRequest,
|
||||||
) -> OneShotPublishEnvelope:
|
) -> OneShotPublishEnvelope:
|
||||||
return cls(
|
return cls(
|
||||||
|
operation_key=f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||||
topic=request.topic,
|
topic=request.topic,
|
||||||
payload=request.payload,
|
payload=request.payload,
|
||||||
payload_sha256=request.payload_sha256,
|
payload_sha256=request.payload_sha256,
|
||||||
|
|
@ -73,6 +75,7 @@ class OneShotPublishEnvelope:
|
||||||
command: ShadowModelingCommand,
|
command: ShadowModelingCommand,
|
||||||
) -> OneShotPublishEnvelope:
|
) -> OneShotPublishEnvelope:
|
||||||
return cls(
|
return cls(
|
||||||
|
operation_key=f"modeling:{command.action.name.casefold()}",
|
||||||
topic=command.topic,
|
topic=command.topic,
|
||||||
payload=command.command.payload,
|
payload=command.command.payload,
|
||||||
payload_sha256=command.payload_sha256,
|
payload_sha256=command.payload_sha256,
|
||||||
|
|
@ -82,6 +85,10 @@ class OneShotPublishEnvelope:
|
||||||
)
|
)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
if not self.operation_key or any(
|
||||||
|
ord(character) <= 0x20 or ord(character) > 0x7E for character in self.operation_key
|
||||||
|
):
|
||||||
|
raise ValueError("publish envelope operation key is invalid")
|
||||||
if self.qos != 2 or self.retain or self.automatic_retry:
|
if self.qos != 2 or self.retain or self.automatic_retry:
|
||||||
raise ValueError("publish envelope differs from the reviewed one-shot contract")
|
raise ValueError("publish envelope differs from the reviewed one-shot contract")
|
||||||
if self.payload_bytes != len(self.payload):
|
if self.payload_bytes != len(self.payload):
|
||||||
|
|
@ -91,6 +98,7 @@ class OneShotPublishEnvelope:
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
"operation_key": self.operation_key,
|
||||||
"topic": self.topic,
|
"topic": self.topic,
|
||||||
"payload_sha256": self.payload_sha256,
|
"payload_sha256": self.payload_sha256,
|
||||||
"payload_bytes": self.payload_bytes,
|
"payload_bytes": self.payload_bytes,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,41 @@ def test_doctor_json() -> None:
|
||||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||||
|
result = runner.invoke(app, ["authority", "provision"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "provisioning not confirmed" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_provision_never_accepts_the_secret_as_a_cli_value(monkeypatch: Any) -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
class FakeSnapshot:
|
||||||
|
service = "fixed-service"
|
||||||
|
account = "fixed-account"
|
||||||
|
|
||||||
|
class FakeProvisioner:
|
||||||
|
def provision_interactively(self) -> FakeSnapshot:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return FakeSnapshot()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"k1link.device_plugins.xgrids_k1.cli.MacOSKeychainApplicationAuthorityProvisioner",
|
||||||
|
FakeProvisioner,
|
||||||
|
)
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["authority", "provision", "--confirm-reviewed-authority"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert calls == 1
|
||||||
|
assert "Keychain item validated" in result.stdout
|
||||||
|
assert "No K1 command was sent" in " ".join(result.stdout.split())
|
||||||
|
|
||||||
|
|
||||||
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
|
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Collection, Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||||
|
ApplicationAcceptanceError,
|
||||||
|
PhysicalAcceptanceChecklist,
|
||||||
|
PhysicalAcceptanceDialogueExecutor,
|
||||||
|
PhysicalAcceptancePermit,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
|
OneShotPublishEnvelope,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
OPENAPI_SUCCESS,
|
||||||
|
CommandHeaderIdentity,
|
||||||
|
ModelingAction,
|
||||||
|
MountType,
|
||||||
|
RecordMode,
|
||||||
|
ScanMode,
|
||||||
|
encode_modeling_start,
|
||||||
|
encode_modeling_stop,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
ShadowModelingCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||||
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
|
||||||
|
|
||||||
|
def _varint(value: int) -> bytes:
|
||||||
|
encoded = bytearray()
|
||||||
|
while value > 0x7F:
|
||||||
|
encoded.append((value & 0x7F) | 0x80)
|
||||||
|
value >>= 7
|
||||||
|
encoded.append(value)
|
||||||
|
return bytes(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _uint(number: int, value: int) -> bytes:
|
||||||
|
return _varint(number << 3) + _varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes(number: int, value: bytes) -> bytes:
|
||||||
|
return _varint((number << 3) | 2) + _varint(len(value)) + value
|
||||||
|
|
||||||
|
|
||||||
|
def _text(number: int, value: str) -> bytes:
|
||||||
|
return _bytes(number, value.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _header(session_id: str) -> bytes:
|
||||||
|
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_info_response(session_id: str) -> bytes:
|
||||||
|
base_info = b"".join(
|
||||||
|
(
|
||||||
|
_text(2, "V3.0.2-20260101-release"),
|
||||||
|
_text(3, "V3.0.2"),
|
||||||
|
_text(6, "LixelKity K1"),
|
||||||
|
_text(7, "K1SERIAL01"),
|
||||||
|
_text(8, "K1"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
working_status = _uint(1, 1)
|
||||||
|
device_info = _bytes(2, base_info) + _bytes(7, working_status)
|
||||||
|
error = _uint(1, OPENAPI_SUCCESS)
|
||||||
|
return _bytes(1, _header(session_id)) + _bytes(2, device_info) + _bytes(15, error)
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_response(session_id: str) -> bytes:
|
||||||
|
return _bytes(1, _header(session_id)) + _bytes(15, _uint(1, OPENAPI_SUCCESS))
|
||||||
|
|
||||||
|
|
||||||
|
def _modeling_response(action: ModelingAction) -> bytes:
|
||||||
|
session = f"{VENDOR_DEVICE_ID}:ModelingRequest"
|
||||||
|
return _bytes(1, _header(session)) + _uint(2, action) + _bytes(15, _uint(1, OPENAPI_SUCCESS))
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticAcceptanceTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.batches: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def exchange_batch_once(
|
||||||
|
self,
|
||||||
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
|
*,
|
||||||
|
required_response_topics: Collection[str],
|
||||||
|
) -> dict[str, bytes]:
|
||||||
|
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||||
|
responses: dict[str, bytes] = {}
|
||||||
|
if MODELING_RESPONSE_TOPIC in required_response_topics:
|
||||||
|
action = (
|
||||||
|
ModelingAction.START
|
||||||
|
if envelopes[0].operation_key == "modeling:start"
|
||||||
|
else ModelingAction.STOP
|
||||||
|
)
|
||||||
|
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
for envelope in envelopes:
|
||||||
|
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
|
||||||
|
assert prefix == "bootstrap"
|
||||||
|
ordinal = int(ordinal_text)
|
||||||
|
if message_type == "DeviceInfoRequest":
|
||||||
|
session = (
|
||||||
|
":DeviceInfoRequest"
|
||||||
|
if ordinal == 1
|
||||||
|
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
||||||
|
)
|
||||||
|
response = _device_info_response(session)
|
||||||
|
else:
|
||||||
|
session = (
|
||||||
|
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
||||||
|
)
|
||||||
|
if message_type == "DeviceConfigRequest":
|
||||||
|
session += ":Publish_Proto_DeviceConfig_SetTime"
|
||||||
|
response = _generic_response(session)
|
||||||
|
for topic in required_response_topics:
|
||||||
|
if topic.endswith(_response_suffix(message_type)):
|
||||||
|
responses[topic] = response
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
def _response_suffix(message_type: str) -> str:
|
||||||
|
values = {
|
||||||
|
"DeviceInfoRequest": "device_info",
|
||||||
|
"ModelingStatusRequest": "modeling_status",
|
||||||
|
"GetRtkAdvanceRequest": "get_rtk_advance",
|
||||||
|
"DeviceConfigRequest": "device_config",
|
||||||
|
"GetNtripProfileRequest": "get_ntrip_profile",
|
||||||
|
"GetCloudServerConfigRequest": "get_cloud_server_config",
|
||||||
|
}
|
||||||
|
return values[message_type]
|
||||||
|
|
||||||
|
|
||||||
|
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||||
|
return PhysicalAcceptanceChecklist(
|
||||||
|
action=action,
|
||||||
|
operator_present=True,
|
||||||
|
owner_controlled_device=True,
|
||||||
|
lixelgo_closed=True,
|
||||||
|
battery_storage_confirmed=True,
|
||||||
|
expected_physical_state_confirmed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
|
||||||
|
transport = SyntheticAcceptanceTransport()
|
||||||
|
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
|
||||||
|
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
|
||||||
|
binding = executor.run_bootstrap(orchestrator)
|
||||||
|
command = ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_start(
|
||||||
|
CommandHeaderIdentity(
|
||||||
|
device_id=binding.vendor_device_id,
|
||||||
|
openapi_key=APPLICATION_KEY,
|
||||||
|
),
|
||||||
|
project_name="SAFE_PROJECT",
|
||||||
|
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||||
|
scan_mode=ScanMode.LCC,
|
||||||
|
mount_type=MountType.HANDHELD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = executor.execute_modeling(command)
|
||||||
|
|
||||||
|
assert response.action is ModelingAction.START
|
||||||
|
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
|
||||||
|
assert permit.snapshot()["consumed"] is True
|
||||||
|
assert executor.snapshot()["bootstrap_complete"] is True
|
||||||
|
assert executor.snapshot()["command_complete"] is True
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
|
||||||
|
executor.execute_modeling(command)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
|
||||||
|
transport = SyntheticAcceptanceTransport()
|
||||||
|
start = PhysicalAcceptanceDialogueExecutor(
|
||||||
|
transport,
|
||||||
|
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||||
|
)
|
||||||
|
start_command = ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_start(
|
||||||
|
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
|
||||||
|
project_name="SAFE_PROJECT",
|
||||||
|
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||||
|
scan_mode=ScanMode.LCC,
|
||||||
|
mount_type=MountType.HANDHELD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="requires.*bootstrap"):
|
||||||
|
start.execute_modeling(start_command)
|
||||||
|
|
||||||
|
stop = PhysicalAcceptanceDialogueExecutor(
|
||||||
|
transport,
|
||||||
|
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
|
||||||
|
)
|
||||||
|
stop_command = ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_stop(
|
||||||
|
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
|
||||||
|
assert transport.batches[-1] == ("modeling:stop",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None:
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="explicitly true"):
|
||||||
|
PhysicalAcceptanceChecklist(
|
||||||
|
action=ModelingAction.START,
|
||||||
|
operator_present=False, # type: ignore[arg-type]
|
||||||
|
owner_controlled_device=True,
|
||||||
|
lixelgo_closed=True,
|
||||||
|
battery_storage_confirmed=True,
|
||||||
|
expected_physical_state_confirmed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = [100.0]
|
||||||
|
permit = PhysicalAcceptancePermit(
|
||||||
|
_checklist(ModelingAction.STOP),
|
||||||
|
ttl_seconds=15.0,
|
||||||
|
monotonic=lambda: now[0],
|
||||||
|
)
|
||||||
|
now[0] = 115.0
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="expired"):
|
||||||
|
permit.consume(ModelingAction.STOP)
|
||||||
|
|
@ -10,6 +10,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
KEYCHAIN_SERVICE,
|
KEYCHAIN_SERVICE,
|
||||||
ApplicationAuthorityLoadError,
|
ApplicationAuthorityLoadError,
|
||||||
MacOSKeychainApplicationAuthorityLoader,
|
MacOSKeychainApplicationAuthorityLoader,
|
||||||
|
MacOSKeychainApplicationAuthorityProvisioner,
|
||||||
)
|
)
|
||||||
|
|
||||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||||
|
|
@ -108,3 +109,67 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||||
|
|
||||||
assert invalid_secret.decode() not in str(error.value)
|
assert invalid_secret.decode() not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("sys.stdout.isatty", return_value=True)
|
||||||
|
@patch("sys.stdin.isatty", return_value=True)
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
@patch("shutil.which", return_value="/usr/bin/security")
|
||||||
|
def test_authority_provisioning_uses_hidden_security_prompt_and_validates_item(
|
||||||
|
_which: object,
|
||||||
|
_system: object,
|
||||||
|
_stdin_tty: object,
|
||||||
|
_stdout_tty: object,
|
||||||
|
) -> None:
|
||||||
|
provisioning_calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def interactive_runner(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
check: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
provisioning_calls.append(args)
|
||||||
|
assert check is False
|
||||||
|
assert timeout == 300.0
|
||||||
|
return subprocess.CompletedProcess(args, 0)
|
||||||
|
|
||||||
|
def lookup_runner(
|
||||||
|
args: list[str],
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
|
||||||
|
|
||||||
|
provisioner = MacOSKeychainApplicationAuthorityProvisioner(
|
||||||
|
runner=interactive_runner,
|
||||||
|
loader=MacOSKeychainApplicationAuthorityLoader(runner=lookup_runner),
|
||||||
|
)
|
||||||
|
snapshot = provisioner.provision_interactively()
|
||||||
|
|
||||||
|
assert provisioning_calls == [
|
||||||
|
[
|
||||||
|
"/usr/bin/security",
|
||||||
|
"add-generic-password",
|
||||||
|
"-U",
|
||||||
|
"-s",
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
"-a",
|
||||||
|
KEYCHAIN_ACCOUNT,
|
||||||
|
"-w",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
assert PRIVATE_AUTHORITY.decode().strip() not in str(provisioning_calls)
|
||||||
|
assert snapshot.secret_cached is False
|
||||||
|
assert snapshot.secret_exportable is False
|
||||||
|
|
||||||
|
|
||||||
|
@patch("sys.stdout.isatty", return_value=False)
|
||||||
|
@patch("sys.stdin.isatty", return_value=False)
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
def test_authority_provisioning_requires_an_interactive_terminal(
|
||||||
|
_system: object,
|
||||||
|
_stdin_tty: object,
|
||||||
|
_stdout_tty: object,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError, match="interactive terminal"):
|
||||||
|
MacOSKeychainApplicationAuthorityProvisioner().provision_interactively()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections import deque
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
|
CONTROL_SUBSCRIPTION_GROUPS,
|
||||||
|
ApplicationCommandOutcomeUnknown,
|
||||||
|
ReviewedApplicationMqttTransport,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
|
OneShotPublishEnvelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReasonCode:
|
||||||
|
def __init__(self, *, failure: bool = False) -> None:
|
||||||
|
self.is_failure = failure
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 100.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class FakeControlClient:
|
||||||
|
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
|
||||||
|
self.on_connect: Any = None
|
||||||
|
self.on_subscribe: Any = None
|
||||||
|
self.on_publish: Any = None
|
||||||
|
self.on_message: Any = None
|
||||||
|
self.on_disconnect: Any = None
|
||||||
|
self.emit_exchange = emit_exchange
|
||||||
|
self.clock = clock
|
||||||
|
self.events: deque[tuple[str, object]] = deque()
|
||||||
|
self.connect_calls: list[tuple[str, int, int]] = []
|
||||||
|
self.subscribe_calls: list[list[tuple[str, int]]] = []
|
||||||
|
self.publish_calls: list[tuple[str, bytes, int, bool]] = []
|
||||||
|
self.unsubscribe_calls: list[list[str]] = []
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
self.next_mid = 20
|
||||||
|
|
||||||
|
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||||
|
self.connect_calls.append((host, port, keepalive))
|
||||||
|
self.events.append(("connect", FakeReasonCode()))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def subscribe(self, topics: list[tuple[str, int]]) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||||
|
self.subscribe_calls.append(topics)
|
||||||
|
mid = 6 + len(self.subscribe_calls)
|
||||||
|
self.events.append(("subscribe", mid))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS, mid
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
self.publish_calls.append((topic, payload, qos, retain))
|
||||||
|
mid = self.next_mid
|
||||||
|
self.next_mid += 1
|
||||||
|
if self.emit_exchange:
|
||||||
|
self.events.append(("publish", mid))
|
||||||
|
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
|
||||||
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||||
|
|
||||||
|
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||||
|
del timeout
|
||||||
|
if self.clock is not None:
|
||||||
|
self.clock.now += 1.0
|
||||||
|
if not self.events:
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
kind, value = self.events.popleft()
|
||||||
|
if kind == "connect":
|
||||||
|
self.on_connect(self, None, SimpleNamespace(), value, None)
|
||||||
|
elif kind == "subscribe":
|
||||||
|
group = CONTROL_SUBSCRIPTION_GROUPS[int(value) - 7]
|
||||||
|
self.on_subscribe(
|
||||||
|
self,
|
||||||
|
None,
|
||||||
|
value,
|
||||||
|
[FakeReasonCode() for _item in group],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif kind == "publish":
|
||||||
|
self.on_publish(self, None, value, FakeReasonCode(), None)
|
||||||
|
elif kind == "message":
|
||||||
|
topic, payload = value
|
||||||
|
self.on_message(self, None, SimpleNamespace(topic=topic, payload=payload))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def unsubscribe(self, topics: list[str]) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||||
|
self.unsubscribe_calls.append(topics)
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS, 50
|
||||||
|
|
||||||
|
def disconnect(self) -> mqtt.MQTTErrorCode:
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
|
||||||
|
payload = b"synthetic-reviewed-request"
|
||||||
|
return OneShotPublishEnvelope(
|
||||||
|
operation_key=operation_key,
|
||||||
|
topic="lixel/application/request/device_info",
|
||||||
|
payload=payload,
|
||||||
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
payload_bytes=len(payload),
|
||||||
|
qos=2,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a direct-LAN"):
|
||||||
|
ReviewedApplicationMqttTransport("192.168.56.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
|
||||||
|
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
|
||||||
|
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
|
||||||
|
assert ("lixel/application/response/modeling", 2) in CONTROL_SUBSCRIPTION_GROUPS[2]
|
||||||
|
assert all(
|
||||||
|
topic not in {"RealtimePointcloud", "lixel/application/report/lio_pcl"}
|
||||||
|
for group in CONTROL_SUBSCRIPTION_GROUPS
|
||||||
|
for topic, _qos in group
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() -> None:
|
||||||
|
fake = FakeControlClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
|
||||||
|
opened = transport.open().as_dict()
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
|
||||||
|
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
|
||||||
|
assert fake.publish_calls == [
|
||||||
|
(
|
||||||
|
"lixel/application/request/device_info",
|
||||||
|
b"synthetic-reviewed-request",
|
||||||
|
2,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
|
||||||
|
assert opened["clean_session"] is False
|
||||||
|
assert opened["automatic_reconnect"] is False
|
||||||
|
assert opened["automatic_retry"] is False
|
||||||
|
snapshot = transport.snapshot().as_dict()
|
||||||
|
assert snapshot["state"] == "closed"
|
||||||
|
assert snapshot["publish_attempts"] == 1
|
||||||
|
assert snapshot["subscribe_attempts"] == 3
|
||||||
|
assert snapshot["qos2_completions"] == 1
|
||||||
|
assert snapshot["correlated_responses"] == 1
|
||||||
|
assert snapshot["operation_keys_consumed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumed_operation_key_can_never_be_published_again() -> None:
|
||||||
|
fake = FakeControlClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
fake = FakeControlClient(emit_exchange=False, clock=clock)
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
exchange_timeout_seconds=2.0,
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transport.snapshot().state == "poisoned"
|
||||||
|
assert len(fake.publish_calls) == 1
|
||||||
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
@ -79,6 +79,7 @@ def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
|
||||||
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
||||||
with pytest.raises(ValueError, match="one-shot contract"):
|
with pytest.raises(ValueError, match="one-shot contract"):
|
||||||
OneShotPublishEnvelope(
|
OneShotPublishEnvelope(
|
||||||
|
operation_key=envelope.operation_key,
|
||||||
topic=envelope.topic,
|
topic=envelope.topic,
|
||||||
payload=envelope.payload,
|
payload=envelope.payload,
|
||||||
payload_sha256=envelope.payload_sha256,
|
payload_sha256=envelope.payload_sha256,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue