Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2540629e | ||
|
|
fb14545850 | ||
|
|
e9fa4bf36b | ||
|
|
8a7bfb02e0 |
@@ -38,7 +38,7 @@ expected next to this one.
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
@@ -77,6 +77,17 @@ a source goes away**.
|
||||
first time somebody picks up the pad. Errors name the variable.
|
||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||
dynamic node's stdout.
|
||||
- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml`
|
||||
is self-contained and CI can validate it with no sibling clones. **It is not a
|
||||
simulator** and must not become one: it never integrates a velocity or moves a pose. Its
|
||||
job is to close the loop so this node can be watched, not to stand in for
|
||||
`dora-ulite6` — anything needing the arm to actually respond needs the arm.
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the
|
||||
velocity that would be sent, with no dataflow and no arm. It exists because the indices
|
||||
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
disagrees is otherwise found by watching a real arm move the wrong way. It goes through
|
||||
the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm
|
||||
would get — keep it that way, or it stops being evidence.
|
||||
|
||||
## Rules that are not negotiable
|
||||
|
||||
|
||||
@@ -38,6 +38,46 @@ somebody can read.
|
||||
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
|
||||
behind a deliberate click, not a thumb resting on a face button.
|
||||
|
||||
### Checking your pad
|
||||
|
||||
```bash
|
||||
uv run dora-teleop-probe
|
||||
```
|
||||
|
||||
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
|
||||
with no dataflow and no arm. **It cannot move anything.** There is no dora node and no
|
||||
connection to the robot; it only reads.
|
||||
|
||||
```text
|
||||
axes (6 reported)
|
||||
0 left stick X +0.600 ----------|-----#----
|
||||
1 left stick Y -0.800 --#-------|----------
|
||||
2 left trigger -1.000 #---------|----------
|
||||
...
|
||||
buttons (6 reported)
|
||||
5:yaw right
|
||||
|
||||
velocity this would send to the arm
|
||||
vx +38.89 mm/s forward / back <--
|
||||
vy +23.53 mm/s left / right <--
|
||||
vz +20.39 mm/s up / down <--
|
||||
wz +30.00 deg/s yaw <--
|
||||
|
||||
moving: yes
|
||||
```
|
||||
|
||||
Run this before trusting the table above. The indices are SDL's documented layout, not
|
||||
observed ones, and a pad that numbers its axes differently is much better found here than
|
||||
by watching an arm move the wrong way. Two things to look for:
|
||||
|
||||
- **Every axis is listed, including ones the layout does not use** — an axis marked
|
||||
`unused` that moves when you push a stick means the layout needs changing.
|
||||
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
||||
itself, and a reason to raise `DEADZONE`.
|
||||
|
||||
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is
|
||||
what the arm would be sent.
|
||||
|
||||
## The lease
|
||||
|
||||
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
||||
@@ -112,7 +152,7 @@ stops the node at startup rather than the first time somebody picks up the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
See `dataflow.yml` for the whole stack wired together. The short version:
|
||||
The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
||||
|
||||
```yaml
|
||||
- id: teleop
|
||||
@@ -132,18 +172,66 @@ See `dataflow.yml` for the whole stack wired together. The short version:
|
||||
outputs: [jog, request, ui_state]
|
||||
```
|
||||
|
||||
Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this
|
||||
repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node.
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
||||
gamepad if you want one — the node starts without and says so.
|
||||
|
||||
### Running it with no arm and no browser
|
||||
|
||||
`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in,
|
||||
so you can drive the real thing from a real gamepad with no robot in the room:
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
||||
gamepad if you want one — the node starts without and says so.
|
||||
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
||||
it is sent:
|
||||
|
||||
```text
|
||||
jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
|
||||
jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
|
||||
jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop
|
||||
```
|
||||
|
||||
It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog
|
||||
is printed and forgotten. It closes the loop so this node's behaviour can be watched —
|
||||
the lease changing hands, the deadman firing when you let go — not so the arm can be
|
||||
pretended at.
|
||||
|
||||
### Watching the wire
|
||||
|
||||
`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what
|
||||
is actually on the wire without the bench printing anything. Run it under a coordinator
|
||||
rather than `dora run`:
|
||||
|
||||
```bash
|
||||
dora up
|
||||
dora start dataflow.yml --uv --name teleop-bench
|
||||
```
|
||||
|
||||
Then, in another terminal:
|
||||
|
||||
```bash
|
||||
dora topic list -d teleop-bench # every edge in the graph
|
||||
dora topic echo -d teleop-bench teleop/jog # every velocity, live
|
||||
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
|
||||
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
|
||||
```
|
||||
|
||||
`hz` is the one that checks the timings this node promises. The `get_state` poll measures
|
||||
at 1.88 Hz against a configured `STATE_POLL_S` of 0.5, and with a pad held over the
|
||||
deadzone `teleop/jog` should sit near the 50 ms tick.
|
||||
|
||||
Tear it down with `dora stop --name teleop-bench && dora down`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
+27
-41
@@ -1,28 +1,33 @@
|
||||
# The whole operator stack, and what CI validates.
|
||||
# The repo's own example, and what CI validates.
|
||||
#
|
||||
# Built from checkouts rather than tags, so `dora validate` resolves each node's
|
||||
# manifest from its `path:` and checks the wiring against the versions in front
|
||||
# of you. The sibling repos are expected next to this one, which is how they are
|
||||
# cloned in practice.
|
||||
# Self-contained on purpose: teleop plus a bench stand-in for the arm and the
|
||||
# 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.
|
||||
#
|
||||
# Three nodes, one loop: the bridge carries the browser, this node decides who
|
||||
# drives, and the arm node moves the arm and reports back.
|
||||
# This is **not** the real stack. The bench answers services and publishes
|
||||
# telemetry but never moves anything — it exists so the node can be driven from
|
||||
# a real gamepad with no arm in the room. README.md carries the three-node
|
||||
# wiring a consumer actually uses.
|
||||
|
||||
strict_types: true
|
||||
|
||||
# Lets `dora topic echo` and `dora topic hz` read what is on the wire, so the
|
||||
# velocities this node sends can be watched without the bench printing them —
|
||||
# and without a robot. Costs nothing when nobody is looking.
|
||||
debug:
|
||||
enable_debug_inspection: true
|
||||
|
||||
nodes:
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
clients: bench/clients
|
||||
response: bench/response
|
||||
robot_state: bench/robot_state
|
||||
tcp_pose: bench/tcp_pose
|
||||
joint_pose: bench/joint_pose
|
||||
outputs:
|
||||
- jog
|
||||
- request
|
||||
@@ -31,35 +36,16 @@ nodes:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
|
||||
- id: ws-transceiver
|
||||
path: ../dora-websocket-transceiver
|
||||
build: pip install .
|
||||
- id: bench
|
||||
path: examples/bench.py
|
||||
inputs:
|
||||
data_in: teleop/ui_state
|
||||
outputs:
|
||||
- data_out
|
||||
- clients
|
||||
env:
|
||||
WS_PORT: "9000"
|
||||
STATIC_DIR: "../dora-teleop/web"
|
||||
HTTP_PORT: "9010"
|
||||
|
||||
- id: ulite6
|
||||
path: ../dora-ulite6
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/100
|
||||
request: teleop/request
|
||||
tick: dora/timer/millis/200
|
||||
jog: teleop/jog
|
||||
request: teleop/request
|
||||
ui_state: teleop/ui_state
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
TOOL_TYPE: "vacuum"
|
||||
# Nothing moves because the node started. An operator asks for the first
|
||||
# motion, which is the whole point of a teleop stack.
|
||||
INIT_MODE: "none"
|
||||
- tcp_pose
|
||||
- joint_pose
|
||||
- clients
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ apiVersion: 1
|
||||
name: dora-teleop
|
||||
namespace: liria
|
||||
description: Hands a robot arm to a human, from a gamepad or a browser
|
||||
categories: [robot, teleop, input]
|
||||
categories: [robot]
|
||||
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
|
||||
|
||||
runtime: python
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Show what the pad is doing, with no dataflow and no arm.
|
||||
|
||||
`dora-teleop-probe` opens the gamepad and prints, live: the raw axes SDL
|
||||
reports, the buttons it sees pressed, and the velocity this node would send.
|
||||
|
||||
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
|
||||
SDL's documented layout rather than an observed one, and a pad that disagrees is
|
||||
otherwise discovered by watching a real arm move the wrong way. It goes through
|
||||
:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity`
|
||||
— the same code the node runs — so what it prints is what the arm would get.
|
||||
|
||||
Nothing here can move anything. There is no dora node and no connection to the
|
||||
arm; this only reads.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from dora_teleop import mapping
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Gamepad
|
||||
from dora_teleop.mapping import LAYOUT
|
||||
|
||||
# What each velocity component is, in the order the arm takes them.
|
||||
COMPONENTS = (
|
||||
("vx", "mm/s", "forward / back"),
|
||||
("vy", "mm/s", "left / right"),
|
||||
("vz", "mm/s", "up / down"),
|
||||
("wx", "deg/s", "roll"),
|
||||
("wy", "deg/s", "pitch"),
|
||||
("wz", "deg/s", "yaw"),
|
||||
)
|
||||
|
||||
CLEAR = "\033[H\033[J"
|
||||
|
||||
|
||||
def axis_names(layout=LAYOUT) -> dict[int, str]:
|
||||
"""Name each axis index the layout uses."""
|
||||
return {
|
||||
layout.left_x: "left stick X",
|
||||
layout.left_y: "left stick Y",
|
||||
layout.left_trigger: "left trigger",
|
||||
layout.right_x: "right stick X",
|
||||
layout.right_y: "right stick Y",
|
||||
layout.right_trigger: "right trigger",
|
||||
}
|
||||
|
||||
|
||||
def button_names(layout=LAYOUT) -> dict[int, str]:
|
||||
"""Name each button index the layout uses."""
|
||||
return {
|
||||
layout.grasp: "grasp",
|
||||
layout.release: "release",
|
||||
layout.clear: "clear",
|
||||
layout.freedrive: "freedrive",
|
||||
layout.yaw_left: "yaw left",
|
||||
layout.yaw_right: "yaw right",
|
||||
layout.estop: "E-STOP",
|
||||
}
|
||||
|
||||
|
||||
def bar(value: float, width: int = 21) -> str:
|
||||
"""Draw a centred bar for a value between -1 and 1.
|
||||
|
||||
Seeing the bar sit off-centre with the stick released is how a worn pad
|
||||
announces itself, which is most of the point of looking at raw numbers.
|
||||
"""
|
||||
middle = width // 2
|
||||
position = middle + round(max(-1.0, min(1.0, value)) * middle)
|
||||
cells = ["-"] * width
|
||||
cells[middle] = "|"
|
||||
cells[position] = "#"
|
||||
return "".join(cells)
|
||||
|
||||
|
||||
def render(sample, velocity: list[float], config: Config) -> str:
|
||||
"""Build the whole display for one reading."""
|
||||
lines = ["dora-teleop probe — Ctrl-C to stop", ""]
|
||||
|
||||
if not sample.present:
|
||||
lines += [
|
||||
" no gamepad found.",
|
||||
"",
|
||||
f" looking for joystick index {config.joystick_index};",
|
||||
" plug one in and this will pick it up.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
names = axis_names()
|
||||
lines.append(f" axes ({len(sample.axes)} reported)")
|
||||
for index, value in enumerate(sample.axes):
|
||||
name = names.get(index, "unused")
|
||||
lines.append(f" {index} {name:<15} {value:+.3f} {bar(value)}")
|
||||
|
||||
labels = button_names()
|
||||
held = [f"{index}:{labels.get(index, 'unused')}" for index, down in enumerate(sample.buttons) if down]
|
||||
lines += ["", f" buttons ({len(sample.buttons)} reported)", f" {', '.join(held) or 'none pressed'}"]
|
||||
|
||||
lines += ["", " velocity this would send to the arm"]
|
||||
for (name, units, meaning), value in zip(COMPONENTS, velocity, strict=True):
|
||||
marker = " <--" if value else ""
|
||||
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
|
||||
|
||||
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
|
||||
lines += ["", f" moving: {moving}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Open the pad and print what it is doing until interrupted."""
|
||||
parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.")
|
||||
parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config.from_env()
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
sample = gamepad.read()
|
||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||
time.sleep(1.0 / args.hz)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
finally:
|
||||
gamepad.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""A bench stand-in for the arm and the browser, so teleop can run with neither.
|
||||
|
||||
Run by `dataflow.yml`. It answers every service, publishes plausible telemetry,
|
||||
and prints the velocities it is sent — which is enough to drive the node from a
|
||||
real gamepad and watch what a real arm would have been told to do.
|
||||
|
||||
**It is not a simulator.** It does not integrate the velocity, move a pose, or
|
||||
model the arm in any way; a jog is printed and forgotten. Its job is to close
|
||||
the loop so the node's own behaviour can be watched, not to stand in for
|
||||
`dora-ulite6`. Anything that needs the arm to actually respond needs the arm.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pyarrow as pa
|
||||
from dora import Node
|
||||
|
||||
REQUEST_ID = "request_id"
|
||||
|
||||
# Somewhere in front of the robot, tool pointing down. Fixed, because a moving
|
||||
# pose here would look like the arm was responding, which it is not.
|
||||
POSE = [250.0, 0.0, 200.0, 180.0, 0.0, 0.0]
|
||||
JOINTS = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Answer whatever teleop asks and report what it would have moved."""
|
||||
node = Node("bench")
|
||||
announced = False
|
||||
tool_engaged = False
|
||||
freedrive = False
|
||||
jogs = 0
|
||||
shown = None
|
||||
|
||||
for event in node:
|
||||
if event["type"] != "INPUT":
|
||||
continue
|
||||
|
||||
if event["id"] == "tick":
|
||||
if not announced:
|
||||
announced = True
|
||||
node.send_output("robot_state", pa.array(["ready"]))
|
||||
node.send_output("tcp_pose", pa.array(POSE, type=pa.float32()), {"units_position": "mm"})
|
||||
node.send_output("joint_pose", pa.array(JOINTS, type=pa.float32()), {"units": "deg"})
|
||||
# No browser is attached to this dataflow, and saying so is what
|
||||
# keeps teleop from believing one went away mid-jog.
|
||||
node.send_output("clients", pa.array([{"connected": 0, "event": "close"}]))
|
||||
|
||||
elif event["id"] == "jog":
|
||||
velocity = event["value"][0].as_py()["velocity"]
|
||||
jogs += 1
|
||||
moving = any(velocity)
|
||||
shown = ", ".join(f"{v:+7.2f}" for v in velocity)
|
||||
print(f"jog {jogs:5d} [{shown}] {'' if moving else '<- stop'}", flush=True)
|
||||
|
||||
elif event["id"] == "request":
|
||||
row = event["value"][0].as_py()
|
||||
service = row.get("service", "")
|
||||
request_id = event["metadata"].get(REQUEST_ID, "")
|
||||
if service == "grasp":
|
||||
tool_engaged = True
|
||||
elif service == "release":
|
||||
tool_engaged = False
|
||||
elif service == "freedrive":
|
||||
freedrive = bool(row.get("enabled", True))
|
||||
if service != "get_state":
|
||||
print(f"service {service} ({request_id})", flush=True)
|
||||
node.send_output(
|
||||
"response",
|
||||
pa.array([_answer(service, tool_engaged, freedrive)]),
|
||||
{REQUEST_ID: request_id},
|
||||
)
|
||||
|
||||
elif event["id"] == "ui_state":
|
||||
# No browser is attached, so this is where the console's picture of
|
||||
# the arm would go. Printing it on change rather than on every tick
|
||||
# makes the things worth watching — the lease moving, the arm
|
||||
# starting and stopping — visible without 20 lines a second.
|
||||
snapshot = json.loads(event["value"][0].as_py())
|
||||
if snapshot.get("type") != "telemetry":
|
||||
continue
|
||||
current = (snapshot["arm_state"], snapshot["owner"], snapshot["estop"])
|
||||
if current != shown:
|
||||
shown = current
|
||||
state, owner, estop = current
|
||||
print(
|
||||
f"console would show: {state}, owner {owner}" + (", E-STOPPED" if estop else ""),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _answer(service: str, tool_engaged: bool, freedrive: bool) -> dict:
|
||||
"""Build the response row the arm node would have sent."""
|
||||
if service == "get_state":
|
||||
return {
|
||||
"ok": True,
|
||||
"service": service,
|
||||
"message": "ready",
|
||||
"connected": True,
|
||||
"ready": True,
|
||||
"tool_engaged": tool_engaged,
|
||||
"freedrive": freedrive,
|
||||
"pose": POSE,
|
||||
"joints": JOINTS,
|
||||
}
|
||||
return {"ok": True, "service": service, "message": f"{service} accepted by the bench"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
# Manifest for the bench stand-in.
|
||||
#
|
||||
# dora resolves a node's manifest from its `path:`, so without this file the
|
||||
# bench would be validated against the teleop node's own contract and every port
|
||||
# would look wrong.
|
||||
|
||||
apiVersion: 1
|
||||
name: bench
|
||||
namespace: liria
|
||||
description: Bench stand-in for the arm and the browser, so teleop can run with neither
|
||||
|
||||
runtime: python
|
||||
entrypoint: bench.py
|
||||
dora: ">=1.0"
|
||||
|
||||
inputs:
|
||||
tick:
|
||||
description: Drives the telemetry the arm node would publish.
|
||||
type: std/core/v1/UInt64
|
||||
jog:
|
||||
description: A velocity teleop wants the arm to move at. Printed, then forgotten.
|
||||
request:
|
||||
description: A service call. Every one is answered ok.
|
||||
ui_state:
|
||||
description: >-
|
||||
The console snapshot teleop publishes. Accepted and dropped: no browser is
|
||||
attached to this dataflow.
|
||||
|
||||
outputs:
|
||||
response:
|
||||
description: One answer per request, under the id it arrived with.
|
||||
robot_state:
|
||||
description: Reports `ready` once, so teleop stops drawing the arm as disconnected.
|
||||
type: std/core/v1/String
|
||||
tcp_pose:
|
||||
description: A fixed tool-tip pose. Fixed on purpose — nothing here integrates a velocity.
|
||||
type: std/core/v1/Float32
|
||||
joint_pose:
|
||||
description: Fixed joint angles, for the same reason.
|
||||
type: std/core/v1/Float32
|
||||
clients:
|
||||
description: Always zero connected, since the WebSocket bridge is not in this dataflow.
|
||||
@@ -23,6 +23,7 @@ dev = ["pytest >= 8.1", "ruff >= 0.9"]
|
||||
|
||||
[project.scripts]
|
||||
dora-teleop = "dora_teleop.main:main"
|
||||
dora-teleop-probe = "dora_teleop.probe:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""The probe: what it draws, without a pad or a terminal."""
|
||||
|
||||
from dora_teleop import mapping, probe
|
||||
from dora_teleop.gamepad import Sample
|
||||
from tests.conftest import stick
|
||||
|
||||
|
||||
def test_a_centred_stick_puts_the_marker_in_the_middle():
|
||||
drawn = probe.bar(0.0)
|
||||
|
||||
assert drawn[len(drawn) // 2] == "#"
|
||||
|
||||
|
||||
def test_the_bar_leans_the_way_the_stick_is_pushed():
|
||||
left = probe.bar(-1.0)
|
||||
right = probe.bar(1.0)
|
||||
|
||||
assert left.index("#") < left.index("|")
|
||||
assert right.index("#") > right.index("|")
|
||||
|
||||
|
||||
def test_a_reading_beyond_full_deflection_stays_on_the_bar():
|
||||
# SDL has been known to report slightly outside -1..1 on a worn stick.
|
||||
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):
|
||||
config.joystick_index = 2
|
||||
|
||||
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
||||
|
||||
assert "no gamepad found" in drawn
|
||||
assert "index 2" in drawn
|
||||
|
||||
|
||||
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
||||
# A pad whose sticks land on indices we do not use is exactly what this is
|
||||
# for, so nothing may be hidden.
|
||||
sample = Sample(axes=[0.0] * 8, buttons=[], present=True)
|
||||
|
||||
drawn = probe.render(sample, [0.0] * 6, config)
|
||||
|
||||
assert "unused" in drawn
|
||||
assert "axes (8 reported)" in drawn
|
||||
|
||||
|
||||
def test_a_pressed_button_is_named(config):
|
||||
sample = stick(buttons=[True])
|
||||
|
||||
drawn = probe.render(sample, [0.0] * 6, config)
|
||||
|
||||
assert "grasp" in drawn
|
||||
|
||||
|
||||
def test_nothing_pressed_says_so_rather_than_showing_an_empty_line(config):
|
||||
drawn = probe.render(stick(), [0.0] * 6, config)
|
||||
|
||||
assert "none pressed" in drawn
|
||||
|
||||
|
||||
def test_the_velocity_is_shown_in_the_units_the_arm_takes(config):
|
||||
velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config)
|
||||
|
||||
drawn = probe.render(stick(axes=[0.0, -1.0]), velocity, config)
|
||||
|
||||
assert "mm/s" in drawn
|
||||
assert "deg/s" in drawn
|
||||
assert f"{config.speed_mm_s:+8.2f}" in drawn
|
||||
|
||||
|
||||
def test_a_still_arm_is_reported_as_still(config):
|
||||
drawn = probe.render(stick(), [0.0] * 6, config)
|
||||
|
||||
assert "told to hold still" in drawn
|
||||
|
||||
|
||||
def test_a_moving_arm_is_reported_as_moving(config):
|
||||
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
||||
|
||||
assert "moving: yes" in drawn
|
||||
Reference in New Issue
Block a user