Read the gamepad from dora-joy, and speak a robot-agnostic contract #2

Merged
cristhian merged 6 commits from feat/robot-agnostic-teleop into main 2026-09-16 17:52:07 +00:00
Owner

Summary

Two changes that turn this from the Lite6's teleop node into one that any of our robots
can be driven by, from a gamepad or a browser.

The pad moves out. It is now dora-joy v0.1,
arriving on a joy input. This node keeps no hardware dependency at all — pygame-ce is
gone — and the pad becomes reusable by anything else in a dataflow.

The output becomes a standard. twist (SI) and command (robot-agnostic verbs)
replace jog and request, which stay published and deprecated so this repository and
dora-ulite6 can move independently. The contract is written up in the vault at
resources/robotics/teleop-contract.md, which is where it lives because no source package
is shared between node repositories.

The console can now actually drive. It sent three of the nine commands the node
serves, so a browser could take the lease and stop the arm but not move it.

Related Issues

Follow-ups, not closed by this PR:

  • liria/dora-ulite6#3 — the arm node accepts twist and command
  • #3 — remove the deprecated ports, once the above lands
  • #4 — joint_jog for joint-space robots, deliberately deferred

Changes

Five commits, in order:

  1. read the gamepad from dora-joy and publish twist and command — the node itself,
    its manifest, the dataflows and their tests.
  2. add the console's jog pad, gripper and freedrive controls — the page and a test
    that checks it against ui.py both ways.
  3. validate the gamepad and robot dataflows in CI
  4. docs: describe the standard ports and the console
  5. update version to 0.2.0 for the new output contract

The one thing worth reading closely

Both control sources are now normalised to a [-1, 1] six-vector — a fraction of full
speed — before anything is scaled, and the scaling happens in exactly one function.

The web path previously did state.web_velocity = command.velocity, passing a velocity
straight through without going near mapping. That is why the console had to speak mm/s,
and why nothing here could be robot-agnostic whatever the ports were called. A test pins
the property this buys: a gamepad and the console asking for the same deflection now
produce byte-identical output.

twist is SI (m/s, rad/s) because that is unambiguous and what a UR or MoveIt takes.
Configuration stays in mm/s and deg/s, because 60 mm/s is a number a person reading a
dataflow can judge.

Three bugs found while moving this code

Each has a test, and each was invisible until the code moved:

  • Sample.active reported every connected pad as in use, for ever. It compared raw
    axes against a threshold, but SDL rests a trigger at -1.0. So the pad took the lease
    the instant it was plugged in and GAMEPAD_IDLE_RELEASE_S never fired — a pad on the
    bench locked the browser out permanently.
  • The E-STOP did not stop. view.estop was set but never consulted when deciding a
    velocity, so Back halted the arm for one tick and the next stick nudge drove it again.
    Nothing on the pad could clear it either — only the console's RESET, which a pad-only
    dataflow has not got. The latch is now checked above the lease, and X releases it and
    clears the controller's error, as RESET does. Neither button is gated on the lease: an
    idle pad hands the arm back two seconds after the stop it just made.
  • The first button press after the pad had been idle was swallowed. Buttons were read
    before the arbiter granted the lease, so the press arrived while nobody owned the arm,
    and by the next tick it was held rather than newly down — the edge was gone for good.

Also

  • A fourth deadman layer, JOY_TTL_S (0.2 s). The pad is another process now, and that
    process dying looks like silence rather than like an unplugged pad, so silence means the
    same thing. Config.from_env refuses layers that are out of order.
  • dora_teleop/message.py declares every published schema. 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.
  • dataflow-robot.yml: pad → teleop → dora-ulite6, both siblings by git:. Its
    output_types: block exists because dora type-checks before it fetches a git: source
    and so cannot read the arm's manifest.
  • dora-teleop-probe is now a dynamic node reading live joy, still through the real
    velocity().
  • joint_jog is specified in dora-node.yml and not implemented — see #4.

