first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=conda-common.sh
|
||||
source "${SCRIPT_DIR}/conda-common.sh"
|
||||
|
||||
REPO_ROOT="$(find_repo_root)"
|
||||
CONDA="$(find_conda)"
|
||||
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
|
||||
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
MOVEIT2_SERVER_CONDA_ENV_NAME="${MOVEIT2_SERVER_CONDA_ENV_NAME:-moveit2-server}"
|
||||
|
||||
find_conda() {
|
||||
if [[ -n "${MOVEIT2_SERVER_CONDA_EXE:-}" && -x "${MOVEIT2_SERVER_CONDA_EXE}" ]]; then
|
||||
printf '%s\n' "${MOVEIT2_SERVER_CONDA_EXE}"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${CONDA_EXE:-}" && -x "${CONDA_EXE}" ]]; then
|
||||
printf '%s\n' "${CONDA_EXE}"
|
||||
return 0
|
||||
fi
|
||||
if command -v conda >/dev/null 2>&1; then
|
||||
command -v conda
|
||||
return 0
|
||||
fi
|
||||
local candidate
|
||||
for candidate in \
|
||||
"${HOME}/miniforge3/bin/conda" \
|
||||
"${HOME}/mambaforge/bin/conda" \
|
||||
"${HOME}/miniconda3/bin/conda" \
|
||||
"${HOME}/anaconda3/bin/conda" \
|
||||
"/opt/conda/bin/conda"
|
||||
do
|
||||
if [[ -x "${candidate}" ]]; then
|
||||
printf '%s\n' "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'Could not find conda. Install Miniforge, put conda on PATH, or set MOVEIT2_SERVER_CONDA_EXE/CONDA_EXE.\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
find_repo_root() {
|
||||
if [[ -n "${MOVEIT2_SERVER_REPO_ROOT:-}" ]]; then
|
||||
if [[ ! -d "${MOVEIT2_SERVER_REPO_ROOT}" ]]; then
|
||||
printf 'MOVEIT2_SERVER_REPO_ROOT does not exist: %s\n' "${MOVEIT2_SERVER_REPO_ROOT}" >&2
|
||||
return 1
|
||||
fi
|
||||
(cd "${MOVEIT2_SERVER_REPO_ROOT}" && pwd -P)
|
||||
return 0
|
||||
fi
|
||||
|
||||
local root
|
||||
if root="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
printf '%s\n' "${root}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'Could not find a repository root from the current directory. Run from the robot project repo or set MOVEIT2_SERVER_REPO_ROOT.\n' >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
name: moveit2-server
|
||||
channels:
|
||||
- conda-forge
|
||||
- robostack-jazzy
|
||||
dependencies:
|
||||
- python=3.12
|
||||
- pip
|
||||
- websockets
|
||||
- ros-jazzy-ros-base
|
||||
- ros-jazzy-rclpy
|
||||
- ros-jazzy-geometry-msgs
|
||||
- ros-jazzy-moveit-py
|
||||
- ros-jazzy-moveit-planners-ompl
|
||||
- ros-jazzy-rmw-cyclonedds-cpp
|
||||
@@ -0,0 +1 @@
|
||||
"""MoveIt2 websocket server for SRDF workflows."""
|
||||
@@ -0,0 +1,904 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
import posixpath
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from moveit2_server.protocol import MotionProtocolError, SUPPORTED_REQUEST_TYPES
|
||||
from moveit2_server.srdf_source import SrdfSource, read_srdf_source
|
||||
|
||||
|
||||
DEFAULT_CAD_DIRECTORY = ""
|
||||
DEFAULT_IK_TIMEOUT = 0.05
|
||||
DEFAULT_IK_ATTEMPTS = 1
|
||||
DEFAULT_POSITION_TOLERANCE = 0.002
|
||||
DEFAULT_PLANNING_PIPELINE = "ompl"
|
||||
DEFAULT_PLANNER_ID = "RRTConnectkConfigDefault"
|
||||
DEFAULT_PLANNING_TIME = 1.0
|
||||
|
||||
|
||||
def _plain_object(value: Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def _path_is_inside(child_path: Path, parent_path: Path) -> bool:
|
||||
try:
|
||||
child_path.resolve().relative_to(parent_path.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _file_version(file_path: Path) -> str:
|
||||
try:
|
||||
stats = file_path.stat()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
if not stats.st_mode:
|
||||
return ""
|
||||
return f"{stats.st_size:x}-{stats.st_mtime_ns:x}"
|
||||
|
||||
|
||||
def _combined_version(paths: list[Path]) -> str:
|
||||
return "|".join(f"{path.name}:{_file_version(path)}" for path in paths)
|
||||
|
||||
|
||||
def _normalize_relative_path(value: Any, *, label: str, suffix: str | None = None) -> str:
|
||||
raw_value = str(value or "").strip().replace("\\", "/").lstrip("/")
|
||||
normalized = posixpath.normpath(raw_value)
|
||||
if not raw_value or normalized in {"", ".", ".."} or normalized.startswith("../"):
|
||||
raise MotionProtocolError(f"{label} must stay inside the repository: {raw_value or '(missing)'}")
|
||||
if suffix and not normalized.lower().endswith(suffix):
|
||||
raise MotionProtocolError(f"{label} must end in {suffix}: {normalized}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_cad_directory(value: Any = DEFAULT_CAD_DIRECTORY) -> str:
|
||||
if value is None:
|
||||
value = DEFAULT_CAD_DIRECTORY
|
||||
raw_value = str(value).strip()
|
||||
if not raw_value:
|
||||
return ""
|
||||
return _normalize_relative_path(raw_value, label="dir")
|
||||
|
||||
|
||||
def normalize_file_ref(value: Any) -> str:
|
||||
return _normalize_relative_path(value, label="file", suffix=".srdf")
|
||||
|
||||
|
||||
def _file_ref_relative_to_cad_dir(file_ref: str, *, cad_dir: str, cad_root: Path) -> str:
|
||||
if not cad_dir:
|
||||
return file_ref
|
||||
prefix = f"{cad_dir.rstrip('/')}/"
|
||||
if not file_ref.startswith(prefix):
|
||||
return file_ref
|
||||
scan_relative_ref = file_ref[len(prefix):]
|
||||
if scan_relative_ref and not (cad_root / file_ref).is_file() and (cad_root / scan_relative_ref).is_file():
|
||||
return scan_relative_ref
|
||||
return file_ref
|
||||
|
||||
|
||||
def _resolve_srdf_urdf_path(srdf_source: SrdfSource, *, srdf_path: Path, repo_root: Path) -> Path:
|
||||
raw_value = str(srdf_source.urdf_ref or "").strip()
|
||||
if not raw_value:
|
||||
raise MotionProtocolError("SRDF must include tcad:urdf metadata")
|
||||
if "\\" in raw_value:
|
||||
raise MotionProtocolError("SRDF tcad:urdf path must use POSIX '/' separators")
|
||||
raw_path = Path(raw_value)
|
||||
if raw_path.is_absolute():
|
||||
raise MotionProtocolError("SRDF tcad:urdf path must be relative")
|
||||
candidate = (srdf_path.parent / raw_path).resolve()
|
||||
if not _path_is_inside(candidate, repo_root):
|
||||
raise MotionProtocolError(f"SRDF tcad:urdf path must stay inside the repository: {raw_value}")
|
||||
if candidate.suffix.lower() != ".urdf":
|
||||
raise MotionProtocolError(f"SRDF tcad:urdf path must end in .urdf: {raw_value}")
|
||||
if not candidate.is_file():
|
||||
raise MotionProtocolError(f"SRDF tcad:urdf file does not exist: {raw_value}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _urdf_robot(urdf_path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
root = ET.parse(urdf_path).getroot()
|
||||
except FileNotFoundError as exc:
|
||||
raise MotionProtocolError(f"URDF file does not exist: {urdf_path}") from exc
|
||||
except ET.ParseError as exc:
|
||||
raise MotionProtocolError(f"URDF file is invalid XML: {urdf_path}") from exc
|
||||
if root.tag != "robot":
|
||||
raise MotionProtocolError("URDF root must be <robot>")
|
||||
links = {
|
||||
str(link.get("name") or "").strip()
|
||||
for link in root.findall("link")
|
||||
if str(link.get("name") or "").strip()
|
||||
}
|
||||
joints: dict[str, dict[str, str]] = {}
|
||||
for joint in root.findall("joint"):
|
||||
name = str(joint.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
parent_element = joint.find("parent")
|
||||
child_element = joint.find("child")
|
||||
joint_type = str(joint.get("type") or "").strip()
|
||||
limit_element = joint.find("limit")
|
||||
lower = _optional_finite_float(limit_element.get("lower") if limit_element is not None else None)
|
||||
upper = _optional_finite_float(limit_element.get("upper") if limit_element is not None else None)
|
||||
joints[name] = {
|
||||
"type": joint_type,
|
||||
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
|
||||
"child": str(child_element.get("link") if child_element is not None else "").strip(),
|
||||
"lower": lower if joint_type in {"revolute", "prismatic"} else None,
|
||||
"upper": upper if joint_type in {"revolute", "prismatic"} else None,
|
||||
"mimic": joint.find("mimic") is not None,
|
||||
}
|
||||
return {
|
||||
"name": str(root.get("name") or "").strip(),
|
||||
"links": links,
|
||||
"joints": joints,
|
||||
}
|
||||
|
||||
|
||||
def _optional_finite_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isfinite(parsed) else None
|
||||
|
||||
|
||||
def _active_joint_names(robot: dict[str, Any]) -> list[str]:
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return []
|
||||
return [
|
||||
str(name)
|
||||
for name, joint in joints.items()
|
||||
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic"))
|
||||
]
|
||||
|
||||
|
||||
def _all_joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
|
||||
if not base_link or not tip_link:
|
||||
return []
|
||||
if base_link == tip_link:
|
||||
return []
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return []
|
||||
by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = {}
|
||||
for raw_name, raw_joint in joints.items():
|
||||
if not isinstance(raw_joint, dict):
|
||||
continue
|
||||
parent = str(raw_joint.get("parent") or "").strip()
|
||||
child = str(raw_joint.get("child") or "").strip()
|
||||
if parent and child:
|
||||
by_parent.setdefault(parent, []).append((str(raw_name), raw_joint))
|
||||
|
||||
stack: list[tuple[str, list[tuple[str, dict[str, Any]]]]] = [(base_link, [])]
|
||||
visited: set[str] = set()
|
||||
while stack:
|
||||
link_name, path = stack.pop()
|
||||
if link_name == tip_link:
|
||||
return [joint_name for joint_name, _joint in path]
|
||||
if link_name in visited:
|
||||
continue
|
||||
visited.add(link_name)
|
||||
for joint_name, joint in reversed(by_parent.get(link_name, [])):
|
||||
child = str(joint.get("child") or "").strip()
|
||||
if child:
|
||||
stack.append((child, [*path, (joint_name, joint)]))
|
||||
return []
|
||||
|
||||
|
||||
def _joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return []
|
||||
names: list[str] = []
|
||||
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
|
||||
joint = joints.get(joint_name)
|
||||
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic")):
|
||||
names.append(joint_name)
|
||||
return names
|
||||
|
||||
|
||||
def _append_unique(target: list[str], values: list[str]) -> None:
|
||||
seen = set(target)
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
target.append(value)
|
||||
seen.add(value)
|
||||
|
||||
|
||||
def _joint_names_for_group(
|
||||
group: dict[str, Any],
|
||||
*,
|
||||
robot: dict[str, Any],
|
||||
groups_by_name: dict[str, dict[str, Any]],
|
||||
visited: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
explicit_joint_names = list(group.get("jointNames") or [])
|
||||
if explicit_joint_names:
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return []
|
||||
return [
|
||||
str(name)
|
||||
for name in explicit_joint_names
|
||||
if isinstance(joints.get(str(name)), dict)
|
||||
and str(joints[str(name)].get("type") or "").strip() != "fixed"
|
||||
and not bool(joints[str(name)].get("mimic"))
|
||||
]
|
||||
|
||||
names: list[str] = []
|
||||
for chain in group.get("chains") or []:
|
||||
if not isinstance(chain, dict):
|
||||
continue
|
||||
_append_unique(
|
||||
names,
|
||||
_joint_names_for_chain(
|
||||
robot,
|
||||
base_link=str(chain.get("baseLink") or "").strip(),
|
||||
tip_link=str(chain.get("tipLink") or "").strip(),
|
||||
),
|
||||
)
|
||||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
group_name = str(group.get("name") or "").strip()
|
||||
if group_name:
|
||||
visited.add(group_name)
|
||||
for subgroup_name in group.get("subgroups") or []:
|
||||
subgroup_key = str(subgroup_name or "").strip()
|
||||
if not subgroup_key or subgroup_key in visited:
|
||||
continue
|
||||
subgroup = groups_by_name.get(subgroup_key)
|
||||
if subgroup is not None:
|
||||
_append_unique(
|
||||
names,
|
||||
_joint_names_for_group(
|
||||
subgroup,
|
||||
robot=robot,
|
||||
groups_by_name=groups_by_name,
|
||||
visited=visited,
|
||||
),
|
||||
)
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def _positive_float(value: Any, *, label: str, default: float) -> float:
|
||||
if value is None:
|
||||
value = default
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError(f"{label} must be positive") from exc
|
||||
if numeric <= 0:
|
||||
raise MotionProtocolError(f"{label} must be positive")
|
||||
return numeric
|
||||
|
||||
|
||||
def _positive_int(value: Any, *, label: str, default: int) -> int:
|
||||
if value is None:
|
||||
value = default
|
||||
try:
|
||||
numeric = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError(f"{label} must be a positive integer") from exc
|
||||
if numeric <= 0:
|
||||
raise MotionProtocolError(f"{label} must be a positive integer")
|
||||
return numeric
|
||||
|
||||
|
||||
def _scaling(value: Any, *, label: str, default: float) -> float:
|
||||
if value is None:
|
||||
value = default
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError(f"{label} must be between 0 and 1") from exc
|
||||
if numeric <= 0 or numeric > 1:
|
||||
raise MotionProtocolError(f"{label} must be between 0 and 1")
|
||||
return numeric
|
||||
|
||||
|
||||
def _boolean(value: Any, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _string(value: Any, *, label: str, default: str = "") -> str:
|
||||
normalized = str(value if value is not None else default).strip()
|
||||
if not normalized:
|
||||
raise MotionProtocolError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _optional_object(value: Any, *, label: str) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not _plain_object(value):
|
||||
raise MotionProtocolError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_srdf_inventory(metadata: dict[str, Any], *, robot: dict[str, Any]) -> dict[str, Any]:
|
||||
if str(metadata.get("robotName") or "").strip() != str(robot.get("name") or "").strip():
|
||||
raise MotionProtocolError("SRDF robotName must match the linked URDF robot name")
|
||||
|
||||
link_names = robot.get("links")
|
||||
joint_payload = robot.get("joints")
|
||||
if not isinstance(link_names, set) or not isinstance(joint_payload, dict):
|
||||
raise MotionProtocolError("URDF robot inventory is invalid")
|
||||
joint_names = set(joint_payload)
|
||||
|
||||
groups = _validate_planning_groups(metadata.get("planningGroups"), link_names=link_names, joint_names=joint_names)
|
||||
group_by_name = {group["name"]: group for group in groups}
|
||||
end_effectors = _validate_end_effectors(
|
||||
metadata.get("endEffectors"),
|
||||
link_names=link_names,
|
||||
groups=group_by_name,
|
||||
robot=robot,
|
||||
)
|
||||
group_states = _validate_group_states(metadata.get("groupStates"), groups=group_by_name, robot=robot)
|
||||
disabled_pairs = _validate_disabled_pairs(metadata.get("disabledCollisionPairs"), link_names=link_names)
|
||||
return {
|
||||
**metadata,
|
||||
"planningGroups": groups,
|
||||
"endEffectors": end_effectors,
|
||||
"groupStates": group_states,
|
||||
"disabledCollisionPairs": disabled_pairs,
|
||||
}
|
||||
|
||||
|
||||
def _group_tip_link(group: Any) -> str:
|
||||
if isinstance(group, dict):
|
||||
link_names = group.get("linkNames")
|
||||
if isinstance(link_names, list) and link_names:
|
||||
return str(link_names[-1])
|
||||
chains = group.get("chains")
|
||||
if isinstance(chains, list) and chains:
|
||||
chain = chains[-1]
|
||||
if isinstance(chain, dict):
|
||||
return str(chain.get("tipLink") or "")
|
||||
return ""
|
||||
link_names = getattr(group, "link_names", ())
|
||||
if link_names:
|
||||
return str(link_names[-1])
|
||||
chains = getattr(group, "chains", ())
|
||||
if chains:
|
||||
return str(getattr(chains[-1], "tip_link", "") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _end_effector_link(end_effector: Any, groups_by_name: dict[str, Any]) -> str:
|
||||
group_name = str(getattr(end_effector, "group", "") or "")
|
||||
group = groups_by_name.get(group_name)
|
||||
if group is not None:
|
||||
group_tip = _group_tip_link(group)
|
||||
if group_tip:
|
||||
return group_tip
|
||||
return ""
|
||||
|
||||
|
||||
def _srdf_inventory_from_source(srdf_source: SrdfSource, *, srdf_path: Path, urdf_path: Path) -> dict[str, Any]:
|
||||
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
|
||||
return {
|
||||
"urdf": str(urdf_path),
|
||||
"srdf": str(srdf_path),
|
||||
"robotName": srdf_source.robot_name,
|
||||
"planningGroups": [
|
||||
{
|
||||
"name": group.name,
|
||||
"jointNames": list(group.joint_names),
|
||||
"linkNames": list(group.link_names),
|
||||
"chains": [
|
||||
{"baseLink": chain.base_link, "tipLink": chain.tip_link}
|
||||
for chain in group.chains
|
||||
],
|
||||
"subgroups": list(group.subgroups),
|
||||
}
|
||||
for group in srdf_source.planning_groups
|
||||
],
|
||||
"endEffectors": [
|
||||
{
|
||||
"name": end_effector.name,
|
||||
"link": _end_effector_link(end_effector, groups_by_name) or end_effector.parent_link,
|
||||
"parentLink": end_effector.parent_link,
|
||||
"group": end_effector.group,
|
||||
"parentGroup": end_effector.parent_group,
|
||||
}
|
||||
for end_effector in srdf_source.end_effectors
|
||||
],
|
||||
"groupStates": [
|
||||
{
|
||||
"name": group_state.name,
|
||||
"group": group_state.group,
|
||||
"jointValuesByName": group_state.joint_values_by_name,
|
||||
"jointValuesByNameRad": group_state.joint_values_by_name,
|
||||
}
|
||||
for group_state in srdf_source.group_states
|
||||
],
|
||||
"disabledCollisionPairs": [
|
||||
{
|
||||
"link1": pair.link1,
|
||||
"link2": pair.link2,
|
||||
"reason": pair.reason,
|
||||
"source": pair.source,
|
||||
}
|
||||
for pair in srdf_source.disabled_collision_pairs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _validate_planning_groups(value: Any, *, link_names: set[str], joint_names: set[str]) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise MotionProtocolError("SRDF planningGroups must be a non-empty array")
|
||||
groups: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
for raw_group in value:
|
||||
if not _plain_object(raw_group):
|
||||
raise MotionProtocolError("SRDF planning group must be an object")
|
||||
name = _string(raw_group.get("name"), label="SRDF planning group name")
|
||||
if name in seen:
|
||||
raise MotionProtocolError(f"Duplicate SRDF planning group {name}")
|
||||
seen.add(name)
|
||||
group_joint_names = _validate_names(raw_group.get("jointNames"), allowed=joint_names, label=f"SRDF planning group {name} jointNames")
|
||||
group_link_names = _validate_names(raw_group.get("linkNames"), allowed=link_names, label=f"SRDF planning group {name} linkNames")
|
||||
chains = raw_group.get("chains") if isinstance(raw_group.get("chains"), list) else []
|
||||
normalized_chains: list[dict[str, str]] = []
|
||||
for raw_chain in chains:
|
||||
if not _plain_object(raw_chain):
|
||||
raise MotionProtocolError(f"SRDF planning group {name} chains must contain objects")
|
||||
base_link = _string(raw_chain.get("baseLink"), label=f"SRDF planning group {name} chain baseLink")
|
||||
tip_link = _string(raw_chain.get("tipLink"), label=f"SRDF planning group {name} chain tipLink")
|
||||
if base_link not in link_names or tip_link not in link_names:
|
||||
raise MotionProtocolError(f"SRDF planning group {name} chain references missing URDF link")
|
||||
normalized_chains.append({"baseLink": base_link, "tipLink": tip_link})
|
||||
subgroups = [str(group).strip() for group in raw_group.get("subgroups", []) if str(group).strip()]
|
||||
groups.append({
|
||||
"name": name,
|
||||
"jointNames": group_joint_names,
|
||||
"linkNames": group_link_names,
|
||||
"chains": normalized_chains,
|
||||
"subgroups": subgroups,
|
||||
})
|
||||
group_names = {group["name"] for group in groups}
|
||||
for group in groups:
|
||||
for subgroup in group["subgroups"]:
|
||||
if subgroup not in group_names:
|
||||
raise MotionProtocolError(f"SRDF planning group {group['name']} references missing subgroup {subgroup}")
|
||||
return groups
|
||||
|
||||
|
||||
def _validate_names(value: Any, *, allowed: set[str], label: str) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MotionProtocolError(f"{label} must be an array")
|
||||
names: list[str] = []
|
||||
seen = set()
|
||||
for raw_name in value:
|
||||
name = str(raw_name or "").strip()
|
||||
if not name:
|
||||
raise MotionProtocolError(f"{label} cannot include empty names")
|
||||
if name in seen:
|
||||
raise MotionProtocolError(f"{label} includes duplicate {name}")
|
||||
if name not in allowed:
|
||||
raise MotionProtocolError(f"{label} references missing name {name}")
|
||||
seen.add(name)
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _link_names_for_group(
|
||||
group: dict[str, Any],
|
||||
*,
|
||||
robot: dict[str, Any],
|
||||
groups_by_name: dict[str, dict[str, Any]],
|
||||
visited: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
links = set(str(link) for link in group.get("linkNames", []) if str(link))
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return links
|
||||
|
||||
def add_joint_links(joint_name: str) -> None:
|
||||
joint = joints.get(joint_name)
|
||||
if not isinstance(joint, dict):
|
||||
return
|
||||
child = str(joint.get("child") or "").strip()
|
||||
if child:
|
||||
links.add(child)
|
||||
|
||||
for joint_name in group.get("jointNames", []):
|
||||
add_joint_links(str(joint_name))
|
||||
for chain in group.get("chains", []):
|
||||
if not isinstance(chain, dict):
|
||||
continue
|
||||
base_link = str(chain.get("baseLink") or "").strip()
|
||||
tip_link = str(chain.get("tipLink") or "").strip()
|
||||
if tip_link:
|
||||
links.add(tip_link)
|
||||
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
|
||||
add_joint_links(joint_name)
|
||||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
group_name = str(group.get("name") or "").strip()
|
||||
if group_name:
|
||||
visited.add(group_name)
|
||||
for subgroup_name in group.get("subgroups", []):
|
||||
subgroup_key = str(subgroup_name or "").strip()
|
||||
if not subgroup_key or subgroup_key in visited:
|
||||
continue
|
||||
subgroup = groups_by_name.get(subgroup_key)
|
||||
if subgroup is not None:
|
||||
links.update(_link_names_for_group(subgroup, robot=robot, groups_by_name=groups_by_name, visited=visited))
|
||||
links.discard("")
|
||||
return links
|
||||
|
||||
|
||||
def _joint_adjacent_to_any_link(robot: dict[str, Any], parent_link: str, child_links: set[str]) -> bool:
|
||||
joints = robot.get("joints")
|
||||
if not isinstance(joints, dict):
|
||||
return False
|
||||
for joint in joints.values():
|
||||
if not isinstance(joint, dict):
|
||||
continue
|
||||
parent = str(joint.get("parent") or "").strip()
|
||||
child = str(joint.get("child") or "").strip()
|
||||
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_end_effectors(
|
||||
value: Any,
|
||||
*,
|
||||
link_names: set[str],
|
||||
groups: dict[str, dict[str, Any]],
|
||||
robot: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MotionProtocolError("SRDF endEffectors must be an array")
|
||||
end_effectors: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
for raw_end_effector in value:
|
||||
if not _plain_object(raw_end_effector):
|
||||
raise MotionProtocolError("SRDF end effector must be an object")
|
||||
name = _string(raw_end_effector.get("name"), label="SRDF end effector name")
|
||||
if name in seen:
|
||||
raise MotionProtocolError(f"Duplicate SRDF end effector {name}")
|
||||
seen.add(name)
|
||||
parent_link = _string(raw_end_effector.get("parentLink"), label=f"SRDF end effector {name} parentLink")
|
||||
if parent_link not in link_names:
|
||||
raise MotionProtocolError(f"SRDF end effector {name} references missing parentLink {parent_link}")
|
||||
group_name = _string(raw_end_effector.get("group"), label=f"SRDF end effector {name} group")
|
||||
if group_name not in groups:
|
||||
raise MotionProtocolError(f"SRDF end effector {name} references missing group {group_name}")
|
||||
parent_group = str(raw_end_effector.get("parentGroup") or "").strip()
|
||||
if parent_group and parent_group not in groups:
|
||||
raise MotionProtocolError(f"SRDF end effector {name} references missing parentGroup {parent_group}")
|
||||
link = str(raw_end_effector.get("link") or _group_tip_link(groups[group_name]) or parent_link).strip()
|
||||
if link not in link_names:
|
||||
raise MotionProtocolError(f"SRDF end effector {name} references missing link {link}")
|
||||
end_effector_links = _link_names_for_group(groups[group_name], robot=robot, groups_by_name=groups)
|
||||
if parent_group:
|
||||
parent_group_links = _link_names_for_group(groups[parent_group], robot=robot, groups_by_name=groups)
|
||||
overlap = sorted(end_effector_links & parent_group_links)
|
||||
if overlap:
|
||||
raise MotionProtocolError(f"SRDF end effector {name} group shares link(s) with parentGroup: {overlap}")
|
||||
if parent_link not in parent_group_links:
|
||||
raise MotionProtocolError(
|
||||
f"SRDF end effector {name} parentLink {parent_link} is not in parentGroup {parent_group}"
|
||||
)
|
||||
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
|
||||
robot,
|
||||
parent_link,
|
||||
end_effector_links,
|
||||
):
|
||||
raise MotionProtocolError(f"SRDF end effector {name} parentLink is not adjacent to its group")
|
||||
end_effectors.append({
|
||||
"name": name,
|
||||
"link": link,
|
||||
"parentLink": parent_link,
|
||||
"group": group_name,
|
||||
"parentGroup": parent_group,
|
||||
})
|
||||
return end_effectors
|
||||
|
||||
|
||||
def _validate_group_state_joint_value(state_name: str, joint_name: str, value: float, joint: dict[str, Any]) -> None:
|
||||
joint_type = str(joint.get("type") or "").strip()
|
||||
if joint_type == "fixed":
|
||||
raise MotionProtocolError(f"SRDF group state {state_name} cannot set fixed joint {joint_name}")
|
||||
if bool(joint.get("mimic")):
|
||||
raise MotionProtocolError(f"SRDF group state {state_name} cannot set mimic joint {joint_name}")
|
||||
if joint_type == "continuous":
|
||||
return
|
||||
lower = joint.get("lower")
|
||||
upper = joint.get("upper")
|
||||
if isinstance(lower, float) and value < lower:
|
||||
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is below its URDF lower limit")
|
||||
if isinstance(upper, float) and value > upper:
|
||||
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is above its URDF upper limit")
|
||||
|
||||
|
||||
def _validate_group_states(value: Any, *, groups: dict[str, dict[str, Any]], robot: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MotionProtocolError("SRDF groupStates must be an array")
|
||||
states: list[dict[str, Any]] = []
|
||||
seen_keys: set[str] = set()
|
||||
joint_payload = robot.get("joints")
|
||||
if not isinstance(joint_payload, dict):
|
||||
raise MotionProtocolError("URDF robot inventory is invalid")
|
||||
for raw_state in value:
|
||||
if not _plain_object(raw_state):
|
||||
raise MotionProtocolError("SRDF group state must be an object")
|
||||
name = _string(raw_state.get("name"), label="SRDF group state name")
|
||||
group = _string(raw_state.get("group"), label=f"SRDF group state {name} group")
|
||||
state_key = f"{group}/{name}"
|
||||
if state_key in seen_keys:
|
||||
raise MotionProtocolError(f"Duplicate SRDF group state {state_key}")
|
||||
seen_keys.add(state_key)
|
||||
if group not in groups:
|
||||
raise MotionProtocolError(f"SRDF group state {name} references missing group {group}")
|
||||
group_joint_names = set(_joint_names_for_group(groups[group], robot=robot, groups_by_name=groups))
|
||||
joint_values = raw_state.get("jointValuesByName")
|
||||
legacy_joint_values = raw_state.get("jointValuesByNameRad")
|
||||
if joint_values is None and legacy_joint_values is not None:
|
||||
joint_values = legacy_joint_values
|
||||
if not _plain_object(joint_values):
|
||||
raise MotionProtocolError(f"SRDF group state {name} jointValuesByName must be an object")
|
||||
parsed_values: dict[str, float] = {}
|
||||
for raw_joint_name, raw_value in joint_values.items():
|
||||
joint_name = str(raw_joint_name or "").strip()
|
||||
joint = joint_payload.get(joint_name)
|
||||
if not isinstance(joint, dict):
|
||||
raise MotionProtocolError(f"SRDF group state {name} references missing joint {joint_name}")
|
||||
if joint_name not in group_joint_names:
|
||||
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} is not in group {group}")
|
||||
try:
|
||||
parsed_value = float(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be numeric") from exc
|
||||
if not isfinite(parsed_value):
|
||||
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be finite")
|
||||
_validate_group_state_joint_value(name, joint_name, parsed_value, joint)
|
||||
parsed_values[joint_name] = parsed_value
|
||||
states.append({"name": name, "group": group, "jointValuesByName": parsed_values, "jointValuesByNameRad": parsed_values})
|
||||
return states
|
||||
|
||||
|
||||
def _validate_disabled_pairs(value: Any, *, link_names: set[str]) -> list[dict[str, str]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MotionProtocolError("SRDF disabledCollisionPairs must be an array")
|
||||
pairs: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for raw_pair in value:
|
||||
if not _plain_object(raw_pair):
|
||||
raise MotionProtocolError("SRDF disabled collision pair must be an object")
|
||||
link1 = _string(raw_pair.get("link1"), label="SRDF disabled collision link1")
|
||||
link2 = _string(raw_pair.get("link2"), label="SRDF disabled collision link2")
|
||||
if link1 == link2:
|
||||
raise MotionProtocolError("SRDF disabled collision pair cannot repeat the same link")
|
||||
if link1 not in link_names or link2 not in link_names:
|
||||
raise MotionProtocolError("SRDF disabled collision pair references missing URDF link")
|
||||
pair_key = "/".join(sorted((link1, link2)))
|
||||
if pair_key in seen:
|
||||
raise MotionProtocolError(f"Duplicate SRDF disabled collision pair {pair_key}")
|
||||
seen.add(pair_key)
|
||||
reason = str(raw_pair.get("reason") or "").strip()
|
||||
if not reason:
|
||||
raise MotionProtocolError(f"SRDF disabled collision pair {pair_key} requires a reason")
|
||||
pairs.append({
|
||||
"link1": link1,
|
||||
"link2": link2,
|
||||
"reason": reason,
|
||||
"source": str(raw_pair.get("source") or "manual").strip(),
|
||||
})
|
||||
return pairs
|
||||
|
||||
|
||||
def _moveit_settings(payload: Any) -> dict[str, Any]:
|
||||
if not _plain_object(payload):
|
||||
return {}
|
||||
return _optional_object(payload.get("moveit2"), label="moveit2")
|
||||
|
||||
|
||||
def _target_payload(payload: Any) -> dict[str, Any]:
|
||||
if not _plain_object(payload):
|
||||
return {}
|
||||
return _optional_object(payload.get("target"), label="target")
|
||||
|
||||
|
||||
def _find_group(metadata: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
for group in metadata.get("planningGroups", []):
|
||||
if str(group.get("name") or "") == name:
|
||||
return group
|
||||
raise MotionProtocolError(f"Selected planning group {name} is not defined by the SRDF")
|
||||
|
||||
|
||||
def _find_end_effector(metadata: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
for end_effector in metadata.get("endEffectors", []):
|
||||
if str(end_effector.get("name") or "") == name:
|
||||
return end_effector
|
||||
raise MotionProtocolError(f"Selected end effector {name} is not defined by the SRDF")
|
||||
|
||||
|
||||
def _default_end_effector(metadata: dict[str, Any], planning_group_name: str) -> dict[str, Any] | None:
|
||||
end_effectors = list(metadata.get("endEffectors", []))
|
||||
for end_effector in end_effectors:
|
||||
if str(end_effector.get("parentGroup") or "") == planning_group_name:
|
||||
return end_effector
|
||||
return end_effectors[0] if end_effectors else None
|
||||
|
||||
|
||||
def _build_command(
|
||||
*,
|
||||
command_name: str,
|
||||
payload: Any,
|
||||
metadata: dict[str, Any],
|
||||
robot: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
moveit2 = _moveit_settings(payload)
|
||||
target = _target_payload(payload)
|
||||
default_group = metadata["planningGroups"][0]
|
||||
planning_group_name = _string(
|
||||
moveit2.get("planningGroup"),
|
||||
label="moveit2.planningGroup",
|
||||
default=str(default_group.get("name") or ""),
|
||||
)
|
||||
planning_group = _find_group(metadata, planning_group_name)
|
||||
|
||||
default_end_effector = _default_end_effector(metadata, planning_group_name)
|
||||
selected_end_effector_name = str(moveit2.get("endEffector") or target.get("endEffector") or "").strip()
|
||||
selected_end_effector = _find_end_effector(metadata, selected_end_effector_name) if selected_end_effector_name else default_end_effector
|
||||
if selected_end_effector is None:
|
||||
raise MotionProtocolError("SRDF must define an end effector for MoveIt2 pose requests")
|
||||
|
||||
robot_links = robot.get("links")
|
||||
if not isinstance(robot_links, set):
|
||||
raise MotionProtocolError("URDF robot inventory is invalid")
|
||||
target_frame = _string(
|
||||
moveit2.get("targetFrame") or target.get("frame"),
|
||||
label="moveit2.targetFrame",
|
||||
default=next(iter(sorted(robot_links)), ""),
|
||||
)
|
||||
if target_frame not in robot_links:
|
||||
raise MotionProtocolError(f"moveit2.targetFrame references missing URDF link {target_frame}")
|
||||
|
||||
ik = _optional_object(moveit2.get("ik"), label="moveit2.ik")
|
||||
planning = _optional_object(moveit2.get("planning"), label="moveit2.planning")
|
||||
target_has_orientation = "quat_xyzw" in target or "rpy" in target
|
||||
position_only_ik = _boolean(ik.get("positionOnly"), default=not target_has_orientation)
|
||||
if not target_has_orientation and not position_only_ik:
|
||||
raise MotionProtocolError("target orientation is required when moveit2.ik.positionOnly is false")
|
||||
groups_by_name = {
|
||||
str(group.get("name") or ""): group
|
||||
for group in metadata.get("planningGroups", [])
|
||||
if isinstance(group, dict) and str(group.get("name") or "")
|
||||
}
|
||||
joint_names = _joint_names_for_group(planning_group, robot=robot, groups_by_name=groups_by_name)
|
||||
target_link = _string(
|
||||
moveit2.get("targetLink") or target.get("targetLink") or target.get("link"),
|
||||
label="moveit2.targetLink",
|
||||
default=str(selected_end_effector.get("link") or ""),
|
||||
)
|
||||
if target_link not in robot_links:
|
||||
raise MotionProtocolError(f"moveit2.targetLink references missing URDF link {target_link}")
|
||||
command = {
|
||||
"planningGroup": planning_group_name,
|
||||
"jointNames": joint_names,
|
||||
"endEffectors": [
|
||||
{
|
||||
"name": selected_end_effector["name"],
|
||||
"link": target_link,
|
||||
"inferredLink": selected_end_effector["link"],
|
||||
"frame": target_frame,
|
||||
"planningGroup": planning_group_name,
|
||||
"jointNames": joint_names,
|
||||
"positionTolerance": _positive_float(
|
||||
ik.get("tolerance", moveit2.get("positionTolerance")),
|
||||
label="moveit2.ik.tolerance",
|
||||
default=DEFAULT_POSITION_TOLERANCE,
|
||||
),
|
||||
}
|
||||
],
|
||||
"ik": {
|
||||
"timeout": _positive_float(ik.get("timeout"), label="moveit2.ik.timeout", default=DEFAULT_IK_TIMEOUT),
|
||||
"attempts": _positive_int(ik.get("attempts"), label="moveit2.ik.attempts", default=DEFAULT_IK_ATTEMPTS),
|
||||
"positionOnly": position_only_ik,
|
||||
"tolerance": _positive_float(
|
||||
ik.get("tolerance", moveit2.get("positionTolerance")),
|
||||
label="moveit2.ik.tolerance",
|
||||
default=DEFAULT_POSITION_TOLERANCE,
|
||||
),
|
||||
},
|
||||
"planner": {
|
||||
"pipeline": _string(planning.get("pipeline"), label="moveit2.planning.pipeline", default=DEFAULT_PLANNING_PIPELINE),
|
||||
"plannerId": _string(planning.get("plannerId"), label="moveit2.planning.plannerId", default=DEFAULT_PLANNER_ID),
|
||||
"planningTime": _positive_float(planning.get("planningTime"), label="moveit2.planning.planningTime", default=DEFAULT_PLANNING_TIME),
|
||||
"maxVelocityScalingFactor": _scaling(
|
||||
planning.get("maxVelocityScalingFactor"),
|
||||
label="moveit2.planning.maxVelocityScalingFactor",
|
||||
default=1.0,
|
||||
),
|
||||
"maxAccelerationScalingFactor": _scaling(
|
||||
planning.get("maxAccelerationScalingFactor"),
|
||||
label="moveit2.planning.maxAccelerationScalingFactor",
|
||||
default=1.0,
|
||||
),
|
||||
},
|
||||
}
|
||||
if command_name not in SUPPORTED_REQUEST_TYPES:
|
||||
raise MotionProtocolError(f"Unsupported request type {command_name}")
|
||||
return command
|
||||
|
||||
|
||||
def build_moveit2_context(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
dir: Any = DEFAULT_CAD_DIRECTORY,
|
||||
file: Any,
|
||||
type: Any,
|
||||
payload: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
command_name = str(type or "").strip()
|
||||
if command_name not in SUPPORTED_REQUEST_TYPES:
|
||||
raise MotionProtocolError(f"Unsupported request type {command_name or '(missing)'}")
|
||||
resolved_repo_root = Path(repo_root).resolve()
|
||||
cad_dir = normalize_cad_directory(dir)
|
||||
file_ref = normalize_file_ref(file)
|
||||
cad_root = (resolved_repo_root / cad_dir).resolve()
|
||||
if not _path_is_inside(cad_root, resolved_repo_root):
|
||||
raise MotionProtocolError(f"dir must stay inside the repository: {cad_dir}")
|
||||
file_ref = _file_ref_relative_to_cad_dir(file_ref, cad_dir=cad_dir, cad_root=cad_root)
|
||||
srdf_path = (cad_root / file_ref).resolve()
|
||||
if not _path_is_inside(srdf_path, cad_root):
|
||||
root_label = cad_dir or "the repository"
|
||||
raise MotionProtocolError(f"file must stay inside {root_label}: {file_ref}")
|
||||
if not srdf_path.is_file():
|
||||
raise MotionProtocolError(f"SRDF file does not exist: {file_ref}")
|
||||
|
||||
try:
|
||||
srdf_source = read_srdf_source(srdf_path)
|
||||
except Exception as exc:
|
||||
raise MotionProtocolError(str(exc)) from exc
|
||||
urdf_path = _resolve_srdf_urdf_path(srdf_source, srdf_path=srdf_path, repo_root=resolved_repo_root)
|
||||
robot = _urdf_robot(urdf_path)
|
||||
inventory_payload = _srdf_inventory_from_source(srdf_source, srdf_path=srdf_path, urdf_path=urdf_path)
|
||||
srdf_inventory = _validate_srdf_inventory(inventory_payload, robot=robot)
|
||||
command = _build_command(command_name=command_name, payload=payload, metadata=srdf_inventory, robot=robot)
|
||||
version_paths = [urdf_path, srdf_path]
|
||||
return {
|
||||
"repoRoot": str(resolved_repo_root),
|
||||
"dir": cad_dir,
|
||||
"file": file_ref,
|
||||
"urdfPath": str(urdf_path),
|
||||
"srdfPath": str(srdf_path),
|
||||
"modelAssetHash": _combined_version(version_paths),
|
||||
"commandName": command_name,
|
||||
"command": command,
|
||||
"srdf": srdf_inventory,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from moveit2_server.protocol import MotionProtocolError, MotionRequest
|
||||
from moveit2_server.moveit_py import MoveItPyAdapter
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _adapter() -> MoveItPyAdapter:
|
||||
return MoveItPyAdapter()
|
||||
|
||||
|
||||
def dispatch(request: MotionRequest) -> dict[str, Any]:
|
||||
adapter = _adapter()
|
||||
if request.type == "srdf.solvePose":
|
||||
result = adapter.solve_pose(request)
|
||||
elif request.type == "srdf.planToPose":
|
||||
result = adapter.plan_to_pose(request)
|
||||
else:
|
||||
raise MotionProtocolError(f"Unsupported request type {request.type}")
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise MotionProtocolError("MoveIt2 adapter result must be an object")
|
||||
if result.get("ok") is False:
|
||||
raise RuntimeError(str(result.get("message") or "MoveIt2 request failed"))
|
||||
result.pop("ok", None)
|
||||
return result
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _json_list(value: str, label: str) -> list[Any]:
|
||||
payload = json.loads(value)
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError(f"{label} must be a JSON array")
|
||||
return payload
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="python -m moveit2_server.joint_state_seed")
|
||||
parser.add_argument("--names", required=True, help="JSON array of joint names")
|
||||
parser.add_argument("--positions", required=True, help="JSON array of joint positions in ROS units")
|
||||
parser.add_argument("--duration", type=float, default=8.0)
|
||||
parser.add_argument("--rate", type=float, default=30.0)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
names = [str(name) for name in _json_list(args.names, "names")]
|
||||
positions = [float(position) for position in _json_list(args.positions, "positions")]
|
||||
if len(names) != len(positions):
|
||||
raise ValueError("names and positions must have the same length")
|
||||
if args.duration <= 0 or args.rate <= 0:
|
||||
raise ValueError("duration and rate must be positive")
|
||||
|
||||
import rclpy
|
||||
from sensor_msgs.msg import JointState
|
||||
|
||||
rclpy.init(args=None)
|
||||
node = rclpy.create_node("moveit2_server_joint_state_seed")
|
||||
publisher = node.create_publisher(JointState, "/joint_states", 10)
|
||||
deadline = time.monotonic() + args.duration
|
||||
period = 1.0 / args.rate
|
||||
try:
|
||||
while time.monotonic() < deadline and rclpy.ok():
|
||||
message = JointState()
|
||||
message.header.stamp = node.get_clock().now().to_msg()
|
||||
message.name = names
|
||||
message.position = positions
|
||||
publisher.publish(message)
|
||||
rclpy.spin_once(node, timeout_sec=0.0)
|
||||
time.sleep(period)
|
||||
finally:
|
||||
try:
|
||||
node.destroy_node()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
rclpy.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,589 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from moveit2_server.protocol import (
|
||||
MotionProtocolError,
|
||||
normalize_joint_values,
|
||||
normalize_motion_target,
|
||||
)
|
||||
|
||||
|
||||
def _deg_to_rad(value: float) -> float:
|
||||
return (float(value) * math.pi) / 180.0
|
||||
|
||||
|
||||
def _rad_to_deg(value: float) -> float:
|
||||
return (float(value) * 180.0) / math.pi
|
||||
|
||||
|
||||
def _solve_command(request: Any) -> dict[str, Any]:
|
||||
return request.command
|
||||
|
||||
|
||||
def _target_end_effector_name(request: Any) -> str:
|
||||
target = request.payload.get("target") if isinstance(request.payload, dict) else None
|
||||
if not isinstance(target, dict):
|
||||
return ""
|
||||
return str(target.get("endEffector") or "").strip()
|
||||
|
||||
|
||||
def _selected_end_effector(request: Any) -> dict[str, Any]:
|
||||
command = _solve_command(request)
|
||||
end_effectors = command.get("endEffectors", [])
|
||||
target_name = _target_end_effector_name(request)
|
||||
if target_name:
|
||||
for end_effector in end_effectors:
|
||||
if str(end_effector.get("name", "")).strip() == target_name:
|
||||
return end_effector
|
||||
raise MotionProtocolError(f"Unknown end effector {target_name}")
|
||||
for end_effector in end_effectors:
|
||||
return end_effector
|
||||
raise MotionProtocolError("moveit_py command endEffectors must be non-empty")
|
||||
|
||||
|
||||
def _joint_names(request: Any) -> list[str]:
|
||||
command = _solve_command(request)
|
||||
end_effector = _selected_end_effector(request)
|
||||
joint_names = [
|
||||
str(name).strip()
|
||||
for name in end_effector.get("jointNames", command.get("jointNames", []))
|
||||
if str(name).strip()
|
||||
]
|
||||
if not joint_names:
|
||||
raise MotionProtocolError("moveit_py command jointNames must be non-empty")
|
||||
return joint_names
|
||||
|
||||
|
||||
def _end_effector(request: Any, name: str) -> dict[str, Any]:
|
||||
end_effector = _selected_end_effector(request)
|
||||
if str(end_effector.get("name", "")).strip() == name:
|
||||
return end_effector
|
||||
raise MotionProtocolError(f"Unknown end effector {name}")
|
||||
|
||||
|
||||
def _planning_group(request: Any) -> str:
|
||||
command = _solve_command(request)
|
||||
end_effector = _selected_end_effector(request)
|
||||
planning_group = str(
|
||||
end_effector.get("planningGroup")
|
||||
or command.get("planningGroup")
|
||||
or request.command.get("planningGroup")
|
||||
or ""
|
||||
).strip()
|
||||
if not planning_group:
|
||||
raise MotionProtocolError("moveit_py command planningGroup must be non-empty")
|
||||
return planning_group
|
||||
|
||||
|
||||
def _robot_description_for_moveit(urdf_path: Path) -> str:
|
||||
"""Return URDF XML with relative mesh paths rewritten as file URIs."""
|
||||
try:
|
||||
tree = ET.parse(urdf_path)
|
||||
except ET.ParseError:
|
||||
return urdf_path.read_text(encoding="utf-8")
|
||||
root = tree.getroot()
|
||||
changed = False
|
||||
for mesh in root.findall(".//mesh"):
|
||||
filename = str(mesh.get("filename") or "").strip()
|
||||
if not filename or "://" in filename or filename.startswith("package:"):
|
||||
continue
|
||||
path = Path(filename)
|
||||
if not path.is_absolute():
|
||||
path = urdf_path.parent / path
|
||||
mesh.set("filename", path.resolve().as_uri())
|
||||
changed = True
|
||||
if not changed:
|
||||
return urdf_path.read_text(encoding="utf-8")
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _active_urdf_joints(urdf_path: Path) -> list[tuple[str, str]]:
|
||||
try:
|
||||
root = ET.parse(urdf_path).getroot()
|
||||
except (OSError, ET.ParseError):
|
||||
return []
|
||||
joints: list[tuple[str, str]] = []
|
||||
for joint in root.findall("joint"):
|
||||
name = str(joint.get("name") or "").strip()
|
||||
joint_type = str(joint.get("type") or "").strip()
|
||||
if name and joint_type != "fixed":
|
||||
joints.append((name, joint_type))
|
||||
return joints
|
||||
|
||||
|
||||
def _urdf_joint_types(urdf_path: Path) -> dict[str, str]:
|
||||
try:
|
||||
root = ET.parse(urdf_path).getroot()
|
||||
except (OSError, ET.ParseError):
|
||||
return {}
|
||||
joint_types: dict[str, str] = {}
|
||||
for joint in root.findall("joint"):
|
||||
name = str(joint.get("name") or "").strip()
|
||||
joint_type = str(joint.get("type") or "").strip()
|
||||
if name:
|
||||
joint_types[name] = joint_type
|
||||
return joint_types
|
||||
|
||||
|
||||
def _joint_types_for_request(request: Any) -> dict[str, str]:
|
||||
urdf_path = Path(str(request.context.get("urdfPath") or ""))
|
||||
joint_types = _urdf_joint_types(urdf_path)
|
||||
if joint_types:
|
||||
return joint_types
|
||||
command = _solve_command(request)
|
||||
fallback_names = [str(name).strip() for name in command.get("jointNames", []) if str(name).strip()]
|
||||
if not fallback_names:
|
||||
fallback_names = _joint_names(request)
|
||||
return {name: "revolute" for name in fallback_names}
|
||||
|
||||
|
||||
def _native_start_joint_values(request: Any, joint_types: dict[str, str] | None = None) -> dict[str, float]:
|
||||
joint_types = joint_types or _joint_types_for_request(request)
|
||||
payload = request.payload if isinstance(request.payload, dict) else {}
|
||||
if "startJointValuesByName" in payload:
|
||||
return normalize_joint_values(payload.get("startJointValuesByName"), label="startJointValuesByName")
|
||||
legacy_values = normalize_joint_values(payload.get("startJointValuesByNameDeg"), label="startJointValuesByNameDeg")
|
||||
return {
|
||||
name: float(value) if str(joint_types.get(name) or "").strip() == "prismatic" else _deg_to_rad(value)
|
||||
for name, value in legacy_values.items()
|
||||
}
|
||||
|
||||
|
||||
def _native_to_legacy_joint_value(value: float, joint_type: str) -> float:
|
||||
return float(value) if str(joint_type or "").strip() == "prismatic" else _rad_to_deg(value)
|
||||
|
||||
|
||||
def _native_to_legacy_joint_values(values: dict[str, float], joint_types: dict[str, str]) -> dict[str, float]:
|
||||
return {
|
||||
name: _native_to_legacy_joint_value(value, str(joint_types.get(name) or "revolute"))
|
||||
for name, value in values.items()
|
||||
}
|
||||
|
||||
|
||||
def _moveit2_groups(request: Any) -> list[dict[str, Any]]:
|
||||
srdf = request.context.get("srdf", {}) if isinstance(request.context, dict) else {}
|
||||
groups = srdf.get("planningGroups", []) if isinstance(srdf, dict) else []
|
||||
return [group for group in groups if isinstance(group, dict)]
|
||||
|
||||
|
||||
def _supports_kdl_kinematics(group: dict[str, Any]) -> bool:
|
||||
if group.get("subgroups"):
|
||||
return False
|
||||
return bool(group.get("jointNames") or group.get("chains"))
|
||||
|
||||
|
||||
def _kinematics_config(request: Any) -> dict[str, Any]:
|
||||
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
|
||||
return {
|
||||
str(group.get("name")): {
|
||||
"kinematics_solver": "kdl_kinematics_plugin/KDLKinematicsPlugin",
|
||||
"kinematics_solver_search_resolution": 0.005,
|
||||
"kinematics_solver_timeout": float(ik.get("timeout") or 0.05),
|
||||
"position_only_ik": bool(ik.get("positionOnly", True)),
|
||||
}
|
||||
for group in _moveit2_groups(request)
|
||||
if str(group.get("name") or "").strip() and _supports_kdl_kinematics(group)
|
||||
}
|
||||
|
||||
|
||||
def _planning_pipeline_config(request: Any) -> dict[str, Any]:
|
||||
planner = request.command.get("planner", {}) if isinstance(request.command, dict) else {}
|
||||
pipeline = str(planner.get("pipeline") or "ompl")
|
||||
planner_id = str(planner.get("plannerId") or "RRTConnectkConfigDefault")
|
||||
planning_time = float(planner.get("planningTime") or 1.0)
|
||||
def request_params() -> dict[str, Any]:
|
||||
return {
|
||||
"planning_attempts": int(request.command.get("ik", {}).get("attempts") or 1),
|
||||
"planning_pipeline": pipeline,
|
||||
"planner_id": planner_id,
|
||||
"planning_time": planning_time,
|
||||
"max_velocity_scaling_factor": float(planner.get("maxVelocityScalingFactor") or 1.0),
|
||||
"max_acceleration_scaling_factor": float(planner.get("maxAccelerationScalingFactor") or 1.0),
|
||||
}
|
||||
|
||||
return {
|
||||
"planning_pipelines": {
|
||||
"pipeline_names": [pipeline],
|
||||
},
|
||||
pipeline: {
|
||||
"planning_plugin": "ompl_interface/OMPLPlanner",
|
||||
"planning_plugins": ["ompl_interface/OMPLPlanner"],
|
||||
"request_adapters": [
|
||||
"default_planning_request_adapters/ResolveConstraintFrames",
|
||||
"default_planning_request_adapters/ValidateWorkspaceBounds",
|
||||
"default_planning_request_adapters/CheckStartStateBounds",
|
||||
"default_planning_request_adapters/CheckStartStateCollision",
|
||||
],
|
||||
"start_state_max_bounds_error": 0.1,
|
||||
},
|
||||
"plan_request_params": request_params(),
|
||||
"ompl_rrtc": {
|
||||
"plan_request_params": request_params(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _joint_state_seed(request: Any) -> tuple[list[str], list[float]]:
|
||||
urdf_path = Path(str(request.context.get("urdfPath") or ""))
|
||||
joint_types = _joint_types_for_request(request)
|
||||
joint_values = _native_start_joint_values(request, joint_types)
|
||||
joints = _active_urdf_joints(urdf_path)
|
||||
if not joints:
|
||||
joints = [(name, "revolute") for name in _joint_names(request)]
|
||||
names: list[str] = []
|
||||
positions: list[float] = []
|
||||
for name, joint_type in joints:
|
||||
names.append(name)
|
||||
del joint_type
|
||||
positions.append(float(joint_values.get(name, 0.0)))
|
||||
return names, positions
|
||||
|
||||
|
||||
class _JointStateSeeder:
|
||||
def __init__(self, request: Any, *, duration_sec: float = 8.0, rate_hz: float = 30.0) -> None:
|
||||
self._request = request
|
||||
self._duration_sec = duration_sec
|
||||
self._rate_hz = rate_hz
|
||||
self._process: subprocess.Popen[bytes] | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
names, positions = _joint_state_seed(self._request)
|
||||
if not names:
|
||||
return
|
||||
self._process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"moveit2_server.joint_state_seed",
|
||||
"--names",
|
||||
json.dumps(names),
|
||||
"--positions",
|
||||
json.dumps(positions),
|
||||
"--duration",
|
||||
str(self._duration_sec),
|
||||
"--rate",
|
||||
str(self._rate_hz),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._process or self._process.poll() is not None:
|
||||
return
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=1.0)
|
||||
|
||||
|
||||
class MoveItPyAdapter:
|
||||
"""MoveIt 2 adapter used by the local ROS 2 MoveIt2 server."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._moveit_by_key: dict[str, Any] = {}
|
||||
|
||||
def _config_dict(self, request: Any) -> dict[str, Any]:
|
||||
urdf_path = Path(str(request.context.get("urdfPath") or ""))
|
||||
srdf_path = Path(str(request.context.get("srdfPath") or ""))
|
||||
if not urdf_path.is_file():
|
||||
raise RuntimeError(f"URDF file does not exist: {urdf_path}")
|
||||
config: dict[str, Any] = {
|
||||
"robot_description": _robot_description_for_moveit(urdf_path),
|
||||
"robot_description_kinematics": _kinematics_config(request),
|
||||
}
|
||||
if srdf_path.is_file():
|
||||
config["robot_description_semantic"] = srdf_path.read_text(encoding="utf-8")
|
||||
config.update(_planning_pipeline_config(request))
|
||||
config["planning_scene_monitor_options"] = {
|
||||
"name": "planning_scene_monitor",
|
||||
"robot_description": "robot_description",
|
||||
"joint_state_topic": "/joint_states",
|
||||
"attached_collision_object_topic": "/moveit_cpp/planning_scene_monitor",
|
||||
"publish_planning_scene_topic": "/moveit_cpp/publish_planning_scene",
|
||||
"monitored_planning_scene_topic": "/moveit_cpp/monitored_planning_scene",
|
||||
"wait_for_initial_state_timeout": 5.0,
|
||||
}
|
||||
ompl_config = config.setdefault("ompl", {})
|
||||
if isinstance(ompl_config, dict):
|
||||
ompl_config.setdefault("planning_plugin", "ompl_interface/OMPLPlanner")
|
||||
ompl_config.setdefault("planning_plugins", ["ompl_interface/OMPLPlanner"])
|
||||
ompl_config.setdefault("request_adapters", [
|
||||
"default_planning_request_adapters/ResolveConstraintFrames",
|
||||
"default_planning_request_adapters/ValidateWorkspaceBounds",
|
||||
"default_planning_request_adapters/CheckStartStateBounds",
|
||||
"default_planning_request_adapters/CheckStartStateCollision",
|
||||
])
|
||||
ompl_config.setdefault("start_state_max_bounds_error", 0.1)
|
||||
planning_scene_monitor_options = config.setdefault("planning_scene_monitor_options", {})
|
||||
if isinstance(planning_scene_monitor_options, dict):
|
||||
current_timeout = float(planning_scene_monitor_options.get("wait_for_initial_state_timeout") or 0.0)
|
||||
planning_scene_monitor_options["wait_for_initial_state_timeout"] = max(current_timeout, 5.0)
|
||||
return config
|
||||
|
||||
def _moveit(self, request: Any) -> Any:
|
||||
try:
|
||||
import rclpy
|
||||
from moveit.planning import MoveItPy
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"moveit_py adapter requires a ROS 2 environment with rclpy and moveit_py. "
|
||||
f"Current interpreter: {sys.executable}. Start moveit2_server from a RoboStack/ROS shell."
|
||||
) from exc
|
||||
|
||||
cache_key = str(request.context.get("modelAssetHash") or request.context.get("urdfPath") or "")
|
||||
if cache_key in self._moveit_by_key:
|
||||
return self._moveit_by_key[cache_key]
|
||||
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=None)
|
||||
node_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:10] if cache_key else "default"
|
||||
config = self._config_dict(request)
|
||||
joint_state_seeder = _JointStateSeeder(request)
|
||||
joint_state_seeder.start()
|
||||
try:
|
||||
try:
|
||||
moveit = MoveItPy(
|
||||
node_name=f"moveit2_server_{node_hash}",
|
||||
config_dict=config,
|
||||
)
|
||||
except TypeError:
|
||||
# Older bindings do not expose config_dict; they require equivalent
|
||||
# ROS parameters to be supplied by the launch environment.
|
||||
moveit = MoveItPy(node_name=f"moveit2_server_{node_hash}")
|
||||
finally:
|
||||
joint_state_seeder.stop()
|
||||
self._moveit_by_key[cache_key] = moveit
|
||||
return moveit
|
||||
|
||||
def _planning_component(self, request: Any, planning_group: str) -> Any:
|
||||
return self._moveit(request).get_planning_component(planning_group)
|
||||
|
||||
def _robot_state(self, request: Any) -> Any:
|
||||
try:
|
||||
from moveit.core.robot_state import RobotState
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
f"moveit_py adapter could not import RobotState for {sys.executable}. "
|
||||
"Start moveit2_server from a RoboStack/ROS shell."
|
||||
) from exc
|
||||
|
||||
robot_state = RobotState(self._moveit(request).get_robot_model())
|
||||
joint_values = _native_start_joint_values(request)
|
||||
joint_names = _joint_names(request)
|
||||
positions = [float(joint_values.get(name, 0.0)) for name in joint_names]
|
||||
planning_group = _planning_group(request)
|
||||
if hasattr(robot_state, "set_joint_group_positions"):
|
||||
robot_state.set_joint_group_positions(planning_group, positions)
|
||||
elif hasattr(robot_state, "set_joint_positions"):
|
||||
robot_state.set_joint_positions(joint_names, positions)
|
||||
else:
|
||||
raise RuntimeError("moveit_py RobotState does not expose a supported joint setter")
|
||||
return robot_state
|
||||
|
||||
def _pose_stamped(self, request: Any) -> tuple[Any, dict[str, Any]]:
|
||||
try:
|
||||
from geometry_msgs.msg import PoseStamped
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
f"moveit_py adapter could not import geometry_msgs.msg.PoseStamped for {sys.executable}. "
|
||||
"Start moveit2_server from a RoboStack/ROS shell."
|
||||
) from exc
|
||||
|
||||
target = normalize_motion_target(request.payload)
|
||||
end_effector = _end_effector(request, target["endEffector"])
|
||||
pose = PoseStamped()
|
||||
pose.header.frame_id = target["frame"]
|
||||
pose.pose.position.x = target["xyz"][0]
|
||||
pose.pose.position.y = target["xyz"][1]
|
||||
pose.pose.position.z = target["xyz"][2]
|
||||
quat_xyzw = target.get("quat_xyzw") or (0.0, 0.0, 0.0, 1.0)
|
||||
pose.pose.orientation.x = quat_xyzw[0]
|
||||
pose.pose.orientation.y = quat_xyzw[1]
|
||||
pose.pose.orientation.z = quat_xyzw[2]
|
||||
pose.pose.orientation.w = quat_xyzw[3]
|
||||
return pose, end_effector
|
||||
|
||||
def _position_only_ik(self, request: Any) -> bool:
|
||||
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
|
||||
return bool(ik.get("positionOnly", True))
|
||||
|
||||
def _position_goal_constraints(self, target_pose: Any, end_effector: dict[str, Any]) -> list[Any]:
|
||||
try:
|
||||
from geometry_msgs.msg import Pose
|
||||
from moveit_msgs.msg import Constraints, PositionConstraint
|
||||
from shape_msgs.msg import SolidPrimitive
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
f"moveit_py adapter could not import MoveIt position constraint messages for {sys.executable}. "
|
||||
"Start moveit2_server from a RoboStack/ROS shell."
|
||||
) from exc
|
||||
|
||||
sphere = SolidPrimitive()
|
||||
sphere.type = SolidPrimitive.SPHERE
|
||||
sphere.dimensions = [float(end_effector.get("positionTolerance") or 0.002)]
|
||||
|
||||
center = Pose()
|
||||
center.orientation.w = 1.0
|
||||
center.position.x = target_pose.pose.position.x
|
||||
center.position.y = target_pose.pose.position.y
|
||||
center.position.z = target_pose.pose.position.z
|
||||
|
||||
position_constraint = PositionConstraint()
|
||||
position_constraint.header.frame_id = target_pose.header.frame_id
|
||||
position_constraint.link_name = str(end_effector.get("link") or "")
|
||||
position_constraint.constraint_region.primitives.append(sphere)
|
||||
position_constraint.constraint_region.primitive_poses.append(center)
|
||||
position_constraint.weight = 1.0
|
||||
|
||||
constraints = Constraints()
|
||||
constraints.name = f"{position_constraint.link_name}_position_goal"
|
||||
constraints.position_constraints.append(position_constraint)
|
||||
return [constraints]
|
||||
|
||||
def _joint_values_from_state(self, robot_state: Any, request: Any) -> dict[str, float]:
|
||||
joint_names = _joint_names(request)
|
||||
planning_group = _planning_group(request)
|
||||
if hasattr(robot_state, "get_joint_group_positions"):
|
||||
values = robot_state.get_joint_group_positions(planning_group)
|
||||
elif hasattr(robot_state, "get_joint_positions"):
|
||||
values = robot_state.get_joint_positions(joint_names)
|
||||
else:
|
||||
raise RuntimeError("moveit_py RobotState does not expose a supported joint getter")
|
||||
return {
|
||||
joint_name: float(values[index])
|
||||
for index, joint_name in enumerate(joint_names)
|
||||
if index < len(values)
|
||||
}
|
||||
|
||||
def solve_pose(self, request: Any) -> dict[str, Any]:
|
||||
robot_state = self._robot_state(request)
|
||||
target_pose, end_effector = self._pose_stamped(request)
|
||||
planning_group = _planning_group(request)
|
||||
|
||||
if not hasattr(robot_state, "set_from_ik"):
|
||||
raise RuntimeError("moveit_py RobotState does not expose set_from_ik")
|
||||
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
|
||||
timeout = float(ik.get("timeout") or 0.05)
|
||||
attempts = int(ik.get("attempts") or 1)
|
||||
try:
|
||||
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout, attempts)
|
||||
except TypeError:
|
||||
try:
|
||||
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout)
|
||||
except TypeError:
|
||||
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"))
|
||||
if not ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MoveIt did not find a pose solution",
|
||||
}
|
||||
joint_values = self._joint_values_from_state(robot_state, request)
|
||||
joint_types = _joint_types_for_request(request)
|
||||
return {
|
||||
"jointValuesByName": joint_values,
|
||||
"jointValuesByNameDeg": _native_to_legacy_joint_values(joint_values, joint_types),
|
||||
"positionOnly": self._position_only_ik(request),
|
||||
"residual": {"position": 0.0},
|
||||
}
|
||||
|
||||
def _serialize_trajectory(self, trajectory: Any, joint_names: list[str], request: Any) -> dict[str, Any]:
|
||||
if hasattr(trajectory, "get_robot_trajectory_msg"):
|
||||
trajectory = trajectory.get_robot_trajectory_msg()
|
||||
joint_trajectory = getattr(trajectory, "joint_trajectory", trajectory)
|
||||
source_joint_names = list(getattr(joint_trajectory, "joint_names", []) or joint_names)
|
||||
joint_types = _joint_types_for_request(request)
|
||||
points = []
|
||||
for point in getattr(joint_trajectory, "points", []) or []:
|
||||
duration = getattr(point, "time_from_start", None)
|
||||
seconds = float(getattr(duration, "sec", 0)) + (float(getattr(duration, "nanosec", 0)) / 1_000_000_000)
|
||||
positions_by_name = {
|
||||
name: float(point.positions[index])
|
||||
for index, name in enumerate(source_joint_names)
|
||||
if index < len(getattr(point, "positions", []))
|
||||
}
|
||||
native_positions = [positions_by_name.get(name, 0.0) for name in joint_names]
|
||||
legacy_positions = [
|
||||
_native_to_legacy_joint_value(positions_by_name.get(name, 0.0), str(joint_types.get(name) or "revolute"))
|
||||
for name in joint_names
|
||||
]
|
||||
points.append({
|
||||
"timeFromStartSec": seconds,
|
||||
"positions": native_positions,
|
||||
"positionsDeg": legacy_positions,
|
||||
})
|
||||
if len(points) > 1 and all(point["timeFromStartSec"] <= 0.0 for point in points):
|
||||
total_joint_delta = 0.0
|
||||
for previous, current in zip(points, points[1:], strict=False):
|
||||
total_joint_delta += sum(
|
||||
abs(current["positionsDeg"][index] - previous["positionsDeg"][index])
|
||||
for index in range(min(len(previous["positionsDeg"]), len(current["positionsDeg"])))
|
||||
)
|
||||
total_duration = min(max(total_joint_delta / 240.0, 0.75), 4.0)
|
||||
step_duration = total_duration / float(len(points) - 1)
|
||||
for index, point in enumerate(points):
|
||||
point["timeFromStartSec"] = index * step_duration
|
||||
return {
|
||||
"jointNames": joint_names,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
def plan_to_pose(self, request: Any) -> dict[str, Any]:
|
||||
target_pose, end_effector = self._pose_stamped(request)
|
||||
planning_group = _planning_group(request)
|
||||
planning_component = self._planning_component(request, planning_group)
|
||||
start_state = self._robot_state(request)
|
||||
planning_component.set_start_state(robot_state=start_state)
|
||||
if self._position_only_ik(request):
|
||||
try:
|
||||
planning_component.set_goal_state(
|
||||
motion_plan_constraints=self._position_goal_constraints(target_pose, end_effector),
|
||||
)
|
||||
except TypeError:
|
||||
planning_component.set_goal_state(
|
||||
pose_stamped_msg=target_pose,
|
||||
pose_link=end_effector.get("link"),
|
||||
)
|
||||
else:
|
||||
planning_component.set_goal_state(
|
||||
pose_stamped_msg=target_pose,
|
||||
pose_link=end_effector.get("link"),
|
||||
)
|
||||
|
||||
plan_result = planning_component.plan()
|
||||
if not plan_result:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MoveIt planning did not find a trajectory",
|
||||
}
|
||||
joint_names = _joint_names(request)
|
||||
trajectory = self._serialize_trajectory(plan_result.trajectory, joint_names, request)
|
||||
final_joint_values: dict[str, float] = {}
|
||||
final_joint_values_legacy: dict[str, float] = {}
|
||||
if trajectory["points"]:
|
||||
final_joint_values = dict(zip(
|
||||
trajectory["jointNames"],
|
||||
trajectory["points"][-1]["positions"],
|
||||
strict=False,
|
||||
))
|
||||
final_joint_values_legacy = dict(zip(
|
||||
trajectory["jointNames"],
|
||||
trajectory["points"][-1]["positionsDeg"],
|
||||
strict=False,
|
||||
))
|
||||
return {
|
||||
"jointValuesByName": final_joint_values,
|
||||
"jointValuesByNameDeg": final_joint_values_legacy,
|
||||
"trajectory": trajectory,
|
||||
"positionOnly": self._position_only_ik(request),
|
||||
"residual": {"position": 0.0},
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
SUPPORTED_REQUEST_TYPES = {"srdf.solvePose", "srdf.planToPose"}
|
||||
SUPPORTED_PROTOCOL_VERSION = 1
|
||||
_ABSOLUTE_PATH_RE = re.compile(r"(?<![\w.])/(?:[^\s'\"<>:,;(){}[\]]+/)+[^\s'\"<>:,;(){}[\]]+")
|
||||
|
||||
|
||||
class MotionProtocolError(ValueError):
|
||||
"""Raised when a MoveIt2 server request is malformed."""
|
||||
|
||||
|
||||
def _plain_object(value: Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def _string(value: Any, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise MotionProtocolError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _number(value: Any, label: str) -> float:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError(f"{label} must be a finite number") from exc
|
||||
if numeric != numeric or numeric in (float("inf"), float("-inf")):
|
||||
raise MotionProtocolError(f"{label} must be a finite number")
|
||||
return numeric
|
||||
|
||||
|
||||
def normalize_xyz(value: Any, label: str = "target.xyz") -> tuple[float, float, float]:
|
||||
if not isinstance(value, list) or len(value) != 3:
|
||||
raise MotionProtocolError(f"{label} must be a 3-number array")
|
||||
return (
|
||||
_number(value[0], f"{label}[0]"),
|
||||
_number(value[1], f"{label}[1]"),
|
||||
_number(value[2], f"{label}[2]"),
|
||||
)
|
||||
|
||||
|
||||
def normalize_quat_xyzw(value: Any, label: str = "target.quat_xyzw") -> tuple[float, float, float, float]:
|
||||
if not isinstance(value, list) or len(value) != 4:
|
||||
raise MotionProtocolError(f"{label} must be a 4-number array")
|
||||
quat = (
|
||||
_number(value[0], f"{label}[0]"),
|
||||
_number(value[1], f"{label}[1]"),
|
||||
_number(value[2], f"{label}[2]"),
|
||||
_number(value[3], f"{label}[3]"),
|
||||
)
|
||||
norm = math.sqrt(sum(component * component for component in quat))
|
||||
if norm <= 1e-12:
|
||||
raise MotionProtocolError(f"{label} must not be zero length")
|
||||
return tuple(component / norm for component in quat)
|
||||
|
||||
|
||||
def normalize_rpy(value: Any, label: str = "target.rpy") -> tuple[float, float, float]:
|
||||
if not isinstance(value, list) or len(value) != 3:
|
||||
raise MotionProtocolError(f"{label} must be a 3-number array")
|
||||
return (
|
||||
_number(value[0], f"{label}[0]"),
|
||||
_number(value[1], f"{label}[1]"),
|
||||
_number(value[2], f"{label}[2]"),
|
||||
)
|
||||
|
||||
|
||||
def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
|
||||
cy = math.cos(yaw * 0.5)
|
||||
sy = math.sin(yaw * 0.5)
|
||||
cp = math.cos(pitch * 0.5)
|
||||
sp = math.sin(pitch * 0.5)
|
||||
cr = math.cos(roll * 0.5)
|
||||
sr = math.sin(roll * 0.5)
|
||||
return normalize_quat_xyzw([
|
||||
(sr * cp * cy) - (cr * sp * sy),
|
||||
(cr * sp * cy) + (sr * cp * sy),
|
||||
(cr * cp * sy) - (sr * sp * cy),
|
||||
(cr * cp * cy) + (sr * sp * sy),
|
||||
])
|
||||
|
||||
|
||||
def normalize_joint_values(value: Any, label: str = "startJointValuesByNameDeg") -> dict[str, float]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not _plain_object(value):
|
||||
raise MotionProtocolError(f"{label} must be an object")
|
||||
normalized: dict[str, float] = {}
|
||||
for raw_name, raw_value in value.items():
|
||||
name = str(raw_name or "").strip()
|
||||
if not name:
|
||||
raise MotionProtocolError(f"{label} cannot include empty joint names")
|
||||
normalized[name] = _number(raw_value, f"{label}.{name}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_protocol_version(value: Any) -> int:
|
||||
if value is None:
|
||||
return SUPPORTED_PROTOCOL_VERSION
|
||||
try:
|
||||
version = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MotionProtocolError("protocolVersion must be an integer") from exc
|
||||
if version != SUPPORTED_PROTOCOL_VERSION:
|
||||
raise MotionProtocolError(f"Unsupported protocolVersion {version}")
|
||||
return version
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WireMessage:
|
||||
id: str
|
||||
type: str
|
||||
protocol_version: int
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MotionRequest:
|
||||
id: str
|
||||
type: str
|
||||
protocol_version: int
|
||||
payload: dict[str, Any]
|
||||
context: dict[str, Any]
|
||||
command: dict[str, Any]
|
||||
|
||||
|
||||
def normalize_wire_message(message: Any) -> WireMessage:
|
||||
if not _plain_object(message):
|
||||
raise MotionProtocolError("MoveIt2 request must be an object")
|
||||
request_id = _string(message.get("id"), "id")
|
||||
request_type = _string(message.get("type"), "type")
|
||||
protocol_version = normalize_protocol_version(message.get("protocolVersion"))
|
||||
if request_type not in SUPPORTED_REQUEST_TYPES:
|
||||
raise MotionProtocolError(f"Unsupported request type {request_type}")
|
||||
payload = message.get("payload")
|
||||
if not _plain_object(payload):
|
||||
raise MotionProtocolError("payload must be an object")
|
||||
return WireMessage(
|
||||
id=request_id,
|
||||
type=request_type,
|
||||
protocol_version=protocol_version,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def normalize_request(message: Any, *, context: dict[str, Any]) -> MotionRequest:
|
||||
wire = normalize_wire_message(message)
|
||||
if not _plain_object(context):
|
||||
raise MotionProtocolError("request context must be an object")
|
||||
command = context.get("command")
|
||||
if not _plain_object(command):
|
||||
raise MotionProtocolError("request context command must be an object")
|
||||
return MotionRequest(
|
||||
id=wire.id,
|
||||
type=wire.type,
|
||||
protocol_version=wire.protocol_version,
|
||||
payload=wire.payload,
|
||||
context=context,
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
def normalize_motion_target(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
target = payload.get("target")
|
||||
if not _plain_object(target):
|
||||
raise MotionProtocolError("target must be an object")
|
||||
has_quat = "quat_xyzw" in target
|
||||
has_rpy = "rpy" in target
|
||||
if has_quat and has_rpy:
|
||||
raise MotionProtocolError("target must include exactly one of quat_xyzw or rpy")
|
||||
quat_xyzw: tuple[float, float, float, float] | None = None
|
||||
orientation_mode = "position_only"
|
||||
if has_quat:
|
||||
quat_xyzw = normalize_quat_xyzw(target.get("quat_xyzw"))
|
||||
orientation_mode = "quat_xyzw"
|
||||
elif has_rpy:
|
||||
quat_xyzw = _quat_xyzw_from_rpy(*normalize_rpy(target.get("rpy")))
|
||||
orientation_mode = "rpy"
|
||||
return {
|
||||
"endEffector": _string(target.get("endEffector"), "target.endEffector"),
|
||||
"frame": _string(target.get("frame"), "target.frame"),
|
||||
"xyz": normalize_xyz(target.get("xyz")),
|
||||
"quat_xyzw": quat_xyzw,
|
||||
"orientationMode": orientation_mode,
|
||||
}
|
||||
|
||||
|
||||
def success_response(request_id: str, result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_error_message(message: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
path = match.group(0)
|
||||
basename = path.rstrip("/").rsplit("/", 1)[-1]
|
||||
return f".../{basename}" if basename else "<path>"
|
||||
|
||||
return _ABSOLUTE_PATH_RE.sub(replace, message)
|
||||
|
||||
|
||||
def error_response(request_id: str, error: BaseException, *, debug: bool = False) -> dict[str, Any]:
|
||||
message = str(error)
|
||||
if not debug:
|
||||
message = _sanitize_error_message(message)
|
||||
return {
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": error.__class__.__name__,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from moveit2_server.context import DEFAULT_CAD_DIRECTORY, build_moveit2_context
|
||||
from moveit2_server.dispatcher import dispatch
|
||||
from moveit2_server.protocol import error_response, normalize_request, normalize_wire_message, success_response
|
||||
|
||||
|
||||
def _json_dumps(message: dict[str, Any]) -> str:
|
||||
return json.dumps(message, separators=(",", ":"))
|
||||
|
||||
|
||||
async def handle_message(raw_message: str, *, repo_root: Path) -> str:
|
||||
request_id = ""
|
||||
debug_errors = os.environ.get("MOVEIT2_SERVER_DEBUG_ERRORS", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
try:
|
||||
message = json.loads(raw_message)
|
||||
wire = normalize_wire_message(message)
|
||||
request_id = wire.id
|
||||
payload_dir = wire.payload.get("dir", DEFAULT_CAD_DIRECTORY)
|
||||
context = build_moveit2_context(
|
||||
repo_root=repo_root,
|
||||
dir=payload_dir,
|
||||
file=wire.payload.get("file"),
|
||||
type=wire.type,
|
||||
payload=wire.payload,
|
||||
)
|
||||
request = normalize_request(message, context=context)
|
||||
return _json_dumps(success_response(request.id, dispatch(request)))
|
||||
except Exception as exc:
|
||||
return _json_dumps(error_response(request_id, exc, debug=debug_errors))
|
||||
|
||||
|
||||
async def handle_client(websocket: Any, path: str | None = None, *, repo_root: Path) -> None:
|
||||
async for raw_message in websocket:
|
||||
await websocket.send(await handle_message(str(raw_message), repo_root=repo_root))
|
||||
|
||||
|
||||
async def run_server(*, repo_root: Path, host: str, port: int) -> None:
|
||||
try:
|
||||
from websockets.asyncio.server import serve
|
||||
except ImportError:
|
||||
from websockets import serve
|
||||
|
||||
async with serve(lambda websocket, path=None: handle_client(websocket, path, repo_root=repo_root), host, port):
|
||||
print(f"moveit2_server listening on ws://{host}:{port}/ws", flush=True)
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
def _check_import(module_name: str, attribute_path: str | None = None) -> str:
|
||||
module = importlib.import_module(module_name)
|
||||
current: Any = module
|
||||
if attribute_path:
|
||||
for attribute in attribute_path.split("."):
|
||||
current = getattr(current, attribute)
|
||||
return str(getattr(module, "__file__", "built-in"))
|
||||
|
||||
|
||||
def check_environment(*, repo_root: Path) -> dict[str, Any]:
|
||||
checks = {
|
||||
"repoRoot": str(repo_root),
|
||||
"rmwImplementation": os.environ.get("RMW_IMPLEMENTATION", ""),
|
||||
"imports": {
|
||||
"websockets": _check_import("websockets"),
|
||||
"rclpy": _check_import("rclpy"),
|
||||
"geometry_msgs.msg.PoseStamped": _check_import("geometry_msgs.msg", "PoseStamped"),
|
||||
"moveit.planning.MoveItPy": _check_import("moveit.planning", "MoveItPy"),
|
||||
},
|
||||
}
|
||||
return checks
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="moveit2_server")
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=str(Path.cwd()),
|
||||
help="Repository root that contains the CAD scan directory.",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Verify required Python, ROS 2, MoveIt, and websocket imports, then exit.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_arg_parser().parse_args(argv)
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
if args.check:
|
||||
print(json.dumps(check_environment(repo_root=repo_root), indent=2, sort_keys=True))
|
||||
return 0
|
||||
asyncio.run(run_server(repo_root=repo_root, host=args.host, port=args.port))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
SRDF_SUFFIX = ".srdf"
|
||||
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
|
||||
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
|
||||
|
||||
|
||||
class SrdfSourceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfChain:
|
||||
base_link: str
|
||||
tip_link: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfPlanningGroup:
|
||||
name: str
|
||||
joint_names: tuple[str, ...]
|
||||
link_names: tuple[str, ...]
|
||||
chains: tuple[SrdfChain, ...]
|
||||
subgroups: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfEndEffector:
|
||||
name: str
|
||||
parent_link: str
|
||||
group: str
|
||||
parent_group: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfGroupState:
|
||||
name: str
|
||||
group: str
|
||||
joint_values_by_name: dict[str, float]
|
||||
|
||||
@property
|
||||
def joint_values_by_name_rad(self) -> dict[str, float]:
|
||||
return self.joint_values_by_name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfDisabledCollisionPair:
|
||||
link1: str
|
||||
link2: str
|
||||
reason: str
|
||||
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SrdfSource:
|
||||
file_ref: str
|
||||
source_path: Path
|
||||
robot_name: str
|
||||
urdf_ref: str
|
||||
planning_groups: tuple[SrdfPlanningGroup, ...]
|
||||
end_effectors: tuple[SrdfEndEffector, ...]
|
||||
group_states: tuple[SrdfGroupState, ...]
|
||||
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
|
||||
|
||||
|
||||
def read_srdf_source(srdf_path: Path) -> SrdfSource:
|
||||
resolved_path = srdf_path.resolve()
|
||||
if resolved_path.suffix.lower() != SRDF_SUFFIX:
|
||||
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
|
||||
try:
|
||||
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
|
||||
return parse_srdf_root(root, source_path=resolved_path)
|
||||
|
||||
|
||||
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
|
||||
if root.tag != "robot":
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
|
||||
robot_name = str(root.attrib.get("name") or "").strip()
|
||||
if not robot_name:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
|
||||
|
||||
planning_groups: list[SrdfPlanningGroup] = []
|
||||
group_names: list[str] = []
|
||||
for group_element in root.findall("group"):
|
||||
name = str(group_element.attrib.get("name") or "").strip()
|
||||
if not name:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
|
||||
group_names.append(name)
|
||||
planning_groups.append(
|
||||
SrdfPlanningGroup(
|
||||
name=name,
|
||||
joint_names=_child_names(group_element, "joint"),
|
||||
link_names=_child_names(group_element, "link"),
|
||||
chains=tuple(
|
||||
SrdfChain(
|
||||
base_link=str(chain.attrib.get("base_link") or "").strip(),
|
||||
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
|
||||
)
|
||||
for chain in group_element.findall("chain")
|
||||
),
|
||||
subgroups=_child_names(group_element, "group"),
|
||||
)
|
||||
)
|
||||
_raise_on_duplicates(group_names, source_path=source_path, label="group")
|
||||
|
||||
end_effectors: list[SrdfEndEffector] = []
|
||||
end_effector_names: list[str] = []
|
||||
for element in root.findall("end_effector"):
|
||||
name = str(element.attrib.get("name") or "").strip()
|
||||
if not name:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
|
||||
end_effector_names.append(name)
|
||||
end_effectors.append(
|
||||
SrdfEndEffector(
|
||||
name=name,
|
||||
parent_link=str(element.attrib.get("parent_link") or "").strip(),
|
||||
group=str(element.attrib.get("group") or "").strip(),
|
||||
parent_group=str(element.attrib.get("parent_group") or "").strip(),
|
||||
)
|
||||
)
|
||||
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
|
||||
|
||||
group_states: list[SrdfGroupState] = []
|
||||
group_state_keys: list[str] = []
|
||||
for element in root.findall("group_state"):
|
||||
name = str(element.attrib.get("name") or "").strip()
|
||||
group = str(element.attrib.get("group") or "").strip()
|
||||
if not name or not group:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
|
||||
group_state_keys.append(f"{group}/{name}")
|
||||
joint_values: dict[str, float] = {}
|
||||
joint_names: list[str] = []
|
||||
for joint_element in element.findall("joint"):
|
||||
joint_name = str(joint_element.attrib.get("name") or "").strip()
|
||||
if not joint_name:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
|
||||
joint_names.append(joint_name)
|
||||
try:
|
||||
joint_value = float(joint_element.attrib.get("value"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SrdfSourceError(
|
||||
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
|
||||
) from exc
|
||||
if not isfinite(joint_value):
|
||||
raise SrdfSourceError(
|
||||
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
|
||||
)
|
||||
joint_values[joint_name] = joint_value
|
||||
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
|
||||
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
|
||||
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
|
||||
|
||||
disabled_pairs: list[SrdfDisabledCollisionPair] = []
|
||||
pair_keys: list[str] = []
|
||||
for element in root.findall("disable_collisions"):
|
||||
link1 = str(element.attrib.get("link1") or "").strip()
|
||||
link2 = str(element.attrib.get("link2") or "").strip()
|
||||
if not link1 or not link2:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
|
||||
if link1 == link2:
|
||||
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
|
||||
reason = str(element.attrib.get("reason") or "").strip()
|
||||
if not reason:
|
||||
raise SrdfSourceError(
|
||||
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
|
||||
)
|
||||
pair_key = "/".join(sorted((link1, link2)))
|
||||
pair_keys.append(pair_key)
|
||||
disabled_pairs.append(
|
||||
SrdfDisabledCollisionPair(
|
||||
link1=link1,
|
||||
link2=link2,
|
||||
reason=reason,
|
||||
source=_disabled_collision_source(reason),
|
||||
)
|
||||
)
|
||||
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
|
||||
|
||||
return SrdfSource(
|
||||
file_ref=_relative_to_repo(source_path.resolve()),
|
||||
source_path=source_path.resolve(),
|
||||
robot_name=robot_name,
|
||||
urdf_ref=_linked_urdf_ref(root),
|
||||
planning_groups=tuple(planning_groups),
|
||||
end_effectors=tuple(end_effectors),
|
||||
group_states=tuple(group_states),
|
||||
disabled_collision_pairs=tuple(disabled_pairs),
|
||||
)
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
|
||||
|
||||
|
||||
def _linked_urdf_ref(root: ET.Element) -> str:
|
||||
for child in list(root):
|
||||
if _local_name(child.tag) != "urdf":
|
||||
continue
|
||||
tag = str(child.tag or "")
|
||||
if (
|
||||
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
|
||||
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
|
||||
or tag.startswith("tcad:")
|
||||
or tag.startswith("explorer:")
|
||||
):
|
||||
return str(child.attrib.get("path") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
|
||||
names: list[str] = []
|
||||
for child in element.findall(tag):
|
||||
name = str(child.attrib.get("name") or "").strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
|
||||
seen = set()
|
||||
duplicates = []
|
||||
for name in names:
|
||||
if name in seen:
|
||||
duplicates.append(name)
|
||||
seen.add(name)
|
||||
if duplicates:
|
||||
raise SrdfSourceError(
|
||||
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
|
||||
)
|
||||
|
||||
|
||||
def _disabled_collision_source(
|
||||
reason: str,
|
||||
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
|
||||
normalized = str(reason or "").strip().lower()
|
||||
if "adjacent" in normalized:
|
||||
return "adjacent"
|
||||
if any(token in normalized for token in ("never", "always", "sample", "default")):
|
||||
return "sampled"
|
||||
if "setup" in normalized or "assistant" in normalized:
|
||||
return "setup_assistant"
|
||||
if "assum" in normalized:
|
||||
return "assumed"
|
||||
return "manual"
|
||||
|
||||
|
||||
def _relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(Path.cwd().resolve()).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "moveit2_server"
|
||||
version = "0.3.9"
|
||||
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"websockets>=12",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
moveit2_server = "moveit2_server.server:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["moveit2_server"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
moveit2_server = "moveit2_server"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=conda-common.sh
|
||||
source "${SCRIPT_DIR}/conda-common.sh"
|
||||
|
||||
REPO_ROOT="$(find_repo_root)"
|
||||
CONDA="$(find_conda)"
|
||||
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
|
||||
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
exec "${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" \
|
||||
python -m moveit2_server.server \
|
||||
--repo-root "${REPO_ROOT}" \
|
||||
--host "${MOVEIT2_SERVER_HOST:-127.0.0.1}" \
|
||||
--port "${MOVEIT2_SERVER_PORT:-8765}" \
|
||||
"$@"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=conda-common.sh
|
||||
source "${SCRIPT_DIR}/conda-common.sh"
|
||||
|
||||
REPO_ROOT="$(find_repo_root)"
|
||||
CONDA="$(find_conda)"
|
||||
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
|
||||
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
"${CONDA}" env update --file "${SCRIPT_DIR}/environment.yml" --prune
|
||||
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m pip install -e "${SCRIPT_DIR}"
|
||||
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
|
||||
Reference in New Issue
Block a user