diff --git a/dataflow-gamepad.yml b/dataflow-gamepad.yml new file mode 100644 index 0000000..fb1e779 --- /dev/null +++ b/dataflow-gamepad.yml @@ -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 diff --git a/dataflow-robot.yml b/dataflow-robot.yml new file mode 100644 index 0000000..cdf69be --- /dev/null +++ b/dataflow-robot.yml @@ -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" diff --git a/dataflow.yml b/dataflow.yml index ac2067d..bd4582e 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -4,10 +4,13 @@ # 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. # +# 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 -# telemetry but never moves anything — it exists so the node can be driven from -# a real gamepad with no arm in the room. README.md carries the three-node -# wiring a consumer actually uses. +# telemetry but never moves anything. README.md carries the four-node wiring a +# consumer actually uses. strict_types: true @@ -29,6 +32,8 @@ nodes: tcp_pose: bench/tcp_pose joint_pose: bench/joint_pose outputs: + - twist + - command - jog - request - ui_state diff --git a/dora-node.yml b/dora-node.yml index f89ef20..c61d5a1 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -5,11 +5,12 @@ # dataflow against it without running the node, so CI needs neither a gamepad # 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 -# pattern-correlated and skips type checks on it, and several Arrow schemas -# legitimately flow over each — one per service, one per console message. -# Annotating them would claim a contract neither side keeps. +# pattern-correlated and skips type checks on it, several Arrow schemas +# legitimately flow over each — one per service, one per console message — and +# dora 1.0 ships no standard joystick type. Annotating them would claim a +# contract neither side keeps. apiVersion: 1 name: dora-teleop @@ -34,6 +35,24 @@ inputs: 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. type: std/core/v1/UInt64 + joy: + required: false + description: >- + One gamepad reading from `dora-joy`, as the Arrow struct + `{axes: list, buttons: list, 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: required: false description: >- @@ -79,23 +98,63 @@ inputs: type: std/core/v1/Float32 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: description: >- - A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}` - in mm/s and deg/s. Re-sent every tick while a source is driving, and - followed by exactly one all-zero vector when it stops — once, not - repeatedly, because a stream of zeroes would keep the arm node believing a - jog is live. + **Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units: + `{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame. + Published alongside `twist` while that node learns the standard one. + + Wire `twist` **or** `jog`, never both — they carry the same intent, and a + consumer reading both would be driven twice. request: description: >- - A service call on the arm — `stop`, `grasp`, `release`, `clear`, - `freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the - metadata. + **Deprecated; use `command`.** The same verbs under the service names + `dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`, + `home` — with `request_id` in the metadata. `move_to` and `get_state` have + no standard equivalent yet and go only here. ui_state: description: >- 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 - 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: SPEED_MM_S: @@ -124,10 +183,6 @@ env: 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 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: type: float default: 0.4 @@ -135,6 +190,14 @@ env: 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 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: type: float default: 2.0 @@ -156,6 +219,10 @@ example: | path: dora-teleop inputs: tick: dora/timer/millis/50 + joy: + source: joy/joy + queue_size: 1 + queue_policy: drop_oldest ui_command: ws-transceiver/data_out clients: ws-transceiver/clients response: ulite6/response @@ -164,6 +231,8 @@ example: | tcp_pose: ulite6/tcp_pose joint_pose: ulite6/joint_pose outputs: + - twist + - command - jog - request - ui_state diff --git a/dora_teleop/config.py b/dora_teleop/config.py index 3c12dae..3ba6be4 100644 --- a/dora_teleop/config.py +++ b/dora_teleop/config.py @@ -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 -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 class DeadmanConfig: """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 - tick; `jog_ttl_s` is how long it will keep doing so without hearing from the - source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`, - which the controller enforces whether or not this process is alive. + Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays + worth acting on; this node re-sends the current velocity every tick; + `jog_ttl_s` is how long it will keep doing so without hearing from the + 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. """ 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 # browser can take over without anyone having to press anything. gamepad_idle_release_s: float = 2.0 @@ -57,6 +53,7 @@ class DeadmanConfig: """Build the timings from the environment.""" return cls( 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), 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 # positioning happens near centre, and expo is what buys resolution there. expo: float - joystick_index: int deadman: DeadmanConfig = field(default_factory=DeadmanConfig) @classmethod @@ -89,11 +85,18 @@ class Config: expo = env_float("EXPO", 0.4) if not 0.0 <= expo <= 1.0: 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( speed_mm_s=env_float("SPEED_MM_S", 60.0), speed_deg_s=env_float("SPEED_DEG_S", 30.0), deadzone=deadzone, expo=expo, - joystick_index=env_int("JOYSTICK_INDEX", 0), - deadman=DeadmanConfig.from_env(), + deadman=deadman, ) diff --git a/dora_teleop/gamepad.py b/dora_teleop/gamepad.py deleted file mode 100644 index c569234..0000000 --- a/dora_teleop/gamepad.py +++ /dev/null @@ -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() diff --git a/dora_teleop/joy.py b/dora_teleop/joy.py new file mode 100644 index 0000000..ac4e506 --- /dev/null +++ b/dora_teleop/joy.py @@ -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() diff --git a/dora_teleop/main.py b/dora_teleop/main.py index 8062b76..f4cefe8 100644 --- a/dora_teleop/main.py +++ b/dora_teleop/main.py @@ -2,9 +2,11 @@ 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: -:mod:`dora_teleop.gamepad` owns the hardware, :mod:`dora_teleop.mapping` turns a -reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and -:mod:`dora_teleop.ui` speaks the console's language. +:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping` +turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed +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 keeps moving until told otherwise, so the question is never "should it start" @@ -20,15 +22,37 @@ import time import pyarrow as pa 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.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.mapping import LAYOUT, pressed 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: @@ -37,10 +61,12 @@ class Teleop: def __init__(self, config: Config): self.config = config self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s) + self.joy = JoyInput(config.deadman.joy_ttl_s) self.view = ui.ArmView() - # The velocity the browser last asked for, and when. A jog is a stream; - # what matters is whether the stream is still arriving. - self.web_velocity = STOP_VELOCITY + # What the browser last asked for, and when. A jog is a stream; what + # matters is whether the stream is still arriving. Normalised, like the + # pad's — the console does not know the robot's units. + self.web_axes = STOP_AXES self.web_seen_at = 0.0 self.clients = 0 # Whether the arm is currently being driven by us. Used to send the stop @@ -58,7 +84,7 @@ class Teleop: 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() config = Config.from_env() @@ -66,8 +92,6 @@ def main() -> None: # dora spawns it, the id from the dataflow wins. node = Node(args.name) state = Teleop(config) - gamepad = Gamepad(config.joystick_index) - gamepad.start() try: for event in node: @@ -75,14 +99,13 @@ def main() -> None: raise RuntimeError(event["error"]) if event["type"] != "INPUT": continue - _handle(node, state, gamepad, event) + _handle(node, state, event) finally: # A motion lives in the arm's controller, not in this process. Leaving # without zeroing the velocity leaves it travelling until the # controller's own expiry catches up, which is the one moment nobody is # watching for it. - _send_jog(node, STOP_VELOCITY) - gamepad.shutdown() + _send_motion(node, state, STOP_AXES) def _parse_args() -> argparse.Namespace: @@ -96,11 +119,13 @@ def _parse_args() -> argparse.Namespace: 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.""" event_id = event["id"] 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": _accept_ui_command(node, state, event) 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 --- -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.""" moment = now() - sample = gamepad.read() - - _read_buttons(node, state, sample) + # 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 + # 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 # 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) - if sample.active: + if active: state.arbiter.note_activity(GAMEPAD, moment) state.arbiter.expire_idle_gamepad(moment) + _read_buttons(node, state, sample) + _drive(node, state, sample, moment) _poll_state(node, state, moment) _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 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 - _send_jog(node, velocity) + _send_motion(node, state, axes) return if state.jogging: # Once, not every tick. Repeating it would keep the arm node believing a # jog is live and hold `robot_state` at `moving` forever. state.jogging = False - _send_jog(node, STOP_VELOCITY) + _send_motion(node, state, STOP_AXES) -def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]: - """Return what the arm should be doing, which is usually nothing. +def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]: + """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. That is deliberate: the default has to be "still", so that a case nobody 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 if owner == GAMEPAD: # An unplugged pad is not a centred pad. Stopping here rather than # waiting out the TTL is the difference between a dropped controller # costing 10 ms of travel and costing 400. if not sample.present: - return STOP_VELOCITY - return mapping.velocity(sample.axes, sample.buttons, state.config) + return STOP_AXES + return mapping.from_pad(sample.axes, sample.buttons, state.config) if owner == WEB: # 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. if state.clients == 0: - return STOP_VELOCITY + return STOP_AXES if moment - state.web_seen_at > state.config.deadman.jog_ttl_s: - return STOP_VELOCITY - return state.web_velocity + return STOP_AXES + return state.web_axes - return STOP_VELOCITY + return STOP_AXES 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: 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 - # moment when asking who owns the arm is the wrong question. + # Neither the stop nor its release is gated on the lease. Somebody reaching + # 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): _estop(node, state) + if tapped(LAYOUT.clear): + _clear(node, state) if not state.arbiter.may_drive(GAMEPAD): return if tapped(LAYOUT.grasp): - _request(node, state, {"service": "grasp"}) + _act(node, state, GRIPPER_CLOSE) if tapped(LAYOUT.release): - _request(node, state, {"service": "release"}) - if tapped(LAYOUT.clear): - _request(node, state, {"service": "clear"}) + _act(node, state, GRIPPER_OPEN) 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: @@ -259,11 +301,7 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None: _control(node, state, command, moment) return if command.type == "reset": - # Clearing the latch is this node's business; clearing the controller's - # 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"}) + _clear(node, state) return if not state.arbiter.may_drive(WEB): @@ -271,18 +309,18 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None: return if command.type == "jog": - state.web_velocity = command.velocity + state.web_axes = mapping.from_console(command.axes) state.web_seen_at = moment state.arbiter.note_activity(WEB, moment) elif command.type == "jog_stop": - state.web_velocity = STOP_VELOCITY + state.web_axes = STOP_AXES state.web_seen_at = moment elif command.type == "home": - _request(node, state, {"service": "home", "wait": False}) + _act(node, state, HOME) 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": - _request(node, state, {"service": "freedrive", "enabled": command.enabled}) + _freedrive(node, state, command.enabled) elif command.type == "move": _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.""" if command.action == "release": state.arbiter.release(WEB) - state.web_velocity = STOP_VELOCITY + state.web_axes = STOP_AXES return if not state.arbiter.take(WEB, moment, preempt=command.preempt): _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)) if state.clients == 0: state.arbiter.release(WEB) - state.web_velocity = STOP_VELOCITY + state.web_axes = STOP_AXES # --- what the arm says back --- @@ -336,14 +374,57 @@ def _estop(node: Node, state: Teleop) -> None: log("e-stop") state.view.estop = True state.jogging = False - state.web_velocity = STOP_VELOCITY - _send_jog(node, STOP_VELOCITY) - _request(node, state, {"service": "stop"}) + state.web_axes = STOP_AXES + _send_motion(node, state, STOP_AXES) + _act(node, state, STOP) -def _send_jog(node: Node, velocity: list[float]) -> None: - """Send one velocity to the arm. There is no answer to a jog, ever.""" - node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}])) +def _clear(node: Node, state: Teleop) -> None: + """Release the latch and clear the controller's error, in that order. + + 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: diff --git a/dora_teleop/mapping.py b/dora_teleop/mapping.py index 93969b9..db08c19 100644 --- a/dora_teleop/mapping.py +++ b/dora_teleop/mapping.py @@ -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 -be right and the part that is cheapest to check. Everything here is in the -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. +be right and the part that is cheapest to check. + +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 up is +X, stick right is +Y. """ +import math 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) +# 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) class Layout: @@ -43,6 +58,17 @@ class 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: """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 -def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]: - """Map one gamepad reading to a base-frame velocity. +def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]: + """Map one gamepad reading to the normalised six-vector. 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 - is analog and blended, which is exactly what the console's four-sector dial - pad could not do. + is analog and blended, which is exactly what a four-sector dial pad cannot 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: @@ -104,20 +133,87 @@ def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LA out = [0.0] * 6 # Up on the stick is negative in SDL, and up should send the arm away from # the operator, along +X. - out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s - out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s - - lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)) - out[VZ] = shaped(lift) * config.speed_mm_s - - 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 + out[VX] = shaped(-axis(axes, layout.left_y)) + out[VY] = shaped(axis(axes, layout.left_x)) + out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger))) + out[WX] = shaped(axis(axes, layout.right_x)) + out[WY] = shaped(-axis(axes, layout.right_y)) + out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left)) 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: """Whether this velocity would actually drive the arm.""" return any(velocity_vector) diff --git a/dora_teleop/message.py b/dora_teleop/message.py new file mode 100644 index 0000000..784df30 --- /dev/null +++ b/dora_teleop/message.py @@ -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) diff --git a/dora_teleop/probe.py b/dora_teleop/probe.py index a99d96f..da9a1af 100644 --- a/dora_teleop/probe.py +++ b/dora_teleop/probe.py @@ -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 -reports, the buttons it sees pressed, and the velocity this node would send. +`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the +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 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 -:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity` -— the same code the node runs — so what it prints is what the arm would get. +the same :func:`~dora_teleop.mapping.velocity` the node runs, on the same +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 -arm; this only reads. +Because it reads `joy` rather than a pad, the dataflow has to declare it: + +```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 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.gamepad import Gamepad from dora_teleop.mapping import LAYOUT # 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: lines += [ - " no gamepad found.", + " no gamepad.", "", - f" looking for joystick index {config.joystick_index};", - " plug one in and this will pick it up.", + " `dora-joy` is reporting no pad, or has stopped reporting at all.", + " Either way the arm is being told to hold still.", ] 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()) 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) def main() -> None: - """Open the pad and print what it is doing until interrupted.""" - parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.") - parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.") + """Attach to a running dataflow and print what the pad is doing.""" + parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.") + 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() config = Config.from_env() - gamepad = Gamepad(config.joystick_index) - gamepad.start() + node = Node(args.name) - try: - while True: - sample = gamepad.read() - velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6 - print(CLEAR + render(sample, velocity, config), flush=True) - time.sleep(1.0 / args.hz) - except KeyboardInterrupt: - print() - finally: - gamepad.shutdown() + for event in node: + if event["type"] != "INPUT" or event["id"] != "joy": + continue + sample = joy.decode(event) + velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6 + print(CLEAR + render(sample, velocity, config), flush=True) if __name__ == "__main__": diff --git a/dora_teleop/ui.py b/dora_teleop/ui.py index c780779..ecfc381 100644 --- a/dora_teleop/ui.py +++ b/dora_teleop/ui.py @@ -49,7 +49,10 @@ class Command: """One thing the console asked for.""" 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 = "" preempt: bool = False enabled: bool = False @@ -70,10 +73,10 @@ def command_of(payload: dict) -> Command | None: return None if kind == "jog": - velocity = payload.get("velocity") or [] - if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity): + axes = payload.get("axes") or [] + if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes): 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"): return Command(type=kind) if kind == "control": @@ -83,7 +86,7 @@ def command_of(payload: dict) -> Command | None: preempt=bool(payload.get("preempt", False)), ) 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": return Command(type="freedrive", enabled=bool(payload.get("enabled", False))) 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: """Build the telemetry object the console reads. - Every key here is read by name in `app.js`. `vacuum` is the console's name - for the mounted tool whatever it actually is, kept because renaming it would - mean editing the page for no gain. + Every key here is read by name in `app.js`, so this and the page change + together. The names are the robot-agnostic ones: `gripper` rather than + `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 { "type": "telemetry", @@ -158,10 +162,14 @@ def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) "arm_state": arm_state(view, jogging), "owner": owner or "none", "estop": view.estop, - "vacuum": view.tool_engaged, + "gripper": view.tool_engaged, "freedrive": view.freedrive_on, "position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None, "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"}, } diff --git a/pyproject.toml b/pyproject.toml index ac8c21d..1f88614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,11 @@ authors = [{ name = "Liria" }] # dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=). 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 = [ "dora-rs >= 1.0.1", "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] diff --git a/tests/conftest.py b/tests/conftest.py index ca847fb..b9a65c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,22 @@ import json +import pyarrow as pa import pytest 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`, +# 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: @@ -26,9 +38,17 @@ class RecordingNode: return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id] 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")] + 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): """Return the name of every service called, in order.""" return [row["service"] for row, _ in self.outputs("request")] @@ -41,20 +61,6 @@ class RecordingNode: 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): """Build one gamepad reading, padded to a full six axes.""" 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) +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): """Build the event dict dora hands a node for one input.""" return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})} @@ -80,8 +100,8 @@ def clean_env(monkeypatch): "SPEED_DEG_S", "DEADZONE", "EXPO", - "JOYSTICK_INDEX", "JOG_TTL_S", + "JOY_TTL_S", "GAMEPAD_IDLE_RELEASE_S", "STATE_POLL_S", ): diff --git a/tests/test_config.py b/tests/test_config.py index b418b31..6c1bf37 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,7 +10,6 @@ def test_the_defaults_need_no_environment(): assert config.speed_mm_s == 60.0 assert config.speed_deg_s == 30.0 - assert config.joystick_index == 0 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 +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): monkeypatch.setenv("SPEED_MM_S", " ") @@ -32,10 +38,12 @@ def test_a_speed_that_is_not_a_number_names_itself(monkeypatch): Config.from_env() -def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch): - monkeypatch.setenv("JOYSTICK_INDEX", "1.5") +def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch): + # 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() @@ -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): monkeypatch.setenv("JOG_TTL_S", "0.25") + monkeypatch.setenv("JOY_TTL_S", "0.1") monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5") config = Config.from_env() 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 diff --git a/tests/test_deadman.py b/tests/test_deadman.py index 9e5054a..d3c6505 100644 --- a/tests/test_deadman.py +++ b/tests/test_deadman.py @@ -8,8 +8,8 @@ import pytest from dora_teleop import main as loop from dora_teleop.arbiter import GAMEPAD, WEB from dora_teleop.config import Config -from dora_teleop.gamepad import Sample -from tests.conftest import FakeGamepad, RecordingNode, input_event, stick +from dora_teleop.joy import Sample +from tests.conftest import RecordingNode, input_event, joy_message, stick STOP = [0.0] * 6 PUSHED = [0.0, -1.0] @@ -34,19 +34,32 @@ def clock(monkeypatch): def build(): config = Config.from_env() config.expo = 0.0 - return RecordingNode(), loop.Teleop(config), FakeGamepad() + return RecordingNode(), loop.Teleop(config) -def jog_from(node, state, gamepad, count=1): - """Run `count` ticks and return every velocity that reached the arm.""" +def send_joy(node, state, sample): + """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): - loop._tick(node, state, gamepad) + loop._tick(node, state) return node.jogs() -def web_jog(node, state, velocity): - """Deliver one console jog message.""" - payload = json.dumps({"type": "jog", "velocity": velocity}) +def drive(node, state, sample, count=1): + """Hold one reading for `count` ticks, the way a real pad and joy node would.""" + 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]))) @@ -55,10 +68,9 @@ def web_jog(node, state, velocity): 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. - node, state, gamepad = build() - gamepad.sample = stick(axes=PUSHED) + node, state = build() - jogs = jog_from(node, state, gamepad) + jogs = drive(node, state, stick(axes=PUSHED)) assert state.arbiter.owner == GAMEPAD 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): # 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. - node, state, gamepad = build() - gamepad.sample = stick(axes=PUSHED) - jog_from(node, state, gamepad) + node, state = build() + drive(node, state, stick(axes=PUSHED)) node.clear() - gamepad.sample = Sample(present=False) - jogs = jog_from(node, state, gamepad) + jogs = drive(node, state, Sample(present=False)) assert jogs == [STOP] -def test_a_stick_returning_to_centre_stops_the_arm(clock): - node, state, gamepad = build() - gamepad.sample = stick(axes=PUSHED) - jog_from(node, state, gamepad) +def test_the_joy_node_dying_stops_the_arm(clock): + # The pad is another process now, and that process dying looks like silence + # rather than like an unplugged pad. It has to mean the same thing: a + # reading nobody is refreshing is not evidence that the stick is still held. + node, state = build() + drive(node, state, stick(axes=PUSHED)) node.clear() - gamepad.sample = stick() - jogs = jog_from(node, state, gamepad) + clock.advance(state.config.deadman.joy_ttl_s + 0.01) + 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] @@ -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): # A stream of zeroes would keep the arm node believing a jog is live and # hold robot_state at `moving` forever. - node, state, gamepad = build() - gamepad.sample = stick(axes=PUSHED) - jog_from(node, state, gamepad) + node, state = build() + drive(node, state, stick(axes=PUSHED)) node.clear() - gamepad.sample = stick() - jogs = jog_from(node, state, gamepad, count=5) + jogs = drive(node, state, stick(), count=5) 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): # The arm's controller expires a velocity on its own; the re-send is what # keeps that backstop from firing mid-motion. - node, state, gamepad = build() - gamepad.sample = stick(axes=PUSHED) + node, state = build() - jogs = jog_from(node, state, gamepad, count=4) + jogs = drive(node, state, stick(axes=PUSHED), count=4) assert len(jogs) == 4 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 --- 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. - node, state, gamepad = build() + node, state = build() state.clients = 1 state.arbiter.take(WEB, clock.t) - web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) - jog_from(node, state, gamepad) + web_jog(node, state, [1.0, 0, 0, 0, 0, 0]) + ticks(node, state) node.clear() 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 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): # The console refreshes every 150 ms against a 400 ms deadline. - node, state, gamepad = build() + node, state = build() state.clients = 1 state.arbiter.take(WEB, clock.t) - web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) - jog_from(node, state, gamepad) + web_jog(node, state, [1.0, 0, 0, 0, 0, 0]) + ticks(node, state) node.clear() clock.advance(0.5) - jogs = jog_from(node, state, gamepad) + jogs = ticks(node, state) assert jogs == [STOP] def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock): - node, state, gamepad = build() + node, state = build() state.clients = 1 state.arbiter.take(WEB, clock.t) sent = [] for _ in range(4): clock.advance(0.15) - web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) - loop._tick(node, state, gamepad) + web_jog(node, state, [1.0, 0, 0, 0, 0, 0]) + loop._tick(node, state) 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): - node, state, gamepad = build() + node, state = build() state.clients = 1 state.arbiter.take(GAMEPAD, clock.t) - web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) - loop._tick(node, state, gamepad) + web_jog(node, state, [1.0, 0, 0, 0, 0, 0]) + loop._tick(node, state) assert node.jogs() == [] 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): # Somebody reaching for the stop is the one moment when asking who owns the # arm is the wrong question. - node, state, _ = build() + node, state = build() state.clients = 1 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() -def test_the_estop_button_works_without_the_pad_holding_the_arm(clock): - node, state, gamepad = build() - state.arbiter.take(WEB, clock.t) - gamepad.sample = stick(buttons=[False] * 6 + [True]) +def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock): + # A stop somebody had to reach for is not undone by the next thumb + # movement. Without this the pad stops the arm for one tick and drives it + # 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() def test_a_latched_estop_is_cleared_by_reset(clock): - node, state, _ = build() + node, state = build() state.view.estop = True 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 "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"] diff --git a/tests/test_joy.py b/tests/test_joy.py new file mode 100644 index 0000000..0cd0571 --- /dev/null +++ b/tests/test_joy.py @@ -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 diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 86a7274..66c5347 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -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[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 diff --git a/tests/test_probe.py b/tests/test_probe.py index 279ad01..2dd4e2f 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -1,7 +1,7 @@ """The probe: what it draws, without a pad or a terminal.""" from dora_teleop import mapping, probe -from dora_teleop.gamepad import Sample +from dora_teleop.joy import Sample 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) -def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config): - config.joystick_index = 2 - +def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config): + # 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) - assert "no gamepad found" in drawn - assert "index 2" in drawn + assert "no gamepad" in drawn + assert "hold still" in drawn 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) 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) diff --git a/tests/test_ui.py b/tests/test_ui.py index ee6c5c8..cbf0034 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -7,18 +7,18 @@ from dora_teleop.arbiter import GAMEPAD, WEB 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.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(): - 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(): - 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(): @@ -36,8 +36,8 @@ def test_taking_control_carries_whether_it_may_preempt(): 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", "vacuum": False}).enabled is False + assert ui.command_of({"type": "gripper", "closed": True}).enabled is True + assert ui.command_of({"type": "gripper", "closed": False}).enabled is False 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", "owner", "estop", - "vacuum", + "gripper", + "units", "freedrive", "position", "joints", diff --git a/uv.lock b/uv.lock index 8fdacc5..4caf60c 100644 --- a/uv.lock +++ b/uv.lock @@ -46,7 +46,6 @@ source = { editable = "." } dependencies = [ { name = "dora-rs" }, { name = "pyarrow" }, - { name = "pygame-ce" }, ] [package.dev-dependencies] @@ -59,7 +58,6 @@ dev = [ requires-dist = [ { name = "dora-rs", specifier = ">=1.0.1" }, { name = "pyarrow", specifier = ">=17.0" }, - { name = "pygame-ce", specifier = ">=2.4" }, ] [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" }, ] -[[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]] name = "pygments" version = "2.21.0"