Validation

  • 130 tests pass (from 76), none needing a gamepad, arm, browser or daemon
  • ruff check and ruff format --check clean
  • CI green on this branch
  • All three dataflows validate; dora build dataflow-robot.yml resolves both pinned
    tags and installs them. --uv no longer applies — see "What changed since" below
  • dora_teleop imports no pygame
  • Every message this node emits parses with dora-ulite6's own parsers — the jog
    velocity, all eight services, and request_id correlation
  • Gamepad path, bench-verified with a synthetic pad built from
    dora_joy.message.pack: twist on the wire as
    {"linear":[0.06,0.0,0.0],...,"frame":"base"}, command as stop then clear
  • Console path, now against the node's own server (the transceiver is gone): the
    page served over HTTP, and over the socket — take control → owner web; hold +X
    → jogs at +60.00 mm/s; release → one stop; gripper → grasp; socket closed → one
    stop and owner none. Telemetry carries gripper and
    units: {position: mm, rotation: deg} — no vacuum
  • Every console direction, driven as real pointer presses through the Chrome
    DevTools Protocol: all nine sectors and pills produce the right axis and sign, and
    reach the bench end to end
  • Deadman, at runtime: killing the joy node mid-jog stops the arm 0.211 s later,
    once; the e-stop latch held a fully deflected stick for 0.85 s with nothing reaching
    the arm; the lease returns 2.0 s after the stick centres
  • Verified against a real Lite6 at 192.168.1.162 — jogging, gripper, E-STOP and
    RESET, through the console
  • The console opened in an actual browser, on the machine and on a tablet
  • Verified against a physical gamepad (no pad on the machine this was built on)

Two things to know before merging

This does not break dora-ulite6. Both the standard and the deprecated ports publish
the same intent, and I checked every message against that node's own parsers. Version is
0.2.0 to say the contract moved; liria/dora-ulite6#3 and #3 carry the rest.

The split and the standardisation are not separable in this branch. They were built
one on top of the other before either was committed, so main.py and mapping.py carry
both. I would rather say so than reconstruct an intermediate state I never actually ran.

The one unticked box needs a gamepad, which the machine this was built on has not got.
Everything else has been run, the arm included.


What changed since the review above

The branch grew three things after the first round. The commit is
serve the web console from the node and drop the websocket bridge.

The bridge is gone

dora-websocket-transceiver was a second node, a second port and a round trip through the
dataflow for every button press, carrying a UI only this node can interpret. The node now
serves the page and holds the sockets itself on one port.

dora_teleop/server.py runs aiohttp on its own thread and meets the control loop at two
non-blocking points only — a bounded queue the tick drains, and a snapshot handed back —
because nothing a browser does may stall the thread that stops the arm. Both queues are
bounded on purpose: a flood is dropped rather than grown, and a slow client loses stale
snapshots rather than the newest.

Contract, so 0.2.0 → 0.3.0: ui_command and clients are gone. ui_state stays
published — no browser reads it now, but dora topic echo and the bench do. New
WEB_HOST and WEB_PORT (7000; 0 opens no port, which is what dataflow-gamepad.yml
wants).

The web deadman moved without changing meaning: it was a clients message from the
bridge, and is now the open-socket count read each tick. One hop fewer.

The console is laid out like the arm's own pendant

Two rings quartered into arc sectors you hold, each with its out-of-plane axis as pills
above — the shape of xArm Studio's jog control on the controller at :18333.

Ring Sectors Pills
translate +X N · −X S · Y+ W · Y− E −Z +Z
rotate +pitch N · −pitch S · −roll W · +roll E −yaw +yaw

Y+ is on the left, matching xArm Studio and the right-hand rule. It is the one
direction that does not read as "screen right is right", and it is deliberate: two
consoles driving one arm that disagree about which way is +Y is a trap.

An earlier revision of this used analog sticks — drag distance as speed. It was dropped
after testing on the arm: Z as a drag lane reads wrong, and the rings match the pendant an
operator already knows.

