feat(simulation): add provider-neutral worker profile
This commit is contained in:
@@ -31,6 +31,14 @@ from k1link.simulation.process_supervisor import (
|
||||
ProcessStopResult,
|
||||
ProcessSupervisorError,
|
||||
)
|
||||
from k1link.simulation.provider_contract import (
|
||||
PROVIDER_PROFILE_SCHEMA,
|
||||
ProviderRole,
|
||||
SimulationClockDescriptor,
|
||||
SimulationProviderContractError,
|
||||
SimulationProviderDescriptor,
|
||||
SimulationProviderProfile,
|
||||
)
|
||||
from k1link.simulation.run_store import (
|
||||
QualificationRunConflictError,
|
||||
QualificationRunIntegrityError,
|
||||
@@ -83,7 +91,9 @@ __all__ = [
|
||||
"ProcessSpec",
|
||||
"ProcessStopResult",
|
||||
"ProcessSupervisorError",
|
||||
"PROVIDER_PROFILE_SCHEMA",
|
||||
"ProviderPin",
|
||||
"ProviderRole",
|
||||
"QualificationArtifact",
|
||||
"QualificationEvent",
|
||||
"QualificationRun",
|
||||
@@ -105,8 +115,12 @@ __all__ = [
|
||||
"StockRoverProfileError",
|
||||
"StockRoverTargetPaths",
|
||||
"SimulationContractError",
|
||||
"SimulationClockDescriptor",
|
||||
"SimulationApplicationService",
|
||||
"SimulationOrchestratorError",
|
||||
"SimulationProviderContractError",
|
||||
"SimulationProviderDescriptor",
|
||||
"SimulationProviderProfile",
|
||||
"SimulationWorkerPort",
|
||||
"SimulationWorldControl",
|
||||
"WorkerAdmission",
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Final
|
||||
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
GIT_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{7,64}$")
|
||||
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
|
||||
|
||||
|
||||
class SimulationContractError(ValueError):
|
||||
@@ -243,11 +244,8 @@ class QualificationRun:
|
||||
_sha256(self.host_profile_sha256, "host profile digest")
|
||||
if self.seed < 0:
|
||||
raise SimulationContractError("run seed must not be negative")
|
||||
if self.clock_domain != "gazebo:/clock" and self.kind in {
|
||||
RunKind.SIMULATION_CLOSED_LOOP,
|
||||
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
|
||||
}:
|
||||
raise SimulationContractError("closed-loop simulation requires Gazebo /clock")
|
||||
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.clock_domain):
|
||||
raise SimulationContractError("clock domain is not a safe identifier")
|
||||
_utc_timestamp(self.created_at_utc, "created timestamp")
|
||||
if self.started_at_utc is not None:
|
||||
_utc_timestamp(self.started_at_utc, "started timestamp")
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.simulation.contracts import ControlProfile
|
||||
|
||||
PROVIDER_PROFILE_SCHEMA: Final = "missioncore.simulation-provider-profile/v1"
|
||||
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
CAPABILITY_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
|
||||
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
|
||||
COMMAND_CAPABILITY_BY_PROFILE: Final = {
|
||||
ControlProfile.ROVER_SPEED_STEERING_V1: "command.rover-speed-steering/v1",
|
||||
ControlProfile.ROVER_SPEED_YAW_RATE_V1: "command.rover-speed-yaw-rate/v1",
|
||||
}
|
||||
|
||||
|
||||
class SimulationProviderContractError(ValueError):
|
||||
"""A simulation provider profile violates the admitted v1 contract."""
|
||||
|
||||
|
||||
class ProviderRole(StrEnum):
|
||||
WORLD = "world"
|
||||
PHYSICS = "physics"
|
||||
STATE = "state"
|
||||
CONTROLLER = "controller"
|
||||
TRANSPORT = "transport"
|
||||
SENSOR = "sensor"
|
||||
TRAFFIC = "traffic"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationProviderDescriptor:
|
||||
provider_id: str
|
||||
roles: tuple[ProviderRole, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.provider_id, "provider id")
|
||||
if not self.roles:
|
||||
raise SimulationProviderContractError("provider roles must not be empty")
|
||||
if any(not isinstance(role, ProviderRole) for role in self.roles):
|
||||
raise SimulationProviderContractError("provider role is unknown")
|
||||
if len(self.roles) != len(set(self.roles)):
|
||||
raise SimulationProviderContractError("provider roles must be unique")
|
||||
if not self.capabilities:
|
||||
raise SimulationProviderContractError("provider capabilities must not be empty")
|
||||
if len(self.capabilities) != len(set(self.capabilities)):
|
||||
raise SimulationProviderContractError("provider capabilities must be unique")
|
||||
for capability in self.capabilities:
|
||||
_capability(capability)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"roles": [role.value for role in self.roles],
|
||||
"capabilities": list(self.capabilities),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SimulationProviderDescriptor:
|
||||
document = _object(value, "provider descriptor")
|
||||
_exact_keys(document, {"provider_id", "roles", "capabilities"}, "provider descriptor")
|
||||
roles = _array(document, "roles")
|
||||
capabilities = _array(document, "capabilities")
|
||||
try:
|
||||
parsed_roles = tuple(
|
||||
ProviderRole(_string_value(role, "provider role")) for role in roles
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SimulationProviderContractError("provider role is unknown") from exc
|
||||
return cls(
|
||||
provider_id=_string(document, "provider_id"),
|
||||
roles=parsed_roles,
|
||||
capabilities=tuple(
|
||||
_string_value(capability, "provider capability") for capability in capabilities
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationClockDescriptor:
|
||||
provider_id: str
|
||||
domain: str
|
||||
unit: str = "nanoseconds"
|
||||
mode: str = "simulation"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.provider_id, "clock provider id")
|
||||
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.domain):
|
||||
raise SimulationProviderContractError("clock domain is not safe")
|
||||
if self.unit != "nanoseconds" or self.mode != "simulation":
|
||||
raise SimulationProviderContractError(
|
||||
"v1 provider clocks must use simulation nanoseconds"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"domain": self.domain,
|
||||
"unit": self.unit,
|
||||
"mode": self.mode,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SimulationClockDescriptor:
|
||||
document = _object(value, "simulation clock")
|
||||
_exact_keys(
|
||||
document,
|
||||
{"provider_id", "domain", "unit", "mode"},
|
||||
"simulation clock",
|
||||
)
|
||||
return cls(
|
||||
provider_id=_string(document, "provider_id"),
|
||||
domain=_string(document, "domain"),
|
||||
unit=_string(document, "unit"),
|
||||
mode=_string(document, "mode"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationProviderProfile:
|
||||
profile_id: str
|
||||
providers: tuple[SimulationProviderDescriptor, ...]
|
||||
clock: SimulationClockDescriptor
|
||||
control_profiles: tuple[ControlProfile, ...]
|
||||
world_frame: str = "map_enu"
|
||||
body_frame: str = "base_link_flu"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.profile_id, "provider profile id")
|
||||
if not self.providers:
|
||||
raise SimulationProviderContractError("provider profile must declare providers")
|
||||
provider_ids = [provider.provider_id for provider in self.providers]
|
||||
if len(provider_ids) != len(set(provider_ids)):
|
||||
raise SimulationProviderContractError("provider ids must be unique")
|
||||
providers_by_id = {provider.provider_id: provider for provider in self.providers}
|
||||
clock_provider = providers_by_id.get(self.clock.provider_id)
|
||||
if clock_provider is None or "clock.simulation" not in clock_provider.capabilities:
|
||||
raise SimulationProviderContractError("clock provider must declare clock.simulation")
|
||||
if not any(
|
||||
ProviderRole.STATE in provider.roles and "state.vehicle-pose" in provider.capabilities
|
||||
for provider in self.providers
|
||||
):
|
||||
raise SimulationProviderContractError(
|
||||
"provider profile must expose canonical vehicle pose"
|
||||
)
|
||||
if not self.control_profiles:
|
||||
raise SimulationProviderContractError("control profiles must not be empty")
|
||||
if any(not isinstance(profile, ControlProfile) for profile in self.control_profiles):
|
||||
raise SimulationProviderContractError("control profile is unknown")
|
||||
if len(self.control_profiles) != len(set(self.control_profiles)):
|
||||
raise SimulationProviderContractError("control profiles must be unique")
|
||||
controller_capabilities = {
|
||||
capability
|
||||
for provider in self.providers
|
||||
if ProviderRole.CONTROLLER in provider.roles
|
||||
for capability in provider.capabilities
|
||||
}
|
||||
if any(
|
||||
COMMAND_CAPABILITY_BY_PROFILE[profile] not in controller_capabilities
|
||||
for profile in self.control_profiles
|
||||
):
|
||||
raise SimulationProviderContractError(
|
||||
"controller providers do not satisfy the declared control profiles"
|
||||
)
|
||||
if self.world_frame != "map_enu" or self.body_frame != "base_link_flu":
|
||||
raise SimulationProviderContractError(
|
||||
"v1 provider profiles must expose map_enu and base_link_flu"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": PROVIDER_PROFILE_SCHEMA,
|
||||
"profile_id": self.profile_id,
|
||||
"providers": [provider.to_dict() for provider in self.providers],
|
||||
"clock": self.clock.to_dict(),
|
||||
"control_profiles": [profile.value for profile in self.control_profiles],
|
||||
"canonical_frames": {
|
||||
"world": self.world_frame,
|
||||
"body": self.body_frame,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SimulationProviderProfile:
|
||||
document = _object(value, "simulation provider profile")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"providers",
|
||||
"clock",
|
||||
"control_profiles",
|
||||
"canonical_frames",
|
||||
},
|
||||
"simulation provider profile",
|
||||
)
|
||||
if document.get("schema_version") != PROVIDER_PROFILE_SCHEMA:
|
||||
raise SimulationProviderContractError("provider profile schema is incompatible")
|
||||
providers = _array(document, "providers")
|
||||
control_profiles = _array(document, "control_profiles")
|
||||
frames = _object(document.get("canonical_frames"), "canonical frames")
|
||||
_exact_keys(frames, {"world", "body"}, "canonical frames")
|
||||
try:
|
||||
parsed_control_profiles = tuple(
|
||||
ControlProfile(_string_value(profile, "control profile"))
|
||||
for profile in control_profiles
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SimulationProviderContractError("control profile is unknown") from exc
|
||||
return cls(
|
||||
profile_id=_string(document, "profile_id"),
|
||||
providers=tuple(
|
||||
SimulationProviderDescriptor.from_dict(provider) for provider in providers
|
||||
),
|
||||
clock=SimulationClockDescriptor.from_dict(document.get("clock")),
|
||||
control_profiles=parsed_control_profiles,
|
||||
world_frame=_string(frames, "world"),
|
||||
body_frame=_string(frames, "body"),
|
||||
)
|
||||
|
||||
|
||||
def _identifier(value: str, label: str) -> str:
|
||||
if not IDENTIFIER_PATTERN.fullmatch(value):
|
||||
raise SimulationProviderContractError(f"{label} is not a safe identifier")
|
||||
return value
|
||||
|
||||
|
||||
def _capability(value: str) -> str:
|
||||
if not CAPABILITY_PATTERN.fullmatch(value):
|
||||
raise SimulationProviderContractError("provider capability is not safe")
|
||||
return value
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise SimulationProviderContractError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(document: dict[str, Any], key: str) -> list[object]:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise SimulationProviderContractError(f"{key} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(document: dict[str, Any], key: str) -> str:
|
||||
return _string_value(document.get(key), key)
|
||||
|
||||
|
||||
def _string_value(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SimulationProviderContractError(f"{label} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
|
||||
if set(value) != expected:
|
||||
raise SimulationProviderContractError(f"{label} keys do not match v1")
|
||||
@@ -7,10 +7,54 @@ from typing import Final
|
||||
|
||||
import yaml
|
||||
|
||||
from k1link.simulation.contracts import ControlProfile
|
||||
from k1link.simulation.process_supervisor import ProcessSpec
|
||||
from k1link.simulation.provider_contract import (
|
||||
ProviderRole,
|
||||
SimulationClockDescriptor,
|
||||
SimulationProviderDescriptor,
|
||||
SimulationProviderProfile,
|
||||
)
|
||||
|
||||
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
|
||||
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
|
||||
GAZEBO_STATE_PROVIDER_ID: Final = "gazebo"
|
||||
PX4_COMMAND_PROVIDER_ID: Final = "px4-ros2-offboard"
|
||||
STOCK_ROVER_PROVIDER_PROFILE: Final = SimulationProviderProfile(
|
||||
profile_id="stock-rover-gazebo-px4-s1d",
|
||||
providers=(
|
||||
SimulationProviderDescriptor(
|
||||
provider_id=GAZEBO_STATE_PROVIDER_ID,
|
||||
roles=(
|
||||
ProviderRole.WORLD,
|
||||
ProviderRole.PHYSICS,
|
||||
ProviderRole.STATE,
|
||||
ProviderRole.SENSOR,
|
||||
),
|
||||
capabilities=(
|
||||
"clock.simulation",
|
||||
"state.vehicle-pose",
|
||||
"truth.ground-truth",
|
||||
"sensor.virtual",
|
||||
),
|
||||
),
|
||||
SimulationProviderDescriptor(
|
||||
provider_id=PX4_COMMAND_PROVIDER_ID,
|
||||
roles=(ProviderRole.CONTROLLER,),
|
||||
capabilities=("command.rover-speed-steering/v1",),
|
||||
),
|
||||
SimulationProviderDescriptor(
|
||||
provider_id="micro-xrce-dds-agent",
|
||||
roles=(ProviderRole.TRANSPORT,),
|
||||
capabilities=("transport.ros2",),
|
||||
),
|
||||
),
|
||||
clock=SimulationClockDescriptor(
|
||||
provider_id=GAZEBO_STATE_PROVIDER_ID,
|
||||
domain="gazebo:/clock",
|
||||
),
|
||||
control_profiles=(ControlProfile.ROVER_SPEED_STEERING_V1,),
|
||||
)
|
||||
|
||||
|
||||
class StockRoverProfileError(ValueError):
|
||||
|
||||
@@ -39,6 +39,9 @@ from k1link.simulation.run_store import (
|
||||
)
|
||||
from k1link.simulation.s0 import ComponentPin
|
||||
from k1link.simulation.stock_rover import (
|
||||
GAZEBO_STATE_PROVIDER_ID,
|
||||
PX4_COMMAND_PROVIDER_ID,
|
||||
STOCK_ROVER_PROVIDER_PROFILE,
|
||||
StockRoverTargetPaths,
|
||||
load_stock_rover_lifecycle_profile,
|
||||
stock_rover_process_environment,
|
||||
@@ -173,7 +176,7 @@ class GazeboPoseCollector:
|
||||
},
|
||||
},
|
||||
"source": {
|
||||
"provider": "gazebo",
|
||||
"provider": GAZEBO_STATE_PROVIDER_ID,
|
||||
"topic": POSE_TOPIC,
|
||||
"signal": "ground-truth",
|
||||
"quality": "diagnostic",
|
||||
@@ -371,12 +374,14 @@ class SimulationWorkerAgent:
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
active = self._active_run()
|
||||
provider_ids: list[str] = []
|
||||
active_provider_ids: list[str] = []
|
||||
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
|
||||
try:
|
||||
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
|
||||
active_provider_ids = [
|
||||
record.process_id for record in self.supervisor.snapshot()
|
||||
]
|
||||
except Exception:
|
||||
provider_ids = []
|
||||
active_provider_ids = []
|
||||
return {
|
||||
"schema_version": STATUS_SCHEMA,
|
||||
"worker_id": "mission-gpu-s1",
|
||||
@@ -386,7 +391,8 @@ class SimulationWorkerAgent:
|
||||
"control_available": True,
|
||||
"active_run_id": active.run_id if active else None,
|
||||
"run_state": active.state.value if active else None,
|
||||
"provider_ids": provider_ids,
|
||||
"active_provider_ids": active_provider_ids,
|
||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
@@ -552,7 +558,7 @@ class SimulationWorkerAgent:
|
||||
sim_time_ns=issued_at_sim_ns,
|
||||
payload={
|
||||
"command_id": command.command_id,
|
||||
"provider": "px4-ros2-offboard",
|
||||
"provider": PX4_COMMAND_PROVIDER_ID,
|
||||
"detail": type(exc).__name__,
|
||||
},
|
||||
expected_revision=current.revision,
|
||||
@@ -632,7 +638,7 @@ class SimulationWorkerAgent:
|
||||
command_ttl_max_ns=250_000_000,
|
||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
||||
),
|
||||
clock_domain="gazebo:/clock",
|
||||
clock_domain=STOCK_ROVER_PROVIDER_PROFILE.clock.domain,
|
||||
created_at_utc=_utc_now(),
|
||||
)
|
||||
|
||||
@@ -810,11 +816,15 @@ def _command_acceptance(
|
||||
"steering_normalized": command.steering_normalized,
|
||||
"authority_scope": command.authority_scope.value,
|
||||
"delivery": {
|
||||
"provider": "px4-ros2-offboard",
|
||||
"mode": "speed-steering",
|
||||
"armed": snapshot.armed,
|
||||
"offboard": snapshot.offboard,
|
||||
"provider_id": PX4_COMMAND_PROVIDER_ID,
|
||||
"control_profile": command.profile.value,
|
||||
"accepted": True,
|
||||
"controller_ready": snapshot.armed and snapshot.offboard,
|
||||
"ttl_expired_count": snapshot.ttl_expired_count,
|
||||
"diagnostics": {
|
||||
"armed": snapshot.armed,
|
||||
"offboard": snapshot.offboard,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,27 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.simulation.contracts import ControlProfile
|
||||
from k1link.simulation.provider_contract import (
|
||||
SimulationProviderContractError,
|
||||
SimulationProviderProfile,
|
||||
)
|
||||
|
||||
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
|
||||
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
|
||||
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
|
||||
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v2"
|
||||
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
|
||||
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v1"
|
||||
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v2"
|
||||
MAX_MESSAGE_BYTES: Final = 64 * 1024
|
||||
OPERATIONS: Final = frozenset({"status", "live", "start", "command", "stop"})
|
||||
SAFE_ID_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
|
||||
|
||||
class SimulationWorkerGatewayError(RuntimeError):
|
||||
@@ -237,24 +245,35 @@ def _validate_status(value: Mapping[str, Any]) -> None:
|
||||
"control_available",
|
||||
"active_run_id",
|
||||
"run_state",
|
||||
"provider_ids",
|
||||
"active_provider_ids",
|
||||
"provider_profile",
|
||||
"isolation",
|
||||
"authority",
|
||||
}
|
||||
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
|
||||
raise SimulationWorkerGatewayError("worker status does not match v1")
|
||||
raise SimulationWorkerGatewayError("worker status does not match v2")
|
||||
active_provider_ids = value.get("active_provider_ids")
|
||||
if (
|
||||
not isinstance(value.get("worker_id"), str)
|
||||
or value.get("transport") != "unix"
|
||||
or value.get("mode") != "simulation"
|
||||
or value.get("available") is not True
|
||||
or not isinstance(value.get("control_available"), bool)
|
||||
or not isinstance(value.get("provider_ids"), list)
|
||||
or any(not isinstance(item, str) for item in value["provider_ids"])
|
||||
or not isinstance(active_provider_ids, list)
|
||||
or len(active_provider_ids) > 32
|
||||
or any(
|
||||
not isinstance(item, str) or not SAFE_ID_PATTERN.fullmatch(item)
|
||||
for item in active_provider_ids
|
||||
)
|
||||
or len(active_provider_ids) != len(set(active_provider_ids))
|
||||
or not isinstance(value.get("isolation"), dict)
|
||||
or not isinstance(value.get("authority"), dict)
|
||||
):
|
||||
raise SimulationWorkerGatewayError("worker status contains invalid values")
|
||||
try:
|
||||
SimulationProviderProfile.from_dict(value.get("provider_profile"))
|
||||
except SimulationProviderContractError as exc:
|
||||
raise SimulationWorkerGatewayError("worker provider profile is invalid") from exc
|
||||
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
|
||||
raise SimulationWorkerGatewayError("worker active run id is invalid")
|
||||
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
|
||||
@@ -290,6 +309,18 @@ def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
|
||||
or not isinstance(value.get("safety"), dict)
|
||||
):
|
||||
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
|
||||
source = value["source"]
|
||||
if (
|
||||
set(source) != {"provider", "topic", "signal", "quality"}
|
||||
or not isinstance(source.get("provider"), str)
|
||||
or not SAFE_ID_PATTERN.fullmatch(source["provider"])
|
||||
or not isinstance(source.get("topic"), str)
|
||||
or not 1 <= len(source["topic"]) <= 512
|
||||
or "\x00" in source["topic"]
|
||||
or source.get("signal") != "ground-truth"
|
||||
or source.get("quality") != "diagnostic"
|
||||
):
|
||||
raise SimulationWorkerGatewayError("vehicle state source is invalid")
|
||||
|
||||
|
||||
def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||
@@ -306,7 +337,7 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||
"delivery",
|
||||
}
|
||||
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
|
||||
raise SimulationWorkerGatewayError("command acceptance does not match v1")
|
||||
raise SimulationWorkerGatewayError("command acceptance does not match v2")
|
||||
delivery = value.get("delivery")
|
||||
numeric_fields = (
|
||||
"sequence",
|
||||
@@ -328,17 +359,35 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||
or not isinstance(delivery, dict)
|
||||
or set(delivery)
|
||||
!= {
|
||||
"provider",
|
||||
"mode",
|
||||
"armed",
|
||||
"offboard",
|
||||
"provider_id",
|
||||
"control_profile",
|
||||
"accepted",
|
||||
"controller_ready",
|
||||
"ttl_expired_count",
|
||||
"diagnostics",
|
||||
}
|
||||
or delivery.get("provider") != "px4-ros2-offboard"
|
||||
or delivery.get("mode") != "speed-steering"
|
||||
or not isinstance(delivery.get("armed"), bool)
|
||||
or not isinstance(delivery.get("offboard"), bool)
|
||||
or not isinstance(delivery.get("provider_id"), str)
|
||||
or not SAFE_ID_PATTERN.fullmatch(delivery["provider_id"])
|
||||
or delivery.get("control_profile")
|
||||
!= ControlProfile.ROVER_SPEED_STEERING_V1.value
|
||||
or delivery.get("accepted") is not True
|
||||
or not isinstance(delivery.get("controller_ready"), bool)
|
||||
or isinstance(delivery.get("ttl_expired_count"), bool)
|
||||
or not isinstance(delivery.get("ttl_expired_count"), int)
|
||||
or delivery["ttl_expired_count"] < 0
|
||||
or not _valid_boolean_diagnostics(delivery.get("diagnostics"))
|
||||
):
|
||||
raise SimulationWorkerGatewayError("command acceptance contains invalid values")
|
||||
|
||||
|
||||
def _valid_boolean_diagnostics(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and len(value) <= 16
|
||||
and all(
|
||||
isinstance(key, str)
|
||||
and SAFE_ID_PATTERN.fullmatch(key) is not None
|
||||
and isinstance(item, bool)
|
||||
for key, item in value.items()
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user