diff --git a/README.md b/README.md index 7ce9990..ba21d15 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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 diff --git a/nodes/profiles.py b/nodes/profiles.py index 93f5e10..7291b4d 100644 --- a/nodes/profiles.py +++ b/nodes/profiles.py @@ -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, @@ -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": {}, @@ -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 @@ -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', '')}" @@ -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, @@ -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) @@ -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, @@ -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()), @@ -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"): @@ -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"), } diff --git a/templates/robot-guided-calibration.json b/templates/robot-guided-calibration.json index 479b319..e49756e 100644 --- a/templates/robot-guided-calibration.json +++ b/templates/robot-guided-calibration.json @@ -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": { @@ -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": { diff --git a/templates/so-arm101-motion-test.json b/templates/so-arm101-motion-test.json index e6e52be..f7be200 100644 --- a/templates/so-arm101-motion-test.json +++ b/templates/so-arm101-motion-test.json @@ -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": { diff --git a/tests/test_robot_nodes.py b/tests/test_robot_nodes.py index 1d8887f..1644e70 100644 --- a/tests/test_robot_nodes.py +++ b/tests/test_robot_nodes.py @@ -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")) @@ -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}, @@ -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, @@ -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