Sending the change rather than the state

_drive now sends a velocity when it changes, repeating an unchanged one on
JOG_KEEPALIVE_S (0.2 s) only to beat the consumer's own expiry. A release is a change and
never waits for the repeat. Measured on the wire: 83 messages → 18 for the same
four-second hold.

This began as a fix for an arm that moved in steps rather than travelling. It was not the
cause
— cutting the rate 4.6× changed nothing on the arm, and that negative result is
what pointed at dora-ulite6 re-arming a timed velocity segment per message. Fixed there
and released as v0.3.1, which this branch now pins. The reduction is kept on its own
merits.

Two bugs found while building this, each with a test

  • The console server failed silently. A port already in use killed the server thread,
    start() sat out its full 10 s timeout and then logged console on http://… anyway —
    node running, nothing serving, and the block backed up the input queue. It now raises
    and the node refuses to start. Found by accident when two test runs collided.
  • Knob travel was a third of intended in the analog revision: a percentage inside a CSS
    translate is a fraction of the knob, not of the circle it crosses. Moot now the sticks
    are gone, but it is why the rings are positioned in user units.

One thing a reviewer should know

dora run --uv cannot spawn this dataflow (dora 1.0.1). dora build --uv installs each
git: node into a venv inside its own clone, while the spawner resolves the entrypoint
with uv run which <name> from the dataflow's directory — so the node is installed where
the spawner never looks. README.md and dataflow-robot.yml now document building into a
single activated environment instead. dataflow.yml is unaffected and --uv is fine there.

Unrelated to this branch, but it is why dora-joy moves to v0.1.1: v0.1 is an
annotated tag, and dora records the tag object's id as the commit hash and then rejects its
own checkout for sitting on the peeled commit. Neither git: dataflow could be built at
all before that.

