Files
dora-joy/README.md
T
cristhian 3624c63ead
validate / validate (pull_request) Successful in 58m42s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m40s
add dora-joy node publishing gamepad axes and buttons
2026-09-16 05:10:33 -05:00

182 lines
5.8 KiB
Markdown

# dora-joy
Dora node that reads a gamepad and publishes its axes and buttons as an Arrow struct. A
source for anything driven by hand: it takes a tick and produces readings.
Values go out raw — no deadzone, no response curve, no scaling — and the node never
interprets them. What a stick or a button *means* is a decision that belongs to whatever
acts on it, and two places shaping one number is a bug nobody can see. This repository
names no consumer: it is a source.
## Usage
Add to your dataflow:
```yaml
nodes:
- id: joy
git: https://gitea.liria.cl/liria/dora-joy.git
tag: v0.1.0
build: pip install .
path: dora-joy
inputs:
tick: dora/timer/millis/20
outputs:
- joy
```
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`): the build command is
rewritten to `uv pip install .`, so the node gets its own managed environment and never
installs into an ambient one.
Give the consuming input `queue_size: 1` unless it genuinely wants a backlog. This node
usually ticks faster than whatever reads it, and a consumer acting on a reading should
act on the newest one or on none:
```yaml
inputs:
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
```
### Runnable dataflow
[`dataflow.yml`](dataflow.yml) is the node plus a printer, and is what CI runs:
```bash
dora run dataflow.yml --uv
```
The printer exists so the dataflow is self-contained and runs with no pad and no
consumer. With a pad plugged in it is also the quickest way to see what is on the wire:
```text
joy 41 axes [+0.000 -0.812 -1.000 +0.000 +0.000 -1.000] buttons [-]
joy 42 axes [+0.000 -0.812 -1.000 +0.000 +0.000 -1.000] buttons [0]
joy 43 no gamepad
```
## Output
| Output | Payload | Metadata |
|---|---|---|
| `joy` | `{axes, buttons, present}` struct | none — the struct describes itself |
- **`axes`** — `list<float32>`, raw SDL. A stick runs -1 to 1 and centres near 0; a
**trigger runs -1 to 1 and rests at -1**, which is the thing that catches people out. A
consumer treating a trigger like a stick will see it half pressed at rest.
- **`buttons`** — `list<bool>`. Down or not.
- **`present`** — `bool`, false when no pad is open: unplugged, never plugged in, or taken
away by SDL mid-run. **An absent pad is not a centred pad.** A consumer driving anything
that moves must read this as a stop, not as zeroed axes.
Published on **every** tick, whether or not anything changed, so the stream doubles as a
heartbeat: silence means this node died and nothing else. A consumer that acts on a
reading should stop on silence as well as on `present: false`.
There is no timestamp in the payload. dora already knows when a message arrived, and a
second clock in it would only invite skew questions.
## Inputs
| Input | Description |
|---|---|
| `tick` | Publishes the latest reading. Wire to `dora/timer/millis/N`; the node stops when it closes. |
20 ms is a good default. Ticking faster than the consumer costs almost nothing and keeps
the reading it acts on fresh.
## Environment variables
| Variable | Default | Meaning |
|---|---|---|
| `JOYSTICK_INDEX` | `0` | Which pad to open, when more than one is plugged in. |
A value that cannot be read stops the node at startup rather than the first time somebody
picks up the pad.
A dataflow can carry it per node, which keeps the configuration with the wiring it belongs
to and out of whatever shell happens to start it:
```yaml
env:
JOYSTICK_INDEX: 1
```
## Checking your pad
```bash
uv run dora-joy-probe
```
Prints every axis and every button by index, live, with no dataflow and nothing attached.
**It cannot move anything.**
```text
dora-joy probe — Ctrl-C to stop
axes (6 reported)
0 +0.600 ----------|-----#----
1 -0.800 --#-------|----------
2 -1.000 #---------|----------
...
buttons (6 reported)
5
push one control at a time and note the index it moves.
```
Push one control at a time and read off the index it moves. Whatever consumes this node
has to decide which index is which, and that decision is almost always SDL's documented
numbering rather than an observed one — a pad that disagrees is much better found here
than by watching a machine move the wrong way. A bar that does not sit centred with the
stick released is a worn pad announcing itself.
The probe shows the reading and nothing else. It has no idea what any index is for, which
is the same thing that makes this node reusable.
## Watching the wire
[`dataflow.yml`](dataflow.yml) sets `debug: enable_debug_inspection: true`, so dora will
let you read the port directly without the printer in the way. Run it under a coordinator
rather than `dora run`:
```bash
dora up
dora start dataflow.yml --uv --name joy-bench
dora topic echo -d joy-bench joy/joy # every reading, live
dora topic hz -d joy-bench joy/joy --duration 10 # should sit near 50 Hz
```
`hz` is the one that checks the promise this node makes: a reading every tick, whether or
not the pad moved. Tear it down with `dora stop --name joy-bench && dora down`.
## Build
```bash
uv sync
```
Every dependency ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64, so
the node installs with no system packages and no compiler. `pygame-ce` is used rather than
`evdev` for that reason and because `evdev` is Linux only.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
gamepad if you want one — the node starts without and says so.
## Test
```bash
uv run pytest
uv run ruff check .
uv run ruff format --check .
```
Unit tests over the wire schema, the configuration and the probe's rendering; they need
neither a pad nor a dataflow. `dora_joy/gamepad.py` is the only file that touches pygame,
and the only one that cannot be tested without hardware. Everything else sees a plain
`Sample`.