Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: http-sim
command: cargo check --no-default-features --features sim,http
- name: hardware-adapter
command: cargo check --no-default-features --features sim,http,mcp,waveshare-ugv,mavlink-drone,bridge-compat
command: cargo check --no-default-features --features sim,http,mcp,waveshare-ugv,mavlink-drone,manipulator,bridge-compat
- name: all-features
command: cargo check --all-features
steps:
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ http = ["dep:axum", "dep:futures-util", "dep:tower-http"]
mcp = ["dep:rmcp", "dep:schemars"]
waveshare-ugv = ["dep:serialport"]
mavlink-drone = []
manipulator = []
bridge-compat = ["http"]
cuda = []

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ required gates without changing core command safety policy.
| bridge compatibility | `compatibility` | `beta` | `sim`, `bridge-compat` | none |
| Waveshare UGV | `mobile-base` | `alpha` | `waveshare-ugv` | `physical-actuation`, `policy-token-or-approval` |
| MAVLink drone | `drone` | `experimental` | `mavlink-drone` | `physical-actuation`, `policy-token-or-approval` |
| manipulator | `manipulator` | `experimental` | `manipulator` | `physical-actuation`, `policy-token-or-approval` |

Maturity levels are:

Expand All @@ -354,6 +355,7 @@ token gate their commands require.
| `mcp` | MCP stdio server | ✓ |
| `waveshare-ugv` | Waveshare UGV physical adapter | opt-in |
| `mavlink-drone` | MAVLink drone adapter skeleton, sim/replay proof, and gated physical profile | opt-in |
| `manipulator` | Manipulator adapter skeleton, mock arm teleop, and gated physical profile | opt-in |
| `bridge-compat` | Legacy robot bridge compatibility | opt-in |

## MAVLink Drone Skeleton
Expand Down Expand Up @@ -382,6 +384,31 @@ Flight capabilities remain policy-gated high-risk actions. The current physical
profile is a skeleton and refuses real flight command execution until a concrete
MAVLink adapter is wired behind this boundary.

## Manipulator Skeleton

The `manipulator` feature adds a mock arm teleop boundary for joint state,
joint command, pose command, home, and stop flows. Joint and pose command
responses carry the `leash-manipulator-v1` schema version so clients can pin
against the skeleton while a real IK or planning provider is still external.

```bash
cargo run --features manipulator -- list
cargo run --features manipulator -- run manipulator-sim
cargo run --features manipulator -- run manipulator-replay --replay-source examples/replay/sim-basic.jsonl
```

Physical manipulator startup is gated just like other physical adapters:

```bash
LEASH_ALLOW_PHYSICAL_ACTUATION=1 \
cargo run --features manipulator -- run manipulator-http --allow-physical-actuation
```

The current physical profile is a skeleton and refuses real joint or pose
execution until a concrete adapter is wired. IK, collision checking, motion
planning, retries, calibration, and native SDK bindings belong out-of-process or
in a future feature/example adapter crate, not in the core command registry.

## Smoke Tests

