read the gamepad from dora-joy and publish twist and command

The pad moves to dora-joy, so this node keeps no hardware dependency and the
pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read`
treats silence past JOY_TTL_S as absence, because that node dying looks like
silence rather than like an unplugged pad and the arm cannot tell them apart.

Both control sources are now normalised to a [-1, 1] six-vector before anything
is scaled, and the scaling happens in one function. The web path used to pass a
velocity straight through without going near mapping, which is why the console
had to speak the arm's units. It no longer does.

`twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog`
and `request` still carry the same intent in dora-ulite6's units and service
names so the two repositories can move independently; LEGACY_REQUEST is the
only place that knows what that node calls a verb. Wire one or the other, never
both. `joint_jog` is specified in the manifest and deliberately not built.

Schemas are declared rather than inferred: pyarrow reads a null `value` as its
own type, so an inferred `command` would change its port's type between one
verb and the next.

Three bugs found while moving this code, each with a test:

- `Sample.active` compared raw axes against a threshold, but SDL rests a
  trigger at -1.0, so every connected pad read as in use for ever: the pad took
  the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired.
- The e-stop latch was never consulted when deciding a velocity, so Back
  stopped the arm for one tick and the next stick nudge drove it again. Nothing
  on the pad could clear it either. It is now checked above the lease, and X
  releases it and clears the controller's error, as the console's RESET does.
- Buttons were read before the arbiter granted the lease, so the first press
  after the pad had been idle was swallowed: not owned yet when read, and no
  longer a rising edge by the next tick.
This commit is contained in:
2026-09-16 12:18:51 -05:00
parent 885b7a569f
commit d336f8d499
21 changed files with 1165 additions and 433 deletions
+84
View File
@@ -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
+102
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
) )
-135
View File
@@ -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()
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+245 -53
View File
@@ -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"]
+61
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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",
Generated
-50
View File
@@ -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"