# 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 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`, 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`. 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`.