feat(simulation): add provider-neutral worker profile
This commit is contained in:
@@ -1,12 +1,44 @@
|
||||
import type { PolygonRunState } from "./runArchive";
|
||||
|
||||
export type PolygonProviderRole =
|
||||
| "world"
|
||||
| "physics"
|
||||
| "state"
|
||||
| "controller"
|
||||
| "transport"
|
||||
| "sensor"
|
||||
| "traffic";
|
||||
|
||||
export interface PolygonProviderDescriptor {
|
||||
providerId: string;
|
||||
roles: PolygonProviderRole[];
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface PolygonProviderProfile {
|
||||
profileId: string;
|
||||
providers: PolygonProviderDescriptor[];
|
||||
clock: {
|
||||
providerId: string;
|
||||
domain: string;
|
||||
unit: "nanoseconds";
|
||||
mode: "simulation";
|
||||
};
|
||||
controlProfiles: string[];
|
||||
canonicalFrames: {
|
||||
world: "map_enu";
|
||||
body: "base_link_flu";
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolygonWorkerStatus {
|
||||
workerId: string;
|
||||
available: boolean;
|
||||
controlAvailable: boolean;
|
||||
activeRunId: string | null;
|
||||
runState: PolygonRunState | null;
|
||||
providerIds: string[];
|
||||
activeProviderIds: string[];
|
||||
providerProfile: PolygonProviderProfile | null;
|
||||
isolation: {
|
||||
network: string;
|
||||
processIdentity: string;
|
||||
@@ -22,6 +54,10 @@ export interface PolygonVehicleState {
|
||||
simTimeNs: number;
|
||||
position: { x: number; y: number; z: number };
|
||||
orientation: { x: number; y: number; z: number; w: number };
|
||||
sourceProvider: string;
|
||||
sourceTopic: string;
|
||||
sourceSignal: "ground-truth";
|
||||
sourceQuality: "diagnostic";
|
||||
}
|
||||
|
||||
export interface PolygonCommandAcceptance {
|
||||
@@ -32,9 +68,12 @@ export interface PolygonCommandAcceptance {
|
||||
validUntilSimNs: number;
|
||||
speedMps: number;
|
||||
steeringNormalized: number;
|
||||
armed: boolean;
|
||||
offboard: boolean;
|
||||
deliveryProvider: string;
|
||||
controlProfile: string;
|
||||
accepted: true;
|
||||
controllerReady: boolean;
|
||||
ttlExpiredCount: number;
|
||||
diagnostics: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export class PolygonWorkerContractError extends Error {}
|
||||
@@ -51,6 +90,17 @@ type PolygonWorkerFetch = (
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_CAPABILITY = /^[a-z0-9][a-z0-9._/-]{0,127}$/;
|
||||
const SAFE_CLOCK_DOMAIN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const PROVIDER_ROLES = new Set<PolygonProviderRole>([
|
||||
"world",
|
||||
"physics",
|
||||
"state",
|
||||
"controller",
|
||||
"transport",
|
||||
"sensor",
|
||||
"traffic",
|
||||
]);
|
||||
const RUN_STATES = new Set<PolygonRunState>([
|
||||
"admitted",
|
||||
"starting",
|
||||
@@ -70,10 +120,22 @@ const STATUS_KEYS = new Set([
|
||||
"control_available",
|
||||
"active_run_id",
|
||||
"run_state",
|
||||
"provider_ids",
|
||||
"active_provider_ids",
|
||||
"provider_profile",
|
||||
"isolation",
|
||||
"authority",
|
||||
]);
|
||||
const PROVIDER_PROFILE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"providers",
|
||||
"clock",
|
||||
"control_profiles",
|
||||
"canonical_frames",
|
||||
]);
|
||||
const PROVIDER_DESCRIPTOR_KEYS = new Set(["provider_id", "roles", "capabilities"]);
|
||||
const CLOCK_KEYS = new Set(["provider_id", "domain", "unit", "mode"]);
|
||||
const CANONICAL_FRAME_KEYS = new Set(["world", "body"]);
|
||||
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"scope",
|
||||
@@ -115,11 +177,12 @@ const COMMAND_KEYS = new Set([
|
||||
"delivery",
|
||||
]);
|
||||
const COMMAND_DELIVERY_KEYS = new Set([
|
||||
"provider",
|
||||
"mode",
|
||||
"armed",
|
||||
"offboard",
|
||||
"provider_id",
|
||||
"control_profile",
|
||||
"accepted",
|
||||
"controller_ready",
|
||||
"ttl_expired_count",
|
||||
"diagnostics",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -184,11 +247,130 @@ function finiteValue(value: unknown, label: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueStrings(
|
||||
value: unknown,
|
||||
label: string,
|
||||
validator: (item: unknown, itemLabel: string) => string,
|
||||
): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > 32) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть ограниченным массивом.`);
|
||||
}
|
||||
const result = value.map((item, index) => validator(item, `${label}[${index}]`));
|
||||
if (new Set(result).size !== result.length) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит повторения.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeProviderProfile(payload: unknown): PolygonProviderProfile {
|
||||
const value = record(payload, "provider_profile");
|
||||
exactKeys(value, PROVIDER_PROFILE_KEYS, "provider_profile");
|
||||
if (value.schema_version !== "missioncore.simulation-provider-profile/v1") {
|
||||
throw new PolygonWorkerContractError("provider_profile имеет неизвестную схему.");
|
||||
}
|
||||
if (!Array.isArray(value.providers) || value.providers.length === 0 || value.providers.length > 32) {
|
||||
throw new PolygonWorkerContractError("provider_profile.providers имеет недопустимый размер.");
|
||||
}
|
||||
const providers = value.providers.map((item, index): PolygonProviderDescriptor => {
|
||||
const provider = record(item, `providers[${index}]`);
|
||||
exactKeys(provider, PROVIDER_DESCRIPTOR_KEYS, `providers[${index}]`);
|
||||
const roles = uniqueStrings(
|
||||
provider.roles,
|
||||
`providers[${index}].roles`,
|
||||
(role, label) => {
|
||||
const result = stringValue(role, label, 32);
|
||||
if (!PROVIDER_ROLES.has(result as PolygonProviderRole)) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит неизвестную роль.`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
) as PolygonProviderRole[];
|
||||
const capabilities = uniqueStrings(
|
||||
provider.capabilities,
|
||||
`providers[${index}].capabilities`,
|
||||
(capability, label) => {
|
||||
const result = stringValue(capability, label, 128);
|
||||
if (!SAFE_CAPABILITY.test(result)) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит небезопасную возможность.`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return {
|
||||
providerId: safeId(provider.provider_id, `providers[${index}].provider_id`),
|
||||
roles,
|
||||
capabilities,
|
||||
};
|
||||
});
|
||||
if (new Set(providers.map((provider) => provider.providerId)).size !== providers.length) {
|
||||
throw new PolygonWorkerContractError("provider_profile содержит повторяющиеся provider_id.");
|
||||
}
|
||||
const clockValue = record(value.clock, "provider_profile.clock");
|
||||
exactKeys(clockValue, CLOCK_KEYS, "provider_profile.clock");
|
||||
const clockProviderId = safeId(clockValue.provider_id, "clock.provider_id");
|
||||
const clockDomain = stringValue(clockValue.domain, "clock.domain", 128);
|
||||
if (
|
||||
!SAFE_CLOCK_DOMAIN.test(clockDomain) ||
|
||||
clockValue.unit !== "nanoseconds" ||
|
||||
clockValue.mode !== "simulation"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("provider_profile.clock нарушает v1 контракт.");
|
||||
}
|
||||
const clockProvider = providers.find((provider) => provider.providerId === clockProviderId);
|
||||
if (!clockProvider?.capabilities.includes("clock.simulation")) {
|
||||
throw new PolygonWorkerContractError("Источник simulation clock не объявлен провайдером.");
|
||||
}
|
||||
if (!providers.some((provider) =>
|
||||
provider.roles.includes("state") &&
|
||||
provider.capabilities.includes("state.vehicle-pose"))) {
|
||||
throw new PolygonWorkerContractError("provider_profile не предоставляет VehicleState.");
|
||||
}
|
||||
const controlProfiles = uniqueStrings(
|
||||
value.control_profiles,
|
||||
"provider_profile.control_profiles",
|
||||
(profile, label) => {
|
||||
const result = stringValue(profile, label, 64);
|
||||
if (!["rover-speed-steering/v1", "rover-speed-yaw-rate/v1"].includes(result)) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит неизвестный профиль.`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const controllerCapabilities = new Set(
|
||||
providers
|
||||
.filter((provider) => provider.roles.includes("controller"))
|
||||
.flatMap((provider) => provider.capabilities),
|
||||
);
|
||||
if (controlProfiles.some((profile) => !controllerCapabilities.has(`command.${profile}`))) {
|
||||
throw new PolygonWorkerContractError("Контроллер не поддерживает заявленный профиль команд.");
|
||||
}
|
||||
const frames = record(value.canonical_frames, "canonical_frames");
|
||||
exactKeys(frames, CANONICAL_FRAME_KEYS, "canonical_frames");
|
||||
if (frames.world !== "map_enu" || frames.body !== "base_link_flu") {
|
||||
throw new PolygonWorkerContractError("provider_profile не нормализован в ENU/FLU.");
|
||||
}
|
||||
return {
|
||||
profileId: safeId(value.profile_id, "provider_profile.profile_id"),
|
||||
providers,
|
||||
clock: {
|
||||
providerId: clockProviderId,
|
||||
domain: clockDomain,
|
||||
unit: "nanoseconds",
|
||||
mode: "simulation",
|
||||
},
|
||||
controlProfiles,
|
||||
canonicalFrames: {
|
||||
world: "map_enu",
|
||||
body: "base_link_flu",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
|
||||
const value = record(payload, "Статус Simulation Worker");
|
||||
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
||||
if (
|
||||
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
|
||||
value.schema_version !== "missioncore.simulation-worker-status/v2" ||
|
||||
value.transport !== "unix" ||
|
||||
value.mode !== "simulation"
|
||||
) {
|
||||
@@ -208,11 +390,22 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
||||
}
|
||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
||||
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
|
||||
if (!Array.isArray(value.active_provider_ids) || value.active_provider_ids.length > 32) {
|
||||
throw new PolygonWorkerContractError("active_provider_ids должен быть ограниченным массивом.");
|
||||
}
|
||||
const activeProviderIds = value.active_provider_ids.map((item, index) =>
|
||||
safeId(item, `active_provider_ids[${index}]`));
|
||||
if (new Set(activeProviderIds).size !== activeProviderIds.length) {
|
||||
throw new PolygonWorkerContractError("active_provider_ids содержит повторения.");
|
||||
}
|
||||
const available = booleanValue(value.available, "available");
|
||||
const controlAvailable = booleanValue(value.control_available, "control_available");
|
||||
const providerProfile = value.provider_profile === null
|
||||
? null
|
||||
: decodeProviderProfile(value.provider_profile);
|
||||
if ((available && providerProfile === null) || (!available && controlAvailable)) {
|
||||
throw new PolygonWorkerContractError("Доступность worker и provider_profile противоречат.");
|
||||
}
|
||||
const providerIds = value.provider_ids.map((item, index) =>
|
||||
safeId(item, `provider_ids[${index}]`));
|
||||
const activeRunId = value.active_run_id === null
|
||||
? null
|
||||
: safeId(value.active_run_id, "active_run_id");
|
||||
@@ -227,11 +420,12 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
|
||||
}
|
||||
return {
|
||||
workerId: safeId(value.worker_id, "worker_id"),
|
||||
available: booleanValue(value.available, "available"),
|
||||
controlAvailable: booleanValue(value.control_available, "control_available"),
|
||||
available,
|
||||
controlAvailable,
|
||||
activeRunId,
|
||||
runState: runStateValue as PolygonRunState | null,
|
||||
providerIds,
|
||||
activeProviderIds,
|
||||
providerProfile,
|
||||
isolation: {
|
||||
network: stringValue(isolation.network, "isolation.network", 64),
|
||||
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
||||
@@ -259,11 +453,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
|
||||
const source = record(value.source, "source");
|
||||
exactKeys(source, SOURCE_KEYS, "source");
|
||||
if (
|
||||
source.provider !== "gazebo" ||
|
||||
source.signal !== "ground-truth" ||
|
||||
source.quality !== "diagnostic"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
|
||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как diagnostic ground truth.");
|
||||
}
|
||||
const safety = record(value.safety, "safety");
|
||||
exactKeys(safety, SAFETY_KEYS, "safety");
|
||||
@@ -295,6 +488,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
|
||||
z: finiteValue(orientation.z, "orientation.z"),
|
||||
w: finiteValue(orientation.w, "orientation.w"),
|
||||
},
|
||||
sourceProvider: safeId(source.provider, "source.provider"),
|
||||
sourceTopic: stringValue(source.topic, "source.topic", 512),
|
||||
sourceSignal: "ground-truth",
|
||||
sourceQuality: "diagnostic",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,19 +499,26 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
|
||||
const value = record(payload, "Подтверждение команды");
|
||||
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
|
||||
if (
|
||||
value.schema_version !== "missioncore.command-acceptance/v1" ||
|
||||
value.schema_version !== "missioncore.command-acceptance/v2" ||
|
||||
value.authority_scope !== "virtual-only"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
|
||||
}
|
||||
const delivery = record(value.delivery, "delivery");
|
||||
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
|
||||
if (
|
||||
delivery.provider !== "px4-ros2-offboard" ||
|
||||
delivery.mode !== "speed-steering"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Команда подтверждена неизвестным PX4 adapter.");
|
||||
if (delivery.control_profile !== "rover-speed-steering/v1" || delivery.accepted !== true) {
|
||||
throw new PolygonWorkerContractError("Команда подтверждена несовместимым adapter.");
|
||||
}
|
||||
const diagnosticsValue = record(delivery.diagnostics, "delivery.diagnostics");
|
||||
if (Object.keys(diagnosticsValue).length > 16) {
|
||||
throw new PolygonWorkerContractError("delivery.diagnostics превышает допустимый размер.");
|
||||
}
|
||||
const diagnostics = Object.fromEntries(
|
||||
Object.entries(diagnosticsValue).map(([key, item]) => [
|
||||
safeId(key, "delivery.diagnostics key"),
|
||||
booleanValue(item, `delivery.diagnostics.${key}`),
|
||||
]),
|
||||
);
|
||||
const sequence = integerValue(value.sequence, "sequence");
|
||||
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
|
||||
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
|
||||
@@ -342,12 +546,15 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
|
||||
validUntilSimNs,
|
||||
speedMps,
|
||||
steeringNormalized,
|
||||
armed: booleanValue(delivery.armed, "delivery.armed"),
|
||||
offboard: booleanValue(delivery.offboard, "delivery.offboard"),
|
||||
deliveryProvider: safeId(delivery.provider_id, "delivery.provider_id"),
|
||||
controlProfile: "rover-speed-steering/v1",
|
||||
accepted: true,
|
||||
controllerReady: booleanValue(delivery.controller_ready, "delivery.controller_ready"),
|
||||
ttlExpiredCount: integerValue(
|
||||
delivery.ttl_expired_count,
|
||||
"delivery.ttl_expired_count",
|
||||
),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ export function PolygonLivePanel() {
|
||||
}
|
||||
}, [worker?.activeRunId]);
|
||||
|
||||
const controllerProvider = worker?.providerProfile?.providers.find((provider) =>
|
||||
provider.roles.includes("controller"));
|
||||
const providerStack = worker?.providerProfile?.providers
|
||||
.map((provider) => provider.providerId)
|
||||
.join(" · ");
|
||||
|
||||
const stopMotion = async () => {
|
||||
const runId = worker?.activeRunId;
|
||||
setControlIntent(null);
|
||||
@@ -184,7 +190,10 @@ export function PolygonLivePanel() {
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
||||
<h2>Stock Ackermann Rover</h2>
|
||||
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
|
||||
<p>
|
||||
Провайдеры симуляции исполняются на отдельном worker; браузер показывает
|
||||
канонический срез ENU/FLU.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge
|
||||
@@ -198,7 +207,7 @@ export function PolygonLivePanel() {
|
||||
disabled={!worker?.controlAvailable || actionBusy}
|
||||
onClick={() => void runAction()}
|
||||
>
|
||||
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
|
||||
{actionBusy ? "Ожидаем провайдеры…" : runActive ? "Остановить" : "Запустить"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -221,9 +230,11 @@ export function PolygonLivePanel() {
|
||||
<div className="polygon-live-telemetry">
|
||||
<div className="polygon-rover-controls">
|
||||
<div className="polygon-rover-controls__heading">
|
||||
<span>Управление PX4</span>
|
||||
<StatusBadge tone={commandAcceptance?.offboard ? "success" : "neutral"}>
|
||||
{commandAcceptance?.offboard ? "Armed · Offboard" : "Ожидает прогона"}
|
||||
<span>
|
||||
Управление {controllerProvider?.providerId ?? "машиной"}
|
||||
</span>
|
||||
<StatusBadge tone={commandAcceptance?.controllerReady ? "success" : "neutral"}>
|
||||
{commandAcceptance?.controllerReady ? "Controller ready" : "Ожидает прогона"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="polygon-rover-controls__grid">
|
||||
@@ -270,8 +281,16 @@ export function PolygonLivePanel() {
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
|
||||
<span>Провайдерный стек</span>
|
||||
<strong>{providerStack || "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Активные процессы</span>
|
||||
<strong>{worker?.activeProviderIds.join(" · ") || "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Источник live-состояния</span>
|
||||
<strong>{live ? `${live.sourceProvider} · ${live.sourceSignal}` : "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Последняя команда</span>
|
||||
@@ -284,8 +303,9 @@ export function PolygonLivePanel() {
|
||||
<div className="polygon-live-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
|
||||
authority нет; live-поза остаётся диагностической.
|
||||
Команды проходят только через заявленный controller provider
|
||||
{controllerProvider ? ` ${controllerProvider.providerId}` : ""}. Физического
|
||||
actuator authority нет; live-поза остаётся диагностической.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="polygon-live-error">{error}</p>}
|
||||
|
||||
@@ -175,7 +175,7 @@ function jsonResponse(payload, status = 200) {
|
||||
|
||||
function workerStatus(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.simulation-worker-status/v1",
|
||||
schema_version: "missioncore.simulation-worker-status/v2",
|
||||
worker_id: "mission-gpu-s1",
|
||||
transport: "unix",
|
||||
mode: "simulation",
|
||||
@@ -183,7 +183,44 @@ function workerStatus(overrides = {}) {
|
||||
control_available: true,
|
||||
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||
run_state: "running",
|
||||
provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
|
||||
active_provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
|
||||
provider_profile: {
|
||||
schema_version: "missioncore.simulation-provider-profile/v1",
|
||||
profile_id: "stock-rover-gazebo-px4-s1d",
|
||||
providers: [
|
||||
{
|
||||
provider_id: "gazebo",
|
||||
roles: ["world", "physics", "state", "sensor"],
|
||||
capabilities: [
|
||||
"clock.simulation",
|
||||
"state.vehicle-pose",
|
||||
"truth.ground-truth",
|
||||
"sensor.virtual",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider_id: "px4-ros2-offboard",
|
||||
roles: ["controller"],
|
||||
capabilities: ["command.rover-speed-steering/v1"],
|
||||
},
|
||||
{
|
||||
provider_id: "micro-xrce-dds-agent",
|
||||
roles: ["transport"],
|
||||
capabilities: ["transport.ros2"],
|
||||
},
|
||||
],
|
||||
clock: {
|
||||
provider_id: "gazebo",
|
||||
domain: "gazebo:/clock",
|
||||
unit: "nanoseconds",
|
||||
mode: "simulation",
|
||||
},
|
||||
control_profiles: ["rover-speed-steering/v1"],
|
||||
canonical_frames: {
|
||||
world: "map_enu",
|
||||
body: "base_link_flu",
|
||||
},
|
||||
},
|
||||
isolation: {
|
||||
network: "loopback-only-netns",
|
||||
process_identity: "missioncore",
|
||||
@@ -229,7 +266,7 @@ function vehicleState(overrides = {}) {
|
||||
|
||||
function commandAcceptance(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.command-acceptance/v1",
|
||||
schema_version: "missioncore.command-acceptance/v2",
|
||||
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||
command_id: "cmd-aabbcc",
|
||||
sequence: 1,
|
||||
@@ -239,11 +276,15 @@ function commandAcceptance(overrides = {}) {
|
||||
steering_normalized: -0.55,
|
||||
authority_scope: "virtual-only",
|
||||
delivery: {
|
||||
provider: "px4-ros2-offboard",
|
||||
mode: "speed-steering",
|
||||
armed: true,
|
||||
offboard: true,
|
||||
provider_id: "px4-ros2-offboard",
|
||||
control_profile: "rover-speed-steering/v1",
|
||||
accepted: true,
|
||||
controller_ready: true,
|
||||
ttl_expired_count: 0,
|
||||
diagnostics: {
|
||||
armed: true,
|
||||
offboard: true,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
@@ -353,7 +394,8 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
|
||||
assert.equal(status.activeRunId, live.runId);
|
||||
assert.equal(status.isolation.artifactPolicy, "d-only");
|
||||
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
|
||||
assert.equal(command.offboard, true);
|
||||
assert.equal(command.controllerReady, true);
|
||||
assert.equal(command.deliveryProvider, "px4-ros2-offboard");
|
||||
assert.equal(command.steeringNormalized, -0.55);
|
||||
assert.throws(
|
||||
() => decodePolygonWorkerStatus(workerStatus({
|
||||
@@ -381,12 +423,67 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Polygon Live accepts an Unreal provider behind the canonical contract", () => {
|
||||
const unrealStatus = workerStatus({
|
||||
provider_profile: {
|
||||
...workerStatus().provider_profile,
|
||||
profile_id: "unreal-native-rover-v1",
|
||||
providers: [
|
||||
{
|
||||
provider_id: "unreal-native",
|
||||
roles: ["world", "physics", "state", "sensor"],
|
||||
capabilities: [
|
||||
"clock.simulation",
|
||||
"state.vehicle-pose",
|
||||
"truth.ground-truth",
|
||||
"sensor.virtual",
|
||||
],
|
||||
},
|
||||
{
|
||||
provider_id: "unreal-direct-control",
|
||||
roles: ["controller"],
|
||||
capabilities: ["command.rover-speed-steering/v1"],
|
||||
},
|
||||
],
|
||||
clock: {
|
||||
provider_id: "unreal-native",
|
||||
domain: "unreal:fixed-step",
|
||||
unit: "nanoseconds",
|
||||
mode: "simulation",
|
||||
},
|
||||
},
|
||||
});
|
||||
const status = decodePolygonWorkerStatus(unrealStatus);
|
||||
const live = decodePolygonVehicleState(vehicleState({
|
||||
source: {
|
||||
provider: "unreal-native",
|
||||
topic: "missioncore/vehicle-state",
|
||||
signal: "ground-truth",
|
||||
quality: "diagnostic",
|
||||
},
|
||||
}));
|
||||
const command = decodePolygonCommandAcceptance(commandAcceptance({
|
||||
delivery: {
|
||||
provider_id: "unreal-direct-control",
|
||||
control_profile: "rover-speed-steering/v1",
|
||||
accepted: true,
|
||||
controller_ready: true,
|
||||
ttl_expired_count: 0,
|
||||
diagnostics: { fixed_step: true },
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(status.providerProfile.clock.domain, "unreal:fixed-step");
|
||||
assert.equal(live.sourceProvider, "unreal-native");
|
||||
assert.equal(command.deliveryProvider, "unreal-direct-control");
|
||||
});
|
||||
|
||||
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
|
||||
const calls = [];
|
||||
const stopped = workerStatus({
|
||||
active_run_id: null,
|
||||
run_state: null,
|
||||
provider_ids: [],
|
||||
active_provider_ids: [],
|
||||
});
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
|
||||
Reference in New Issue
Block a user