diff --git a/docs/actuator-setup.md b/docs/actuator-setup.md new file mode 100644 index 0000000..88187e8 --- /dev/null +++ b/docs/actuator-setup.md @@ -0,0 +1,125 @@ +# Actuator Setup + +The first shelf button, **Actuator Setup**, opens a Local USB scanner. Pick the +USB port and press **Scan**. Each responding servo appears as a separate connected +node with its own ID, torque state, position, voltage, temperature and settings. +Discovery and per-servo calibration/testing need only the USB port. A robot +profile can be selected later for whole-robot calibration and testing. +Unreadable replies appear as **Unresolved ID** cards while discovery continues. +These show the attempted address and error; they do not claim a physical servo +count or confirmed duplicate. Servos sharing an ID must be connected one at a +time to receive unique IDs before the full chain can be identified separately. +The workflow is also available under **Templates → Actuator Setup**. Existing +shelves receive the button first while keeping their other shortcuts; it remains +customizable through the shelf's settings. + +1. Select the USB adapter. Stop other sessions using that adapter, check power + and wiring, and press **Scan**. Rescanning refreshes existing servo cards, + adds newly discovered IDs and flags previously seen IDs that no longer respond. +2. To program an ID, support the arm and power off before changing wiring. + Connect only the selected actuator, power on and scan again. Enter **New ID** + on its servo card and press **Set ID**. Keep the isolated actuator supported; + changing its ID retires the assembly's saved calibration. +3. Power off, reconnect the complete arm and rescan. Resolve missing IDs, open + **Calibration and motion test** on a servo card, select the robot profile and + press **Calibrate** to record new hand-guided limits and home. +4. Press **Open motion test** to open that servo in Servo Debug Monitor. Motion + requires its explicit **Arm** action, fresh feedback and calibrated limits. + +New IDs can be entered directly from 1–253. Robot profiles must match the final +joint IDs before calibration and motion. Initial ID programming supports STS3215; +unsupported models remain visible for diagnosis. Discovery is bounded, scans +IDs 0–253 and preserves torque. Running or reopening this workflow performs +no physical operation until an operator presses a control. + +**Hardware settings** shows a read-only snapshot of the baud rate code, position +register limits, torque limit, operating mode and EEPROM lock for supported +models. These raw limits are separate from calibrated safe motion limits. +Use **Advanced** on the USB scanner to change baud rate or refresh USB ports. + +## Per-servo calibration, test slider and saved poses + +The card opens on **1. Set ID**, with the New ID field and assignment controls +visible first. **2. Calibrate / test** contains the motion and capture controls; +disabled actions explain the missing prerequisite beside the button. +ID setup uses **New ID → Set ID**, with no confirmation checkboxes or manual +scan-expiry step. The button is the explicit assignment action after the inline +isolation/support and calibration-reset instructions. Its owning control scans +the bus, checks the selected ID and torque, writes, and verifies the result. + +Each servo card has **Calibration and test** controls. Capture **Min** and **Max** +from fresh measurements while the servo is supported and torque is released. +Either tick direction is accepted. Use comfortable positions clear of mechanical +stops. Testing uses those exact endpoints as command limits. Existing setup files +also use their exact captures when reopened; saving retires the old automatic +20-tick inset. **Home** is optional: +an interior capture supplies the test origin; otherwise the midpoint is calculated +for tick-to-angle conversion. This calculated center does not replace a captured +Home or command the motor to move there. Partial points and named poses can also +be saved with **Save states**. + +Before arming, the slider follows the motor's measured position as you move it +by hand, showing the full raw tick range even before calibration is saved. +Read-only feedback refreshes while the panel is visible and pauses while another +control is working. Connection errors replace the live status. Capture buttons +read the current position again; captured points are retained as drafts across +reloads. **Arm** validates and saves a complete range before enabling torque; +**Save states** is also available separately. + +After **Arm**, the slider controls the target. The Arm button is the +explicit authorization; there is no extra checkbox. Use the calibrated actuator +with its unique ID and keep the assembly supported. Arm holds the measured +position anywhere within the captured endpoints, including either endpoint. +It holds still until a slider command arrives. Each new slider target is sent +directly to the servo's position controller, within the saved limits. The driver +sets and verifies a finite 180°/s speed ceiling, acceleration 50 and zero goal +time in the same RAM command. The servo controls the movement between targets. +New targets replace pending targets immediately; the managed loop checks +feedback on a 20 ms cadence, subject to USB latency. +The UI uses fresh shared feedback for acknowledgements and updates readings +every 100 ms. **Stop** releases torque and +ends the session. Closing or hiding the controls stops it; a managed three-second +lease also releases torque if the UI disconnects. Stale requests and invalid +feedback disarm the test. STS3215 position mode must be verified before arming. + +The slider spans the captured Min/Max ticks and follows their direction, including +decreasing tick values from Min to Max. Both exact endpoints are reachable targets; +commands outside them are rejected. +While calibration controls are open, the card's position and torque fields use +live feedback; scan-only values are labeled as scan snapshots. Unavailable live +feedback displays an unknown state instead of an old torque reading. + +Enter a **Pose name**, press **Capture pose**, then stop any active test and press +**Save states**. Captures read measured positions, including while testing; +they do not save the requested slider value as if it were feedback. Select a +pose to preview its target before moving. Saved poses outside the calibrated +range are clamped to that range for testing. + +State files live under the robot data directory's `actuator_setups/` folder, +keyed by USB hardware identity, provider and servo ID, with the actuator model +recorded. They load when the same servo card is reopened. ID assignment retires +these files alongside existing assembly calibration. Replacing a same-model +actuator at the same address requires recapturing calibration; these servos do +not expose an individual serial identity through this setup protocol. Whole-robot +profile calibration remains available from the card's expandable controls. + +Assignment retires saved calibration files for the selected physical USB +identity into `calibrations/retired/`, preserving them for review while requiring +new calibration for the changed assembly. It verifies EEPROM relocking and the +new ID and keeps uncertain results visible. Each assignment uses a fresh, +single-use discovery token for legacy clients; the current **Set ID** button +performs fresh discovery on every click. It never enables torque or commands motion. + +## Delivery + +Select the USB scanner or a servo card and drag a corner to resize it. The +controls fill the available space, with scrolling available inside smaller +cards. Save the workflow to retain the card sizes. + +Managed Runtime release decision: **no Runtime release for this workstation +Local USB workflow**. The editor UI and editor-server control routes run on the +workstation; the robot and driver changes load through the existing package +contract. This change does not add managed-device commissioning or modify the +device source lock. Local verification does not publish a release. A future +managed-device delivery must verify its actual Software update controls and +follow the owning package/Runtime release sequence in `AGENTS.md`. diff --git a/docs/provider-authoring.md b/docs/provider-authoring.md index 2db5329..1e6277c 100644 --- a/docs/provider-authoring.md +++ b/docs/provider-authoring.md @@ -213,6 +213,53 @@ Use the resolver already defined by the capability: - Calibration control discovers capability-specific provider registrations and opens the implementation selected by the profile. +- Actuator Setup resolves `_bn_robot_actuator_setup_provider` by the highest unique + positive `match_hardware(hardware)` score for the selected USB port. Saved workflows + can also select the provider through the profile's joint or calibration binding. + `scan(config)` returns normalized `actuators` + including `servo_id`, `model`, `model_number`, `assignment_supported`, + `torque_enabled`, `raw_position`, `voltage_v`, `temperature_c`, + `hardware_error_flags`, `hardware_errors` and `errors`, plus optional `settings` + and `settings_errors`. The editor creates an `ActuatorServoSetup` card for each + responding ID, connected to the scanner's typed `bus` output. The owning provider's + scan can retain unreadable addresses with `discovery_status: unreadable`, + `model_number: null`, `assignment_supported: false` and an actionable `errors` + list. These records represent uncertain addresses rather than confirmed servos; + they preserve partial discovery and cannot authorize writes. The provider's + `assign(config, expected, new_id)` rescans under exclusive bus ownership and + verifies the ID write and protection state. Optional `release(config, expected)` + independently verifies torque off. The robot facade owns expiring single-use + confirmation and retirement of hardware-bound calibration. The dedicated ID + button uses an explicit `operator_action: assign` request after visible isolation + and calibration-reset instructions; its owning control refreshes discovery + automatically. Legacy clients retain their confirmation/token contract. Ordinary graph + cooks remain inert. See [Actuator Setup](actuator-setup.md). +- The optional setup callback `read_position(config, servo_id)` performs a bounded, + read-only sample of one discovered address and closes its connection. It returns + `servo_id`, `reported_id`, `model_number`, `assignment_supported`, `raw_position`, + `torque_enabled`, `hardware_error_flags`, `hardware_errors`, `errors`, Unix + `sampled_at`, and a raw `position_range` with `min`/`max`. The editor requests + samples only while the calibration panel is visible and idle. The facade + serializes requests, rejects stale or mismatched feedback, and blocks this path + while a motion session owns the bus. It preserves measured values with hardware + warnings; calibration capture and arming keep their stricter health checks. +- Per-actuator setup can expose `build_test_context(config, state, row)` on the + same provider. It converts normalized raw Min/Home/Max points into the standard + joint-motion profile and calibration context, including `degrees_per_tick`. + The facade sorts the two captured endpoints and uses an interior Home or a + calculated midpoint as the test origin, retaining all original captures in + storage. The Arm button sends `operator_action: arm-test` and + `save_calibration: true`; the facade validates hardware and current position, + persists the range, then arms. Hold accepts the full captured range; commanded + targets retain the inset limits at the driver boundary. + Legacy callers still require a prior save. + The robot facade persists points and named measured poses by physical USB + identity/provider/servo ID. Its managed test uses the existing motion service, + fresh feedback and a three-second UI lease. Raw + register conversion and model/mode validation stay in the provider. + New slider targets wake the controller immediately and replace pending targets. + Target acknowledgements and UI status may reuse + the worker's feedback for at most 250 ms; motion retains fresh driver checks. - Servo motion discovers `_bn_robot_joint_motion_provider` registrations and opens only the package/component selected by the profile's `joint_group`, `calibration_control`, or `position_feedback` binding. A session supplies @@ -220,6 +267,16 @@ Use the resolver already defined by the capability: and `close()`. `hold()` seeds every configured joint from current feedback before torque; `command()` verifies freshness, torque, and hardware health at the physical driver boundary. +- Providers with `supports_position_targets: true` also implement + `command_position_target(positions_deg, max_velocity_deg_s=..., deadline=...)`. + An armed context can select `position_target_mode: true`; the gateway checks + this provider capability before holding torque. It sends the latest calibrated + destination directly through motion arbitration, with a finite positive speed + ceiling enforced by the provider's position controller. The regular `command` + path retains software velocity limiting. Feetech setup uses a 180°/s ceiling, + validates model/mode, writes acceleration/goal/time/speed together, and checks + goal and speed readback. Its hardware warnings, stale-data and release paths + apply to both command modes. The mock provider supplies the same target method. - Managed attachments synchronize the selected package, build a process descriptor from provider configuration, and ask Runtime to start or reuse it. - Existing-topic providers remain read-only and use ROS interface checks as diff --git a/editor-server/server.py b/editor-server/server.py index c83a303..30db7f1 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -2969,12 +2969,123 @@ def depth_frame(node_id: str): ) +def _actuator_setup_parent(node_id: str) -> str: + meta = _session.node_meta[node_id] + if meta["type"] == "ActuatorSetup": + return node_id + parents = [edge["from"] for edge in _session.graph._edges + if edge["to"] == node_id and edge["to_port"] == "bus" + and edge["from_port"] == "bus" + and _session.node_meta.get(edge["from"], {}).get("type") == "ActuatorSetup"] + if len(parents) != 1: + raise HTTPException(409, "Connect this servo card to one USB scanner") + return parents[0] + + +def _sync_actuator_setup_cards(scanner_id: str, outputs: dict) -> dict: + scanner = _session.node_meta[scanner_id] + rows = outputs.get("actuators") or [] + children = {edge["to"]: _session.node_meta[edge["to"]] for edge in _session.graph._edges + if edge["from"] == scanner_id and edge["from_port"] == "bus" and edge["to_port"] == "bus" + and _session.node_meta.get(edge["to"], {}).get("type") == "ActuatorServoSetup"} + by_servo = {meta.get("params", {}).get("servo_id"): node_id for node_id, meta in children.items()} + node_outputs = {} + for row in rows: + servo_id = row["servo_id"] + child_id = by_servo.get(servo_id) + if child_id is None: + index = len(children) + x, y = scanner.get("pos", [80, 80]) + child = add_node(AddNodeReq(type_name="ActuatorServoSetup", + pos=[x + 470 + (index % 3) * 400, y + (index // 3) * 720], params={"servo_id": servo_id})) + child_id = child["id"] + connect(ConnectReq(from_id=scanner_id, from_port="bus", to_id=child_id, to_port="bus")) + children[child_id] = child + by_servo[servo_id] = child_id + node_outputs[child_id] = { + "actuator": row, "present": True, "scan_token": outputs.get("scan_token", ""), + "scan_count": len(rows), "scanned_at": outputs.get("scanned_at", time.time()), + "serial_port": scanner.get("params", {}).get("serial_port", ""), + "baudrate": scanner.get("params", {}).get("baudrate", 1000000), + "report": outputs.get("report", "") if outputs.get("assigned") or outputs.get("released") + else f"Servo {servo_id} responded on the last scan", + } + for child_id in children: + if child_id not in node_outputs: + node_outputs[child_id] = {"actuator": {}, "present": False, "scan_token": "", + "report": "Not detected. Check wiring and scan the USB bus again."} + node_outputs[scanner_id] = outputs + for target_id, values in node_outputs.items(): + for port, value in values.items(): + _session.graph._cache[(target_id, port)] = value + _save() + return {"graph": get_graph(), "node_outputs": node_outputs} + + @app.post("/nodes/{node_id}/control") def control_node(node_id: str, req: NodeControlReq): _require_app_permission("controls", node_id, req.action, req.payload) meta = _session.node_meta.get(node_id) if meta is None: raise HTTPException(404, "Node not found") + if meta.get("type") in {"ActuatorSetup", "ActuatorServoSetup"}: + scanner_id = _actuator_setup_parent(node_id) + scanner_meta = _session.node_meta[scanner_id] + params = dict(scanner_meta.get("params") or {}) + source_selection = dict(params) + current_graph = _session.graph + if req.action in {"calibrate", "monitor"}: + profile_id = str(meta.get("params", {}).get("profile_id") or params.get("profile_id") or "") + serial_port = str(params.get("serial_port") or "") + if not profile_id or profile_id in {"auto", "none"} or not serial_port: + raise HTTPException(409, "Select a profile and USB port first") + slug = "robot-guided-calibration" if req.action == "calibrate" else "servo-debug-monitor" + workflow = _read_workflow_file(_template_path(slug)) + robot_params = workflow["node_meta"]["robot"]["params"] + robot_params.update(profile_id=profile_id) + if req.action == "calibrate": + robot_params.update(serial_port=serial_port, port_filter=serial_port, action="check") + else: + target = next((item for item in _local_robot_monitor_targets(profile_id) + if item.get("port") == serial_port), None) + if target is None: + raise HTTPException(409, "Selected USB port is no longer connected") + robot_params.update(robot_id=target["id"], robot_name=target["name"]) + if meta.get("type") == "ActuatorServoSetup": + workflow["node_meta"] = {key: value for key, value in workflow["node_meta"].items() + if key in {"robot", "servo_1"}} + workflow["node_meta"]["servo_1"]["params"]["servo_id"] = meta["params"]["servo_id"] + workflow["edges"] = [edge for edge in workflow["edges"] + if edge["from"] in workflow["node_meta"] and edge["to"] in workflow["node_meta"]] + queue_open_workflow_tab(OpenWorkflowTabReq( + name=workflow["name"], workflow=workflow, organize=False)) + return {"ok": True, "node_id": node_id, + "outputs": {"report": f"Opening {workflow['name']} for {serial_port}"}} + setup_node = _NODE_REGISTRY.get("ActuatorSetup") + control = getattr(setup_node, "_bn_actuator_setup_control", None) + if not callable(control): + raise HTTPException(503, "Actuator setup is unavailable; reload blacknode-robot") + if meta.get("type") == "ActuatorServoSetup": + params["servo_id"] = meta.get("params", {}).get("servo_id") + outputs = dict(control(params, req.action, req.payload)) + if (current_graph is not _session.graph or _session.node_meta.get(scanner_id) is not scanner_meta + or scanner_meta.get("params", {}) != source_selection): + raise HTTPException(409, "The workflow changed during setup; return to its USB scanner and scan again") + if req.action == "assign" and outputs.get("assigned") and meta.get("type") == "ActuatorServoSetup": + for edge in list(_session.graph._edges): + other_id = edge["to"] + other = _session.node_meta.get(other_id, {}) + if (edge["from"] == scanner_id and edge["to_port"] == "bus" and other_id != node_id + and other.get("type") == "ActuatorServoSetup" + and other.get("params", {}).get("servo_id") == outputs["new_id"]): + remove_node(other_id) + meta["params"]["servo_id"] = outputs["new_id"] + _session.graph._nodes[node_id]["params"]["servo_id"] = outputs["new_id"] + if req.action == "scan" or outputs.get("assigned") or outputs.get("released"): + canvas = _sync_actuator_setup_cards(scanner_id, outputs) + # Keep the transport-only canvas snapshot out of runtime caches. + outputs = {**outputs, "setup_canvas": canvas} + return {"ok": bool(outputs.get("ok")), "node_id": node_id, "outputs": outputs} if meta.get("type") == "RobotServo": action = str(req.action or "").strip().lower() if action not in {"arm", "disarm", "joint-command", "status"}: diff --git a/editor/src/App.tsx b/editor/src/App.tsx index d779da3..93e9a42 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -17,6 +17,8 @@ import ComputeDeviceNode from './components/ComputeDeviceNode' import ROS2GraphExplorerNode from './components/ROS2GraphExplorerNode' import RobotMonitorNode from './components/RobotMonitorNode' import RobotServoNode from './components/RobotServoNode' +import ActuatorSetupNode from './components/ActuatorSetupNode' +import ActuatorServoSetupNode from './components/ActuatorServoSetupNode' import SubnetNode from './components/SubnetNode' import SubnetBreadcrumb from './components/SubnetBreadcrumb' import SubgraphInputNode from './components/SubgraphInputNode' @@ -52,6 +54,8 @@ const NODE_TYPES = { ros2graphexplorer: ROS2GraphExplorerNode, robotmonitor: RobotMonitorNode, robotservo: RobotServoNode, + actuatorsetup: ActuatorSetupNode, + actuatorservosetup: ActuatorServoSetupNode, subnetnode: SubnetNode, subnetinput: SubgraphInputNode, subnetoutput: SubgraphOutputNode, diff --git a/editor/src/components/ActuatorServoSetupNode.tsx b/editor/src/components/ActuatorServoSetupNode.tsx new file mode 100644 index 0000000..7c3b947 --- /dev/null +++ b/editor/src/components/ActuatorServoSetupNode.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react' +import { NodeResizer } from '@reactflow/node-resizer' +import { Handle, Position, type NodeProps } from 'reactflow' +import { api, type DeviceRobotProfile } from '../api' +import { useStore, type NodeData } from '../store' +import NodeFrame from './NodeFrame' +import { setupError } from './ActuatorSetupNode' +import ServoCalibrationControls, { type ServoFeedback } from './ServoCalibrationControls' +import './ActuatorSetupNode.css' + +type Actuator = { + discovery_status?: string + servo_id?: number; model?: string; model_number?: number; assignment_supported?: boolean + torque_enabled?: boolean; raw_position?: number; voltage_v?: number; temperature_c?: number + hardware_error_flags?: number; hardware_errors?: string[]; errors?: string[] + settings?: Record; settings_errors?: string[] +} +const SETTING_LABELS: Record = { + baud_rate_code: 'Baud rate code', min_position_ticks: 'Min position (ticks)', + max_position_ticks: 'Max position (ticks)', max_torque: 'Max torque (raw)', + operating_mode: 'Operating mode', eeprom_locked: 'EEPROM lock', +} + +export default function ActuatorServoSetupNode({ id, data, selected }: NodeProps) { + const controlNode = useStore(s => s.controlNode) + const updateParam = useStore(s => s.updateParam) + const nodes = useStore(s => s.nodes) + const edges = useStore(s => s.edges) + const parentId = edges.find(edge => edge.target === id && edge.targetHandle === 'bus')?.source + const parent = nodes.find(node => node.id === parentId) + const results = data.portResults || {} + const row = (results.actuator || {}) as Actuator + const unresolved = row.discovery_status === 'unreadable' + const servoId = Number(data.params?.servo_id ?? 1) + const profileId = String(data.params?.profile_id || '') + const scannedAt = Number(results.scanned_at || 0) + const present = results.present === true && results.serial_port === parent?.data.params?.serial_port + && results.baudrate === Number(parent?.data.params?.baudrate || 1000000) + const [newId, setNewId] = useState(servoId) + const [busy, setBusy] = useState(false) + const [testArmed, setTestArmed] = useState(false) + const [feedback, setFeedback] = useState(null) + const [report, setReport] = useState('') + const [profiles, setProfiles] = useState([]) + const [panel, setPanel] = useState<'id' | 'calibration' | 'settings'>('id') + useEffect(() => { setNewId(servoId) }, [servoId]) + useEffect(() => { setFeedback(null) }, [servoId, scannedAt]) + const measuredPosition = feedback ? (feedback.live ? feedback.raw_position : undefined) : row.raw_position + const measuredTorque = feedback ? (feedback.live ? feedback.torque_enabled : undefined) : row.torque_enabled + const feedbackSource = feedback?.live ? 'live' : feedback ? 'waiting' : 'scan' + const canWrite = Boolean(parentId && parent?.data.params?.serial_port) && !busy && !testArmed + const canAssign = canWrite && Number.isInteger(newId) && newId >= 1 && newId <= 253 + const idBlocked = testArmed ? 'Stop the motion test before changing the ID.' + : !parentId || !parent?.data.params?.serial_port ? 'Select the USB port on the scanner.' + : !Number.isInteger(newId) || newId < 1 || newId > 253 ? 'Enter an ID from 1 to 253.' : '' + + const act = async (action: string) => { + setBusy(true) + if (action === 'assign') setReport('Checking the connected actuator and setting its ID…') + try { + const result = await controlNode(id, action, { + confirm_read_only: true, operator_action: action, new_id: newId, + }) + setReport(String(result.outputs.report || '')) + } catch (error) { setReport(setupError(error)) } + finally { setBusy(false) } + } + return + +
{unresolved ? 'Unresolved ID' : 'Servo'} {servoId} + {present ? `${unresolved ? 'Unreadable reply · physical count unknown' : row.model || 'Unknown model'} · ${results.serial_port}` : 'Not detected · scan the USB bus'}
+
event.stopPropagation()}> +
+ Position · {feedbackSource}{present ? measuredPosition ?? 'Unknown' : '—'} ticks + Torque · {feedbackSource}{!present || measuredTorque == null ? 'Unknown' : measuredTorque ? 'On' : 'Off'} + Voltage{present ? row.voltage_v ?? '—' : '—'} V + Temperature{present ? row.temperature_c ?? '—' : '—'} °C +
+ {present && Boolean(row.hardware_error_flags || row.errors?.length) &&
+ {[...(row.hardware_errors || []), ...(row.errors || [])].join('; ') || `Hardware status ${row.hardware_error_flags}`} +
} +
+ + + +
+ {panel === 'calibration' && } + {panel === 'id' &&
+ Servo ID + +

Keep only this actuator connected and supported. Changing its ID resets saved calibration for this assembly.

+ + The button scans, checks torque is off, writes the ID and verifies it. + {present && row.torque_enabled !== false && } + {idBlocked && {idBlocked}} +
} + {panel === 'settings' && <>
Hardware settings · read only + {present ? <>Model number: {row.model_number ?? 'Unknown'} +
{Object.entries(row.settings || {}).map(([key, value]) =>
{SETTING_LABELS[key] || key}
{value}
)}
+ {(row.settings_errors || []).map((error, index) => {error})} + Register limits are not a calibrated safe motion range. + :

Scan the bus to read this servo’s settings.

} +
+
{ + if (event.currentTarget.open && !profiles.length) void api.listRobotMonitorTargets('none') + .then(result => setProfiles(result.profiles || [])).catch(error => setReport(setupError(error))) + }}>Whole-robot calibration +

