Files
dora-joy/README.md
T
cristhianandClaude Opus 5 e934c1ca3a
validate / validate (push) Successful in 58m43s
validate / test (push) Successful in 58m44s
validate / markdownlint (push) Successful in 58m40s
update version to 0.1.1 and pin examples to the tag
`v0.1` is an annotated tag, which dora 1.0.1 cannot check out: it records
the tag object's id as the commit hash, then rejects the checkout for
sitting on the peeled commit. `v0.1.1` is lightweight and resolves
straight to the commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:35:47 -03:00

5.8 KiB

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:

nodes:
  - id: joy
    git: https://gitea.liria.cl/liria/dora-joy.git
    tag: v0.1.1
    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:

    inputs:
      joy:
        source: joy/joy
        queue_size: 1
        queue_policy: drop_oldest

Runnable dataflow

dataflow.yml is the node plus a printer, and is what CI runs:

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:

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:

    env:
      JOYSTICK_INDEX: 1

Checking your pad

uv run dora-joy-probe

Prints every axis and every button by index, live, with no dataflow and nothing attached. It cannot move anything.

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 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:

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

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

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.