# Summary Two changes that turn this from the Lite6's teleop node into one that any of our robots can be driven by, from a gamepad or a browser. **The pad moves out.** It is now [`dora-joy`](https://gitea.liria.cl/liria/dora-joy) `v0.1`, arriving on a `joy` input. This node keeps no hardware dependency at all — `pygame-ce` is gone — and the pad becomes reusable by anything else in a dataflow. **The output becomes a standard.** `twist` (SI) and `command` (robot-agnostic verbs) replace `jog` and `request`, which stay published and deprecated so this repository and `dora-ulite6` can move independently. The contract is written up in the vault at `resources/robotics/teleop-contract.md`, which is where it lives because no source package is shared between node repositories. **The console can now actually drive.** It sent three of the nine commands the node serves, so a browser could take the lease and stop the arm but not move it. ## Related Issues Follow-ups, not closed by this PR: - liria/dora-ulite6#3 — the arm node accepts `twist` and `command` - #3 — remove the deprecated ports, once the above lands - #4 — `joint_jog` for joint-space robots, deliberately deferred ## Changes Five commits, in order: 1. **`read the gamepad from dora-joy and publish twist and command`** — the node itself, its manifest, the dataflows and their tests. 2. **`add the console's jog pad, gripper and freedrive controls`** — the page and a test that checks it against `ui.py` both ways. 3. **`validate the gamepad and robot dataflows in CI`** 4. **`docs: describe the standard ports and the console`** 5. **`update version to 0.2.0 for the new output contract`** ### The one thing worth reading closely Both control sources are now normalised to a `[-1, 1]` six-vector — a fraction of full speed — **before** anything is scaled, and the scaling happens in exactly one function. The web path previously did `state.web_velocity = command.velocity`, passing a velocity straight through without going near `mapping`. That is why the console had to speak mm/s, and why nothing here could be robot-agnostic whatever the ports were called. A test pins the property this buys: a gamepad and the console asking for the same deflection now produce byte-identical output. `twist` is SI (m/s, rad/s) because that is unambiguous and what a UR or MoveIt takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person reading a dataflow can judge. ### Three bugs found while moving this code Each has a test, and each was invisible until the code moved: - **`Sample.active` reported every connected pad as in use, for ever.** It compared raw axes against a threshold, but SDL rests a trigger at **-1.0**. So the pad took the lease the instant it was plugged in and `GAMEPAD_IDLE_RELEASE_S` never fired — a pad on the bench locked the browser out permanently. - **The E-STOP did not stop.** `view.estop` was set but never consulted when deciding a velocity, so Back halted the arm for one tick and the next stick nudge drove it again. Nothing on the pad could clear it either — only the console's RESET, which a pad-only dataflow has not got. The latch is now checked above the lease, and X releases it and clears the controller's error, as RESET does. Neither button is gated on the lease: an idle pad hands the arm back two seconds after the stop it just made. - **The first button press after the pad had been idle was swallowed.** Buttons were read before the arbiter granted the lease, so the press arrived while nobody owned the arm, and by the next tick it was held rather than newly down — the edge was gone for good. ### Also - A fourth deadman layer, `JOY_TTL_S` (0.2 s). The pad is another process now, and that process dying looks like silence rather than like an unplugged pad, so silence means the same thing. `Config.from_env` refuses layers that are out of order. - `dora_teleop/message.py` declares every published schema. 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. - `dataflow-robot.yml`: pad → teleop → `dora-ulite6`, both siblings by `git:`. Its `output_types:` block exists because dora type-checks before it fetches a `git:` source and so cannot read the arm's manifest. - `dora-teleop-probe` is now a dynamic node reading live `joy`, still through the real `velocity()`. - `joint_jog` is specified in `dora-node.yml` and **not implemented** — see #4. ## Validation - [x] 130 tests pass (from 76), none needing a gamepad, arm, browser or daemon - [x] `ruff check` and `ruff format --check` clean - [x] CI green on this branch - [x] All three dataflows validate; `dora build dataflow-robot.yml` resolves both pinned tags and installs them. **`--uv` no longer applies** — see "What changed since" below - [x] `dora_teleop` imports no pygame - [x] Every message this node emits parses with `dora-ulite6`'s own parsers — the jog velocity, all eight services, and `request_id` correlation - [x] **Gamepad path**, bench-verified with a synthetic pad built from `dora_joy.message.pack`: `twist` on the wire as `{"linear":[0.06,0.0,0.0],...,"frame":"base"}`, `command` as `stop` then `clear` - [x] **Console path**, now against the node's own server (the transceiver is gone): the page served over HTTP, and over the socket — take control → `owner web`; hold `+X` → jogs at `+60.00 mm/s`; release → one stop; gripper → `grasp`; socket closed → one stop and `owner none`. Telemetry carries `gripper` and `units: {position: mm, rotation: deg}` — no `vacuum` - [x] **Every console direction**, driven as real pointer presses through the Chrome DevTools Protocol: all nine sectors and pills produce the right axis and sign, and reach the bench end to end - [x] **Deadman**, at runtime: killing the joy node mid-jog stops the arm 0.211 s later, once; the e-stop latch held a fully deflected stick for 0.85 s with nothing reaching the arm; the lease returns 2.0 s after the stick centres - [x] Verified against a **real Lite6** at 192.168.1.162 — jogging, gripper, E-STOP and RESET, through the console - [x] The console opened in an actual browser, on the machine and on a tablet - [ ] Verified against a physical gamepad (no pad on the machine this was built on) ## Two things to know before merging **This does not break `dora-ulite6`.** Both the standard and the deprecated ports publish the same intent, and I checked every message against that node's own parsers. Version is `0.2.0` to say the contract moved; liria/dora-ulite6#3 and #3 carry the rest. **The split and the standardisation are not separable in this branch.** They were built one on top of the other before either was committed, so `main.py` and `mapping.py` carry both. I would rather say so than reconstruct an intermediate state I never actually ran. The one unticked box needs a gamepad, which the machine this was built on has not got. Everything else has been run, the arm included. --- ## What changed since the review above The branch grew three things after the first round. The commit is `serve the web console from the node and drop the websocket bridge`. ### The bridge is gone `dora-websocket-transceiver` was a second node, a second port and a round trip through the dataflow for every button press, carrying a UI only this node can interpret. The node now serves the page and holds the sockets itself on one port. `dora_teleop/server.py` runs aiohttp on its own thread and meets the control loop at two non-blocking points only — a bounded queue the tick drains, and a snapshot handed back — because nothing a browser does may stall the thread that stops the arm. Both queues are bounded on purpose: a flood is dropped rather than grown, and a slow client loses stale snapshots rather than the newest. **Contract, so `0.2.0` → `0.3.0`:** `ui_command` and `clients` are gone. `ui_state` stays published — no browser reads it now, but `dora topic echo` and the bench do. New `WEB_HOST` and `WEB_PORT` (**7000**; `0` opens no port, which is what `dataflow-gamepad.yml` wants). The web deadman moved without changing meaning: it was a `clients` message from the bridge, and is now the open-socket count read each tick. One hop fewer. ### The console is laid out like the arm's own pendant Two rings quartered into arc sectors you hold, each with its out-of-plane axis as pills above — the shape of xArm Studio's jog control on the controller at `:18333`. | Ring | Sectors | Pills | |---|---|---| | translate | `+X` N · `−X` S · `Y+` **W** · `Y−` E | `−Z` `+Z` | | rotate | `+pitch` N · `−pitch` S · `−roll` W · `+roll` E | `−yaw` `+yaw` | **`Y+` is on the left**, matching xArm Studio and the right-hand rule. It is the one direction that does not read as "screen right is right", and it is deliberate: two consoles driving one arm that disagree about which way is `+Y` is a trap. An earlier revision of this used analog sticks — drag distance as speed. It was dropped after testing on the arm: Z as a drag lane reads wrong, and the rings match the pendant an operator already knows. ### Sending the change rather than the state `_drive` now sends a velocity when it **changes**, repeating an unchanged one on `JOG_KEEPALIVE_S` (0.2 s) only to beat the consumer's own expiry. A release is a change and never waits for the repeat. Measured on the wire: **83 messages → 18** for the same four-second hold. This began as a fix for an arm that moved in steps rather than travelling. **It was not the cause** — cutting the rate 4.6× changed nothing on the arm, and that negative result is what pointed at `dora-ulite6` re-arming a timed velocity segment per message. Fixed there and released as **v0.3.1**, which this branch now pins. The reduction is kept on its own merits. ### Two bugs found while building this, each with a test - **The console server failed silently.** A port already in use killed the server thread, `start()` sat out its full 10 s timeout and then logged `console on http://…` anyway — node running, nothing serving, and the block backed up the input queue. It now raises and the node refuses to start. Found by accident when two test runs collided. - **Knob travel was a third of intended** in the analog revision: a percentage inside a CSS `translate` is a fraction of the knob, not of the circle it crosses. Moot now the sticks are gone, but it is why the rings are positioned in user units. ### One thing a reviewer should know **`dora run --uv` cannot spawn this dataflow** (dora 1.0.1). `dora build --uv` installs each `git:` node into a venv inside its own clone, while the spawner resolves the entrypoint with `uv run which <name>` from the *dataflow's* directory — so the node is installed where the spawner never looks. `README.md` and `dataflow-robot.yml` now document building into a single activated environment instead. `dataflow.yml` is unaffected and `--uv` is fine there. Unrelated to this branch, but it is why `dora-joy` moves to **v0.1.1**: `v0.1` is an annotated tag, and dora records the tag object's id as the commit hash and then rejects its own checkout for sitting on the peeled commit. Neither `git:` dataflow could be built at all before that.
cristhian added 4 commits 2026-09-16 16:20:54 +00:00
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.
The page was a status readout: it sent three of the nine commands the node
serves, so the browser could take the lease and stop the arm but not move it.

