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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ The selected entry's serial number (or port path when no serial is available)
becomes the robot's `hardware_id`; discovery's index-0 shortcut values never
override a different selected entry.

For remote deployment, declare the device capabilities and choose calibration
in the editor's **Deployments** panel. **Activate on device** binds the saved
calibration to the paired device while it is connected and disarmed. Staging
then embeds that exact robot profile and calibration in the workflow. The
remote `Robot` node applies the embedded home positions and safe ranges only
when discovery returns the same physical hardware identity.

The Properties panel keeps transport controls under **Advanced**. They are not
required for normal setup: `probe_open` actively opens candidate serial ports
for diagnostics, vendor/product IDs narrow discovery to a USB adapter model,
Expand Down Expand Up @@ -170,18 +177,20 @@ configuration.

Open **Robot Guided Calibration** after saving a profile:

1. Load the profile and start discovery with the robot connected.
2. Press **Release + live pose** on Manual Move and physically support the arm.
3. Confirm live joint values are changing, then press **Start recording**.
4. Slowly move every joint through the safe physical range you intend to use.
1. Enter a clear **Calibration name**, such as `Workshop arm` or
`Left SO-ARM101`.
2. Load the profile and start discovery with the robot connected.
3. Press **Release + live pose** on Manual Move and physically support the arm.
4. Confirm live joint values are changing, then press **Start recording**.
5. Slowly move every joint through the safe physical range you intend to use.
Do not force a hard stop.
5. Put the robot in the pose that should read as zero and press **Capture Home**.
6. Press **Stop recording** whenever you want to pause extrema collection
6. Put the robot in the pose that should read as zero and press **Capture Home**.
7. Press **Stop recording** whenever you want to pause extrema collection
without losing samples. Current pose remains live; press **Resume recording**
to continue.
7. Press **Save calibration**. The recorder applies the configured safety
margin inside the observed extrema and saves it under the hardware ID.
8. Press **Hold position** only while the arm is supported and the workspace is
8. Press **Save calibration**. The recorder applies the configured safety
margin inside the observed extrema and saves the name with its hardware ID.
9. Press **Hold position** only while the arm is supported and the workspace is
clear.

