Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,21 @@ jobs:
run: |
pytest -vv --tb=long --log-cli-level=DEBUG tests/test_par6_backend.py

- name: Review the par6 browser layout
env:
PYTHONUNBUFFERED: '1'
PYTHONUTF8: '1'
WALDO_PAR6_E2E: '1'
PAR6D_BIN: ${{ github.workspace }}/par6/target/release/par6d
run: |
pytest -vv --tb=long --junitxml=test-results-par6-layout.xml tests/test_gui_layout_screen.py::test_compact_layout_par6

- name: Preserve test report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-par6-e2e
path: test-results.xml
path: |
test-results.xml
test-results-par6-layout.xml
retention-days: 7
43 changes: 43 additions & 0 deletions docs/guides/named-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,46 @@ Commander passes its directory to launched scripts and isolated previews.
Files use a versioned JSON format and atomic replacement. Corrupt or
unsupported snapshots fail explicitly. Loading or saving setup issues no
motion and does not apply robot configuration.

## TCP position calibration and orientation teaching

In **Setup → TCP**, keep the same physical tip touching one stationary point and
capture at least four poses with varied wrist orientations. Each capture reads
the referenced, stationary arm and removes any existing user TCP correction.
**Solve position** estimates the tip translation and reports RMS and maximum
sample error. A single orientation or inconsistent captures are rejected.
The position solve leaves the displayed orientation unchanged.

To teach orientation, align the physical tool with the axes of WRF or a named
setup frame, select those reference axes, and choose **Teach orientation**.
This changes only roll/pitch/yaw. Both operations use millimetres and intrinsic
XYZ degrees relative to the registered tool. You can also enter all six values
manually after **Read applied** identifies the active tool.

**Set calibration** adds the displayed values to the working setup; **Save**
persists them. Saved entries show measurement provenance and their tool/variant
binding. Saving does not configure the robot or edit the program. **Apply to
controller** explicitly queues the displayed transform, waits for completion,
and checks readback before updating the scene. A different tool or variant is
refused. A disconnected capture session discards its unsaved samples.

Programs apply saved data explicitly:

```python
from parol6 import RobotClient # or: from par6 import RobotClient
from waldo_commander.setup import load_setup

calibration = load_setup("bench").tcp_calibrations["tip"]
with RobotClient() as rbt:
# Select the matching physical tool and variant before applying its data.
index = rbt.set_tcp_transform(*calibration.values)
if not rbt.wait_command(index):
raise RuntimeError("TCP application was not confirmed")
applied = rbt.tcp_transform()
```