It now holds a direction to move and releases to stop, with pads for
translation and rotation, gripper, freedrive, home and a speed slider. It sends
a fraction of full deflection rather than a speed, so it never learns the
robot's units — the same vector a stick produces.

Plain HTML and no build step: the bridge serves the directory as it stands, and
a console that cannot be fixed without a toolchain is the wrong thing to have
between a person and a robot.

Details that matter on a machine that moves: the pointer is captured so sliding
off a button still releases it; releasing sends jog_stop rather than relying on
the deadman; blur, tab-hide and a closed socket release everything; and the pad
is disabled unless the console holds the lease, so a button that would be
refused looks refused.

test_console.py checks the page against ui.py both ways — every message type it
sends is one the node serves, every telemetry key it reads is one the node
sends. Neither language catches a rename.
Both pull sibling nodes by `git:`, but dora resolves the wiring without
fetching them, so this stays offline. It is what catches a drift between the
robot dataflow's output_types block and the tag it pins.

Also ignore `git/`, where dora build leaves those clones.
docs: describe the standard ports and the console
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m41s
ebd3b3e493
cristhian added 1 commit 2026-09-16 16:26:20 +00:00
update version to 0.2.0 for the new output contract
validate / validate (pull_request) Successful in 58m40s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
ffbba0f4d1
`twist` and `command` are the ports a consumer should wire from now on, and
`jog` and `request` are deprecated. Nothing breaks yet — both still publish —
but the version should say the contract moved.
cristhian added the feature label 2026-09-16 16:27:10 +00:00
cristhian added 1 commit 2026-09-16 17:50:33 +00:00
serve the web console from the node and drop the websocket bridge
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
da1b079b7b
The browser reached this node through `dora-websocket-transceiver`: a second
node, a second port, and a round trip through the dataflow for every button
press, to carry a UI only this node can interpret. The node now serves the
page and holds the browsers' sockets itself on one port, so the console is
one URL and a consumer wires nothing for it.

