Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd3b3e493 | ||
|
|
1b63ce97c9 | ||
|
|
177511af1f | ||
|
|
d336f8d499 | ||
|
|
885b7a569f |
@@ -35,6 +35,18 @@ jobs:
|
||||
- name: Validate the dataflow
|
||||
run: dora validate dataflow.yml
|
||||
|
||||
# The gamepad dataflow pulls dora-joy by `git:`, but validation resolves
|
||||
# the wiring without fetching it, so this stays offline and still catches
|
||||
# a mistyped port.
|
||||
- name: Validate the gamepad dataflow
|
||||
run: dora validate dataflow-gamepad.yml
|
||||
|
||||
# Same again for the real-arm stack. Its `output_types:` block is what
|
||||
# lets this pass without an arm or a clone of the arm node, so a drift
|
||||
# between it and the tag it pins fails here.
|
||||
- name: Validate the robot dataflow
|
||||
run: dora validate dataflow-robot.yml
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -12,3 +12,6 @@ build/
|
||||
.ruff_cache/
|
||||
out/
|
||||
.dora/
|
||||
# Where `dora build` clones a `git:` node source, which
|
||||
# dataflow-robot.yml and dataflow-gamepad.yml both have.
|
||||
git/
|
||||
|
||||
@@ -7,12 +7,21 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
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
|
||||
It is **the piece in the middle and nothing else**, and it **touches no hardware at all**.
|
||||
The arm is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that
|
||||
node's ordinary `jog` and `request` ports. 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 gamepad is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
||||
axes and buttons on `joy` and knows nothing about arms. Do not add any of the three here.
|
||||
A teleop node that grew its own HTTP server is exactly the monolith `dora-ulite6` was
|
||||
rewritten to get out of, and the pad was split out of this repo for the same reason: a
|
||||
node whose job is policy should not carry a hardware dependency, and a pad nobody else can
|
||||
reach is a pad that gets reimplemented.
|
||||
|
||||
**What stays here is everything about what a reading *means***: the deadzone, the response
|
||||
curve, the layout (which button is grasp), and the mapping to base-frame mm/s and deg/s.
|
||||
`dora-joy` publishes raw SDL and nothing else, so there is exactly one place shaping each
|
||||
number.
|
||||
|
||||
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
|
||||
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
||||
@@ -29,16 +38,17 @@ uv sync --group dev # plus pytest and ruff
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
|
||||
dora run dataflow-gamepad.yml --uv # the same, with dora-joy pulled in by git:
|
||||
```
|
||||
|
||||
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts
|
||||
expected next to this one.
|
||||
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||||
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
@@ -54,14 +64,26 @@ a source goes away**.
|
||||
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/joy.py`** — The pad as it arrives from `dora-joy`. `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.
|
||||
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
|
||||
into another process added a way for it to go away that did not exist before — that
|
||||
process dying — and from here that looks like silence rather than like an unplugged pad.
|
||||
So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
|
||||
because `dora-joy` publishes on every tick whether or not the pad moved; if that ever
|
||||
changes, this layer is gone and nothing here will say so.
|
||||
- **`dora_teleop/message.py`** — The wire shapes, **declared and never inferred**.
|
||||
pyarrow types a struct from its first row, so `{"value": None}` is `null` and
|
||||
`{"value": 1.0}` is `double` — an inferred `command` would change its port's type
|
||||
between one verb and the next.
|
||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity, and the layout
|
||||
that says which index is which. 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. `is_active` lives here rather than on `Sample`
|
||||
because it needs the layout: **a trigger rests at -1.0**, so the pad-is-in-use test has
|
||||
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
|
||||
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires.
|
||||
- **`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
|
||||
@@ -74,7 +96,8 @@ a source goes away**.
|
||||
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.
|
||||
first time somebody picks up the pad. Errors name the variable. It also refuses deadman
|
||||
layers that are out of order, which is otherwise invisible until the day it matters.
|
||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||
dynamic node's stdout.
|
||||
- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml`
|
||||
@@ -82,23 +105,59 @@ a source goes away**.
|
||||
simulator** and must not become one: it never integrates a velocity or moves a pose. Its
|
||||
job is to close the loop so this node can be watched, not to stand in for
|
||||
`dora-ulite6` — anything needing the arm to actually respond needs the arm.
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the
|
||||
velocity that would be sent, with no dataflow and no arm. It exists because the indices
|
||||
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
|
||||
running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
|
||||
the buttons and the velocity that would be sent. It exists because the indices in
|
||||
`mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
disagrees is otherwise found by watching a real arm move the wrong way. It goes through
|
||||
the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm
|
||||
would get — keep it that way, or it stops being evidence.
|
||||
the same `velocity()` the node runs, on the same readings, so what it prints is what the
|
||||
arm would get — keep it that way, or it stops being evidence. `dataflow-gamepad.yml`
|
||||
declares it as `path: dynamic`. For a pad whose layout is unknown, `dora-joy-probe`
|
||||
needs no dataflow at all.
|
||||
|
||||
## The standard, and why it is one
|
||||
|
||||
This node publishes `twist` (SI) and `command` (robot-agnostic verbs), not one robot's
|
||||
vocabulary. The contract is in the vault at `resources/robotics/teleop-contract.md`, which
|
||||
is where it lives because no source package is shared between node repositories.
|
||||
|
||||
**Normalise first, scale once.** Every control source becomes a `[-1, 1]` six-vector
|
||||
before anything is scaled, and the scaling happens in exactly one function. The deadzone
|
||||
and the response curve are applied only to the gamepad, because they correct a worn
|
||||
physical stick and a browser has none. Break this and the console silently acquires the
|
||||
robot's units, which is what it used to have and the reason it fitted only one arm.
|
||||
|
||||
`jog` and `request` still carry the same intent in `dora-ulite6`'s units and service
|
||||
names. They are deprecated, exist so the two repositories can move independently, and go
|
||||
away once the arm node takes the standard ports. `LEGACY_REQUEST` in `main.py` is the only
|
||||
place that knows what that node calls a verb.
|
||||
|
||||
`joint_jog` is specified in `dora-node.yml` and deliberately **not implemented**. A
|
||||
joint-space robot needs IK and position integration against joint limits, neither of which
|
||||
belongs here, and a port nothing wires reads as supported while having never run.
|
||||
|
||||
## 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.
|
||||
- **E-STOP is never gated on the lease, and neither is its release.** From either
|
||||
source, at any time. The latch (`view.estop`) is checked first in
|
||||
`_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
|
||||
not a stop. `_clear` is the only way out and does both halves, this node's latch and the
|
||||
controller's error; the pad's X and the console's RESET both go through it, so a
|
||||
pad-only dataflow is not left with a latch nothing can release.
|
||||
- **The deadman layers must stay in order**: `JOY_TTL_S` (0.2) < `JOG_TTL_S` (0.4) < the
|
||||
arm node's `VELOCITY_COMMAND_TTL_S` (0.5). Each is the backstop for the one inside it,
|
||||
and a backstop that fires first backs up nothing. `Config.from_env` enforces the first
|
||||
pair; nothing can enforce the second from here.
|
||||
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
|
||||
without waiting out any timer beyond the one that detects it.
|
||||
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
|
||||
a velocity straight through, which is why the console had to speak mm/s. One conversion
|
||||
point or none.
|
||||
- **Declare every published Arrow schema.** See `message.py`.
|
||||
- **Do not reintroduce a web server, a robot driver or a gamepad driver here.** See above.
|
||||
In particular: no `pygame`, no `evdev`, and no environment variable naming a device.
|
||||
|
||||
## What this node cannot do
|
||||
|
||||
@@ -116,22 +175,19 @@ about except through `robot_state` and telemetry.
|
||||
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
|
||||
`joy`, `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.
|
||||
checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
|
||||
standard joystick type. 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.
|
||||
- **The `joy` message** (`dora-joy`): the Arrow struct
|
||||
`{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
|
||||
Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
|
||||
and no timestamp: freshness is arrival time here.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -4,13 +4,19 @@ A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the
|
||||
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
|
||||
ordinary `jog` and `request` ports, reads the pad from
|
||||
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), 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.
|
||||
It contains no web server, no robot driver and no gamepad 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. It touches no hardware at all.
|
||||
|
||||
## The gamepad
|
||||
|
||||
The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
||||
axes and buttons and knows nothing about arms. Everything below — the deadzone, the
|
||||
response curve, which button is which, and what any of it means in mm/s — happens here.
|
||||
|
||||
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.
|
||||
@@ -23,9 +29,9 @@ for one axis at a time at one fixed speed.
|
||||
| Bumpers | Yaw, left and right | deg/s |
|
||||
| A | Close the tool | — |
|
||||
| B | Open the tool | — |
|
||||
| X | Clear a controller error | — |
|
||||
| X | Release the E-STOP and clear the arm's error | — |
|
||||
| Y | Toggle freedrive | — |
|
||||
| Back | **E-STOP** | — |
|
||||
| Back | **E-STOP** — latches until X | — |
|
||||
|
||||
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.
|
||||
@@ -33,20 +39,30 @@ 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.
|
||||
somebody can read. The layout lives here rather than in `dora-joy` for the same reason —
|
||||
`dora-joy` does not know what a button is for, which is what makes it reusable.
|
||||
|
||||
`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.
|
||||
|
||||
**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm
|
||||
still no matter what the sticks do — a stop somebody had to reach for is not undone by the
|
||||
next thumb movement. X is the only way back, and it does both halves: releases the latch
|
||||
here and clears the error on the controller. Neither button is gated on the lease, because
|
||||
an idle pad hands the arm back two seconds after the stop it just made, and a latch you
|
||||
must first take the arm back to release is a trap.
|
||||
|
||||
### Checking your pad
|
||||
|
||||
```bash
|
||||
uv run dora-teleop-probe
|
||||
dora up
|
||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
uv run dora-teleop-probe # in another terminal
|
||||
```
|
||||
|
||||
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
|
||||
with no dataflow and no arm. **It cannot move anything.** There is no dora node and no
|
||||
connection to the robot; it only reads.
|
||||
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
|
||||
dynamic node, publishes nothing and never talks to the arm; it only reads.
|
||||
|
||||
```text
|
||||
axes (6 reported)
|
||||
@@ -64,6 +80,7 @@ connection to the robot; it only reads.
|
||||
wz +30.00 deg/s yaw <--
|
||||
|
||||
moving: yes
|
||||
asking for the arm: yes
|
||||
```
|
||||
|
||||
Run this before trusting the table above. The indices are SDL's documented layout, not
|
||||
@@ -75,8 +92,33 @@ by watching an arm move the wrong way. Two things to look for:
|
||||
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
||||
itself, and a reason to raise `DEADZONE`.
|
||||
|
||||
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is
|
||||
what the arm would be sent.
|
||||
It goes through the same `velocity()` the node runs, on the same readings, so what it
|
||||
prints is what the arm would be sent.
|
||||
|
||||
For a pad whose layout is not known at all — which index is even which — use
|
||||
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow and no coordinator.
|
||||
|
||||
## Speaking to any robot
|
||||
|
||||
Nothing above is specific to one arm. Both control sources are reduced to a **normalised**
|
||||
six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only
|
||||
then scaled, in one function. A gamepad reaches that vector through the deadzone and the
|
||||
response curve, which exist to correct a worn physical stick; the browser sends it already
|
||||
clean, because a browser has no worn centre.
|
||||
|
||||
That is what lets the same node and the same console drive a robot in mm/s and one in m/s:
|
||||
neither source ever knew the difference. It is also why a gamepad and the console asking
|
||||
for the same deflection produce byte-identical output.
|
||||
|
||||
`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt
|
||||
takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person
|
||||
reading a dataflow can judge.
|
||||
|
||||
`command` carries verbs rather than service names: `gripper_close` rather than `grasp`,
|
||||
so a robot that swaps a vacuum for a two-finger gripper needs no change at either end.
|
||||
|
||||
The full contract, including the reserved `joint_jog` port for joint-space robots, is in
|
||||
the vault at `resources/robotics/teleop-contract.md`.
|
||||
|
||||
## The lease
|
||||
|
||||
@@ -99,18 +141,23 @@ 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
|
||||
never whether to start but whether anything has happened that means it must stop. Four
|
||||
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
|
||||
2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
|
||||
process dying looks like silence rather than like an unplugged pad — so silence means
|
||||
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
|
||||
is what makes silence unambiguous.
|
||||
3. **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 reports
|
||||
itself gone or the last browser disconnects. An unplugged pad is not a centred pad.
|
||||
4. **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.
|
||||
Keep them in that order, each shorter than the one behind it. A backstop that fires before
|
||||
the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at
|
||||
startup rather than letting that happen quietly.
|
||||
|
||||
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
||||
controller, not in this process.
|
||||
@@ -123,6 +170,7 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
| Direction | Name | Description |
|
||||
|-----------|----------------|------------------------------------------------------|
|
||||
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
||||
| in | `joy` | One pad reading from `dora-joy` |
|
||||
| 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 |
|
||||
@@ -130,10 +178,15 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
| 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 | `twist` | **Standard.** `{linear, angular, frame}`, SI |
|
||||
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
|
||||
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
|
||||
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
|
||||
| out | `ui_state` | The console's telemetry snapshot, as JSON |
|
||||
|
||||
Wire `twist` **or** `jog`, never both: they carry the same intent, and a robot reading
|
||||
both is driven twice. Same for `command` and `request`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -142,17 +195,20 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
| `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 |
|
||||
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
|
||||
| `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.
|
||||
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
|
||||
`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time
|
||||
somebody picks up the pad.
|
||||
|
||||
`JOYSTICK_INDEX` moved to `dora-joy` along with the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
||||
The real stack is four nodes: this one, the pad, the WebSocket bridge and the arm.
|
||||
|
||||
```yaml
|
||||
- id: teleop
|
||||
@@ -162,6 +218,10 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
@@ -173,20 +233,63 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
||||
```
|
||||
|
||||
Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this
|
||||
repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node.
|
||||
repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node. Add `dora-joy` on a
|
||||
20 ms tick for the pad — `queue_size: 1` matters, because it ticks faster than this node
|
||||
does and an arm should be driven by the newest reading or by none.
|
||||
|
||||
**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.
|
||||
Leave `joy` unwired for a browser-only console; the node starts without it.
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No
|
||||
hardware: the pad, the arm and the browser are all other nodes.
|
||||
|
||||
### Driving a real arm
|
||||
|
||||
`dataflow-robot.yml` is the one that moves a robot: the pad, this node and
|
||||
[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), with no bench and no browser.
|
||||
Both sibling nodes come in by `git:`, so it runs from a fresh clone with nothing else
|
||||
checked out.
|
||||
|
||||
```bash
|
||||
dora build dataflow-robot.yml --uv # clones and installs both, needs the network
|
||||
dora run dataflow-robot.yml --uv
|
||||
```
|
||||
|
||||
Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults
|
||||
are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so
|
||||
nothing moves until somebody pushes a stick.
|
||||
|
||||
The four deadman timings have to stay ordered, each below the one behind it, and the file
|
||||
pins all of them so the chain is visible in one place:
|
||||
|
||||
| Layer | Setting | Default |
|
||||
|---|---|---|
|
||||
| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
|
||||
| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
|
||||
| Controller's own expiry | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
|
||||
|
||||
`dora` type-checks a dataflow before it fetches a `git:` source, so it cannot read the
|
||||
arm's manifest from here. The `output_types:` block on the `ulite6` node repeats what
|
||||
`dora-ulite6` v0.3.0 annotates, which is also what pinning that tag asserts — re-check it
|
||||
when moving to a newer one.
|
||||
|
||||
Adding the web console means adding `dora-websocket-transceiver` and wiring `ui_command`,
|
||||
`clients` and its `data_in`; the four-node stack under **Usage** above has that.
|
||||
|
||||
### Running it with no arm and no browser
|
||||
|
||||
`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in,
|
||||
so you can drive the real thing from a real gamepad with no robot in the room:
|
||||
with **no pad**: pulling `dora-joy` in by `git:` would cost that file the one property it
|
||||
has, which is that it resolves and runs from this checkout alone, with no network and no
|
||||
sibling clones.
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
dora run dataflow.yml --uv # bench only, browser path
|
||||
dora run dataflow-gamepad.yml --uv # the same bench with a real pad
|
||||
```
|
||||
|
||||
`dataflow-gamepad.yml` adds `dora-joy` by `git:` and declares the probe. It needs the
|
||||
network on its first build.
|
||||
|
||||
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
||||
it is sent:
|
||||
|
||||
@@ -209,13 +312,14 @@ rather than `dora run`:
|
||||
|
||||
```bash
|
||||
dora up
|
||||
dora start dataflow.yml --uv --name teleop-bench
|
||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
```
|
||||
|
||||
Then, in another terminal:
|
||||
|
||||
```bash
|
||||
dora topic list -d teleop-bench # every edge in the graph
|
||||
dora topic echo -d teleop-bench joy/joy # what the pad is reporting
|
||||
dora topic echo -d teleop-bench teleop/jog # every velocity, live
|
||||
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
|
||||
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
|
||||
@@ -227,14 +331,31 @@ deadzone `teleop/jog` should sit near the 50 ms tick.
|
||||
|
||||
Tear it down with `dora stop --name teleop-bench && dora down`.
|
||||
|
||||
## The console
|
||||
|
||||
`web/index.html` is the browser half: a jog pad for translation and rotation, gripper,
|
||||
freedrive, home, the lease buttons and E-STOP, over a telemetry readout. Plain HTML with
|
||||
no build step — the bridge serves the directory as it stands, and a console you cannot
|
||||
fix without a toolchain is the wrong thing to have between a person and a robot.
|
||||
|
||||
Hold a direction to move, release to stop. The page re-sends its jog every 150 ms against
|
||||
the node's 400 ms deadline, so letting go stops the robot even if the release is lost. It
|
||||
sends a **fraction of full deflection**, never a speed, so it never learns the robot's
|
||||
units; the speed slider scales that fraction. The jog pad is disabled unless the console
|
||||
holds the lease, so a button that would be refused looks refused.
|
||||
|
||||
`tests/test_console.py` checks the page against `ui.py` — every message type it sends is
|
||||
one the node serves, and every telemetry key it reads is one the node sends. Neither
|
||||
language catches a rename, and the wrong place to discover one is on a machine that moves.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 112 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`.
|
||||
Every file here is testable without hardware, because there is no hardware. The pad
|
||||
arrives on `joy` as a plain `Sample`.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# The same bench, with a real gamepad and a probe.
|
||||
#
|
||||
# `dataflow.yml` is what CI validates, and it deliberately has no pad in it: the
|
||||
# pad is `dora-joy`, another repo, and pulling it in by `git:` would cost that
|
||||
# file its one property — that it resolves and runs from this checkout alone.
|
||||
# This file is the other half. It needs the network on first build, and a pad.
|
||||
#
|
||||
# The bench answers services and publishes telemetry but never moves anything.
|
||||
# What this is for is watching the node decide: the lease changing hands, the
|
||||
# deadman firing when you let go, and the arm being stopped when the pad — or
|
||||
# the node reading it — goes away.
|
||||
#
|
||||
# Run it under a coordinator rather than `dora run`, so `dora topic` can read the
|
||||
# wire and so the probe has something to attach to:
|
||||
#
|
||||
# dora up
|
||||
# dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
# dora-teleop-probe # in another terminal
|
||||
# dora topic echo -d teleop-bench teleop/jog
|
||||
# dora stop --name teleop-bench && dora down
|
||||
|
||||
strict_types: true
|
||||
|
||||
debug:
|
||||
enable_debug_inspection: true
|
||||
|
||||
nodes:
|
||||
- id: joy
|
||||
git: https://gitea.liria.cl/liria/dora-joy.git
|
||||
tag: v0.1
|
||||
build: pip install .
|
||||
path: dora-joy
|
||||
inputs:
|
||||
tick: dora/timer/millis/20
|
||||
outputs:
|
||||
- joy
|
||||
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
|
||||
# loop would work through a backlog of readings that have already been
|
||||
# overtaken. An arm should be driven by the newest one or by none.
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
clients: bench/clients
|
||||
response: bench/response
|
||||
robot_state: bench/robot_state
|
||||
tcp_pose: bench/tcp_pose
|
||||
joint_pose: bench/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
|
||||
# Declared, not spawned: `dora-teleop-probe` attaches to this id when you run
|
||||
# it, and the dataflow runs perfectly well when nobody does.
|
||||
- id: teleop-probe
|
||||
path: dynamic
|
||||
inputs:
|
||||
joy: joy/joy
|
||||
|
||||
- id: bench
|
||||
path: examples/bench.py
|
||||
inputs:
|
||||
tick: dora/timer/millis/200
|
||||
jog: teleop/jog
|
||||
request: teleop/request
|
||||
ui_state: teleop/ui_state
|
||||
outputs:
|
||||
- response
|
||||
- robot_state
|
||||
- tcp_pose
|
||||
- joint_pose
|
||||
- clients
|
||||
@@ -0,0 +1,102 @@
|
||||
# Joystick control of a real arm. Three nodes, no bench and no browser.
|
||||
#
|
||||
# This is the one that moves a robot. `dataflow.yml` and `dataflow-gamepad.yml`
|
||||
# drive a stand-in; this one drives `dora-ulite6`, so everything the pad does
|
||||
# here happens in the room. Read `The gamepad` in README.md before picking it up
|
||||
# — Back is the E-STOP.
|
||||
#
|
||||
# Both sibling nodes are pulled by `git:`, so this runs from a fresh clone with
|
||||
# no other checkouts. It needs the network on its first build, and an arm.
|
||||
#
|
||||
# dora build dataflow-robot.yml --uv
|
||||
# dora run dataflow-robot.yml --uv
|
||||
#
|
||||
# Set ROBOT_IP and TOOL_TYPE below to match the arm in front of you. The
|
||||
# defaults are `dora-ulite6`'s own: 192.168.1.192 and a vacuum tool.
|
||||
#
|
||||
# For the web console as well as the pad, add `dora-websocket-transceiver` and
|
||||
# wire `ui_command`, `clients` and its `data_in` — README.md has that wiring.
|
||||
# Without it the console ports below simply go unread, which is why `ui_state`
|
||||
# has no consumer here.
|
||||
|
||||
strict_types: true
|
||||
|
||||
# Lets `dora topic echo` read what is on the wire — the velocities going to the
|
||||
# arm, above all. Run under `dora start` rather than `dora run` to use it.
|
||||
debug:
|
||||
enable_debug_inspection: true
|
||||
|
||||
nodes:
|
||||
- id: joy
|
||||
git: https://gitea.liria.cl/liria/dora-joy.git
|
||||
tag: v0.1
|
||||
build: pip install .
|
||||
path: dora-joy
|
||||
inputs:
|
||||
tick: dora/timer/millis/20
|
||||
outputs:
|
||||
- joy
|
||||
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
|
||||
# loop would work through a backlog of readings that have already been
|
||||
# overtaken. An arm should be driven by the newest one or by none.
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
|
||||
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
|
||||
# inside it, and a backstop that fires first backs up nothing.
|
||||
JOY_TTL_S: "0.2"
|
||||
JOG_TTL_S: "0.4"
|
||||
|
||||
- id: ulite6
|
||||
git: https://gitea.liria.cl/liria/dora-ulite6.git
|
||||
tag: v0.3.0
|
||||
build: pip install .
|
||||
path: dora-ulite6
|
||||
inputs:
|
||||
tick: dora/timer/millis/100
|
||||
request: teleop/request
|
||||
jog: teleop/jog
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
# dora type-checks before it fetches a `git:` source, so it cannot read the
|
||||
# arm's own manifest from here. These three repeat what `dora-ulite6`
|
||||
# v0.3.0 annotates; `response` and `motion_state` stay untyped there and so
|
||||
# stay untyped here. Re-check them when moving to a new tag.
|
||||
output_types:
|
||||
joint_pose: std/core/v1/Float32
|
||||
tcp_pose: std/core/v1/Float32
|
||||
robot_state: std/core/v1/String
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
TOOL_TYPE: "vacuum"
|
||||
# The outermost deadman, enforced by the controller whether or not any of
|
||||
# this is alive. Every timer above must stay below it.
|
||||
VELOCITY_COMMAND_TTL_S: "0.5"
|
||||
# The pad does not home. Nothing moves until somebody pushes a stick.
|
||||
INIT_MODE: "none"
|
||||
+8
-3
@@ -4,10 +4,13 @@
|
||||
# browser, both built from this checkout, so `dora validate` resolves every
|
||||
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
|
||||
#
|
||||
# There is **no gamepad here**, because the pad is another node and wiring it in
|
||||
# by `git:` would cost this file the one property it has. For a real pad on a
|
||||
# real bench, use `dataflow-gamepad.yml`.
|
||||
#
|
||||
# This is **not** the real stack. The bench answers services and publishes
|
||||
# telemetry but never moves anything — it exists so the node can be driven from
|
||||
# a real gamepad with no arm in the room. README.md carries the three-node
|
||||
# wiring a consumer actually uses.
|
||||
# telemetry but never moves anything. README.md carries the four-node wiring a
|
||||
# consumer actually uses.
|
||||
|
||||
strict_types: true
|
||||
|
||||
@@ -29,6 +32,8 @@ nodes:
|
||||
tcp_pose: bench/tcp_pose
|
||||
joint_pose: bench/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
|
||||
+86
-17
@@ -5,11 +5,12 @@
|
||||
# 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
|
||||
# `joy`, `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.
|
||||
# pattern-correlated and skips type checks on it, several Arrow schemas
|
||||
# legitimately flow over each — one per service, one per console message — and
|
||||
# dora 1.0 ships no standard joystick type. Annotating them would claim a
|
||||
# contract neither side keeps.
|
||||
|
||||
apiVersion: 1
|
||||
name: dora-teleop
|
||||
@@ -34,6 +35,24 @@ inputs:
|
||||
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
|
||||
joy:
|
||||
required: false
|
||||
description: >-
|
||||
One gamepad reading from `dora-joy`, as the Arrow struct
|
||||
`{axes: list<float32>, buttons: list<bool>, present: bool}`. Raw SDL — the
|
||||
deadzone, the response curve and the base-frame mapping all happen here.
|
||||
Wire it with `queue_size: 1`: `dora-joy` ticks faster than this node does,
|
||||
and a backlog of old readings is the last thing an arm should be driven
|
||||
from.
|
||||
|
||||
**`present: false` stops the arm at once**, without waiting out any timer,
|
||||
because an unplugged pad is not a centred pad. So does the stream going
|
||||
quiet for longer than `JOY_TTL_S` — `dora-joy` publishes on every tick
|
||||
whether or not the pad moved, so silence means that node died, and a dead
|
||||
pad node is indistinguishable from an unplugged pad as far as the arm is
|
||||
concerned.
|
||||
|
||||
Leave it unwired for a browser-only console. The node says so and starts.
|
||||
ui_command:
|
||||
required: false
|
||||
description: >-
|
||||
@@ -79,23 +98,63 @@ inputs:
|
||||
type: std/core/v1/Float32
|
||||
|
||||
outputs:
|
||||
twist:
|
||||
description: >-
|
||||
**The standard motion port.** A velocity, as
|
||||
`{linear: [x, y, z], angular: [x, y, z], frame}` in **SI** — metres per
|
||||
second and radians per second — in the frame named by `frame`, currently
|
||||
always `base`.
|
||||
|
||||
Re-sent every tick while a source is driving, and followed by exactly one
|
||||
all-zero Twist when it stops — once, not repeatedly, because a stream of
|
||||
zeroes keeps a consumer believing a jog is live. There is no answer to a
|
||||
Twist, ever: it expires on the consumer's own TTL and the sender keeps
|
||||
sending.
|
||||
|
||||
Both control sources are normalised to a fraction of full speed before
|
||||
anything is scaled, so a gamepad and the web console asking for the same
|
||||
deflection produce byte-identical Twists.
|
||||
command:
|
||||
description: >-
|
||||
**The standard discrete port.** One thing to do, as `{action, value}`.
|
||||
`action` is robot-agnostic — `gripper_open`, `gripper_close`, `stop`,
|
||||
`home`, `clear`, `freedrive` — so a consumer that swaps a vacuum for a
|
||||
two-finger gripper needs no change here. `value` carries the argument
|
||||
where a verb has one (`freedrive` uses 1.0 and 0.0) and is null otherwise.
|
||||
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.
|
||||
**Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units:
|
||||
`{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame.
|
||||
Published alongside `twist` while that node learns the standard one.
|
||||
|
||||
Wire `twist` **or** `jog`, never both — they carry the same intent, and a
|
||||
consumer reading both would be driven twice.
|
||||
request:
|
||||
description: >-
|
||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
||||
metadata.
|
||||
**Deprecated; use `command`.** The same verbs under the service names
|
||||
`dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`,
|
||||
`home` — with `request_id` in the metadata. `move_to` and `get_state` have
|
||||
no standard equivalent yet and go only here.
|
||||
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`.
|
||||
name in the console's page, so the two change together.
|
||||
|
||||
# Reserved, not implemented. A joint-space robot — a lerobot SO-101, say — takes
|
||||
# joint targets rather than a Cartesian velocity, and no amount of scaling turns
|
||||
# one into the other: it needs inverse kinematics, which is another node's job.
|
||||
# When such a consumer exists, this is the port it wires, mirroring ROS's
|
||||
# control_msgs/JointJog beside geometry_msgs/Twist:
|
||||
#
|
||||
# joint_jog:
|
||||
# description: >-
|
||||
# A joint-space velocity, as `{names: [str], velocities: [float]}`, the
|
||||
# joints named rather than positional so a consumer cannot mistake the
|
||||
# order.
|
||||
#
|
||||
# It is written down rather than built because a port nothing wires is worse
|
||||
# than no port: it reads as supported and has never once run.
|
||||
|
||||
env:
|
||||
SPEED_MM_S:
|
||||
@@ -124,10 +183,6 @@ env:
|
||||
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
|
||||
@@ -135,6 +190,14 @@ env:
|
||||
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.
|
||||
JOY_TTL_S:
|
||||
type: float
|
||||
default: 0.2
|
||||
description: >-
|
||||
How long the `joy` stream may go quiet before the pad reads as gone and the
|
||||
arm is stopped. Must be **below** JOG_TTL_S. `dora-joy` publishes on every
|
||||
tick, so this is really "how many missed readings to tolerate" — at a 20 ms
|
||||
joy tick, the default is ten. Defaults to 0.2.
|
||||
GAMEPAD_IDLE_RELEASE_S:
|
||||
type: float
|
||||
default: 2.0
|
||||
@@ -156,6 +219,10 @@ example: |
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
@@ -164,6 +231,8 @@ example: |
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
|
||||
+22
-19
@@ -20,31 +20,27 @@ def env_float(name: str, default: float) -> float:
|
||||
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
"""Read a whole number, naming the variable if it cannot be read as one."""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadmanConfig:
|
||||
"""How long a source may go quiet before the arm is told to stop.
|
||||
|
||||
Three layers, outermost last. This node re-sends the current velocity every
|
||||
tick; `jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||
source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`,
|
||||
which the controller enforces whether or not this process is alive.
|
||||
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
|
||||
worth acting on; this node re-sends the current velocity every tick;
|
||||
`jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||
source; and behind all of them sits the arm node's own
|
||||
`VELOCITY_COMMAND_TTL_S`, which the controller enforces whether or not this
|
||||
process is alive.
|
||||
|
||||
Keep them in that order — ours shorter than the controller's — or the
|
||||
Keep them in that order — each shorter than the one behind it — or a
|
||||
backstop fires before the thing it is backing up.
|
||||
"""
|
||||
|
||||
jog_ttl_s: float = 0.4
|
||||
# How long the `joy` stream may go quiet before the pad reads as gone. The
|
||||
# pad lives in another node now, and that node dying looks like silence
|
||||
# rather than like an unplugged pad, so silence has to mean the same thing.
|
||||
# `dora-joy` publishes every tick, so it means nothing else.
|
||||
joy_ttl_s: float = 0.2
|
||||
# How long a gamepad may sit untouched before it gives the arm back, so the
|
||||
# browser can take over without anyone having to press anything.
|
||||
gamepad_idle_release_s: float = 2.0
|
||||
@@ -57,6 +53,7 @@ class DeadmanConfig:
|
||||
"""Build the timings from the environment."""
|
||||
return cls(
|
||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
||||
joy_ttl_s=env_float("JOY_TTL_S", 0.2),
|
||||
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
||||
)
|
||||
@@ -77,7 +74,6 @@ class Config:
|
||||
# How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine
|
||||
# positioning happens near centre, and expo is what buys resolution there.
|
||||
expo: float
|
||||
joystick_index: int
|
||||
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
||||
|
||||
@classmethod
|
||||
@@ -89,11 +85,18 @@ class Config:
|
||||
expo = env_float("EXPO", 0.4)
|
||||
if not 0.0 <= expo <= 1.0:
|
||||
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
|
||||
deadman = DeadmanConfig.from_env()
|
||||
# The layers have to fire outermost last, and each one is a backstop for
|
||||
# the one inside it. Behind both of these sits the arm node's own
|
||||
# VELOCITY_COMMAND_TTL_S, which no setting here can see.
|
||||
if deadman.joy_ttl_s >= deadman.jog_ttl_s:
|
||||
raise ValueError(
|
||||
f"JOY_TTL_S must be below JOG_TTL_S ({deadman.jog_ttl_s}), not {deadman.joy_ttl_s}"
|
||||
)
|
||||
return cls(
|
||||
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
||||
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
||||
deadzone=deadzone,
|
||||
expo=expo,
|
||||
joystick_index=env_int("JOYSTICK_INDEX", 0),
|
||||
deadman=DeadmanConfig.from_env(),
|
||||
deadman=deadman,
|
||||
)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""The gamepad, and the thread that reads it.
|
||||
|
||||
**Nothing outside this file may touch pygame.** The rest of the node sees only
|
||||
:class:`Sample`, which is a plain immutable reading — that is what lets every
|
||||
other module be tested without a pad plugged in, and what keeps SDL's quirks
|
||||
from leaking into the arm logic.
|
||||
|
||||
The reader is a thread because SDL wants to be pumped at its own cadence and
|
||||
because a pad being unplugged should be noticed within milliseconds rather than
|
||||
on whenever the dataflow next ticks. It never publishes anything; it fills a
|
||||
slot, and the event loop drains it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dora_teleop.log import log
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
"""One reading of the pad.
|
||||
|
||||
``present`` is the one that matters for safety: a pad that has been
|
||||
unplugged reads as absent rather than as centred, so the difference between
|
||||
"the operator let go" and "the operator's pad fell off the desk" is not lost.
|
||||
"""
|
||||
|
||||
axes: list[float] = field(default_factory=list)
|
||||
buttons: list[bool] = field(default_factory=list)
|
||||
present: bool = False
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether anything is being touched at all.
|
||||
|
||||
Deliberately raw — no deadzone. This decides whether the pad is asking
|
||||
for the arm, and :mod:`dora_teleop.mapping` decides how far to move it.
|
||||
A separate, tighter threshold here would mean a stick could claim the
|
||||
arm and then not move it.
|
||||
"""
|
||||
return self.present and (any(abs(a) > 0.3 for a in self.axes) or any(self.buttons))
|
||||
|
||||
|
||||
ABSENT = Sample()
|
||||
|
||||
|
||||
class Gamepad:
|
||||
"""Reads one pad on a thread and keeps the latest reading available."""
|
||||
|
||||
def __init__(self, index: int, poll_interval_s: float = 0.01):
|
||||
self.index = index
|
||||
self.poll_interval_s = poll_interval_s
|
||||
self._lock = threading.Lock()
|
||||
self._sample = ABSENT
|
||||
self._stopping = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="teleop-gamepad", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start reading. Never blocks, and never fails for want of a pad."""
|
||||
self._thread.start()
|
||||
|
||||
def read(self) -> Sample:
|
||||
"""Return the most recent reading."""
|
||||
with self._lock:
|
||||
return self._sample
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop reading and let the thread finish."""
|
||||
self._stopping.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Open the pad, read it, and keep trying if it is not there.
|
||||
|
||||
A missing pad is an ordinary state, not an error: the browser is a whole
|
||||
control path of its own, and the node has no business refusing to start
|
||||
because nobody has plugged a controller in yet.
|
||||
"""
|
||||
# Two subsystems, not all of them. `pygame.init()` also opens audio,
|
||||
# which on a headless robot means a screenful of ALSA errors in the
|
||||
# dataflow's stderr for a device this node never uses. The display is
|
||||
# needed despite there being no window: SDL refreshes joystick state
|
||||
# during the event pump, and the pump needs the video subsystem.
|
||||
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
||||
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||
import pygame
|
||||
|
||||
pygame.display.init()
|
||||
pygame.joystick.init()
|
||||
joystick = None
|
||||
|
||||
while not self._stopping.is_set():
|
||||
pygame.event.pump()
|
||||
if joystick is None:
|
||||
joystick = self._open(pygame)
|
||||
if joystick is None:
|
||||
self._store(ABSENT)
|
||||
self._stopping.wait(1.0)
|
||||
continue
|
||||
try:
|
||||
sample = Sample(
|
||||
axes=[joystick.get_axis(i) for i in range(joystick.get_numaxes())],
|
||||
buttons=[bool(joystick.get_button(i)) for i in range(joystick.get_numbuttons())],
|
||||
present=True,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 — an unplugged pad raises from inside SDL
|
||||
log(f"the gamepad went away ({error})")
|
||||
joystick = None
|
||||
self._store(ABSENT)
|
||||
continue
|
||||
self._store(sample)
|
||||
self._stopping.wait(self.poll_interval_s)
|
||||
|
||||
def _open(self, pygame):
|
||||
"""Open the configured pad, or return None if it is not there."""
|
||||
pygame.joystick.quit()
|
||||
pygame.joystick.init()
|
||||
if pygame.joystick.get_count() <= self.index:
|
||||
return None
|
||||
joystick = pygame.joystick.Joystick(self.index)
|
||||
joystick.init()
|
||||
log(f"gamepad {self.index}: {joystick.get_name()}")
|
||||
return joystick
|
||||
|
||||
def _store(self, sample: Sample) -> None:
|
||||
with self._lock:
|
||||
self._sample = sample
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
|
||||
return time.monotonic()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""The gamepad, as it arrives from `dora-joy`.
|
||||
|
||||
The pad itself lives in another node now. What reaches this one is a plain
|
||||
reading on the `joy` input, and this module is the seam the loop reads it
|
||||
through: :class:`Sample` is what the rest of the node sees, exactly as it did
|
||||
when the pad was read in-process.
|
||||
|
||||
**The safety-critical part is :meth:`JoyInput.read`.** Moving the pad into
|
||||
another process added a way for it to go away that did not exist before — that
|
||||
process dying — and from here that looks like silence rather than like an
|
||||
unplugged pad. So silence past `JOY_TTL_S` reads as *absent*, which is the same
|
||||
answer, because a reading nobody is refreshing is not evidence of anything.
|
||||
`dora-joy` publishes on every tick whether or not the pad moved, so silence
|
||||
means one thing only.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
"""One reading of the pad.
|
||||
|
||||
``present`` is the one that matters for safety: a pad that has been
|
||||
unplugged reads as absent rather than as centred, so the difference between
|
||||
"the operator let go" and "the operator's pad fell off the desk" is not lost.
|
||||
"""
|
||||
|
||||
axes: list[float] = field(default_factory=list)
|
||||
buttons: list[bool] = field(default_factory=list)
|
||||
present: bool = False
|
||||
|
||||
|
||||
ABSENT = Sample()
|
||||
|
||||
|
||||
def decode(event: dict) -> Sample:
|
||||
"""Read one `joy` message into a reading.
|
||||
|
||||
A message this node cannot make sense of reads as absent rather than as
|
||||
centred, for the same reason an unplugged pad does.
|
||||
"""
|
||||
value = event["value"]
|
||||
if not len(value):
|
||||
return ABSENT
|
||||
row = value[0].as_py()
|
||||
return Sample(
|
||||
axes=list(row.get("axes") or []),
|
||||
buttons=[bool(down) for down in row.get("buttons") or []],
|
||||
present=bool(row.get("present")),
|
||||
)
|
||||
|
||||
|
||||
class JoyInput:
|
||||
"""The latest reading, and how long ago it arrived."""
|
||||
|
||||
def __init__(self, ttl_s: float):
|
||||
self.ttl_s = ttl_s
|
||||
self._sample = ABSENT
|
||||
self._seen_at = 0.0
|
||||
|
||||
def accept(self, sample: Sample, moment: float) -> None:
|
||||
"""Take one reading off the wire."""
|
||||
self._sample = sample
|
||||
self._seen_at = moment
|
||||
|
||||
def read(self, moment: float) -> Sample:
|
||||
"""Return the current reading, or absence if nobody is still sending one.
|
||||
|
||||
A stale reading is not a reading. `dora-joy` publishes every tick, so
|
||||
silence means it died, and a dead pad node is indistinguishable from an
|
||||
unplugged pad as far as the arm is concerned — which is the only safe
|
||||
way to read it.
|
||||
"""
|
||||
if moment - self._seen_at > self.ttl_s:
|
||||
return ABSENT
|
||||
return self._sample
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
|
||||
return time.monotonic()
|
||||
+141
-60
@@ -2,9 +2,11 @@
|
||||
|
||||
The loop owns the one decision this node exists to make — whether a velocity
|
||||
reaches the arm this tick, and from whom. Everything else is delegated:
|
||||
:mod:`dora_teleop.gamepad` owns the hardware, :mod:`dora_teleop.mapping` turns a
|
||||
reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and
|
||||
:mod:`dora_teleop.ui` speaks the console's language.
|
||||
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
|
||||
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
|
||||
to, and :mod:`dora_teleop.ui` speaks the console's language. **No hardware is
|
||||
touched here at all** — the pad is `dora-joy`, the arm is `dora-ulite6` and the
|
||||
browser is the WebSocket bridge.
|
||||
|
||||
**The safety-critical part is `_drive`.** An arm moving under a velocity command
|
||||
keeps moving until told otherwise, so the question is never "should it start"
|
||||
@@ -20,15 +22,37 @@ import time
|
||||
import pyarrow as pa
|
||||
from dora import Node
|
||||
|
||||
from dora_teleop import mapping, ui
|
||||
from dora_teleop import joy, mapping, message, ui
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Gamepad, now
|
||||
from dora_teleop.joy import JoyInput, now
|
||||
from dora_teleop.log import log
|
||||
from dora_teleop.mapping import LAYOUT, pressed
|
||||
|
||||
REQUEST_ID = "request_id"
|
||||
STOP_VELOCITY = [0.0] * 6
|
||||
# Still, in the normalised units both sources speak.
|
||||
STOP_AXES = [0.0] * 6
|
||||
|
||||
# The verbs this node publishes on `command`. Robot-agnostic on purpose: a
|
||||
# consumer that grows a two-finger gripper where the last one had a vacuum
|
||||
# should need no change here, and neither should this node.
|
||||
GRIPPER_CLOSE = "gripper_close"
|
||||
GRIPPER_OPEN = "gripper_open"
|
||||
STOP = "stop"
|
||||
HOME = "home"
|
||||
CLEAR = "clear"
|
||||
FREEDRIVE = "freedrive"
|
||||
|
||||
# The same verbs as `dora-ulite6` names them, for the deprecated `request` port.
|
||||
# This table is the whole of what used to be spread through the loop, and it is
|
||||
# what a robot speaking the standard lets us delete.
|
||||
LEGACY_REQUEST = {
|
||||
GRIPPER_CLOSE: {"service": "grasp"},
|
||||
GRIPPER_OPEN: {"service": "release"},
|
||||
STOP: {"service": "stop"},
|
||||
CLEAR: {"service": "clear"},
|
||||
HOME: {"service": "home", "wait": False},
|
||||
}
|
||||
|
||||
|
||||
class Teleop:
|
||||
@@ -37,10 +61,12 @@ class Teleop:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
||||
self.joy = JoyInput(config.deadman.joy_ttl_s)
|
||||
self.view = ui.ArmView()
|
||||
# The velocity the browser last asked for, and when. A jog is a stream;
|
||||
# what matters is whether the stream is still arriving.
|
||||
self.web_velocity = STOP_VELOCITY
|
||||
# What the browser last asked for, and when. A jog is a stream; what
|
||||
# matters is whether the stream is still arriving. Normalised, like the
|
||||
# pad's — the console does not know the robot's units.
|
||||
self.web_axes = STOP_AXES
|
||||
self.web_seen_at = 0.0
|
||||
self.clients = 0
|
||||
# Whether the arm is currently being driven by us. Used to send the stop
|
||||
@@ -58,7 +84,7 @@ class Teleop:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Connect to the dataflow, start reading the pad, and run until dora stops us."""
|
||||
"""Connect to the dataflow and run until dora stops us."""
|
||||
args = _parse_args()
|
||||
config = Config.from_env()
|
||||
|
||||
@@ -66,8 +92,6 @@ def main() -> None:
|
||||
# dora spawns it, the id from the dataflow wins.
|
||||
node = Node(args.name)
|
||||
state = Teleop(config)
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
|
||||
try:
|
||||
for event in node:
|
||||
@@ -75,14 +99,13 @@ def main() -> None:
|
||||
raise RuntimeError(event["error"])
|
||||
if event["type"] != "INPUT":
|
||||
continue
|
||||
_handle(node, state, gamepad, event)
|
||||
_handle(node, state, event)
|
||||
finally:
|
||||
# A motion lives in the arm's controller, not in this process. Leaving
|
||||
# without zeroing the velocity leaves it travelling until the
|
||||
# controller's own expiry catches up, which is the one moment nobody is
|
||||
# watching for it.
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
gamepad.shutdown()
|
||||
_send_motion(node, state, STOP_AXES)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
@@ -96,11 +119,13 @@ def _parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
||||
def _handle(node: Node, state: Teleop, event: dict) -> None:
|
||||
"""Route one input to whatever deals with it."""
|
||||
event_id = event["id"]
|
||||
if event_id == "tick":
|
||||
_tick(node, state, gamepad)
|
||||
_tick(node, state)
|
||||
elif event_id == "joy":
|
||||
state.joy.accept(joy.decode(event), now())
|
||||
elif event_id == "ui_command":
|
||||
_accept_ui_command(node, state, event)
|
||||
elif event_id == "clients":
|
||||
@@ -122,21 +147,30 @@ def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
||||
# --- the tick, where the arm is actually driven ---
|
||||
|
||||
|
||||
def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None:
|
||||
def _tick(node: Node, state: Teleop) -> None:
|
||||
"""Decide what the arm should be doing, and say so."""
|
||||
moment = now()
|
||||
sample = gamepad.read()
|
||||
|
||||
_read_buttons(node, state, sample)
|
||||
# Absent covers three things now: no pad plugged in, a pad unplugged
|
||||
# mid-jog, and `dora-joy` no longer sending. The arm cannot tell them apart
|
||||
# and does not need to.
|
||||
sample = state.joy.read(moment)
|
||||
|
||||
# Who owns the arm is settled before anything they pressed is acted on.
|
||||
# The other order drops the first press after the pad has been idle: the
|
||||
# lease is not held yet when the buttons are read, and by the next tick the
|
||||
# button is held rather than newly down, so the edge is gone for good.
|
||||
#
|
||||
# A pad taking the arm the moment a stick moves is what keeps an operator
|
||||
# from having to reach for a browser to use the thing in their hands.
|
||||
if sample.active and state.arbiter.owner is None:
|
||||
active = mapping.is_active(sample)
|
||||
if active and state.arbiter.owner is None:
|
||||
state.arbiter.take(GAMEPAD, moment)
|
||||
if sample.active:
|
||||
if active:
|
||||
state.arbiter.note_activity(GAMEPAD, moment)
|
||||
state.arbiter.expire_idle_gamepad(moment)
|
||||
|
||||
_read_buttons(node, state, sample)
|
||||
|
||||
_drive(node, state, sample, moment)
|
||||
_poll_state(node, state, moment)
|
||||
_publish(node, state)
|
||||
@@ -149,46 +183,51 @@ def _drive(node: Node, state: Teleop, sample, moment: float) -> None:
|
||||
stream of jogs as one continuous one, and the re-send exists so the
|
||||
controller's own expiry never fires mid-motion.
|
||||
"""
|
||||
velocity = _wanted_velocity(state, sample, moment)
|
||||
axes = _wanted_axes(state, sample, moment)
|
||||
|
||||
if mapping.is_moving(velocity):
|
||||
if mapping.is_moving(axes):
|
||||
state.jogging = True
|
||||
_send_jog(node, velocity)
|
||||
_send_motion(node, state, axes)
|
||||
return
|
||||
|
||||
if state.jogging:
|
||||
# Once, not every tick. Repeating it would keep the arm node believing a
|
||||
# jog is live and hold `robot_state` at `moving` forever.
|
||||
state.jogging = False
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
_send_motion(node, state, STOP_AXES)
|
||||
|
||||
|
||||
def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]:
|
||||
"""Return what the arm should be doing, which is usually nothing.
|
||||
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
|
||||
"""Return what the robot should be doing, normalised, which is usually nothing.
|
||||
|
||||
Every path that is not an owner actively asking for motion returns a stop.
|
||||
That is deliberate: the default has to be "still", so that a case nobody
|
||||
thought of fails by halting rather than by carrying on.
|
||||
"""
|
||||
# First, and above the lease. A stop somebody had to reach for is not undone
|
||||
# by the next thumb movement: it holds until it is deliberately cleared.
|
||||
if state.view.estop:
|
||||
return STOP_AXES
|
||||
|
||||
owner = state.arbiter.owner
|
||||
if owner == GAMEPAD:
|
||||
# An unplugged pad is not a centred pad. Stopping here rather than
|
||||
# waiting out the TTL is the difference between a dropped controller
|
||||
# costing 10 ms of travel and costing 400.
|
||||
if not sample.present:
|
||||
return STOP_VELOCITY
|
||||
return mapping.velocity(sample.axes, sample.buttons, state.config)
|
||||
return STOP_AXES
|
||||
return mapping.from_pad(sample.axes, sample.buttons, state.config)
|
||||
|
||||
if owner == WEB:
|
||||
# The console refreshes its jog every 150 ms; the browser going quiet,
|
||||
# or the tab closing, has to stop the arm without anything being sent.
|
||||
if state.clients == 0:
|
||||
return STOP_VELOCITY
|
||||
return STOP_AXES
|
||||
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
||||
return STOP_VELOCITY
|
||||
return state.web_velocity
|
||||
return STOP_AXES
|
||||
return state.web_axes
|
||||
|
||||
return STOP_VELOCITY
|
||||
return STOP_AXES
|
||||
|
||||
|
||||
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||
@@ -204,20 +243,23 @@ def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||
def tapped(index: int) -> bool:
|
||||
return pressed(sample.buttons, index) and not pressed(previous, index)
|
||||
|
||||
# The stop is not gated on the lease. Somebody reaching for it is the one
|
||||
# moment when asking who owns the arm is the wrong question.
|
||||
# Neither the stop nor its release is gated on the lease. Somebody reaching
|
||||
# for the stop is the one moment when asking who owns the arm is the wrong
|
||||
# question, and a latch that cannot be released without first taking the arm
|
||||
# back is a trap — an idle pad hands the lease away two seconds after the
|
||||
# stop it just made. The console's RESET is ungated for the same reason.
|
||||
if tapped(LAYOUT.estop):
|
||||
_estop(node, state)
|
||||
if tapped(LAYOUT.clear):
|
||||
_clear(node, state)
|
||||
if not state.arbiter.may_drive(GAMEPAD):
|
||||
return
|
||||
if tapped(LAYOUT.grasp):
|
||||
_request(node, state, {"service": "grasp"})
|
||||
_act(node, state, GRIPPER_CLOSE)
|
||||
if tapped(LAYOUT.release):
|
||||
_request(node, state, {"service": "release"})
|
||||
if tapped(LAYOUT.clear):
|
||||
_request(node, state, {"service": "clear"})
|
||||
_act(node, state, GRIPPER_OPEN)
|
||||
if tapped(LAYOUT.freedrive):
|
||||
_request(node, state, {"service": "freedrive", "enabled": not state.view.freedrive_on})
|
||||
_freedrive(node, state, not state.view.freedrive_on)
|
||||
|
||||
|
||||
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
||||
@@ -259,11 +301,7 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
||||
_control(node, state, command, moment)
|
||||
return
|
||||
if command.type == "reset":
|
||||
# Clearing the latch is this node's business; clearing the controller's
|
||||
# error is the arm's. Both, in that order, or the console's RESET button
|
||||
# leaves one of the two still saying stop.
|
||||
state.view.estop = False
|
||||
_request(node, state, {"service": "clear"})
|
||||
_clear(node, state)
|
||||
return
|
||||
|
||||
if not state.arbiter.may_drive(WEB):
|
||||
@@ -271,18 +309,18 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
||||
return
|
||||
|
||||
if command.type == "jog":
|
||||
state.web_velocity = command.velocity
|
||||
state.web_axes = mapping.from_console(command.axes)
|
||||
state.web_seen_at = moment
|
||||
state.arbiter.note_activity(WEB, moment)
|
||||
elif command.type == "jog_stop":
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_axes = STOP_AXES
|
||||
state.web_seen_at = moment
|
||||
elif command.type == "home":
|
||||
_request(node, state, {"service": "home", "wait": False})
|
||||
_act(node, state, HOME)
|
||||
elif command.type == "gripper":
|
||||
_request(node, state, {"service": "grasp" if command.enabled else "release"})
|
||||
_act(node, state, GRIPPER_CLOSE if command.enabled else GRIPPER_OPEN)
|
||||
elif command.type == "freedrive":
|
||||
_request(node, state, {"service": "freedrive", "enabled": command.enabled})
|
||||
_freedrive(node, state, command.enabled)
|
||||
elif command.type == "move":
|
||||
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
||||
|
||||
@@ -291,7 +329,7 @@ def _control(node: Node, state: Teleop, command, moment: float) -> None:
|
||||
"""Hand the arm to the console, or take it back."""
|
||||
if command.action == "release":
|
||||
state.arbiter.release(WEB)
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_axes = STOP_AXES
|
||||
return
|
||||
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
||||
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
||||
@@ -303,7 +341,7 @@ def _accept_clients(state: Teleop, event: dict) -> None:
|
||||
state.clients = int(row.get("connected", 0))
|
||||
if state.clients == 0:
|
||||
state.arbiter.release(WEB)
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_axes = STOP_AXES
|
||||
|
||||
|
||||
# --- what the arm says back ---
|
||||
@@ -336,14 +374,57 @@ def _estop(node: Node, state: Teleop) -> None:
|
||||
log("e-stop")
|
||||
state.view.estop = True
|
||||
state.jogging = False
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
_request(node, state, {"service": "stop"})
|
||||
state.web_axes = STOP_AXES
|
||||
_send_motion(node, state, STOP_AXES)
|
||||
_act(node, state, STOP)
|
||||
|
||||
|
||||
def _send_jog(node: Node, velocity: list[float]) -> None:
|
||||
"""Send one velocity to the arm. There is no answer to a jog, ever."""
|
||||
node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}]))
|
||||
def _clear(node: Node, state: Teleop) -> None:
|
||||
"""Release the latch and clear the controller's error, in that order.
|
||||
|
||||
Both, or one of the two is still saying stop: the latch is this node's
|
||||
business and the error is the arm's. The console's RESET and the pad's
|
||||
`clear` button are the same act and go through here, so a pad-only
|
||||
dataflow is not left with a latch nothing can release.
|
||||
"""
|
||||
log("cleared")
|
||||
state.view.estop = False
|
||||
_act(node, state, CLEAR)
|
||||
|
||||
|
||||
def _send_motion(node: Node, state: Teleop, axes: list[float]) -> None:
|
||||
"""Send one motion command, on the standard port and the deprecated one.
|
||||
|
||||
There is no answer to either, ever — a jog is a stream that expires on the
|
||||
consumer's own TTL, so the sender keeps sending and nobody waits.
|
||||
|
||||
`twist` is the standard: SI, framed, what a robot node should take. `jog`
|
||||
is `dora-ulite6`'s mm/s and deg/s, published alongside until that node
|
||||
learns the standard. Both carry the same intent, so wiring either is safe;
|
||||
wiring both would drive the arm twice.
|
||||
"""
|
||||
config = state.config
|
||||
node.send_output("twist", message.pack_twist(mapping.twist(axes, config)))
|
||||
node.send_output("jog", message.pack_jog(mapping.scale(axes, config)))
|
||||
|
||||
|
||||
def _act(node: Node, state: Teleop, action: str, value: float | None = None) -> None:
|
||||
"""Ask for one discrete thing, on the standard port and the deprecated one.
|
||||
|
||||
The verb is robot-agnostic; `LEGACY_REQUEST` is the only place that knows
|
||||
what `dora-ulite6` calls it. A robot speaking the standard needs no entry
|
||||
there, which is how that table eventually goes away.
|
||||
"""
|
||||
node.send_output("command", message.pack_command(action, value))
|
||||
row = LEGACY_REQUEST.get(action)
|
||||
if row is not None:
|
||||
_request(node, state, dict(row))
|
||||
|
||||
|
||||
def _freedrive(node: Node, state: Teleop, enabled: bool) -> None:
|
||||
"""Toggle freedrive. Its argument rides as the verb's value."""
|
||||
_act(node, state, FREEDRIVE, value=float(enabled))
|
||||
_request(node, state, {"service": "freedrive", "enabled": enabled})
|
||||
|
||||
|
||||
def _request(node: Node, state: Teleop, row: dict) -> None:
|
||||
|
||||
+116
-20
@@ -1,19 +1,34 @@
|
||||
"""Turning stick deflection into a velocity the arm understands.
|
||||
"""Turning a control input into motion, in one place for every source.
|
||||
|
||||
Pure functions, no state and no hardware, because this is the part that has to
|
||||
be right and the part that is cheapest to check. Everything here is in the
|
||||
arm node's units: `[vx, vy, vz, wx, wy, wz]`, mm/s and deg/s, in the robot's
|
||||
**base** frame — the same frame `tcp_pose` reports.
|
||||
be right and the part that is cheapest to check.
|
||||
|
||||
Everything passes through a **normalised** six-vector first: `[-1, 1]` per
|
||||
degree of freedom, `[x, y, z, roll, pitch, yaw]`, in the robot's **base**
|
||||
frame. A gamepad reaches it through :func:`from_pad`, which shapes away a worn
|
||||
stick; the browser sends it already clean. Only then is it scaled, and that
|
||||
happens exactly once — which is what lets the same node drive an arm in mm/s
|
||||
and one in m/s without either source knowing the difference.
|
||||
|
||||
Two scalings exist. :func:`twist` is the standard one, SI, and is what a robot
|
||||
should consume. :func:`velocity` is mm/s and deg/s for `dora-ulite6`'s `jog`
|
||||
port, kept while that node learns the standard.
|
||||
|
||||
The screen convention comes from the old web console, so the two agree: stick
|
||||
up is +X, stick right is +Y.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The arm takes six numbers; these name them.
|
||||
# Six degrees of freedom; these name their places in the vector.
|
||||
VX, VY, VZ, WX, WY, WZ = range(6)
|
||||
|
||||
# Operator-facing settings are in mm/s and deg/s because that is what a person
|
||||
# reading a dataflow can judge — 60 mm/s means something, 0.06 m/s less so. The
|
||||
# wire is SI. These convert between the two, and are the only place that does.
|
||||
MM_PER_M = 1000.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
@@ -43,6 +58,17 @@ class Layout:
|
||||
|
||||
LAYOUT = Layout()
|
||||
|
||||
# The frame a velocity is expressed in. Base is what `tcp_pose` reports and what
|
||||
# the console's arrows mean; tool is reserved for a consumer that wants it.
|
||||
BASE_FRAME = "base"
|
||||
TOOL_FRAME = "tool"
|
||||
|
||||
# How far a control must travel before the pad counts as being used. Raw, with
|
||||
# no deadzone: this decides whether the pad is asking for the arm, and
|
||||
# `velocity` decides how far to move it. A tighter threshold here than there
|
||||
# would mean a stick could claim the arm and then not move it.
|
||||
ACTIVITY = 0.3
|
||||
|
||||
|
||||
def deadzone(value: float, width: float) -> float:
|
||||
"""Ignore travel near centre, and rescale what is left to the full range.
|
||||
@@ -89,13 +115,16 @@ def pressed(buttons: list[bool], index: int) -> bool:
|
||||
return bool(buttons[index]) if index < len(buttons) else False
|
||||
|
||||
|
||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading to a base-frame velocity.
|
||||
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading to the normalised six-vector.
|
||||
|
||||
Translation comes from the left stick and the triggers, rotation from the
|
||||
right stick and the bumpers. Several axes move at once on purpose: a stick
|
||||
is analog and blended, which is exactly what the console's four-sector dial
|
||||
pad could not do.
|
||||
is analog and blended, which is exactly what a four-sector dial pad cannot do.
|
||||
|
||||
The deadzone and the response curve are applied here and nowhere else,
|
||||
because they exist to correct a physical stick. A browser has no worn
|
||||
centre, so :func:`from_console` does not use them.
|
||||
"""
|
||||
|
||||
def shaped(value: float) -> float:
|
||||
@@ -104,20 +133,87 @@ def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LA
|
||||
out = [0.0] * 6
|
||||
# Up on the stick is negative in SDL, and up should send the arm away from
|
||||
# the operator, along +X.
|
||||
out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s
|
||||
out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s
|
||||
|
||||
lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger))
|
||||
out[VZ] = shaped(lift) * config.speed_mm_s
|
||||
|
||||
out[WX] = shaped(axis(axes, layout.right_x)) * config.speed_deg_s
|
||||
out[WY] = shaped(-axis(axes, layout.right_y)) * config.speed_deg_s
|
||||
|
||||
yaw = pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left)
|
||||
out[WZ] = yaw * config.speed_deg_s
|
||||
out[VX] = shaped(-axis(axes, layout.left_y))
|
||||
out[VY] = shaped(axis(axes, layout.left_x))
|
||||
out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)))
|
||||
out[WX] = shaped(axis(axes, layout.right_x))
|
||||
out[WY] = shaped(-axis(axes, layout.right_y))
|
||||
out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left))
|
||||
return out
|
||||
|
||||
|
||||
def from_console(values) -> list[float]:
|
||||
"""Read the normalised six-vector a browser sent, clamped to what it may ask for.
|
||||
|
||||
The console speaks the same units as a stick — a fraction of full speed per
|
||||
axis — so that how fast anything goes is decided once, here, rather than
|
||||
once per source. A page that has never heard of the robot cannot get its
|
||||
units wrong.
|
||||
"""
|
||||
if not isinstance(values, list) or len(values) != 6:
|
||||
return [0.0] * 6
|
||||
out = []
|
||||
for value in values:
|
||||
if not isinstance(value, (int, float)):
|
||||
return [0.0] * 6
|
||||
out.append(max(-1.0, min(1.0, float(value))))
|
||||
return out
|
||||
|
||||
|
||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading straight to `dora-ulite6`'s mm/s and deg/s.
|
||||
|
||||
A convenience over `scale(from_pad(...))`, kept because the probe and the
|
||||
tests read better for it.
|
||||
"""
|
||||
return scale(from_pad(axes, buttons, config, layout), config)
|
||||
|
||||
|
||||
def scale(normalised: list[float], config) -> list[float]:
|
||||
"""Scale the normalised six-vector to mm/s and deg/s, base frame.
|
||||
|
||||
`dora-ulite6`'s `jog` units. Deprecated in favour of :func:`twist`, and kept
|
||||
only while that node learns to take the standard one.
|
||||
"""
|
||||
out = [value * config.speed_mm_s for value in normalised[:3]]
|
||||
out += [value * config.speed_deg_s for value in normalised[3:]]
|
||||
return out
|
||||
|
||||
|
||||
def twist(normalised: list[float], config, frame: str = BASE_FRAME) -> dict:
|
||||
"""Scale the normalised six-vector to a Twist, in SI.
|
||||
|
||||
Metres per second and radians per second, which is what a robot node should
|
||||
consume: unambiguous, and the same numbers a UR or MoveIt would take. A
|
||||
consumer wanting other units converts them itself, at one known boundary.
|
||||
"""
|
||||
linear = [value * config.speed_mm_s / MM_PER_M for value in normalised[:3]]
|
||||
angular = [math.radians(value * config.speed_deg_s) for value in normalised[3:]]
|
||||
return {"linear": linear, "angular": angular, "frame": frame}
|
||||
|
||||
|
||||
def is_active(sample, layout: Layout = LAYOUT) -> bool:
|
||||
"""Whether anything on the pad is being touched at all.
|
||||
|
||||
A trigger is not a stick. SDL rests a trigger at **-1.0**, so comparing its
|
||||
raw value against a threshold reports every connected pad as being used,
|
||||
for ever — which means the pad claims the arm the moment it is plugged in
|
||||
and `GAMEPAD_IDLE_RELEASE_S` never fires. Triggers go through
|
||||
:func:`trigger` here for the same reason they do in :func:`velocity`.
|
||||
|
||||
Axes the layout does not name are still counted: a pad that reports its
|
||||
d-pad as two extra axes is asking for the arm when somebody presses it.
|
||||
"""
|
||||
if not sample.present:
|
||||
return False
|
||||
if any(sample.buttons):
|
||||
return True
|
||||
triggers = (layout.left_trigger, layout.right_trigger)
|
||||
if any(trigger(axis(sample.axes, index)) > ACTIVITY for index in triggers):
|
||||
return True
|
||||
return any(abs(value) > ACTIVITY for index, value in enumerate(sample.axes) if index not in triggers)
|
||||
|
||||
|
||||
def is_moving(velocity_vector: list[float]) -> bool:
|
||||
"""Whether this velocity would actually drive the arm."""
|
||||
return any(velocity_vector)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The wire shapes this node publishes.
|
||||
|
||||
Declared, never inferred. pyarrow reads `{"value": None}` as `null` and
|
||||
`{"value": 1.0}` as `double`, so an inferred `command` would change the type of
|
||||
its port depending on which verb was pressed — `stop` and `freedrive` would not
|
||||
agree. The same hazard that makes an unplugged gamepad retype a `joy` port.
|
||||
|
||||
dora 1.0 has no standard type for either of these, so these structs and the
|
||||
manifest that describes them are the whole contract a consumer gets.
|
||||
"""
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
# A velocity, in SI. Metres per second and radians per second, in the frame
|
||||
# named by `frame` — the units a robot node should consume, and the same ones a
|
||||
# UR or MoveIt would take.
|
||||
TWIST = pa.struct(
|
||||
[
|
||||
("linear", pa.list_(pa.float64())),
|
||||
("angular", pa.list_(pa.float64())),
|
||||
("frame", pa.string()),
|
||||
]
|
||||
)
|
||||
|
||||
# One discrete thing to do. `action` is a robot-agnostic verb; `value` carries
|
||||
# its argument where it has one and is null otherwise.
|
||||
COMMAND = pa.struct([("action", pa.string()), ("value", pa.float64())])
|
||||
|
||||
# `dora-ulite6`'s velocity, in mm/s and deg/s. Deprecated along with the `jog`
|
||||
# port it goes out on.
|
||||
JOG = pa.struct([("velocity", pa.list_(pa.float64()))])
|
||||
|
||||
|
||||
def pack_twist(twist: dict) -> pa.Array:
|
||||
"""Build one `twist` message."""
|
||||
return pa.array([twist], type=TWIST)
|
||||
|
||||
|
||||
def pack_command(action: str, value: float | None) -> pa.Array:
|
||||
"""Build one `command` message."""
|
||||
return pa.array([{"action": action, "value": value}], type=COMMAND)
|
||||
|
||||
|
||||
def pack_jog(velocity: list[float]) -> pa.Array:
|
||||
"""Build one deprecated `jog` message."""
|
||||
return pa.array([{"velocity": [float(v) for v in velocity]}], type=JOG)
|
||||
+48
-29
@@ -1,24 +1,40 @@
|
||||
"""Show what the pad is doing, with no dataflow and no arm.
|
||||
"""Show what the pad is doing to the arm, without an arm.
|
||||
|
||||
`dora-teleop-probe` opens the gamepad and prints, live: the raw axes SDL
|
||||
reports, the buttons it sees pressed, and the velocity this node would send.
|
||||
`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the
|
||||
same `joy` stream this node reads, and prints: the raw axes, the buttons, and the
|
||||
velocity this node would send. **It cannot move anything.** It publishes no
|
||||
output and never talks to the arm; it only reads.
|
||||
|
||||
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
|
||||
SDL's documented layout rather than an observed one, and a pad that disagrees is
|
||||
otherwise discovered by watching a real arm move the wrong way. It goes through
|
||||
:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity`
|
||||
— the same code the node runs — so what it prints is what the arm would get.
|
||||
the same :func:`~dora_teleop.mapping.velocity` the node runs, on the same
|
||||
readings, so what it prints is what the arm would get — keep it that way, or it
|
||||
stops being evidence.
|
||||
|
||||
Nothing here can move anything. There is no dora node and no connection to the
|
||||
arm; this only reads.
|
||||
Because it reads `joy` rather than a pad, the dataflow has to declare it:
|
||||
|
||||
```yaml
|
||||
- id: teleop-probe
|
||||
path: dynamic
|
||||
inputs:
|
||||
joy: joy/joy
|
||||
```
|
||||
|
||||
`dataflow-gamepad.yml` in this repo already does. Start that dataflow under a
|
||||
coordinator (`dora start`, not `dora run`), then run `dora-teleop-probe` in
|
||||
another terminal.
|
||||
|
||||
For a pad whose layout is not known at all — which index is which — use
|
||||
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from dora_teleop import mapping
|
||||
from dora import Node
|
||||
|
||||
from dora_teleop import joy, mapping
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Gamepad
|
||||
from dora_teleop.mapping import LAYOUT
|
||||
|
||||
# What each velocity component is, in the order the arm takes them.
|
||||
@@ -79,10 +95,10 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
||||
|
||||
if not sample.present:
|
||||
lines += [
|
||||
" no gamepad found.",
|
||||
" no gamepad.",
|
||||
"",
|
||||
f" looking for joystick index {config.joystick_index};",
|
||||
" plug one in and this will pick it up.",
|
||||
" `dora-joy` is reporting no pad, or has stopped reporting at all.",
|
||||
" Either way the arm is being told to hold still.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -102,30 +118,33 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
||||
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
|
||||
|
||||
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
|
||||
lines += ["", f" moving: {moving}"]
|
||||
lines += [
|
||||
"",
|
||||
f" moving: {moving}",
|
||||
f" asking for the arm: {'yes' if mapping.is_active(sample) else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Open the pad and print what it is doing until interrupted."""
|
||||
parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.")
|
||||
parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.")
|
||||
"""Attach to a running dataflow and print what the pad is doing."""
|
||||
parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.")
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
default="teleop-probe",
|
||||
help="The id this probe is declared under in the dataflow. Defaults to teleop-probe.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config.from_env()
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
node = Node(args.name)
|
||||
|
||||
try:
|
||||
while True:
|
||||
sample = gamepad.read()
|
||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||
time.sleep(1.0 / args.hz)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
finally:
|
||||
gamepad.shutdown()
|
||||
for event in node:
|
||||
if event["type"] != "INPUT" or event["id"] != "joy":
|
||||
continue
|
||||
sample = joy.decode(event)
|
||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+17
-9
@@ -49,7 +49,10 @@ class Command:
|
||||
"""One thing the console asked for."""
|
||||
|
||||
type: str
|
||||
velocity: list[float] = field(default_factory=list)
|
||||
# Normalised, `[-1, 1]` per degree of freedom — a fraction of full speed,
|
||||
# exactly what a stick reports. Not a velocity: the console does not know
|
||||
# the robot's units and must not have to.
|
||||
axes: list[float] = field(default_factory=list)
|
||||
action: str = ""
|
||||
preempt: bool = False
|
||||
enabled: bool = False
|
||||
@@ -70,10 +73,10 @@ def command_of(payload: dict) -> Command | None:
|
||||
return None
|
||||
|
||||
if kind == "jog":
|
||||
velocity = payload.get("velocity") or []
|
||||
if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity):
|
||||
axes = payload.get("axes") or []
|
||||
if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes):
|
||||
return None
|
||||
return Command(type="jog", velocity=[float(v) for v in velocity])
|
||||
return Command(type="jog", axes=[float(v) for v in axes])
|
||||
if kind in ("jog_stop", "estop", "reset", "home"):
|
||||
return Command(type=kind)
|
||||
if kind == "control":
|
||||
@@ -83,7 +86,7 @@ def command_of(payload: dict) -> Command | None:
|
||||
preempt=bool(payload.get("preempt", False)),
|
||||
)
|
||||
if kind == "gripper":
|
||||
return Command(type="gripper", enabled=bool(payload.get("vacuum", False)))
|
||||
return Command(type="gripper", enabled=bool(payload.get("closed", False)))
|
||||
if kind == "freedrive":
|
||||
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
||||
if kind == "move":
|
||||
@@ -146,9 +149,10 @@ def arm_state(view: ArmView, jogging: bool) -> str:
|
||||
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
|
||||
"""Build the telemetry object the console reads.
|
||||
|
||||
Every key here is read by name in `app.js`. `vacuum` is the console's name
|
||||
for the mounted tool whatever it actually is, kept because renaming it would
|
||||
mean editing the page for no gain.
|
||||
Every key here is read by name in `app.js`, so this and the page change
|
||||
together. The names are the robot-agnostic ones: `gripper` rather than
|
||||
`vacuum`, because a page that says vacuum only fits a robot with one on it,
|
||||
and `units` so a page can label a pose without guessing.
|
||||
"""
|
||||
return {
|
||||
"type": "telemetry",
|
||||
@@ -158,10 +162,14 @@ def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float)
|
||||
"arm_state": arm_state(view, jogging),
|
||||
"owner": owner or "none",
|
||||
"estop": view.estop,
|
||||
"vacuum": view.tool_engaged,
|
||||
"gripper": view.tool_engaged,
|
||||
"freedrive": view.freedrive_on,
|
||||
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
||||
"joints": list(view.joints),
|
||||
# What `position` is in, so the page labels it rather than assuming. The
|
||||
# telemetry ports carry whatever the robot publishes; only the command
|
||||
# path is normalised.
|
||||
"units": {"position": "mm", "rotation": "deg"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+2
-4
@@ -9,13 +9,11 @@ authors = [{ name = "Liria" }]
|
||||
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
||||
requires-python = ">=3.11"
|
||||
|
||||
# No hardware dependency. The pad is `dora-joy`, the arm is `dora-ulite6` and the
|
||||
# browser is `dora-websocket-transceiver`; this node decides and nothing else.
|
||||
dependencies = [
|
||||
"dora-rs >= 1.0.1",
|
||||
"pyarrow >= 17.0",
|
||||
# pygame-ce rather than evdev: Littlehand targets Linux and macOS, evdev is
|
||||
# Linux-only, and pygame-ce ships prebuilt wheels for linux-x86_64,
|
||||
# linux-aarch64 and macos-arm64 — so no dependency here compiles from source.
|
||||
"pygame-ce >= 2.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+37
-17
@@ -2,10 +2,22 @@
|
||||
|
||||
import json
|
||||
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Sample
|
||||
from dora_teleop.joy import Sample
|
||||
|
||||
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
|
||||
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
|
||||
# so a test for an absent pad would otherwise build a message no consumer sees.
|
||||
JOY = pa.struct(
|
||||
[
|
||||
("axes", pa.list_(pa.float32())),
|
||||
("buttons", pa.list_(pa.bool_())),
|
||||
("present", pa.bool_()),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class RecordingNode:
|
||||
@@ -26,9 +38,17 @@ class RecordingNode:
|
||||
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
|
||||
|
||||
def jogs(self):
|
||||
"""Return every velocity sent to the arm, in order."""
|
||||
"""Return every velocity sent on the deprecated `jog` port, in order."""
|
||||
return [row["velocity"] for row, _ in self.outputs("jog")]
|
||||
|
||||
def twists(self):
|
||||
"""Return every Twist sent on the standard port, in order."""
|
||||
return [row for row, _ in self.outputs("twist")]
|
||||
|
||||
def actions(self):
|
||||
"""Return the verb of every standard `command`, in order."""
|
||||
return [row["action"] for row, _ in self.outputs("command")]
|
||||
|
||||
def services(self):
|
||||
"""Return the name of every service called, in order."""
|
||||
return [row["service"] for row, _ in self.outputs("request")]
|
||||
@@ -41,20 +61,6 @@ class RecordingNode:
|
||||
self.sent.clear()
|
||||
|
||||
|
||||
class FakeGamepad:
|
||||
"""Stands in for the reader thread: hands back whatever the test set."""
|
||||
|
||||
def __init__(self, sample=None):
|
||||
self.sample = sample or Sample()
|
||||
self.stopped = False
|
||||
|
||||
def read(self):
|
||||
return self.sample
|
||||
|
||||
def shutdown(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def stick(axes=None, buttons=None, present=True):
|
||||
"""Build one gamepad reading, padded to a full six axes."""
|
||||
values = list(axes or [])
|
||||
@@ -67,6 +73,20 @@ def stick(axes=None, buttons=None, present=True):
|
||||
return Sample(axes=values, buttons=list(buttons or []), present=present)
|
||||
|
||||
|
||||
def joy_message(sample):
|
||||
"""Build the Arrow row `dora-joy` would publish for one reading."""
|
||||
return pa.array(
|
||||
[
|
||||
{
|
||||
"axes": [float(value) for value in sample.axes],
|
||||
"buttons": [bool(down) for down in sample.buttons],
|
||||
"present": sample.present,
|
||||
}
|
||||
],
|
||||
type=JOY,
|
||||
)
|
||||
|
||||
|
||||
def input_event(event_id, value, metadata=None):
|
||||
"""Build the event dict dora hands a node for one input."""
|
||||
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
||||
@@ -80,8 +100,8 @@ def clean_env(monkeypatch):
|
||||
"SPEED_DEG_S",
|
||||
"DEADZONE",
|
||||
"EXPO",
|
||||
"JOYSTICK_INDEX",
|
||||
"JOG_TTL_S",
|
||||
"JOY_TTL_S",
|
||||
"GAMEPAD_IDLE_RELEASE_S",
|
||||
"STATE_POLL_S",
|
||||
):
|
||||
|
||||
+14
-4
@@ -10,7 +10,6 @@ def test_the_defaults_need_no_environment():
|
||||
|
||||
assert config.speed_mm_s == 60.0
|
||||
assert config.speed_deg_s == 30.0
|
||||
assert config.joystick_index == 0
|
||||
|
||||
|
||||
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
||||
@@ -19,6 +18,13 @@ def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
||||
assert Config.from_env().deadman.jog_ttl_s < 0.5
|
||||
|
||||
|
||||
def test_the_layers_fire_innermost_first():
|
||||
# A stale pad reading has to be noticed before the jog deadline it feeds.
|
||||
deadman = Config.from_env().deadman
|
||||
|
||||
assert deadman.joy_ttl_s < deadman.jog_ttl_s
|
||||
|
||||
|
||||
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
||||
monkeypatch.setenv("SPEED_MM_S", " ")
|
||||
|
||||
@@ -32,10 +38,12 @@ def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch):
|
||||
monkeypatch.setenv("JOYSTICK_INDEX", "1.5")
|
||||
def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
|
||||
# Layers out of order means a backstop firing before the thing it backs up,
|
||||
# which is invisible until the day something actually goes wrong.
|
||||
monkeypatch.setenv("JOY_TTL_S", "0.4")
|
||||
|
||||
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
|
||||
with pytest.raises(ValueError, match="JOY_TTL_S"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
@@ -59,9 +67,11 @@ def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value):
|
||||
|
||||
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
||||
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
||||
monkeypatch.setenv("JOY_TTL_S", "0.1")
|
||||
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.deadman.jog_ttl_s == 0.25
|
||||
assert config.deadman.joy_ttl_s == 0.1
|
||||
assert config.deadman.gamepad_idle_release_s == 5.0
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""The page and the node are one contract, checked here rather than by eye.
|
||||
|
||||
`ui.py` translates for a console it cannot import, and the page reads telemetry
|
||||
by key. Nothing in either language catches a rename, so a typo would surface as
|
||||
a dead button on a machine that can move — which is the wrong place to find it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from dora_teleop import ui
|
||||
|
||||
PAGE = Path(__file__).resolve().parent.parent / "web" / "index.html"
|
||||
|
||||
|
||||
def page():
|
||||
return PAGE.read_text()
|
||||
|
||||
|
||||
def sent_types():
|
||||
"""Return every message type the page can send."""
|
||||
return set(re.findall(r'type:\s*"([a-z_]+)"', page()))
|
||||
|
||||
|
||||
def read_keys():
|
||||
"""Return the telemetry keys the page renders by name."""
|
||||
listed = re.search(r"const KEYS = \[(.*?)\]", page(), re.DOTALL).group(1)
|
||||
return set(re.findall(r'"([a-z_]+)"', listed))
|
||||
|
||||
|
||||
def snapshot_keys():
|
||||
view = ui.ArmView()
|
||||
view.pose = [1.0] * 6
|
||||
return set(ui.snapshot(view, "web", False, 0.0))
|
||||
|
||||
|
||||
def test_every_message_the_page_sends_is_one_the_node_serves():
|
||||
# `data` is the bridge's own envelope, not a console command.
|
||||
for kind in sent_types() - {"data"}:
|
||||
assert ui.command_of({"type": kind}) is not None or kind == "jog", kind
|
||||
|
||||
|
||||
def test_every_telemetry_key_the_page_reads_is_one_the_node_sends():
|
||||
missing = read_keys() - snapshot_keys()
|
||||
|
||||
assert not missing, f"the page reads keys the node does not send: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_the_page_reads_the_pose_units_from_the_node():
|
||||
# Rather than assuming mm, which is only true of one robot.
|
||||
assert "units.position" in page().replace("telemetry.units && telemetry.units.position", "units.position")
|
||||
assert "units" in snapshot_keys()
|
||||
|
||||
|
||||
def test_the_jog_the_page_sends_is_normalised_and_accepted():
|
||||
# Six axes, each a fraction of full deflection. If the page ever sent a
|
||||
# velocity instead, it would have to know the robot's units.
|
||||
command = ui.command_of(json.loads('{"type": "jog", "axes": [0, -0.6, 0, 0, 0, 0]}'))
|
||||
|
||||
assert command is not None
|
||||
assert command.axes == [0.0, -0.6, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_the_page_can_ask_for_everything_the_node_serves():
|
||||
# `move` is the one command with no button: it is a pose in the robot's own
|
||||
# units, which is exactly what this page is built not to know.
|
||||
served = {"jog", "jog_stop", "estop", "reset", "home", "control", "gripper", "freedrive"}
|
||||
|
||||
assert served <= sent_types()
|
||||
|
||||
|
||||
def test_the_page_gates_its_jog_pad_on_holding_the_lease():
|
||||
# The node refuses a jog from a non-owner and answers with a toast; a pad
|
||||
# that looks live but is refused teaches an operator to distrust the screen.
|
||||
assert 'owner === "web"' in page()
|
||||
|
||||
|
||||
def test_the_published_schemas_do_not_depend_on_the_value_sent():
|
||||
# pyarrow reads a null `value` as its own type, so an inferred schema would
|
||||
# change the type of the `command` port between one verb and the next.
|
||||
from dora_teleop import message
|
||||
|
||||
assert message.pack_command("stop", None).type == message.COMMAND
|
||||
assert message.pack_command("freedrive", 1.0).type == message.COMMAND
|
||||
assert (
|
||||
message.pack_twist({"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}).type == message.TWIST
|
||||
)
|
||||
assert message.pack_jog([0.0] * 6).type == message.JOG
|
||||
+245
-53
@@ -8,8 +8,8 @@ import pytest
|
||||
from dora_teleop import main as loop
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Sample
|
||||
from tests.conftest import FakeGamepad, RecordingNode, input_event, stick
|
||||
from dora_teleop.joy import Sample
|
||||
from tests.conftest import RecordingNode, input_event, joy_message, stick
|
||||
|
||||
STOP = [0.0] * 6
|
||||
PUSHED = [0.0, -1.0]
|
||||
@@ -34,19 +34,32 @@ def clock(monkeypatch):
|
||||
def build():
|
||||
config = Config.from_env()
|
||||
config.expo = 0.0
|
||||
return RecordingNode(), loop.Teleop(config), FakeGamepad()
|
||||
return RecordingNode(), loop.Teleop(config)
|
||||
|
||||
|
||||
def jog_from(node, state, gamepad, count=1):
|
||||
"""Run `count` ticks and return every velocity that reached the arm."""
|
||||
def send_joy(node, state, sample):
|
||||
"""Deliver one reading the way `dora-joy` publishes it, decoding included."""
|
||||
loop._handle(node, state, input_event("joy", joy_message(sample)))
|
||||
|
||||
|
||||
def ticks(node, state, count=1):
|
||||
"""Run `count` ticks with nothing new arriving, and return every velocity sent."""
|
||||
for _ in range(count):
|
||||
loop._tick(node, state, gamepad)
|
||||
loop._tick(node, state)
|
||||
return node.jogs()
|
||||
|
||||
|
||||
def web_jog(node, state, velocity):
|
||||
"""Deliver one console jog message."""
|
||||
payload = json.dumps({"type": "jog", "velocity": velocity})
|
||||
def drive(node, state, sample, count=1):
|
||||
"""Hold one reading for `count` ticks, the way a real pad and joy node would."""
|
||||
for _ in range(count):
|
||||
send_joy(node, state, sample)
|
||||
loop._tick(node, state)
|
||||
return node.jogs()
|
||||
|
||||
|
||||
def web_jog(node, state, axes):
|
||||
"""Deliver one console jog message, in the normalised units the page speaks."""
|
||||
payload = json.dumps({"type": "jog", "axes": axes})
|
||||
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
||||
|
||||
|
||||
@@ -55,10 +68,9 @@ def web_jog(node, state, velocity):
|
||||
|
||||
def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
||||
# Nobody should have to reach for a browser to use the pad in their hands.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
node, state = build()
|
||||
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert state.arbiter.owner == GAMEPAD
|
||||
assert jogs[-1][0] > 0
|
||||
@@ -67,25 +79,49 @@ def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
||||
def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock):
|
||||
# An unplugged pad is not a centred pad, and waiting out the TTL here is the
|
||||
# difference between a dropped controller costing 10 ms of travel and 400.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = Sample(present=False)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = drive(node, state, Sample(present=False))
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
def test_the_joy_node_dying_stops_the_arm(clock):
|
||||
# The pad is another process now, and that process dying looks like silence
|
||||
# rather than like an unplugged pad. It has to mean the same thing: a
|
||||
# reading nobody is refreshing is not evidence that the stick is still held.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
clock.advance(state.config.deadman.joy_ttl_s + 0.01)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_joy_node_that_keeps_publishing_keeps_the_arm_moving(clock):
|
||||
# The mirror of the test above: time passing is not by itself a reason to
|
||||
# stop, or a held stick would stutter.
|
||||
node, state = build()
|
||||
|
||||
sent = []
|
||||
for _ in range(4):
|
||||
clock.advance(0.05)
|
||||
sent = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert len(sent) == 4
|
||||
assert all(jog[0] > 0 for jog in sent)
|
||||
|
||||
|
||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
jogs = drive(node, state, stick())
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
@@ -93,13 +129,11 @@ def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
||||
# A stream of zeroes would keep the arm node believing a jog is live and
|
||||
# hold robot_state at `moving` forever.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad, count=5)
|
||||
jogs = drive(node, state, stick(), count=5)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
@@ -107,29 +141,39 @@ def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
||||
def test_a_held_stick_is_resent_every_tick(clock):
|
||||
# The arm's controller expires a velocity on its own; the re-send is what
|
||||
# keeps that backstop from firing mid-motion.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
node, state = build()
|
||||
|
||||
jogs = jog_from(node, state, gamepad, count=4)
|
||||
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||
|
||||
assert len(jogs) == 4
|
||||
assert all(jog[0] > 0 for jog in jogs)
|
||||
|
||||
|
||||
def test_a_pad_that_was_never_wired_up_leaves_the_arm_alone(clock):
|
||||
# `joy` is optional: a browser-only console wires no pad at all, and the
|
||||
# node must not read that as a centred stick asking for the arm.
|
||||
node, state = build()
|
||||
|
||||
jogs = ticks(node, state, count=3)
|
||||
|
||||
assert jogs == []
|
||||
assert state.arbiter.owner is None
|
||||
|
||||
|
||||
# --- the browser ---
|
||||
|
||||
|
||||
def test_the_last_browser_leaving_stops_the_arm(clock):
|
||||
# Closing the tab has to stop the arm, and nothing is sent when it does.
|
||||
node, state, gamepad = build()
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
jog_from(node, state, gamepad)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
node.clear()
|
||||
|
||||
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
assert state.arbiter.owner is None
|
||||
@@ -137,41 +181,41 @@ def test_the_last_browser_leaving_stops_the_arm(clock):
|
||||
|
||||
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
|
||||
# The console refreshes every 150 ms against a 400 ms deadline.
|
||||
node, state, gamepad = build()
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
jog_from(node, state, gamepad)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
node.clear()
|
||||
|
||||
clock.advance(0.5)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
||||
node, state, gamepad = build()
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
|
||||
sent = []
|
||||
for _ in range(4):
|
||||
clock.advance(0.15)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state, gamepad)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state)
|
||||
sent = node.jogs()
|
||||
|
||||
assert all(jog[0] == pytest.approx(10.0) for jog in sent)
|
||||
assert all(jog[0] == pytest.approx(state.config.speed_mm_s) for jog in sent)
|
||||
|
||||
|
||||
def test_a_non_owner_jog_is_ignored(clock):
|
||||
node, state, gamepad = build()
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(GAMEPAD, clock.t)
|
||||
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state, gamepad)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state)
|
||||
|
||||
assert node.jogs() == []
|
||||
assert any(message.get("type") == "error" for message in node.ui())
|
||||
@@ -183,7 +227,7 @@ def test_a_non_owner_jog_is_ignored(clock):
|
||||
def test_estop_is_never_gated_by_the_lease(clock):
|
||||
# Somebody reaching for the stop is the one moment when asking who owns the
|
||||
# arm is the wrong question.
|
||||
node, state, _ = build()
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(GAMEPAD, clock.t)
|
||||
|
||||
@@ -194,18 +238,87 @@ def test_estop_is_never_gated_by_the_lease(clock):
|
||||
assert "stop" in node.services()
|
||||
|
||||
|
||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
||||
node, state, gamepad = build()
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
gamepad.sample = stick(buttons=[False] * 6 + [True])
|
||||
def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
|
||||
# A stop somebody had to reach for is not undone by the next thumb
|
||||
# movement. Without this the pad stops the arm for one tick and drives it
|
||||
# again on the next, which is not a stop at all.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
loop._tick(node, state, gamepad)
|
||||
drive(node, state, stick(axes=PUSHED, buttons=[False] * 6 + [True]))
|
||||
node.clear()
|
||||
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||
|
||||
assert jogs == []
|
||||
|
||||
|
||||
def test_the_pad_can_release_the_latch_it_set(clock):
|
||||
# A pad-only dataflow has no console RESET, so a latch nothing on the pad
|
||||
# can release would strand the arm until the process was restarted.
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
assert state.view.estop is True
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert state.view.estop is False
|
||||
assert "clear" in node.services()
|
||||
|
||||
|
||||
def test_the_latch_is_released_without_holding_the_arm(clock):
|
||||
# An idle pad hands the lease back two seconds after the stop it just made,
|
||||
# so gating the release on the lease would be a trap.
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
state.arbiter.release(GAMEPAD)
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert state.view.estop is False
|
||||
|
||||
|
||||
def test_the_arm_drives_again_once_the_latch_is_released(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
node.clear()
|
||||
|
||||
jogs = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert jogs[-1][0] > 0
|
||||
|
||||
|
||||
def test_a_latched_estop_stops_the_browser_too(clock):
|
||||
# The latch is the node's, not the pad's. A console jog must not walk
|
||||
# through a stop somebody made on the gamepad.
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
state.arbiter.release(GAMEPAD)
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
node.clear()
|
||||
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == []
|
||||
|
||||
|
||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
||||
node, state = build()
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
|
||||
assert "stop" in node.services()
|
||||
|
||||
|
||||
def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||
node, state, _ = build()
|
||||
node, state = build()
|
||||
state.view.estop = True
|
||||
|
||||
payload = json.dumps({"type": "reset"})
|
||||
@@ -213,3 +326,82 @@ def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||
|
||||
assert state.view.estop is False
|
||||
assert "clear" in node.services()
|
||||
|
||||
|
||||
# --- the standard ports ---
|
||||
|
||||
|
||||
def test_the_same_deflection_from_either_source_asks_for_the_same_motion(clock):
|
||||
# The whole reason both sources are normalised before they are scaled. If
|
||||
# these ever differ, one path has grown a conversion the other has not —
|
||||
# which is how a console ends up in the robot's units.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||
from_pad = node.twists()[-1]
|
||||
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
from_web = node.twists()[-1]
|
||||
|
||||
assert from_pad == from_web
|
||||
|
||||
|
||||
def test_the_twist_is_si_and_the_legacy_jog_is_not(clock):
|
||||
# 60 mm/s is 0.06 m/s. A consumer reading the wrong port at the wrong scale
|
||||
# is a thousandfold error, so this is worth pinning.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||
|
||||
twist, jog = node.twists()[-1], node.jogs()[-1]
|
||||
|
||||
assert twist["linear"][0] == pytest.approx(state.config.speed_mm_s / 1000.0)
|
||||
assert jog[0] == pytest.approx(state.config.speed_mm_s)
|
||||
assert twist["frame"] == "base"
|
||||
|
||||
|
||||
def test_a_stop_reaches_both_ports(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick())
|
||||
|
||||
assert node.jogs() == [STOP]
|
||||
assert node.twists() == [{"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}]
|
||||
|
||||
|
||||
def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
|
||||
# The verb is what a robot speaking the standard reads; the service name is
|
||||
# what dora-ulite6 reads until it does.
|
||||
node, state = build()
|
||||
|
||||
drive(node, state, stick(buttons=[True]))
|
||||
|
||||
assert node.actions() == ["gripper_close"]
|
||||
assert "grasp" in node.services()
|
||||
|
||||
|
||||
def test_every_verb_the_pad_can_ask_for_is_robot_agnostic(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False, True]))
|
||||
drive(node, state, stick(buttons=[False] * 3 + [True]))
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert node.actions() == ["gripper_open", "freedrive", "stop", "clear"]
|
||||
|
||||
|
||||
def test_the_first_press_after_the_pad_has_been_idle_is_not_dropped(clock):
|
||||
# The lease has to be settled before the buttons are read. Read them first
|
||||
# and the press arrives while nobody owns the arm, and by the next tick the
|
||||
# button is held rather than newly down — so the edge is gone for good and
|
||||
# the operator presses A and watches nothing happen.
|
||||
node, state = build()
|
||||
assert state.arbiter.owner is None
|
||||
|
||||
drive(node, state, stick(buttons=[True]), count=3)
|
||||
|
||||
assert node.actions() == ["gripper_close"]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The pad as it arrives on the wire, and what silence on that wire means."""
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from dora_teleop.joy import ABSENT, JoyInput, Sample, decode
|
||||
from tests.conftest import input_event, joy_message, stick
|
||||
|
||||
|
||||
def joy_event(sample):
|
||||
return input_event("joy", joy_message(sample))
|
||||
|
||||
|
||||
def test_a_reading_survives_the_round_trip():
|
||||
sample = decode(joy_event(stick(axes=[0.5, -0.5], buttons=[False, True])))
|
||||
|
||||
assert sample.axes[:2] == [0.5, -0.5]
|
||||
assert sample.buttons == [False, True]
|
||||
assert sample.present is True
|
||||
|
||||
|
||||
def test_an_absent_pad_decodes_as_absent_not_as_centred():
|
||||
sample = decode(joy_event(ABSENT))
|
||||
|
||||
assert sample.present is False
|
||||
assert sample.axes == []
|
||||
|
||||
|
||||
def test_a_pad_with_fewer_axes_than_the_layout_decodes_without_complaint():
|
||||
# A three-axis flight stick is a strange thing to drive an arm with, but it
|
||||
# must not take the node down; `mapping.axis` treats a missing one as centred.
|
||||
sample = decode(joy_event(Sample(axes=[0.0, 0.0, 0.0], buttons=[False], present=True)))
|
||||
|
||||
assert len(sample.axes) == 3
|
||||
|
||||
|
||||
def test_a_message_with_no_rows_reads_as_absent():
|
||||
empty = input_event("joy", pa.array([], type=joy_message(ABSENT).type))
|
||||
|
||||
assert decode(empty) is ABSENT
|
||||
|
||||
|
||||
def test_a_fresh_reading_is_handed_back():
|
||||
joy = JoyInput(ttl_s=0.2)
|
||||
sample = stick(axes=[0.5])
|
||||
joy.accept(sample, 100.0)
|
||||
|
||||
assert joy.read(100.1) is sample
|
||||
|
||||
|
||||
def test_a_reading_nobody_is_refreshing_reads_as_absent():
|
||||
# dora-joy publishes every tick, so silence means it died — and a dead pad
|
||||
# node is indistinguishable from an unplugged pad as far as the arm cares.
|
||||
joy = JoyInput(ttl_s=0.2)
|
||||
joy.accept(stick(axes=[0.5]), 100.0)
|
||||
|
||||
assert joy.read(100.5) is ABSENT
|
||||
|
||||
|
||||
def test_nothing_ever_arriving_reads_as_absent():
|
||||
# A dataflow with no `joy` wired at all, which is a browser-only console.
|
||||
assert JoyInput(ttl_s=0.2).read(100.0) is ABSENT
|
||||
@@ -108,3 +108,35 @@ def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config):
|
||||
|
||||
assert velocity[VX] == 0.0
|
||||
assert velocity[WZ] == 0.0
|
||||
|
||||
|
||||
# --- is the pad being used at all ---
|
||||
|
||||
|
||||
def test_a_pad_at_rest_is_not_asking_for_the_arm():
|
||||
# SDL rests a trigger at -1.0, so a raw threshold reports every connected
|
||||
# pad as in use for ever: the pad claims the arm the moment it is plugged
|
||||
# in and GAMEPAD_IDLE_RELEASE_S never fires.
|
||||
assert mapping.is_active(stick()) is False
|
||||
|
||||
|
||||
def test_an_absent_pad_is_not_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(present=False)) is False
|
||||
|
||||
|
||||
def test_a_nudged_stick_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(axes=[0.5])) is True
|
||||
|
||||
|
||||
def test_a_held_trigger_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(axes=[0.0, 0.0, 1.0])) is True
|
||||
|
||||
|
||||
def test_a_held_button_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(buttons=[True])) is True
|
||||
|
||||
|
||||
def test_an_axis_the_layout_does_not_use_still_counts():
|
||||
# A pad reporting its d-pad as two extra axes is asking for the arm when
|
||||
# somebody presses it, whether or not this node maps it to anything.
|
||||
assert mapping.is_active(stick(axes=[0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 1.0])) is True
|
||||
|
||||
+13
-6
@@ -1,7 +1,7 @@
|
||||
"""The probe: what it draws, without a pad or a terminal."""
|
||||
|
||||
from dora_teleop import mapping, probe
|
||||
from dora_teleop.gamepad import Sample
|
||||
from dora_teleop.joy import Sample
|
||||
from tests.conftest import stick
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ def test_a_reading_beyond_full_deflection_stays_on_the_bar():
|
||||
assert "#" in probe.bar(4.0)
|
||||
|
||||
|
||||
def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config):
|
||||
config.joystick_index = 2
|
||||
|
||||
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
|
||||
# Absence covers both "no pad" and "dora-joy stopped sending", and the probe
|
||||
# must not imply the arm is waiting on something it is not.
|
||||
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
||||
|
||||
assert "no gamepad found" in drawn
|
||||
assert "index 2" in drawn
|
||||
assert "no gamepad" in drawn
|
||||
assert "hold still" in drawn
|
||||
|
||||
|
||||
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
||||
@@ -79,3 +79,10 @@ def test_a_moving_arm_is_reported_as_moving(config):
|
||||
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
||||
|
||||
assert "moving: yes" in drawn
|
||||
|
||||
|
||||
def test_whether_the_pad_is_asking_for_the_arm_is_shown(config):
|
||||
# The probe is where a resting trigger claiming the arm would have been
|
||||
# visible, so it says what `is_active` thinks rather than leaving it implied.
|
||||
assert "asking for the arm: no" in probe.render(stick(), [0.0] * 6, config)
|
||||
assert "asking for the arm: yes" in probe.render(stick(axes=[0.0, -1.0]), [0.0] * 6, config)
|
||||
|
||||
+8
-7
@@ -7,18 +7,18 @@ from dora_teleop.arbiter import GAMEPAD, WEB
|
||||
|
||||
|
||||
def test_a_jog_carries_its_six_numbers():
|
||||
command = ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, 6]})
|
||||
command = ui.command_of({"type": "jog", "axes": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
|
||||
|
||||
assert command.type == "jog"
|
||||
assert command.velocity == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||
assert command.axes == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
|
||||
|
||||
|
||||
def test_a_jog_of_the_wrong_length_is_not_a_jog():
|
||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3]}) is None
|
||||
assert ui.command_of({"type": "jog", "axes": [1, 2, 3]}) is None
|
||||
|
||||
|
||||
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
|
||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, "fast"]}) is None
|
||||
assert ui.command_of({"type": "jog", "axes": [1, 2, 3, 4, 5, "fast"]}) is None
|
||||
|
||||
|
||||
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
|
||||
@@ -36,8 +36,8 @@ def test_taking_control_carries_whether_it_may_preempt():
|
||||
|
||||
|
||||
def test_the_gripper_button_says_which_way():
|
||||
assert ui.command_of({"type": "gripper", "vacuum": True}).enabled is True
|
||||
assert ui.command_of({"type": "gripper", "vacuum": False}).enabled is False
|
||||
assert ui.command_of({"type": "gripper", "closed": True}).enabled is True
|
||||
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
|
||||
|
||||
|
||||
def test_a_move_needs_a_position():
|
||||
@@ -109,7 +109,8 @@ def test_the_snapshot_carries_every_key_the_page_reads_by_name():
|
||||
"arm_state",
|
||||
"owner",
|
||||
"estop",
|
||||
"vacuum",
|
||||
"gripper",
|
||||
"units",
|
||||
"freedrive",
|
||||
"position",
|
||||
"joints",
|
||||
|
||||
@@ -46,7 +46,6 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dora-rs" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pygame-ce" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -59,7 +58,6 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||
{ name = "pyarrow", specifier = ">=17.0" },
|
||||
{ name = "pygame-ce", specifier = ">=2.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -296,54 +294,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygame-ce"
|
||||
version = "2.5.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/2d/0f942ec31d558a6a1f2fd0df9965ff0055f165ed5b8d36f6509b1f3768a2/pygame_ce-2.5.8.tar.gz", hash = "sha256:3c8e69088ead310037972c391306ea58e74d7296b35d1890067749235fd554ba", size = 5300831, upload-time = "2026-08-09T11:39:49.226Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/9b/884fb7367951e74e7f77061bb08e893272794c07b1212dc61ba258c3c71e/pygame_ce-2.5.8-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:9e98223c8874177c8441923a8f60da76b1de9d7f2450602a4c7ef24c15e84f1a", size = 16588698, upload-time = "2026-08-09T11:38:07.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/7d/b3f3a58cd09e97dbbedde419bedb7ead1f7e4d3cd6465b00bdf56766ded9/pygame_ce-2.5.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a7b2a54548926eaf535ac574cfa6528f96bd1e2f524a20fea514b2922dd68bcc", size = 12060566, upload-time = "2026-08-09T11:38:09.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/8b/7ba5dbf40ff461984327539a6895ada8ab2eac4c035238f97a0fd9da9866/pygame_ce-2.5.8-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff283d1bee65e208cf2f0da7f2f4e3d0993d1787938898de2872fbefab1f786b", size = 12599420, upload-time = "2026-08-09T11:38:12.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/02/c315738725cd08d2ff4cc8f8dd29638e69e70f200d3610bcd291cbb886b8/pygame_ce-2.5.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:33e1b5a78cd6d25c2c8f00c09cdc0ba8000eec81b53dc7d8d7323d8319691d69", size = 12155284, upload-time = "2026-08-09T11:38:14.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/3d/b9d57ebab57fdef946626ae275b111c077b83fe0d0ccc3a2ec12b17523d2/pygame_ce-2.5.8-cp311-cp311-win32.whl", hash = "sha256:b919b49b8a41564f7d0ad43f6dc97a66f29d3956fb592581f4abd62442644c9c", size = 9122893, upload-time = "2026-08-09T11:38:16.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/68/54cbebea295bbd9598604e3b4c3ef2aff75df1612482c2d102963151096c/pygame_ce-2.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:0f75f6cf17607e380687aa5551c46aa830c8037b2950c71e48be8891ddc737da", size = 9666898, upload-time = "2026-08-09T11:38:19.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8a/79129cc0c755d639d180a582869e4c7a0d5106dbf611262bcf5d554ce59d/pygame_ce-2.5.8-cp311-cp311-win_arm64.whl", hash = "sha256:8e7931bd6b8ffc1474e7b3d5717e18bf8ff3eed95ab9042f1b9a0907e9ff6519", size = 10212252, upload-time = "2026-08-09T11:38:21.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/9c48c56cb36baa49d8732fc41896d2d8a6027bf94155aa1a5663fd72f7ff/pygame_ce-2.5.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ae2141c81595b4b13b780d325b9ea0016c6647f8b3a6004278b00765a96664d", size = 16570732, upload-time = "2026-08-09T11:38:24.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/83/f4979079f5a8f729b0f488e987db1227086816c26e1b985d61fc9a60605f/pygame_ce-2.5.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e401643af9455cb69f8cb77050c325f290314da0051e9ab82c5ccaf8017c314a", size = 12048869, upload-time = "2026-08-09T11:38:26.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/38/9961f6543bfb4ecc4dbeee0fdb57edbd633945dc143df9c70ee476416cc4/pygame_ce-2.5.8-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6d4839f127b1b66f1dfe7e0dca3d2233b6f3a63077690081d1996e24087c6055", size = 12587614, upload-time = "2026-08-09T11:38:29.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/07/e139faa8911d48991e161e356e652be2a642303f3baf6a57c7ba4a1f0f49/pygame_ce-2.5.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf9c8b4376077efec6ae31443172ae98545f278d0e4426852e81c28ab1f5d7c2", size = 12147342, upload-time = "2026-08-09T11:38:32.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e6/238b48936733ed1908486df072137e7eef61fd26de4620d54e438b69d509/pygame_ce-2.5.8-cp312-cp312-win32.whl", hash = "sha256:91d2434d8cbdb7cd04c9b37e76a8154641da25f4e274265806407f495fe6e889", size = 9126160, upload-time = "2026-08-09T11:38:34.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/13/2bba0ebe563047ae9de68dc05656dc0bfa9394c60970548b06d2f2e106c5/pygame_ce-2.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:2cfe8afe1e7955780f0bd4f01d65f1b4903f561407052081cfcec31e80957c3c", size = 9668595, upload-time = "2026-08-09T11:38:37.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2e/871161f0546d549fa458609029771d7a36c1e7758e164a3b2c2ed61b507b/pygame_ce-2.5.8-cp312-cp312-win_arm64.whl", hash = "sha256:6c48a3ad5f7102e076055a3c817c12e932ee5c9b831080db2ea9754236b0490d", size = 10213955, upload-time = "2026-08-09T11:38:39.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/06/f2e2d9fe3eb2dc1fde30b90e250274e85ba355af729892ac71ba653924a1/pygame_ce-2.5.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56441a9bb75c2461750dc0e6e4a46e3833b3cf6339bfbaf16f93ecac037510f3", size = 16562516, upload-time = "2026-08-09T11:38:42.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/fe/4be67df98bb05f7b024900f80fe4d07a72cec7022262d17c449b2a9f6034/pygame_ce-2.5.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3183bc0d808e739ca2400df45a5bfae10704ccbcacab0f04f9adc9027e86427", size = 12045702, upload-time = "2026-08-09T11:38:44.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8f/f7d283799aaa2208c1276f085d514f2ac37ce1bb5df940c7cbac7e6a6320/pygame_ce-2.5.8-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:841aecccb498419367936bea0b1b0c6e9d635dc81c41ef26ec1769158f1ab871", size = 12585829, upload-time = "2026-08-09T11:38:47.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4d/03fd52c7f958b8e929757a118cada9aee16ae9d4fd84f2682ee0cbd3941d/pygame_ce-2.5.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5bb966b610e161a8f6d908b4c62ef3a1f3e922bc7bed73bd70a3e15dc780b90e", size = 12145623, upload-time = "2026-08-09T11:38:49.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/61/c02613190a3256a656258bb4f677aecc09c3cb1f5521490ce7d5fb6308dd/pygame_ce-2.5.8-cp313-cp313-win32.whl", hash = "sha256:cab944d76af71e707803856b4db984ca90d9d6453e470e89771ed963c11ed912", size = 9123635, upload-time = "2026-08-09T11:38:52.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/da9186e5b88714c16fdb23bc4ba0bca4a75c21e3a5cf9607765773f68d22/pygame_ce-2.5.8-cp313-cp313-win_amd64.whl", hash = "sha256:f495b0eb7a5c54c59da58e964bc7f68073c3f43cf307729fd48309104a04c190", size = 9668342, upload-time = "2026-08-09T11:38:54.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d3/78136bc51be25afbd7954c22488860e80f32d9a885f6667fed8aac7df0d6/pygame_ce-2.5.8-cp313-cp313-win_arm64.whl", hash = "sha256:dea22b4d4c418bbd0bd9853ae797ecfa882436d2684ceb6063ba8b520adc8500", size = 10211021, upload-time = "2026-08-09T11:38:57.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e6/8e83904cf4184223419345a78c905e7c1ace10228befd87eb497ba015c8c/pygame_ce-2.5.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ec4efa6b57a194f5ad51b92211cc74b485320df951b0cc870235b2b889b9b6d", size = 16571019, upload-time = "2026-08-09T11:39:00.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/f3/4f90a0b5e86635d741111084eaa4d4c65fcab2f461edb8c9efa502e4c630/pygame_ce-2.5.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb91d0bb0b5e2a4da61910d752769d4580cacffb4b7338cee57c932351c67e7b", size = 12051251, upload-time = "2026-08-09T11:39:02.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/7e/b0c4f5d43261e8707353ef40cf6620d46d42f43a502c236e93ce6ea82e7b/pygame_ce-2.5.8-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68bd9c4ac42bb2549c034d56399404f16c87c56714db721ed975a759798a3d16", size = 12585932, upload-time = "2026-08-09T11:39:04.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/62/06f0ceb7f5a154e0071a4ce81a16de47b9f3b85034fa391686d489d15be5/pygame_ce-2.5.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0027bd2255cbb39789d9c7ff95a5f040f56d1cfeb09504497447c0ee06169a24", size = 12147357, upload-time = "2026-08-09T11:39:07.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/57/14d6b40318af71cf20e4c750866795424e018f79c5a5cf3a9a74a8717c4c/pygame_ce-2.5.8-cp314-cp314-win32.whl", hash = "sha256:c5285d444e4b789ef95522bbd2b90433163ad2b89e0ae4891236fde88a027fbd", size = 9255121, upload-time = "2026-08-09T11:39:09.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/31/92d32a9bf9b78e9ef10cec7224e2f00efc957757505822af8dc4225e9b41/pygame_ce-2.5.8-cp314-cp314-win_amd64.whl", hash = "sha256:b4c2e28c201240199356952e6bd0221969d2eb8a1a15c3454d77db209bad7891", size = 9833757, upload-time = "2026-08-09T11:39:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/26/4024483d4a3161ed8fb8d3f3f4957580af31d468e0c31df7e6038f45ccdc/pygame_ce-2.5.8-cp314-cp314-win_arm64.whl", hash = "sha256:b23034594412456504822d3088cf5f292f63bbe415eac29735340a59999e4116", size = 10410381, upload-time = "2026-08-09T11:39:14.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/11/0b47f80b261d6379c86b9cc3fa405af5bd62af10506de7e28c2275d67b86/pygame_ce-2.5.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a4e0804d53bae8d9aa5192fc2259b40dd084b48ea273dd7dd6e1c12b73303c4", size = 16571139, upload-time = "2026-08-09T11:39:16.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/95/694cd02641a0c0956b6c5919b56599af302524ac133551b654f550f28919/pygame_ce-2.5.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7f03d38b3693c014dd47d4e281620b6756669129e545af1cd608a9484582846", size = 12051154, upload-time = "2026-08-09T11:39:19.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5e/bed22b0d07d9e96bb9a448acc9da7a235f046280264c33476f0c0753d302/pygame_ce-2.5.8-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d084b79535f3529aa157edd9401f4f33e5082f318d287029291d6596bb225105", size = 12585976, upload-time = "2026-08-09T11:39:22.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/ce/8c167ba5ba736e372730ae29ef0f8cb2e62a88580e647ae987bc112eb1cb/pygame_ce-2.5.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9cbf6e2648ae6d76aa8ece82187d240300cfd9f4c90860e1e466f78cf0ab14a", size = 12147466, upload-time = "2026-08-09T11:39:24.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/92/c99ed51479f2d2a5d7fc9d5aa3b880e4c0fcc87f30cb95ecf325f35dd2fd/pygame_ce-2.5.8-cp315-cp315-win32.whl", hash = "sha256:312a01ff5439a0bd2e55b56683518454da4cbe4cad493d0580e9c49f6aacc2d8", size = 9254928, upload-time = "2026-08-09T11:39:29.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/41/404836598d666ffe02681fd0c29f013daaa8baf18e5de5f7a5d4870b6105/pygame_ce-2.5.8-cp315-cp315-win_amd64.whl", hash = "sha256:5b789aa7e4239be025c9de8428c2c422f0811f0e3dd702b574d836fd9ae040eb", size = 9833657, upload-time = "2026-08-09T11:39:31.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a2/7a844f772c6f0967e75d49cb10602fbb878b9daee1cc080d2b9597bcc873/pygame_ce-2.5.8-cp315-cp315-win_arm64.whl", hash = "sha256:28c4fed3870e3edf72ea7ea359bb5f64c55db0fb9cb3bdb26b4b674b4b4bcff4", size = 10410231, upload-time = "2026-08-09T11:39:34.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b8/525dfcfda50ea9e98cd4097d6d0beabcfa0a6007475c04dab2021077c6de/pygame_ce-2.5.8-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:0aa908c9fce4d508205e0b9cdc0f7338066da63322f6bcaa9a9c8aff9155a87e", size = 16485912, upload-time = "2026-08-09T11:39:37.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/33/d02386a4314be22bc6198d93c2d99f372c0c8c7b71fc9c390cf36d5c5c99/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:894faf17699ea7f14eabd9161ef532c1d248e40f568dab9240fc68b4c1ea85dc", size = 12008829, upload-time = "2026-08-09T11:39:39.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/26/a14abbac97c681385dbb01d5c44c867992dbf2265a784af35141c485813e/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d0c4b2b38e1946ea7a7c625ea317e237573e8731e4a56055eb4361b3c2daa0f", size = 12538786, upload-time = "2026-08-09T11:39:42.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/6c/2a8d9ca41d40e2d30e940107a019bbc17de518948c360789f59a439d689a/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ea02f653f76d496870766e1fb8419b6a6aebd81d40265057672f1f6d63d3509", size = 12102335, upload-time = "2026-08-09T11:39:44.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b5/1068d4d24891b17fcb6cf3556b1de8d5821a2bc44ebbea2b25dbd767ae2b/pygame_ce-2.5.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2f2f1c03dc8d4460779f9e55f959a7c7e523101b3530f60f3208e5ca65a6216", size = 9635346, upload-time = "2026-08-09T11:39:46.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
|
||||
+218
-17
@@ -3,47 +3,137 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>teleop — placeholder console</title>
|
||||
<title>teleop 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; }
|
||||
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 62ch; }
|
||||
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 16px; max-width: 60ch; }
|
||||
padding: 16px; margin-bottom: 16px; max-width: 62ch; }
|
||||
.card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: #8a8a9e; margin: 0 0 12px; font-weight: 600; }
|
||||
.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; }
|
||||
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer;
|
||||
touch-action: manipulation; user-select: none; }
|
||||
button:hover:not(:disabled) { background: #363648; }
|
||||
button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
button.stop { background: #5a1f27; border-color: #7d2b35; }
|
||||
button.stop:hover { background: #6d2630; }
|
||||
button.holding { background: #1f4a5a; border-color: #2b6d7d; }
|
||||
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; max-width: 280px; }
|
||||
.pad button { padding: 14px 0; }
|
||||
.pad .blank { visibility: hidden; }
|
||||
.pads { display: flex; flex-wrap: wrap; gap: 24px; }
|
||||
.pads figure { margin: 0; }
|
||||
.pads figcaption { color: #8a8a9e; padding-bottom: 8px; }
|
||||
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
label.speed { display: flex; align-items: center; gap: 10px; color: #8a8a9e; }
|
||||
label.speed input { flex: 1; }
|
||||
#owner { color: #7fd1e8; }
|
||||
#toast { color: #e8a37f; min-height: 1.5em; }
|
||||
#link { color: #8a8a9e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>teleop — placeholder console</h1>
|
||||
<h1>teleop 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.
|
||||
Hold a direction to move; release to stop. The page never sends a speed in the robot's
|
||||
units — it sends a fraction of full deflection, exactly as a gamepad stick does, and the
|
||||
node scales it. That is why this console does not need to know what robot is on the
|
||||
other end.
|
||||
</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>
|
||||
<h2>State</h2>
|
||||
<div id="state"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<div class="bar">
|
||||
<button id="take">Take control</button>
|
||||
<button id="release">Release</button>
|
||||
<button class="stop" id="estop">E-STOP</button>
|
||||
<button id="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="drive">
|
||||
<h2>Move</h2>
|
||||
<div class="pads">
|
||||
<figure>
|
||||
<figcaption>translation</figcaption>
|
||||
<div class="pad">
|
||||
<button class="blank"></button>
|
||||
<button data-axis="0" data-sign="1">+X</button>
|
||||
<button data-axis="2" data-sign="1">+Z</button>
|
||||
<button data-axis="1" data-sign="-1">−Y</button>
|
||||
<button data-axis="0" data-sign="-1">−X</button>
|
||||
<button data-axis="1" data-sign="1">+Y</button>
|
||||
<button class="blank"></button>
|
||||
<button class="blank"></button>
|
||||
<button data-axis="2" data-sign="-1">−Z</button>
|
||||
</div>
|
||||
</figure>
|
||||
<figure>
|
||||
<figcaption>rotation</figcaption>
|
||||
<div class="pad">
|
||||
<button class="blank"></button>
|
||||
<button data-axis="4" data-sign="1">+pitch</button>
|
||||
<button data-axis="5" data-sign="1">+yaw</button>
|
||||
<button data-axis="3" data-sign="-1">−roll</button>
|
||||
<button data-axis="4" data-sign="-1">−pitch</button>
|
||||
<button data-axis="3" data-sign="1">+roll</button>
|
||||
<button class="blank"></button>
|
||||
<button class="blank"></button>
|
||||
<button data-axis="5" data-sign="-1">−yaw</button>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
<p></p>
|
||||
<label class="speed">
|
||||
speed
|
||||
<input id="speed" type="range" min="10" max="100" step="5" value="60">
|
||||
<span id="speedout">60%</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Tool</h2>
|
||||
<div class="bar">
|
||||
<button id="close">Close gripper</button>
|
||||
<button id="open">Open gripper</button>
|
||||
<button id="freedrive">Freedrive</button>
|
||||
<button id="home">Home</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<div id="link"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "vacuum", "freedrive"];
|
||||
|
||||
// Read by name out of the node's telemetry snapshot. Robot-agnostic: `gripper`,
|
||||
// not `vacuum`, because a page that says vacuum only fits a robot with one.
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "gripper", "freedrive"];
|
||||
|
||||
// The node refreshes a jog every tick and stops the robot if the stream dries
|
||||
// up (JOG_TTL_S, 0.4 s). Resending faster than that is what makes releasing a
|
||||
// button a stop, with nothing needing to be sent to say so.
|
||||
const REFRESH_MS = 150;
|
||||
|
||||
let socket = null;
|
||||
let owner = "none";
|
||||
// One axis per degree of freedom: [x, y, z, roll, pitch, yaw], each -1..1. A
|
||||
// fraction of full speed, never a velocity.
|
||||
let axes = [0, 0, 0, 0, 0, 0];
|
||||
const held = new Map();
|
||||
let refresh = null;
|
||||
|
||||
function send(payload) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
@@ -51,15 +141,121 @@ function send(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function scale() {
|
||||
return Number(document.getElementById("speed").value) / 100;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
axes = [0, 0, 0, 0, 0, 0];
|
||||
for (const [, { axis, sign }] of held) axes[axis] = sign * scale();
|
||||
}
|
||||
|
||||
function moving() {
|
||||
return axes.some((value) => value !== 0);
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (moving()) send({ type: "jog", axes });
|
||||
}
|
||||
|
||||
function press(button) {
|
||||
if (button.disabled) return;
|
||||
held.set(button, { axis: Number(button.dataset.axis), sign: Number(button.dataset.sign) });
|
||||
button.classList.add("holding");
|
||||
recompute();
|
||||
pump();
|
||||
// Resend while held. The node's deadman expects a stream, not one message.
|
||||
if (refresh === null) refresh = setInterval(pump, REFRESH_MS);
|
||||
}
|
||||
|
||||
function letGo(button) {
|
||||
if (!held.has(button)) return;
|
||||
held.delete(button);
|
||||
button.classList.remove("holding");
|
||||
recompute();
|
||||
if (!moving()) {
|
||||
// Say so rather than going quiet: the deadman would stop it either way,
|
||||
// but a button released is an intention and 400 ms of travel is not.
|
||||
send({ type: "jog_stop" });
|
||||
clearInterval(refresh);
|
||||
refresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseEverything() {
|
||||
for (const button of [...held.keys()]) letGo(button);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
for (const button of document.querySelectorAll(".pad button[data-axis]")) {
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
// Keeps the pointer's events coming to this button even if the finger
|
||||
// slides off it, so letting go always reaches letGo().
|
||||
button.setPointerCapture(event.pointerId);
|
||||
press(button);
|
||||
});
|
||||
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
||||
button.addEventListener(name, () => letGo(button));
|
||||
}
|
||||
button.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
// A tab that goes away mid-jog must not leave the robot travelling. The node
|
||||
// and the bridge both cover this; doing it here too costs one line.
|
||||
window.addEventListener("blur", releaseEverything);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) releaseEverything();
|
||||
});
|
||||
|
||||
const speed = document.getElementById("speed");
|
||||
speed.addEventListener("input", () => {
|
||||
document.getElementById("speedout").textContent = `${speed.value}%`;
|
||||
recompute();
|
||||
});
|
||||
|
||||
const click = {
|
||||
take: { type: "control", action: "take", preempt: true },
|
||||
release: { type: "control", action: "release" },
|
||||
estop: { type: "estop" },
|
||||
reset: { type: "reset" },
|
||||
close: { type: "gripper", closed: true },
|
||||
open: { type: "gripper", closed: false },
|
||||
home: { type: "home" },
|
||||
};
|
||||
for (const [id, payload] of Object.entries(click)) {
|
||||
document.getElementById(id).addEventListener("click", () => send(payload));
|
||||
}
|
||||
document.getElementById("freedrive").addEventListener("click", () => {
|
||||
send({ type: "freedrive", enabled: !current.freedrive });
|
||||
});
|
||||
}
|
||||
|
||||
let current = {};
|
||||
|
||||
function render(telemetry) {
|
||||
current = telemetry;
|
||||
const rows = KEYS.map((key) =>
|
||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||
const pose = telemetry.position;
|
||||
if (pose) {
|
||||
// The node says what the pose is in rather than the page assuming it.
|
||||
const unit = (telemetry.units && telemetry.units.position) || "";
|
||||
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>`);
|
||||
rows.push(`<div class="row"><span>position</span><span>${shown} ${unit}</span></div>`);
|
||||
}
|
||||
document.getElementById("state").innerHTML = rows.join("");
|
||||
|
||||
// The jog pad is gated on holding the lease, so a button that would be
|
||||
// refused looks refused. E-STOP and Reset never are.
|
||||
owner = telemetry.owner;
|
||||
const mine = owner === "web";
|
||||
for (const id of ["close", "open", "freedrive", "home"]) {
|
||||
document.getElementById(id).disabled = !mine;
|
||||
}
|
||||
for (const button of document.querySelectorAll(".pad button[data-axis]")) {
|
||||
button.disabled = !mine;
|
||||
}
|
||||
if (!mine) releaseEverything();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
@@ -72,13 +268,18 @@ function connect() {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type !== "data" || !message.payload) return;
|
||||
if (message.payload.type === "telemetry") render(message.payload);
|
||||
if (message.payload.type === "error") {
|
||||
document.getElementById("toast").textContent = message.payload.detail;
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
releaseEverything();
|
||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
wire();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user