add the node manifest, dataflow, CI and docs
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. The jog, request and ui ports stay untyped, because dora treats a port carrying request_id as pattern-correlated and several Arrow schemas legitimately flow over each — one per service, one per console message. dataflow.yml runs the whole operator stack from checkouts: this node, the WebSocket bridge and the arm. web/ holds a placeholder page, not the real console. It 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.
This commit is contained in:
@@ -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
|
# 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