```bash
Expand Down
32 changes: 32 additions & 0 deletions scripts/smoke-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,38 @@ const checks = [
return "mavlink drone sim/replay/physical stack metadata resolved without hardware";
},
},
{
name: "stack-catalog-manipulator",
argv: ["cargo", "run", "--quiet", "--features", "manipulator", "--", "list", "--format", "json"],
validate: (stdout) => {
const stacks = JSON.parse(stdout);
const byName = new Map(stacks.map((stack) => [stack.name, stack]));
for (const required of ["manipulator-sim", "manipulator-replay", "manipulator-http"]) {
if (!byName.has(required)) {
throw new Error(`missing stack ${required}`);
}
}
const sim = byName.get("manipulator-sim");
const replay = byName.get("manipulator-replay");
const physical = byName.get("manipulator-http");
if (sim.hardware_required || replay.hardware_required) {
throw new Error("manipulator sim/replay stacks should not require hardware");
}
if (!physical.hardware_required) {
throw new Error("manipulator physical stack did not declare hardware_required");
}
if (sim.adapter.category !== "manipulator" || replay.adapter.category !== "manipulator" || physical.adapter.category !== "manipulator") {
throw new Error("manipulator stacks did not declare manipulator adapter metadata");
}
if (!sim.adapter.capabilities.includes("manipulator_joint_state") || !replay.adapter.capabilities.includes("manipulator_pose_command")) {
throw new Error("manipulator sim/replay stacks did not expose manipulator capabilities");
}
if (!physical.adapter.required_gates.includes("physical-actuation")) {
throw new Error("manipulator physical stack did not declare physical-actuation gate");
}
return "manipulator sim/replay/physical stack metadata resolved without hardware";
},
},
{
name: "graph-sim-json",
argv: ["cargo", "run", "--quiet", "--", "graph", "sim", "--format", "json"],
Expand Down
3 changes: 3 additions & 0 deletions scripts/smoke-physical-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ check_refuses_without_gate "waveshare-ugv" \
check_refuses_without_gate "mavlink-drone" \
cargo run --quiet --features mavlink-drone -- serve http --profile mavlink-drone --listen 127.0.0.1:18082 --mavlink-endpoint udp://127.0.0.1:14550

check_refuses_without_gate "manipulator" \
cargo run --quiet --features manipulator -- serve http --profile manipulator --listen 127.0.0.1:18083

echo "physical gate smoke ok"
3 changes: 2 additions & 1 deletion src/bin/leash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -788,14 +788,15 @@ fn resolve_config_stack(name: &str) -> Result<ConfigStack> {
"replay" => Profile::Replay,
"waveshare-ugv" => Profile::WaveshareUgv,
"mavlink-drone" => Profile::MavlinkDrone,
"manipulator" => Profile::Manipulator,
other => {
let stacks = built_in_stacks()
.into_iter()
.map(|stack| stack.name)
.collect::<Vec<_>>()
.join(", ");
bail!(
"unknown stack or profile '{other}'; expected sim, replay, waveshare-ugv, mavlink-drone, or one of: {stacks}"
"unknown stack or profile '{other}'; expected sim, replay, waveshare-ugv, mavlink-drone, manipulator, or one of: {stacks}"
);
}
};
Expand Down
202 changes: 192 additions & 10 deletions src/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,52 @@ impl CapabilityRegistry {
)?)
.map_err(Into::into)
}
#[cfg(feature = "manipulator")]
"manipulator_joint_state" => {
ensure_fields(&args, &[])?;
serde_json::to_value(self.harness.manipulator_joint_state()).map_err(Into::into)
}
#[cfg(feature = "manipulator")]
"manipulator_joint_command" => {
ensure_fields(&args, &["token", "joints"])?;
let token = optional_string(&args, "token")?;
let joints = args
.get("joints")
.cloned()
.ok_or_else(|| anyhow!("joints is required"))?;
serde_json::to_value(self.harness.manipulator_command(
"joint_command",
token.as_deref(),
json!({ "joints": joints }),
)?)
.map_err(Into::into)
}
#[cfg(feature = "manipulator")]
"manipulator_pose_command" => {
ensure_fields(&args, &["token", "pose"])?;
let token = optional_string(&args, "token")?;
let pose = args
.get("pose")
.cloned()
.ok_or_else(|| anyhow!("pose is required"))?;
serde_json::to_value(self.harness.manipulator_command(
"pose_command",
token.as_deref(),
json!({ "pose": pose }),
)?)
.map_err(Into::into)
}
#[cfg(feature = "manipulator")]
"manipulator_home" => {
ensure_fields(&args, &["token"])?;
let token = optional_string(&args, "token")?;
serde_json::to_value(self.harness.manipulator_command(
"home",
token.as_deref(),
json!({}),
)?)
.map_err(Into::into)
}
other => Err(anyhow!("unknown capability '{other}'")),
}
}
Expand Down Expand Up @@ -695,16 +741,25 @@ pub fn default_capability_descriptors() -> Vec<CapabilityDescriptor> {
"SpatialMemoryStatus",
),
];
feature_capability_descriptors(descriptors)
}

#[cfg(any(feature = "mavlink-drone", feature = "manipulator"))]
fn feature_capability_descriptors(
mut descriptors: Vec<CapabilityDescriptor>,
) -> Vec<CapabilityDescriptor> {
#[cfg(feature = "mavlink-drone")]
{
let mut descriptors = descriptors;
descriptors.extend(drone_capability_descriptors());
descriptors
}
#[cfg(not(feature = "mavlink-drone"))]
{
descriptors
}
descriptors.extend(drone_capability_descriptors());
#[cfg(feature = "manipulator")]
descriptors.extend(manipulator_capability_descriptors());
descriptors
}

#[cfg(not(any(feature = "mavlink-drone", feature = "manipulator")))]
fn feature_capability_descriptors(
descriptors: Vec<CapabilityDescriptor>,
) -> Vec<CapabilityDescriptor> {
descriptors
}

#[cfg(feature = "mavlink-drone")]
Expand Down Expand Up @@ -772,6 +827,48 @@ fn drone_capability_descriptors() -> Vec<CapabilityDescriptor> {
]
}

#[cfg(feature = "manipulator")]
fn manipulator_capability_descriptors() -> Vec<CapabilityDescriptor> {
vec![
descriptor(
"manipulator_joint_state",
"Read the versioned manipulator joint state",
SafetyClass::ObserveOnly,
object_schema(&[]),
"ManipulatorJointState",
),
descriptor(
"manipulator_joint_command",
"Send a versioned manipulator joint command",
SafetyClass::PhysicalHighRisk,
object_schema(&[
("token", "string", false),
("approval", "boolean", false),
("joints", "ManipulatorJointCommandV1", true),
]),
"ManipulatorCommandStatus",
),
descriptor(
"manipulator_pose_command",
"Send a versioned manipulator end-effector pose command",
SafetyClass::PhysicalHighRisk,
object_schema(&[
("token", "string", false),
("approval", "boolean", false),
("pose", "ManipulatorPoseCommandV1", true),
]),
"ManipulatorCommandStatus",
),
descriptor(
"manipulator_home",
"Move the manipulator to its configured home pose",
SafetyClass::PhysicalHighRisk,
object_schema(&[("token", "string", false), ("approval", "boolean", false)]),
"ManipulatorCommandStatus",
),
]
}

fn descriptor(
name: &str,
description: &str,
Expand Down Expand Up @@ -837,6 +934,19 @@ fn canonical_name(name: &str) -> &str {
| "drone.move-velocity"
| "drone/move-velocity" => "drone_move_velocity",
"drone.fly_to" | "drone/fly_to" | "drone.fly-to" | "drone/fly-to" => "drone_fly_to",
"manipulator.joint_state"
| "manipulator/joint_state"
| "manipulator.joint-state"
| "manipulator/joint-state" => "manipulator_joint_state",
"manipulator.joint_command"
| "manipulator/joint_command"
| "manipulator.joint-command"
| "manipulator/joint-command" => "manipulator_joint_command",
"manipulator.pose_command"
| "manipulator/pose_command"
| "manipulator.pose-command"
| "manipulator/pose-command" => "manipulator_pose_command",
"manipulator.home" | "manipulator/home" => "manipulator_home",
other => other,
}
}
Expand Down Expand Up @@ -964,7 +1074,7 @@ fn optional_spatial_memory_kind(
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "mavlink-drone")]
#[cfg(any(feature = "mavlink-drone", feature = "manipulator"))]
use crate::config::Profile;
use crate::{config::PolicyMode, types::TelemetryFrame, HarnessConfig};

Expand Down Expand Up @@ -1089,6 +1199,78 @@ mod tests {
assert!(err.contains("gated skeleton"));
}

#[cfg(feature = "manipulator")]
#[tokio::test]
async fn manipulator_capabilities_are_simulated_and_policy_gated() {
let harness = Harness::new(HarnessConfig {
policy_mode: PolicyMode::RequireApproval,
..HarnessConfig::default()
})
.unwrap();
let registry = CapabilityRegistry::new(harness);

let state = registry
.invoke_value("manipulator.joint-state", json!({}))
.unwrap();
assert_eq!(state["version"], "leash-manipulator-v1");
assert_eq!(state["simulated"], true);
assert_eq!(state["joints"][0]["name"], "shoulder_pan");

let err = registry
.invoke_value(
"manipulator_joint_command",
json!({ "joints": [{ "name": "elbow", "position_rad": 0.4 }] }),
)
.unwrap_err()
.to_string();
assert!(err.contains("policy require-approval"));

let commanded = registry
.invoke_value(
"manipulator.joint-command",
json!({
"approval": true,
"joints": [{ "name": "elbow", "position_rad": 0.4 }]
}),
)
.unwrap();
assert_eq!(commanded["version"], "leash-manipulator-v1");
assert_eq!(commanded["command"], "joint_command");
assert_eq!(commanded["simulated"], true);

let homed = registry
.invoke_value("manipulator_home", json!({ "approval": true }))
.unwrap();
assert_eq!(homed["command"], "home");
}

#[cfg(feature = "manipulator")]
#[tokio::test]
async fn physical_manipulator_profile_stays_a_gated_skeleton() {
let err = match Harness::new(HarnessConfig {
profile: Profile::Manipulator,
..HarnessConfig::default()
}) {
Ok(_) => panic!("expected manipulator profile to require the physical gate"),
Err(err) => err.to_string(),
};
assert!(err.contains("LEASH_ALLOW_PHYSICAL_ACTUATION"));

let harness = Harness::new(HarnessConfig {
profile: Profile::Manipulator,
allow_physical_actuation: true,
policy_mode: PolicyMode::RequireApproval,
..HarnessConfig::default()
})
.unwrap();
let registry = CapabilityRegistry::new(harness);
let err = registry
.invoke_value("manipulator_home", json!({ "approval": true }))
.unwrap_err()
.to_string();
assert!(err.contains("gated skeleton"));
}

#[tokio::test]
async fn patrol_capabilities_start_stop_and_report_status() {
let harness = Harness::new(HarnessConfig::default()).unwrap();
Expand Down
Loading
Loading