Jogging a real Lite6 does not feel like travel. The arm moves in discrete steps for as
long as the direction is held.
The cause is in set_cartesian_velocity, which passed duration=velocity_command_ttl_s
on every message. duration is a maximum, not a hint — the SDK documents it as
"over this time will automatically set the speed to 0" — so each message re-armed a
half-second timed segment and the controller re-planned the same motion again.
What was ruled out first
The velocity stream itself. Measured at the consumer: a steady 20 Hz, median gap
49.9 ms over 83 messages, constant 60.0 mm/s, no gaps. Nothing steppy is being sent.
Link or worker saturation. Controller round-trips are ~2 ms median, so neither the
network nor the job worker is behind at 20 Hz.
Sending less often.dora-teleop was changed to send on change plus a keepalive,
cutting 83 messages to 18 for the same motion. No improvement on the arm. Re-arming
four times a second steps just as much as twenty. That is what ruled out rate as the
cause and pointed at re-arming itself.
The change
duration=0 — the SDK's "always effective, will not stop automatically" — and an
unchanged velocity is not sent to the arm at all. One command stands until a different
one replaces it. Verified on the arm at 192.168.1.162: smooth.
The remembered velocity is cleared in the enter-velocity-mode branch, which every route
out of the mode comes back through, so re-entry always re-sends.
The half that keeps it safe
duration was also the controller-side deadman. _expire_jog was relying on it —
it flipped _jogging and reported ready without commanding anything, because the
controller's own timer had already stopped the arm.
With the velocity held indefinitely, that would leave the arm travelling while the node
reported idle: worse than not expiring it at all. So _expire_jog now queues the stop
itself, through the job queue rather than the driver, since it runs on the reporter
thread and the control thread owns the driver.
VELOCITY_COMMAND_TTL_S keeps its value and its meaning to a caller. It is now enforced
here rather than by the controller, and it is a real deadman rather than a description
of one.
This is one commit on purpose: duration=0 without the _expire_jog stop is an arm that
never stops when its sender dies.
Tests
86 to 92. Adds tests/test_driver.py — the driver had no direct coverage at all,
because FakeArm replaces it everywhere else, which is why this was invisible to the
suite. It pins: no duration is ever passed, a held velocity reaches the controller once,
a changed one gets through, and leaving velocity mode makes the next identical velocity
send again.
test_a_sender_that_goes_quiet_actually_stops_the_arm asserts the zero reaches the arm,
not just that the state changed — the assertion whose absence let the old _expire_jog
look correct.
Notes for the reviewer
Docs updated for the semantics change: README.md, dora-node.yml, config.py, plus
two invariants in AGENTS.md so the per-message duration is not reintroduced and the
unchanged-velocity check is not "simplified" away.
Six files in the repo are unformatted under ruff format on main already. Left alone
rather than mixed into this PR.
Not yet exercised on hardware: the _expire_jog stop path. The smooth-jogging half
was confirmed on the arm; killing a sender mid-jog to watch the new stop fire was not.
Worth doing before merge.
## The problem
Jogging a real Lite6 does not feel like travel. The arm moves in discrete steps for as
long as the direction is held.
The cause is in `set_cartesian_velocity`, which passed `duration=velocity_command_ttl_s`
on **every** message. `duration` is a maximum, not a hint — the SDK documents it as
"over this time will automatically set the speed to 0" — so each message re-armed a
half-second timed segment and the controller re-planned the same motion again.
## What was ruled out first
- **The velocity stream itself.** Measured at the consumer: a steady 20 Hz, median gap
49.9 ms over 83 messages, constant 60.0 mm/s, no gaps. Nothing steppy is being sent.
- **Link or worker saturation.** Controller round-trips are ~2 ms median, so neither the
network nor the job worker is behind at 20 Hz.
- **Sending less often.** `dora-teleop` was changed to send on change plus a keepalive,
cutting 83 messages to 18 for the same motion. **No improvement on the arm.** Re-arming
four times a second steps just as much as twenty. That is what ruled out rate as the
cause and pointed at re-arming itself.
## The change
`duration=0` — the SDK's "always effective, will not stop automatically" — and an
unchanged velocity is not sent to the arm at all. One command stands until a different
one replaces it. Verified on the arm at 192.168.1.162: smooth.
The remembered velocity is cleared in the enter-velocity-mode branch, which every route
out of the mode comes back through, so re-entry always re-sends.
## The half that keeps it safe
`duration` was also the controller-side deadman. `_expire_jog` was **relying** on it —
it flipped `_jogging` and reported `ready` without commanding anything, because the
controller's own timer had already stopped the arm.
With the velocity held indefinitely, that would leave the arm travelling while the node
reported idle: worse than not expiring it at all. So `_expire_jog` now queues the stop
itself, through the job queue rather than the driver, since it runs on the reporter
thread and the control thread owns the driver.
`VELOCITY_COMMAND_TTL_S` keeps its value and its meaning to a caller. It is now enforced
here rather than by the controller, and it is a real deadman rather than a description
of one.
This is one commit on purpose: `duration=0` without the `_expire_jog` stop is an arm that
never stops when its sender dies.
## Tests
86 to 92. Adds `tests/test_driver.py` — **the driver had no direct coverage at all**,
because `FakeArm` replaces it everywhere else, which is why this was invisible to the
suite. It pins: no duration is ever passed, a held velocity reaches the controller once,
a changed one gets through, and leaving velocity mode makes the next identical velocity
send again.
`test_a_sender_that_goes_quiet_actually_stops_the_arm` asserts the zero reaches the arm,
not just that the state changed — the assertion whose absence let the old `_expire_jog`
look correct.
## Notes for the reviewer
- Docs updated for the semantics change: `README.md`, `dora-node.yml`, `config.py`, plus
two invariants in `AGENTS.md` so the per-message `duration` is not reintroduced and the
unchanged-velocity check is not "simplified" away.
- Six files in the repo are unformatted under `ruff format` on `main` already. Left alone
rather than mixed into this PR.
- **Not yet exercised on hardware:** the `_expire_jog` stop path. The smooth-jogging half
was confirmed on the arm; killing a sender mid-jog to watch the new stop fire was not.
Worth doing before merge.
`set_cartesian_velocity` passed `duration=velocity_command_ttl_s` on every
message, so each one re-armed a timed segment and the controller re-planned
the same motion. On a real Lite6 that is not smooth travel: the arm moves in
steps. Reducing how often the velocity is sent does not help — re-arming four
times a second still steps — so it now uses `duration=0`, the SDK's "always
effective, will not stop automatically", and skips a velocity that has not
changed. One command stands until a different one replaces it.
That removes the controller-side expiry, which `_expire_jog` was relying on:
it flipped `_jogging` and reported `ready` without commanding anything,
because the controller's own timer had already stopped the arm. With the
velocity held indefinitely that would leave an arm travelling while the node
reported idle, so `_expire_jog` now queues the stop itself. It goes through
the job queue rather than the driver, since it runs on the reporter thread
and the control thread owns the driver.
`VELOCITY_COMMAND_TTL_S` is unchanged in value and meaning to a caller, but
it is now enforced here rather than by the controller.
Both halves are one commit on purpose: `duration=0` without the stop in
`_expire_jog` is an arm that never stops when its sender dies.
Adds tests/test_driver.py — the driver had no direct coverage, because
FakeArm replaces it everywhere else, which is why this was invisible to the
suite.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The problem
Jogging a real Lite6 does not feel like travel. The arm moves in discrete steps for as
long as the direction is held.
The cause is in
set_cartesian_velocity, which passedduration=velocity_command_ttl_son every message.
durationis a maximum, not a hint — the SDK documents it as"over this time will automatically set the speed to 0" — so each message re-armed a
half-second timed segment and the controller re-planned the same motion again.
What was ruled out first
49.9 ms over 83 messages, constant 60.0 mm/s, no gaps. Nothing steppy is being sent.
network nor the job worker is behind at 20 Hz.
dora-teleopwas changed to send on change plus a keepalive,cutting 83 messages to 18 for the same motion. No improvement on the arm. Re-arming
four times a second steps just as much as twenty. That is what ruled out rate as the
cause and pointed at re-arming itself.
The change
duration=0— the SDK's "always effective, will not stop automatically" — and anunchanged velocity is not sent to the arm at all. One command stands until a different
one replaces it. Verified on the arm at 192.168.1.162: smooth.
The remembered velocity is cleared in the enter-velocity-mode branch, which every route
out of the mode comes back through, so re-entry always re-sends.
The half that keeps it safe
durationwas also the controller-side deadman._expire_jogwas relying on it —it flipped
_joggingand reportedreadywithout commanding anything, because thecontroller's own timer had already stopped the arm.
With the velocity held indefinitely, that would leave the arm travelling while the node
reported idle: worse than not expiring it at all. So
_expire_jognow queues the stopitself, through the job queue rather than the driver, since it runs on the reporter
thread and the control thread owns the driver.
VELOCITY_COMMAND_TTL_Skeeps its value and its meaning to a caller. It is now enforcedhere rather than by the controller, and it is a real deadman rather than a description
of one.
This is one commit on purpose:
duration=0without the_expire_jogstop is an arm thatnever stops when its sender dies.
Tests
86 to 92. Adds
tests/test_driver.py— the driver had no direct coverage at all,because
FakeArmreplaces it everywhere else, which is why this was invisible to thesuite. It pins: no duration is ever passed, a held velocity reaches the controller once,
a changed one gets through, and leaving velocity mode makes the next identical velocity
send again.
test_a_sender_that_goes_quiet_actually_stops_the_armasserts the zero reaches the arm,not just that the state changed — the assertion whose absence let the old
_expire_joglook correct.
Notes for the reviewer
README.md,dora-node.yml,config.py, plustwo invariants in
AGENTS.mdso the per-messagedurationis not reintroduced and theunchanged-velocity check is not "simplified" away.
ruff formatonmainalready. Left alonerather than mixed into this PR.
_expire_jogstop path. The smooth-jogging halfwas confirmed on the arm; killing a sender mid-jog to watch the new stop fire was not.
Worth doing before merge.