Hand the arm to a human, from a gamepad or a browser #1
@@ -0,0 +1,58 @@
|
||||
# A pull request would otherwise run this twice on the same commit: once for the
|
||||
# branch push and once for the PR.
|
||||
|
||||
name: validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
DORA_VERSION: v1.0.1
|
||||
DORA_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the dora CLI
|
||||
run: |
|
||||
curl -fsSL -o dora-cli.tar.gz \
|
||||
"https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz"
|
||||
tar xzf dora-cli.tar.gz
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}"
|
||||
|
||||
- run: dora --version
|
||||
|
||||
# strict_types: true in the dataflow turns a type warning into a non-zero
|
||||
# exit, so a mismatched annotation fails here rather than at runtime.
|
||||
- name: Validate the dataflow
|
||||
run: dora validate dataflow.yml
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
# --locked fails if uv.lock is out of date with pyproject.toml, so a
|
||||
# dependency change that was never locked cannot reach main.
|
||||
- run: uv sync --locked --group dev
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv run pytest -q
|
||||
|
||||
markdownlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DavidAnson/markdownlint-cli2-action@v16
|
||||
with:
|
||||
globs: "*.md"
|
||||
@@ -0,0 +1,138 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding assistants when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a
|
||||
web browser, and decides which of the two is driving.
|
||||
|
||||
It is **the piece in the middle and nothing else**. It contains no robot driver — the arm
|
||||
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
|
||||
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by
|
||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
||||
Do not add either here. A teleop node that grew its own HTTP server is exactly the
|
||||
monolith `dora-ulite6` was rewritten to get out of.
|
||||
|
||||
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
|
||||
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
||||
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
|
||||
rebuild the monolith inside a node.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
uv sync # runtime deps
|
||||
uv sync --group dev # plus pytest and ruff
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts
|
||||
expected next to this one.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Two things carry the design: **what may touch the hardware**, and **what must happen when
|
||||
a source goes away**.
|
||||
|
||||
- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists
|
||||
to make: whether a velocity reaches the arm this tick, and from whom. **It never
|
||||
blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that
|
||||
is not an owner actively asking for motion returns a stop, so a case nobody thought of
|
||||
fails by halting rather than by carrying on. Keep it that way: the default has to be
|
||||
"still".
|
||||
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this
|
||||
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what
|
||||
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic.
|
||||
`Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as
|
||||
centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable.
|
||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions,
|
||||
no state, no hardware, because this is the part that has to be right and the cheapest to
|
||||
check. Output is always the arm node's units: mm/s and deg/s, base frame.
|
||||
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
|
||||
rather than one answer per input path. Ported from the monolithic repo's `RobotService`
|
||||
lease because the console's `app.js` is already built on it — it gates its jog pad on
|
||||
`t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean
|
||||
rewriting the page to match the node rather than the other way round.
|
||||
- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a
|
||||
contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`,
|
||||
`position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is
|
||||
where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is
|
||||
reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`).
|
||||
- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an
|
||||
unusable setting stops the node before it joins the dataflow instead of surfacing the
|
||||
first time somebody picks up the pad. Errors name the variable.
|
||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||
dynamic node's stdout.
|
||||
|
||||
## Rules that are not negotiable
|
||||
|
||||
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
|
||||
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
|
||||
- **E-STOP is never gated on the lease.** From either source, at any time.
|
||||
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The
|
||||
controller-side expiry is the backstop; a backstop that fires first backs up nothing.
|
||||
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out
|
||||
any timer.
|
||||
- **Do not reintroduce a web server or a robot driver here.** See above.
|
||||
|
||||
## What this node cannot do
|
||||
|
||||
The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner,
|
||||
so another node publishing `request` will preempt an operator mid-motion and nothing here
|
||||
can prevent it. Say so rather than implying otherwise.
|
||||
|
||||
`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm
|
||||
node, with no response and no error output. What this node sends, it learns nothing back
|
||||
about except through `robot_state` and telemetry.
|
||||
|
||||
## Two manifest rules worth knowing
|
||||
|
||||
`dora-node.yml` is the typed contract a consumer wires against, and the only one they get:
|
||||
no source package is shared between node repositories. `dora validate` checks a dataflow
|
||||
against it without running the node, so CI needs neither a gamepad nor a robot.
|
||||
|
||||
`jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately
|
||||
**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type
|
||||
checks on it, and several Arrow schemas legitimately flow over each. Annotating them would
|
||||
claim a contract neither side keeps.
|
||||
|
||||
## Key APIs
|
||||
|
||||
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
|
||||
iteration via `for event in node`.
|
||||
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux
|
||||
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for
|
||||
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only
|
||||
the display and joystick subsystems are initialised: `pygame.init()` also opens audio,
|
||||
which on a headless robot fills stderr with ALSA errors for a device never used. The
|
||||
display is needed despite there being no window, because SDL refreshes joystick state
|
||||
during the event pump and the pump needs it.
|
||||
|
||||
## Tools
|
||||
|
||||
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
|
||||
issues, pull requests, and other repository operations.
|
||||
|
||||
## Code style
|
||||
|
||||
- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises`
|
||||
sections as needed).
|
||||
- Run `ruff check .` and `ruff format .` before committing. CI enforces both.
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not mention AI tools in commit messages or documentation.
|
||||
@@ -1 +1,152 @@
|
||||
# dora-teleop
|
||||
|
||||
A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or
|
||||
open a browser and move it from there — one of the two at a time, decided here.
|
||||
|
||||
It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
|
||||
ordinary `jog` and `request` ports, and talks to the browser through
|
||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
||||
It contains no web server and no robot driver: it is the piece in the middle that decides
|
||||
what the arm should be doing, and that decision is the only thing it owns.
|
||||
|
||||
## The gamepad
|
||||
|
||||
Analog and blended — several axes at once, and how fast depends on how far you push. That
|
||||
is the whole reason a pad beats the web console's four-sector dial pad, which can only ask
|
||||
for one axis at a time at one fixed speed.
|
||||
|
||||
| Control | Does | Units |
|
||||
|-------------|-------------------------------------------|-------|
|
||||
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
|
||||
| Triggers | Lift on the right, lower on the left | mm/s |
|
||||
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
|
||||
| Bumpers | Yaw, left and right | deg/s |
|
||||
| A | Close the tool | — |
|
||||
| B | Open the tool | — |
|
||||
| X | Clear a controller error | — |
|
||||
| Y | Toggle freedrive | — |
|
||||
| Back | **E-STOP** | — |
|
||||
|
||||
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
|
||||
the same convention the web console's dial uses, so the two agree.
|
||||
|
||||
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
|
||||
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
|
||||
environment variable: an index nobody can verify from outside is worse than a line of code
|
||||
somebody can read.
|
||||
|
||||
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
|
||||
behind a deliberate click, not a thumb resting on a face button.
|
||||
|
||||
## The lease
|
||||
|
||||
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
||||
|
||||
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
|
||||
Nobody should have to reach for a browser to use the thing in their hands.
|
||||
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
||||
bench does not lock the browser out forever.
|
||||
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
|
||||
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
|
||||
is better placed than this code to decide they should have it.
|
||||
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
|
||||
the stop is the one moment when asking who owns the arm is the wrong question.
|
||||
|
||||
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
|
||||
Another node publishing `request` will preempt an operator mid-motion and nothing here can
|
||||
stop it — if that matters for your dataflow, do not wire one.
|
||||
|
||||
## The deadman
|
||||
|
||||
An arm under a velocity command keeps moving until told otherwise, so the question is
|
||||
never whether to start but whether anything has happened that means it must stop. Three
|
||||
layers, outermost last:
|
||||
|
||||
1. **Re-send every tick** (50 ms) while a source is driving.
|
||||
2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
|
||||
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged
|
||||
or the last browser disconnects. An unplugged pad is not a centred pad.
|
||||
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
|
||||
the controller whether or not this process is alive.
|
||||
|
||||
Keep them in that order. If this node's timer is longer than the controller's, the backstop
|
||||
fires before the thing it is backing up.
|
||||
|
||||
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
||||
controller, not in this process.
|
||||
|
||||
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
|
||||
believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
|
||||
## Ports
|
||||
|
||||
| Direction | Name | Description |
|
||||
|-----------|----------------|------------------------------------------------------|
|
||||
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
||||
| in | `ui_command` | One console message, from the bridge's `data_out` |
|
||||
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
|
||||
| in | `response` | An answer from the arm |
|
||||
| in | `motion_state` | How a motion ended |
|
||||
| in | `robot_state` | The arm's own state |
|
||||
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
|
||||
| in | `joint_pose` | Joint angles, six float32, degrees |
|
||||
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s |
|
||||
| out | `request` | A service call, `request_id` in the metadata |
|
||||
| out | `ui_state` | The console's telemetry snapshot, as JSON |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|--------------------------|---------|--------------------------------------------------------|
|
||||
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
|
||||
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
|
||||
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
|
||||
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
|
||||
| `JOYSTICK_INDEX` | `0` | Which pad to open |
|
||||
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
|
||||
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
|
||||
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
|
||||
|
||||
A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve
|
||||
stops the node at startup rather than the first time somebody picks up the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
See `dataflow.yml` for the whole stack wired together. The short version:
|
||||
|
||||
```yaml
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs: [jog, request, ui_state]
|
||||
```
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
||||
gamepad if you want one — the node starts without and says so.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that
|
||||
cannot be tested without hardware. Everything else sees a plain `Sample`.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# The whole operator stack, and what CI validates.
|
||||
#
|
||||
# Built from checkouts rather than tags, so `dora validate` resolves each node's
|
||||
# manifest from its `path:` and checks the wiring against the versions in front
|
||||
# of you. The sibling repos are expected next to this one, which is how they are
|
||||
# cloned in practice.
|
||||
#
|
||||
# Three nodes, one loop: the bridge carries the browser, this node decides who
|
||||
# drives, and the arm node moves the arm and reports back.
|
||||
|
||||
strict_types: true
|
||||
|
||||
nodes:
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
|
||||
- id: ws-transceiver
|
||||
path: ../dora-websocket-transceiver
|
||||
build: pip install .
|
||||
inputs:
|
||||
data_in: teleop/ui_state
|
||||
outputs:
|
||||
- data_out
|
||||
- clients
|
||||
env:
|
||||
WS_PORT: "9000"
|
||||
STATIC_DIR: "../dora-teleop/web"
|
||||
HTTP_PORT: "9010"
|
||||
|
||||
- id: ulite6
|
||||
path: ../dora-ulite6
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/100
|
||||
request: teleop/request
|
||||
jog: teleop/jog
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
TOOL_TYPE: "vacuum"
|
||||
# Nothing moves because the node started. An operator asks for the first
|
||||
# motion, which is the whole point of a teleop stack.
|
||||
INIT_MODE: "none"
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Node manifest: the typed contract a consumer wires against.
|
||||
#
|
||||
# No source package is shared between node repositories, so this file and its
|
||||
# type URNs are the only contract a consumer has. `dora validate` checks a
|
||||
# dataflow against it without running the node, so CI needs neither a gamepad
|
||||
# nor a robot.
|
||||
#
|
||||
# `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are
|
||||
# deliberately **untyped**. dora treats a port carrying `request_id` as
|
||||
# pattern-correlated and skips type checks on it, and several Arrow schemas
|
||||
# legitimately flow over each — one per service, one per console message.
|
||||
# Annotating them would claim a contract neither side keeps.
|
||||
|
||||
apiVersion: 1
|
||||
name: dora-teleop
|
||||
namespace: liria
|
||||
description: Hands a robot arm to a human, from a gamepad or a browser
|
||||
categories: [robot, teleop, input]
|
||||
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
|
||||
|
||||
runtime: python
|
||||
entrypoint: dora-teleop
|
||||
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
|
||||
# gives the node its own managed venv under `.dora/python-envs/`, so the
|
||||
# dependencies it sees at runtime are the ones installed here.
|
||||
build: pip install .
|
||||
dora: ">=1.0"
|
||||
|
||||
inputs:
|
||||
tick:
|
||||
description: >-
|
||||
Drives everything time-based: re-sending the held velocity, expiring a
|
||||
source that has gone quiet, and publishing the console's telemetry. Use 50
|
||||
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
|
||||
firing between ticks, which an operator feels as the arm stuttering.
|
||||
type: std/core/v1/UInt64
|
||||
ui_command:
|
||||
required: false
|
||||
description: >-
|
||||
One message from the web console, as the JSON string the WebSocket bridge
|
||||
publishes on `data_out`. Wire it from the bridge. A message this node does
|
||||
not recognise is dropped rather than refused, so a page from a newer build
|
||||
cannot take the arm down.
|
||||
clients:
|
||||
required: false
|
||||
description: >-
|
||||
Connect and disconnect events from the WebSocket bridge, as
|
||||
`{connected, event}`. **This is the browser's deadman.** Without it a tab
|
||||
closing mid-jog leaves the arm travelling until the arm node's own expiry
|
||||
catches up, because a socket that went away sends nothing to say so.
|
||||
response:
|
||||
required: false
|
||||
description: >-
|
||||
An answer from the arm node, correlated by `request_id` in the metadata. A
|
||||
failure is passed to the console as a toast; a `get_state` answer fills in
|
||||
what telemetry does not carry.
|
||||
motion_state:
|
||||
required: false
|
||||
description: >-
|
||||
How a motion ended. Needed because this node sends motions with
|
||||
`wait: false`, and a non-blocking motion reports its outcome here and
|
||||
nowhere else.
|
||||
robot_state:
|
||||
required: false
|
||||
description: >-
|
||||
The arm's own state, one of `connecting`, `ready`, `moving`, `recovering`
|
||||
or `unavailable`. Translated into the vocabulary the console draws.
|
||||
type: std/core/v1/String
|
||||
tcp_pose:
|
||||
required: false
|
||||
description: >-
|
||||
Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and
|
||||
degrees, base frame. Shown in the console's pose readout.
|
||||
type: std/core/v1/Float32
|
||||
joint_pose:
|
||||
required: false
|
||||
description: >-
|
||||
Joint angles, six float32 in degrees. Shown in the console's joint readout.
|
||||
type: std/core/v1/Float32
|
||||
|
||||
outputs:
|
||||
jog:
|
||||
description: >-
|
||||
A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}`
|
||||
in mm/s and deg/s. Re-sent every tick while a source is driving, and
|
||||
followed by exactly one all-zero vector when it stops — once, not
|
||||
repeatedly, because a stream of zeroes would keep the arm node believing a
|
||||
jog is live.
|
||||
request:
|
||||
description: >-
|
||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
||||
metadata.
|
||||
ui_state:
|
||||
description: >-
|
||||
The console's telemetry snapshot, as a JSON string for the WebSocket
|
||||
bridge to broadcast. Also carries error toasts. Every key in it is read by
|
||||
name in the console's `app.js`.
|
||||
|
||||
env:
|
||||
SPEED_MM_S:
|
||||
type: float
|
||||
default: 60.0
|
||||
description: >-
|
||||
Translation speed at full stick deflection, in mm/s. A stick is analog, so
|
||||
this is a maximum and anything less moves proportionally slower. Defaults
|
||||
to 60.0.
|
||||
SPEED_DEG_S:
|
||||
type: float
|
||||
default: 30.0
|
||||
description: Rotation speed at full deflection, in deg/s. Defaults to 30.0.
|
||||
DEADZONE:
|
||||
type: float
|
||||
default: 0.12
|
||||
description: >-
|
||||
Stick travel near centre to ignore, as a fraction. A worn stick that does
|
||||
not centre would otherwise drive the arm on its own. What is left is
|
||||
rescaled, so motion still starts from zero just outside the band.
|
||||
Defaults to 0.12.
|
||||
EXPO:
|
||||
type: float
|
||||
default: 0.4
|
||||
description: >-
|
||||
How much of the response curve is cubic, from 0 (linear) to 1. Fine
|
||||
positioning happens near centre and a linear stick gives it almost no
|
||||
resolution there. Defaults to 0.4.
|
||||
JOYSTICK_INDEX:
|
||||
type: int
|
||||
default: 0
|
||||
description: Which pad to open, when more than one is plugged in. Defaults to 0.
|
||||
JOG_TTL_S:
|
||||
type: float
|
||||
default: 0.4
|
||||
description: >-
|
||||
How long the owning source may go quiet before the arm is stopped. Keep it
|
||||
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
|
||||
is the controller-side backstop behind this one. Defaults to 0.4.
|
||||
GAMEPAD_IDLE_RELEASE_S:
|
||||
type: float
|
||||
default: 2.0
|
||||
description: >-
|
||||
How long a pad may sit untouched before it hands the arm back, so a
|
||||
browser can take over without anyone pressing anything. Defaults to 2.0.
|
||||
STATE_POLL_S:
|
||||
type: float
|
||||
default: 0.5
|
||||
description: >-
|
||||
How often to call `get_state` for what telemetry does not carry: whether
|
||||
the tool is engaged and whether freedrive is on. Defaults to 0.5.
|
||||
|
||||
example: |
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>teleop — placeholder console</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
h1 { font-size: 16px; margin: 0 0 4px; }
|
||||
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 60ch; }
|
||||
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 16px; max-width: 60ch; }
|
||||
.row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
|
||||
.row span:last-child { color: #7fd1e8; }
|
||||
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
||||
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
||||
button:hover { background: #363648; }
|
||||
button.stop { background: #5a1f27; border-color: #7d2b35; }
|
||||
#link { color: #8a8a9e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>teleop — placeholder console</h1>
|
||||
<p class="note">
|
||||
Not the real console. This page exists so the dataflow has something to serve and so the
|
||||
node can be checked end to end before the Control section is ported across from the
|
||||
monolithic repo. It reads telemetry and can take, release and stop — nothing else.
|
||||
</p>
|
||||
|
||||
<div class="card" id="state"></div>
|
||||
|
||||
<div class="card">
|
||||
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
|
||||
<button onclick="send({type:'control', action:'release'})">Release</button>
|
||||
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
|
||||
<button onclick="send({type:'reset'})">Reset</button>
|
||||
</div>
|
||||
|
||||
<div id="link"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "vacuum", "freedrive"];
|
||||
let socket = null;
|
||||
|
||||
function send(payload) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "data", payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function render(telemetry) {
|
||||
const rows = KEYS.map((key) =>
|
||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||
const pose = telemetry.position;
|
||||
if (pose) {
|
||||
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
|
||||
rows.push(`<div class="row"><span>position</span><span>${shown} mm</span></div>`);
|
||||
}
|
||||
document.getElementById("state").innerHTML = rows.join("");
|
||||
}
|
||||
|
||||
function connect() {
|
||||
// The socket is the bridge's port, not this page's — the page is served over
|
||||
// HTTP on a different one.
|
||||
const url = `ws://${location.hostname}:9000`;
|
||||
document.getElementById("link").textContent = `socket: ${url}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type !== "data" || !message.payload) return;
|
||||
if (message.payload.type === "telemetry") render(message.payload);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user