Reconnect the complete robot. Select its profile to record calibration and test within safe limits.

+ +
+
+
} + + {Boolean(report || results.report) &&
{report || (unresolved ? 'Unresolved address. Other discovered servos remain available.' : String(results.report))}
} + {present && Last scan: {new Date(scannedAt * 1000).toLocaleTimeString()} · snapshot} +
+
Select this node, then drag a corner to resize ↘
+ + +
+} diff --git a/editor/src/components/ActuatorSetupNode.css b/editor/src/components/ActuatorSetupNode.css new file mode 100644 index 0000000..c2c2c0d --- /dev/null +++ b/editor/src/components/ActuatorSetupNode.css @@ -0,0 +1,38 @@ +.bn-actuator-title { padding: 18px 20px; display: grid; gap: 6px; border-bottom: 1px solid var(--line2); flex-shrink: 0; } +.bn-actuator-title strong { font-size: 20px; } +.bn-actuator-title span { color: #5eead4; font-size: 12px; } +.bn-actuator-setup { padding: 16px; display: grid; gap: 14px; flex: 1; min-height: 0; overflow: auto; align-content: start; box-sizing: border-box; } +.bn-actuator-setup fieldset { border: 1px solid var(--line2); border-radius: var(--bn-node-inner-radius, 6px); padding: 14px; display: grid; gap: 12px; min-width: 0; } +.bn-actuator-setup legend { padding: 0 6px; color: #5eead4; font-weight: 600; } +.bn-actuator-setup label { display: grid; grid-template-columns: 120px 1fr; align-items: center; gap: 10px; font-size: 12px; } +.bn-actuator-setup select { width: 100%; min-width: 0; padding: 8px; background: var(--bg); color: var(--tx1); border: 1px solid var(--line2); border-radius: var(--bn-node-inner-radius, 6px); } +.bn-actuator-setup p { margin: 0; font-size: 12px; line-height: 1.6; color: var(--tx2); } +.bn-actuator-setup small { display: block; font-size: 11px; line-height: 1.5; color: var(--tx2); } +.bn-actuator-setup .bn-actuator-confirm { grid-template-columns: 18px 1fr; line-height: 1.5; } +.bn-actuator-setup button { padding: 9px 12px; background: #134e4a; border: 1px solid #2c7972; border-radius: var(--bn-node-inner-radius, 6px); color: #ccfbf1; cursor: pointer; font-size: 12px; } +.bn-actuator-setup button:disabled { opacity: .45; cursor: default; } +.bn-actuator-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.bn-actuator-results { max-height: 250px; overflow: auto; } +.bn-actuator-results table { width: 100%; border-collapse: collapse; font-size: 12px; } +.bn-actuator-results th, .bn-actuator-results td { text-align: left; padding: 9px 6px; border-bottom: 1px solid var(--line2); } +.bn-actuator-report { padding: 12px; border-left: 3px solid #14b8a6; background: #14b8a60d; font-size: 12px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } +.bn-actuator-setup input[type=number] { width: 100%; box-sizing: border-box; padding: 8px; color: var(--tx1); background: var(--bg); border: 1px solid var(--line2); border-radius: var(--bn-node-inner-radius, 6px); } +.bn-actuator-setup details { font-size: 12px; border-top: 1px solid var(--line2); padding-top: 12px; } +.bn-actuator-setup details > :not(summary) { margin-top: 12px; } +.bn-actuator-setup summary { cursor: pointer; color: var(--tx2); } +.bn-actuator-setup .bn-actuator-scan { font-size: 15px; font-weight: 600; padding: 12px; } +.bn-actuator-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.bn-actuator-facts span { display: grid; gap: 5px; font-size: 11px; color: var(--tx2); } +.bn-actuator-facts strong { font-size: 16px; color: var(--tx1); } +.bn-actuator-setup dl > div { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--line2); } +.bn-actuator-setup input:not([type]), .bn-actuator-setup input[type=text] { width: 100%; min-width: 0; box-sizing: border-box; padding: 8px; background: var(--bg); color: var(--tx1); border: 1px solid var(--line2); border-radius: var(--bn-node-inner-radius, 6px); } +.bn-actuator-setup .bn-servo-test-target { display: grid; grid-template-columns: 1fr; gap: 10px; } +.bn-servo-test-target input { width: 100%; accent-color: #14b8a6; } +.bn-servo-test-target input:disabled { opacity: 1; } +.bn-actuator-resize-hint { flex-shrink: 0; padding: 8px 16px; border-top: 1px solid var(--line2); color: var(--tx2); font-size: 11px; text-align: right; } +.bn-servo-test-controls { display: grid; gap: 12px; } +.bn-servo-slider-ends { display: flex; justify-content: space-between; color: var(--tx2); font-size: 11px; } +.bn-servo-poses { order: 1; } +.bn-actuator-tabs { display: flex; gap: 5px; } +.bn-actuator-tabs button { flex: 1; padding: 8px 4px; background: var(--bg); color: var(--tx2); } +.bn-actuator-tabs button[aria-selected=true] { background: #134e4a; color: #ccfbf1; border-color: #5eead4; } diff --git a/editor/src/components/ActuatorSetupNode.test.tsx b/editor/src/components/ActuatorSetupNode.test.tsx new file mode 100644 index 0000000..01d5a5f --- /dev/null +++ b/editor/src/components/ActuatorSetupNode.test.tsx @@ -0,0 +1,130 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { NodeProps } from 'reactflow' +import type { NodeData } from '../store' +import ActuatorSetupNode from './ActuatorSetupNode' +import ActuatorServoSetupNode from './ActuatorServoSetupNode' + +const { control, update, targets, state } = vi.hoisted(() => ({ + control: vi.fn(), update: vi.fn(), targets: vi.fn(), + state: { nodes: [{ id: 'setup', data: { params: { serial_port: 'COM3', baudrate: 1000000 } } }], + edges: [{ source: 'setup', target: 'servo', targetHandle: 'bus' }] }, +})) +vi.mock('../api', () => ({ api: { controlNode: control, listRobotMonitorTargets: targets } })) +vi.mock('../store', () => ({ useStore: (selector: (s: unknown) => unknown) => selector({ ...state, controlNode: control, updateParam: update }) })) +vi.mock('reactflow', () => ({ Handle: () => null, Position: { Right: 'right', Left: 'left' } })) +vi.mock('@reactflow/node-resizer', () => ({ NodeResizer: () => null })) +vi.mock('./NodeFrame', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
})) +afterEach(cleanup) +beforeEach(() => { + vi.clearAllMocks() + state.nodes[0].data.params.serial_port = 'COM3' + targets.mockResolvedValue({ targets: [{ id: 'usb', kind: 'local_usb', port: 'COM3', hardware_id: 'physical' }], profiles: [] }) + update.mockResolvedValue(undefined) + control.mockResolvedValue({ ok: true, outputs: { report: 'Found 2 servos on COM3.' } }) +}) + +function showServo(overrides = {}) { + return render()} />) +} + +it('requires only a USB port and scans only when pressed', async () => { + render()} />) + await screen.findByRole('option', { name: 'COM3 · physical' }) + expect(control).not.toHaveBeenCalled() + expect(screen.queryByLabelText('Robot profile')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Scan' })) + await screen.findByText('Found 2 servos on COM3.') + expect(control).toHaveBeenCalledExactlyOnceWith('setup', 'scan', { confirm_read_only: true }) +}) + +it('sets the entered ID in one click with no checkbox or cached scan token', async () => { + showServo() + expect(screen.getByText('Servo 1')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '1. Set ID' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByLabelText('New ID')).toBeVisible() + fireEvent.click(screen.getByRole('tab', { name: 'Settings' })) + expect(screen.getByText('4095')).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '1. Set ID' })) + fireEvent.change(screen.getByLabelText('New ID'), { target: { value: '6' } }) + const assign = screen.getByRole('button', { name: 'Set ID' }) + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + expect(assign).toBeEnabled() + fireEvent.click(assign) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'assign', { + new_id: 6, operator_action: 'assign', confirm_read_only: true, + })) +}) + +it('uses fresh backend preflight even when an old scan showed several servos', async () => { + control.mockResolvedValue({ ok: false, outputs: { report: 'Connect exactly one actuator' } }) + showServo({ scan_count: 4 }) + fireEvent.click(screen.getByRole('button', { name: 'Set ID' })) + await screen.findByText('Connect exactly one actuator') + expect(control).toHaveBeenCalledWith('servo', 'assign', expect.objectContaining({ operator_action: 'assign' })) +}) + +it('does not disable Set ID because discovery expired and rejects invalid numbers', () => { + showServo({ scanned_at: Date.now() / 1000 - 121, scan_token: '' }) + expect(screen.getByRole('button', { name: 'Set ID' })).toBeEnabled() + fireEvent.change(screen.getByLabelText('New ID'), { target: { value: '254' } }) + expect(screen.getByRole('button', { name: 'Set ID' })).toBeDisabled() + expect(screen.getByText('Enter an ID from 1 to 253.')).toBeInTheDocument() +}) + +it('shows an unresolved address and explains a failed fresh assignment check', async () => { + control.mockResolvedValue({ ok: false, outputs: { report: 'This address is unresolved. Connect one actuator and scan until its ID and model can be read' } }) + showServo({ actuator: { servo_id: 1, discovery_status: 'unreadable', errors: ['Unreadable reply'], assignment_supported: false } }) + expect(screen.getByText('Unresolved ID 1')).toBeInTheDocument() + expect(screen.getByText(/physical count unknown/)).toBeInTheDocument() + expect(screen.getByText('Unreadable reply')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Set ID' })) + await screen.findByText(/This address is unresolved/) +}) + +it('uses live torque and position in the card after Arm, slider movement and Stop', async () => { + let ticks = 1500 + let torque = false + control.mockImplementation(async (_id, action, payload) => { + if (action === 'state-status') return { ok: true, outputs: { saved: true, + states: { points: { min: 1000, home: 2000, max: 3000 } }, test_limits: { min: 1020, home: 2000, max: 2980 } } } + if (action === 'arm-test') torque = true + if (action === 'stop-test') torque = false + if (action === 'test-target') ticks = payload.ticks + return { ok: true, outputs: { raw_position: ticks, torque_enabled: torque, + ...(action === 'read-position' ? {} : { armed: torque, target_ticks: ticks }) } } + }) + showServo() + expect(screen.getByText('Off')).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '2. Calibrate / test' })) + await screen.findByText('1500 ticks') + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + await screen.findByText('On') + expect(screen.getByText('Torque · live')).toHaveTextContent('On') + expect(screen.queryByText('Off')).not.toBeInTheDocument() + fireEvent.change(screen.getByRole('slider'), { target: { value: '1600' } }) + await screen.findByText('1600 ticks') + expect(control).toHaveBeenCalledWith('servo', 'test-target', { ticks: 1600, issued_at: expect.any(Number) }) + fireEvent.click(screen.getByRole('button', { name: 'Stop' })) + await screen.findByText('Off') + expect(screen.getByText('Torque · live')).toHaveTextContent('Off') +}) + +it('separates ID setup from calibration and explains Arm preflight requirements', async () => { + showServo() + expect(screen.queryByRole('button', { name: 'Arm' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '2. Calibrate / test' })) + expect(screen.getByRole('button', { name: 'Arm' })).toBeEnabled() + expect(screen.getByRole('note')).toHaveTextContent('Capture Min and Max before testing. Home is optional.') + expect(screen.queryByLabelText('New ID')).not.toBeInTheDocument() + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'state-status')) + fireEvent.click(screen.getByRole('tab', { name: '1. Set ID' })) + expect(screen.getByLabelText('New ID')).toBeVisible() +}) diff --git a/editor/src/components/ActuatorSetupNode.tsx b/editor/src/components/ActuatorSetupNode.tsx new file mode 100644 index 0000000..daf35e6 --- /dev/null +++ b/editor/src/components/ActuatorSetupNode.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { NodeResizer } from '@reactflow/node-resizer' +import { Handle, Position, type NodeProps } from 'reactflow' +import { api, type RobotMonitorTarget } from '../api' +import { useStore, type NodeData } from '../store' +import NodeFrame from './NodeFrame' +import './ActuatorSetupNode.css' + +export function setupError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.includes('does not expose direct controls') + ? 'Restart Blacknode once to activate the new setup controls.' : message +} + +export default function ActuatorSetupNode({ id, data, selected }: NodeProps) { + const updateParam = useStore(s => s.updateParam) + const controlNode = useStore(s => s.controlNode) + const [ports, setPorts] = useState([]) + const [busy, setBusy] = useState(false) + const [report, setReport] = useState('Pick your USB adapter and press Scan.') + const port = String(data.params?.serial_port || '') + const baudrate = Number(data.params?.baudrate || 1000000) + const refreshPorts = async () => { + try { + const result = await api.listRobotMonitorTargets('none') + setPorts(result.targets.filter(target => target.kind === 'local_usb')) + } catch (error) { setReport(setupError(error)) } + } + useEffect(() => { void refreshPorts() }, []) + + const configure = async (key: string, value: unknown) => { + setBusy(true) + try { + await updateParam(id, key, value) + if (data.params?.profile_id) await updateParam(id, 'profile_id', '') + setReport('Press Scan to discover the servos on this adapter.') + } catch (error) { setReport(setupError(error)) } + finally { setBusy(false) } + } + const scan = async () => { + setBusy(true) + setReport('Scanning IDs 0–253. Servo cards will appear when the scan finishes…') + try { + if (data.params?.profile_id) await updateParam(id, 'profile_id', '') + const result = await controlNode(id, 'scan', { confirm_read_only: true }) + setReport(String(result.outputs.report || '')) + if (result.ok) window.requestAnimationFrame(() => window.dispatchEvent(new Event('blacknode:fit-view'))) + } catch (error) { setReport(setupError(error)) } + finally { setBusy(false) } + } + return + +
Actuator SetupUSB → Scan → Servo cards
+
event.stopPropagation()}> + + +
{report}
+

