feat(k1): add physical acceptance transport
This commit is contained in:
@@ -36,6 +36,10 @@ from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||
capture_mqtt,
|
||||
)
|
||||
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.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)
|
||||
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)
|
||||
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(net_app, name="net")
|
||||
app.add_typer(usb_app, name="usb")
|
||||
app.add_typer(analyze_app, name="analyze")
|
||||
app.add_typer(authority_app, name="authority")
|
||||
|
||||
|
||||
class ToolStatus(TypedDict):
|
||||
@@ -101,6 +110,39 @@ def _command_output(args: list[str]) -> str | None:
|
||||
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:
|
||||
output = _command_output(["route", "-n", "get", "default"])
|
||||
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 shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
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_ACCOUNT = "lixelgo-application-fw-3.0.2"
|
||||
KEYCHAIN_TIMEOUT_SECONDS = 5.0
|
||||
KEYCHAIN_INTERACTIVE_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
class ApplicationAuthorityLoadError(RuntimeError):
|
||||
@@ -31,6 +33,16 @@ class CommandRunner(Protocol):
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
class InteractiveCommandRunner(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationAuthoritySourceSnapshot:
|
||||
provider: str = "macos-keychain"
|
||||
@@ -116,3 +128,63 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||
finally:
|
||||
for index in range(len(secret_buffer)):
|
||||
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)
|
||||
class OneShotPublishEnvelope:
|
||||
operation_key: str
|
||||
topic: str
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
@@ -59,6 +60,7 @@ class OneShotPublishEnvelope:
|
||||
request: EncodedApplicationRequest,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
operation_key=f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||
topic=request.topic,
|
||||
payload=request.payload,
|
||||
payload_sha256=request.payload_sha256,
|
||||
@@ -73,6 +75,7 @@ class OneShotPublishEnvelope:
|
||||
command: ShadowModelingCommand,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
operation_key=f"modeling:{command.action.name.casefold()}",
|
||||
topic=command.topic,
|
||||
payload=command.command.payload,
|
||||
payload_sha256=command.payload_sha256,
|
||||
@@ -82,6 +85,10 @@ class OneShotPublishEnvelope:
|
||||
)
|
||||
|
||||
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:
|
||||
raise ValueError("publish envelope differs from the reviewed one-shot contract")
|
||||
if self.payload_bytes != len(self.payload):
|
||||
@@ -91,6 +98,7 @@ class OneShotPublishEnvelope:
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"operation_key": self.operation_key,
|
||||
"topic": self.topic,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
|
||||
Reference in New Issue
Block a user