Skip to content
This repository was archived by the owner on Jul 26, 2026. It is now read-only.

test: add a launch_testing integration test of the dummy bringup - #114

Merged
youtalk merged 32 commits into
rollingfrom
feat/integration-tests
Jul 25, 2026
Merged

test: add a launch_testing integration test of the dummy bringup#114
youtalk merged 32 commits into
rollingfrom
feat/integration-tests

Conversation

@youtalk

@youtalk youtalk commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Adds a launch_testing integration test that brings up the whole stack in dummy mode and proves the write()/read() round trip end to end.

Until now the package's tests all exercised DynamixelHardware through a gmock driver. Nothing verified that the plugin actually loads under a real controller_manager, that its exported interfaces are claimable by real controllers, or that a trajectory command travels through write(), into the emulated servo, back out through read() and onto /joint_states.

Changes

  • dynamixel_hardware/test/launch/test_robot.urdf.xacro — a minimal two-joint robot with a use_dummy xacro arg defaulting to true.
  • dynamixel_hardware/test/launch/test_controllers.yaml — a 100 Hz controller_manager with joint_state_broadcaster and a position-only joint_trajectory_controller.
  • dynamixel_hardware/test/launch/test_dummy_bringup.launch.py — the launch test: ros2_control_node fed by robot_state_publisher with the xacro-processed description, controller spawners, ReadyToTest, then four assertions — the controller_manager node is running, both controllers reach active, /joint_states is published, and a JTC trajectory converges to its goal within tolerance.
  • Registered through add_ros_isolated_launch_test (isolated ROS_DOMAIN_ID per test) with TIMEOUT 180, plus the <test_depend> entries CI's rosdep needs.

What the convergence assertion actually proves

/joint_states is published by joint_state_broadcaster from state interfaces, and DummyDriver::write_positions is the only writer of the emulated servo position — so the goal can only appear there by travelling through the plugin's write(), into the emulated servo, and back out through read(). A missing joint yields float('inf') rather than a default, so absent data and mismatched joint names fail rather than pass, and an expired deadline is a failure, not a pass.

This was verified by mutation rather than by argument. Three independent broken-plugin states were observed and each fails a different subset: with the plugin unloadable only the node check passes; with write() neutered (via a temporary torque_enable=false, which makes the emulated robot freeze) the first three pass and only convergence fails, reporting last seen: {'joint1': 0.0, 'joint2': 0.0}; with read() returning NaN convergence fails too. A liveness-only test would have passed all three.

Distro coverage

The test is registered behind find_package(controller_manager QUIET), so a runner without ros2_control skips it cleanly rather than failing the build. The skip announces itself with a message(STATUS ...) — deliberately STATUS rather than WARNING, so it does not produce a permanent --- stderr block — because a silent guard means that if rosdep ever fails to install controller_manager, the integration test would quietly stop being registered and CI would stay green without it.

The test currently runs on all four target distros. ros2_control 6.8.0 rolling binaries were published on 2026-07-25, which closed the gap that this guard was originally written for; the guard stays because it is the difference between a graceful skip and a hard build failure if that gap ever reopens.

Testing

Clean-build container gate on this branch head, package build directories removed each time: rolling 200 tests / 0 errors / 0 failures, humble 201 / 0 / 0, jazzy 201 / 0 / 0. 10/10 consecutive launch-test runs on humble and jazzy at 2.9–4.6 s against the 180 s timeout.

Known local-only flake, documented in the test

One humble run in roughly forty expired the convergence poll on a fully healthy bringup. Traced to domain_coordinator.domain_id() releasing its ID on process exit and reissuing the same one to the next process, so residual DDS state from the previous run satisfies get_subscription_count() > 0 from a dead endpoint and the single publish() goes nowhere. It appears only on back-to-back local invocations — zero failures in six independent invocations, which is the pattern CI runs. The mechanism, the recognisable symptom and the unapplied remedy (re-publishing inside the poll, which would make the JTC replace its active trajectory every iteration and change what the test exercises) are all recorded in a comment at the publish site, so that if CI ever does show it, the diagnosis is already there.

youtalk and others added 30 commits July 24, 2026 23:21
Regression tests for the canonical port_name parameter (#87, #86):
usb_port is still accepted with a one-time deprecation warning,
port_name wins when both are given, and a missing port parameter is
reported as an init error instead of an uncaught exception.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
When torque_enable is false the plugin never turns servo torque on:
on_activate skips the torque-on and the mode-switch sequencing skips
the re-enable, while explicit torque-off requests still go through.
This supports leader arms in teleoperation setups that must stay
freely movable. Re-implements #106 on the driver-abstraction base.

Co-authored-by: Maverobot <Maverobot@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
…t torque-off still runs

Two gaps from the initial torque_enable implementation:

- perform_command_mode_switch() only cleared the fault latch when
  torque_enabled_ was true, but with torque_enable=false torque can
  never become enabled, so any transient mode-switch failure latched
  switch_failed_ permanently and every later write() escalated into
  on_error(), tearing the component down for a leader arm that is
  behaving exactly as configured. The latch now also clears on a
  successful switch when torque_enable_param_ is false, since staying
  de-energized is the intended healthy outcome in that configuration.
- added a test asserting that torque-off requests (on_deactivate) still
  reach the driver when torque_enable is false, pinning the safety-
  relevant half of the guard that was previously unasserted.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
read() now holds the last-known state and returns OK for up to
read_error_tolerance - 1 consecutive read_states failures (hardware
parameter, default 5); the Nth consecutive failure returns
return_type::ERROR so the controller manager can react (on_error on
jazzy+). Any successful read resets the counter. Fixes the spurious
component shutdowns on momentarily noisy buses reported in #88.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
write() now sends nothing (and returns OK) until read() has succeeded
once after activation; that first success also re-syncs commands to
the actual servo state via reset_command(). A bus where reads fail
can therefore never produce a zero/NaN goal position and the arm no
longer slams into itself on flaky startup.

The has_valid_state_ latch lives in the shared read_joint_states()
helper so it fires from both read() and on_activate()'s initial read
without duplicating the logic. on_activate() now treats that initial
read as best-effort: activation succeeds even when it fails, relying
on the write guard to keep the bus silent until a read lands.

Driver write failures are now logged and counted against a new
write_error_tolerance hardware parameter (default 5, separate from
read_error_tolerance) instead of being silently ignored.

Fixes #92.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
baud_rate, read_error_tolerance and write_error_tolerance each had the
same find -> stoi/try-catch -> range-check -> ERROR skeleton inlined
in init_impl, and the queued gear_ratio/offset/Return_Delay_Time tasks
would each have copied it again. Factor the shape into
parse_int_param(params, name, out, min_value); callers keep the
parameter-specific "is it required" logic (baud_rate still errors when
absent and not in dummy mode) on top of it.

Also give baud_rate an explicit lower bound: it is passed on as the
uint32_t argument to DynamixelWorkbench::init(), so a zero or negative
value would be meaningless or silently wrap.

Behavior is otherwise unchanged: identical log wording, identical
CallbackReturn::ERROR outcomes on parse failure or an out-of-range
value, and an absent key still keeps the compiled-in default.

Also add a write_error_tolerance = 1 boundary test: the first write
failure must escalate to ERROR immediately, with no one-failure grace
period.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
gear_ratio (default 1.0) is the number of motor revolutions per joint
revolution. The plugin converts between joint-side and motor-side
values on every read/write path: positions and velocities are divided
by the ratio on read and multiplied on write, efforts are multiplied
on read and divided on write, composing with the existing
torque_constant scaling. PWM duty ratios are left unconverted. The
conversion helpers also take an offset term, added by Task 7. Negative
ratios invert the direction; zero is rejected at init via the new
parse_double_param helper, a sibling of parse_int_param.

Re-implements #95 on the driver-abstraction base. Closes #94.

Co-authored-by: Tacha-S <13605820+Tacha-S@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
offset (default 0.0, joint-side radians) shifts the reported
position: joint_reported = raw - offset, and position commands add
the offset back before being sent. Combined with gear_ratio the
pipeline is joint = motor / gear_ratio - offset. Velocity, effort,
and PWM are unaffected.

parse_double_param and parse_int_param now take an optional joint
name, included in their error messages for per-joint parameters (and
omitted for hardware-level ones like baud_rate), so a typo'd
gear_ratio or offset value is traceable on a multi-joint robot.

This re-implements the intent of the URDF <calibration rising>
support from #96 as a plain <ros2_control> param instead, since
HardwareInfo exposes <ros2_control> params uniformly on all four
supported distros. Closes #93.

Co-authored-by: Tacha-S <13605820+Tacha-S@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Task 7 review flagged that PWM's independence from offset (and,
transitively, gear_ratio) was only stated in comments, never
exercised by a test -- offset's correction explicitly required this
to be asserted, not just structurally implied. Add
PwmIsUnaffectedByGearRatioAndOffset to ParamsRobustnessTest: a joint
configured for pwm control, with a non-default gear_ratio AND offset,
must see its commanded duty ratio reach write_pwms() unchanged.
Covering both parameters in one test closes the equivalent gap left
open for gear_ratio alone by the prior task's review.