Each responding servo appears as a separate node. Scanning reads settings and preserves torque.

+
Advanced + + +

If the port is busy, close other monitors and stop sessions using this adapter.

+
+
+
Select this node, then drag a corner to resize ↘
+ +
+} diff --git a/editor/src/components/ServoCalibrationControls.test.tsx b/editor/src/components/ServoCalibrationControls.test.tsx new file mode 100644 index 0000000..3b4051b --- /dev/null +++ b/editor/src/components/ServoCalibrationControls.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import ServoCalibrationControls from './ServoCalibrationControls' + +const { control } = vi.hoisted(() => ({ control: vi.fn() })) +vi.mock('../api', () => ({ api: { controlNode: control } })) +vi.mock('./ActuatorSetupNode', () => ({ setupError: (error: unknown) => String(error) })) +afterEach(cleanup) +const saved = { states: { points: { min: 1000, home: 2000, max: 3000 }, poses: { Ready: 2100 } }, + saved: true, test_limits: { min: 1000, home: 2000, max: 3000 } } +beforeEach(() => { + vi.clearAllMocks() + control.mockImplementation(async (_id, action) => ({ ok: true, outputs: + action === 'state-status' ? saved : action === 'read-position' ? { raw_position: 2000, torque_enabled: false } + : action === 'arm-test' ? { armed: true, target_ticks: 2000 } + : action === 'test-target' ? { armed: true, target_ticks: 2001, raw_position: 2001 } + : action === 'stop-test' ? { armed: false } : { ...saved, report: 'Saved successfully' } })) +}) +function show() { + return render( undefined} />) +} + +it('loads saved states and reads position without motion before explicit arm', async () => { + show() + await screen.findByText('Capture Home · 2000') + expect(control.mock.calls.map(call => call[1])).toEqual(['state-status', 'read-position']) + const slider = screen.getByRole('slider', { name: 'Test position' }) + expect(slider).toHaveAttribute('min', '1000') + expect(slider).toHaveAttribute('max', '3000') + fireEvent.change(screen.getByLabelText('Saved pose'), { target: { value: 'Ready' } }) + fireEvent.pointerUp(slider) + expect(control.mock.calls.map(call => call[1])).toEqual(['state-status', 'read-position']) + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Arm' })).toBeEnabled() +}) + +it('follows hand movement before calibration and stops reading when closed', async () => { + let ticks = 500 + control.mockImplementation(async (_id, action) => ({ ok: true, outputs: action === 'state-status' + ? { saved: false, states: { points: {} }, test_limits: {} } + : { raw_position: ticks, torque_enabled: false, position_range: { min: 0, max: 4095 } } })) + const view = show() + await screen.findByText('Measured position · 500 ticks') + ticks = 3500 + await screen.findByText('Measured position · 3500 ticks') + expect(screen.getByRole('slider')).toHaveValue('3500') + expect(control.mock.calls.every(call => ['state-status', 'read-position'].includes(call[1]))).toBe(true) + view.unmount() + const count = control.mock.calls.length + await new Promise(resolve => setTimeout(resolve, 300)) + expect(control).toHaveBeenCalledTimes(count) +}) + +it('sends a fresh bounded slider command after arm and stops on unmount', async () => { + const view = show() + await screen.findByText('Capture Home · 2000') + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + await screen.findByText(/Armed · drag the slider/) + expect(control).toHaveBeenCalledWith('servo', 'arm-test', { operator_action: 'arm-test', save_calibration: true }) + const slider = screen.getByRole('slider', { name: 'Test position' }) + fireEvent.change(slider, { target: { value: '2001' } }) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'test-target', { + ticks: 2001, issued_at: expect.any(Number), + })) + expect(screen.getByRole('button', { name: 'Capture Min · 1000' })).toBeDisabled() + view.unmount() + expect(control).toHaveBeenCalledWith('servo', 'stop-test') +}) + +it('allows Arm to accept captured endpoints without a separate Save click', async () => { + control.mockImplementation(async (_id, action) => ({ ok: true, outputs: + action === 'state-status' ? { ...saved, saved: false, states: { points: { min: 3000, max: 1000 }, dirty: true } } + : action === 'read-position' ? { raw_position: 2000, torque_enabled: false } + : { armed: true, saved: true, target_ticks: 2000 } })) + show() + await screen.findByText('Capture Min · 3000') + expect(screen.getByRole('slider')).toHaveStyle({ direction: 'rtl' }) + expect(screen.getByText('Min · 3000')).toBeInTheDocument() + expect(screen.getByText('Max · 1000')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Arm' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + await screen.findByText(/Armed · drag the slider/) + expect(control).toHaveBeenCalledWith('servo', 'arm-test', { operator_action: 'arm-test', save_calibration: true }) + expect(control.mock.calls.some(call => call[1] === 'save-states')).toBe(false) +}) + +it('shows an Arm hardware failure beside the controls', async () => { + control.mockImplementation(async (_id, action) => action === 'arm-test' + ? { ok: false, outputs: { ok: false, armed: false, report: 'USB port is busy' } } + : { ok: true, outputs: action === 'state-status' ? saved : { raw_position: 1000, torque_enabled: false } }) + show() + await screen.findByText('Measured position · 1000 ticks') + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + expect(await screen.findByRole('alert')).toHaveTextContent('USB port is busy') + expect(screen.getByRole('button', { name: 'Arm' })).toBeEnabled() +}) + +it('holds on Arm and sends both exact captured endpoints', async () => { + control.mockImplementation(async (_id, action) => ({ ok: true, outputs: action === 'state-status' ? saved + : action === 'read-position' ? { raw_position: 1000, torque_enabled: false } + : { armed: true, raw_position: 1000, target_ticks: 1000 } })) + show() + await screen.findByText('Measured position · 1000 ticks') + expect(screen.queryByRole('note')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + await screen.findByText(/Armed · drag the slider/) + const slider = screen.getByRole('slider') + expect(slider).toHaveValue('1000') + for (const ticks of [3000, 1000]) { + fireEvent.change(slider, { target: { value: String(ticks) } }) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'test-target', { ticks, issued_at: expect.any(Number) })) + expect(slider).toHaveValue(String(ticks)) + } +}) + +it('captures calibration or named pose and saves states explicitly', async () => { + show() + await screen.findByText('Capture Home · 2000') + fireEvent.click(screen.getByRole('button', { name: 'Capture Min · 1000' })) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'capture-state', { point: 'min' })) + fireEvent.change(screen.getByLabelText('Pose name'), { target: { value: 'Ready' } }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Capture pose' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Capture pose' })) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'capture-state', { point: 'pose', name: 'Ready' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save states' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Save states' })) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'save-states', {})) +}) + +it('stops an arm response that arrives after the card was closed', async () => { + let finish: ((value: unknown) => void) | undefined + control.mockImplementation(async (_id, action) => action === 'arm-test' + ? new Promise(resolve => { finish = resolve }) : { ok: true, outputs: saved }) + const view = show() + await screen.findByText('Capture Home · 2000') + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + view.unmount() + control.mockClear() + finish?.({ ok: true, outputs: { armed: true } }) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'stop-test')) +}) + +it('keeps dragging responsive and sends only the newest waiting target', async () => { + let finish: ((value: unknown) => void) | undefined + let commands = 0 + control.mockImplementation(async (_id, action) => { + if (action === 'state-status') return { ok: true, outputs: saved } + if (action === 'test-target' && commands++ === 0) return new Promise(resolve => { finish = resolve }) + if (action === 'test-status') return { ok: true, outputs: { armed: true, target_ticks: 2001 } } + return { ok: true, outputs: { armed: action !== 'stop-test' } } + }) + show() + await screen.findByText('Capture Home · 2000') + fireEvent.click(screen.getByRole('button', { name: 'Arm' })) + await screen.findByText(/Armed · drag the slider/) + const slider = screen.getByRole('slider', { name: 'Test position' }) + for (const value of ['2001', '2010', '2020']) fireEvent.change(slider, { target: { value } }) + expect(slider).toBeEnabled() + expect(control.mock.calls.filter(call => call[1] === 'test-target')).toHaveLength(1) + await waitFor(() => expect(control).toHaveBeenCalledWith('servo', 'test-status')) + expect(slider).toHaveValue('2020') + finish?.({ ok: true, outputs: { armed: true, target_ticks: 2001 } }) + await waitFor(() => expect(control.mock.calls.filter(call => call[1] === 'test-target')).toHaveLength(2)) + expect(control.mock.calls.filter(call => call[1] === 'test-target').map(call => call[2].ticks)).toEqual([2001, 2020]) + expect(slider).toHaveValue('2020') +}) diff --git a/editor/src/components/ServoCalibrationControls.tsx b/editor/src/components/ServoCalibrationControls.tsx new file mode 100644 index 0000000..670872f --- /dev/null +++ b/editor/src/components/ServoCalibrationControls.tsx @@ -0,0 +1,271 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { setupError } from './ActuatorSetupNode' + +type SavedStates = { points?: Record; poses?: Record; dirty?: boolean } +type Limits = { min: number; home: number; max: number } +export type ServoFeedback = { live: boolean; raw_position?: number; torque_enabled?: boolean } + +export default function ServoCalibrationControls({ id, selection, enabled, position, onArmedChange, onFeedback }: { + id: string; selection: string; enabled: boolean; position?: number + onArmedChange: (armed: boolean) => void + onFeedback?: (feedback: ServoFeedback) => void +}) { + const [states, setStates] = useState({}) + const [limits, setLimits] = useState(null) + const [saved, setSaved] = useState(false) + const [armed, setArmed] = useState(false) + const [busy, setBusy] = useState(false) + const [target, setTarget] = useState(position ?? 2048) + const [actual, setActual] = useState(position) + const [torque, setTorque] = useState() + const [feedbackLive, setFeedbackLive] = useState(false) + const [rawRange, setRawRange] = useState({ min: 0, max: 4095 }) + const [feedback, setFeedback] = useState('Reading live position…') + const [poseName, setPoseName] = useState('') + const [report, setReport] = useState('') + const live = useRef({ generation: 0, armed: false, pendingArm: false }) + const motionQueue = useRef({ running: false, pending: undefined as { ticks: number; issued_at: number } | undefined }) + + const apply = (outputs: Record, updateTarget = true) => { + if (outputs.states) setStates(outputs.states as SavedStates) + if (outputs.test_limits) { + const next = outputs.test_limits as Partial + setLimits(typeof next.min === 'number' && typeof next.home === 'number' && typeof next.max === 'number' ? next as Limits : null) + } + if (typeof outputs.saved === 'boolean') setSaved(outputs.saved) + if (typeof outputs.raw_position === 'number') { setActual(outputs.raw_position); setFeedbackLive(true) } + if (typeof outputs.torque_enabled === 'boolean') setTorque(outputs.torque_enabled) + else if (outputs.armed === false) { setTorque(undefined); setFeedbackLive(false) } + if (updateTarget && typeof outputs.target_ticks === 'number') setTarget(outputs.target_ticks) + if (typeof outputs.armed === 'boolean') { + live.current.armed = outputs.armed + setArmed(outputs.armed); onArmedChange(outputs.armed) + } + if (outputs.ok === false && outputs.report) setReport(String(outputs.report)) + } + const action = async (name: string, payload: Record = {}) => { + if (name === 'stop-test') { + motionQueue.current.pending = undefined + ++live.current.generation + live.current.armed = false; setArmed(false); onArmedChange(false) + } + const generation = live.current.generation + setReport('') + setBusy(true) + if (name === 'arm-test') live.current.pendingArm = true + try { + const result = await api.controlNode(id, name, payload) + if (generation !== live.current.generation) { + if (result.outputs.armed) await api.controlNode(id, 'stop-test') + return + } + apply(result.outputs) + if (!result.ok) setReport(String(result.outputs.report || 'Action failed')) + } catch (error) { + if (generation === live.current.generation) { + setTorque(undefined); setFeedbackLive(false) + setReport(setupError(error)); setArmed(false); live.current.armed = false; onArmedChange(false) + } + if (name === 'arm-test' || name === 'test-target') void api.controlNode(id, 'stop-test').catch(() => undefined) + } finally { + live.current.pendingArm = false + if (generation === live.current.generation) setBusy(false) + } + } + + const queueMove = (ticks: number) => { + if (!live.current.armed) return + motionQueue.current.pending = { ticks, issued_at: Date.now() / 1000 } + if (motionQueue.current.running) return + motionQueue.current.running = true + const generation = live.current.generation + void (async () => { + try { + while (motionQueue.current.pending && live.current.armed && generation === live.current.generation) { + const command = motionQueue.current.pending + motionQueue.current.pending = undefined + const result = await api.controlNode(id, 'test-target', command) + if (generation !== live.current.generation) return + apply(result.outputs, false) + if (!result.ok || result.outputs.armed !== true) { + motionQueue.current.pending = undefined + break + } + } + } catch (error) { + if (generation === live.current.generation) { + setTorque(undefined); setFeedbackLive(false) + motionQueue.current.pending = undefined + setReport(setupError(error)); live.current.armed = false; setArmed(false); onArmedChange(false) + void api.controlNode(id, 'stop-test').catch(() => undefined) + } + } finally { + motionQueue.current.running = false + const pending = motionQueue.current.pending + if (pending && live.current.armed) queueMove(pending.ticks) + } + })() + } + + useEffect(() => { + const generation = ++live.current.generation + motionQueue.current.pending = undefined + setStates({}); setLimits(null); setSaved(false); setArmed(false) + setTorque(undefined); setFeedbackLive(false) + live.current.armed = false; setActual(position); setTarget(position ?? 2048); setBusy(false) + if (enabled) void api.controlNode(id, 'state-status').then(result => { + if (live.current.generation === generation) apply(result.outputs) + }).catch(error => { if (live.current.generation === generation) setReport(setupError(error)) }) + const stop = () => { + motionQueue.current.pending = undefined + if (live.current.armed || live.current.pendingArm) { + ++live.current.generation + live.current.armed = false; setArmed(false); onArmedChange(false) + void api.controlNode(id, 'stop-test').catch(() => undefined) + } + } + const hidden = () => { if (document.hidden) stop() } + window.addEventListener('pagehide', stop) + document.addEventListener('visibilitychange', hidden) + return () => { + ++live.current.generation + stop() + window.removeEventListener('pagehide', stop) + document.removeEventListener('visibilitychange', hidden) + } + }, [id, selection, enabled]) + + useEffect(() => { + if (!enabled || armed || busy) return + let cancelled = false + let running = false + let timer: ReturnType + let staleTimer: ReturnType + const poll = async () => { + if (cancelled || document.hidden || running || live.current.pendingArm || live.current.armed) return + running = true + staleTimer = setTimeout(() => { + if (!cancelled) { setActual(undefined); setTorque(undefined); setFeedbackLive(false); setFeedback('Position unavailable · waiting for fresh feedback') } + }, 1000) + try { + const result = await api.controlNode(id, 'read-position', { confirm_read_only: true }) + if (cancelled || document.hidden || live.current.pendingArm || live.current.armed) return + if (!result.ok || typeof result.outputs.raw_position !== 'number') { + throw new Error(String(result.outputs.report || 'Live position unavailable')) + } + if (typeof result.outputs.sampled_at === 'number' && Date.now() / 1000 - result.outputs.sampled_at > 1.5) { + throw new Error('Position feedback is stale') + } + setActual(result.outputs.raw_position) + setTorque(typeof result.outputs.torque_enabled === 'boolean' ? result.outputs.torque_enabled : undefined) + setFeedbackLive(true) + const range = result.outputs.position_range as { min?: number; max?: number } | undefined + if (typeof range?.min === 'number' && typeof range.max === 'number' && range.min < range.max) { + setRawRange({ min: range.min, max: range.max }) + } + setFeedback(result.outputs.report ? `Hardware warning: ${result.outputs.report}` + : result.outputs.torque_enabled === false ? 'Live · move the motor by hand; the slider follows.' + : 'Torque is on or unknown. Release torque before moving the motor by hand.') + } catch (error) { + if (!cancelled) { setActual(undefined); setTorque(undefined); setFeedbackLive(false); setFeedback(`Position unavailable · ${setupError(error)}`) } + } finally { + clearTimeout(staleTimer) + running = false + if (!cancelled && !document.hidden) timer = setTimeout(() => void poll(), 250) + } + } + const visibility = () => { + clearTimeout(timer) + if (document.hidden) { setActual(undefined); setTorque(undefined); setFeedbackLive(false); setFeedback('Live position paused') } + else void poll() + } + setFeedback('Reading live position…') + void poll() + document.addEventListener('visibilitychange', visibility) + return () => { cancelled = true; clearTimeout(timer); clearTimeout(staleTimer); document.removeEventListener('visibilitychange', visibility) } + }, [id, selection, enabled, armed, busy]) + + useEffect(() => { + if (!armed) return + let cancelled = false + let timer: ReturnType + const poll = async () => { + const generation = live.current.generation + try { + const result = await api.controlNode(id, 'test-status') + if (!cancelled && generation === live.current.generation) apply(result.outputs, false) + } catch (error) { + if (!cancelled && generation === live.current.generation) { + setTorque(undefined); setFeedbackLive(false) + setReport(setupError(error)); live.current.armed = false; setArmed(false); onArmedChange(false) + void api.controlNode(id, 'stop-test').catch(() => undefined) + } + } + if (!cancelled && live.current.armed) timer = setTimeout(() => void poll(), 100) + } + timer = setTimeout(() => void poll(), 100) + return () => { cancelled = true; clearTimeout(timer) } + }, [id, armed]) + + useEffect(() => { + onFeedback?.({ live: feedbackLive && enabled && !busy, raw_position: actual, torque_enabled: torque }) + }, [actual, torque, feedbackLive, enabled, busy, onFeedback]) + useEffect(() => () => onFeedback?.({ live: false }), [onFeedback]) + + const move = () => { + if (armed && !busy) queueMove(target) + } + const armBlocked = !enabled ? 'Scan for a readable, supported servo before calibration or testing.' + : !limits ? (['min', 'max'].every(point => states.points?.[point] != null) + ? 'Captured endpoints are too close. Recapture a wider comfortable range.' + : 'Capture Min and Max before testing. Home is optional.') + : '' + const visibleReport = report + const reversed = (states.points?.min ?? 0) > (states.points?.max ?? 4095) + const capturedMin = states.points?.min + const capturedMax = states.points?.max + const sliderMin = limits && capturedMin != null && capturedMax != null ? Math.min(capturedMin, capturedMax) : rawRange.min + const sliderMax = limits && capturedMin != null && capturedMax != null ? Math.max(capturedMin, capturedMax) : rawRange.max + return
+ Calibration and test +

Support the robot. Capture with torque off; Arm saves the range and enables torque.

+
{(['min', 'home', 'max'] as const).map(point => )} + +
+ {saved ? 'Saved' : states.dirty ? 'Captured · Arm to save and test' : 'Capture Min and Max'} +
Named poses + +
+ +
+ + +
+
+ {limits &&
Min · {reversed ? sliderMax : sliderMin}Max · {reversed ? sliderMin : sliderMax}
} + + {armed ? `Armed · drag the slider to move. Measured: ${actual ?? 'unknown'} ticks.` : feedback} +
+ + +
+ {(visibleReport || (!armed && armBlocked)) &&
{visibleReport || armBlocked}
} +
+
+} diff --git a/editor/src/components/WorkflowShortcuts.tsx b/editor/src/components/WorkflowShortcuts.tsx index a1d5b00..9681163 100644 --- a/editor/src/components/WorkflowShortcuts.tsx +++ b/editor/src/components/WorkflowShortcuts.tsx @@ -42,7 +42,16 @@ const WORKFLOW_SHORTCUT_COLOR_OPTIONS: Array<{ id: WorkflowShortcutColor; label: ] export const WORKFLOW_SHORTCUTS_STORAGE_KEY = 'blacknode-workflow-shortcuts' +const ACTUATOR_SETUP_SHELF_MIGRATION_KEY = 'blacknode-actuator-setup-shelf-v1' +const ACTUATOR_SETUP_SHORTCUT: WorkflowShortcut = { + id: 'actuator-setup', + label: 'Actuator Setup', + templateSlug: 'actuator-setup', + icon: 'robot', + color: 'cyan', +} export const DEFAULT_WORKFLOW_SHORTCUTS: WorkflowShortcut[] = [ + ACTUATOR_SETUP_SHORTCUT, { id: 'collect-episodes', label: 'Collect episodes', @@ -149,6 +158,19 @@ export default function WorkflowShortcuts() { useEffect(() => { try { + // Upgrade existing shelves once, preserving their other shortcuts and + // allowing the operator to customize or remove this button afterwards. + if (window.localStorage.getItem(ACTUATOR_SETUP_SHELF_MIGRATION_KEY) !== '1') { + const existingIndex = shortcuts.findIndex(shortcut => shortcut.templateSlug === 'actuator-setup') + const migrated = existingIndex === 0 ? shortcuts : [ + existingIndex >= 0 ? shortcuts[existingIndex] : { ...ACTUATOR_SETUP_SHORTCUT }, + ...shortcuts.filter((_shortcut, index) => index !== existingIndex), + ] + window.localStorage.setItem(WORKFLOW_SHORTCUTS_STORAGE_KEY, JSON.stringify(migrated)) + window.localStorage.setItem(ACTUATOR_SETUP_SHELF_MIGRATION_KEY, '1') + if (migrated !== shortcuts) setShortcuts(migrated) + return + } window.localStorage.setItem(WORKFLOW_SHORTCUTS_STORAGE_KEY, JSON.stringify(shortcuts)) } catch { // Browser privacy settings may disable local storage. Shortcuts still work diff --git a/editor/src/store.ts b/editor/src/store.ts index d74cf64..13b778b 100644 --- a/editor/src/store.ts +++ b/editor/src/store.ts @@ -363,6 +363,8 @@ function reactNodeType(typeName: string): string { if (typeName === 'ROS2GraphExplorer') return 'ros2graphexplorer' if (typeName === 'RobotMonitor') return 'robotmonitor' if (typeName === 'RobotServo') return 'robotservo' + if (typeName === 'ActuatorSetup') return 'actuatorsetup' + if (typeName === 'ActuatorServoSetup') return 'actuatorservosetup' return OUTPUT_NODE_TYPES.has(typeName) ? 'outputnode' : MODEL_NODE_TYPES.has(typeName) ? 'modelnode' : VALUE_NODE_TYPES.has(typeName) ? 'valuenode' : 'blacknode' } @@ -424,6 +426,8 @@ function makeReactNode(meta: BnNodeMeta): Node { ...(meta.type === 'ROS2GraphExplorer' ? { style: { width: 1040, height: 760 } } : {}), ...(meta.type === 'RobotMonitor' ? { style: { width: 760 } } : {}), ...(meta.type === 'RobotServo' ? { style: { width: 360 } } : {}), + ...(meta.type === 'ActuatorSetup' ? { style: { width: 380 } } : {}), + ...(meta.type === 'ActuatorServoSetup' ? { style: { width: 370 } } : {}), } } @@ -3466,7 +3470,25 @@ export const useStore = create((set, get) => ({ }, controlNode: async (id, action, payload = {}) => { + const requestTabId = get().activeTabId const result = await api.controlNode(id, action, payload) + const canvas = result.outputs.setup_canvas as { + graph: GraphSnapshot; node_outputs: Record> + } | undefined + if (canvas && get().activeTabId === requestTabId) { + set(s => { + const graph = parseGraph(canvas.graph.nodes, canvas.graph.edges) + return { + ...graph, + nodes: graph.nodes.map(node => ({ ...node, data: { ...node.data, + portResults: canvas.node_outputs[node.id] || s.nodes.find(old => old.id === node.id)?.data.portResults, + } })), + ...markActiveTabDirty(s), + } + }) + return result + } + if (get().activeTabId !== requestTabId) return result set(s => ({ nodes: propagateLiveTerminalValues(s.nodes.map(node => node.id === id ? { ...node, diff --git a/python/blacknode/package_index.py b/python/blacknode/package_index.py index 1476461..5d6fa3a 100644 --- a/python/blacknode/package_index.py +++ b/python/blacknode/package_index.py @@ -305,7 +305,8 @@ "FeetechBusConfig", "FeetechBusProbe", "FeetechCalibrationProvider", - "FeetechRawMonitorProvider" + "FeetechRawMonitorProvider", + "FeetechActuatorSetupProvider" ], "adapters": { "ros2": { @@ -339,6 +340,7 @@ "FeetechBusProbe", "FeetechCalibrationProvider", "FeetechRawMonitorProvider", + "FeetechActuatorSetupProvider", "FeetechROS2Adapter" ] }, @@ -600,6 +602,9 @@ "RobotMonitor", "RobotRawMonitor", "RobotRawMonitorMockProvider", + "ActuatorSetup", + "ActuatorServoSetup", + "RobotActuatorSetupMockProvider", "RobotROSCapabilityDiscover", "RobotROSInterfaceCheck", "RobotStream", @@ -665,6 +670,9 @@ "RobotProfileSave", "RobotRawMonitor", "RobotRawMonitorMockProvider", + "ActuatorSetup", + "ActuatorServoSetup", + "RobotActuatorSetupMockProvider", "RobotROSCapabilityDiscover", "RobotROSInterfaceCheck", "RobotServo", diff --git a/tests/test_editor_actuator_setup.py b/tests/test_editor_actuator_setup.py new file mode 100644 index 0000000..c661fce --- /dev/null +++ b/tests/test_editor_actuator_setup.py @@ -0,0 +1,165 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "editor-server")) +import server +from blacknode.graph import Graph +from blacknode.node import Bool, Dict, Int, List, Text, node + + +class ActuatorSetupEditorTests(unittest.TestCase): + def setUp(self): + self.meta = {"type": "ActuatorSetup", "params": { + "profile_id": "so_arm101", "serial_port": "COM3", "baudrate": 1000000}} + self.patchers = [ + patch.dict(server._session.node_meta, {"setup-test": self.meta}), + patch.object(server, "_require_app_permission"), + patch.object(server, "_template_path", side_effect=lambda slug: slug), + patch.object(server, "_read_workflow_file", side_effect=lambda _path: { + "name": "Robot controls", "node_meta": { + "robot": {"params": {}}, "control": {"params": {"action": "check"}}, + "servo_1": {"params": {"servo_id": 1}}, + }, "edges": [], + }), + ] + for patcher in self.patchers: + patcher.start() + self.addCleanup(patcher.stop) + + def test_controls_use_saved_selection_and_ephemeral_confirmation(self): + control = Mock(return_value={"ok": False, "report": "Connect one actuator"}) + node = Mock(_bn_actuator_setup_control=control) + payload = {"scan_token": "single-use", "confirm_isolated": True, "joint_id": "gripper"} + with patch.dict(server._NODE_REGISTRY, {"ActuatorSetup": node}): + result = server.control_node("setup-test", server.NodeControlReq(action="assign", payload=payload)) + control.assert_called_once_with(self.meta["params"], "assign", payload) + self.assertFalse(result["ok"]) + self.assertNotIn("scan_token", self.meta["params"]) + + def test_missing_package_has_actionable_error(self): + with patch.dict(server._NODE_REGISTRY, {"ActuatorSetup": object()}): + with self.assertRaises(server.HTTPException) as caught: + server.control_node("setup-test", server.NodeControlReq(action="inspect")) + self.assertEqual(caught.exception.status_code, 503) + + def test_calibration_handoff_keeps_hardware_and_remains_disarmed(self): + with patch.object(server, "queue_open_workflow_tab") as queue: + result = server.control_node("setup-test", server.NodeControlReq(action="calibrate")) + workflow = queue.call_args.args[0].workflow + params = workflow["node_meta"]["robot"]["params"] + self.assertEqual(params["profile_id"], "so_arm101") + self.assertEqual(params["serial_port"], "COM3") + self.assertEqual(params["action"], "check") + self.assertEqual(workflow["node_meta"]["control"]["params"]["action"], "check") + self.assertTrue(result["ok"]) + + def test_monitor_handoff_resolves_profile_specific_target(self): + targets = [{"id": "correct", "port": "COM3", "name": "Follower"}, + {"id": "wrong", "port": "COM4", "name": "Leader"}] + with patch.object(server, "_local_robot_monitor_targets", return_value=targets) as resolve, \ + patch.object(server, "queue_open_workflow_tab") as queue: + server.control_node("setup-test", server.NodeControlReq(action="monitor")) + resolve.assert_called_once_with("so_arm101") + workflow = queue.call_args.args[0].workflow + self.assertEqual(workflow["node_meta"]["robot"]["params"]["robot_id"], "correct") + + def test_disconnected_monitor_does_not_open_wrong_robot(self): + with patch.object(server, "_local_robot_monitor_targets", return_value=[]), \ + patch.object(server, "queue_open_workflow_tab") as queue: + with self.assertRaises(server.HTTPException): + server.control_node("setup-test", server.NodeControlReq(action="monitor")) + queue.assert_not_called() + + +class ActuatorSetupCanvasTests(unittest.TestCase): + def setUp(self): + self.session = SimpleNamespace(graph=Graph(), node_meta={}, metadata={}, entrypoint=None) + for patcher in [patch.object(server, "_session", self.session), patch.object(server, "_save"), + patch.object(server, "_require_app_permission"), patch.dict(server._NODE_REGISTRY)]: + patcher.start() + self.addCleanup(patcher.stop) + # Exercise editor routing with the package's public port contract, even + # in a core-only checkout. Provider behavior is tested by its package. + @node(name="ActuatorSetup", + inputs={"profile_id": Text(default=""), "serial_port": Text(default=""), "baudrate": Int(default=1000000)}, + outputs={"ok": Bool, "assigned": Bool, "actuators": List, "profile": Dict, "bus": Dict, "report": Text}) + def scanner(_ctx): + return {} + server._NODE_REGISTRY["ActuatorSetup"]._bn_actuator_setup_control = Mock() + + @node(name="ActuatorServoSetup", + inputs={"bus": Dict, "servo_id": Int(default=1), "profile_id": Text(default="")}, + outputs={"report": Text}) + def servo(_ctx): + return {} + + self.scanner = server.add_node(server.AddNodeReq(type_name="ActuatorSetup", + params={"serial_port": "COM3", "baudrate": 1000000}, pos=[80, 80]))["id"] + + def scan(self, *ids): + return server._sync_actuator_setup_cards(self.scanner, { + "ok": bool(ids), "actuators": [{"servo_id": value, "model": "Mock"} for value in ids], + "scan_token": "token", "scanned_at": 123, + }) + + def children(self): + return {meta["params"]["servo_id"]: key for key, meta in self.session.node_meta.items() + if meta["type"] == "ActuatorServoSetup"} + + def test_scan_creates_connected_cards_and_rescan_preserves_configuration(self): + first = self.scan(2, 3, 5) + children = self.children() + self.assertEqual(set(children), {2, 3, 5}) + self.session.node_meta[children[2]]["params"]["profile_id"] = "my_robot" + second = self.scan(2, 5, 6) + self.assertEqual(self.children()[2], children[2]) + self.assertEqual(self.session.node_meta[children[2]]["params"]["profile_id"], "my_robot") + self.assertFalse(second["node_outputs"][children[3]]["present"]) + self.assertEqual(second["node_outputs"][children[3]]["scan_token"], "") + self.assertEqual(len(self.session.graph._edges), 4) + for edge in self.session.graph._edges: + self.assertEqual((edge["from"], edge["from_port"], edge["to_port"]), (self.scanner, "bus", "bus")) + self.assertEqual(first["node_outputs"][children[2]]["actuator"]["servo_id"], 2) + self.assertNotIn("scan_token", self.session.node_meta[children[2]]["params"]) + graph = second["graph"] + report = server.validate_bn_workflow({"kind": "blacknode.workflow", "schema_version": 1, + "name": "USB actuator setup", "entrypoint": {"node_id": self.scanner, "port": "report"}, + "node_meta": {meta["id"]: meta for meta in graph["nodes"]}, "edges": graph["edges"]}) + self.assertTrue(report.ok, report.to_dict()) + + def test_child_control_uses_its_own_id_and_connected_scanner(self): + self.scan(2) + child = self.children()[2] + control = Mock(return_value={"ok": False, "report": "Isolate this actuator"}) + with patch.object(server._NODE_REGISTRY["ActuatorSetup"], "_bn_actuator_setup_control", control): + server.control_node(child, server.NodeControlReq(action="assign", payload={"new_id": 6})) + self.assertEqual(control.call_args.args[0], {"serial_port": "COM3", "baudrate": 1000000, "servo_id": 2}) + + def test_assignment_reuses_selected_card_and_removes_duplicate_destination(self): + self.scan(1, 6) + original = self.children() + control = Mock(return_value={"ok": True, "assigned": True, "new_id": 6, + "actuators": [{"servo_id": 6}]}) + with patch.object(server._NODE_REGISTRY["ActuatorSetup"], "_bn_actuator_setup_control", control): + result = server.control_node(original[1], server.NodeControlReq(action="assign")) + self.assertEqual(self.children(), {6: original[1]}) + self.assertNotIn(original[6], self.session.graph._nodes) + self.assertIn("setup_canvas", result["outputs"]) + + def test_scan_result_cannot_mutate_a_different_workflow(self): + def changed(*args): + self.session.graph = Graph() + return {"ok": True, "actuators": [{"servo_id": 1}]} + with patch.object(server._NODE_REGISTRY["ActuatorSetup"], "_bn_actuator_setup_control", changed): + with self.assertRaises(server.HTTPException) as caught: + server.control_node(self.scanner, server.NodeControlReq(action="scan")) + self.assertEqual(caught.exception.status_code, 409) + self.assertEqual(self.children(), {}) + + def test_disconnected_servo_cannot_control_any_bus(self): + child = server.add_node(server.AddNodeReq(type_name="ActuatorServoSetup", params={"servo_id": 1}))["id"] + with self.assertRaises(server.HTTPException): + server.control_node(child, server.NodeControlReq(action="scan")) diff --git a/tests/test_package_index.py b/tests/test_package_index.py index de8e63b..21dcdf4 100644 --- a/tests/test_package_index.py +++ b/tests/test_package_index.py @@ -229,12 +229,16 @@ def test_core_index_maps_official_node_types_to_git_packages(): "FeetechBusProbe", "FeetechCalibrationProvider", "FeetechRawMonitorProvider", + "FeetechActuatorSetupProvider", ] assert set(drivers["components"]) == {"feetech"} assert drivers["components"]["feetech"]["adapters"]["ros2"]["default"] is False assert payload["nodes"]["FeetechROS2Adapter"]["package"] == "blacknode-drivers" assert payload["nodes"]["FeetechBusProbe"]["package"] == "blacknode-drivers" assert payload["nodes"]["FeetechCalibrationProvider"]["package"] == "blacknode-drivers" + assert payload["nodes"]["FeetechActuatorSetupProvider"]["package"] == "blacknode-drivers" + for name in ("ActuatorSetup", "ActuatorServoSetup", "RobotActuatorSetupMockProvider"): + assert payload["nodes"][name]["package"] == "blacknode-robot" assert "CUDAKernelLab" not in payload["nodes"] assert "CUDACustomKernel" not in payload["nodes"] assert payload["nodes"]["ROS2"]["package"] == "blacknode-ros2"