Read the gamepad from dora-joy, and speak a robot-agnostic contract #2
@@ -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
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Joystick control of a real arm. Three nodes, no bench and no browser.
|
||||||
|
#
|
||||||
|
# This is the one that moves a robot. `dataflow.yml` and `dataflow-gamepad.yml`
|
||||||
|
# drive a stand-in; this one drives `dora-ulite6`, so everything the pad does
|
||||||
|
# here happens in the room. Read `The gamepad` in README.md before picking it up
|
||||||
|
# — Back is the E-STOP.
|
||||||
|
#
|
||||||
|
# Both sibling nodes are pulled by `git:`, so this runs from a fresh clone with
|
||||||
|
# no other checkouts. It needs the network on its first build, and an arm.
|
||||||
|
#
|
||||||
|
# dora build dataflow-robot.yml --uv
|
||||||
|
# dora run dataflow-robot.yml --uv
|
||||||
|
#
|
||||||
|
# Set ROBOT_IP and TOOL_TYPE below to match the arm in front of you. The
|
||||||
|
# defaults are `dora-ulite6`'s own: 192.168.1.192 and a vacuum tool.
|
||||||
|
#
|
||||||
|
# For the web console as well as the pad, add `dora-websocket-transceiver` and
|
||||||
|
# wire `ui_command`, `clients` and its `data_in` — README.md has that wiring.
|
||||||
|
# Without it the console ports below simply go unread, which is why `ui_state`
|
||||||
|
# has no consumer here.
|
||||||
|
|
||||||
|
strict_types: true
|
||||||
|
|
||||||
|
# Lets `dora topic echo` read what is on the wire — the velocities going to the
|
||||||
|
# arm, above all. Run under `dora start` rather than `dora run` to use it.
|
||||||
|
debug:
|
||||||
|
enable_debug_inspection: true
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- id: joy
|
||||||
|
git: https://gitea.liria.cl/liria/dora-joy.git
|
||||||
|
tag: v0.1
|
||||||
|
build: pip install .
|
||||||
|
path: dora-joy
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/20
|
||||||
|
outputs:
|
||||||
|
- joy
|
||||||
|
|
||||||
|
- id: teleop
|
||||||
|
path: dora-teleop
|
||||||
|
build: pip install .
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/50
|
||||||
|
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
|
||||||
|
# loop would work through a backlog of readings that have already been
|
||||||
|
# overtaken. An arm should be driven by the newest one or by none.
|
||||||
|
joy:
|
||||||
|
source: joy/joy
|
||||||
|
queue_size: 1
|
||||||
|
queue_policy: drop_oldest
|
||||||
|
response: ulite6/response
|
||||||
|
motion_state: ulite6/motion_state
|
||||||
|
robot_state: ulite6/robot_state
|
||||||
|
tcp_pose: ulite6/tcp_pose
|
||||||
|
joint_pose: ulite6/joint_pose
|
||||||
|
outputs:
|
||||||
|
- twist
|
||||||
|
- command
|
||||||
|
- jog
|
||||||
|
- request
|
||||||
|
- ui_state
|
||||||
|
env:
|
||||||
|
SPEED_MM_S: "60"
|
||||||
|
SPEED_DEG_S: "30"
|
||||||
|
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
|
||||||
|
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
|
||||||
|
# inside it, and a backstop that fires first backs up nothing.
|
||||||
|
JOY_TTL_S: "0.2"
|
||||||
|
JOG_TTL_S: "0.4"
|
||||||
|
|
||||||
|
- id: ulite6
|
||||||
|
git: https://gitea.liria.cl/liria/dora-ulite6.git
|
||||||
|
tag: v0.3.0
|
||||||
|
build: pip install .
|
||||||
|
path: dora-ulite6
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/100
|
||||||
|
request: teleop/request
|
||||||
|
jog: teleop/jog
|
||||||
|
outputs:
|
||||||
|
- response
|
||||||
|
- motion_state
|
||||||
|
- joint_pose
|
||||||
|
- tcp_pose
|
||||||
|
- robot_state
|
||||||
|
# dora type-checks before it fetches a `git:` source, so it cannot read the
|
||||||
|
# arm's own manifest from here. These three repeat what `dora-ulite6`
|
||||||
|
# v0.3.0 annotates; `response` and `motion_state` stay untyped there and so
|
||||||
|
# stay untyped here. Re-check them when moving to a new tag.
|
||||||
|
output_types:
|
||||||
|
joint_pose: std/core/v1/Float32
|
||||||
|
tcp_pose: std/core/v1/Float32
|
||||||
|
robot_state: std/core/v1/String
|
||||||
|
env:
|
||||||
|
ROBOT_IP: "192.168.1.192"
|
||||||
|
TOOL_TYPE: "vacuum"
|
||||||
|
# The outermost deadman, enforced by the controller whether or not any of
|
||||||
|
# this is alive. Every timer above must stay below it.
|
||||||
|
VELOCITY_COMMAND_TTL_S: "0.5"
|
||||||
|
# The pad does not home. Nothing moves until somebody pushes a stick.
|
||||||
|
INIT_MODE: "none"
|
||||||
+8
-3
@@ -4,10 +4,13 @@
|
|||||||
# browser, both built from this checkout, so `dora validate` resolves every
|
# browser, both built from this checkout, so `dora validate` resolves every
|
||||||
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
|
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
|
||||||
#
|
#
|
||||||
|
# There is **no gamepad here**, because the pad is another node and wiring it in
|
||||||
|
# by `git:` would cost this file the one property it has. For a real pad on a
|
||||||
|
# real bench, use `dataflow-gamepad.yml`.
|
||||||
|
#
|
||||||
# This is **not** the real stack. The bench answers services and publishes
|
# This is **not** the real stack. The bench answers services and publishes
|
||||||
# telemetry but never moves anything — it exists so the node can be driven from
|
# telemetry but never moves anything. README.md carries the four-node wiring a
|
||||||
# a real gamepad with no arm in the room. README.md carries the three-node
|
# consumer actually uses.
|
||||||
# wiring a consumer actually uses.
|
|
||||||
|
|
||||||
strict_types: true
|
strict_types: true
|
||||||
|
|
||||||
@@ -29,6 +32,8 @@ nodes:
|
|||||||
tcp_pose: bench/tcp_pose
|
tcp_pose: bench/tcp_pose
|
||||||
joint_pose: bench/joint_pose
|
joint_pose: bench/joint_pose
|
||||||
outputs:
|
outputs:
|
||||||
|
- twist
|
||||||
|
- command
|
||||||
- jog
|
- jog
|
||||||
- request
|
- request
|
||||||
- ui_state
|
- ui_state
|
||||||
|
|||||||
+86
-17
@@ -5,11 +5,12 @@
|
|||||||
# dataflow against it without running the node, so CI needs neither a gamepad
|
# dataflow against it without running the node, so CI needs neither a gamepad
|
||||||
# nor a robot.
|
# nor a robot.
|
||||||
#
|
#
|
||||||
# `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are
|
# `joy`, `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are
|
||||||
# deliberately **untyped**. dora treats a port carrying `request_id` as
|
# deliberately **untyped**. dora treats a port carrying `request_id` as
|
||||||
# pattern-correlated and skips type checks on it, and several Arrow schemas
|
# pattern-correlated and skips type checks on it, several Arrow schemas
|
||||||
# legitimately flow over each — one per service, one per console message.
|
# legitimately flow over each — one per service, one per console message — and
|
||||||
# Annotating them would claim a contract neither side keeps.
|
# dora 1.0 ships no standard joystick type. Annotating them would claim a
|
||||||
|
# contract neither side keeps.
|
||||||
|
|
||||||
apiVersion: 1
|
apiVersion: 1
|
||||||
name: dora-teleop
|
name: dora-teleop
|
||||||
@@ -34,6 +35,24 @@ inputs:
|
|||||||
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
|
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
|
||||||
firing between ticks, which an operator feels as the arm stuttering.
|
firing between ticks, which an operator feels as the arm stuttering.
|
||||||
type: std/core/v1/UInt64
|
type: std/core/v1/UInt64
|
||||||
|
joy:
|
||||||
|
required: false
|
||||||
|
description: >-
|
||||||
|
One gamepad reading from `dora-joy`, as the Arrow struct
|
||||||
|
`{axes: list<float32>, buttons: list<bool>, present: bool}`. Raw SDL — the
|
||||||
|
deadzone, the response curve and the base-frame mapping all happen here.
|
||||||
|
Wire it with `queue_size: 1`: `dora-joy` ticks faster than this node does,
|
||||||
|
and a backlog of old readings is the last thing an arm should be driven
|
||||||
|
from.
|
||||||
|
|
||||||
|
**`present: false` stops the arm at once**, without waiting out any timer,
|
||||||
|
because an unplugged pad is not a centred pad. So does the stream going
|
||||||
|
quiet for longer than `JOY_TTL_S` — `dora-joy` publishes on every tick
|
||||||
|
whether or not the pad moved, so silence means that node died, and a dead
|
||||||
|
pad node is indistinguishable from an unplugged pad as far as the arm is
|
||||||
|
concerned.
|
||||||
|
|
||||||
|
Leave it unwired for a browser-only console. The node says so and starts.
|
||||||
ui_command:
|
ui_command:
|
||||||
required: false
|
required: false
|
||||||
description: >-
|
description: >-
|
||||||
@@ -79,23 +98,63 @@ inputs:
|
|||||||
type: std/core/v1/Float32
|
type: std/core/v1/Float32
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
|
twist:
|
||||||
|
description: >-
|
||||||
|
**The standard motion port.** A velocity, as
|
||||||
|
`{linear: [x, y, z], angular: [x, y, z], frame}` in **SI** — metres per
|
||||||
|
second and radians per second — in the frame named by `frame`, currently
|
||||||
|
always `base`.
|
||||||
|
|
||||||
|
Re-sent every tick while a source is driving, and followed by exactly one
|
||||||
|
all-zero Twist when it stops — once, not repeatedly, because a stream of
|
||||||
|
zeroes keeps a consumer believing a jog is live. There is no answer to a
|
||||||
|
Twist, ever: it expires on the consumer's own TTL and the sender keeps
|
||||||
|
sending.
|
||||||
|
|
||||||
|
Both control sources are normalised to a fraction of full speed before
|
||||||
|
anything is scaled, so a gamepad and the web console asking for the same
|
||||||
|
deflection produce byte-identical Twists.
|
||||||
|
command:
|
||||||
|
description: >-
|
||||||
|
**The standard discrete port.** One thing to do, as `{action, value}`.
|
||||||
|
`action` is robot-agnostic — `gripper_open`, `gripper_close`, `stop`,
|
||||||
|
`home`, `clear`, `freedrive` — so a consumer that swaps a vacuum for a
|
||||||
|
two-finger gripper needs no change here. `value` carries the argument
|
||||||
|
where a verb has one (`freedrive` uses 1.0 and 0.0) and is null otherwise.
|
||||||
jog:
|
jog:
|
||||||
description: >-
|
description: >-
|
||||||
A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}`
|
**Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units:
|
||||||
in mm/s and deg/s. Re-sent every tick while a source is driving, and
|
`{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame.
|
||||||
followed by exactly one all-zero vector when it stops — once, not
|
Published alongside `twist` while that node learns the standard one.
|
||||||
repeatedly, because a stream of zeroes would keep the arm node believing a
|
|
||||||
jog is live.
|
Wire `twist` **or** `jog`, never both — they carry the same intent, and a
|
||||||
|
consumer reading both would be driven twice.
|
||||||
request:
|
request:
|
||||||
description: >-
|
description: >-
|
||||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
**Deprecated; use `command`.** The same verbs under the service names
|
||||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
`dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`,
|
||||||
metadata.
|
`home` — with `request_id` in the metadata. `move_to` and `get_state` have
|
||||||
|
no standard equivalent yet and go only here.
|
||||||
ui_state:
|
ui_state:
|
||||||
description: >-
|
description: >-
|
||||||
The console's telemetry snapshot, as a JSON string for the WebSocket
|
The console's telemetry snapshot, as a JSON string for the WebSocket
|
||||||
bridge to broadcast. Also carries error toasts. Every key in it is read by
|
bridge to broadcast. Also carries error toasts. Every key in it is read by
|
||||||
name in the console's `app.js`.
|
name in the console's page, so the two change together.
|
||||||
|
|
||||||
|
# Reserved, not implemented. A joint-space robot — a lerobot SO-101, say — takes
|
||||||
|
# joint targets rather than a Cartesian velocity, and no amount of scaling turns
|
||||||
|
# one into the other: it needs inverse kinematics, which is another node's job.
|
||||||
|
# When such a consumer exists, this is the port it wires, mirroring ROS's
|
||||||
|
# control_msgs/JointJog beside geometry_msgs/Twist:
|
||||||
|
#
|
||||||
|
# joint_jog:
|
||||||
|
# description: >-
|
||||||
|
# A joint-space velocity, as `{names: [str], velocities: [float]}`, the
|
||||||
|
# joints named rather than positional so a consumer cannot mistake the
|
||||||
|
# order.
|
||||||
|
#
|
||||||
|
# It is written down rather than built because a port nothing wires is worse
|
||||||
|
# than no port: it reads as supported and has never once run.
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SPEED_MM_S:
|
SPEED_MM_S:
|
||||||
@@ -124,10 +183,6 @@ env:
|
|||||||
How much of the response curve is cubic, from 0 (linear) to 1. Fine
|
How much of the response curve is cubic, from 0 (linear) to 1. Fine
|
||||||
positioning happens near centre and a linear stick gives it almost no
|
positioning happens near centre and a linear stick gives it almost no
|
||||||
resolution there. Defaults to 0.4.
|
resolution there. Defaults to 0.4.
|
||||||
JOYSTICK_INDEX:
|
|
||||||
type: int
|
|
||||||
default: 0
|
|
||||||
description: Which pad to open, when more than one is plugged in. Defaults to 0.
|
|
||||||
JOG_TTL_S:
|
JOG_TTL_S:
|
||||||
type: float
|
type: float
|
||||||
default: 0.4
|
default: 0.4
|
||||||
@@ -135,6 +190,14 @@ env:
|
|||||||
How long the owning source may go quiet before the arm is stopped. Keep it
|
How long the owning source may go quiet before the arm is stopped. Keep it
|
||||||
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
|
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
|
||||||
is the controller-side backstop behind this one. Defaults to 0.4.
|
is the controller-side backstop behind this one. Defaults to 0.4.
|
||||||
|
JOY_TTL_S:
|
||||||
|
type: float
|
||||||
|
default: 0.2
|
||||||
|
description: >-
|
||||||
|
How long the `joy` stream may go quiet before the pad reads as gone and the
|
||||||
|
arm is stopped. Must be **below** JOG_TTL_S. `dora-joy` publishes on every
|
||||||
|
tick, so this is really "how many missed readings to tolerate" — at a 20 ms
|
||||||
|
joy tick, the default is ten. Defaults to 0.2.
|
||||||
GAMEPAD_IDLE_RELEASE_S:
|
GAMEPAD_IDLE_RELEASE_S:
|
||||||
type: float
|
type: float
|
||||||
default: 2.0
|
default: 2.0
|
||||||
@@ -156,6 +219,10 @@ example: |
|
|||||||
path: dora-teleop
|
path: dora-teleop
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/50
|
tick: dora/timer/millis/50
|
||||||
|
joy:
|
||||||
|
source: joy/joy
|
||||||
|
queue_size: 1
|
||||||
|
queue_policy: drop_oldest
|
||||||
ui_command: ws-transceiver/data_out
|
ui_command: ws-transceiver/data_out
|
||||||
clients: ws-transceiver/clients
|
clients: ws-transceiver/clients
|
||||||
response: ulite6/response
|
response: ulite6/response
|
||||||
@@ -164,6 +231,8 @@ example: |
|
|||||||
tcp_pose: ulite6/tcp_pose
|
tcp_pose: ulite6/tcp_pose
|
||||||
joint_pose: ulite6/joint_pose
|
joint_pose: ulite6/joint_pose
|
||||||
outputs:
|
outputs:
|
||||||
|
- twist
|
||||||
|
- command
|
||||||
- jog
|
- jog
|
||||||
- request
|
- request
|
||||||
- ui_state
|
- ui_state
|
||||||
|
|||||||
+22
-19
@@ -20,31 +20,27 @@ def env_float(name: str, default: float) -> float:
|
|||||||
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
||||||
|
|
||||||
|
|
||||||
def env_int(name: str, default: int) -> int:
|
|
||||||
"""Read a whole number, naming the variable if it cannot be read as one."""
|
|
||||||
value = os.environ.get(name, "").strip()
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeadmanConfig:
|
class DeadmanConfig:
|
||||||
"""How long a source may go quiet before the arm is told to stop.
|
"""How long a source may go quiet before the arm is told to stop.
|
||||||
|
|
||||||
Three layers, outermost last. This node re-sends the current velocity every
|
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
|
||||||
tick; `jog_ttl_s` is how long it will keep doing so without hearing from the
|
worth acting on; this node re-sends the current velocity every tick;
|
||||||
source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`,
|
`jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||||
which the controller enforces whether or not this process is alive.
|
source; and behind all of them sits the arm node's own
|
||||||
|
`VELOCITY_COMMAND_TTL_S`, which the controller enforces whether or not this
|
||||||
|
process is alive.
|
||||||
|
|
||||||
Keep them in that order — ours shorter than the controller's — or the
|
Keep them in that order — each shorter than the one behind it — or a
|
||||||
backstop fires before the thing it is backing up.
|
backstop fires before the thing it is backing up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
jog_ttl_s: float = 0.4
|
jog_ttl_s: float = 0.4
|
||||||
|
# How long the `joy` stream may go quiet before the pad reads as gone. The
|
||||||
|
# pad lives in another node now, and that node dying looks like silence
|
||||||
|
# rather than like an unplugged pad, so silence has to mean the same thing.
|
||||||
|
# `dora-joy` publishes every tick, so it means nothing else.
|
||||||
|
joy_ttl_s: float = 0.2
|
||||||
# How long a gamepad may sit untouched before it gives the arm back, so the
|
# How long a gamepad may sit untouched before it gives the arm back, so the
|
||||||
# browser can take over without anyone having to press anything.
|
# browser can take over without anyone having to press anything.
|
||||||
gamepad_idle_release_s: float = 2.0
|
gamepad_idle_release_s: float = 2.0
|
||||||
@@ -57,6 +53,7 @@ class DeadmanConfig:
|
|||||||
"""Build the timings from the environment."""
|
"""Build the timings from the environment."""
|
||||||
return cls(
|
return cls(
|
||||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
||||||
|
joy_ttl_s=env_float("JOY_TTL_S", 0.2),
|
||||||
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
||||||
)
|
)
|
||||||
@@ -77,7 +74,6 @@ class Config:
|
|||||||
# How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine
|
# How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine
|
||||||
# positioning happens near centre, and expo is what buys resolution there.
|
# positioning happens near centre, and expo is what buys resolution there.
|
||||||
expo: float
|
expo: float
|
||||||
joystick_index: int
|
|
||||||
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -89,11 +85,18 @@ class Config:
|
|||||||
expo = env_float("EXPO", 0.4)
|
expo = env_float("EXPO", 0.4)
|
||||||
if not 0.0 <= expo <= 1.0:
|
if not 0.0 <= expo <= 1.0:
|
||||||
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
|
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
|
||||||
|
deadman = DeadmanConfig.from_env()
|
||||||
|
# The layers have to fire outermost last, and each one is a backstop for
|
||||||
|
# the one inside it. Behind both of these sits the arm node's own
|
||||||
|
# VELOCITY_COMMAND_TTL_S, which no setting here can see.
|
||||||
|
if deadman.joy_ttl_s >= deadman.jog_ttl_s:
|
||||||
|
raise ValueError(
|
||||||
|
f"JOY_TTL_S must be below JOG_TTL_S ({deadman.jog_ttl_s}), not {deadman.joy_ttl_s}"
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
||||||
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
||||||
deadzone=deadzone,
|
deadzone=deadzone,
|
||||||
expo=expo,
|
expo=expo,
|
||||||
joystick_index=env_int("JOYSTICK_INDEX", 0),
|
deadman=deadman,
|
||||||
deadman=DeadmanConfig.from_env(),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""The gamepad, as it arrives from `dora-joy`.
|
||||||
|
|
||||||
|
The pad itself lives in another node now. What reaches this one is a plain
|
||||||
|
reading on the `joy` input, and this module is the seam the loop reads it
|
||||||
|
through: :class:`Sample` is what the rest of the node sees, exactly as it did
|
||||||
|
when the pad was read in-process.
|
||||||
|
|
||||||
|
**The safety-critical part is :meth:`JoyInput.read`.** Moving the pad into
|
||||||
|
another process added a way for it to go away that did not exist before — that
|
||||||
|
process dying — and from here that looks like silence rather than like an
|
||||||
|
unplugged pad. So silence past `JOY_TTL_S` reads as *absent*, which is the same
|
||||||
|
answer, because a reading nobody is refreshing is not evidence of anything.
|
||||||
|
`dora-joy` publishes on every tick whether or not the pad moved, so silence
|
||||||
|
means one thing only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Sample:
|
||||||
|
"""One reading of the pad.
|
||||||
|
|
||||||
|
``present`` is the one that matters for safety: a pad that has been
|
||||||
|
unplugged reads as absent rather than as centred, so the difference between
|
||||||
|
"the operator let go" and "the operator's pad fell off the desk" is not lost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
axes: list[float] = field(default_factory=list)
|
||||||
|
buttons: list[bool] = field(default_factory=list)
|
||||||
|
present: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
ABSENT = Sample()
|
||||||
|
|
||||||
|
|
||||||
|
def decode(event: dict) -> Sample:
|
||||||
|
"""Read one `joy` message into a reading.
|
||||||
|
|
||||||
|
A message this node cannot make sense of reads as absent rather than as
|
||||||
|
centred, for the same reason an unplugged pad does.
|
||||||
|
"""
|
||||||
|
value = event["value"]
|
||||||
|
if not len(value):
|
||||||
|
return ABSENT
|
||||||
|
row = value[0].as_py()
|
||||||
|
return Sample(
|
||||||
|
axes=list(row.get("axes") or []),
|
||||||
|
buttons=[bool(down) for down in row.get("buttons") or []],
|
||||||
|
present=bool(row.get("present")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JoyInput:
|
||||||
|
"""The latest reading, and how long ago it arrived."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_s: float):
|
||||||
|
self.ttl_s = ttl_s
|
||||||
|
self._sample = ABSENT
|
||||||
|
self._seen_at = 0.0
|
||||||
|
|
||||||
|
def accept(self, sample: Sample, moment: float) -> None:
|
||||||
|
"""Take one reading off the wire."""
|
||||||
|
self._sample = sample
|
||||||
|
self._seen_at = moment
|
||||||
|
|
||||||
|
def read(self, moment: float) -> Sample:
|
||||||
|
"""Return the current reading, or absence if nobody is still sending one.
|
||||||
|
|
||||||
|
A stale reading is not a reading. `dora-joy` publishes every tick, so
|
||||||
|
silence means it died, and a dead pad node is indistinguishable from an
|
||||||
|
unplugged pad as far as the arm is concerned — which is the only safe
|
||||||
|
way to read it.
|
||||||
|
"""
|
||||||
|
if moment - self._seen_at > self.ttl_s:
|
||||||
|
return ABSENT
|
||||||
|
return self._sample
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> float:
|
||||||
|
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
|
||||||
|
return time.monotonic()
|
||||||
+141
-60
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
The loop owns the one decision this node exists to make — whether a velocity
|
The loop owns the one decision this node exists to make — whether a velocity
|
||||||
reaches the arm this tick, and from whom. Everything else is delegated:
|
reaches the arm this tick, and from whom. Everything else is delegated:
|
||||||
:mod:`dora_teleop.gamepad` owns the hardware, :mod:`dora_teleop.mapping` turns a
|
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
|
||||||
reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and
|
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
|
||||||
:mod:`dora_teleop.ui` speaks the console's language.
|
to, and :mod:`dora_teleop.ui` speaks the console's language. **No hardware is
|
||||||
|
touched here at all** — the pad is `dora-joy`, the arm is `dora-ulite6` and the
|
||||||
|
browser is the WebSocket bridge.
|
||||||
|
|
||||||
**The safety-critical part is `_drive`.** An arm moving under a velocity command
|
**The safety-critical part is `_drive`.** An arm moving under a velocity command
|
||||||
keeps moving until told otherwise, so the question is never "should it start"
|
keeps moving until told otherwise, so the question is never "should it start"
|
||||||
@@ -20,15 +22,37 @@ import time
|
|||||||
import pyarrow as pa
|
import pyarrow as pa
|
||||||
from dora import Node
|
from dora import Node
|
||||||
|
|
||||||
from dora_teleop import mapping, ui
|
from dora_teleop import joy, mapping, message, ui
|
||||||
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
|
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
|
||||||
from dora_teleop.config import Config
|
from dora_teleop.config import Config
|
||||||
from dora_teleop.gamepad import Gamepad, now
|
from dora_teleop.joy import JoyInput, now
|
||||||
from dora_teleop.log import log
|
from dora_teleop.log import log
|
||||||
from dora_teleop.mapping import LAYOUT, pressed
|
from dora_teleop.mapping import LAYOUT, pressed
|
||||||
|
|
||||||
REQUEST_ID = "request_id"
|
REQUEST_ID = "request_id"
|
||||||
STOP_VELOCITY = [0.0] * 6
|
# Still, in the normalised units both sources speak.
|
||||||
|
STOP_AXES = [0.0] * 6
|
||||||
|
|
||||||
|
# The verbs this node publishes on `command`. Robot-agnostic on purpose: a
|
||||||
|
# consumer that grows a two-finger gripper where the last one had a vacuum
|
||||||
|
# should need no change here, and neither should this node.
|
||||||
|
GRIPPER_CLOSE = "gripper_close"
|
||||||
|
GRIPPER_OPEN = "gripper_open"
|
||||||
|
STOP = "stop"
|
||||||
|
HOME = "home"
|
||||||
|
CLEAR = "clear"
|
||||||
|
FREEDRIVE = "freedrive"
|
||||||
|
|
||||||
|
# The same verbs as `dora-ulite6` names them, for the deprecated `request` port.
|
||||||
|
# This table is the whole of what used to be spread through the loop, and it is
|
||||||
|
# what a robot speaking the standard lets us delete.
|
||||||
|
LEGACY_REQUEST = {
|
||||||
|
GRIPPER_CLOSE: {"service": "grasp"},
|
||||||
|
GRIPPER_OPEN: {"service": "release"},
|
||||||
|
STOP: {"service": "stop"},
|
||||||
|
CLEAR: {"service": "clear"},
|
||||||
|
HOME: {"service": "home", "wait": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Teleop:
|
class Teleop:
|
||||||
@@ -37,10 +61,12 @@ class Teleop:
|
|||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
||||||
|
self.joy = JoyInput(config.deadman.joy_ttl_s)
|
||||||
self.view = ui.ArmView()
|
self.view = ui.ArmView()
|
||||||
# The velocity the browser last asked for, and when. A jog is a stream;
|
# What the browser last asked for, and when. A jog is a stream; what
|
||||||
# what matters is whether the stream is still arriving.
|
# matters is whether the stream is still arriving. Normalised, like the
|
||||||
self.web_velocity = STOP_VELOCITY
|
# pad's — the console does not know the robot's units.
|
||||||
|
self.web_axes = STOP_AXES
|
||||||
self.web_seen_at = 0.0
|
self.web_seen_at = 0.0
|
||||||
self.clients = 0
|
self.clients = 0
|
||||||
# Whether the arm is currently being driven by us. Used to send the stop
|
# Whether the arm is currently being driven by us. Used to send the stop
|
||||||
@@ -58,7 +84,7 @@ class Teleop:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Connect to the dataflow, start reading the pad, and run until dora stops us."""
|
"""Connect to the dataflow and run until dora stops us."""
|
||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
|
|
||||||
@@ -66,8 +92,6 @@ def main() -> None:
|
|||||||
# dora spawns it, the id from the dataflow wins.
|
# dora spawns it, the id from the dataflow wins.
|
||||||
node = Node(args.name)
|
node = Node(args.name)
|
||||||
state = Teleop(config)
|
state = Teleop(config)
|
||||||
gamepad = Gamepad(config.joystick_index)
|
|
||||||
gamepad.start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for event in node:
|
for event in node:
|
||||||
@@ -75,14 +99,13 @@ def main() -> None:
|
|||||||
raise RuntimeError(event["error"])
|
raise RuntimeError(event["error"])
|
||||||
if event["type"] != "INPUT":
|
if event["type"] != "INPUT":
|
||||||
continue
|
continue
|
||||||
_handle(node, state, gamepad, event)
|
_handle(node, state, event)
|
||||||
finally:
|
finally:
|
||||||
# A motion lives in the arm's controller, not in this process. Leaving
|
# A motion lives in the arm's controller, not in this process. Leaving
|
||||||
# without zeroing the velocity leaves it travelling until the
|
# without zeroing the velocity leaves it travelling until the
|
||||||
# controller's own expiry catches up, which is the one moment nobody is
|
# controller's own expiry catches up, which is the one moment nobody is
|
||||||
# watching for it.
|
# watching for it.
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
gamepad.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args() -> argparse.Namespace:
|
def _parse_args() -> argparse.Namespace:
|
||||||
@@ -96,11 +119,13 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
def _handle(node: Node, state: Teleop, event: dict) -> None:
|
||||||
"""Route one input to whatever deals with it."""
|
"""Route one input to whatever deals with it."""
|
||||||
event_id = event["id"]
|
event_id = event["id"]
|
||||||
if event_id == "tick":
|
if event_id == "tick":
|
||||||
_tick(node, state, gamepad)
|
_tick(node, state)
|
||||||
|
elif event_id == "joy":
|
||||||
|
state.joy.accept(joy.decode(event), now())
|
||||||
elif event_id == "ui_command":
|
elif event_id == "ui_command":
|
||||||
_accept_ui_command(node, state, event)
|
_accept_ui_command(node, state, event)
|
||||||
elif event_id == "clients":
|
elif event_id == "clients":
|
||||||
@@ -122,21 +147,30 @@ def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
|||||||
# --- the tick, where the arm is actually driven ---
|
# --- the tick, where the arm is actually driven ---
|
||||||
|
|
||||||
|
|
||||||
def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None:
|
def _tick(node: Node, state: Teleop) -> None:
|
||||||
"""Decide what the arm should be doing, and say so."""
|
"""Decide what the arm should be doing, and say so."""
|
||||||
moment = now()
|
moment = now()
|
||||||
sample = gamepad.read()
|
# Absent covers three things now: no pad plugged in, a pad unplugged
|
||||||
|
# mid-jog, and `dora-joy` no longer sending. The arm cannot tell them apart
|
||||||
_read_buttons(node, state, sample)
|
# and does not need to.
|
||||||
|
sample = state.joy.read(moment)
|
||||||
|
|
||||||
|
# Who owns the arm is settled before anything they pressed is acted on.
|
||||||
|
# The other order drops the first press after the pad has been idle: the
|
||||||
|
# lease is not held yet when the buttons are read, and by the next tick the
|
||||||
|
# button is held rather than newly down, so the edge is gone for good.
|
||||||
|
#
|
||||||
# A pad taking the arm the moment a stick moves is what keeps an operator
|
# A pad taking the arm the moment a stick moves is what keeps an operator
|
||||||
# from having to reach for a browser to use the thing in their hands.
|
# from having to reach for a browser to use the thing in their hands.
|
||||||
if sample.active and state.arbiter.owner is None:
|
active = mapping.is_active(sample)
|
||||||
|
if active and state.arbiter.owner is None:
|
||||||
state.arbiter.take(GAMEPAD, moment)
|
state.arbiter.take(GAMEPAD, moment)
|
||||||
if sample.active:
|
if active:
|
||||||
state.arbiter.note_activity(GAMEPAD, moment)
|
state.arbiter.note_activity(GAMEPAD, moment)
|
||||||
state.arbiter.expire_idle_gamepad(moment)
|
state.arbiter.expire_idle_gamepad(moment)
|
||||||
|
|
||||||
|
_read_buttons(node, state, sample)
|
||||||
|
|
||||||
_drive(node, state, sample, moment)
|
_drive(node, state, sample, moment)
|
||||||
_poll_state(node, state, moment)
|
_poll_state(node, state, moment)
|
||||||
_publish(node, state)
|
_publish(node, state)
|
||||||
@@ -149,46 +183,51 @@ def _drive(node: Node, state: Teleop, sample, moment: float) -> None:
|
|||||||
stream of jogs as one continuous one, and the re-send exists so the
|
stream of jogs as one continuous one, and the re-send exists so the
|
||||||
controller's own expiry never fires mid-motion.
|
controller's own expiry never fires mid-motion.
|
||||||
"""
|
"""
|
||||||
velocity = _wanted_velocity(state, sample, moment)
|
axes = _wanted_axes(state, sample, moment)
|
||||||
|
|
||||||
if mapping.is_moving(velocity):
|
if mapping.is_moving(axes):
|
||||||
state.jogging = True
|
state.jogging = True
|
||||||
_send_jog(node, velocity)
|
_send_motion(node, state, axes)
|
||||||
return
|
return
|
||||||
|
|
||||||
if state.jogging:
|
if state.jogging:
|
||||||
# Once, not every tick. Repeating it would keep the arm node believing a
|
# Once, not every tick. Repeating it would keep the arm node believing a
|
||||||
# jog is live and hold `robot_state` at `moving` forever.
|
# jog is live and hold `robot_state` at `moving` forever.
|
||||||
state.jogging = False
|
state.jogging = False
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
|
|
||||||
|
|
||||||
def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]:
|
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
|
||||||
"""Return what the arm should be doing, which is usually nothing.
|
"""Return what the robot should be doing, normalised, which is usually nothing.
|
||||||
|
|
||||||
Every path that is not an owner actively asking for motion returns a stop.
|
Every path that is not an owner actively asking for motion returns a stop.
|
||||||
That is deliberate: the default has to be "still", so that a case nobody
|
That is deliberate: the default has to be "still", so that a case nobody
|
||||||
thought of fails by halting rather than by carrying on.
|
thought of fails by halting rather than by carrying on.
|
||||||
"""
|
"""
|
||||||
|
# First, and above the lease. A stop somebody had to reach for is not undone
|
||||||
|
# by the next thumb movement: it holds until it is deliberately cleared.
|
||||||
|
if state.view.estop:
|
||||||
|
return STOP_AXES
|
||||||
|
|
||||||
owner = state.arbiter.owner
|
owner = state.arbiter.owner
|
||||||
if owner == GAMEPAD:
|
if owner == GAMEPAD:
|
||||||
# An unplugged pad is not a centred pad. Stopping here rather than
|
# An unplugged pad is not a centred pad. Stopping here rather than
|
||||||
# waiting out the TTL is the difference between a dropped controller
|
# waiting out the TTL is the difference between a dropped controller
|
||||||
# costing 10 ms of travel and costing 400.
|
# costing 10 ms of travel and costing 400.
|
||||||
if not sample.present:
|
if not sample.present:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
return mapping.velocity(sample.axes, sample.buttons, state.config)
|
return mapping.from_pad(sample.axes, sample.buttons, state.config)
|
||||||
|
|
||||||
if owner == WEB:
|
if owner == WEB:
|
||||||
# The console refreshes its jog every 150 ms; the browser going quiet,
|
# The console refreshes its jog every 150 ms; the browser going quiet,
|
||||||
# or the tab closing, has to stop the arm without anything being sent.
|
# or the tab closing, has to stop the arm without anything being sent.
|
||||||
if state.clients == 0:
|
if state.clients == 0:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
return state.web_velocity
|
return state.web_axes
|
||||||
|
|
||||||
return STOP_VELOCITY
|
return STOP_AXES
|
||||||
|
|
||||||
|
|
||||||
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||||
@@ -204,20 +243,23 @@ def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
|||||||
def tapped(index: int) -> bool:
|
def tapped(index: int) -> bool:
|
||||||
return pressed(sample.buttons, index) and not pressed(previous, index)
|
return pressed(sample.buttons, index) and not pressed(previous, index)
|
||||||
|
|
||||||
# The stop is not gated on the lease. Somebody reaching for it is the one
|
# Neither the stop nor its release is gated on the lease. Somebody reaching
|
||||||
# moment when asking who owns the arm is the wrong question.
|
# for the stop is the one moment when asking who owns the arm is the wrong
|
||||||
|
# question, and a latch that cannot be released without first taking the arm
|
||||||
|
# back is a trap — an idle pad hands the lease away two seconds after the
|
||||||
|
# stop it just made. The console's RESET is ungated for the same reason.
|
||||||
if tapped(LAYOUT.estop):
|
if tapped(LAYOUT.estop):
|
||||||
_estop(node, state)
|
_estop(node, state)
|
||||||
|
if tapped(LAYOUT.clear):
|
||||||
|
_clear(node, state)
|
||||||
if not state.arbiter.may_drive(GAMEPAD):
|
if not state.arbiter.may_drive(GAMEPAD):
|
||||||
return
|
return
|
||||||
if tapped(LAYOUT.grasp):
|
if tapped(LAYOUT.grasp):
|
||||||
_request(node, state, {"service": "grasp"})
|
_act(node, state, GRIPPER_CLOSE)
|
||||||
if tapped(LAYOUT.release):
|
if tapped(LAYOUT.release):
|
||||||
_request(node, state, {"service": "release"})
|
_act(node, state, GRIPPER_OPEN)
|
||||||
if tapped(LAYOUT.clear):
|
|
||||||
_request(node, state, {"service": "clear"})
|
|
||||||
if tapped(LAYOUT.freedrive):
|
if tapped(LAYOUT.freedrive):
|
||||||
_request(node, state, {"service": "freedrive", "enabled": not state.view.freedrive_on})
|
_freedrive(node, state, not state.view.freedrive_on)
|
||||||
|
|
||||||
|
|
||||||
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
||||||
@@ -259,11 +301,7 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
|||||||
_control(node, state, command, moment)
|
_control(node, state, command, moment)
|
||||||
return
|
return
|
||||||
if command.type == "reset":
|
if command.type == "reset":
|
||||||
# Clearing the latch is this node's business; clearing the controller's
|
_clear(node, state)
|
||||||
# error is the arm's. Both, in that order, or the console's RESET button
|
|
||||||
# leaves one of the two still saying stop.
|
|
||||||
state.view.estop = False
|
|
||||||
_request(node, state, {"service": "clear"})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not state.arbiter.may_drive(WEB):
|
if not state.arbiter.may_drive(WEB):
|
||||||
@@ -271,18 +309,18 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if command.type == "jog":
|
if command.type == "jog":
|
||||||
state.web_velocity = command.velocity
|
state.web_axes = mapping.from_console(command.axes)
|
||||||
state.web_seen_at = moment
|
state.web_seen_at = moment
|
||||||
state.arbiter.note_activity(WEB, moment)
|
state.arbiter.note_activity(WEB, moment)
|
||||||
elif command.type == "jog_stop":
|
elif command.type == "jog_stop":
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
state.web_seen_at = moment
|
state.web_seen_at = moment
|
||||||
elif command.type == "home":
|
elif command.type == "home":
|
||||||
_request(node, state, {"service": "home", "wait": False})
|
_act(node, state, HOME)
|
||||||
elif command.type == "gripper":
|
elif command.type == "gripper":
|
||||||
_request(node, state, {"service": "grasp" if command.enabled else "release"})
|
_act(node, state, GRIPPER_CLOSE if command.enabled else GRIPPER_OPEN)
|
||||||
elif command.type == "freedrive":
|
elif command.type == "freedrive":
|
||||||
_request(node, state, {"service": "freedrive", "enabled": command.enabled})
|
_freedrive(node, state, command.enabled)
|
||||||
elif command.type == "move":
|
elif command.type == "move":
|
||||||
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
||||||
|
|
||||||
@@ -291,7 +329,7 @@ def _control(node: Node, state: Teleop, command, moment: float) -> None:
|
|||||||
"""Hand the arm to the console, or take it back."""
|
"""Hand the arm to the console, or take it back."""
|
||||||
if command.action == "release":
|
if command.action == "release":
|
||||||
state.arbiter.release(WEB)
|
state.arbiter.release(WEB)
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
return
|
return
|
||||||
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
||||||
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
||||||
@@ -303,7 +341,7 @@ def _accept_clients(state: Teleop, event: dict) -> None:
|
|||||||
state.clients = int(row.get("connected", 0))
|
state.clients = int(row.get("connected", 0))
|
||||||
if state.clients == 0:
|
if state.clients == 0:
|
||||||
state.arbiter.release(WEB)
|
state.arbiter.release(WEB)
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
|
|
||||||
|
|
||||||
# --- what the arm says back ---
|
# --- what the arm says back ---
|
||||||
@@ -336,14 +374,57 @@ def _estop(node: Node, state: Teleop) -> None:
|
|||||||
log("e-stop")
|
log("e-stop")
|
||||||
state.view.estop = True
|
state.view.estop = True
|
||||||
state.jogging = False
|
state.jogging = False
|
||||||
state.web_velocity = STOP_VELOCITY
|
state.web_axes = STOP_AXES
|
||||||
_send_jog(node, STOP_VELOCITY)
|
_send_motion(node, state, STOP_AXES)
|
||||||
_request(node, state, {"service": "stop"})
|
_act(node, state, STOP)
|
||||||
|
|
||||||
|
|
||||||
def _send_jog(node: Node, velocity: list[float]) -> None:
|
def _clear(node: Node, state: Teleop) -> None:
|
||||||
"""Send one velocity to the arm. There is no answer to a jog, ever."""
|
"""Release the latch and clear the controller's error, in that order.
|
||||||
node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}]))
|
|
||||||
|
Both, or one of the two is still saying stop: the latch is this node's
|
||||||
|
business and the error is the arm's. The console's RESET and the pad's
|
||||||
|
`clear` button are the same act and go through here, so a pad-only
|
||||||
|
dataflow is not left with a latch nothing can release.
|
||||||
|
"""
|
||||||
|
log("cleared")
|
||||||
|
state.view.estop = False
|
||||||
|
_act(node, state, CLEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_motion(node: Node, state: Teleop, axes: list[float]) -> None:
|
||||||
|
"""Send one motion command, on the standard port and the deprecated one.
|
||||||
|
|
||||||
|
There is no answer to either, ever — a jog is a stream that expires on the
|
||||||
|
consumer's own TTL, so the sender keeps sending and nobody waits.
|
||||||
|
|
||||||
|
`twist` is the standard: SI, framed, what a robot node should take. `jog`
|
||||||
|
is `dora-ulite6`'s mm/s and deg/s, published alongside until that node
|
||||||
|
learns the standard. Both carry the same intent, so wiring either is safe;
|
||||||
|
wiring both would drive the arm twice.
|
||||||
|
"""
|
||||||
|
config = state.config
|
||||||
|
node.send_output("twist", message.pack_twist(mapping.twist(axes, config)))
|
||||||
|
node.send_output("jog", message.pack_jog(mapping.scale(axes, config)))
|
||||||
|
|
||||||
|
|
||||||
|
def _act(node: Node, state: Teleop, action: str, value: float | None = None) -> None:
|
||||||
|
"""Ask for one discrete thing, on the standard port and the deprecated one.
|
||||||
|
|
||||||
|
The verb is robot-agnostic; `LEGACY_REQUEST` is the only place that knows
|
||||||
|
what `dora-ulite6` calls it. A robot speaking the standard needs no entry
|
||||||
|
there, which is how that table eventually goes away.
|
||||||
|
"""
|
||||||
|
node.send_output("command", message.pack_command(action, value))
|
||||||
|
row = LEGACY_REQUEST.get(action)
|
||||||
|
if row is not None:
|
||||||
|
_request(node, state, dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def _freedrive(node: Node, state: Teleop, enabled: bool) -> None:
|
||||||
|
"""Toggle freedrive. Its argument rides as the verb's value."""
|
||||||
|
_act(node, state, FREEDRIVE, value=float(enabled))
|
||||||
|
_request(node, state, {"service": "freedrive", "enabled": enabled})
|
||||||
|
|
||||||
|
|
||||||
def _request(node: Node, state: Teleop, row: dict) -> None:
|
def _request(node: Node, state: Teleop, row: dict) -> None:
|
||||||
|
|||||||
+116
-20
@@ -1,19 +1,34 @@
|
|||||||
"""Turning stick deflection into a velocity the arm understands.
|
"""Turning a control input into motion, in one place for every source.
|
||||||
|
|
||||||
Pure functions, no state and no hardware, because this is the part that has to
|
Pure functions, no state and no hardware, because this is the part that has to
|
||||||
be right and the part that is cheapest to check. Everything here is in the
|
be right and the part that is cheapest to check.
|
||||||
arm node's units: `[vx, vy, vz, wx, wy, wz]`, mm/s and deg/s, in the robot's
|
|
||||||
**base** frame — the same frame `tcp_pose` reports.
|
Everything passes through a **normalised** six-vector first: `[-1, 1]` per
|
||||||
|
degree of freedom, `[x, y, z, roll, pitch, yaw]`, in the robot's **base**
|
||||||
|
frame. A gamepad reaches it through :func:`from_pad`, which shapes away a worn
|
||||||
|
stick; the browser sends it already clean. Only then is it scaled, and that
|
||||||
|
happens exactly once — which is what lets the same node drive an arm in mm/s
|
||||||
|
and one in m/s without either source knowing the difference.
|
||||||
|
|
||||||
|
Two scalings exist. :func:`twist` is the standard one, SI, and is what a robot
|
||||||
|
should consume. :func:`velocity` is mm/s and deg/s for `dora-ulite6`'s `jog`
|
||||||
|
port, kept while that node learns the standard.
|
||||||
|
|
||||||
The screen convention comes from the old web console, so the two agree: stick
|
The screen convention comes from the old web console, so the two agree: stick
|
||||||
up is +X, stick right is +Y.
|
up is +X, stick right is +Y.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# The arm takes six numbers; these name them.
|
# Six degrees of freedom; these name their places in the vector.
|
||||||
VX, VY, VZ, WX, WY, WZ = range(6)
|
VX, VY, VZ, WX, WY, WZ = range(6)
|
||||||
|
|
||||||
|
# Operator-facing settings are in mm/s and deg/s because that is what a person
|
||||||
|
# reading a dataflow can judge — 60 mm/s means something, 0.06 m/s less so. The
|
||||||
|
# wire is SI. These convert between the two, and are the only place that does.
|
||||||
|
MM_PER_M = 1000.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Layout:
|
class Layout:
|
||||||
@@ -43,6 +58,17 @@ class Layout:
|
|||||||
|
|
||||||
LAYOUT = Layout()
|
LAYOUT = Layout()
|
||||||
|
|
||||||
|
# The frame a velocity is expressed in. Base is what `tcp_pose` reports and what
|
||||||
|
# the console's arrows mean; tool is reserved for a consumer that wants it.
|
||||||
|
BASE_FRAME = "base"
|
||||||
|
TOOL_FRAME = "tool"
|
||||||
|
|
||||||
|
# How far a control must travel before the pad counts as being used. Raw, with
|
||||||
|
# no deadzone: this decides whether the pad is asking for the arm, and
|
||||||
|
# `velocity` decides how far to move it. A tighter threshold here than there
|
||||||
|
# would mean a stick could claim the arm and then not move it.
|
||||||
|
ACTIVITY = 0.3
|
||||||
|
|
||||||
|
|
||||||
def deadzone(value: float, width: float) -> float:
|
def deadzone(value: float, width: float) -> float:
|
||||||
"""Ignore travel near centre, and rescale what is left to the full range.
|
"""Ignore travel near centre, and rescale what is left to the full range.
|
||||||
@@ -89,13 +115,16 @@ def pressed(buttons: list[bool], index: int) -> bool:
|
|||||||
return bool(buttons[index]) if index < len(buttons) else False
|
return bool(buttons[index]) if index < len(buttons) else False
|
||||||
|
|
||||||
|
|
||||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||||
"""Map one gamepad reading to a base-frame velocity.
|
"""Map one gamepad reading to the normalised six-vector.
|
||||||
|
|
||||||
Translation comes from the left stick and the triggers, rotation from the
|
Translation comes from the left stick and the triggers, rotation from the
|
||||||
right stick and the bumpers. Several axes move at once on purpose: a stick
|
right stick and the bumpers. Several axes move at once on purpose: a stick
|
||||||
is analog and blended, which is exactly what the console's four-sector dial
|
is analog and blended, which is exactly what a four-sector dial pad cannot do.
|
||||||
pad could not do.
|
|
||||||
|
The deadzone and the response curve are applied here and nowhere else,
|
||||||
|
because they exist to correct a physical stick. A browser has no worn
|
||||||
|
centre, so :func:`from_console` does not use them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def shaped(value: float) -> float:
|
def shaped(value: float) -> float:
|
||||||
@@ -104,20 +133,87 @@ def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LA
|
|||||||
out = [0.0] * 6
|
out = [0.0] * 6
|
||||||
# Up on the stick is negative in SDL, and up should send the arm away from
|
# Up on the stick is negative in SDL, and up should send the arm away from
|
||||||
# the operator, along +X.
|
# the operator, along +X.
|
||||||
out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s
|
out[VX] = shaped(-axis(axes, layout.left_y))
|
||||||
out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s
|
out[VY] = shaped(axis(axes, layout.left_x))
|
||||||
|
out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)))
|
||||||
lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger))
|
out[WX] = shaped(axis(axes, layout.right_x))
|
||||||
out[VZ] = shaped(lift) * config.speed_mm_s
|
out[WY] = shaped(-axis(axes, layout.right_y))
|
||||||
|
out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left))
|
||||||
out[WX] = shaped(axis(axes, layout.right_x)) * config.speed_deg_s
|
|
||||||
out[WY] = shaped(-axis(axes, layout.right_y)) * config.speed_deg_s
|
|
||||||
|
|
||||||
yaw = pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left)
|
|
||||||
out[WZ] = yaw * config.speed_deg_s
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def from_console(values) -> list[float]:
|
||||||
|
"""Read the normalised six-vector a browser sent, clamped to what it may ask for.
|
||||||
|
|
||||||
|
The console speaks the same units as a stick — a fraction of full speed per
|
||||||
|
axis — so that how fast anything goes is decided once, here, rather than
|
||||||
|
once per source. A page that has never heard of the robot cannot get its
|
||||||
|
units wrong.
|
||||||
|
"""
|
||||||
|
if not isinstance(values, list) or len(values) != 6:
|
||||||
|
return [0.0] * 6
|
||||||
|
out = []
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return [0.0] * 6
|
||||||
|
out.append(max(-1.0, min(1.0, float(value))))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||||
|
"""Map one gamepad reading straight to `dora-ulite6`'s mm/s and deg/s.
|
||||||
|
|
||||||
|
A convenience over `scale(from_pad(...))`, kept because the probe and the
|
||||||
|
tests read better for it.
|
||||||
|
"""
|
||||||
|
return scale(from_pad(axes, buttons, config, layout), config)
|
||||||
|
|
||||||
|
|
||||||
|
def scale(normalised: list[float], config) -> list[float]:
|
||||||
|
"""Scale the normalised six-vector to mm/s and deg/s, base frame.
|
||||||
|
|
||||||
|
`dora-ulite6`'s `jog` units. Deprecated in favour of :func:`twist`, and kept
|
||||||
|
only while that node learns to take the standard one.
|
||||||
|
"""
|
||||||
|
out = [value * config.speed_mm_s for value in normalised[:3]]
|
||||||
|
out += [value * config.speed_deg_s for value in normalised[3:]]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def twist(normalised: list[float], config, frame: str = BASE_FRAME) -> dict:
|
||||||
|
"""Scale the normalised six-vector to a Twist, in SI.
|
||||||
|
|
||||||
|
Metres per second and radians per second, which is what a robot node should
|
||||||
|
consume: unambiguous, and the same numbers a UR or MoveIt would take. A
|
||||||
|
consumer wanting other units converts them itself, at one known boundary.
|
||||||
|
"""
|
||||||
|
linear = [value * config.speed_mm_s / MM_PER_M for value in normalised[:3]]
|
||||||
|
angular = [math.radians(value * config.speed_deg_s) for value in normalised[3:]]
|
||||||
|
return {"linear": linear, "angular": angular, "frame": frame}
|
||||||
|
|
||||||
|
|
||||||
|
def is_active(sample, layout: Layout = LAYOUT) -> bool:
|
||||||
|
"""Whether anything on the pad is being touched at all.
|
||||||
|
|
||||||
|
A trigger is not a stick. SDL rests a trigger at **-1.0**, so comparing its
|
||||||
|
raw value against a threshold reports every connected pad as being used,
|
||||||
|
for ever — which means the pad claims the arm the moment it is plugged in
|
||||||
|
and `GAMEPAD_IDLE_RELEASE_S` never fires. Triggers go through
|
||||||
|
:func:`trigger` here for the same reason they do in :func:`velocity`.
|
||||||
|
|
||||||
|
Axes the layout does not name are still counted: a pad that reports its
|
||||||
|
d-pad as two extra axes is asking for the arm when somebody presses it.
|
||||||
|
"""
|
||||||
|
if not sample.present:
|
||||||
|
return False
|
||||||
|
if any(sample.buttons):
|
||||||
|
return True
|
||||||
|
triggers = (layout.left_trigger, layout.right_trigger)
|
||||||
|
if any(trigger(axis(sample.axes, index)) > ACTIVITY for index in triggers):
|
||||||
|
return True
|
||||||
|
return any(abs(value) > ACTIVITY for index, value in enumerate(sample.axes) if index not in triggers)
|
||||||
|
|
||||||
|
|
||||||
def is_moving(velocity_vector: list[float]) -> bool:
|
def is_moving(velocity_vector: list[float]) -> bool:
|
||||||
"""Whether this velocity would actually drive the arm."""
|
"""Whether this velocity would actually drive the arm."""
|
||||||
return any(velocity_vector)
|
return any(velocity_vector)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""The wire shapes this node publishes.
|
||||||
|
|
||||||
|
Declared, never inferred. pyarrow reads `{"value": None}` as `null` and
|
||||||
|
`{"value": 1.0}` as `double`, so an inferred `command` would change the type of
|
||||||
|
its port depending on which verb was pressed — `stop` and `freedrive` would not
|
||||||
|
agree. The same hazard that makes an unplugged gamepad retype a `joy` port.
|
||||||
|
|
||||||
|
dora 1.0 has no standard type for either of these, so these structs and the
|
||||||
|
manifest that describes them are the whole contract a consumer gets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pyarrow as pa
|
||||||
|
|
||||||
|
# A velocity, in SI. Metres per second and radians per second, in the frame
|
||||||
|
# named by `frame` — the units a robot node should consume, and the same ones a
|
||||||
|
# UR or MoveIt would take.
|
||||||
|
TWIST = pa.struct(
|
||||||
|
[
|
||||||
|
("linear", pa.list_(pa.float64())),
|
||||||
|
("angular", pa.list_(pa.float64())),
|
||||||
|
("frame", pa.string()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# One discrete thing to do. `action` is a robot-agnostic verb; `value` carries
|
||||||
|
# its argument where it has one and is null otherwise.
|
||||||
|
COMMAND = pa.struct([("action", pa.string()), ("value", pa.float64())])
|
||||||
|
|
||||||
|
# `dora-ulite6`'s velocity, in mm/s and deg/s. Deprecated along with the `jog`
|
||||||
|
# port it goes out on.
|
||||||
|
JOG = pa.struct([("velocity", pa.list_(pa.float64()))])
|
||||||
|
|
||||||
|
|
||||||
|
def pack_twist(twist: dict) -> pa.Array:
|
||||||
|
"""Build one `twist` message."""
|
||||||
|
return pa.array([twist], type=TWIST)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_command(action: str, value: float | None) -> pa.Array:
|
||||||
|
"""Build one `command` message."""
|
||||||
|
return pa.array([{"action": action, "value": value}], type=COMMAND)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_jog(velocity: list[float]) -> pa.Array:
|
||||||
|
"""Build one deprecated `jog` message."""
|
||||||
|
return pa.array([{"velocity": [float(v) for v in velocity]}], type=JOG)
|
||||||
+46
-27
@@ -1,24 +1,40 @@
|
|||||||
"""Show what the pad is doing, with no dataflow and no arm.
|
"""Show what the pad is doing to the arm, without an arm.
|
||||||
|
|
||||||
`dora-teleop-probe` opens the gamepad and prints, live: the raw axes SDL
|
`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the
|
||||||
reports, the buttons it sees pressed, and the velocity this node would send.
|
same `joy` stream this node reads, and prints: the raw axes, the buttons, and the
|
||||||
|
velocity this node would send. **It cannot move anything.** It publishes no
|
||||||
|
output and never talks to the arm; it only reads.
|
||||||
|
|
||||||
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
|
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
|
||||||
SDL's documented layout rather than an observed one, and a pad that disagrees is
|
SDL's documented layout rather than an observed one, and a pad that disagrees is
|
||||||
otherwise discovered by watching a real arm move the wrong way. It goes through
|
otherwise discovered by watching a real arm move the wrong way. It goes through
|
||||||
:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity`
|
the same :func:`~dora_teleop.mapping.velocity` the node runs, on the same
|
||||||
— the same code the node runs — so what it prints is what the arm would get.
|
readings, so what it prints is what the arm would get — keep it that way, or it
|
||||||
|
stops being evidence.
|
||||||
|
|
||||||
Nothing here can move anything. There is no dora node and no connection to the
|
Because it reads `joy` rather than a pad, the dataflow has to declare it:
|
||||||
arm; this only reads.
|
|
||||||
|
```yaml
|
||||||
|
- id: teleop-probe
|
||||||
|
path: dynamic
|
||||||
|
inputs:
|
||||||
|
joy: joy/joy
|
||||||
|
```
|
||||||
|
|
||||||
|
`dataflow-gamepad.yml` in this repo already does. Start that dataflow under a
|
||||||
|
coordinator (`dora start`, not `dora run`), then run `dora-teleop-probe` in
|
||||||
|
another terminal.
|
||||||
|
|
||||||
|
For a pad whose layout is not known at all — which index is which — use
|
||||||
|
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import time
|
|
||||||
|
|
||||||
from dora_teleop import mapping
|
from dora import Node
|
||||||
|
|
||||||
|
from dora_teleop import joy, mapping
|
||||||
from dora_teleop.config import Config
|
from dora_teleop.config import Config
|
||||||
from dora_teleop.gamepad import Gamepad
|
|
||||||
from dora_teleop.mapping import LAYOUT
|
from dora_teleop.mapping import LAYOUT
|
||||||
|
|
||||||
# What each velocity component is, in the order the arm takes them.
|
# What each velocity component is, in the order the arm takes them.
|
||||||
@@ -79,10 +95,10 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
|||||||
|
|
||||||
if not sample.present:
|
if not sample.present:
|
||||||
lines += [
|
lines += [
|
||||||
" no gamepad found.",
|
" no gamepad.",
|
||||||
"",
|
"",
|
||||||
f" looking for joystick index {config.joystick_index};",
|
" `dora-joy` is reporting no pad, or has stopped reporting at all.",
|
||||||
" plug one in and this will pick it up.",
|
" Either way the arm is being told to hold still.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -102,30 +118,33 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
|||||||
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
|
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
|
||||||
|
|
||||||
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
|
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
|
||||||
lines += ["", f" moving: {moving}"]
|
lines += [
|
||||||
|
"",
|
||||||
|
f" moving: {moving}",
|
||||||
|
f" asking for the arm: {'yes' if mapping.is_active(sample) else 'no'}",
|
||||||
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Open the pad and print what it is doing until interrupted."""
|
"""Attach to a running dataflow and print what the pad is doing."""
|
||||||
parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.")
|
parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.")
|
||||||
parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.")
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
default="teleop-probe",
|
||||||
|
help="The id this probe is declared under in the dataflow. Defaults to teleop-probe.",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
gamepad = Gamepad(config.joystick_index)
|
node = Node(args.name)
|
||||||
gamepad.start()
|
|
||||||
|
|
||||||
try:
|
for event in node:
|
||||||
while True:
|
if event["type"] != "INPUT" or event["id"] != "joy":
|
||||||
sample = gamepad.read()
|
continue
|
||||||
|
sample = joy.decode(event)
|
||||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||||
time.sleep(1.0 / args.hz)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
finally:
|
|
||||||
gamepad.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+17
-9
@@ -49,7 +49,10 @@ class Command:
|
|||||||
"""One thing the console asked for."""
|
"""One thing the console asked for."""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
velocity: list[float] = field(default_factory=list)
|
# Normalised, `[-1, 1]` per degree of freedom — a fraction of full speed,
|
||||||
|
# exactly what a stick reports. Not a velocity: the console does not know
|
||||||
|
# the robot's units and must not have to.
|
||||||
|
axes: list[float] = field(default_factory=list)
|
||||||
action: str = ""
|
action: str = ""
|
||||||
preempt: bool = False
|
preempt: bool = False
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@@ -70,10 +73,10 @@ def command_of(payload: dict) -> Command | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if kind == "jog":
|
if kind == "jog":
|
||||||
velocity = payload.get("velocity") or []
|
axes = payload.get("axes") or []
|
||||||
if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity):
|
if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes):
|
||||||
return None
|
return None
|
||||||
return Command(type="jog", velocity=[float(v) for v in velocity])
|
return Command(type="jog", axes=[float(v) for v in axes])
|
||||||
if kind in ("jog_stop", "estop", "reset", "home"):
|
if kind in ("jog_stop", "estop", "reset", "home"):
|
||||||
return Command(type=kind)
|
return Command(type=kind)
|
||||||
if kind == "control":
|
if kind == "control":
|
||||||
@@ -83,7 +86,7 @@ def command_of(payload: dict) -> Command | None:
|
|||||||
preempt=bool(payload.get("preempt", False)),
|
preempt=bool(payload.get("preempt", False)),
|
||||||
)
|
)
|
||||||
if kind == "gripper":
|
if kind == "gripper":
|
||||||
return Command(type="gripper", enabled=bool(payload.get("vacuum", False)))
|
return Command(type="gripper", enabled=bool(payload.get("closed", False)))
|
||||||
if kind == "freedrive":
|
if kind == "freedrive":
|
||||||
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
||||||
if kind == "move":
|
if kind == "move":
|
||||||
@@ -146,9 +149,10 @@ def arm_state(view: ArmView, jogging: bool) -> str:
|
|||||||
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
|
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
|
||||||
"""Build the telemetry object the console reads.
|
"""Build the telemetry object the console reads.
|
||||||
|
|
||||||
Every key here is read by name in `app.js`. `vacuum` is the console's name
|
Every key here is read by name in `app.js`, so this and the page change
|
||||||
for the mounted tool whatever it actually is, kept because renaming it would
|
together. The names are the robot-agnostic ones: `gripper` rather than
|
||||||
mean editing the page for no gain.
|
`vacuum`, because a page that says vacuum only fits a robot with one on it,
|
||||||
|
and `units` so a page can label a pose without guessing.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": "telemetry",
|
"type": "telemetry",
|
||||||
@@ -158,10 +162,14 @@ def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float)
|
|||||||
"arm_state": arm_state(view, jogging),
|
"arm_state": arm_state(view, jogging),
|
||||||
"owner": owner or "none",
|
"owner": owner or "none",
|
||||||
"estop": view.estop,
|
"estop": view.estop,
|
||||||
"vacuum": view.tool_engaged,
|
"gripper": view.tool_engaged,
|
||||||
"freedrive": view.freedrive_on,
|
"freedrive": view.freedrive_on,
|
||||||
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
||||||
"joints": list(view.joints),
|
"joints": list(view.joints),
|
||||||
|
# What `position` is in, so the page labels it rather than assuming. The
|
||||||
|
# telemetry ports carry whatever the robot publishes; only the command
|
||||||
|
# path is normalised.
|
||||||
|
"units": {"position": "mm", "rotation": "deg"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -9,13 +9,11 @@ authors = [{ name = "Liria" }]
|
|||||||
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
# No hardware dependency. The pad is `dora-joy`, the arm is `dora-ulite6` and the
|
||||||
|
# browser is `dora-websocket-transceiver`; this node decides and nothing else.
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dora-rs >= 1.0.1",
|
"dora-rs >= 1.0.1",
|
||||||
"pyarrow >= 17.0",
|
"pyarrow >= 17.0",
|
||||||
# pygame-ce rather than evdev: Littlehand targets Linux and macOS, evdev is
|
|
||||||
# Linux-only, and pygame-ce ships prebuilt wheels for linux-x86_64,
|
|
||||||
# linux-aarch64 and macos-arm64 — so no dependency here compiles from source.
|
|
||||||
"pygame-ce >= 2.4",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+37
-17
@@ -2,10 +2,22 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pyarrow as pa
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from dora_teleop.config import Config
|
from dora_teleop.config import Config
|
||||||
from dora_teleop.gamepad import Sample
|
from dora_teleop.joy import Sample
|
||||||
|
|
||||||
|
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
|
||||||
|
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
|
||||||
|
# so a test for an absent pad would otherwise build a message no consumer sees.
|
||||||
|
JOY = pa.struct(
|
||||||
|
[
|
||||||
|
("axes", pa.list_(pa.float32())),
|
||||||
|
("buttons", pa.list_(pa.bool_())),
|
||||||
|
("present", pa.bool_()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecordingNode:
|
class RecordingNode:
|
||||||
@@ -26,9 +38,17 @@ class RecordingNode:
|
|||||||
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
|
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
|
||||||
|
|
||||||
def jogs(self):
|
def jogs(self):
|
||||||
"""Return every velocity sent to the arm, in order."""
|
"""Return every velocity sent on the deprecated `jog` port, in order."""
|
||||||
return [row["velocity"] for row, _ in self.outputs("jog")]
|
return [row["velocity"] for row, _ in self.outputs("jog")]
|
||||||
|
|
||||||
|
def twists(self):
|
||||||
|
"""Return every Twist sent on the standard port, in order."""
|
||||||
|
return [row for row, _ in self.outputs("twist")]
|
||||||
|
|
||||||
|
def actions(self):
|
||||||
|
"""Return the verb of every standard `command`, in order."""
|
||||||
|
return [row["action"] for row, _ in self.outputs("command")]
|
||||||
|
|
||||||
def services(self):
|
def services(self):
|
||||||
"""Return the name of every service called, in order."""
|
"""Return the name of every service called, in order."""
|
||||||
return [row["service"] for row, _ in self.outputs("request")]
|
return [row["service"] for row, _ in self.outputs("request")]
|
||||||
@@ -41,20 +61,6 @@ class RecordingNode:
|
|||||||
self.sent.clear()
|
self.sent.clear()
|
||||||
|
|
||||||
|
|
||||||
class FakeGamepad:
|
|
||||||
"""Stands in for the reader thread: hands back whatever the test set."""
|
|
||||||
|
|
||||||
def __init__(self, sample=None):
|
|
||||||
self.sample = sample or Sample()
|
|
||||||
self.stopped = False
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
return self.sample
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
self.stopped = True
|
|
||||||
|
|
||||||
|
|
||||||
def stick(axes=None, buttons=None, present=True):
|
def stick(axes=None, buttons=None, present=True):
|
||||||
"""Build one gamepad reading, padded to a full six axes."""
|
"""Build one gamepad reading, padded to a full six axes."""
|
||||||
values = list(axes or [])
|
values = list(axes or [])
|
||||||
@@ -67,6 +73,20 @@ def stick(axes=None, buttons=None, present=True):
|
|||||||
return Sample(axes=values, buttons=list(buttons or []), present=present)
|
return Sample(axes=values, buttons=list(buttons or []), present=present)
|
||||||
|
|
||||||
|
|
||||||
|
def joy_message(sample):
|
||||||
|
"""Build the Arrow row `dora-joy` would publish for one reading."""
|
||||||
|
return pa.array(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"axes": [float(value) for value in sample.axes],
|
||||||
|
"buttons": [bool(down) for down in sample.buttons],
|
||||||
|
"present": sample.present,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type=JOY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def input_event(event_id, value, metadata=None):
|
def input_event(event_id, value, metadata=None):
|
||||||
"""Build the event dict dora hands a node for one input."""
|
"""Build the event dict dora hands a node for one input."""
|
||||||
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
||||||
@@ -80,8 +100,8 @@ def clean_env(monkeypatch):
|
|||||||
"SPEED_DEG_S",
|
"SPEED_DEG_S",
|
||||||
"DEADZONE",
|
"DEADZONE",
|
||||||
"EXPO",
|
"EXPO",
|
||||||
"JOYSTICK_INDEX",
|
|
||||||
"JOG_TTL_S",
|
"JOG_TTL_S",
|
||||||
|
"JOY_TTL_S",
|
||||||
"GAMEPAD_IDLE_RELEASE_S",
|
"GAMEPAD_IDLE_RELEASE_S",
|
||||||
"STATE_POLL_S",
|
"STATE_POLL_S",
|
||||||
):
|
):
|
||||||
|
|||||||
+14
-4
@@ -10,7 +10,6 @@ def test_the_defaults_need_no_environment():
|
|||||||
|
|
||||||
assert config.speed_mm_s == 60.0
|
assert config.speed_mm_s == 60.0
|
||||||
assert config.speed_deg_s == 30.0
|
assert config.speed_deg_s == 30.0
|
||||||
assert config.joystick_index == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
||||||
@@ -19,6 +18,13 @@ def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
|||||||
assert Config.from_env().deadman.jog_ttl_s < 0.5
|
assert Config.from_env().deadman.jog_ttl_s < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_layers_fire_innermost_first():
|
||||||
|
# A stale pad reading has to be noticed before the jog deadline it feeds.
|
||||||
|
deadman = Config.from_env().deadman
|
||||||
|
|
||||||
|
assert deadman.joy_ttl_s < deadman.jog_ttl_s
|
||||||
|
|
||||||
|
|
||||||
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
||||||
monkeypatch.setenv("SPEED_MM_S", " ")
|
monkeypatch.setenv("SPEED_MM_S", " ")
|
||||||
|
|
||||||
@@ -32,10 +38,12 @@ def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
|
|||||||
Config.from_env()
|
Config.from_env()
|
||||||
|
|
||||||
|
|
||||||
def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch):
|
def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
|
||||||
monkeypatch.setenv("JOYSTICK_INDEX", "1.5")
|
# Layers out of order means a backstop firing before the thing it backs up,
|
||||||
|
# which is invisible until the day something actually goes wrong.
|
||||||
|
monkeypatch.setenv("JOY_TTL_S", "0.4")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
|
with pytest.raises(ValueError, match="JOY_TTL_S"):
|
||||||
Config.from_env()
|
Config.from_env()
|
||||||
|
|
||||||
|
|
||||||
@@ -59,9 +67,11 @@ def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value):
|
|||||||
|
|
||||||
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
||||||
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
||||||
|
monkeypatch.setenv("JOY_TTL_S", "0.1")
|
||||||
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
||||||
|
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
|
|
||||||
assert config.deadman.jog_ttl_s == 0.25
|
assert config.deadman.jog_ttl_s == 0.25
|
||||||
|
assert config.deadman.joy_ttl_s == 0.1
|
||||||
assert config.deadman.gamepad_idle_release_s == 5.0
|
assert config.deadman.gamepad_idle_release_s == 5.0
|
||||||
|
|||||||
+245
-53
@@ -8,8 +8,8 @@ import pytest
|
|||||||
from dora_teleop import main as loop
|
from dora_teleop import main as loop
|
||||||
from dora_teleop.arbiter import GAMEPAD, WEB
|
from dora_teleop.arbiter import GAMEPAD, WEB
|
||||||
from dora_teleop.config import Config
|
from dora_teleop.config import Config
|
||||||
from dora_teleop.gamepad import Sample
|
from dora_teleop.joy import Sample
|
||||||
from tests.conftest import FakeGamepad, RecordingNode, input_event, stick
|
from tests.conftest import RecordingNode, input_event, joy_message, stick
|
||||||
|
|
||||||
STOP = [0.0] * 6
|
STOP = [0.0] * 6
|
||||||
PUSHED = [0.0, -1.0]
|
PUSHED = [0.0, -1.0]
|
||||||
@@ -34,19 +34,32 @@ def clock(monkeypatch):
|
|||||||
def build():
|
def build():
|
||||||
config = Config.from_env()
|
config = Config.from_env()
|
||||||
config.expo = 0.0
|
config.expo = 0.0
|
||||||
return RecordingNode(), loop.Teleop(config), FakeGamepad()
|
return RecordingNode(), loop.Teleop(config)
|
||||||
|
|
||||||
|
|
||||||
def jog_from(node, state, gamepad, count=1):
|
def send_joy(node, state, sample):
|
||||||
"""Run `count` ticks and return every velocity that reached the arm."""
|
"""Deliver one reading the way `dora-joy` publishes it, decoding included."""
|
||||||
|
loop._handle(node, state, input_event("joy", joy_message(sample)))
|
||||||
|
|
||||||
|
|
||||||
|
def ticks(node, state, count=1):
|
||||||
|
"""Run `count` ticks with nothing new arriving, and return every velocity sent."""
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
return node.jogs()
|
return node.jogs()
|
||||||
|
|
||||||
|
|
||||||
def web_jog(node, state, velocity):
|
def drive(node, state, sample, count=1):
|
||||||
"""Deliver one console jog message."""
|
"""Hold one reading for `count` ticks, the way a real pad and joy node would."""
|
||||||
payload = json.dumps({"type": "jog", "velocity": velocity})
|
for _ in range(count):
|
||||||
|
send_joy(node, state, sample)
|
||||||
|
loop._tick(node, state)
|
||||||
|
return node.jogs()
|
||||||
|
|
||||||
|
|
||||||
|
def web_jog(node, state, axes):
|
||||||
|
"""Deliver one console jog message, in the normalised units the page speaks."""
|
||||||
|
payload = json.dumps({"type": "jog", "axes": axes})
|
||||||
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
||||||
|
|
||||||
|
|
||||||
@@ -55,10 +68,9 @@ def web_jog(node, state, velocity):
|
|||||||
|
|
||||||
def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
||||||
# Nobody should have to reach for a browser to use the pad in their hands.
|
# Nobody should have to reach for a browser to use the pad in their hands.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
|
||||||
|
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = drive(node, state, stick(axes=PUSHED))
|
||||||
|
|
||||||
assert state.arbiter.owner == GAMEPAD
|
assert state.arbiter.owner == GAMEPAD
|
||||||
assert jogs[-1][0] > 0
|
assert jogs[-1][0] > 0
|
||||||
@@ -67,25 +79,49 @@ def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
|||||||
def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock):
|
def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock):
|
||||||
# An unplugged pad is not a centred pad, and waiting out the TTL here is the
|
# An unplugged pad is not a centred pad, and waiting out the TTL here is the
|
||||||
# difference between a dropped controller costing 10 ms of travel and 400.
|
# difference between a dropped controller costing 10 ms of travel and 400.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
drive(node, state, stick(axes=PUSHED))
|
||||||
jog_from(node, state, gamepad)
|
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = Sample(present=False)
|
jogs = drive(node, state, Sample(present=False))
|
||||||
jogs = jog_from(node, state, gamepad)
|
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
|
|
||||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
def test_the_joy_node_dying_stops_the_arm(clock):
|
||||||
node, state, gamepad = build()
|
# The pad is another process now, and that process dying looks like silence
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
# rather than like an unplugged pad. It has to mean the same thing: a
|
||||||
jog_from(node, state, gamepad)
|
# reading nobody is refreshing is not evidence that the stick is still held.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=PUSHED))
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = stick()
|
clock.advance(state.config.deadman.joy_ttl_s + 0.01)
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = ticks(node, state)
|
||||||
|
|
||||||
|
assert jogs == [STOP]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_joy_node_that_keeps_publishing_keeps_the_arm_moving(clock):
|
||||||
|
# The mirror of the test above: time passing is not by itself a reason to
|
||||||
|
# stop, or a held stick would stutter.
|
||||||
|
node, state = build()
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
for _ in range(4):
|
||||||
|
clock.advance(0.05)
|
||||||
|
sent = drive(node, state, stick(axes=PUSHED))
|
||||||
|
|
||||||
|
assert len(sent) == 4
|
||||||
|
assert all(jog[0] > 0 for jog in sent)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=PUSHED))
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
jogs = drive(node, state, stick())
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
@@ -93,13 +129,11 @@ def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
|||||||
def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
||||||
# A stream of zeroes would keep the arm node believing a jog is live and
|
# A stream of zeroes would keep the arm node believing a jog is live and
|
||||||
# hold robot_state at `moving` forever.
|
# hold robot_state at `moving` forever.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
drive(node, state, stick(axes=PUSHED))
|
||||||
jog_from(node, state, gamepad)
|
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
gamepad.sample = stick()
|
jogs = drive(node, state, stick(), count=5)
|
||||||
jogs = jog_from(node, state, gamepad, count=5)
|
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
@@ -107,29 +141,39 @@ def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
|||||||
def test_a_held_stick_is_resent_every_tick(clock):
|
def test_a_held_stick_is_resent_every_tick(clock):
|
||||||
# The arm's controller expires a velocity on its own; the re-send is what
|
# The arm's controller expires a velocity on its own; the re-send is what
|
||||||
# keeps that backstop from firing mid-motion.
|
# keeps that backstop from firing mid-motion.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
gamepad.sample = stick(axes=PUSHED)
|
|
||||||
|
|
||||||
jogs = jog_from(node, state, gamepad, count=4)
|
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||||
|
|
||||||
assert len(jogs) == 4
|
assert len(jogs) == 4
|
||||||
assert all(jog[0] > 0 for jog in jogs)
|
assert all(jog[0] > 0 for jog in jogs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pad_that_was_never_wired_up_leaves_the_arm_alone(clock):
|
||||||
|
# `joy` is optional: a browser-only console wires no pad at all, and the
|
||||||
|
# node must not read that as a centred stick asking for the arm.
|
||||||
|
node, state = build()
|
||||||
|
|
||||||
|
jogs = ticks(node, state, count=3)
|
||||||
|
|
||||||
|
assert jogs == []
|
||||||
|
assert state.arbiter.owner is None
|
||||||
|
|
||||||
|
|
||||||
# --- the browser ---
|
# --- the browser ---
|
||||||
|
|
||||||
|
|
||||||
def test_the_last_browser_leaving_stops_the_arm(clock):
|
def test_the_last_browser_leaving_stops_the_arm(clock):
|
||||||
# Closing the tab has to stop the arm, and nothing is sent when it does.
|
# Closing the tab has to stop the arm, and nothing is sent when it does.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
jog_from(node, state, gamepad)
|
ticks(node, state)
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = ticks(node, state)
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
assert state.arbiter.owner is None
|
assert state.arbiter.owner is None
|
||||||
@@ -137,41 +181,41 @@ def test_the_last_browser_leaving_stops_the_arm(clock):
|
|||||||
|
|
||||||
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
|
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
|
||||||
# The console refreshes every 150 ms against a 400 ms deadline.
|
# The console refreshes every 150 ms against a 400 ms deadline.
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
jog_from(node, state, gamepad)
|
ticks(node, state)
|
||||||
node.clear()
|
node.clear()
|
||||||
|
|
||||||
clock.advance(0.5)
|
clock.advance(0.5)
|
||||||
jogs = jog_from(node, state, gamepad)
|
jogs = ticks(node, state)
|
||||||
|
|
||||||
assert jogs == [STOP]
|
assert jogs == [STOP]
|
||||||
|
|
||||||
|
|
||||||
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(WEB, clock.t)
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
|
||||||
sent = []
|
sent = []
|
||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
clock.advance(0.15)
|
clock.advance(0.15)
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
sent = node.jogs()
|
sent = node.jogs()
|
||||||
|
|
||||||
assert all(jog[0] == pytest.approx(10.0) for jog in sent)
|
assert all(jog[0] == pytest.approx(state.config.speed_mm_s) for jog in sent)
|
||||||
|
|
||||||
|
|
||||||
def test_a_non_owner_jog_is_ignored(clock):
|
def test_a_non_owner_jog_is_ignored(clock):
|
||||||
node, state, gamepad = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(GAMEPAD, clock.t)
|
state.arbiter.take(GAMEPAD, clock.t)
|
||||||
|
|
||||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
loop._tick(node, state, gamepad)
|
loop._tick(node, state)
|
||||||
|
|
||||||
assert node.jogs() == []
|
assert node.jogs() == []
|
||||||
assert any(message.get("type") == "error" for message in node.ui())
|
assert any(message.get("type") == "error" for message in node.ui())
|
||||||
@@ -183,7 +227,7 @@ def test_a_non_owner_jog_is_ignored(clock):
|
|||||||
def test_estop_is_never_gated_by_the_lease(clock):
|
def test_estop_is_never_gated_by_the_lease(clock):
|
||||||
# Somebody reaching for the stop is the one moment when asking who owns the
|
# Somebody reaching for the stop is the one moment when asking who owns the
|
||||||
# arm is the wrong question.
|
# arm is the wrong question.
|
||||||
node, state, _ = build()
|
node, state = build()
|
||||||
state.clients = 1
|
state.clients = 1
|
||||||
state.arbiter.take(GAMEPAD, clock.t)
|
state.arbiter.take(GAMEPAD, clock.t)
|
||||||
|
|
||||||
@@ -194,18 +238,87 @@ def test_estop_is_never_gated_by_the_lease(clock):
|
|||||||
assert "stop" in node.services()
|
assert "stop" in node.services()
|
||||||
|
|
||||||
|
|
||||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
|
||||||
node, state, gamepad = build()
|
# A stop somebody had to reach for is not undone by the next thumb
|
||||||
state.arbiter.take(WEB, clock.t)
|
# movement. Without this the pad stops the arm for one tick and drives it
|
||||||
gamepad.sample = stick(buttons=[False] * 6 + [True])
|
# again on the next, which is not a stop at all.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=PUSHED))
|
||||||
|
node.clear()
|
||||||
|
|
||||||
loop._tick(node, state, gamepad)
|
drive(node, state, stick(axes=PUSHED, buttons=[False] * 6 + [True]))
|
||||||
|
node.clear()
|
||||||
|
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||||
|
|
||||||
|
assert jogs == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pad_can_release_the_latch_it_set(clock):
|
||||||
|
# A pad-only dataflow has no console RESET, so a latch nothing on the pad
|
||||||
|
# can release would strand the arm until the process was restarted.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
assert state.view.estop is True
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
drive(node, state, stick(buttons=[False, False, True]))
|
||||||
|
|
||||||
|
assert state.view.estop is False
|
||||||
|
assert "clear" in node.services()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_latch_is_released_without_holding_the_arm(clock):
|
||||||
|
# An idle pad hands the lease back two seconds after the stop it just made,
|
||||||
|
# so gating the release on the lease would be a trap.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
state.arbiter.release(GAMEPAD)
|
||||||
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
drive(node, state, stick(buttons=[False, False, True]))
|
||||||
|
|
||||||
|
assert state.view.estop is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_arm_drives_again_once_the_latch_is_released(clock):
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
drive(node, state, stick(buttons=[False, False, True]))
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
jogs = drive(node, state, stick(axes=PUSHED))
|
||||||
|
|
||||||
|
assert jogs[-1][0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_latched_estop_stops_the_browser_too(clock):
|
||||||
|
# The latch is the node's, not the pad's. A console jog must not walk
|
||||||
|
# through a stop somebody made on the gamepad.
|
||||||
|
node, state = build()
|
||||||
|
state.clients = 1
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
state.arbiter.release(GAMEPAD)
|
||||||
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
|
jogs = ticks(node, state)
|
||||||
|
|
||||||
|
assert jogs == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
||||||
|
node, state = build()
|
||||||
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
|
||||||
assert "stop" in node.services()
|
assert "stop" in node.services()
|
||||||
|
|
||||||
|
|
||||||
def test_a_latched_estop_is_cleared_by_reset(clock):
|
def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||||
node, state, _ = build()
|
node, state = build()
|
||||||
state.view.estop = True
|
state.view.estop = True
|
||||||
|
|
||||||
payload = json.dumps({"type": "reset"})
|
payload = json.dumps({"type": "reset"})
|
||||||
@@ -213,3 +326,82 @@ def test_a_latched_estop_is_cleared_by_reset(clock):
|
|||||||
|
|
||||||
assert state.view.estop is False
|
assert state.view.estop is False
|
||||||
assert "clear" in node.services()
|
assert "clear" in node.services()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the standard ports ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_deflection_from_either_source_asks_for_the_same_motion(clock):
|
||||||
|
# The whole reason both sources are normalised before they are scaled. If
|
||||||
|
# these ever differ, one path has grown a conversion the other has not —
|
||||||
|
# which is how a console ends up in the robot's units.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||||
|
from_pad = node.twists()[-1]
|
||||||
|
|
||||||
|
node, state = build()
|
||||||
|
state.clients = 1
|
||||||
|
state.arbiter.take(WEB, clock.t)
|
||||||
|
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||||
|
ticks(node, state)
|
||||||
|
from_web = node.twists()[-1]
|
||||||
|
|
||||||
|
assert from_pad == from_web
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_twist_is_si_and_the_legacy_jog_is_not(clock):
|
||||||
|
# 60 mm/s is 0.06 m/s. A consumer reading the wrong port at the wrong scale
|
||||||
|
# is a thousandfold error, so this is worth pinning.
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||||
|
|
||||||
|
twist, jog = node.twists()[-1], node.jogs()[-1]
|
||||||
|
|
||||||
|
assert twist["linear"][0] == pytest.approx(state.config.speed_mm_s / 1000.0)
|
||||||
|
assert jog[0] == pytest.approx(state.config.speed_mm_s)
|
||||||
|
assert twist["frame"] == "base"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stop_reaches_both_ports(clock):
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(axes=PUSHED))
|
||||||
|
node.clear()
|
||||||
|
|
||||||
|
drive(node, state, stick())
|
||||||
|
|
||||||
|
assert node.jogs() == [STOP]
|
||||||
|
assert node.twists() == [{"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
|
||||||
|
# The verb is what a robot speaking the standard reads; the service name is
|
||||||
|
# what dora-ulite6 reads until it does.
|
||||||
|
node, state = build()
|
||||||
|
|
||||||
|
drive(node, state, stick(buttons=[True]))
|
||||||
|
|
||||||
|
assert node.actions() == ["gripper_close"]
|
||||||
|
assert "grasp" in node.services()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_verb_the_pad_can_ask_for_is_robot_agnostic(clock):
|
||||||
|
node, state = build()
|
||||||
|
drive(node, state, stick(buttons=[False, True]))
|
||||||
|
drive(node, state, stick(buttons=[False] * 3 + [True]))
|
||||||
|
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||||
|
drive(node, state, stick(buttons=[False, False, True]))
|
||||||
|
|
||||||
|
assert node.actions() == ["gripper_open", "freedrive", "stop", "clear"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_first_press_after_the_pad_has_been_idle_is_not_dropped(clock):
|
||||||
|
# The lease has to be settled before the buttons are read. Read them first
|
||||||
|
# and the press arrives while nobody owns the arm, and by the next tick the
|
||||||
|
# button is held rather than newly down — so the edge is gone for good and
|
||||||
|
# the operator presses A and watches nothing happen.
|
||||||
|
node, state = build()
|
||||||
|
assert state.arbiter.owner is None
|
||||||
|
|
||||||
|
drive(node, state, stick(buttons=[True]), count=3)
|
||||||
|
|
||||||
|
assert node.actions() == ["gripper_close"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -108,3 +108,35 @@ def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config):
|
|||||||
|
|
||||||
assert velocity[VX] == 0.0
|
assert velocity[VX] == 0.0
|
||||||
assert velocity[WZ] == 0.0
|
assert velocity[WZ] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- is the pad being used at all ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pad_at_rest_is_not_asking_for_the_arm():
|
||||||
|
# SDL rests a trigger at -1.0, so a raw threshold reports every connected
|
||||||
|
# pad as in use for ever: the pad claims the arm the moment it is plugged
|
||||||
|
# in and GAMEPAD_IDLE_RELEASE_S never fires.
|
||||||
|
assert mapping.is_active(stick()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absent_pad_is_not_asking_for_the_arm():
|
||||||
|
assert mapping.is_active(stick(present=False)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nudged_stick_is_asking_for_the_arm():
|
||||||
|
assert mapping.is_active(stick(axes=[0.5])) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_trigger_is_asking_for_the_arm():
|
||||||
|
assert mapping.is_active(stick(axes=[0.0, 0.0, 1.0])) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_button_is_asking_for_the_arm():
|
||||||
|
assert mapping.is_active(stick(buttons=[True])) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_axis_the_layout_does_not_use_still_counts():
|
||||||
|
# A pad reporting its d-pad as two extra axes is asking for the arm when
|
||||||
|
# somebody presses it, whether or not this node maps it to anything.
|
||||||
|
assert mapping.is_active(stick(axes=[0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 1.0])) is True
|
||||||
|
|||||||
+13
-6
@@ -1,7 +1,7 @@
|
|||||||
"""The probe: what it draws, without a pad or a terminal."""
|
"""The probe: what it draws, without a pad or a terminal."""
|
||||||
|
|
||||||
from dora_teleop import mapping, probe
|
from dora_teleop import mapping, probe
|
||||||
from dora_teleop.gamepad import Sample
|
from dora_teleop.joy import Sample
|
||||||
from tests.conftest import stick
|
from tests.conftest import stick
|
||||||
|
|
||||||
|
|
||||||
@@ -25,13 +25,13 @@ def test_a_reading_beyond_full_deflection_stays_on_the_bar():
|
|||||||
assert "#" in probe.bar(4.0)
|
assert "#" in probe.bar(4.0)
|
||||||
|
|
||||||
|
|
||||||
def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config):
|
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
|
||||||
config.joystick_index = 2
|
# Absence covers both "no pad" and "dora-joy stopped sending", and the probe
|
||||||
|
# must not imply the arm is waiting on something it is not.
|
||||||
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
||||||
|
|
||||||
assert "no gamepad found" in drawn
|
assert "no gamepad" in drawn
|
||||||
assert "index 2" in drawn
|
assert "hold still" in drawn
|
||||||
|
|
||||||
|
|
||||||
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
||||||
@@ -79,3 +79,10 @@ def test_a_moving_arm_is_reported_as_moving(config):
|
|||||||
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
||||||
|
|
||||||
assert "moving: yes" in drawn
|
assert "moving: yes" in drawn
|
||||||
|
|
||||||
|
|
||||||
|
def test_whether_the_pad_is_asking_for_the_arm_is_shown(config):
|
||||||
|
# The probe is where a resting trigger claiming the arm would have been
|
||||||
|
# visible, so it says what `is_active` thinks rather than leaving it implied.
|
||||||
|
assert "asking for the arm: no" in probe.render(stick(), [0.0] * 6, config)
|
||||||
|
assert "asking for the arm: yes" in probe.render(stick(axes=[0.0, -1.0]), [0.0] * 6, config)
|
||||||
|
|||||||
+8
-7
@@ -7,18 +7,18 @@ from dora_teleop.arbiter import GAMEPAD, WEB
|
|||||||
|
|
||||||
|
|
||||||
def test_a_jog_carries_its_six_numbers():
|
def test_a_jog_carries_its_six_numbers():
|
||||||
command = ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, 6]})
|
command = ui.command_of({"type": "jog", "axes": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
|
||||||
|
|
||||||
assert command.type == "jog"
|
assert command.type == "jog"
|
||||||
assert command.velocity == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
assert command.axes == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
|
||||||
|
|
||||||
|
|
||||||
def test_a_jog_of_the_wrong_length_is_not_a_jog():
|
def test_a_jog_of_the_wrong_length_is_not_a_jog():
|
||||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3]}) is None
|
assert ui.command_of({"type": "jog", "axes": [1, 2, 3]}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
|
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
|
||||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, "fast"]}) is None
|
assert ui.command_of({"type": "jog", "axes": [1, 2, 3, 4, 5, "fast"]}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
|
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
|
||||||
@@ -36,8 +36,8 @@ def test_taking_control_carries_whether_it_may_preempt():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_gripper_button_says_which_way():
|
def test_the_gripper_button_says_which_way():
|
||||||
assert ui.command_of({"type": "gripper", "vacuum": True}).enabled is True
|
assert ui.command_of({"type": "gripper", "closed": True}).enabled is True
|
||||||
assert ui.command_of({"type": "gripper", "vacuum": False}).enabled is False
|
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
|
||||||
|
|
||||||
|
|
||||||
def test_a_move_needs_a_position():
|
def test_a_move_needs_a_position():
|
||||||
@@ -109,7 +109,8 @@ def test_the_snapshot_carries_every_key_the_page_reads_by_name():
|
|||||||
"arm_state",
|
"arm_state",
|
||||||
"owner",
|
"owner",
|
||||||
"estop",
|
"estop",
|
||||||
"vacuum",
|
"gripper",
|
||||||
|
"units",
|
||||||
"freedrive",
|
"freedrive",
|
||||||
"position",
|
"position",
|
||||||
"joints",
|
"joints",
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dora-rs" },
|
{ name = "dora-rs" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
{ name = "pygame-ce" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -59,7 +58,6 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||||
{ name = "pyarrow", specifier = ">=17.0" },
|
{ name = "pyarrow", specifier = ">=17.0" },
|
||||||
{ name = "pygame-ce", specifier = ">=2.4" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -296,54 +294,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pygame-ce"
|
|
||||||
version = "2.5.8"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/26/2d/0f942ec31d558a6a1f2fd0df9965ff0055f165ed5b8d36f6509b1f3768a2/pygame_ce-2.5.8.tar.gz", hash = "sha256:3c8e69088ead310037972c391306ea58e74d7296b35d1890067749235fd554ba", size = 5300831, upload-time = "2026-08-09T11:39:49.226Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/9b/884fb7367951e74e7f77061bb08e893272794c07b1212dc61ba258c3c71e/pygame_ce-2.5.8-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:9e98223c8874177c8441923a8f60da76b1de9d7f2450602a4c7ef24c15e84f1a", size = 16588698, upload-time = "2026-08-09T11:38:07.009Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/7d/b3f3a58cd09e97dbbedde419bedb7ead1f7e4d3cd6465b00bdf56766ded9/pygame_ce-2.5.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a7b2a54548926eaf535ac574cfa6528f96bd1e2f524a20fea514b2922dd68bcc", size = 12060566, upload-time = "2026-08-09T11:38:09.516Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/8b/7ba5dbf40ff461984327539a6895ada8ab2eac4c035238f97a0fd9da9866/pygame_ce-2.5.8-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff283d1bee65e208cf2f0da7f2f4e3d0993d1787938898de2872fbefab1f786b", size = 12599420, upload-time = "2026-08-09T11:38:12.101Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/02/c315738725cd08d2ff4cc8f8dd29638e69e70f200d3610bcd291cbb886b8/pygame_ce-2.5.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:33e1b5a78cd6d25c2c8f00c09cdc0ba8000eec81b53dc7d8d7323d8319691d69", size = 12155284, upload-time = "2026-08-09T11:38:14.456Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/3d/b9d57ebab57fdef946626ae275b111c077b83fe0d0ccc3a2ec12b17523d2/pygame_ce-2.5.8-cp311-cp311-win32.whl", hash = "sha256:b919b49b8a41564f7d0ad43f6dc97a66f29d3956fb592581f4abd62442644c9c", size = 9122893, upload-time = "2026-08-09T11:38:16.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/68/54cbebea295bbd9598604e3b4c3ef2aff75df1612482c2d102963151096c/pygame_ce-2.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:0f75f6cf17607e380687aa5551c46aa830c8037b2950c71e48be8891ddc737da", size = 9666898, upload-time = "2026-08-09T11:38:19.213Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/8a/79129cc0c755d639d180a582869e4c7a0d5106dbf611262bcf5d554ce59d/pygame_ce-2.5.8-cp311-cp311-win_arm64.whl", hash = "sha256:8e7931bd6b8ffc1474e7b3d5717e18bf8ff3eed95ab9042f1b9a0907e9ff6519", size = 10212252, upload-time = "2026-08-09T11:38:21.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/9c48c56cb36baa49d8732fc41896d2d8a6027bf94155aa1a5663fd72f7ff/pygame_ce-2.5.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ae2141c81595b4b13b780d325b9ea0016c6647f8b3a6004278b00765a96664d", size = 16570732, upload-time = "2026-08-09T11:38:24.118Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/83/f4979079f5a8f729b0f488e987db1227086816c26e1b985d61fc9a60605f/pygame_ce-2.5.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e401643af9455cb69f8cb77050c325f290314da0051e9ab82c5ccaf8017c314a", size = 12048869, upload-time = "2026-08-09T11:38:26.733Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/38/9961f6543bfb4ecc4dbeee0fdb57edbd633945dc143df9c70ee476416cc4/pygame_ce-2.5.8-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6d4839f127b1b66f1dfe7e0dca3d2233b6f3a63077690081d1996e24087c6055", size = 12587614, upload-time = "2026-08-09T11:38:29.143Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/07/e139faa8911d48991e161e356e652be2a642303f3baf6a57c7ba4a1f0f49/pygame_ce-2.5.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf9c8b4376077efec6ae31443172ae98545f278d0e4426852e81c28ab1f5d7c2", size = 12147342, upload-time = "2026-08-09T11:38:32.166Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/e6/238b48936733ed1908486df072137e7eef61fd26de4620d54e438b69d509/pygame_ce-2.5.8-cp312-cp312-win32.whl", hash = "sha256:91d2434d8cbdb7cd04c9b37e76a8154641da25f4e274265806407f495fe6e889", size = 9126160, upload-time = "2026-08-09T11:38:34.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/13/2bba0ebe563047ae9de68dc05656dc0bfa9394c60970548b06d2f2e106c5/pygame_ce-2.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:2cfe8afe1e7955780f0bd4f01d65f1b4903f561407052081cfcec31e80957c3c", size = 9668595, upload-time = "2026-08-09T11:38:37.279Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/2e/871161f0546d549fa458609029771d7a36c1e7758e164a3b2c2ed61b507b/pygame_ce-2.5.8-cp312-cp312-win_arm64.whl", hash = "sha256:6c48a3ad5f7102e076055a3c817c12e932ee5c9b831080db2ea9754236b0490d", size = 10213955, upload-time = "2026-08-09T11:38:39.559Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/06/f2e2d9fe3eb2dc1fde30b90e250274e85ba355af729892ac71ba653924a1/pygame_ce-2.5.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56441a9bb75c2461750dc0e6e4a46e3833b3cf6339bfbaf16f93ecac037510f3", size = 16562516, upload-time = "2026-08-09T11:38:42.311Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/fe/4be67df98bb05f7b024900f80fe4d07a72cec7022262d17c449b2a9f6034/pygame_ce-2.5.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3183bc0d808e739ca2400df45a5bfae10704ccbcacab0f04f9adc9027e86427", size = 12045702, upload-time = "2026-08-09T11:38:44.903Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/8f/f7d283799aaa2208c1276f085d514f2ac37ce1bb5df940c7cbac7e6a6320/pygame_ce-2.5.8-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:841aecccb498419367936bea0b1b0c6e9d635dc81c41ef26ec1769158f1ab871", size = 12585829, upload-time = "2026-08-09T11:38:47.507Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/4d/03fd52c7f958b8e929757a118cada9aee16ae9d4fd84f2682ee0cbd3941d/pygame_ce-2.5.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5bb966b610e161a8f6d908b4c62ef3a1f3e922bc7bed73bd70a3e15dc780b90e", size = 12145623, upload-time = "2026-08-09T11:38:49.87Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/61/c02613190a3256a656258bb4f677aecc09c3cb1f5521490ce7d5fb6308dd/pygame_ce-2.5.8-cp313-cp313-win32.whl", hash = "sha256:cab944d76af71e707803856b4db984ca90d9d6453e470e89771ed963c11ed912", size = 9123635, upload-time = "2026-08-09T11:38:52.452Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/da9186e5b88714c16fdb23bc4ba0bca4a75c21e3a5cf9607765773f68d22/pygame_ce-2.5.8-cp313-cp313-win_amd64.whl", hash = "sha256:f495b0eb7a5c54c59da58e964bc7f68073c3f43cf307729fd48309104a04c190", size = 9668342, upload-time = "2026-08-09T11:38:54.988Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/d3/78136bc51be25afbd7954c22488860e80f32d9a885f6667fed8aac7df0d6/pygame_ce-2.5.8-cp313-cp313-win_arm64.whl", hash = "sha256:dea22b4d4c418bbd0bd9853ae797ecfa882436d2684ceb6063ba8b520adc8500", size = 10211021, upload-time = "2026-08-09T11:38:57.529Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/e6/8e83904cf4184223419345a78c905e7c1ace10228befd87eb497ba015c8c/pygame_ce-2.5.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ec4efa6b57a194f5ad51b92211cc74b485320df951b0cc870235b2b889b9b6d", size = 16571019, upload-time = "2026-08-09T11:39:00.084Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/f3/4f90a0b5e86635d741111084eaa4d4c65fcab2f461edb8c9efa502e4c630/pygame_ce-2.5.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb91d0bb0b5e2a4da61910d752769d4580cacffb4b7338cee57c932351c67e7b", size = 12051251, upload-time = "2026-08-09T11:39:02.561Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/7e/b0c4f5d43261e8707353ef40cf6620d46d42f43a502c236e93ce6ea82e7b/pygame_ce-2.5.8-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68bd9c4ac42bb2549c034d56399404f16c87c56714db721ed975a759798a3d16", size = 12585932, upload-time = "2026-08-09T11:39:04.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/62/06f0ceb7f5a154e0071a4ce81a16de47b9f3b85034fa391686d489d15be5/pygame_ce-2.5.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0027bd2255cbb39789d9c7ff95a5f040f56d1cfeb09504497447c0ee06169a24", size = 12147357, upload-time = "2026-08-09T11:39:07.433Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/57/14d6b40318af71cf20e4c750866795424e018f79c5a5cf3a9a74a8717c4c/pygame_ce-2.5.8-cp314-cp314-win32.whl", hash = "sha256:c5285d444e4b789ef95522bbd2b90433163ad2b89e0ae4891236fde88a027fbd", size = 9255121, upload-time = "2026-08-09T11:39:09.723Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/31/92d32a9bf9b78e9ef10cec7224e2f00efc957757505822af8dc4225e9b41/pygame_ce-2.5.8-cp314-cp314-win_amd64.whl", hash = "sha256:b4c2e28c201240199356952e6bd0221969d2eb8a1a15c3454d77db209bad7891", size = 9833757, upload-time = "2026-08-09T11:39:11.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/26/4024483d4a3161ed8fb8d3f3f4957580af31d468e0c31df7e6038f45ccdc/pygame_ce-2.5.8-cp314-cp314-win_arm64.whl", hash = "sha256:b23034594412456504822d3088cf5f292f63bbe415eac29735340a59999e4116", size = 10410381, upload-time = "2026-08-09T11:39:14.417Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/11/0b47f80b261d6379c86b9cc3fa405af5bd62af10506de7e28c2275d67b86/pygame_ce-2.5.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a4e0804d53bae8d9aa5192fc2259b40dd084b48ea273dd7dd6e1c12b73303c4", size = 16571139, upload-time = "2026-08-09T11:39:16.979Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/95/694cd02641a0c0956b6c5919b56599af302524ac133551b654f550f28919/pygame_ce-2.5.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7f03d38b3693c014dd47d4e281620b6756669129e545af1cd608a9484582846", size = 12051154, upload-time = "2026-08-09T11:39:19.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/5e/bed22b0d07d9e96bb9a448acc9da7a235f046280264c33476f0c0753d302/pygame_ce-2.5.8-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d084b79535f3529aa157edd9401f4f33e5082f318d287029291d6596bb225105", size = 12585976, upload-time = "2026-08-09T11:39:22.555Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/ce/8c167ba5ba736e372730ae29ef0f8cb2e62a88580e647ae987bc112eb1cb/pygame_ce-2.5.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9cbf6e2648ae6d76aa8ece82187d240300cfd9f4c90860e1e466f78cf0ab14a", size = 12147466, upload-time = "2026-08-09T11:39:24.935Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/92/c99ed51479f2d2a5d7fc9d5aa3b880e4c0fcc87f30cb95ecf325f35dd2fd/pygame_ce-2.5.8-cp315-cp315-win32.whl", hash = "sha256:312a01ff5439a0bd2e55b56683518454da4cbe4cad493d0580e9c49f6aacc2d8", size = 9254928, upload-time = "2026-08-09T11:39:29.525Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/41/404836598d666ffe02681fd0c29f013daaa8baf18e5de5f7a5d4870b6105/pygame_ce-2.5.8-cp315-cp315-win_amd64.whl", hash = "sha256:5b789aa7e4239be025c9de8428c2c422f0811f0e3dd702b574d836fd9ae040eb", size = 9833657, upload-time = "2026-08-09T11:39:31.897Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/a2/7a844f772c6f0967e75d49cb10602fbb878b9daee1cc080d2b9597bcc873/pygame_ce-2.5.8-cp315-cp315-win_arm64.whl", hash = "sha256:28c4fed3870e3edf72ea7ea359bb5f64c55db0fb9cb3bdb26b4b674b4b4bcff4", size = 10410231, upload-time = "2026-08-09T11:39:34.184Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/b8/525dfcfda50ea9e98cd4097d6d0beabcfa0a6007475c04dab2021077c6de/pygame_ce-2.5.8-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:0aa908c9fce4d508205e0b9cdc0f7338066da63322f6bcaa9a9c8aff9155a87e", size = 16485912, upload-time = "2026-08-09T11:39:37.277Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/33/d02386a4314be22bc6198d93c2d99f372c0c8c7b71fc9c390cf36d5c5c99/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:894faf17699ea7f14eabd9161ef532c1d248e40f568dab9240fc68b4c1ea85dc", size = 12008829, upload-time = "2026-08-09T11:39:39.717Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/26/a14abbac97c681385dbb01d5c44c867992dbf2265a784af35141c485813e/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d0c4b2b38e1946ea7a7c625ea317e237573e8731e4a56055eb4361b3c2daa0f", size = 12538786, upload-time = "2026-08-09T11:39:42.37Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/6c/2a8d9ca41d40e2d30e940107a019bbc17de518948c360789f59a439d689a/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ea02f653f76d496870766e1fb8419b6a6aebd81d40265057672f1f6d63d3509", size = 12102335, upload-time = "2026-08-09T11:39:44.64Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/b5/1068d4d24891b17fcb6cf3556b1de8d5821a2bc44ebbea2b25dbd767ae2b/pygame_ce-2.5.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2f2f1c03dc8d4460779f9e55f959a7c7e523101b3530f60f3208e5ca65a6216", size = 9635346, upload-time = "2026-08-09T11:39:46.968Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user