docs: simplify the README and record the pad layout
The README carried a lot of rationale that AGENTS.md already holds; what is left is the tables a consumer wires against. It gains the swept axis and button indices, and says plainly that the axes check themselves at runtime and the buttons cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,16 +46,19 @@ uv sync --group dev # plus pytest and ruff
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
|
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:
|
dora run dataflow-robot.yml # the pad and a real arm, both pulled in by git:
|
||||||
|
dora run dataflow-gamepad.yml # the same, with the probe declared and no console
|
||||||
```
|
```
|
||||||
|
|
||||||
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||||||
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
|
is what lets CI validate it. Keep it that way; the pad belongs in the other two. Neither
|
||||||
|
of those takes `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone
|
||||||
|
and then looks for its entrypoint in the dataflow's venv. README.md has the detail.
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
|
uv run pytest # 149 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 .
|
||||||
```
|
```
|
||||||
@@ -74,6 +77,14 @@ a source goes away**.
|
|||||||
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
|
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
|
||||||
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
|
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
|
||||||
operator let go" and "the pad fell off the desk" stay distinguishable.
|
operator let go" and "the pad fell off the desk" stay distinguishable.
|
||||||
|
**`Calibration` is the other half of that seam.** It measures a pad the first time it is
|
||||||
|
seen at rest and hands the rest of the node a reading as the layout's ideal pad would
|
||||||
|
have sent it, so a worn stick's tilt is subtracted rather than deadzoned and a trigger
|
||||||
|
that rests at 0 stops being a special case. A pad whose resting axes do not fit `Layout`
|
||||||
|
reads as *absent* and is never corrected: one reading cannot tell a trigger at -1.0 from
|
||||||
|
a stick held hard over, and guessing wrong drives a real arm. It is not re-measured
|
||||||
|
while the pad is in use — doing that under a held stick would record the deflection as
|
||||||
|
centre, and the arm would move when the operator let go.
|
||||||
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
|
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
|
||||||
into another process added a way for it to go away that did not exist before — that
|
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.
|
process dying — and from here that looks like silence rather than like an unplugged pad.
|
||||||
@@ -90,7 +101,12 @@ a source goes away**.
|
|||||||
units: mm/s and deg/s, base frame. `is_active` lives here rather than on `Sample`
|
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
|
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
|
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
|
||||||
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires.
|
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires. `rest_zeros` and `canonical` are the
|
||||||
|
same concern one layer out: they check a resting pad against `Layout` and map it onto
|
||||||
|
the ideal one. They are the only defence against a wrong index, which is not a
|
||||||
|
theoretical failure — an Xbox One S pad reporting its triggers on 4 and 5 against a
|
||||||
|
layout expecting them on 2 and 5 read as a full-speed pitch command from an untouched
|
||||||
|
pad, held the lease for ever, and moved a real arm into a controller error.
|
||||||
- **`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 is already built on it — it gates its controls on
|
lease because the console is already built on it — it gates its controls on
|
||||||
@@ -170,6 +186,13 @@ belongs here, and a port nothing wires reads as supported while having never run
|
|||||||
pair; nothing can enforce the second from here.
|
pair; nothing can enforce the second from here.
|
||||||
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
|
- **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.
|
without waiting out any timer beyond the one that detects it.
|
||||||
|
- **A pad is driven from only after it has been seen at rest**, and a pad whose rest does
|
||||||
|
not fit `Layout` is not driven from at all. This covers the axes and **cannot cover the
|
||||||
|
buttons**: an unpressed button reads the same on every pad, so a wrong button index is
|
||||||
|
silent until somebody presses it and gets another verb. Sweeping a new pad with the
|
||||||
|
probe is the only check, and it is worth doing before the arm is powered. The cost is that a pad held when it appears
|
||||||
|
is ignored until it is let go; the alternative is guessing a numbering from one reading,
|
||||||
|
against a real arm.
|
||||||
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
|
- **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
|
a velocity straight through, which is why the console had to speak mm/s. One conversion
|
||||||
point or none.
|
point or none.
|
||||||
|
|||||||
@@ -4,430 +4,301 @@ 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, reads the pad from
|
`jog` and `request` ports, reads the pad from
|
||||||
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and **serves the browser console
|
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and serves the browser console itself
|
||||||
itself** on one port. It contains no robot driver and no gamepad driver: it is the piece
|
on one port. It contains no robot driver and no gamepad driver: it is the piece in the
|
||||||
in the middle that decides what the arm should be doing, and that decision is the only
|
middle that decides what the arm should be doing, and it touches no hardware at all.
|
||||||
thing it owns. It touches no hardware at all.
|
|
||||||
|
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`.
|
||||||
|
|
||||||
## The gamepad
|
## The gamepad
|
||||||
|
|
||||||
The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
| Control | Does | Units |
|
||||||
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.
|
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
|
||||||
|
| Triggers | Lift on the right, lower on the left | mm/s |
|
||||||
|
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
|
||||||
|
| Bumpers | Yaw, left and right | deg/s |
|
||||||
|
| A | Close the tool | — |
|
||||||
|
| B | Open the tool | — |
|
||||||
|
| X | Release the E-STOP and clear the arm's error | — |
|
||||||
|
| Y | Toggle freedrive | — |
|
||||||
|
| Back | **E-STOP** — latches until X | — |
|
||||||
|
|
||||||
Analog and blended — several axes at once, and how fast depends on how far you push. That
|
Up on the stick sends the arm away from you, along **+X** in the base frame — the same
|
||||||
is the whole reason a pad beats the console, whose ring sectors ask for one direction at
|
convention the console uses, so the two agree.
|
||||||
the speed the dial is set to. The console can still hold two at once — a sector and a Z
|
|
||||||
pill — but it cannot ask for a third of full speed on one axis and full on another.
|
|
||||||
|
|
||||||
| Control | Does | Units |
|
**Back latches.** It stops the arm, tells the controller to stop, and keeps the arm still
|
||||||
|-------------|-------------------------------------------|-------|
|
no matter what the sticks do. **X is the only way out**, and it does both halves: releases
|
||||||
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
|
the latch here and clears the error on the controller. Neither button is gated on the
|
||||||
| Triggers | Lift on the right, lower on the left | mm/s |
|
lease — an idle pad hands the arm back two seconds after the stop it just made, and a
|
||||||
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
|
latch you must first take the arm back to release is a trap.
|
||||||
| Bumpers | Yaw, left and right | deg/s |
|
|
||||||
| A | Close the tool | — |
|
|
||||||
| B | Open the tool | — |
|
|
||||||
| X | Release the E-STOP and clear the arm's error | — |
|
|
||||||
| Y | Toggle freedrive | — |
|
|
||||||
| Back | **E-STOP** — latches until X | — |
|
|
||||||
|
|
||||||
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
|
|
||||||
the same convention the web console's dial uses, so the two agree.
|
|
||||||
|
|
||||||
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
|
|
||||||
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
|
|
||||||
environment variable: an index nobody can verify from outside is worse than a line of code
|
|
||||||
somebody can read. 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.
|
||||||
|
|
||||||
**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm
|
### The layout
|
||||||
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
|
These are the indices an Xbox One S pad reports **over Bluetooth**, swept one control at a
|
||||||
here and clears the error on the controller. Neither button is gated on the lease, because
|
time. They are not universal — the same pad on USB numbers its buttons differently — so a
|
||||||
an idle pad hands the arm back two seconds after the stop it just made, and a latch you
|
pad that disagrees is an edit to `Layout` in `dora_teleop/mapping.py`, not a new
|
||||||
must first take the arm back to release is a trap.
|
environment variable. It lives here rather than in `dora-joy` because `dora-joy` does not
|
||||||
|
know what a button is *for*, which is what makes it reusable.
|
||||||
|
|
||||||
|
| Control | Axis | Control | Button |
|
||||||
|
|----------------------|------|------------------|--------|
|
||||||
|
| Left stick X / Y | 0, 1 | A — grasp | 0 |
|
||||||
|
| Right stick X / Y | 2, 3 | B — release | 1 |
|
||||||
|
| Left / right trigger | 4, 5 | X — clear | 3 |
|
||||||
|
| | | Y — freedrive | 4 |
|
||||||
|
| | | LB / RB — yaw | 6, 7 |
|
||||||
|
| | | Back — E-STOP | 11 |
|
||||||
|
|
||||||
|
**Sweep a new pad before the arm is powered.** The axes check themselves and the buttons
|
||||||
|
cannot:
|
||||||
|
|
||||||
|
- **Axes.** Every pad is measured once, on the first reading in which it is sitting still.
|
||||||
|
A stick must rest near centre and a trigger near the bottom of its travel; an axis
|
||||||
|
resting anywhere else means the numbering is wrong, something is being held, or the pad
|
||||||
|
is broken. All three read as **no pad** until it rests, and the log names the axis. A
|
||||||
|
trigger read as a stick asks for full speed while lying untouched on the desk, so a pad
|
||||||
|
this node has never seen still never gets the arm.
|
||||||
|
- **Buttons.** An unpressed button reads the same on every pad, so a wrong button index is
|
||||||
|
silent until it fires. That is not hypothetical: on this pad, against the indices this
|
||||||
|
node shipped with, X toggled freedrive and LB was the E-STOP.
|
||||||
|
|
||||||
|
That same measurement makes a worn stick harmless — its resting tilt becomes its zero, so
|
||||||
|
the tilt is **subtracted** rather than deadzoned away and the travel left still reaches
|
||||||
|
full speed. A trigger that reads 0 until it first moves, rather than SDL's −1, is handled
|
||||||
|
the same way instead of reading as half pressed.
|
||||||
|
|
||||||
### Checking your pad
|
### Checking your pad
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora up
|
source .venv/bin/activate
|
||||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
dora build dataflow-gamepad.yml
|
||||||
|
dora run dataflow-gamepad.yml
|
||||||
uv run dora-teleop-probe # in another terminal
|
uv run dora-teleop-probe # in another terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
|
The probe prints the raw axes, the buttons it sees and the velocity this node would send,
|
||||||
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
|
live, from the same `joy` stream the node reads. **It cannot move anything** — it attaches
|
||||||
dynamic node, publishes nothing and never talks to the arm; it only reads.
|
as a dynamic node, publishes nothing and never talks to the arm.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
axes (6 reported)
|
axes (6 reported)
|
||||||
0 left stick X +0.600 ----------|-----#----
|
0 left stick X +0.600 ----------|-----#----
|
||||||
1 left stick Y -0.800 --#-------|----------
|
1 left stick Y -0.800 --#-------|----------
|
||||||
2 left trigger -1.000 #---------|----------
|
4 left trigger -1.000 #---------|----------
|
||||||
...
|
buttons (17 reported)
|
||||||
buttons (6 reported)
|
7:yaw right
|
||||||
5:yaw right
|
|
||||||
|
|
||||||
velocity this would send to the arm
|
velocity this would send to the arm
|
||||||
vx +38.89 mm/s forward / back <--
|
vx +38.89 mm/s forward / back <--
|
||||||
vy +23.53 mm/s left / right <--
|
vy +23.53 mm/s left / right <--
|
||||||
vz +20.39 mm/s up / down <--
|
|
||||||
wz +30.00 deg/s yaw <--
|
wz +30.00 deg/s yaw <--
|
||||||
|
|
||||||
moving: yes
|
moving: yes
|
||||||
asking for the arm: yes
|
asking for the arm: yes
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this before trusting the table above. The indices are SDL's documented layout, not
|
Two things to look for: an axis marked `unused` that moves when you push a stick, and a
|
||||||
observed ones, and a pad that numbers its axes differently is much better found here than
|
bar that does not sit centred with the stick released. It goes through the same
|
||||||
by watching an arm move the wrong way. Two things to look for:
|
`velocity()` the node runs, so what it prints is what the arm would be sent.
|
||||||
|
|
||||||
- **Every axis is listed, including ones the layout does not use** — an axis marked
|
For a pad whose numbering is not known at all, `dora-joy-probe` in the `dora-joy` repo
|
||||||
`unused` that moves when you push a stick means the layout needs changing.
|
needs no dataflow.
|
||||||
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
|
||||||
itself, and a reason to raise `DEADZONE`.
|
|
||||||
|
|
||||||
It goes through the same `velocity()` the node runs, on the same readings, so what it
|
|
||||||
prints is what the arm would be sent.
|
|
||||||
|
|
||||||
For a pad whose layout is not known at all — which index is even which — use
|
|
||||||
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow and no coordinator.
|
|
||||||
|
|
||||||
## Speaking to any robot
|
## Speaking to any robot
|
||||||
|
|
||||||
Nothing above is specific to one arm. Both control sources are reduced to a **normalised**
|
Both control sources are reduced to a **normalised** six-vector — `[-1, 1]` per degree of
|
||||||
six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only
|
freedom, a fraction of full speed — and only then scaled, in one function. The gamepad
|
||||||
then scaled, in one function. A gamepad reaches that vector through the deadzone and the
|
reaches that vector through the deadzone and the response curve, which correct a worn
|
||||||
response curve, which exist to correct a worn physical stick; the browser sends it already
|
physical stick; the browser sends it already clean.
|
||||||
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:
|
That is what lets one node and one console drive a robot in mm/s and one in m/s. `twist`
|
||||||
neither source ever knew the difference. It is also why a gamepad and the console asking
|
is SI, because that is what a UR or MoveIt takes; configuration stays in mm/s and deg/s,
|
||||||
for the same deflection produce byte-identical output.
|
because `60 mm/s` is a number a person reading a dataflow can judge. `command` carries
|
||||||
|
verbs rather than service names — `gripper_close`, not `grasp` — so swapping a vacuum for
|
||||||
|
a two-finger gripper needs no change at either end.
|
||||||
|
|
||||||
`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt
|
The full contract, including the reserved `joint_jog` port, is in the vault at
|
||||||
takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person
|
`resources/robotics/teleop-contract.md`.
|
||||||
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
|
||||||
|
|
||||||
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
||||||
|
|
||||||
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
|
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
|
||||||
Nobody should have to reach for a browser to use the thing in their hands.
|
|
||||||
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
||||||
bench does not lock the browser out forever.
|
bench does not lock the browser out.
|
||||||
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
|
- The **browser takes it explicitly** with TAKE CONTROL and returns it with RELEASE. It
|
||||||
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
|
can preempt: an operator who can see the arm is better placed than this code to decide.
|
||||||
is better placed than this code to decide they should have it.
|
- **E-STOP is never gated**, from either source, held lease or not.
|
||||||
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
|
|
||||||
the stop is the one moment when asking who owns the arm is the wrong question.
|
|
||||||
|
|
||||||
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
|
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists,
|
||||||
Another node publishing `request` will preempt an operator mid-motion and nothing here can
|
so another node publishing `request` will preempt an operator mid-motion.
|
||||||
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. Four layers, each
|
||||||
never whether to start but whether anything has happened that means it must stop. Four
|
shorter than the one behind it — a backstop that fires first backs up nothing:
|
||||||
layers, outermost last:
|
|
||||||
|
|
||||||
1. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) while a source is driving.
|
| Layer | Setting | Default |
|
||||||
2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
|
|--------------------------|-------------------------------------|---------|
|
||||||
process dying looks like silence rather than like an unplugged pad — so silence means
|
| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
|
||||||
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
|
| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
|
||||||
is what makes silence unambiguous.
|
| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
|
||||||
3. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
|
| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
|
||||||
and stops it *immediately*, without waiting out the timer, when the gamepad reports
|
|
||||||
itself gone or the last browser disconnects. An unplugged pad is not a centred pad.
|
|
||||||
4. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
|
|
||||||
the controller whether or not this process is alive.
|
|
||||||
|
|
||||||
Keep them in that order, each shorter than the one behind it. A backstop that fires before
|
`JOY_TTL_S >= JOG_TTL_S` stops the node at startup rather than failing quietly later. The
|
||||||
the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at
|
last layer is enforced by `dora-ulite6` whether or not this process is alive.
|
||||||
startup rather than letting that happen quietly.
|
|
||||||
|
|
||||||
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
A pad reporting itself gone, or the last browser disconnecting, stops the arm
|
||||||
controller, not in this process.
|
**immediately** without waiting out any timer: an unplugged pad is not a centred pad. On
|
||||||
|
shutdown the node sends one all-zero velocity, because a motion lives in the controller
|
||||||
|
rather than in this process.
|
||||||
|
|
||||||
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
|
**What goes on the wire is the change.** A velocity is a set-point the arm holds, so one
|
||||||
believing a jog is live and hold `robot_state` at `moving` forever.
|
is sent when it *changes* and otherwise repeated only every `JOG_KEEPALIVE_S`. Repeating
|
||||||
|
every tick made a real xArm re-plan the same motion twenty times a second, which reads as
|
||||||
### What goes on the wire is the change
|
stepping. The stop is sent **once** — a stream of zeroes keeps `dora-ulite6` believing a
|
||||||
|
jog is live. A change never waits for the repeat.
|
||||||
A velocity is a set-point the arm holds, not a frame in a sequence. This node sends one
|
|
||||||
when it **changes** — a new direction, or the stop — and otherwise repeats the current one
|
|
||||||
only every `JOG_KEEPALIVE_S` (0.2 s), which exists solely so the arm's own expiry never
|
|
||||||
fires mid-motion.
|
|
||||||
|
|
||||||
It used to repeat every tick. On a real xArm that means the controller is handed the same
|
|
||||||
velocity twenty times a second and re-plans the same motion each time, which an operator
|
|
||||||
feels as the arm moving in steps rather than travelling. Keep the repeat well under the
|
|
||||||
arm's `VELOCITY_COMMAND_TTL_S` and no faster than it needs to be.
|
|
||||||
|
|
||||||
**A change never waits for the repeat.** Releasing a button is acted on the tick it
|
|
||||||
happens, so sending less does not cost anything at the operator's end.
|
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
| Direction | Name | Description |
|
| Direction | Name | Description |
|
||||||
|-----------|----------------|------------------------------------------------------|
|
|-----------|----------------|-------------------------------------------------------|
|
||||||
| in | `tick` | 50 ms — change detection, deadman, telemetry |
|
| in | `tick` | 50 ms — change detection, deadman, telemetry |
|
||||||
| in | `joy` | One pad reading from `dora-joy` |
|
| in | `joy` | One pad reading from `dora-joy` |
|
||||||
| in | `response` | An answer from the arm |
|
| in | `response` | An answer from the arm |
|
||||||
| in | `motion_state` | How a motion ended |
|
| in | `motion_state` | How a motion ended |
|
||||||
| 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 | `twist` | **Standard.** `{linear, angular, frame}`, SI |
|
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
|
||||||
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
|
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
|
||||||
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
|
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
|
||||||
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
|
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
|
||||||
| out | `ui_state` | The console's telemetry snapshot, as JSON — see below |
|
| 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
|
Wire `twist` **or** `jog`, never both — they carry the same intent, and a robot reading
|
||||||
both is driven twice. Same for `command` and `request`.
|
both is driven twice. Same for `command` and `request`.
|
||||||
|
|
||||||
The browser is not on this table. It reaches the node over the node's own HTTP and
|
The browser is not on this table: it reaches the node over the node's own HTTP and
|
||||||
WebSocket port rather than through the dataflow, so there is nothing to wire for it —
|
WebSocket port rather than through the dataflow, so there is nothing to wire for it.
|
||||||
`ui_state` is published for anyone else watching and has no consumer in the stacks below.
|
|
||||||
|
|
||||||
## Environment Variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|--------------------------|---------|--------------------------------------------------------|
|
|--------------------------|----------|--------------------------------------------------------|
|
||||||
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
|
||||||
| `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 |
|
||||||
| `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 |
|
| `JOG_KEEPALIVE_S` | `0.2` | How often an unchanged velocity is repeated |
|
||||||
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
|
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
|
||||||
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
|
| `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 |
|
||||||
|
| `WEB_HOST` | `0.0.0.0`| Address the console binds to |
|
||||||
|
| `WEB_PORT` | `7000` | Console port; **`0` opens no port at all** |
|
||||||
|
|
||||||
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
|
A value that cannot be read, or one that breaks the deadman order, stops the node at
|
||||||
`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time
|
startup rather than the first time somebody picks up the pad. `JOYSTICK_INDEX` moved to
|
||||||
somebody picks up the pad.
|
`dora-joy` along with the pad.
|
||||||
|
|
||||||
`JOYSTICK_INDEX` moved to `dora-joy` along with the pad.
|
## Running it
|
||||||
|
|
||||||
## Usage
|
| Dataflow | Nodes | Needs |
|
||||||
|
|-------------------------|------------------------------|-----------------|
|
||||||
The real stack is three nodes: this one, the pad and the arm. The console comes with
|
| `dataflow.yml` | teleop + bench stand-in | nothing |
|
||||||
this node.
|
| `dataflow-robot.yml` | joy + teleop + ulite6 | a pad and an arm |
|
||||||
|
| `dataflow-gamepad.yml` | the same, plus the probe, console shut | a pad and an arm |
|
||||||
```yaml
|
|
||||||
- id: teleop
|
|
||||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
|
||||||
tag: v0.3.0
|
|
||||||
build: pip install .
|
|
||||||
path: dora-teleop
|
|
||||||
inputs:
|
|
||||||
tick: dora/timer/millis/50
|
|
||||||
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: [jog, request, ui_state]
|
|
||||||
env:
|
|
||||||
WEB_PORT: "7000"
|
|
||||||
```
|
|
||||||
|
|
||||||
Wire `ulite6`'s `request` and `jog` from this node, and 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.
|
|
||||||
|
|
||||||
Then open `http://<the machine running teleop>:7000` for the console. Nothing else needs
|
|
||||||
wiring: the node serves the page and holds the browsers' sockets, and the page finds the
|
|
||||||
socket on its own origin.
|
|
||||||
|
|
||||||
Leave `joy` unwired for a browser-only console; the node starts without it. Set
|
|
||||||
`WEB_PORT: "0"` for a pad-only one, and no port is opened.
|
|
||||||
|
|
||||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No
|
|
||||||
hardware: the pad and the arm are other nodes, and the browser is any browser.
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
source .venv/bin/activate # see the note below before reaching for --uv
|
dora run dataflow.yml --uv # no pad, no arm, browser path only
|
||||||
dora build dataflow-robot.yml # clones and installs both, needs the network
|
|
||||||
|
source .venv/bin/activate # a real arm: see the --uv note below
|
||||||
|
dora build dataflow-robot.yml # clones and installs both siblings
|
||||||
dora run dataflow-robot.yml
|
dora run dataflow-robot.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv`
|
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||||||
installs each `git:` node into a venv inside its own clone directory, while `dora run
|
is what lets CI validate it. The bench answers services and publishes telemetry but is
|
||||||
--uv` resolves the entrypoint by running `uv run which <name>` from the *dataflow's*
|
**not a simulator**: it never integrates a velocity or moves a pose.
|
||||||
directory — which only ever sees the dataflow's own venv. The node is installed where the
|
|
||||||
spawner does not look, and the run fails with `Could not find source path dora-joy within
|
|
||||||
uv`. Building into one activated environment, as above, puts every node's entrypoint
|
|
||||||
somewhere the spawner finds it. The venv needs `pip` for that (`uv pip install pip`),
|
|
||||||
since `uv` does not create one.
|
|
||||||
|
|
||||||
`dataflow.yml` is unaffected and `--uv` is fine there: its only Python node is this repo,
|
Set `ROBOT_IP` and `TOOL_TYPE` to match the arm in front of you. `INIT_MODE` is `none`, so
|
||||||
which is already in this repo's venv.
|
nothing moves until somebody pushes a stick. The console comes with the node — open
|
||||||
|
`http://<that machine>:7000` for the same arm the pad drives, under the same lease.
|
||||||
|
|
||||||
Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults
|
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1): each is installed
|
||||||
are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so
|
into a venv inside its own clone, while the spawner resolves entrypoints from the
|
||||||
nothing moves until somebody pushes a stick.
|
dataflow's venv, and the run fails with `Could not find source path dora-joy within uv`.
|
||||||
|
Build into one activated environment instead, as above; it needs `pip`
|
||||||
|
(`uv pip install pip`). **`dora start` refuses them outright** — "node joy has git source,
|
||||||
|
but no `dora build` was run yet", straight after a build that succeeded — so use
|
||||||
|
`dora run`, which also means `dora topic` is unavailable for those two dataflows.
|
||||||
|
|
||||||
The deadman timings have to stay ordered, each below the one behind it, and the file pins
|
`dora` type-checks a dataflow before fetching a `git:` source, so it cannot read the arm's
|
||||||
all of them so the chain is visible in one place:
|
manifest. The `output_types:` block on the `ulite6` node repeats what `dora-ulite6` v0.3.1
|
||||||
|
annotates — re-check it when moving to a newer tag.
|
||||||
| Layer | Setting | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
|
|
||||||
| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
|
|
||||||
| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
|
|
||||||
| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
|
|
||||||
|
|
||||||
The last one is enforced by `dora-ulite6` itself from v0.3.1 on, not by the controller:
|
|
||||||
the velocity is set once and held, so that node stops the arm rather than letting a
|
|
||||||
controller-side timer do it.
|
|
||||||
|
|
||||||
`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.1 annotates, which is also what pinning that tag asserts — re-check it
|
|
||||||
when moving to a newer one.
|
|
||||||
|
|
||||||
The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening
|
|
||||||
`http://<that machine>:7000` gets you the same arm the pad drives, under the same lease.
|
|
||||||
|
|
||||||
### 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,
|
|
||||||
with **no pad**: pulling `dora-joy` in by `git:` would cost that file the one property it
|
|
||||||
has, which is that it resolves and runs from this checkout alone, with no network and no
|
|
||||||
sibling clones.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dora run dataflow.yml --uv # bench only, browser path
|
|
||||||
dora run dataflow-gamepad.yml --uv # the same bench with a real pad
|
|
||||||
```
|
|
||||||
|
|
||||||
`dataflow-gamepad.yml` adds `dora-joy` by `git:` and declares the probe. It needs the
|
|
||||||
network on its first build.
|
|
||||||
|
|
||||||
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
|
||||||
it is sent:
|
|
||||||
|
|
||||||
```text
|
|
||||||
jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
|
|
||||||
jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
|
|
||||||
jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop
|
|
||||||
```
|
|
||||||
|
|
||||||
It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog
|
|
||||||
is printed and forgotten. It closes the loop so this node's behaviour can be watched —
|
|
||||||
the lease changing hands, the deadman firing when you let go — not so the arm can be
|
|
||||||
pretended at.
|
|
||||||
|
|
||||||
### Watching the wire
|
### Watching the wire
|
||||||
|
|
||||||
`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what
|
Every dataflow sets `debug: enable_debug_inspection: true`, so dora will let you read the
|
||||||
is actually on the wire without the bench printing anything. Run it under a coordinator
|
wire without any node printing it. This needs a coordinator, so it works for the
|
||||||
rather than `dora run`:
|
arm-free dataflow:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora up
|
dora up
|
||||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
dora start dataflow.yml --uv --name teleop-bench
|
||||||
```
|
|
||||||
|
|
||||||
Then, in another terminal:
|
|
||||||
|
|
||||||
```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 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
|
||||||
|
dora stop --name teleop-bench && dora down
|
||||||
```
|
```
|
||||||
|
|
||||||
`hz` is the one that checks the timings this node promises. The `get_state` poll measures
|
|
||||||
at 1.88 Hz against a configured `STATE_POLL_S` of 0.5, and with a pad held over the
|
|
||||||
deadzone `teleop/jog` should sit near the 50 ms tick.
|
|
||||||
|
|
||||||
Tear it down with `dora stop --name teleop-bench && dora down`.
|
|
||||||
|
|
||||||
## The console
|
## The console
|
||||||
|
|
||||||
`dora_teleop/web/index.html` is the browser half: two analog sticks, a lift lane and a
|
`dora_teleop/web/index.html` is the browser half: two analog rings, a lift lane and a yaw
|
||||||
yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry
|
lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry
|
||||||
readout. Plain HTML with no build step — the node serves the file as it stands, and a
|
readout. Plain HTML with no build step — a console you cannot fix without a toolchain is
|
||||||
console you cannot fix without a toolchain is the wrong thing to have between a person and
|
the wrong thing to have between a person and a robot.
|
||||||
a robot.
|
|
||||||
|
|
||||||
**Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold,
|
It is laid out like the arm's own pendant: each ring is quartered into sectors you hold,
|
||||||
with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's
|
with the out-of-plane axis as a pair of pills above it — the same shape and the same
|
||||||
jog control on the controller at `:18333`, and deliberately the same directions, because
|
directions as xArm Studio's jog control at `:18333`.
|
||||||
two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a
|
|
||||||
preference.
|
|
||||||
|
|
||||||
| Ring | Sectors | Pills above |
|
| Ring | Sectors | Pills above |
|
||||||
|-----------|------------------------------------------------|----------------|
|
|-----------|------------------------------------------------------------|---------------|
|
||||||
| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` |
|
| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` |
|
||||||
| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` |
|
| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` |
|
||||||
|
|
||||||
**`Y+` is on the left**, matching xArm Studio and the right-hand rule with `+X` away from
|
**`Y+` is on the left**, matching xArm Studio and the right-hand rule with `+X` away from
|
||||||
the operator and `+Z` up. It is the one direction that does not read as "screen right is
|
the operator and `+Z` up. Two consoles that disagree about which way is `+Y` is a trap.
|
||||||
right", and it is that way on purpose.
|
|
||||||
|
|
||||||
Directions are held, not analog: speed comes from the dial, and every held direction is a
|
The node serves the page and holds the WebSocket on `/ws` of the same port, so there is
|
||||||
separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one
|
one URL and nothing to keep in step. That runs on its own thread and meets the control
|
||||||
gesture.
|
loop at two non-blocking points only, because a browser that stops reading must never
|
||||||
|
stall the thread that stops the arm. **The browser's deadman is the socket itself** — the
|
||||||
|
last one closing releases the lease and stops the arm.
|
||||||
|
|
||||||
The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same
|
Directions are held, not analog: speed comes from the dial, and each held direction is a
|
||||||
port, so the console is one URL. That work happens on a thread of its own and meets the
|
separate pointer, so two fingers work at once. The page re-sends its jog every 150 ms
|
||||||
control loop at two points only — the messages the next tick drains, and the snapshot
|
against the node's 400 ms deadline, and sends a **fraction of full deflection**, never a
|
||||||
handed back — because a browser that stops reading must never be able to stall the thread
|
speed, so it never learns the robot's units. Controls are dimmed and inert unless the
|
||||||
that stops the arm.
|
console holds the lease. If `WEB_PORT` is taken the node **refuses to start**.
|
||||||
|
|
||||||
**The browser's deadman is the socket itself.** The last one closing releases the lease
|
|
||||||
and stops the arm, which is why the tab closing mid-jog does not leave it travelling.
|
|
||||||
|
|
||||||
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 dial sets that fraction. Every control is dimmed and inert unless the
|
|
||||||
console holds the lease, so one that would be refused looks refused.
|
|
||||||
|
|
||||||
If `WEB_PORT` is already taken the node **refuses to start** and says so, rather than
|
|
||||||
carrying on with a console nobody can open.
|
|
||||||
|
|
||||||
`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 # 112 tests, no gamepad, arm, browser or daemon needed
|
uv run pytest # 149 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 .
|
||||||
```
|
```
|
||||||
|
|
||||||
Every file here is testable without hardware, because there is no hardware. The pad
|
Every file here is testable without hardware, because there is no hardware. `tests/` names
|
||||||
arrives on `joy` as a plain `Sample`.
|
its controls (`axes_for`, `buttons_for`) rather than numbering them — a test that numbers
|
||||||
|
its axes goes on passing while meaning something else once a pad renumbers them.
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ inputs:
|
|||||||
pad node is indistinguishable from an unplugged pad as far as the arm is
|
pad node is indistinguishable from an unplugged pad as far as the arm is
|
||||||
concerned.
|
concerned.
|
||||||
|
|
||||||
|
Each pad is measured the first time it is seen sitting still, so a stick
|
||||||
|
that no longer centres has its tilt subtracted rather than deadzoned away.
|
||||||
|
A pad whose resting axes do not fit the layout this node compiles in — a
|
||||||
|
trigger where a stick should be, say — reads the same as no pad at all
|
||||||
|
and is named in the log, rather than being guessed at against a real arm.
|
||||||
|
|
||||||
Leave it unwired for a browser-only console. The node says so and starts.
|
Leave it unwired for a browser-only console. The node says so and starts.
|
||||||
response:
|
response:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
Reference in New Issue
Block a user