Author SHA1 Message Date
cristhian ac2540629e docs: explain what debug inspection is for
validate / validate (pull_request) Successful in 58m43s
validate / test (pull_request) Successful in 58m50s
validate / markdownlint (pull_request) Successful in 58m42s
The dataflow turned on `enable_debug_inspection` without the README saying why
anyone would want it, which made it a line of configuration nobody could act on.

Documents the three commands it buys — topic list, topic echo, topic hz — and
that they need a coordinator rather than `dora run`. hz is the one that checks
the timings this node promises, so it names the measurement to expect.
2026-09-15 17:21:49 -05:00
cristhian fb14545850 enable debug inspection on the example dataflow
validate / validate (pull_request) Successful in 58m48s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m43s
`dora topic echo` and `dora topic hz` only read the wire when the dataflow asks
for it. Turning it on here means the velocities this node sends can be watched
live, and their cadence measured, without the bench printing anything and
without a robot — which is most of what testing this node with only a gamepad
needs.

Measured with it: the get_state poll runs at 1.88 Hz against a configured
STATE_POLL_S of 0.5.
2026-09-15 17:08:31 -05:00
cristhian e9fa4bf36b fix the node manifest and make the example dataflow self-contained
validate / validate (pull_request) Successful in 58m46s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m43s
Two problems, both found by the validate job once it could finally run.

categories named `teleop` and `input`, neither of which dora defines. The set is
fixed — sensor, actuator, robot, transform, filter, ml-inference, llm, speech,
communication, recorder, visualization, simulator, debug — and an unknown
variant makes the whole manifest unparseable, so every type annotation in it was
being ignored.

dataflow.yml spanned three repositories through sibling paths, which meant dora
could not resolve the other nodes' manifests and reported their typed outputs as
unannotated. It also could not be validated in CI, which clones one repo. The
house pattern is a self-contained dataflow, and this now follows it: teleop plus
a bench stand-in for the arm and the browser, both from this checkout. The real
three-node wiring moves to README.md, where a consumer would look for it.

The bench answers every service, publishes plausible telemetry, and prints the
velocities it is sent, so the node can be driven from a real gamepad with no
robot in the room. It is not a simulator and must not become one: it never
integrates a velocity or moves a pose.
2026-09-15 16:51:30 -05:00
cristhian 8a7bfb02e0 add a probe that shows what the gamepad is doing
validate / validate (pull_request) Failing after 58m47s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s
The axis and button indices in mapping.py are SDL's documented layout rather
than an observed one, and until now the only way to find a pad that numbers
things differently was to watch a real arm move the wrong way.

`dora-teleop-probe` prints the raw axes, the buttons it sees and the velocity
that would be sent, live, with no dataflow and no arm — it cannot move anything.
It goes through the same Gamepad and velocity() the node runs, so what it prints
is what the arm would get; that is the whole reason it is worth having, and the
reason it must not grow its own copy of the mapping.

Every axis is listed, including ones the layout does not use, because an axis
marked unused that moves when a stick is pushed is exactly the finding this is
for. The centred bar makes a worn stick that does not return to zero obvious.
2026-09-15 15:47:36 -05:00
9 changed files with 498 additions and 47 deletions
+12 -1
View File
@@ -38,7 +38,7 @@ expected next to this one.
## Test ## Test
```bash ```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 check .
uv run ruff format --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. first time somebody picks up the pad. Errors name the variable.
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a - **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
dynamic node's stdout. 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 ## Rules that are not negotiable
+92 -4
View File
@@ -38,6 +38,46 @@ somebody can read.
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs `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. 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 ## The lease
Two sources, one arm. Whoever holds the lease drives; the other is ignored. 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 ## 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 ```yaml
- id: teleop - id: teleop
@@ -132,18 +172,66 @@ See `dataflow.yml` for the whole stack wired together. The short version:
outputs: [jog, request, ui_state] 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 ```bash
dora run dataflow.yml --uv dora run dataflow.yml --uv
``` ```
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a The bench answers every service, publishes plausible telemetry, and prints each velocity
gamepad if you want one — the node starts without and says so. 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 ## Development
```bash ```bash
uv sync --group dev 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 check .
uv run ruff format --check . uv run ruff format --check .
``` ```
+27 -41
View File
@@ -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 # Self-contained on purpose: teleop plus a bench stand-in for the arm and the
# manifest from its `path:` and checks the wiring against the versions in front # browser, both built from this checkout, so `dora validate` resolves every
# of you. The sibling repos are expected next to this one, which is how they are # manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
# cloned in practice.
# #
# Three nodes, one loop: the bridge carries the browser, this node decides who # This is **not** the real stack. The bench answers services and publishes
# drives, and the arm node moves the arm and reports back. # 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 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: nodes:
- id: teleop - id: teleop
path: dora-teleop path: dora-teleop
build: pip install . build: pip install .
inputs: inputs:
tick: dora/timer/millis/50 tick: dora/timer/millis/50
ui_command: ws-transceiver/data_out clients: bench/clients
clients: ws-transceiver/clients response: bench/response
response: ulite6/response robot_state: bench/robot_state
motion_state: ulite6/motion_state tcp_pose: bench/tcp_pose
robot_state: ulite6/robot_state joint_pose: bench/joint_pose
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs: outputs:
- jog - jog
- request - request
@@ -31,35 +36,16 @@ nodes:
SPEED_MM_S: "60" SPEED_MM_S: "60"
SPEED_DEG_S: "30" SPEED_DEG_S: "30"
- id: ws-transceiver - id: bench
path: ../dora-websocket-transceiver path: examples/bench.py
build: pip install .
inputs: inputs:
data_in: teleop/ui_state tick: dora/timer/millis/200
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
jog: teleop/jog jog: teleop/jog
request: teleop/request
ui_state: teleop/ui_state
outputs: outputs:
- response - response
- motion_state
- joint_pose
- tcp_pose
- robot_state - robot_state
env: - tcp_pose
ROBOT_IP: "192.168.1.192" - joint_pose
TOOL_TYPE: "vacuum" - clients
# 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"
+1 -1
View File
@@ -15,7 +15,7 @@ apiVersion: 1
name: dora-teleop name: dora-teleop
namespace: liria namespace: liria
description: Hands a robot arm to a human, from a gamepad or a browser 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] keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
runtime: python runtime: python
+132
View File
@@ -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()
+110
View File
@@ -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()
+42
View File
@@ -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.
+1
View File
@@ -23,6 +23,7 @@ dev = ["pytest >= 8.1", "ruff >= 0.9"]
[project.scripts] [project.scripts]
dora-teleop = "dora_teleop.main:main" dora-teleop = "dora_teleop.main:main"
dora-teleop-probe = "dora_teleop.probe:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
+81
View File
@@ -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