The legacy XYZ setter remains available and clears user rotation. Changing the
tool/variant or resetting the controller clears the applied correction. Native
FK, preview and motion share the correction; collision meshes remain attached
to the physical tool links. A calibrated tip does not replace the tool's
physical geometry model.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The **Settings** tab in the control panel provides:
- **On** — always visible as a full translucent shell.
- **Off** — hidden.
- **Camera** — select a video device for the gripper panel feed, often used for monitoring pick-and-place or running ML inference on the end-effector view. If you'd like to add annotations to the camera feed, you can do so by processing the raw webcam in your own script and outputting to a virtual camera via pyvirtualcam + v4l2loopback — then just select that virtual device here. On Linux: `sudo apt install v4l2loopback-dkms`.
- **Tool** — select the active end-effector from the tools the backend provides. See the [PAROL6 tools](https://github.com/Jepson2k/PAROL6-python-API#tools) documentation for the tools available with the default backend. Changing the tool updates the TCP offset for Cartesian calculations, swaps the tool mesh in the 3D view, and re-runs any active simulation. If a tool has variants (e.g. different jaw sets), a variant selector appears. A per-tool TCP offset field lets you fine-tune the tool tip position in mm.
- **Tool** — select the active end-effector from the tools the backend provides. See the [PAROL6 tools](https://github.com/Jepson2k/PAROL6-python-API#tools) documentation for the tools available with the default backend. Changing the tool updates the TCP offset for Cartesian calculations, swaps the tool mesh in the 3D view, and re-runs any active simulation. If a tool has variants (e.g. different jaw sets), a variant selector appears. Per-tool TCP fields let you fine-tune translation in mm and, on supported backends, intrinsic XYZ orientation in degrees. [Setup → TCP](guides/named-setup.md#tcp-position-calibration-and-orientation-teaching) provides pivot-position calibration and separate orientation teaching.

### Running on a Remote Machine

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ dependencies = [
]

[project.optional-dependencies]
parol6 = ["parol6 @ git+https://github.com/PCrnjak/PAROL6-python-API.git@v0.8.0"]
parol6 = ["parol6 @ git+https://github.com/PCrnjak/PAROL6-python-API.git@v0.9.0"]
par6 = ["par6 @ git+https://github.com/Jepson2k/par6.git@main#subdirectory=python"]
v4l2 = ["linuxpy>=0.24"]
dev = [
"parol6 @ git+https://github.com/PCrnjak/PAROL6-python-API.git@v0.8.0",
"parol6 @ git+https://github.com/PCrnjak/PAROL6-python-API.git@v0.9.0",
"ruff",
# ty 0.0.74 regressed ParamSpec-typed calls (numba's Dispatcher.__call__
# stubs): every 2-arg call of a jitted pinokin function reports
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ def pytest_runtest_makereport(item, call):
return rep
for r in records:
if r.levelname == "ERROR":
item.user_properties.append(
("error_log", f"{r.name}: {r.getMessage()}")
)
os.write(
2,
(
Expand Down
2 changes: 2 additions & 0 deletions tests/test_blend_radius.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async def test_blend_radius_setting_controls_r_in_generated_code(user: User) ->
# Raise the setting through the real settings row
user.find(kind=ui.tab, content="Settings").click()
await asyncio.sleep(0)
next(iter(user.find(marker="settings-category").elements)).set_value("Jog")
next(iter(user.find(marker="settings-jog-advanced").elements)).set_value(True)
await _set_blend_radius(user, 5)

# Insert-command palette: blended moves queue (r=5 plus wait=False — the
Expand Down
3 changes: 3 additions & 0 deletions tests/test_control_panel_jogging.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ async def jog_l_spy(frame, *args, **kwargs):

user.find(marker="tab-settings").click()
await asyncio.sleep(0)
next(iter(user.find(marker="settings-category").elements)).set_value("Jog")
frame_select = next(iter(user.find(marker="select-translation-frame").elements))
user.find(marker="tab-cartesian").click()
await asyncio.sleep(0)
Expand Down Expand Up @@ -672,6 +673,8 @@ async def test_jog_arrow_inversion_flips_button_direction_and_label(user: User)

user.find(marker="tab-settings").click()
await asyncio.sleep(0)
next(iter(user.find(marker="settings-category").elements)).set_value("Jog")
next(iter(user.find(marker="settings-jog-advanced").elements)).set_value(True)
invert_switch = next(iter(user.find(marker="switch-invert-x").elements))
try:
invert_switch.set_value(True)
Expand Down
265 changes: 265 additions & 0 deletions tests/test_gui_layout_screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Compact panels remain usable at laptop sizes and enlarged browser text."""

import json

import pytest
import waldoctl
from nicegui import Client
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from waldoctl.setup import Frame, Pose, SetupSnapshot

from tests.helpers.browser_helpers import dismiss_dialogs, run_in_app
from tests.helpers.wait import screen_wait_for_scene_ready
from tests.test_par6_backend import par6_env, requires_par6 # noqa: F401
from waldo_commander.setup import SetupStore
from waldo_commander.state import ui_state


@pytest.fixture
def layout_screen(screen):
try:
yield screen
finally:
screen.selenium.execute_cdp_cmd("Emulation.clearDeviceMetricsOverride", {})


def review_layout(screen, tmp_path, monkeypatch, backend):
monkeypatch.setenv("WALDO_SETUP_DIR", str(tmp_path / "setups"))
SetupStore().save(
"assembly",
SetupSnapshot(
frames={"fixture": Frame((25, 0, 0, 0, 0, 0))},
poses={
"pick": Pose((10, 200, 180, 90, 0, 90)),
"place": Pose((40, 200, 180, 90, 0, 90)),
},
),
)
screen.open("/")
screen_wait_for_scene_ready(screen, timeout_s=60)
dismiss_dialogs(screen)
assert run_in_app(lambda: ui_state.active_robot.name.lower()) == backend

def marked(marker):
client = Client.instances[ui_state.active_client_id]
return next(e for e in client.elements.values() if marker in e._markers)

def element(marker):
try:
identifier = run_in_app(lambda: marked(marker).id)
except (KeyError, StopIteration) as error:
raise NoSuchElementException(marker) from error
return screen.selenium.find_element(By.ID, f"c{identifier}")

def click(marker):
target = WebDriverWait(screen.selenium, 10).until(
lambda _: element(marker) if element(marker).is_displayed() else None
)
target.click()
screen.selenium.execute_cdp_cmd(
"Input.dispatchMouseEvent", {"type": "mouseMoved", "x": 600, "y": 4}
)

def settings():
def select():
client = Client.instances[ui_state.active_client_id]
with client:
from nicegui import ui

tab = next(
e
for e in client.elements.values()
if isinstance(e, ui.tab) and e._props.get("label") == "Settings"
)
tab.parent_slot.parent.set_value(tab._props["name"])

run_in_app(select)

results = []
for width, height, zoom in [
(1920, 1080, 1),
(1366, 900, 1),
(1366, 768, 1),
(1366, 768, 1.25),
]:
screen.selenium.execute_cdp_cmd(
"Emulation.setDeviceMetricsOverride",
{
"width": round(width / zoom),
"height": round(height / zoom),
"deviceScaleFactor": zoom,
"mobile": False,
},
)
settings()
WebDriverWait(screen.selenium, 10).until(
lambda _: element("settings-category").is_displayed()
)
dimensions = screen.selenium.execute_script("""
const e = document.querySelector('.settings-content');
const b = e.querySelector('.panel-body');
return {width:e.clientWidth, content:e.scrollWidth, height:b.clientHeight, body:b.scrollHeight};
""")
screen.selenium.save_screenshot(
str(tmp_path / f"{backend}-settings-{width}-{height}-{zoom}.png")
)
assert dimensions["content"] <= dimensions["width"] + 1, dimensions
assert dimensions["body"] <= dimensions["height"] + 1, dimensions
assert run_in_app(lambda: marked("settings-backend-select").value) == backend
WebDriverWait(screen.selenium, 10).until(
lambda _: run_in_app(
lambda: (
marked("select-tool").value == waldoctl.commander.status.tool.key
)
)
)
screen.selenium.save_screenshot(
str(tmp_path / f"{backend}-settings-{width}-{height}-{zoom}.png")
)

click("tab-skills")
WebDriverWait(screen.selenium, 10).until(
lambda _: element("skill-run").is_displayed()
)
WebDriverWait(screen.selenium, 10).until(
lambda d: d.execute_script(
"""
const e=document.getElementById(arguments[0]); const r=e.getBoundingClientRect();
return e.contains(document.elementFromPoint(r.x+5,r.y+5));
""",
element("skill-run").get_attribute("id"),
)
)
bounds = screen.selenium.execute_script(
"""
const e=document.getElementById(arguments[0]); const r=e.getBoundingClientRect();
return {bottom:r.bottom, height:innerHeight, visible:e.contains(document.elementFromPoint(r.x+5,r.y+5))};
""",
element("skill-run").get_attribute("id"),
)
appearance = screen.selenium.execute_script(
"const e=document.getElementById(arguments[0]); return {color:getComputedStyle(e).color, classes:e.className};",
element("skill-run").get_attribute("id"),
)
assert appearance["color"] == "rgb(125, 211, 252)", appearance
assert bounds["bottom"] <= bounds["height"], bounds
assert bounds["visible"], bounds
separation = screen.selenium.execute_script("""
const a=document.querySelector('.readout-panel').getBoundingClientRect();
const b=document.querySelector('.top-panels-container').getBoundingClientRect();
return {readout:a.left, panel:b.right};
""")
assert separation["readout"] >= separation["panel"], separation
screen.selenium.save_screenshot(
str(tmp_path / f"{backend}-skills-{width}-{height}-{zoom}.png")
)

click("tab-program")
WebDriverWait(screen.selenium, 10).until(
lambda d: d.execute_script(
"return (document.querySelector('.editor-tabs-scroll')?.clientWidth || 0) > 0"
)
)
dimensions = screen.selenium.execute_script("""
const e=document.querySelector('.editor-tabs-scroll');
return {width:e.clientWidth, total:e.closest('.q-tab-panel').clientWidth};
""")
assert dimensions["width"] >= 140, dimensions
screen.selenium.save_screenshot(
str(tmp_path / f"{backend}-program-{width}-{height}-{zoom}.png")
)
results.append(
{
"viewport": [width, height],
"zoom": zoom,
"editor": dimensions,
"skills": bounds,
}
)
(tmp_path / f"{backend}-layout.json").write_text(json.dumps(results, indent=2))

if backend == "par6":
from nicegui import ui

screen.selenium.execute_cdp_cmd(
"Emulation.setDeviceMetricsOverride",
{
"width": 1366,
"height": 900,
"deviceScaleFactor": 1,
"mobile": False,
},
)
click("tab-diagnostics")
WebDriverWait(screen.selenium, 10).until(
lambda _: element("diag-torque-chart").is_displayed()
)
WebDriverWait(screen.selenium, 10).until(
lambda _: run_in_app(
lambda: bool(marked("diag-torque-chart").options["series"][0]["data"])
)
)
_ = element("diag-expand-chart").location_once_scrolled_into_view
screen.selenium.save_screenshot(str(tmp_path / "par6-torque.png"))
click("diag-expand-chart")
WebDriverWait(screen.selenium, 10).until(
lambda d: d.find_elements(By.CSS_SELECTOR, ".q-dialog .nicegui-echart")
)
screen.selenium.save_screenshot(str(tmp_path / "par6-torque-expanded.png"))
(tmp_path / "expanded-browser.json").write_text(
json.dumps(screen.selenium.get_log("browser"), indent=2)
)
click("expanded-chart-close")
WebDriverWait(screen.selenium, 15).until(
lambda d: d.execute_script(
"return !Array.from(document.querySelectorAll('.q-dialog')).some(e => e.getClientRects().length)"
)
)

click("tab-par6-drives")

def tune():
client = Client.instances[ui_state.active_client_id]
with client:
expansion = next(
e
for e in client.elements.values()
if isinstance(e, ui.expansion) and e._props.get("label") == "Tuning"
)
expansion.set_value(True)

run_in_app(tune)
WebDriverWait(screen.selenium, 10).until(
lambda _: element("drives-save-config").is_displayed()
)
WebDriverWait(screen.selenium, 10).until(
lambda _: run_in_app(lambda: bool(marked("drives-node-select").options))
)
_ = element("drives-save-config").location_once_scrolled_into_view
geometry = screen.selenium.execute_script(
"""
const b=document.getElementById(arguments[0]); const e=b.closest('.plugin-panel-content');
const r=b.getBoundingClientRect();
return {scroll:e.scrollHeight, height:e.clientHeight, overflow:getComputedStyle(e).overflowY,
visible:b.contains(document.elementFromPoint(r.x+5,r.y+5))};
""",
element("drives-save-config").get_attribute("id"),
)
assert geometry["scroll"] > geometry["height"], geometry
assert geometry["overflow"] == "auto" and geometry["visible"], geometry
screen.selenium.save_screenshot(str(tmp_path / "par6-drives-tuning.png"))


@pytest.mark.browser
def test_compact_layout_parol6(layout_screen, tmp_path, monkeypatch):
review_layout(layout_screen, tmp_path, monkeypatch, "parol6")


@requires_par6
@pytest.mark.browser
@pytest.mark.timeout(120)
@pytest.mark.usefixtures("par6_env")
def test_compact_layout_par6(layout_screen, tmp_path, monkeypatch):
review_layout(layout_screen, tmp_path, monkeypatch, "par6")
Loading
Loading