feat(simulation): drive stock rover through PX4 offboard
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
@@ -8,12 +8,28 @@ import {
|
||||
import {
|
||||
fetchPolygonVehicleState,
|
||||
fetchPolygonWorkerStatus,
|
||||
sendPolygonRoverCommand,
|
||||
startPolygonWorker,
|
||||
stopPolygonWorker,
|
||||
type PolygonCommandAcceptance,
|
||||
type PolygonVehicleState,
|
||||
type PolygonWorkerStatus,
|
||||
} from "../core/polygon/liveWorker";
|
||||
|
||||
interface ControlIntent {
|
||||
id: "forward" | "reverse" | "left" | "right";
|
||||
label: string;
|
||||
speedMps: number;
|
||||
steeringNormalized: number;
|
||||
}
|
||||
|
||||
const CONTROL_INTENTS: ControlIntent[] = [
|
||||
{ id: "forward", label: "Вперёд", speedMps: 1, steeringNormalized: 0 },
|
||||
{ id: "left", label: "Влево", speedMps: 0.8, steeringNormalized: -0.55 },
|
||||
{ id: "right", label: "Вправо", speedMps: 0.8, steeringNormalized: 0.55 },
|
||||
{ id: "reverse", label: "Назад", speedMps: -0.65, steeringNormalized: 0 },
|
||||
];
|
||||
|
||||
const PolygonRoverScene = lazy(async () => {
|
||||
const module = await import("./PolygonRoverScene");
|
||||
return { default: module.PolygonRoverScene };
|
||||
@@ -30,7 +46,11 @@ export function PolygonLivePanel() {
|
||||
const [trajectory, setTrajectory] = useState<PolygonVehicleState[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [controlIntent, setControlIntent] = useState<ControlIntent | null>(null);
|
||||
const [commandAcceptance, setCommandAcceptance] =
|
||||
useState<PolygonCommandAcceptance | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const commandInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionBusy) return;
|
||||
@@ -73,12 +93,75 @@ export function PolygonLivePanel() {
|
||||
|
||||
const runActive = Boolean(worker?.activeRunId);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = worker?.activeRunId;
|
||||
if (!runId || worker.runState !== "running" || !controlIntent) return;
|
||||
const controller = new AbortController();
|
||||
let timer: number | null = null;
|
||||
const publish = async () => {
|
||||
if (commandInFlight.current || controller.signal.aborted) return;
|
||||
commandInFlight.current = true;
|
||||
try {
|
||||
const accepted = await sendPolygonRoverCommand(runId, {
|
||||
speedMps: controlIntent.speedMps,
|
||||
steeringNormalized: controlIntent.steeringNormalized,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setCommandAcceptance(accepted);
|
||||
setError(null);
|
||||
} catch (commandError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setControlIntent(null);
|
||||
setError(message(commandError));
|
||||
} finally {
|
||||
commandInFlight.current = false;
|
||||
if (!controller.signal.aborted) {
|
||||
timer = window.setTimeout(() => void publish(), 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
void publish();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [controlIntent, worker?.activeRunId, worker?.runState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!worker?.activeRunId) {
|
||||
setControlIntent(null);
|
||||
setCommandAcceptance(null);
|
||||
}
|
||||
}, [worker?.activeRunId]);
|
||||
|
||||
const stopMotion = async () => {
|
||||
const runId = worker?.activeRunId;
|
||||
setControlIntent(null);
|
||||
if (!runId || worker?.runState !== "running") return;
|
||||
try {
|
||||
const accepted = await sendPolygonRoverCommand(runId, {
|
||||
speedMps: 0,
|
||||
steeringNormalized: 0,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
setCommandAcceptance(accepted);
|
||||
setError(null);
|
||||
} catch (commandError) {
|
||||
setError(message(commandError));
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async () => {
|
||||
if (!worker?.controlAvailable || actionBusy) return;
|
||||
setActionBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
if (worker.activeRunId) {
|
||||
await stopMotion();
|
||||
}
|
||||
const next = worker.activeRunId
|
||||
? await stopPolygonWorker(worker.activeRunId, { idempotencyKey })
|
||||
: await startPolygonWorker({ idempotencyKey });
|
||||
@@ -136,6 +219,40 @@ export function PolygonLivePanel() {
|
||||
</Suspense>
|
||||
|
||||
<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" : "Ожидает прогона"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="polygon-rover-controls__grid">
|
||||
{CONTROL_INTENTS.map((intent) => (
|
||||
<Button
|
||||
key={intent.id}
|
||||
size="compact"
|
||||
variant={controlIntent?.id === intent.id ? "primary" : "secondary"}
|
||||
disabled={!runActive || worker?.runState !== "running" || actionBusy}
|
||||
onClick={() => setControlIntent(intent)}
|
||||
>
|
||||
{intent.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={!runActive || worker?.runState !== "running" || actionBusy}
|
||||
onClick={() => void stopMotion()}
|
||||
>
|
||||
Стоп
|
||||
</Button>
|
||||
</div>
|
||||
<small>
|
||||
{controlIntent
|
||||
? `${controlIntent.label}: ${controlIntent.speedMps.toFixed(2)} m/s · steering ${controlIntent.steeringNormalized.toFixed(2)}`
|
||||
: "Команда 250 мс; потеря браузера автоматически обнуляет скорость."}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Прогон</span>
|
||||
<strong>{worker?.activeRunId ?? "—"}</strong>
|
||||
@@ -156,11 +273,19 @@ export function PolygonLivePanel() {
|
||||
<span>Провайдеры</span>
|
||||
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Последняя команда</span>
|
||||
<strong>
|
||||
{commandAcceptance
|
||||
? `#${commandAcceptance.sequence} · ${commandAcceptance.speedMps.toFixed(2)} m/s · ${commandAcceptance.steeringNormalized.toFixed(2)}`
|
||||
: "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="polygon-live-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Нет actuator authority. Live-поза диагностическая и пока не доказывает
|
||||
приёмку PX4/ROS 2 telemetry, navigation или safety.
|
||||
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
|
||||
authority нет; live-поза остаётся диагностической.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="polygon-live-error">{error}</p>}
|
||||
|
||||
Reference in New Issue
Block a user