read the gamepad from dora-joy and publish twist and command
The pad moves to dora-joy, so this node keeps no hardware dependency and the pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read` treats silence past JOY_TTL_S as absence, because that node dying looks like silence rather than like an unplugged pad and the arm cannot tell them apart. Both control sources are now normalised to a [-1, 1] six-vector before anything is scaled, and the scaling happens in one function. The web path used to pass a velocity straight through without going near mapping, which is why the console had to speak the arm's units. It no longer does. `twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog` and `request` still carry the same intent in dora-ulite6's units and service names so the two repositories can move independently; LEGACY_REQUEST is the only place that knows what that node calls a verb. Wire one or the other, never both. `joint_jog` is specified in the manifest and deliberately not built. Schemas are declared rather than inferred: pyarrow reads a null `value` as its own type, so an inferred `command` would change its port's type between one verb and the next. Three bugs found while moving this code, each with a test: - `Sample.active` compared raw axes against a threshold, but SDL rests a trigger at -1.0, so every connected pad read as in use for ever: the pad took the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired. - The e-stop latch was never consulted when deciding a velocity, so Back stopped the arm for one tick and the next stick nudge drove it again. Nothing on the pad could clear it either. It is now checked above the lease, and X releases it and clears the controller's error, as the console's RESET does. - Buttons were read before the arbiter granted the lease, so the first press after the pad had been idle was swallowed: not owned yet when read, and no longer a rising edge by the next tick.
This commit is contained in:
@@ -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