Verified the assertion is load-bearing by temporarily reintroducing
an offset subtraction on the pwm write path and confirming the test
fails before reverting.

ParamsRobustnessTest gains command-interface export and a
set_command_value() helper (mirroring the existing state_value()
pattern) since driving write() into the PWM branch requires setting
joint.command.pwm directly -- reset_command() always resets it to
0.0, so there is no other way to give it a distinguishing non-zero
value.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Return_Delay_Time joins kExtraJointParameters, so a joint-level
<param name="Return_Delay_Time">0</param> is written to the control
table on configure. Setting it to 0 removes the 500 us default reply
delay per servo and is the main mitigation for the read latency
reported in #90. Re-implements #105.

Co-authored-by: Ignacio Davila <99193391+IDavGal@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Two <joint> entries sharing one Dynamixel id gave the plugin two
independent command, state and control-mode records for a single
servo, which then fought each other on every write cycle. The
configuration is now rejected during on_init, naming the id and both
joints, instead of silently misbehaving at runtime.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
A NaN or infinite command reached convertRadian2Value,
convertVelocity2Value, convertCurrent2Value or duty_to_pwm_ticks and
was narrowed to int32_t, which is undefined behavior: an unspecified
goal value went to a torqued servo and FE_INVALID was raised, with no
error reported. All four write paths now refuse the whole batch and
report which id carried the non-finite value.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Adds a regression test for the ids/values size guard added alongside
the finite-value check, so the length-mismatch branch is exercised
and not just implemented.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
A single plugin-wide torque flag could not express the state a partial
failure leaves behind: energizing joint 1 and failing on joint 2 left
the flag false while joint 1 was under power, so the next mode switch
skipped the mandatory torque-off leg and the firmware rejected the
Operating_Mode write. Torque state now lives on each joint, updated as
each driver call returns, and the mode-switch fault latch clears only
when every joint is energized again.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
test_dynamixel_hardware.cpp uses hardware_interface::HW_IF_* at eleven
sites but never included the header that defines them. jazzy and
rolling supply it transitively through
hardware_interface/hardware_component_interface.hpp, which humble has
no equivalent of, so only humble failed to compile -- and every task
since the breakage was introduced verified on jazzy alone. The header
path, namespace and symbol spellings are identical on all four target
distros, so a plain include is correct and no compat.hpp gate applies.

Naming the parameters of the ReadReturns action type is a second
humble-only fix: uncrustify 0.72 (humble) and 0.78 (jazzy and later)
disagree about the space in a function type ending in "&)", and no
spelling of that construct satisfies both. Naming the parameters
removes the construct they disagree on.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
A rejected set_torque(id, true) was still recorded as energized, on the
reasoning that assuming the worst kept the next switch from skipping
the mandatory torque-off leg. That assumption is conservative for the
mode write but optimistic for the fault latch, and the latch is what
reads the flag: two joints switched, joint 2's re-enable rejected,
switch_failed_ latched, then a later switch on joint 1 alone found
all_torque_enabled() true, cleared the fault, and write() resumed
sync-writing goals to a limp servo.

The torque-off leg no longer skips a joint it believes is already off
-- a joint that is merely not known to be energized may still be
torqued, and the redundant torque-off is idempotent -- so nothing
depends on the pessimistic value any more, and the flag is now set
only after the driver confirms torque on. Restoring torque remains
limited to the joints that were confirmed energized on entry, so a
mode switch still never powers up servos left limp by a deactivation,
a torque_enable=false configuration or an earlier failure.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Deleting "all_torque_enabled() ||" from the latch-clearing condition
left the whole suite green, so the true branch was unpinned. It is
reachable: an activation rejected on a joint that was ALREADY
confirmed energized leaves that joint's flag untouched, so every joint
reads energized while on_activate returns ERROR without clearing the
latch, and the next successful switch legitimately clears it. Verified
by mutation -- the new test fails with the disjunct removed and passes
with it restored.

Also corrects two pieces of prose the per-joint change had made
wrong. The mode-switch failure log told operators torque would be
restored "by a successful switch or by re-activating the component",
but a switch only restores joints that were energized when it began,
so re-activation is the answer except under torque_enable=false, where
staying de-energized is intended and a successful switch does clear
the error. And a test named for the removed policy of assuming a
rejected torque-on had energized the servo now says what it really
pins: the servo that did come back on is still tracked.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
update_legacy_heuristic() called apply_mode_switch() directly, so a
mode-switch failure it triggered returned return_type::ERROR for one
write() cycle but never set switch_failed_. The affected joint stayed
de-energized while write() silently re-attempted the same switch on
every later cycle instead of latching the fault like the
perform_command_mode_switch() path already does.