Recording never commands movement. It refuses to start while torque is on, and
Expand Down
111 changes: 105 additions & 6 deletions nodes/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ def robot_profile_save(ctx: dict) -> dict:
inputs={
"trigger": AnyPort,
"profile_id": Enum(_available_profile_ids(), default="so_arm101"),
"profile": Dict,
"calibration": Dict,
"selection": Int(default=0),
"hardware": Dict,
"usb": Dict,
Expand Down Expand Up @@ -561,8 +563,21 @@ def robot_profile_save(ctx: dict) -> dict:
},
)
def robot_profile_load(ctx: dict) -> dict:
profile_id = _slug(ctx.get("profile_id") or "so_arm101")
profile, path = load_profile(profile_id)
supplied_profile = (
copy.deepcopy(ctx.get("profile"))
if isinstance(ctx.get("profile"), dict) and ctx.get("profile")
else None
)
profile_id = _slug(
(supplied_profile or {}).get("id")
or ctx.get("profile_id")
or "so_arm101"
)
if supplied_profile is not None:
profile = supplied_profile
path = None
else:
profile, path = load_profile(profile_id)
if profile is None:
known = ", ".join(item["id"] for item in list_profiles()) or "none"
return {"found": False, "ready": False, "profile": {}, "driver": {}, "robot": {}, "hardware": {},
Expand Down Expand Up @@ -603,12 +618,72 @@ def robot_profile_load(ctx: dict) -> dict:

effective_ctx = {**ctx, "hardware": hardware}
hardware_id = _hardware_id(effective_ctx)
effective_profile = copy.deepcopy(profile)
supplied_calibration = (
copy.deepcopy(ctx.get("calibration"))
if isinstance(ctx.get("calibration"), dict) and ctx.get("calibration")
else None
)
if supplied_calibration is not None:
calibration_profile_id = str(
supplied_calibration.get("profile_id") or ""
).strip()
calibration_hardware_id = str(
supplied_calibration.get("hardware_id") or ""
).strip()
profile_joint_ids = {
str(joint.get("id") or "").strip()
for joint in _joint_list(profile)
}
calibration_joint_ids = set(
(supplied_calibration.get("joints") or {}).keys()
if isinstance(supplied_calibration.get("joints"), dict)
else []
)
if calibration_profile_id != str(profile.get("id") or ""):
return {
"found": False, "ready": False, "profile": {}, "driver": {},
"robot": {}, "hardware": hardware, "devices": devices,
"calibration": {}, "path": "",
"report": "embedded calibration profile does not match the Robot profile",
}
if not hardware_id or calibration_hardware_id != hardware_id:
return {
"found": False, "ready": False, "profile": {}, "driver": {},
"robot": {}, "hardware": hardware, "devices": devices,
"calibration": {}, "path": "",
"report": (
"embedded calibration is bound to "
f"{calibration_hardware_id or 'an unknown device'}, "
f"but discovery selected {hardware_id or 'no hardware'}"
),
}
if profile_joint_ids != calibration_joint_ids:
return {
"found": False, "ready": False, "profile": {}, "driver": {},
"robot": {}, "hardware": hardware, "devices": devices,
"calibration": {}, "path": "",
"report": "embedded calibration joints do not match the Robot profile",
}
effective_profile = _apply_calibration(profile, supplied_calibration)
else:
effective_profile = copy.deepcopy(profile)
rate_override = float(ctx.get("rate_hz") or 0.0)
if rate_override > 0:
effective_profile.setdefault("driver", {})["rate_hz"] = rate_override
supplied_driver = ctx.get("driver") if isinstance(ctx.get("driver"), dict) else {}
driver = dict(supplied_driver) or _driver_from_profile(effective_profile, hardware_id, str(ctx.get("topic_prefix") or ""))
if supplied_driver:
driver = dict(supplied_driver)
if supplied_calibration is not None:
driver["profile"] = copy.deepcopy(effective_profile)
driver["joints"] = _joint_list(effective_profile)
driver["hardware_id"] = hardware_id
driver["calibration_path"] = ""
else:
driver = _driver_from_profile(
effective_profile,
"" if supplied_calibration is not None else hardware_id,
str(ctx.get("topic_prefix") or ""),
)
effective = dict(driver.get("profile") or profile)
from .robot import robot_discovery

Expand Down Expand Up @@ -647,7 +722,13 @@ def robot_profile_load(ctx: dict) -> dict:
"path": str(path or "builtin"),
"report": (
f"loaded robot profile '{profile_id}' ({len(_joint_list(effective))} joint(s))"
+ (f"\ncalibration: {driver['calibration_path']}" if driver.get("calibration_path") else "\ncalibration: none")
+ (
"\ncalibration: embedded deployment calibration"
if supplied_calibration is not None
else f"\ncalibration: {driver['calibration_path']}"
if driver.get("calibration_path")
else "\ncalibration: none"
)
+ (f"\nhardware: {hardware_id}" if hardware_id else "\nhardware: not connected")
+ (f"\n{discovery_report}" if discovery_report else "")
+ f"\n{connection.get('report', '')}"
Expand Down Expand Up @@ -858,6 +939,7 @@ def _session_outputs(session: dict[str, Any] | None, report: str, *, saved: bool
inputs={
"action": Enum(["check", "start", "pause", "capture_home", "finish", "cancel"], default="check"),
"run_id": Text(default="robot_calibration"),
"calibration_name": Text(default=""),
"profile": Dict,
"hardware_id": Text(default=""),
"hardware": Dict,
Expand Down Expand Up @@ -889,11 +971,14 @@ def _session_outputs(session: dict[str, Any] | None, report: str, *, saved: bool
def robot_calibration_recorder(ctx: dict) -> dict:
action = str(ctx.get("action") or "check").strip().lower()
run_id = str(ctx.get("run_id") or "robot_calibration").strip() or "robot_calibration"
requested_name = " ".join(str(ctx.get("calibration_name") or "").split())[:96]
pose = dict(ctx.get("pose") or {})
torque_enabled = bool(ctx.get("torque_enabled", True))
require_released = bool(ctx.get("require_released", True))
with _calibration_lock:
session = _calibration_sessions.get(run_id)
if session is not None and requested_name:
session["calibration_name"] = requested_name
if action == "start":
profile = copy.deepcopy(ctx.get("profile") if isinstance(ctx.get("profile"), dict) else {})
errors = _validate_profile(profile)
Expand All @@ -920,6 +1005,10 @@ def robot_calibration_recorder(ctx: dict) -> dict:
"run_id": run_id,
"profile": profile,
"hardware_id": hardware_id,
"calibration_name": (
requested_name
or f"{str(profile.get('display_name') or profile.get('id') or 'Robot')} · {hardware_id}"
),
"observed": {},
"home": {},
"samples": 0,
Expand Down Expand Up @@ -1009,6 +1098,7 @@ def robot_calibration_recorder(ctx: dict) -> dict:
return _session_outputs(session, "calibration not saved:\n- " + "\n- ".join(invalid))
calibration = {
"schema_version": _PROFILE_SCHEMA,
"name": str(session.get("calibration_name") or session["hardware_id"]),
"profile_id": str(profile["id"]),
"hardware_id": str(session["hardware_id"]),
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
Expand All @@ -1027,7 +1117,15 @@ def robot_calibration_recorder(ctx: dict) -> dict:
session["path"] = str(path)
session["active"] = False
session["paused"] = False
return _session_outputs(session, f"saved calibration for {session['hardware_id']}\n{path}", saved=True, path=str(path))
return _session_outputs(
session,
(
f"saved calibration '{calibration['name']}' "
f"for {session['hardware_id']}\n{path}"
),
saved=True,
path=str(path),
)
if session.get("active"):
report = "calibration recording is active and receiving live joint samples"
elif session.get("paused"):
Expand All @@ -1048,6 +1146,7 @@ def calibration_runtime_status() -> dict[str, Any]:
"state": "recording" if session.get("active") else "paused" if session.get("paused") else "saved" if session.get("calibration") else "idle",
"samples": int(session.get("samples") or 0),
"hardware_id": str(session.get("hardware_id") or ""),
"calibration_name": str(session.get("calibration_name") or ""),
"profile_id": str(session.get("profile", {}).get("id") or ""),
"updated_at": session.get("updated_at"),
}
Expand Down
11 changes: 6 additions & 5 deletions templates/robot-guided-calibration.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"template": true,
"description": "Load a named robot, discover and start it, release torque explicitly, record hand-moved joint extrema, capture Home, and save reviewed safe limits per USB serial. Calibration never commands motion.",
"color": "#f59e0b",
"required_packages": ["blacknode-controllers", "blacknode-robot", "blacknode-ros2"]
"required_packages": ["blacknode-controllers", "blacknode-robot", "blacknode-ros2"],
"required_capabilities": ["position_feedback", "servo_bus"]
},
"node_meta": {
"robot": {
Expand Down Expand Up @@ -50,14 +51,14 @@
"calibration": {
"id": "calibration", "type": "RobotCalibrationRecorder", "pos": [1960, 90],
"params": {
"action": "check", "run_id": "robot_calibration", "hardware_id": "", "require_released": true,
"action": "check", "run_id": "robot_calibration", "calibration_name": "Workshop arm", "hardware_id": "", "require_released": true,
"safety_margin_deg": 3.0
},
"inputs": ["action", "run_id", "profile", "hardware_id", "hardware", "pose", "torque_enabled", "require_released", "safety_margin_deg"],
"inputs": ["action", "run_id", "calibration_name", "profile", "hardware_id", "hardware", "pose", "torque_enabled", "require_released", "safety_margin_deg"],
"outputs": ["live", "state", "active", "data_ready", "samples", "pose", "capturing_joint", "range_updates", "observed", "home", "calibration", "profile", "driver", "saved", "path", "dashboard", "report"],
"input_types": {"action": "Text", "run_id": "Text", "profile": "Dict", "hardware_id": "Text", "hardware": "Dict", "pose": "Dict", "torque_enabled": "Bool", "require_released": "Bool", "safety_margin_deg": "Float"},
"input_types": {"action": "Text", "run_id": "Text", "calibration_name": "Text", "profile": "Dict", "hardware_id": "Text", "hardware": "Dict", "pose": "Dict", "torque_enabled": "Bool", "require_released": "Bool", "safety_margin_deg": "Float"},
"output_types": {"live": "Bool", "state": "Text", "active": "Bool", "data_ready": "Bool", "samples": "Int", "pose": "Dict", "capturing_joint": "Text", "range_updates": "Dict", "observed": "Dict", "home": "Dict", "calibration": "Dict", "profile": "Dict", "driver": "Dict", "saved": "Bool", "path": "Text", "dashboard": "Image", "report": "Text"},
"input_defaults": {"action": "check", "run_id": "robot_calibration", "hardware_id": "", "torque_enabled": true, "require_released": true, "safety_margin_deg": 3.0},
"input_defaults": {"action": "check", "run_id": "robot_calibration", "calibration_name": "", "hardware_id": "", "torque_enabled": true, "require_released": true, "safety_margin_deg": 3.0},
"input_choices": {"action": ["check", "start", "pause", "capture_home", "finish", "cancel"]}
},
"out": {
Expand Down
3 changes: 2 additions & 1 deletion templates/so-arm101-motion-test.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"template": true,
"description": "Connect and control a real SO-ARM101 in one visible path: discover USB, launch the driver, show an explicitly live joint monitor, optionally release torque for manual positioning, then safely hold or arm one joint movement. Safe by default: Manual Move action is check, motion is disarmed, and Stop all disables torque.",
"color": "#14b8a6",
"required_packages": ["blacknode-controllers", "blacknode-robot", "blacknode-ros2"]
"required_packages": ["blacknode-controllers", "blacknode-robot", "blacknode-ros2"],
"required_capabilities": ["joint_group", "position_feedback", "servo_bus"]
},
"node_meta": {
"robot": {
Expand Down
63 changes: 63 additions & 0 deletions tests/test_robot_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,66 @@ def fake_connection(ctx):
assert seen["action"] == "check"


def test_robot_applies_embedded_calibration_only_to_matching_hardware(monkeypatch):
profile = profile_nodes.builtin_profile("so_arm101")
calibration = {
"schema_version": 1,
"profile_id": profile["id"],
"hardware_id": "SERIAL-42",
"joints": {
joint["id"]: {
"home_ticks": 2000 + index,
"safe_min_deg": -50.0,
"safe_max_deg": 50.0,
}
for index, joint in enumerate(profile["joints"])
},
}
hardware = {
"found": True,
"ready": True,
"port": "COM7",
"serial": "SERIAL-42",
"devices": [{"path": "COM7", "serial": "SERIAL-42", "accessible": True}],
"recommended": {"path": "COM7", "serial": "SERIAL-42", "accessible": True},
"report": "found",
}
monkeypatch.setattr(robot_nodes, "robot_usb_discovery", lambda _ctx: hardware)
monkeypatch.setattr(robot_nodes, "robot_discovery", lambda ctx: {
"ready": False,
"usb_ready": True,
"driver_running": False,
"robot": {"ready": False, "driver": ctx["driver"]},
"report": "driver checked",
})

result = _NODE_REGISTRY["Robot"]({
"profile": profile,
"calibration": calibration,
"driver": {"driver": "blacknode_drivers.feetech", "run_id": "embedded-test"},
"action": "check",
})

assert result["found"] is True
assert result["calibration"]["hardware_id"] == "SERIAL-42"
assert result["profile"]["joints"][0]["home_ticks"] == 2000
assert result["profile"]["joints"][0]["safe_min_deg"] == -50.0
assert result["driver"]["run_id"] == "embedded-test"
assert result["driver"]["joints"][0]["home_ticks"] == 2000
assert "embedded deployment calibration" in result["report"]

rejected_calibration = dict(calibration)
rejected_calibration["hardware_id"] = "OTHER-SERIAL"
rejected = _NODE_REGISTRY["Robot"]({
"profile": profile,
"calibration": rejected_calibration,
"action": "check",
})

assert rejected["found"] is False
assert "discovery selected SERIAL-42" in rejected["report"]


def test_profile_duplicate_turns_builtin_into_editable_local_robot(monkeypatch, tmp_path):
monkeypatch.setenv("BLACKNODE_ROBOTS_DIR", str(tmp_path / "robots"))

Expand Down Expand Up @@ -618,6 +678,7 @@ def test_calibration_records_extrema_home_margin_and_device_file(monkeypatch, tm
started = _NODE_REGISTRY["RobotCalibrationRecorder"]({
"action": "start",
"run_id": run_id,
"calibration_name": "Workbench left arm",
"profile": profile,
"hardware_id": "USB ABC-123",
"pose": {"shoulder_pan": 0.0, "shoulder_lift": 0.0},
Expand Down Expand Up @@ -651,6 +712,7 @@ def test_calibration_records_extrema_home_margin_and_device_file(monkeypatch, tm
assert finished["active"] is False
assert finished["live"] is True
assert finished["state"] == "saved"
assert finished["calibration"]["name"] == "Workbench left arm"
after_save = _NODE_REGISTRY["RobotCalibrationRecorder"]({
"action": "_sample",
"run_id": run_id,
Expand All @@ -663,6 +725,7 @@ def test_calibration_records_extrema_home_margin_and_device_file(monkeypatch, tm
assert after_save["pose"] == {"shoulder_pan": 6.0, "shoulder_lift": 11.0}
path = tmp_path / "robots" / "calibration_arm" / "calibrations" / "usb_abc_123.json"
assert path.exists()
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "Workbench left arm"
shoulder = finished["calibration"]["joints"]["shoulder_pan"]
assert shoulder["observed_min_deg"] == -25.0
assert shoulder["observed_max_deg"] == 35.0
Expand Down