`dora_teleop/server.py` runs aiohttp on a thread of its own and meets the
control loop at two non-blocking points: a bounded queue the tick drains, and
a snapshot handed back. Both queues are bounded deliberately — a flood is
dropped rather than grown, and a slow browser loses stale snapshots rather
than the newest — because nothing a browser does may stall the thread that
stops the arm. A port already in use stops the node rather than leaving it
announcing a console nobody can open.

The web deadman moved but did not change: it was a `clients` message from the
bridge and is now the open-socket count read each tick. One hop fewer, same
meaning.

Contract, so 0.2.0 -> 0.3.0: `ui_command` and `clients` are gone. `ui_state`
stays published for `dora topic echo` and the bench, with no browser reading
it. New WEB_HOST and WEB_PORT (7000; 0 opens no port).

The console itself is laid out like the arm's own pendant — two rings
quartered into arc sectors you hold, each with its out-of-plane axis as pills
above. Directions and signs match xArm Studio on the controller, `Y+` on the
left included: two consoles driving one arm that disagree about which way is
+Y is a trap, not a preference.

Also sends a velocity only when it changes, repeating an unchanged one on
JOG_KEEPALIVE_S (0.2 s) purely to beat the consumer's own expiry. Repeating
every tick made `dora-ulite6` re-plan the same motion twenty times a second.
That turned out not to be the whole cause of the stepping an operator felt —
the rest was fixed in dora-ulite6 v0.3.1, pinned here — but sending 18
messages where 83 went before is worth keeping on its own.

`dora-joy` moves to v0.1.1: v0.1 is an annotated tag, and dora 1.0.1 records
the tag object's id as the commit hash and then rejects its own checkout for
sitting on the peeled commit. Neither dataflow could be built before this.
cristhian merged commit 7c1c33f5b1 into main 2026-09-16 17:52:07 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: liria/dora-teleop#2