Extract apply_mode_switch_or_latch(), which applies the switch via
apply_mode_switch() and, on failure, sets switch_failed_ and logs the
existing operator-facing recovery message. Both
perform_command_mode_switch() and update_legacy_heuristic() now route
through this single helper, so a failure on either path latches
identically. Clearing switch_failed_ on a successful switch remains
perform_command_mode_switch()'s job alone, unchanged.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Adds a `## Hardware parameters` reference covering every hardware-level
and per-joint parameter, including the ones introduced by this branch:
torque_enable, read_error_tolerance, write_error_tolerance, gear_ratio,
offset and Return_Delay_Time. Subsections spell out the gear/offset
conversion contract, the two independent failure budgets, the write
guard that keeps the bus silent until the first successful read, and
the driver's refusal of non-finite command batches.

Adds a `### Read latency and Return_Delay_Time` subsection explaining
how Return_Delay_Time and the USB latency timer dominate read latency,
and what to set them to (#90).

Corrects statements this branch made false: the mode-switch paragraph
claimed torque is re-enabled for "exactly the joints that change" and
that a later switch can recover a latched fault, which per-joint torque
tracking and the confirmed-only restore rule no longer allow; and the
per-joint intro claimed there are only two optional parameters. Also
drops the stale joint_ids reference, which no longer names a parameter
the plugin reads.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Joint id parsing cast std::stoi() straight to uint8_t with no bounds
check, so an out-of-range value silently wrapped instead of failing
init: "300" became 44, "-1" became 255. The duplicate-id scan then
compared the wrapped values, so ids that only collided after
truncation (e.g. 1 and 257) were reported as a duplicate pair naming
the wrong id.

Reuse parse_int_param() (min_value 0) for the non-numeric and negative
cases, then add an explicit upper-bound check against the largest
valid individual servo id. Per dynamixel_sdk/packet_handler.h
(Protocol 2.0): MAX_ID is 0xFC (252), id 253 is unused, and 254 is
BROADCAST_ID, so 252 is the ceiling. The range check runs before the
duplicate-id scan, so an out-of-range id is now reported for what it
is instead of a false collision.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
torque_constant used a bare std::stod() inside try/catch and then
rejected only <= 0.0. std::stod parses "nan" and "inf" per strtod, and
neither satisfies that comparison (NaN <= 0.0 is false, inf > 0.0 is
true), so both slipped through. "nan" silently degraded the joint to
raw-mA effort with no warning, since the downstream > 0.0 guards in
effort_command_to_motor/effort_state_from_motor also fail for NaN.
"inf" zeroed every effort command on write and published +-inf/NaN on
the effort state.

Extend parse_double_param() with a DoubleParamRule enum expressing the
three acceptance rules already in use: kFinite (offset, any finite
value), kFiniteNonZero (gear_ratio, finite and non-zero, negatives
legal), and kFinitePositive (torque_constant, finite and strictly
positive). gear_ratio and offset now pass the rule that reproduces
their previous allow_zero behavior exactly, with unchanged messages;
torque_constant is routed through the helper for the first time,
keeping its original wording (joint name, "(Nm/A > 0)").

Update the two README lines that called torque_constant's positivity
requirement out without mentioning finiteness, and the Hardware
parameters id row now that it is range-checked. Also fix two unrelated
documentation inaccuracies raised alongside these defects:
Return_Delay_Time is an EEPROM register, not RAM like its neighbors in
that table, so it survives a mode change (the plugin still rewrites it
regardless); and the finite-command check in write() happens before
gear_ratio/offset/torque_constant conversion, not before every
conversion -- the driver-boundary unit conversion is what runs after
it.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
With torque_enable=false no joint can ever be energized: set_torque_all()
returns before touching the bus and apply_mode_switch()'s re-enable leg
is gated off. Every goal sync-write write() issued in that configuration
was therefore a bus round-trip the servo could not act on, storing a goal
that reset_joint_command() would overwrite the moment torque ever did
come on.

That is exactly backwards for the configuration the parameter exists for
-- a leader arm in teleoperation, back-driven by hand while its states
are published -- where read latency is the only thing that matters
(#90). Those writes also fed handle_write_result(), so failures on writes
with no purpose could escalate write() to ERROR and take the leader arm
down.

Only the four driver write_*() calls are skipped. tick(), the legacy
heuristic and the batching loop's prev_command bookkeeping all still run:
the heuristic compares each cycle's command against the previous one, so
a loop that stopped recording them would pin a legacy joint in whichever
mode it entered. The write-failure counter is left untouched rather than
reset through handle_write_result(true), mirroring the has_valid_state_
guard above it -- a cycle that issued no driver call is neither evidence
of a healthy bus nor a failure.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
read() and write() escalating to return_type::ERROR after N consecutive
failures is the intended fix for #88, and it is right: a bus that has
stopped answering must not look healthy. But it is also a runtime
behavior change that lands on every existing user with no URDF change --
before this branch read() logged and returned OK forever, and write()
returned OK regardless of what the driver reported -- and it routes the
component through on_error, which disables torque and disconnects,
deactivating controllers mid-motion.

Both tolerances were floored at 1, so an operator on a marginal USB
adapter or an electrically noisy bus -- exactly the population #90 is
about -- had no configuration that restored the old ride-through
behavior. 0 is now accepted on both and disables escalation for that
direction.

A disabled tolerance still counts and still warns: silence about a dying
bus would be worse than the behavior it restores. The warning renders the
denominator as "disabled" rather than "/0", and the counter saturates at
INT_MAX because nothing but a successful cycle resets it any more.
Negative values stay rejected -- they express nothing 0 does not.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
ensure_finite_commands() tested std::isfinite() on the double, and the
very next thing every conversion does is static_cast<float>(values[i]).
Any finite double above ~3.4e38 becomes inf as a float, and
convertRadian2Value() / convertVelocity2Value() / convertCurrent2Value()
assign their result to an int32_t -- converting a non-finite float to an
integer type is undefined behavior, in a 100 Hz control loop.

The window is reachable two ways: a diverging controller passing through
~1e39 for a single cycle before it reaches NaN, or an accepted
pathological parameter -- gear_ratio 1e300 is finite and non-zero, so
to_motor_position() produces 1e300, and torque_constant 1e-300 is finite
and positive, so effort_command_to_motor() produces 1e303.

Narrowing before the check closes it for all three conversions. NaN and
the infinities narrow to themselves, so they are still caught, and the
whole-batch-refusal semantics and the id/index/value error shape are
unchanged. write_pwms() was never at risk, since duty_to_pwm_ticks()
clamps to [-1, 1] first, but it shares the guard: a duty ratio that far
out of range is now refused as the caller bug it is rather than silently
clamped to full duty.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
The extra control-table parameters are rewritten after every mode change
because a mode change resets them, and the comment in apply_mode_switch()
said as much: "Extra control-table parameters live in RAM and are reset
by a mode change." That stopped being true when Return_Delay_Time was
appended to the list. It sits at X-series control-table address 9, in the
EEPROM area -- as does Operating_Mode at 11, which is exactly why
on_configure() has to set the operating mode explicitly -- and EEPROM
survives a mode change.

So every switch issued one extra per-joint blocking itemWrite()
round-trip, on a path reachable from write(), to restore a value that was
never lost. Endurance is not the concern (mode switches are human-driven
and rare); the write is simply useless and the code's own comment had
become wrong.

Each table entry now records whether it lives in RAM, and
write_extra_joint_params() takes a scope: on_configure() writes
everything, since that is the one point where the servo's EEPROM is not
known to match the URDF, and apply_mode_switch() writes only what the
mode change reset.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Add a launch_testing integration test that brings the plugin up through
ros2_control_node in dummy mode, with a two-joint test robot, the
joint_state_broadcaster and a position-only joint_trajectory_controller.

Beyond asserting that the nodes and the controllers come up, the test
publishes a JointTrajectory and polls /joint_states until the emulated
joints reach the goal. DummyDriver only moves its emulated servos from
write_positions(), so convergence exercises the whole write()/read()
round trip instead of merely proving that the processes started.

The fixtures are located relative to __file__ because launch_testing
runs the test from the source tree, so they need no install rules.

This commit only adds the files; nothing runs them yet.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Register test/launch/test_dummy_bringup.launch.py through an
add_ros_isolated_launch_test() helper, so that every run gets its own
ROS_DOMAIN_ID, and cap it at TIMEOUT 120 so a wedged bringup fails
instead of hanging the job.

controller_manager has no installable rolling binaries during the
Ubuntu-resolute transition, so the registration sits behind
find_package(controller_manager QUIET): the test runs on humble and
jazzy and auto-skips on rolling, leaving the rest of the suite intact
there.

Declare the new test-only dependencies in package.xml so that CI's
rosdep installs them; the development images already carrying them is
not the same as CI having them.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Move find_package(ament_cmake_ros REQUIRED) and
find_package(launch_testing_ament_cmake REQUIRED), together with the
add_ros_isolated_launch_test() helper that depends on them, inside the
if(controller_manager_FOUND) block.

The guard exists because rolling's dependency set is incomplete during
the Ubuntu-resolute transition. Leaving REQUIRED lookups outside it
turned exactly that condition into a hard configure failure: the same
gap that hides controller_manager can just as easily hide these, and
the graceful skip would have become a broken package on the one distro
the guard was written for.

Verified with cmake --trace-source over this file: on rolling only the
find_package(controller_manager QUIET) line runs, while jazzy still
reaches both REQUIRED lookups and the registration. humble and jazzy
keep running the launch test; rolling keeps skipping it.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Add an else() branch to the controller_manager guard that logs the
skip at STATUS level. The guard was silent, so a rosdep regression on
a distro that is meant to run the launch test would have quietly
unregistered it and left CI green with the integration coverage gone;
the message makes the skip greppable, and its absence on humble and
jazzy is now the positive signal that the test is still registered.
STATUS rather than WARNING keeps rolling's build free of a permanent
stderr block that people would learn to ignore.

Declare the six packages the launch test imports directly:
builtin_interfaces, launch_ros, launch_testing, rclpy, sensor_msgs and
trajectory_msgs. They reached CI only transitively through the
controllers and launch_testing_ros, which is the same "declare what
you use" gap the other test_depend entries exist to close.

Give check_node_running an explicit 15 s timeout instead of inheriting
the 5 s default; it is the first check after ReadyToTest() and so
absorbs the whole ros2_control_node startup on a cold runner.

Correct the test_d comment, which claimed the goal appears once the
trajectory finishes. Convergence within 0.05 rad is reached mid-spline,
before time_from_start elapses.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Raise the dummy bringup launch test from TIMEOUT 120 to 180 and record
the arithmetic next to the value. unittest runs all four sub-tests
regardless of whether an earlier one failed, so the worst case is the
sum of their deadlines rather than the largest: about 100-105 s once
launch startup and teardown are counted. At 120 that left roughly 15%
headroom, and exceeding it makes ctest report a bare Timeout, throwing
away the assertion messages the test exists to produce. A passing run
still takes about 3 s, so the happy path is unaffected.

Document, at the publish site, an intermittent failure seen on
back-to-back local runs. run_test_isolated.py takes its domain from
domain_coordinator, which releases the ID on exit and immediately
reissues it, so leftover discovery state can satisfy
get_subscription_count() from a dead endpoint; the single publish then
goes nowhere and the poll expires against a healthy bringup. Measured
0 failures in 6 independent invocations against 1 in ~40 back-to-back
runs, and CI never recycles a domain, so test_d is left unchanged. The
comment names the remedy that was not applied, re-publishing inside the
poll, together with its behavioural cost.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Base automatically changed from feat/params-robustness to rolling July 25, 2026 22:43
@youtalk
youtalk marked this pull request as ready for review July 25, 2026 22:44
From ros2_control 6.x on, a controller node no longer inherits the
parameter file handed to ros2_control_node, so joint_trajectory_controller
loaded with an empty `joints` list and refused to initialise:

  Invalid value set during initialization for parameter 'joints':
  Length of parameter 'joints' is '0' but must be greater than '0'

That failed test_b and test_d on rolling and lyrical while humble and
jazzy stayed green. Passing --param-file to both spawners fixes it; the
flag exists on humble, jazzy and rolling alike, so one form covers every
target distro and no compat branch is needed.

The launch test was previously skipped on rolling because the local dev
image had no controller_manager. ros2_control 6.8.0 rolling binaries were
published on 2026-07-25, so CI's rosdep now installs them, the
find_package guard succeeds and the test really runs there. The local
rolling image was extended to match CI before this fix was verified.

Verified in the containers, clean rebuild of the package each time:
rolling 200 tests / 0 errors / 0 failures, humble 201 / 0 / 0,
jazzy 201 / 0 / 0. Rolling gains the launch test it used to skip.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
@youtalk
youtalk merged commit d200a24 into rolling Jul 25, 2026
6 checks passed
@youtalk
youtalk deleted the feat/integration-tests branch July 25, 2026 23:00
youtalk added a commit that referenced this pull request Jul 25, 2026
#115)

* test: cover port_name/usb_port fallback and deprecation warning

Regression tests for the canonical port_name parameter (#87, #86):
usb_port is still accepted with a one-time deprecation warning,
port_name wins when both are given, and a missing port parameter is
reported as an init error instead of an uncaught exception.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: add torque_enable hardware parameter

When torque_enable is false the plugin never turns servo torque on:
on_activate skips the torque-on and the mode-switch sequencing skips
the re-enable, while explicit torque-off requests still go through.
This supports leader arms in teleoperation setups that must stay
freely movable. Re-implements #106 on the driver-abstraction base.

Co-authored-by: Maverobot <Maverobot@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: clear switch_failed_ latch when torque_enable is false and assert torque-off still runs

Two gaps from the initial torque_enable implementation:

- perform_command_mode_switch() only cleared the fault latch when
  torque_enabled_ was true, but with torque_enable=false torque can
  never become enabled, so any transient mode-switch failure latched
  switch_failed_ permanently and every later write() escalated into
  on_error(), tearing the component down for a leader arm that is
  behaving exactly as configured. The latch now also clears on a
  successful switch when torque_enable_param_ is false, since staying
  de-energized is the intended healthy outcome in that configuration.
- added a test asserting that torque-off requests (on_deactivate) still
  reach the driver when torque_enable is false, pinning the safety-
  relevant half of the guard that was previously unasserted.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: tolerate transient read failures via read_error_tolerance

read() now holds the last-known state and returns OK for up to
read_error_tolerance - 1 consecutive read_states failures (hardware
parameter, default 5); the Nth consecutive failure returns
return_type::ERROR so the controller manager can react (on_error on
jazzy+). Any successful read resets the counter. Fixes the spurious
component shutdowns on momentarily noisy buses reported in #88.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: guard write() until the first successful read

write() now sends nothing (and returns OK) until read() has succeeded
once after activation; that first success also re-syncs commands to
the actual servo state via reset_command(). A bus where reads fail
can therefore never produce a zero/NaN goal position and the arm no
longer slams into itself on flaky startup.

The has_valid_state_ latch lives in the shared read_joint_states()
helper so it fires from both read() and on_activate()'s initial read
without duplicating the logic. on_activate() now treats that initial
read as best-effort: activation succeeds even when it fails, relying
on the write guard to keep the bus silent until a read lands.

Driver write failures are now logged and counted against a new
write_error_tolerance hardware parameter (default 5, separate from
read_error_tolerance) instead of being silently ignored.

Fixes #92.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* refactor: extract parse_int_param to dedupe hardware-parameter parsing

baud_rate, read_error_tolerance and write_error_tolerance each had the
same find -> stoi/try-catch -> range-check -> ERROR skeleton inlined
in init_impl, and the queued gear_ratio/offset/Return_Delay_Time tasks
would each have copied it again. Factor the shape into
parse_int_param(params, name, out, min_value); callers keep the
parameter-specific "is it required" logic (baud_rate still errors when
absent and not in dummy mode) on top of it.

Also give baud_rate an explicit lower bound: it is passed on as the
uint32_t argument to DynamixelWorkbench::init(), so a zero or negative
value would be meaningless or silently wrap.

Behavior is otherwise unchanged: identical log wording, identical
CallbackReturn::ERROR outcomes on parse failure or an out-of-range
value, and an absent key still keeps the compiled-in default.

Also add a write_error_tolerance = 1 boundary test: the first write
failure must escalate to ERROR immediately, with no one-failure grace
period.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: add per-joint gear_ratio parameter

gear_ratio (default 1.0) is the number of motor revolutions per joint
revolution. The plugin converts between joint-side and motor-side
values on every read/write path: positions and velocities are divided
by the ratio on read and multiplied on write, efforts are multiplied
on read and divided on write, composing with the existing
torque_constant scaling. PWM duty ratios are left unconverted. The
conversion helpers also take an offset term, added by Task 7. Negative
ratios invert the direction; zero is rejected at init via the new
parse_double_param helper, a sibling of parse_int_param.

Re-implements #95 on the driver-abstraction base. Closes #94.

Co-authored-by: Tacha-S <13605820+Tacha-S@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: add per-joint offset parameter

offset (default 0.0, joint-side radians) shifts the reported
position: joint_reported = raw - offset, and position commands add
the offset back before being sent. Combined with gear_ratio the
pipeline is joint = motor / gear_ratio - offset. Velocity, effort,
and PWM are unaffected.

parse_double_param and parse_int_param now take an optional joint
name, included in their error messages for per-joint parameters (and
omitted for hardware-level ones like baud_rate), so a typo'd
gear_ratio or offset value is traceable on a multi-joint robot.

This re-implements the intent of the URDF <calibration rising>
support from #96 as a plain <ros2_control> param instead, since
HardwareInfo exposes <ros2_control> params uniformly on all four
supported distros. Closes #93.

Co-authored-by: Tacha-S <13605820+Tacha-S@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: assert pwm is unaffected by gear_ratio and offset

Task 7 review flagged that PWM's independence from offset (and,
transitively, gear_ratio) was only stated in comments, never
exercised by a test -- offset's correction explicitly required this
to be asserted, not just structurally implied. Add
PwmIsUnaffectedByGearRatioAndOffset to ParamsRobustnessTest: a joint
configured for pwm control, with a non-default gear_ratio AND offset,
must see its commanded duty ratio reach write_pwms() unchanged.
Covering both parameters in one test closes the equivalent gap left
open for gear_ratio alone by the prior task's review.

Verified the assertion is load-bearing by temporarily reintroducing
an offset subtraction on the pwm write path and confirming the test
fails before reverting.

ParamsRobustnessTest gains command-interface export and a
set_command_value() helper (mirroring the existing state_value()
pattern) since driving write() into the PWM branch requires setting
joint.command.pwm directly -- reset_command() always resets it to
0.0, so there is no other way to give it a distinguishing non-zero
value.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: write Return_Delay_Time from joint parameters

Return_Delay_Time joins kExtraJointParameters, so a joint-level
<param name="Return_Delay_Time">0</param> is written to the control
table on configure. Setting it to 0 removes the 500 us default reply
delay per servo and is the main mitigation for the read latency
reported in #90. Re-implements #105.

Co-authored-by: Ignacio Davila <99193391+IDavGal@users.noreply.github.com>
Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: reject duplicate joint ids at initialization

Two <joint> entries sharing one Dynamixel id gave the plugin two
independent command, state and control-mode records for a single
servo, which then fought each other on every write cycle. The
configuration is now rejected during on_init, naming the id and both
joints, instead of silently misbehaving at runtime.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: reject non-finite commands before tick conversion

A NaN or infinite command reached convertRadian2Value,
convertVelocity2Value, convertCurrent2Value or duty_to_pwm_ticks and
was narrowed to int32_t, which is undefined behavior: an unspecified
goal value went to a torqued servo and FE_INVALID was raised, with no
error reported. All four write paths now refuse the whole batch and
report which id carried the non-finite value.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: cover ids/values length mismatch in ensure_finite_commands

Adds a regression test for the ids/values size guard added alongside
the finite-value check, so the length-mismatch branch is exercised
and not just implemented.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: track servo torque state per joint

A single plugin-wide torque flag could not express the state a partial
failure leaves behind: energizing joint 1 and failing on joint 2 left
the flag false while joint 1 was under power, so the next mode switch
skipped the mandatory torque-off leg and the firmware rejected the
Operating_Mode write. Torque state now lives on each joint, updated as
each driver call returns, and the mode-switch fault latch clears only
when every joint is energized again.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* build: restore the humble build of the test suite

test_dynamixel_hardware.cpp uses hardware_interface::HW_IF_* at eleven
sites but never included the header that defines them. jazzy and
rolling supply it transitively through
hardware_interface/hardware_component_interface.hpp, which humble has
no equivalent of, so only humble failed to compile -- and every task
since the breakage was introduced verified on jazzy alone. The header
path, namespace and symbol spellings are identical on all four target
distros, so a plain include is correct and no compat.hpp gate applies.

Naming the parameters of the ReadReturns action type is a second
humble-only fix: uncrustify 0.72 (humble) and 0.78 (jazzy and later)
disagree about the space in a function type ending in "&)", and no
spelling of that construct satisfies both. Naming the parameters
removes the construct they disagree on.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: record joint torque only once the driver confirms it

A rejected set_torque(id, true) was still recorded as energized, on the
reasoning that assuming the worst kept the next switch from skipping
the mandatory torque-off leg. That assumption is conservative for the
mode write but optimistic for the fault latch, and the latch is what
reads the flag: two joints switched, joint 2's re-enable rejected,
switch_failed_ latched, then a later switch on joint 1 alone found
all_torque_enabled() true, cleared the fault, and write() resumed
sync-writing goals to a limp servo.

The torque-off leg no longer skips a joint it believes is already off
-- a joint that is merely not known to be energized may still be
torqued, and the redundant torque-off is idempotent -- so nothing
depends on the pessimistic value any more, and the flag is now set
only after the driver confirms torque on. Restoring torque remains
limited to the joints that were confirmed energized on entry, so a
mode switch still never powers up servos left limp by a deactivation,
a torque_enable=false configuration or an earlier failure.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: pin the all_torque_enabled branch of the latch clear

Deleting "all_torque_enabled() ||" from the latch-clearing condition
left the whole suite green, so the true branch was unpinned. It is
reachable: an activation rejected on a joint that was ALREADY
confirmed energized leaves that joint's flag untouched, so every joint
reads energized while on_activate returns ERROR without clearing the
latch, and the next successful switch legitimately clears it. Verified
by mutation -- the new test fails with the disjunct removed and passes
with it restored.

Also corrects two pieces of prose the per-joint change had made
wrong. The mode-switch failure log told operators torque would be
restored "by a successful switch or by re-activating the component",
but a switch only restores joints that were energized when it began,
so re-activation is the answer except under torque_enable=false, where
staying de-energized is intended and a successful switch does clear
the error. And a test named for the removed policy of assuming a
rejected torque-on had energized the servo now says what it really
pins: the servo that did come back on is still tracked.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: latch switch_failed_ when the legacy heuristic's switch fails

update_legacy_heuristic() called apply_mode_switch() directly, so a
mode-switch failure it triggered returned return_type::ERROR for one
write() cycle but never set switch_failed_. The affected joint stayed
de-energized while write() silently re-attempted the same switch on
every later cycle instead of latching the fault like the
perform_command_mode_switch() path already does.

Extract apply_mode_switch_or_latch(), which applies the switch via
apply_mode_switch() and, on failure, sets switch_failed_ and logs the
existing operator-facing recovery message. Both
perform_command_mode_switch() and update_legacy_heuristic() now route
through this single helper, so a failure on either path latches
identically. Clearing switch_failed_ on a successful switch remains
perform_command_mode_switch()'s job alone, unchanged.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* docs: document hardware parameters and read-latency guidance

Adds a `## Hardware parameters` reference covering every hardware-level
and per-joint parameter, including the ones introduced by this branch:
torque_enable, read_error_tolerance, write_error_tolerance, gear_ratio,
offset and Return_Delay_Time. Subsections spell out the gear/offset
conversion contract, the two independent failure budgets, the write
guard that keeps the bus silent until the first successful read, and
the driver's refusal of non-finite command batches.

Adds a `### Read latency and Return_Delay_Time` subsection explaining
how Return_Delay_Time and the USB latency timer dominate read latency,
and what to set them to (#90).

Corrects statements this branch made false: the mode-switch paragraph
claimed torque is re-enabled for "exactly the joints that change" and
that a later switch can recover a latched fault, which per-joint torque
tracking and the confirmed-only restore rule no longer allow; and the
per-joint intro claimed there are only two optional parameters. Also
drops the stale joint_ids reference, which no longer names a parameter
the plugin reads.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: reject out-of-range Dynamixel ids at initialization

Joint id parsing cast std::stoi() straight to uint8_t with no bounds
check, so an out-of-range value silently wrapped instead of failing
init: "300" became 44, "-1" became 255. The duplicate-id scan then
compared the wrapped values, so ids that only collided after
truncation (e.g. 1 and 257) were reported as a duplicate pair naming
the wrong id.

Reuse parse_int_param() (min_value 0) for the non-numeric and negative
cases, then add an explicit upper-bound check against the largest
valid individual servo id. Per dynamixel_sdk/packet_handler.h
(Protocol 2.0): MAX_ID is 0xFC (252), id 253 is unused, and 254 is
BROADCAST_ID, so 252 is the ceiling. The range check runs before the
duplicate-id scan, so an out-of-range id is now reported for what it
is instead of a false collision.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: reject non-finite torque_constant values at initialization

torque_constant used a bare std::stod() inside try/catch and then
rejected only <= 0.0. std::stod parses "nan" and "inf" per strtod, and
neither satisfies that comparison (NaN <= 0.0 is false, inf > 0.0 is
true), so both slipped through. "nan" silently degraded the joint to
raw-mA effort with no warning, since the downstream > 0.0 guards in
effort_command_to_motor/effort_state_from_motor also fail for NaN.
"inf" zeroed every effort command on write and published +-inf/NaN on
the effort state.

Extend parse_double_param() with a DoubleParamRule enum expressing the
three acceptance rules already in use: kFinite (offset, any finite
value), kFiniteNonZero (gear_ratio, finite and non-zero, negatives
legal), and kFinitePositive (torque_constant, finite and strictly
positive). gear_ratio and offset now pass the rule that reproduces
their previous allow_zero behavior exactly, with unchanged messages;
torque_constant is routed through the helper for the first time,
keeping its original wording (joint name, "(Nm/A > 0)").

Update the two README lines that called torque_constant's positivity
requirement out without mentioning finiteness, and the Hardware
parameters id row now that it is range-checked. Also fix two unrelated
documentation inaccuracies raised alongside these defects:
Return_Delay_Time is an EEPROM register, not RAM like its neighbors in
that table, so it survives a mode change (the plugin still rewrites it
regardless); and the finite-command check in write() happens before
gear_ratio/offset/torque_constant conversion, not before every
conversion -- the driver-boundary unit conversion is what runs after
it.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: skip goal sync-writes when torque_enable is false

With torque_enable=false no joint can ever be energized: set_torque_all()
returns before touching the bus and apply_mode_switch()'s re-enable leg
is gated off. Every goal sync-write write() issued in that configuration
was therefore a bus round-trip the servo could not act on, storing a goal
that reset_joint_command() would overwrite the moment torque ever did
come on.

That is exactly backwards for the configuration the parameter exists for
-- a leader arm in teleoperation, back-driven by hand while its states
are published -- where read latency is the only thing that matters
(#90). Those writes also fed handle_write_result(), so failures on writes
with no purpose could escalate write() to ERROR and take the leader arm
down.

Only the four driver write_*() calls are skipped. tick(), the legacy
heuristic and the batching loop's prev_command bookkeeping all still run:
the heuristic compares each cycle's command against the previous one, so
a loop that stopped recording them would pin a legacy joint in whichever
mode it entered. The write-failure counter is left untouched rather than
reset through handle_write_result(true), mirroring the has_valid_state_
guard above it -- a cycle that issued no driver call is neither evidence
of a healthy bus nor a failure.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* feat: accept a zero error tolerance as "never escalate"

read() and write() escalating to return_type::ERROR after N consecutive
failures is the intended fix for #88, and it is right: a bus that has
stopped answering must not look healthy. But it is also a runtime
behavior change that lands on every existing user with no URDF change --
before this branch read() logged and returned OK forever, and write()
returned OK regardless of what the driver reported -- and it routes the
component through on_error, which disables torque and disconnects,
deactivating controllers mid-motion.

Both tolerances were floored at 1, so an operator on a marginal USB
adapter or an electrically noisy bus -- exactly the population #90 is
about -- had no configuration that restored the old ride-through
behavior. 0 is now accepted on both and disables escalation for that
direction.

A disabled tolerance still counts and still warns: silence about a dying
bus would be worse than the behavior it restores. The warning renders the
denominator as "disabled" rather than "/0", and the counter saturates at
INT_MAX because nothing but a successful cycle resets it any more.
Negative values stay rejected -- they express nothing 0 does not.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: check command finiteness in the type the conversion uses

ensure_finite_commands() tested std::isfinite() on the double, and the
very next thing every conversion does is static_cast<float>(values[i]).
Any finite double above ~3.4e38 becomes inf as a float, and
convertRadian2Value() / convertVelocity2Value() / convertCurrent2Value()
assign their result to an int32_t -- converting a non-finite float to an
integer type is undefined behavior, in a 100 Hz control loop.

The window is reachable two ways: a diverging controller passing through
~1e39 for a single cycle before it reaches NaN, or an accepted
pathological parameter -- gear_ratio 1e300 is finite and non-zero, so
to_motor_position() produces 1e300, and torque_constant 1e-300 is finite
and positive, so effort_command_to_motor() produces 1e303.

Narrowing before the check closes it for all three conversions. NaN and
the infinities narrow to themselves, so they are still caught, and the
whole-batch-refusal semantics and the id/index/value error shape are
unchanged. write_pwms() was never at risk, since duty_to_pwm_ticks()
clamps to [-1, 1] first, but it shares the guard: a duty ratio that far
out of range is now refused as the caller bug it is rather than silently
clamped to full duty.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* fix: rewrite only the RAM control-table parameters after a mode change

The extra control-table parameters are rewritten after every mode change
because a mode change resets them, and the comment in apply_mode_switch()
said as much: "Extra control-table parameters live in RAM and are reset
by a mode change." That stopped being true when Return_Delay_Time was
appended to the list. It sits at X-series control-table address 9, in the
EEPROM area -- as does Operating_Mode at 11, which is exactly why
on_configure() has to set the operating mode explicitly -- and EEPROM
survives a mode change.

So every switch issued one extra per-joint blocking itemWrite()
round-trip, on a path reachable from write(), to restore a value that was
never lost. Endurance is not the concern (mode switches are human-driven
and rare); the write is simply useless and the code's own comment had
become wrong.

Each table entry now records whether it lives in RAM, and
write_extra_joint_params() takes a scope: on_configure() writes
everything, since that is the one point where the servo's EEPROM is not
known to match the URDF, and apply_mode_switch() writes only what the
mode change reset.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: add a dummy-mode bringup launch test for DynamixelHardware

Add a launch_testing integration test that brings the plugin up through
ros2_control_node in dummy mode, with a two-joint test robot, the
joint_state_broadcaster and a position-only joint_trajectory_controller.

Beyond asserting that the nodes and the controllers come up, the test
publishes a JointTrajectory and polls /joint_states until the emulated
joints reach the goal. DummyDriver only moves its emulated servos from
write_positions(), so convergence exercises the whole write()/read()
round trip instead of merely proving that the processes started.

The fixtures are located relative to __file__ because launch_testing
runs the test from the source tree, so they need no install rules.

This commit only adds the files; nothing runs them yet.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: register the dummy bringup launch test with ctest

Register test/launch/test_dummy_bringup.launch.py through an
add_ros_isolated_launch_test() helper, so that every run gets its own
ROS_DOMAIN_ID, and cap it at TIMEOUT 120 so a wedged bringup fails
instead of hanging the job.

controller_manager has no installable rolling binaries during the
Ubuntu-resolute transition, so the registration sits behind
find_package(controller_manager QUIET): the test runs on humble and
jazzy and auto-skips on rolling, leaving the rest of the suite intact
there.

Declare the new test-only dependencies in package.xml so that CI's
rosdep installs them; the development images already carrying them is
not the same as CI having them.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: scope the launch-test find_package calls to the guard

Move find_package(ament_cmake_ros REQUIRED) and
find_package(launch_testing_ament_cmake REQUIRED), together with the
add_ros_isolated_launch_test() helper that depends on them, inside the
if(controller_manager_FOUND) block.

The guard exists because rolling's dependency set is incomplete during
the Ubuntu-resolute transition. Leaving REQUIRED lookups outside it
turned exactly that condition into a hard configure failure: the same
gap that hides controller_manager can just as easily hide these, and
the graceful skip would have become a broken package on the one distro
the guard was written for.

Verified with cmake --trace-source over this file: on rolling only the
find_package(controller_manager QUIET) line runs, while jazzy still
reaches both REQUIRED lookups and the registration. humble and jazzy
keep running the launch test; rolling keeps skipping it.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: announce the launch-test skip and declare its direct deps

Add an else() branch to the controller_manager guard that logs the
skip at STATUS level. The guard was silent, so a rosdep regression on
a distro that is meant to run the launch test would have quietly
unregistered it and left CI green with the integration coverage gone;
the message makes the skip greppable, and its absence on humble and
jazzy is now the positive signal that the test is still registered.
STATUS rather than WARNING keeps rolling's build free of a permanent
stderr block that people would learn to ignore.

Declare the six packages the launch test imports directly:
builtin_interfaces, launch_ros, launch_testing, rclpy, sensor_msgs and
trajectory_msgs. They reached CI only transitively through the
controllers and launch_testing_ros, which is the same "declare what
you use" gap the other test_depend entries exist to close.

Give check_node_running an explicit 15 s timeout instead of inheriting
the 5 s default; it is the first check after ReadyToTest() and so
absorbs the whole ros2_control_node startup on a cold runner.

Correct the test_d comment, which claimed the goal appears once the
trajectory finishes. Convergence within 0.05 rad is reached mid-spline,
before time_from_start elapses.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: raise the launch-test timeout and record the domain-ID race

Raise the dummy bringup launch test from TIMEOUT 120 to 180 and record
the arithmetic next to the value. unittest runs all four sub-tests
regardless of whether an earlier one failed, so the worst case is the
sum of their deadlines rather than the largest: about 100-105 s once
launch startup and teardown are counted. At 120 that left roughly 15%
headroom, and exceeding it makes ctest report a bare Timeout, throwing
away the assertion messages the test exists to produce. A passing run
still takes about 3 s, so the happy path is unaffected.

Document, at the publish site, an intermittent failure seen on
back-to-back local runs. run_test_isolated.py takes its domain from
domain_coordinator, which releases the ID on exit and immediately
reissues it, so leftover discovery state can satisfy
get_subscription_count() from a dead endpoint; the single publish then
goes nowhere and the poll expires against a healthy bringup. Measured
0 failures in 6 independent invocations against 1 in ~40 back-to-back
runs, and CI never recycles a domain, so test_d is left unchanged. The
comment names the remedy that was not applied, re-publishing inside the
poll, together with its behavioural cost.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* test: pass the controller parameters to the spawners explicitly

From ros2_control 6.x on, a controller node no longer inherits the
parameter file handed to ros2_control_node, so joint_trajectory_controller
loaded with an empty `joints` list and refused to initialise:

  Invalid value set during initialization for parameter 'joints':
  Length of parameter 'joints' is '0' but must be greater than '0'

That failed test_b and test_d on rolling and lyrical while humble and
jazzy stayed green. Passing --param-file to both spawners fixes it; the
flag exists on humble, jazzy and rolling alike, so one form covers every
target distro and no compat branch is needed.

The launch test was previously skipped on rolling because the local dev
image had no controller_manager. ros2_control 6.8.0 rolling binaries were
published on 2026-07-25, so CI's rosdep now installs them, the
find_package guard succeeds and the test really runs there. The local
rolling image was extended to match CI before this fix was verified.

Verified in the containers, clean rebuild of the package each time:
rolling 200 tests / 0 errors / 0 failures, humble 201 / 0 / 0,
jazzy 201 / 0 / 0. Rolling gains the launch test it used to skip.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* ci: add Mergify backport automation

Three label-driven backport rules (backport-humble, backport-jazzy,
backport-lyrical) with base=rolling, a conflict nag for authors, a
maintainer ping for conflicted Mergify backports, and a reminder
comment for pull requests not targeting rolling. Mirrors the
ros-controls configuration.

Schema verified against the current Mergify docs (pull_request_rules,
conditions operators, and the backport/comment action keys used here
are all current). backport-lyrical stays dormant until the lyrical
branch and the backport-* labels both exist; neither the labels nor
the app installation are created by this commit.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* docs: backfill changelog for 1.0.0

Accumulate the modernization campaign under the standard Forthcoming
heading: CI overhaul (#109), build modernization (#110), driver
abstraction (#111), the control-mode rework (#112), the parameter
and read/write robustness work (#113), integration tests (#114), and
this PR's Mergify automation and README rewrite. catkin_prepare_release
rewrites Forthcoming to 1.0.0 at release time.

This PR's own bullet has no issue/pull-request number yet, since it
is not opened until after this commit (the branch's push and draft-PR
creation happen separately); add the number once it exists, following
the existing precedent of the unnumbered CMake-hotfix bullet in the
0.6.1 section.

Contributors are cross-referenced against the community PRs/issues
whose features this campaign actually reimplements, not commit
trailers: none of the campaign's commits carry Co-authored-by lines,
so that lookup path (as originally planned) returns nothing.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* docs: rewrite README for the 1.0.0 line

Restructure the README around what a first-time user needs in order:
what the plugin does, which distro to install, how to configure it,
how to run it, and how to build and test it.

New sections: CI badges and a supported-distro/branch table, a
feature summary, a distro-neutral installation section (the old one
told users to put Rolling on Ubuntu 22.04), a control-mode reference
listing the control-table item each mode requires of the servo
model, notes on the framework-side rw_rate and is_async attributes,
a migration note for 0.x URDFs, container-based build and test
instructions, and the Mergify backport workflow for contributors.

The parameter tables, the gearing/offset, error-tolerance and
read-latency sections and the mode-switch description are carried
over from the previous pass, which verified them against the
source. Three stale claims are dropped: the Ubuntu 22.04 line, the
quoted example diff that contradicted the usb_port deprecation
notice, and an unmeasured assertion about the loop rate a 4-5 servo
bus sustains.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* ci: clone the examples repository's main branch in the nightly job

The downstream-examples job cloned dynamixel_hardware_examples with
-b ${{ matrix.branch }}, but that repository has exactly one branch,
main. Every matrix entry (humble, jazzy, lyrical, rolling) therefore
failed at the checkout step with "Remote branch <distro> not found in
upstream origin", so the downstream build has never actually run.

Clone main unconditionally. The two repositories deliberately use
different branch models -- one branch per distro here, a single main
branch there -- so the fix is to stop parameterising the clone rather
than to create branches in the examples repository. A comment records
that asymmetry at the clone, since it is what produced the bug.

matrix.branch now appears only on the two dynamixel_hardware
checkouts, which are the only branch-per-distro operations in the
workflow.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* docs: correct the README's release and build guidance

The apt line implied a binary for every supported distro. rosdistro
master has no release section for dynamixel_hardware in humble, so
ros-humble-dynamixel-hardware does not exist; name the distros that
do have one (jazzy, kilted, lyrical, rolling) and send humble users
to the source build.

Kilted had fallen out of the document entirely. It is deliberately
not part of the 1.0.0 line, but it is a supported distro with a
released 0.6.0 binary, so say so rather than leaving its users with
no answer.

The source build cloned -b $ROS_DISTRO, which fails outright on
lyrical (that branch is cut as part of this release) and silently
yields the 0.6.x line on humble and jazzy, neither of which contains
anything this README describes. Clone the default rolling branch
instead and explain why one branch serves every distro.

Also: baud_rate is parsed and range-checked before the dummy driver
takes over, so it is not "irrelevant" under use_dummy; only plain
position mode clamps to +-pi, so name the three modes that do not;
and split the container block, whose in-container lines carried a
'#' prompt that made them read as shell comments.

The launch-test paragraph now describes a test that is present on
this branch, so it names the file, the isolation runner and the
skip message verbatim.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* docs: correct the changelog contributor list and #113 bullet

The contributor list was derived on the premise that no commit in
this repository carries a Co-authored-by trailer. It does: a
per-commit sweep of the Forthcoming range (f008ad6..HEAD) yields
exactly eight names, and 010f774 names Ignacio Davila in a trailer.

Fix two defects against that ground truth. IDavGal was the GitHub
handle, not the name in the trailer. Betacrucis has no git evidence
anywhere in the repository -- author, committer or trailer -- and is
an issue-only reporter, which is the same reason the list already
excludes BenKlee; applying that rule to one name and not the other
was the actual inconsistency.

The #113 bullet also listed every finalized parameter except
write_error_tolerance, which the code enforces and the README
documents as a first-class parameter.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

* ci: make the Mergify backport-action note stand alone

The header comment pointed at the README for the korthout/backport-
action fallback, but the README rewrite deliberately dropped that
line: it is a maintainer contingency, not user-facing documentation.
That left "see README" resolving to nothing.

State the fallback in the comment itself, where the person who would
act on it is already reading.

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>

---------

Signed-off-by: Yutaka Kondo <yutaka.kondo@youtalk.jp>
Co-authored-by: Maverobot <Maverobot@users.noreply.github.com>
Co-authored-by: Tacha-S <13605820+Tacha-S@users.noreply.github.com>
Co-authored-by: Ignacio Davila <99193391+IDavGal@users.noreply.github.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant