Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8255a25
Add a discoverable Python skill library
Jepson2k Sep 7, 2026
de723e1
Verify taught setup against the native pose matrix
Jepson2k Sep 7, 2026
e560bc6
Check skill transforms against both native backends
Jepson2k Sep 7, 2026
9b60f07
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 7, 2026
18a51d5
Merge commit 'refs/gui-restack/50' into HEAD
Jepson2k Sep 10, 2026
8abe0c6
Simplify skill forms and preserve valid tool-selection results in pre…
Jepson2k Sep 10, 2026
cd852f2
Apply the repository formatter to skill fields
Jepson2k Sep 10, 2026
23c306a
Restore the recording program after a failed or stopped skill run
Jepson2k Sep 11, 2026
8c59fdc
Merge commit '2c2e412fd7e54cf3f7e55ded85177b8b20eb7179' into feat/pyt…
Jepson2k Sep 11, 2026
750cab7
Name the missing setup, qualify colliding skills, stamp the recorder …
Jepson2k Sep 11, 2026
9425521
Merge commit '5ec353cf8d2cea002816abf377a77994960f5ea5' into feat/pyt…
Jepson2k Sep 12, 2026
b7ae94b
Merge branch 'feat/named-setup-data' into feat/python-skill-library
claude Sep 17, 2026
f486f11
Read skill requirements off the backend in the skill library
claude Sep 17, 2026
2423b43
Select the arm's current tool in the program the skill panel runs
claude Sep 17, 2026
6628e3c
Answer a refused tool selection by its code, not by a queue wait
claude Sep 17, 2026
935970b
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 22, 2026
e3f2b5c
Show skills as a grid of diagrams, and draw a skill's path before it …
Jepson2k Sep 22, 2026
6460845
Give plugin panels their panel's full width
Jepson2k Sep 23, 2026
595c387
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 23, 2026
ace1c8c
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 23, 2026
26767cb
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 23, 2026
2432fed
Insert skills from the editor and run a selection live
Jepson2k Sep 23, 2026
4f56710
Give the editor's file tabs the room the ⋮ button takes
Jepson2k Sep 24, 2026
9c9a075
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
f2b33c9
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
23ae98d
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
feb0f0f
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
6dd2ffe
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
25c3f65
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
21926fc
Left-align the editor's ⋮ menu items
Jepson2k Sep 24, 2026
397b185
Show the editor's menu buttons' tooltips to their left
Jepson2k Sep 24, 2026
085f7f8
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 24, 2026
cd9234c
Probe the scene right of the editor in the skill dialog test
Jepson2k Sep 24, 2026
46ebff6
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 25, 2026
c3b4427
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 27, 2026
50124a6
Run selection brings only the program's own imports
Jepson2k Sep 27, 2026
7a05290
Plan skill previews in the preview worker, and drop a refused one's path
Jepson2k Sep 27, 2026
79f9a64
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 27, 2026
db933c0
Run selection keeps the imports of the block it sits in
Jepson2k Sep 27, 2026
f86684f
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Sep 27, 2026
d8c76e2
Merge branch 'feat/named-setup-data' into feat/python-skill-library
Jepson2k Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/guides/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,38 @@ an async client. Start an async program explicitly with `asyncio.run(main())`,
just as when running its Python file directly. A function definition by itself
does not execute, including in preview.

The editor's **Insert Command** menu lists the skills that take a form under
**Skills**, with their diagrams: approach, retract, park and align tool axis,
then skills from other packages. Gripper skills are not listed there; the
Gripper tab drives the gripper live and `rbt.tool` commands are in the same
menu. Choosing a skill opens its parameters where the side panels open, and
the 3D view draws its motion as a dashed path from where the arm is now,
following the values as they are filled in; the robot does not move.
**Insert** puts an import and a call at the editor's cursor, or at the
recording cursor while recording. Saved poses and setups are inserted as fixed
snapshots; saving different setup data later does not change that call. To
follow saved data on the next run, edit the Python to load it explicitly with
`load_setup`. The form does not read edited Python back into its fields.

To try a call on the robot, select its lines and choose **Run selection** from
the editor's **⋮** menu. The selection runs as its own small program with the
program's imports and the tool the arm carries, with the usual pause and stop
controls, then the editor returns to the program; after a failed run it stays
on the run's tab so its log is in view. While recording, the lines are already
in the program, so the run adds nothing to it.

| Skill | Behavior |
|---|---|
| `retract` | Move a positive distance along current tool Z. |
| `approach` | Move to positive target-tool-Z clearance, then linearly to an explicit WRF `Pose`. |
| `park` | Joint-interpolate to a named pose in an explicit `SetupSnapshot`. |
| `align_tool_axis` | Rotate one tool axis toward a WRF direction while keeping the TCP position. Returns `None` if already aligned. |
| `gripper_open`, `gripper_close` | Command the selected supported gripper and wait for completion. Native calibration requirements still apply. |

Each motion goes through the backend planner and collision checks. Approach
does not search for a detour. Gripper command completion does not confirm that
an object was grasped.

## Write and compose skills

```python
Expand Down Expand Up @@ -63,6 +95,12 @@ withdraw_twice = "mybench.skills:withdraw_twice"
`waldoctl.skills.discover_skills()` returns skills keyed by stable id. Broken
plugins are diagnosed and skipped; duplicate ids exclude all conflicting
providers. A panel may call a skill but the skill does not subclass a panel.
The decorator's `api_version` defaults to `1`. An incompatible API version is
refused before execution and shown in the panel's discovery diagnostics. For
headless discovery, pass a list as `diagnostics=` to collect the same messages.
Skill function names also appear in editor completion, with their import module.
Use ordinary Python for arguments that cannot be represented by the panel's
literal fields, such as image sources or custom resource objects.

## Preview, stepping and progress

Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ waldo-commander = "waldo_commander.main:main"

[project.entry-points."waldoctl.skills"]
retract = "waldo_commander.skills:retract"
approach = "waldo_commander.skills:approach"
park = "waldo_commander.skills:park"
align_tool_axis = "waldo_commander.skills:align_tool_axis"
gripper_open = "waldo_commander.skills:gripper_open"
gripper_close = "waldo_commander.skills:gripper_close"

[project.entry-points."waldoctl.panels"]
handeye = "waldo_commander.components.handeye_calibration:HandEyeCalibrationPanel"
Expand Down
28 changes: 28 additions & 0 deletions tests/helpers/preview_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""A skill that edits the collision world, installed by tests that need one.

It lives in its own small module because a skill preview runs in a worker
process, which imports the skill by name.
"""

from waldoctl import Box
from waldoctl.client import RobotClient
from waldoctl.skills import skill

from waldo_commander.skills import retract


@skill(id="test.fence_then_retract", version="1.0.0")
async def fence_then_retract(rbt: RobotClient) -> None:
"""Fence off a corner of the workspace, then retract."""
await rbt.set_shapes(
[
Box(
name="preview_fence",
x=0.05,
y=0.05,
z=0.05,
pose=(-0.4, -0.4, 0.05, 0, 0, 0),
)
]
)
await retract.async_call(rbt, distance_mm=2.0)
95 changes: 95 additions & 0 deletions tests/test_editor_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,101 @@ async def test_manual_inserts_follow_cursor(user: User) -> None:
assert textarea.value.splitlines()[2] == " time.sleep(1.0)"


@pytest.mark.integration
async def test_a_selection_belongs_to_the_tab_it_was_made_in(user: User) -> None:
"""Lines selected in one program must not be what an action on another
program replaces: switching tabs leaves the new tab with no selection."""
import waldoctl

from waldo_commander.state import ui_state

await user.open("/")
await wait_for_app_ready()
await enable_sim(user)
await ensure_robot_ready_for_motion()
user.find(marker="tab-program").click()
await asyncio.sleep(0)
editor = ui_state.editor_panel
assert editor is not None

first = waldoctl.commander.programs.active
assert first is not None
textarea = ui_state.active_textarea
textarea.value = "a = 1\nb = 2\nc = 3\n"
_set_selection(textarea, 2, 3)

user.find(marker="editor-new-tab-btn").click()
await asyncio.sleep(0.1)
second = waldoctl.commander.programs.active
assert second is not None and second is not first
other = ui_state.active_textarea
other.value = "x = 1\ny = 2\nz = 3\n"
await asyncio.sleep(0)

user.find(marker="editor-capture-pose").click()
await asyncio.sleep(0.1)
lines = str(other.value).split("\n")
assert lines[:3] == ["x = 1", "y = 2", "z = 3"], (
f"the other tab's selection replaced this tab's lines: {other.value!r}"
)
assert any(line.startswith("rbt.move_") for line in lines), other.value


@pytest.mark.integration
async def test_run_selection_brings_the_imports_in_its_scope(
user: User,
) -> None:
"""A selection runs beside the imports in its scope: the program's own and
those of the block it sits in, where an inserted skill puts its import.
One in another function belongs to that function, and a guarded one keeps
its guard."""
import waldoctl

from waldo_commander.components.script_execution import script_exec
from waldo_commander.state import ui_state

await user.open("/")
await wait_for_app_ready()
await enable_sim(user)
user.find(marker="tab-program").click()
await asyncio.sleep(0)
editor = ui_state.editor_panel
textarea = ui_state.active_textarea
assert editor is not None and textarea is not None
textarea.value = (
"import math\n"
"from typing import TYPE_CHECKING\n"
"from parol6 import RobotClient\n"
"if TYPE_CHECKING:\n"
" import no_such_typing_module\n"
"try:\n"
" import no_such_fast_json as json\n"
"except ImportError:\n"
" import json\n"
"\n"
"\n"
"def optional():\n"
" import no_such_module_in_a_def\n"
"\n"
"\n"
"with RobotClient() as rbt:\n"
" from math import tau\n"
" assert json.dumps(math.sqrt(4)) == '2.0' and tau > 6\n"
)
await asyncio.sleep(0)
_set_selection(textarea, 18, 18)
await asyncio.sleep(0)
user.find(marker="editor-run-selection").click()
async with asyncio.timeout(30):
while script_exec.last_exit_code is None:
await asyncio.sleep(0.05)
run = waldoctl.commander.programs.get(editor._selection_program_id or "")
assert run is not None
assert script_exec.last_exit_code == 0, "\n".join(
entry.text for entry in run.log.entries
)


@pytest.mark.integration
async def test_a_record_and_a_plan_of_different_lengths_still_build_a_scrub_bar(
user: User,
Expand Down
55 changes: 55 additions & 0 deletions tests/test_par6_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,61 @@ def _text(marker: str) -> str:
await poll_until(lambda: table.rows, bool, what="rows from the bus scan")
present = {row["node"] for row in table.rows if row["present"] == "yes"}
assert {0, 1, 2, 3, 4, 5} <= present, f"scan rows: {table.rows}"

import numpy as np
from par6 import config as par6_config
from waldo_commander.skills import gripper_open, gripper_close, retract
from waldoctl.setup import Pose
from par6._par6 import pose_matrix

mixed = Pose((0, 0, 0, 37, 25, -28))
assert mixed.matrix() == pytest.approx(
np.asarray(
pose_matrix([0, 0, 0], np.radians(mixed.values[3:]).tolist())
).reshape(4, 4)
), "shared setup rotation must match PAR6's native pose conversion"

client = waldoctl.commander.client
park = np.degrees(par6_config.config().park_pose_rad()).tolist()
await client.reset()
async with asyncio.timeout(20):
while True:
await client.teleport(park)
if await client.wait_status(
lambda s: s.homed and np.allclose(s.angles, park, atol=0.5),
timeout=0.5,
):
break
before = await client.pose()
assert before is not None
native = await client.status()
assert native is not None
assert Pose(tuple(before)).matrix()[:3, :3] == pytest.approx(
np.asarray(native.pose).reshape(4, 4)[:3, :3], abs=0.01
), "setup pose rotations must agree with the native PAR6 transform"
await retract.async_call(client, distance_mm=10, speed=0.2)
after = await client.pose()
assert after is not None
assert np.linalg.norm(np.array(after[:3]) - before[:3]) == pytest.approx(
10, abs=1.0
)
assert await client.select_tool(par6_config.fitted_tool_key()) >= 0
index = await client.tool.calibrate()
assert await client.wait_command(index, timeout=15)
await gripper_close.async_call(client)
assert await client.wait_status(
lambda s: s.tool_status is not None
and bool(s.tool_status.positions)
and s.tool_status.positions[0] > 0.9,
timeout=5,
)
await gripper_open.async_call(client)
assert await client.wait_status(
lambda s: s.tool_status is not None
and bool(s.tool_status.positions)
and s.tool_status.positions[0] < 0.1,
timeout=5,
)
finally:
# main.py never owns the spawned runtime's lifetime; the test does.
robot = getattr(ui_state, "robot", None)
Expand Down
Loading
Loading