Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd3b3e493 | ||
|
|
1b63ce97c9 | ||
|
|
177511af1f | ||
|
|
d336f8d499 | ||
|
|
885b7a569f |
@@ -35,6 +35,18 @@ jobs:
|
|||||||
- name: Validate the dataflow
|
- name: Validate the dataflow
|
||||||
run: dora validate dataflow.yml
|
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:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ build/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
out/
|
out/
|
||||||
.dora/
|
.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
|
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.
|
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
|
It is **the piece in the middle and nothing else**, and it **touches no hardware at all**.
|
||||||
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
|
The arm is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that
|
||||||
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by
|
node's ordinary `jog` and `request` ports. The browser is carried by
|
||||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
[`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
|
The gamepad is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
||||||
monolith `dora-ulite6` was rewritten to get out of.
|
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`
|
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
|
||||||
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
||||||
@@ -29,16 +38,17 @@ uv sync --group dev # plus pytest and ruff
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```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
|
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||||||
expected next to this one.
|
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```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 check .
|
||||||
uv run ruff format --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
|
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
|
fails by halting rather than by carrying on. Keep it that way: the default has to be
|
||||||
"still".
|
"still".
|
||||||
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this
|
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
|
||||||
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what
|
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
|
||||||
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic.
|
operator let go" and "the pad fell off the desk" stay distinguishable.
|
||||||
`Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as
|
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
|
||||||
centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable.
|
into another process added a way for it to go away that did not exist before — that
|
||||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions,
|
process dying — and from here that looks like silence rather than like an unplugged pad.
|
||||||
no state, no hardware, because this is the part that has to be right and the cheapest to
|
So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
|
||||||
check. Output is always the arm node's units: mm/s and deg/s, base frame.
|
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",
|
- **`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`
|
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
|
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`).
|
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
|
- **`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
|
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
|
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||||
dynamic node's stdout.
|
dynamic node's stdout.
|
||||||
- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml`
|
- **`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
|
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
|
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-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
|
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
|
||||||
velocity that would be sent, with no dataflow and no arm. It exists because the indices
|
running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
|
||||||
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
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
|
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
|
the same `velocity()` the node runs, on the same readings, so what it prints is what the
|
||||||
would get — keep it that way, or it stops being evidence.
|
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
|
## Rules that are not negotiable
|
||||||
|
|
||||||
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
|
- **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.
|
`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.
|
- **E-STOP is never gated on the lease, and neither is its release.** From either
|
||||||
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The
|
source, at any time. The latch (`view.estop`) is checked first in
|
||||||
controller-side expiry is the backstop; a backstop that fires first backs up nothing.
|
`_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
|
||||||
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out
|
not a stop. `_clear` is the only way out and does both halves, this node's latch and the
|
||||||
any timer.
|
controller's error; the pad's X and the console's RESET both go through it, so a
|
||||||
- **Do not reintroduce a web server or a robot driver here.** See above.
|
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
|
## 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
|
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.
|
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
|
**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
|
checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
|
||||||
claim a contract neither side keeps.
|
standard joystick type. Annotating them would claim a contract neither side keeps.
|
||||||
|
|
||||||
## Key APIs
|
## Key APIs
|
||||||
|
|
||||||
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
|
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
|
||||||
iteration via `for event in node`.
|
iteration via `for event in node`.
|
||||||
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux
|
- **The `joy` message** (`dora-joy`): the Arrow struct
|
||||||
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for
|
`{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
|
||||||
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only
|
Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
|
||||||
the display and joystick subsystems are initialised: `pygame.init()` also opens audio,
|
and no timestamp: freshness is arrival time here.
|
||||||
which on a headless robot fills stderr with ALSA errors for a device never used. The
|
|
||||||
display is needed despite there being no window, because SDL refreshes joystick state
|
|
||||||
during the event pump and the pump needs it.
|
|
||||||
|
|
||||||
## Tools
|
## 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.
|
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
|
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).
|
[`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
|
It contains no web server, no robot driver and no gamepad driver: it is the piece in the
|
||||||
what the arm should be doing, and that decision is the only thing it owns.
|
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 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
|
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
|
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.
|
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 |
|
| Bumpers | Yaw, left and right | deg/s |
|
||||||
| A | Close the tool | — |
|
| A | Close the tool | — |
|
||||||
| B | Open 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 | — |
|
| 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 —
|
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.
|
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
|
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
|
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
|
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
|
`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.
|
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
|
### Checking your pad
|
||||||
|
|
||||||
```bash
|
```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,
|
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
|
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
|
||||||
connection to the robot; it only reads.
|
dynamic node, publishes nothing and never talks to the arm; it only reads.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
axes (6 reported)
|
axes (6 reported)
|
||||||
@@ -64,6 +80,7 @@ connection to the robot; it only reads.
|
|||||||
wz +30.00 deg/s yaw <--
|
wz +30.00 deg/s yaw <--
|
||||||
|
|
||||||
moving: yes
|
moving: yes
|
||||||
|
asking for the arm: yes
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this before trusting the table above. The indices are SDL's documented layout, not
|
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
|
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
||||||
itself, and a reason to raise `DEADZONE`.
|
itself, and a reason to raise `DEADZONE`.
|
||||||
|
|
||||||
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is
|
It goes through the same `velocity()` the node runs, on the same readings, so what it
|
||||||
what the arm would be sent.
|
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
|
## The lease
|
||||||
|
|
||||||
@@ -99,18 +141,23 @@ stop it — if that matters for your dataflow, do not wire one.
|
|||||||
## The deadman
|
## The deadman
|
||||||
|
|
||||||
An arm under a velocity command keeps moving until told otherwise, so the question is
|
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:
|
layers, outermost last:
|
||||||
|
|
||||||
1. **Re-send every tick** (50 ms) while a source is driving.
|
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 —
|
2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
|
||||||
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged
|
process dying looks like silence rather than like an unplugged pad — so silence means
|
||||||
or the last browser disconnects. An unplugged pad is not a centred pad.
|
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
|
||||||
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
|
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.
|
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
|
Keep them in that order, each shorter than the one behind it. A backstop that fires before
|
||||||
fires before the thing it is backing up.
|
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
|
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
||||||
controller, not in this process.
|
controller, not in this process.
|
||||||
@@ -123,6 +170,7 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
|||||||
| Direction | Name | Description |
|
| Direction | Name | Description |
|
||||||
|-----------|----------------|------------------------------------------------------|
|
|-----------|----------------|------------------------------------------------------|
|
||||||
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
| 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 | `ui_command` | One console message, from the bridge's `data_out` |
|
||||||
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
|
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
|
||||||
| in | `response` | An answer from the arm |
|
| 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 | `robot_state` | The arm's own state |
|
||||||
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
|
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
|
||||||
| in | `joint_pose` | Joint angles, six float32, degrees |
|
| in | `joint_pose` | Joint angles, six float32, degrees |
|
||||||
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s |
|
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
|
||||||
| out | `request` | A service call, `request_id` in the metadata |
|
| 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 |
|
| 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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| 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 |
|
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
|
||||||
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
|
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
|
||||||
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
|
||||||
stops the node at startup rather than the first time somebody picks up the pad.
|
`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
|
## 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
|
```yaml
|
||||||
- id: teleop
|
- id: teleop
|
||||||
@@ -162,6 +218,10 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
|||||||
path: dora-teleop
|
path: dora-teleop
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/50
|
tick: dora/timer/millis/50
|
||||||
|
joy:
|
||||||
|
source: joy/joy
|
||||||
|
queue_size: 1
|
||||||
|
queue_policy: drop_oldest
|
||||||
ui_command: ws-transceiver/data_out
|
ui_command: ws-transceiver/data_out
|
||||||
clients: ws-transceiver/clients
|
clients: ws-transceiver/clients
|
||||||
response: ulite6/response
|
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
|
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
|
Leave `joy` unwired for a browser-only console; the node starts without it.
|
||||||
gamepad if you want one — the node starts without and says so.
|
|
||||||
|
**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
|
### 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,
|
`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
|
```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
|
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
||||||
it is sent:
|
it is sent:
|
||||||
|
|
||||||
@@ -209,13 +312,14 @@ rather than `dora run`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora up
|
dora up
|
||||||
dora start dataflow.yml --uv --name teleop-bench
|
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, in another terminal:
|
Then, in another terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora topic list -d teleop-bench # every edge in the graph
|
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/jog # every velocity, live
|
||||||
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
|
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
|
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`.
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
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 check .
|
||||||
uv run ruff format --check .
|
uv run ruff format --check .
|
||||||
```
|
```
|
||||||
|
|
||||||
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that
|
Every file here is testable without hardware, because there is no hardware. The pad
|
||||||
cannot be tested without hardware. Everything else sees a plain `Sample`.
|
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
|
# 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.
|
# 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
|
# 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
|
# telemetry but never moves anything. README.md carries the four-node wiring a
|
||||||
# a real gamepad with no arm in the room. README.md carries the three-node
|
# consumer actually uses.
|
||||||
# wiring a consumer actually uses.
|
|
||||||
|
|
||||||
strict_types: true
|
strict_types: true
|
||||||
|
|
||||||
@@ -29,6 +32,8 @@ nodes:
|
|||||||
tcp_pose: bench/tcp_pose
|
tcp_pose: bench/tcp_pose
|
||||||
joint_pose: bench/joint_pose
|
joint_pose: bench/joint_pose
|
||||||
outputs:
|
outputs:
|
||||||
|
- twist
|
||||||
|
- command
|
||||||
- jog
|
- jog
|
||||||
- request
|
- request
|
||||||
- ui_state
|
- ui_state
|
||||||
|
|||||||
+86
-17
@@ -5,11 +5,12 @@
|
|||||||
# dataflow against it without running the node, so CI needs neither a gamepad
|
# dataflow against it without running the node, so CI needs neither a gamepad
|
||||||
# nor a robot.
|
# 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
|
# deliberately **untyped**. dora treats a port carrying `request_id` as
|
||||||
# pattern-correlated and skips type checks on it, and several Arrow schemas
|
# pattern-correlated and skips type checks on it, several Arrow schemas
|
||||||
# legitimately flow over each — one per service, one per console message.
|
# legitimately flow over each — one per service, one per console message — and
|
||||||
# Annotating them would claim a contract neither side keeps.
|
# dora 1.0 ships no standard joystick type. Annotating them would claim a
|
||||||
|
# contract neither side keeps.
|
||||||
|
|
||||||
apiVersion: 1
|
apiVersion: 1
|
||||||
name: dora-teleop
|
name: dora-teleop
|
||||||
@@ -34,6 +35,24 @@ inputs:
|
|||||||
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
|
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.
|
firing between ticks, which an operator feels as the arm stuttering.
|
||||||
type: std/core/v1/UInt64
|
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:
|
ui_command:
|
||||||
required: false
|
required: false
|
||||||
description: >-
|
description: >-
|
||||||
@@ -79,23 +98,63 @@ inputs:
|
|||||||
type: std/core/v1/Float32
|
type: std/core/v1/Float32
|
||||||
|
|
||||||
outputs:
|
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:
|
jog:
|
||||||
description: >-
|
description: >-
|
||||||
A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}`
|
**Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units:
|
||||||
in mm/s and deg/s. Re-sent every tick while a source is driving, and
|
`{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame.
|
||||||
followed by exactly one all-zero vector when it stops — once, not
|
Published alongside `twist` while that node learns the standard one.
|
||||||
repeatedly, because a stream of zeroes would keep the arm node believing a
|
|
||||||
jog is live.
|
Wire `twist` **or** `jog`, never both — they carry the same intent, and a
|
||||||
|
consumer reading both would be driven twice.
|
||||||
request:
|
request:
|
||||||
description: >-
|
description: >-
|
||||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
**Deprecated; use `command`.** The same verbs under the service names
|
||||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
`dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`,
|
||||||
metadata.
|
`home` — with `request_id` in the metadata. `move_to` and `get_state` have
|
||||||
|
no standard equivalent yet and go only here.
|
||||||
ui_state:
|
ui_state:
|
||||||
description: >-
|
description: >-
|
||||||
The console's telemetry snapshot, as a JSON string for the WebSocket
|
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
|
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:
|
env:
|
||||||
SPEED_MM_S:
|
SPEED_MM_S:
|
||||||
@@ -124,10 +183,6 @@ env:
|
|||||||
How much of the response curve is cubic, from 0 (linear) to 1. Fine
|
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
|
positioning happens near centre and a linear stick gives it almost no
|
||||||
resolution there. Defaults to 0.4.
|
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:
|
JOG_TTL_S:
|
||||||
type: float
|
type: float
|
||||||
default: 0.4
|
default: 0.4
|
||||||
@@ -135,6 +190,14 @@ env:
|
|||||||
How long the owning source may go quiet before the arm is stopped. Keep it
|
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
|
**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.
|
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:
|
GAMEPAD_IDLE_RELEASE_S:
|
||||||
type: float
|
type: float
|
||||||
default: 2.0
|
default: 2.0
|
||||||
@@ -156,6 +219,10 @@ example: |
|
|||||||
path: dora-teleop
|
path: dora-teleop
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/50
|
tick: dora/timer/millis/50
|
||||||
|
joy:
|
||||||
|
source: joy/joy
|
||||||
|
queue_size: 1
|
||||||
|
queue_policy: drop_oldest
|
||||||
ui_command: ws-transceiver/data_out
|
ui_command: ws-transceiver/data_out
|
||||||
clients: ws-transceiver/clients
|
clients: ws-transceiver/clients
|
||||||
response: ulite6/response
|
response: ulite6/response
|
||||||
@@ -164,6 +231,8 @@ example: |
|
|||||||
tcp_pose: ulite6/tcp_pose
|
tcp_pose: ulite6/tcp_pose
|
||||||
joint_pose: ulite6/joint_pose
|
joint_pose: ulite6/joint_pose
|
||||||
outputs:
|
outputs:
|
||||||
|
- twist
|
||||||
|
- command
|
||||||
- jog
|
- jog
|
||||||
- request
|
- request
|
||||||
- ui_state
|
- 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
|
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
|
@dataclass
|
||||||
class DeadmanConfig:
|
class DeadmanConfig:
|
||||||
"""How long a source may go quiet before the arm is told to stop.
|
"""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
|
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
|
||||||
tick; `jog_ttl_s` is how long it will keep doing so without hearing from the
|
worth acting on; this node re-sends the current velocity every tick;
|
||||||
source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`,
|
`jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||||
which the controller enforces whether or not this process is alive.
|
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.
|
backstop fires before the thing it is backing up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
jog_ttl_s: float = 0.4
|
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
|
# 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.
|
# browser can take over without anyone having to press anything.
|
||||||
gamepad_idle_release_s: float = 2.0
|
gamepad_idle_release_s: float = 2.0
|
||||||
@@ -57,6 +53,7 @@ class DeadmanConfig:
|
|||||||
"""Build the timings from the environment."""
|
"""Build the timings from the environment."""
|
||||||
return cls(
|
return cls(
|
||||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
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),
|
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
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
|
# 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.
|
# positioning happens near centre, and expo is what buys resolution there.
|
||||||
expo: float
|
expo: float
|
||||||
joystick_index: int
|
|
||||||
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -89,11 +85,18 @@ class Config:
|
|||||||
expo = env_float("EXPO", 0.4)
|
expo = env_float("EXPO", 0.4)
|
||||||
if not 0.0 <= expo <= 1.0:
|
if not 0.0 <= expo <= 1.0:
|
||||||
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
|
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(
|
return cls(
|
||||||
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
||||||
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
||||||
deadzone=deadzone,
|
deadzone=deadzone,
|
||||||
expo=expo,
|
expo=expo,
|
||||||
joystick_index=env_int("JOYSTICK_INDEX", 0),
|
deadman=deadman,
|
||||||
deadman=DeadmanConfig.from_env(),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
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:
|
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
|
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
|
||||||
reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and
|
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
|
||||||
:mod:`dora_teleop.ui` speaks the console's language.
|
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
|
**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"
|
keeps moving until told otherwise, so the question is never "should it start"
|
||||||
@@ -20,15 +22,37 @@ import time
|
|||||||
import pyarrow as pa
|
import pyarrow as pa
|
||||||
from dora import Node
|
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.arbiter import GAMEPAD, WEB, Arbiter
|
||||||
from dora_teleop.config import Config
|
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.log import log
|
||||||
from dora_teleop.mapping import LAYOUT, pressed
|
from dora_teleop.mapping import LAYOUT, pressed
|
||||||
|
|
||||||
REQUEST_ID = "request_id"
|
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:
|
class Teleop:
|
||||||
@@ -37,10 +61,12 @@ class Teleop:
|
|||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
||||||
|
self.joy = JoyInput(config.deadman.joy_ttl_s)
|
||||||
self.view = ui.ArmView()
|
self.view = ui.ArmView()
|
||||||
# The velocity the browser last asked for, and when. A jog is a stream;
|
# What the browser last asked for, and when. A jog is a stream; what
|
||||||
# what matters is whether the stream is still arriving.
|
# matters is whether the stream is still arriving. Normalised, like the
|
||||||
self.web_velocity = STOP_VELOCITY
|
# pad's — the console does not know the robot's units.
|
||||||
|
self.web_axes = STOP_AXES
|
||||||
self.web_seen_at = 0.0
|
self.web_seen_at = 0.0
|
||||||
self.clients = 0
|
self.clients = 0
|
||||||
# Whether the arm is currently being driven by us. Used to send the stop
|
# Whether the arm is currently being driven by us. Used to send the stop
|
||||||
@@ -58,7 +84,7 @@ class Teleop:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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()
|
args = _parse_args()
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
|
|
||||||
@@ -66,8 +92,6 @@ def main() -> None:
|
|||||||
# dora spawns it, the id from the dataflow wins.
|
# dora spawns it, the id from the dataflow wins.
|
||||||
node = Node(args.name)
|
node = Node(args.name)
|
||||||
state = Teleop(config)
|
state = Teleop(config)
|
||||||
gamepad = Gamepad(config.joystick_index)
|
|
||||||
gamepad.start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for event in node:
|
for event in node:
|
||||||
@@ -75,14 +99,13 @@ def main() -> None:
|
|||||||
raise RuntimeError(event["error"])
|
raise RuntimeError(event["error"])
|
||||||
if event["type"] != "INPUT":
|
if event["type"] != "INPUT":
|
||||||
continue
|
continue
|
||||||
_handle(node, state, gamepad, event)
|
_handle(node, state, event)
|
||||||
finally:
|
finally:
|
||||||
# A motion lives in the arm's controller, not in this process. Leaving
|
# A motion lives in the arm's controller, not in this process. Leaving
|
||||||
# without zeroing the velocity leaves it travelling until the
|
# without zeroing the velocity leaves it travelling until the
|
||||||
# controller's own expiry catches up, which is the one moment nobody is
|
# controller's own expiry catches up, which is the one moment nobody is
|
||||||
# watching for it.
|
# watching for it.
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
gamepad.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args() -> argparse.Namespace:
|
def _parse_args() -> argparse.Namespace:
|
||||||
@@ -96,11 +119,13 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
return parser.parse_args()
|
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."""
|
"""Route one input to whatever deals with it."""
|
||||||
event_id = event["id"]
|
event_id = event["id"]
|
||||||
if event_id == "tick":
|
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":
|
elif event_id == "ui_command":
|
||||||
_accept_ui_command(node, state, event)
|
_accept_ui_command(node, state, event)
|
||||||
elif event_id == "clients":
|
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 ---
|
# --- 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."""
|
"""Decide what the arm should be doing, and say so."""
|
||||||
moment = now()
|
moment = now()
|
||||||
sample = gamepad.read()
|
# 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
|
||||||
_read_buttons(node, state, sample)
|
# 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
|
# 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.
|
# 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)
|
state.arbiter.take(GAMEPAD, moment)
|
||||||
if sample.active:
|
if active:
|
||||||
state.arbiter.note_activity(GAMEPAD, moment)
|
state.arbiter.note_activity(GAMEPAD, moment)
|
||||||
state.arbiter.expire_idle_gamepad(moment)
|
state.arbiter.expire_idle_gamepad(moment)
|
||||||
|
|
||||||
|
_read_buttons(node, state, sample)
|
||||||
|
|
||||||
_drive(node, state, sample, moment)
|
_drive(node, state, sample, moment)
|
||||||
_poll_state(node, state, moment)
|
_poll_state(node, state, moment)
|
||||||
_publish(node, state)
|
_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
|
stream of jogs as one continuous one, and the re-send exists so the
|
||||||
controller's own expiry never fires mid-motion.
|
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
|
state.jogging = True
|
||||||
_send_jog(node, velocity)
|
_send_motion(node, state, axes)
|
||||||
return
|
return
|
||||||
|
|
||||||
if state.jogging:
|
if state.jogging:
|
||||||
# Once, not every tick. Repeating it would keep the arm node believing a
|
# Once, not every tick. Repeating it would keep the arm node believing a
|
||||||
# jog is live and hold `robot_state` at `moving` forever.
|
# jog is live and hold `robot_state` at `moving` forever.
|
||||||
state.jogging = False
|
state.jogging = False
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
|
|
||||||
|
|
||||||
def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]:
|
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
|
||||||
"""Return what the arm should be doing, which is usually nothing.
|
"""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.
|
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
|
That is deliberate: the default has to be "still", so that a case nobody
|
||||||
thought of fails by halting rather than by carrying on.
|
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
|
owner = state.arbiter.owner
|
||||||
if owner == GAMEPAD:
|
if owner == GAMEPAD:
|
||||||
# An unplugged pad is not a centred pad. Stopping here rather than
|
# An unplugged pad is not a centred pad. Stopping here rather than
|
||||||
# waiting out the TTL is the difference between a dropped controller
|
# waiting out the TTL is the difference between a dropped controller
|
||||||
# costing 10 ms of travel and costing 400.
|
# costing 10 ms of travel and costing 400.
|
||||||
if not sample.present:
|
if not sample.present:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
return mapping.velocity(sample.axes, sample.buttons, state.config)
|
return mapping.from_pad(sample.axes, sample.buttons, state.config)
|
||||||
|
|
||||||
if owner == WEB:
|
if owner == WEB:
|
||||||
# The console refreshes its jog every 150 ms; the browser going quiet,
|
# 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.
|
# or the tab closing, has to stop the arm without anything being sent.
|
||||||
if state.clients == 0:
|
if state.clients == 0:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
return state.web_velocity
|
return state.web_axes
|
||||||
|
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
|
|
||||||
|
|
||||||
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
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:
|
def tapped(index: int) -> bool:
|
||||||
return pressed(sample.buttons, index) and not pressed(previous, index)
|
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
|
# Neither the stop nor its release is gated on the lease. Somebody reaching
|
||||||
# moment when asking who owns the arm is the wrong question.
|
# 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):
|
if tapped(LAYOUT.estop):
|
||||||
_estop(node, state)
|
_estop(node, state)
|
||||||
|
if tapped(LAYOUT.clear):
|
||||||
|
_clear(node, state)
|
||||||
if not state.arbiter.may_drive(GAMEPAD):
|
if not state.arbiter.may_drive(GAMEPAD):
|
||||||
return
|
return
|
||||||
if tapped(LAYOUT.grasp):
|
if tapped(LAYOUT.grasp):
|
||||||
_request(node, state, {"service": "grasp"})
|
_act(node, state, GRIPPER_CLOSE)
|
||||||
if tapped(LAYOUT.release):
|
if tapped(LAYOUT.release):
|
||||||
_request(node, state, {"service": "release"})
|
_act(node, state, GRIPPER_OPEN)
|
||||||
if tapped(LAYOUT.clear):
|
|
||||||
_request(node, state, {"service": "clear"})
|
|
||||||
if tapped(LAYOUT.freedrive):
|
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:
|
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)
|
_control(node, state, command, moment)
|
||||||
return
|
return
|
||||||
if command.type == "reset":
|
if command.type == "reset":
|
||||||
# Clearing the latch is this node's business; clearing the controller's
|
_clear(node, state)
|
||||||
# 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"})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not state.arbiter.may_drive(WEB):
|
if not state.arbiter.may_drive(WEB):
|
||||||
@@ -271,18 +309,18 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if command.type == "jog":
|
if command.type == "jog":
|
||||||
state.web_velocity = command.velocity
|
state.web_axes = mapping.from_console(command.axes)
|
||||||
state.web_seen_at = moment
|
state.web_seen_at = moment
|
||||||
state.arbiter.note_activity(WEB, moment)
|
state.arbiter.note_activity(WEB, moment)
|
||||||
elif command.type == "jog_stop":
|
elif command.type == "jog_stop":
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
state.web_seen_at = moment
|
state.web_seen_at = moment
|
||||||
elif command.type == "home":
|
elif command.type == "home":
|
||||||
_request(node, state, {"service": "home", "wait": False})
|
_act(node, state, HOME)
|
||||||
elif command.type == "gripper":
|
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":
|
elif command.type == "freedrive":
|
||||||
_request(node, state, {"service": "freedrive", "enabled": command.enabled})
|
_freedrive(node, state, command.enabled)
|
||||||
elif command.type == "move":
|
elif command.type == "move":
|
||||||
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
_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."""
|
"""Hand the arm to the console, or take it back."""
|
||||||
if command.action == "release":
|
if command.action == "release":
|
||||||
state.arbiter.release(WEB)
|
state.arbiter.release(WEB)
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
return
|
return
|
||||||
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
||||||
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
_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))
|
state.clients = int(row.get("connected", 0))
|
||||||
if state.clients == 0:
|
if state.clients == 0:
|
||||||
state.arbiter.release(WEB)
|
state.arbiter.release(WEB)
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
|
|
||||||
|
|
||||||
# --- what the arm says back ---
|
# --- what the arm says back ---
|
||||||
@@ -336,14 +374,57 @@ def _estop(node: Node, state: Teleop) -> None:
|
|||||||
log("e-stop")
|
log("e-stop")
|
||||||
state.view.estop = True
|
state.view.estop = True
|
||||||
state.jogging = False
|
state.jogging = False
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
_request(node, state, {"service": "stop"})
|
_act(node, state, STOP)
|
||||||
|
|
||||||
|
|
||||||
def _send_jog(node: Node, velocity: list[float]) -> None:
|
def _clear(node: Node, state: Teleop) -> None:
|
||||||
"""Send one velocity to the arm. There is no answer to a jog, ever."""
|
"""Release the latch and clear the controller's error, in that order.
|
||||||
node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}]))
|
|
||||||
|
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:
|
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
|
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
|
be right and the part that is cheapest to check.
|
||||||
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.
|
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
|
The screen convention comes from the old web console, so the two agree: stick
|
||||||
up is +X, stick right is +Y.
|
up is +X, stick right is +Y.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
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)
|
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)
|
@dataclass(frozen=True)
|
||||||
class Layout:
|
class Layout:
|
||||||
@@ -43,6 +58,17 @@ class Layout:
|
|||||||
|
|
||||||
LAYOUT = 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:
|
def deadzone(value: float, width: float) -> float:
|
||||||
"""Ignore travel near centre, and rescale what is left to the full range.
|
"""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
|
return bool(buttons[index]) if index < len(buttons) else False
|
||||||
|
|
||||||
|
|
||||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||||
"""Map one gamepad reading to a base-frame velocity.
|
"""Map one gamepad reading to the normalised six-vector.
|
||||||
|
|
||||||
Translation comes from the left stick and the triggers, rotation from the
|
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
|
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
|
is analog and blended, which is exactly what a four-sector dial pad cannot do.
|
||||||
pad could not 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:
|
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
|
out = [0.0] * 6
|
||||||
# Up on the stick is negative in SDL, and up should send the arm away from
|
# Up on the stick is negative in SDL, and up should send the arm away from
|
||||||
# the operator, along +X.
|
# the operator, along +X.
|
||||||
out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s
|
out[VX] = shaped(-axis(axes, layout.left_y))
|
||||||
out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s
|
out[VY] = shaped(axis(axes, layout.left_x))
|
||||||
|
out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)))
|
||||||
lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger))
|
out[WX] = shaped(axis(axes, layout.right_x))
|
||||||
out[VZ] = shaped(lift) * config.speed_mm_s
|
out[WY] = shaped(-axis(axes, layout.right_y))
|
||||||
|
out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left))
|
||||||
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
|
|
||||||
return out
|
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:
|
def is_moving(velocity_vector: list[float]) -> bool:
|
||||||
"""Whether this velocity would actually drive the arm."""
|
"""Whether this velocity would actually drive the arm."""
|
||||||
return any(velocity_vector)
|
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)
|
||||||
+46
-27
@@ -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
|
`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the
|
||||||
reports, the buttons it sees pressed, and the velocity this node would send.
|
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
|
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
|
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
|
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 :func:`~dora_teleop.mapping.velocity` the node runs, on the same
|
||||||
— the same code the node runs — so what it prints is what the arm would get.
|
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
|
Because it reads `joy` rather than a pad, the dataflow has to declare it:
|
||||||
arm; this only reads.
|
|
||||||
|
```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 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.config import Config
|
||||||
from dora_teleop.gamepad import Gamepad
|
|
||||||
from dora_teleop.mapping import LAYOUT
|
from dora_teleop.mapping import LAYOUT
|
||||||
|
|
||||||
# What each velocity component is, in the order the arm takes them.
|
# 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:
|
if not sample.present:
|
||||||
lines += [
|
lines += [
|
||||||
" no gamepad found.",
|
" no gamepad.",
|
||||||
"",
|
"",
|
||||||
f" looking for joystick index {config.joystick_index};",
|
" `dora-joy` is reporting no pad, or has stopped reporting at all.",
|
||||||
" plug one in and this will pick it up.",
|
" Either way the arm is being told to hold still.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
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())
|
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"
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Open the pad and print what it is doing until interrupted."""
|
"""Attach to a running dataflow and print what the pad is doing."""
|
||||||
parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.")
|
parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.")
|
||||||
parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
gamepad = Gamepad(config.joystick_index)
|
node = Node(args.name)
|
||||||
gamepad.start()
|
|
||||||
|
|
||||||
try:
|
for event in node:
|
||||||
while True:
|
if event["type"] != "INPUT" or event["id"] != "joy":
|
||||||
sample = gamepad.read()
|
continue
|
||||||
|
sample = joy.decode(event)
|
||||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||||
time.sleep(1.0 / args.hz)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
finally:
|
|
||||||
gamepad.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+17
-9
@@ -49,7 +49,10 @@ class Command:
|
|||||||
"""One thing the console asked for."""
|
"""One thing the console asked for."""
|
||||||
|
|
||||||
type: str
|
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 = ""
|
action: str = ""
|
||||||
preempt: bool = False
|
preempt: bool = False
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@@ -70,10 +73,10 @@ def command_of(payload: dict) -> Command | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if kind == "jog":
|
if kind == "jog":
|
||||||
velocity = payload.get("velocity") or []
|
axes = payload.get("axes") or []
|
||||||
if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity):
|
if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes):
|
||||||
return None
|
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"):
|
if kind in ("jog_stop", "estop", "reset", "home"):
|
||||||
return Command(type=kind)
|
return Command(type=kind)
|
||||||
if kind == "control":
|
if kind == "control":
|
||||||
@@ -83,7 +86,7 @@ def command_of(payload: dict) -> Command | None:
|
|||||||
preempt=bool(payload.get("preempt", False)),
|
preempt=bool(payload.get("preempt", False)),
|
||||||
)
|
)
|
||||||
if kind == "gripper":
|
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":
|
if kind == "freedrive":
|
||||||
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
||||||
if kind == "move":
|
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:
|
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
|
||||||
"""Build the telemetry object the console reads.
|
"""Build the telemetry object the console reads.
|
||||||
|
|
||||||
Every key here is read by name in `app.js`. `vacuum` is the console's name
|
Every key here is read by name in `app.js`, so this and the page change
|
||||||
for the mounted tool whatever it actually is, kept because renaming it would
|
together. The names are the robot-agnostic ones: `gripper` rather than
|
||||||
mean editing the page for no gain.
|
`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 {
|
return {
|
||||||
"type": "telemetry",
|
"type": "telemetry",
|
||||||
@@ -158,10 +162,14 @@ def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float)
|
|||||||
"arm_state": arm_state(view, jogging),
|
"arm_state": arm_state(view, jogging),
|
||||||
"owner": owner or "none",
|
"owner": owner or "none",
|
||||||
"estop": view.estop,
|
"estop": view.estop,
|
||||||
"vacuum": view.tool_engaged,
|
"gripper": view.tool_engaged,
|
||||||
"freedrive": view.freedrive_on,
|
"freedrive": view.freedrive_on,
|
||||||
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
||||||
"joints": list(view.joints),
|
"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=).
|
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
||||||
requires-python = ">=3.11"
|
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 = [
|
dependencies = [
|
||||||
"dora-rs >= 1.0.1",
|
"dora-rs >= 1.0.1",
|
||||||
"pyarrow >= 17.0",
|
"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]
|
[dependency-groups]
|
||||||
|
|||||||
+37
-17
@@ -2,10 +2,22 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pyarrow as pa
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from dora_teleop.config import Config
|
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:
|
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]
|
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
|
||||||
|
|
||||||
def jogs(self):
|
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")]
|
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):
|
def services(self):
|
||||||
"""Return the name of every service called, in order."""
|
"""Return the name of every service called, in order."""
|
||||||
return [row["service"] for row, _ in self.outputs("request")]
|
return [row["service"] for row, _ in self.outputs("request")]
|
||||||
@@ -41,20 +61,6 @@ class RecordingNode:
|
|||||||
self.sent.clear()
|
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):
|
def stick(axes=None, buttons=None, present=True):
|
||||||
"""Build one gamepad reading, padded to a full six axes."""
|
"""Build one gamepad reading, padded to a full six axes."""
|
||||||
values = list(axes or [])
|
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)
|
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):
|
def input_event(event_id, value, metadata=None):
|
||||||
"""Build the event dict dora hands a node for one input."""
|
"""Build the event dict dora hands a node for one input."""
|
||||||
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
||||||
@@ -80,8 +100,8 @@ def clean_env(monkeypatch):
|
|||||||
"SPEED_DEG_S",
|
"SPEED_DEG_S",
|
||||||
"DEADZONE",
|
"DEADZONE",
|
||||||
"EXPO",
|
"EXPO",
|
||||||
"JOYSTICK_INDEX",
|
|
||||||
"JOG_TTL_S",
|
"JOG_TTL_S",
|
||||||
|
"JOY_TTL_S",
|
||||||
"GAMEPAD_IDLE_RELEASE_S",
|
"GAMEPAD_IDLE_RELEASE_S",
|
||||||
"STATE_POLL_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_mm_s == 60.0
|
||||||
assert config.speed_deg_s == 30.0
|
assert config.speed_deg_s == 30.0
|
||||||
assert config.joystick_index == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
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
|
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):
|
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
||||||
monkeypatch.setenv("SPEED_MM_S", " ")
|
monkeypatch.setenv("SPEED_MM_S", " ")
|
||||||
|
|
||||||
@@ -32,10 +38,12 @@ def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
|
|||||||
Config.from_env()
|
Config.from_env()
|
||||||
|
|
||||||
|
|
||||||
def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch):
|
def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
|
||||||
monkeypatch.setenv("JOYSTICK_INDEX", "1.5")
|
# 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()
|
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):
|
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
||||||
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
||||||
|
monkeypatch.setenv("JOY_TTL_S", "0.1")
|
||||||
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
||||||
|
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
|
|
||||||
assert config.deadman.jog_ttl_s == 0.25
|
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
|
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 import main as loop
|
||||||
from dora_teleop.arbiter import GAMEPAD, WEB
|
from dora_teleop.arbiter import GAMEPAD, WEB
|
||||||
from dora_teleop.config import Config
|
from dora_teleop.config import Config
|
||||||
from dora_teleop.gamepad import Sample
|
from dora_teleop.joy import Sample
|
||||||
from tests.conftest import FakeGamepad, RecordingNode, input_event, stick
|
from tests.conftest import RecordingNode, input_event, joy_message, stick
|
||||||
|
|
||||||
STOP = [0.0] * 6
|
STOP = [0.0] * 6
|
||||||
PUSHED = [0.0, -1.0]
|
PUSHED = [0.0, -1.0]
|
||||||
@@ -34,19 +34,32 @@ def clock(monkeypatch):
|
|||||||
def build():
|
def build():
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
config.expo = 0.0
|
config.expo = 0.0
|
||||||
return RecordingNode(), loop.Teleop(config), FakeGamepad()
|
return RecordingNode(), loop.Teleop(config)
|
||||||
|
|
||||||
|
|
||||||
def jog_from(node, state, gamepad, count=1):
|
def send_joy(node, state, sample):
|
||||||
"""Run `count` ticks and return every velocity that reached the arm."""
|
"""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):
|
for _ in range(count):
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
return node.jogs()
|
return node.jogs()
|
||||||
|
|
||||||
|
|
||||||
def web_jog(node, state, velocity):
|
def drive(node, state, sample, count=1):
|
||||||
"""Deliver one console jog message."""
|
"""Hold one reading for `count` ticks, the way a real pad and joy node would."""
|
||||||
payload = json.dumps({"type": "jog", "velocity": velocity})
|
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])))
|
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):
|
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.
|
# Nobody should have to reach for a browser to use the pad in their hands.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
|
||||||
|
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = drive(node, state, stick(axes=PUSHED))
|
||||||
|
|
||||||
assert state.arbiter.owner == GAMEPAD
|
assert state.arbiter.owner == GAMEPAD
|
||||||
assert jogs[-1][0] > 0
|
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):
|
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
|
# 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.
|
# difference between a dropped controller costing 10 ms of travel and 400.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
drive(node, state, stick(axes=PUSHED))
|
||||||
jog_from(node, state, gamepad)
|
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = Sample(present=False)
|
jogs = drive(node, state, Sample(present=False))
|
||||||
jogs = jog_from(node, state, gamepad)
|
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
|
|
||||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
def test_the_joy_node_dying_stops_the_arm(clock):
|
||||||
node, state, gamepad = build()
|
# The pad is another process now, and that process dying looks like silence
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
# rather than like an unplugged pad. It has to mean the same thing: a
|
||||||
jog_from(node, state, gamepad)
|
# reading nobody is refreshing is not evidence that the stick is still held.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=PUSHED))
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = stick()
|
clock.advance(state.config.deadman.joy_ttl_s + 0.01)
|
||||||
jogs = jog_from(node, state, gamepad)
|
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]
|
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):
|
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
|
# A stream of zeroes would keep the arm node believing a jog is live and
|
||||||
# hold robot_state at `moving` forever.
|
# hold robot_state at `moving` forever.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
drive(node, state, stick(axes=PUSHED))
|
||||||
jog_from(node, state, gamepad)
|
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = stick()
|
jogs = drive(node, state, stick(), count=5)
|
||||||
jogs = jog_from(node, state, gamepad, count=5)
|
|
||||||
|
|
||||||
assert jogs == [STOP]
|
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):
|
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
|
# The arm's controller expires a velocity on its own; the re-send is what
|
||||||
# keeps that backstop from firing mid-motion.
|
# keeps that backstop from firing mid-motion.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
|
||||||
|
|
||||||
jogs = jog_from(node, state, gamepad, count=4)
|
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||||
|
|
||||||
assert len(jogs) == 4
|
assert len(jogs) == 4
|
||||||
assert all(jog[0] > 0 for jog in jogs)
|
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 ---
|
# --- the browser ---
|
||||||
|
|
||||||
|
|
||||||
def test_the_last_browser_leaving_stops_the_arm(clock):
|
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.
|
# 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.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
jog_from(node, state, gamepad)
|
ticks(node, state)
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
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 jogs == [STOP]
|
||||||
assert state.arbiter.owner is None
|
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):
|
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
|
||||||
# The console refreshes every 150 ms against a 400 ms deadline.
|
# The console refreshes every 150 ms against a 400 ms deadline.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
jog_from(node, state, gamepad)
|
ticks(node, state)
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
clock.advance(0.5)
|
clock.advance(0.5)
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = ticks(node, state)
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
|
|
||||||
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
|
||||||
sent = []
|
sent = []
|
||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
clock.advance(0.15)
|
clock.advance(0.15)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
sent = node.jogs()
|
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):
|
def test_a_non_owner_jog_is_ignored(clock):
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(GAMEPAD, clock.t)
|
state.arbiter.take(GAMEPAD, clock.t)
|
||||||
|
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
|
|
||||||
assert node.jogs() == []
|
assert node.jogs() == []
|
||||||
assert any(message.get("type") == "error" for message in node.ui())
|
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):
|
def test_estop_is_never_gated_by_the_lease(clock):
|
||||||
# Somebody reaching for the stop is the one moment when asking who owns the
|
# Somebody reaching for the stop is the one moment when asking who owns the
|
||||||
# arm is the wrong question.
|
# arm is the wrong question.
|
||||||
node, state, _ = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(GAMEPAD, clock.t)
|
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()
|
assert "stop" in node.services()
|
||||||
|
|
||||||
|
|
||||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
|
||||||
node, state, gamepad = build()
|
# A stop somebody had to reach for is not undone by the next thumb
|
||||||
state.arbiter.take(WEB, clock.t)
|
# movement. Without this the pad stops the arm for one tick and drives it
|
||||||
gamepad.sample = stick(buttons=[False] * 6 + [True])
|
# 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()
|
assert "stop" in node.services()
|
||||||
|
|
||||||
|
|
||||||
def test_a_latched_estop_is_cleared_by_reset(clock):
|
def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||||
node, state, _ = build()
|
node, state = build()
|
||||||
state.view.estop = True
|
state.view.estop = True
|
||||||
|
|
||||||
payload = json.dumps({"type": "reset"})
|
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 state.view.estop is False
|
||||||
assert "clear" in node.services()
|
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[VX] == 0.0
|
||||||
assert velocity[WZ] == 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."""
|
"""The probe: what it draws, without a pad or a terminal."""
|
||||||
|
|
||||||
from dora_teleop import mapping, probe
|
from dora_teleop import mapping, probe
|
||||||
from dora_teleop.gamepad import Sample
|
from dora_teleop.joy import Sample
|
||||||
from tests.conftest import stick
|
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)
|
assert "#" in probe.bar(4.0)
|
||||||
|
|
||||||
|
|
||||||
def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config):
|
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
|
||||||
config.joystick_index = 2
|
# 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)
|
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
||||||
|
|
||||||
assert "no gamepad found" in drawn
|
assert "no gamepad" in drawn
|
||||||
assert "index 2" in drawn
|
assert "hold still" in drawn
|
||||||
|
|
||||||
|
|
||||||
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
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)
|
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
||||||
|
|
||||||
assert "moving: yes" in drawn
|
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():
|
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.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():
|
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():
|
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():
|
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():
|
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", "closed": True}).enabled is True
|
||||||
assert ui.command_of({"type": "gripper", "vacuum": False}).enabled is False
|
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
|
||||||
|
|
||||||
|
|
||||||
def test_a_move_needs_a_position():
|
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",
|
"arm_state",
|
||||||
"owner",
|
"owner",
|
||||||
"estop",
|
"estop",
|
||||||
"vacuum",
|
"gripper",
|
||||||
|
"units",
|
||||||
"freedrive",
|
"freedrive",
|
||||||
"position",
|
"position",
|
||||||
"joints",
|
"joints",
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dora-rs" },
|
{ name = "dora-rs" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
{ name = "pygame-ce" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -59,7 +58,6 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||||
{ name = "pyarrow", specifier = ">=17.0" },
|
{ name = "pyarrow", specifier = ">=17.0" },
|
||||||
{ name = "pygame-ce", specifier = ">=2.4" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
+218
-17
@@ -3,47 +3,137 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>teleop — placeholder console</title>
|
<title>teleop console</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
||||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
h1 { font-size: 16px; margin: 0 0 4px; }
|
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;
|
.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 { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
|
||||||
.row span:last-child { color: #7fd1e8; }
|
.row span:last-child { color: #7fd1e8; }
|
||||||
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
||||||
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer;
|
||||||
button:hover { background: #363648; }
|
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 { 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; }
|
#link { color: #8a8a9e; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>teleop — placeholder console</h1>
|
<h1>teleop console</h1>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Not the real console. This page exists so the dataflow has something to serve and so the
|
Hold a direction to move; release to stop. The page never sends a speed in the robot's
|
||||||
node can be checked end to end before the Control section is ported across from the
|
units — it sends a fraction of full deflection, exactly as a gamepad stick does, and the
|
||||||
monolithic repo. It reads telemetry and can take, release and stop — nothing else.
|
node scales it. That is why this console does not need to know what robot is on the
|
||||||
|
other end.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card" id="state"></div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
|
<h2>State</h2>
|
||||||
<button onclick="send({type:'control', action:'release'})">Release</button>
|
<div id="state"></div>
|
||||||
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
|
|
||||||
<button onclick="send({type:'reset'})">Reset</button>
|
|
||||||
</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>
|
<div id="link"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"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 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) {
|
function send(payload) {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
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) {
|
function render(telemetry) {
|
||||||
|
current = telemetry;
|
||||||
const rows = KEYS.map((key) =>
|
const rows = KEYS.map((key) =>
|
||||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||||
const pose = telemetry.position;
|
const pose = telemetry.position;
|
||||||
if (pose) {
|
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(", ");
|
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("");
|
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() {
|
function connect() {
|
||||||
@@ -72,13 +268,18 @@ function connect() {
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type !== "data" || !message.payload) return;
|
if (message.type !== "data" || !message.payload) return;
|
||||||
if (message.payload.type === "telemetry") render(message.payload);
|
if (message.payload.type === "telemetry") render(message.payload);
|
||||||
|
if (message.payload.type === "error") {
|
||||||
|
document.getElementById("toast").textContent = message.payload.detail;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
releaseEverything();
|
||||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||||
setTimeout(connect, 2000);
|
setTimeout(connect, 2000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wire();
|
||||||
connect();
|
connect();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user