Skip to content

Propagate feedback and command errors through the ROS hardware interface - #10

Merged
justagist merged 6 commits into
mainfrom
ros-error-propagation-main
Oct 15, 2026
Merged

justagist merged 6 commits into
mainfrom
ros-error-propagation-main

Conversation

@justagist

@justagist justagist commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Same six commits as #9, which was reviewed and merged but landed on the freshness-snapshots
branch instead of main. Cherry-picked onto main; the resulting tree is identical.

  • controlJoints() and controlEndPose() hold a mutex for their three frames, so concurrent
    callers cannot interleave into a target the arm never received from either.
  • write() validates every command before sending anything and returns ERROR when a CAN send
    fails. on_activate() checks its sends too.
  • While active, read() returns ERROR when joint or gripper feedback goes stale. The
    controller manager then stops the controllers; the arm holds its last target.
  • on_error() releases the CAN session so the component can be reconfigured; motors untouched.

Conversions live in piper_cpp_ros/command_conversion.hpp with an ament_cmake_gtest target.

Summary by Sourcery

Harden the ROS hardware interface by propagating feedback, validation, and CAN transmission errors while preserving safe arm behavior and recovery.

New Features:

  • Propagate stale-feedback and command-transmission failures through the ROS hardware interface so controller operation stops safely.

Bug Fixes:

  • Prevent concurrent multi-frame joint and end-pose commands from interleaving on the CAN bus.
  • Reject invalid command values before transmission and preserve the arm's last target when hardware errors occur.
  • Release the CAN session after entering the hardware error state so the component can be reconfigured.

Enhancements:

  • Require fresh joint and gripper feedback during activation and while the component is active.
  • Centralize joint and gripper command conversions with explicit wire-value validation.

Documentation:

  • Document the hardware interface behavior for stale feedback and invalid or failed commands.

Tests:

  • Add coverage for non-interleaved concurrent joint command frames and command conversion validation.

Copilot AI balanced review requested due to automatic review settings October 15, 2026 11:29
@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens the ROS hardware interface by serializing multipart arm commands, validating all outgoing targets before transmission, propagating CAN and stale-feedback failures into lifecycle errors, and releasing the CAN session during error recovery while preserving the arm’s last target.

Sequence diagram for validated ROS command transmission

sequenceDiagram
    participant ControllerManager
    participant PiperHardware
    participant PiperInterface
    participant CAN

    ControllerManager->>PiperHardware: write()
    PiperHardware->>PiperHardware: jointCommandToMillideg()
    PiperHardware->>PiperHardware: gripperCommandToMicrometres()
    alt command validation fails
        PiperHardware-->>ControllerManager: ERROR
        Note over PiperHardware,CAN: No command is sent
    else commands valid
        PiperHardware->>PiperInterface: controlJoints()
        PiperInterface->>CAN: send joint frames
        CAN-->>PiperInterface: success or failure
        alt CAN send fails
            PiperInterface-->>PiperHardware: false
            PiperHardware-->>ControllerManager: ERROR
        else joint send succeeds
            PiperHardware->>PiperInterface: controlGripper()
            PiperInterface->>CAN: send gripper command
            CAN-->>PiperInterface: success or failure
            PiperHardware-->>ControllerManager: OK or ERROR
        end
    end
Loading

File-Level Changes

Change Details Files
Prevent multipart motion commands from interleaving across concurrent callers.
  • Added a mutex covering all frames emitted by joint and Cartesian control commands.
  • Added a stress test that verifies complete, non-mixed three-frame joint command groups on the bus.
piper_cpp/include/piper_cpp/interface/piper_interface_v2.h
piper_cpp/src/interface/piper_interface_v2.cpp
piper_cpp/test/piper_interface_v2_test.cpp
Propagate transport and feedback failures through the lifecycle hardware interface.
  • Checked enable, motion-mode, homing, zeroing, and command send results during activation and write.
  • Returned errors for stale active joint or gripper feedback, allowing controller shutdown.
  • Added error-state cleanup that closes the CAN session while leaving motors enabled to hold the last target.
piper_cpp_ros/include/piper_cpp_ros/piper_hardware_interface.hpp
piper_cpp_ros/src/piper_hardware_interface.cpp
piper_cpp_ros/README.md
Validate and centralize ROS-to-wire command conversions before transmission.
  • Extracted joint and gripper conversion helpers with finite-value and range validation.
  • Validated the complete command set before sending any joint or gripper frames.
  • Added focused conversion unit tests and wired them into the ament test build.
piper_cpp_ros/include/piper_cpp_ros/command_conversion.hpp
piper_cpp_ros/src/piper_hardware_interface.cpp
piper_cpp_ros/test/command_conversion_test.cpp
piper_cpp_ros/CMakeLists.txt
piper_cpp_ros/package.xml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e818d108-f0c6-422e-a86f-f187bef91ce4
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@justagist
justagist merged commit 3e50e32 into main Oct 15, 2026
6 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Lifecycle recovery and motor-hold behavior require human validation with ros2_control and the physical arm.

0 open findings

What changed in this PR

Brings the previously reviewed #9 changes onto main, hardening the ROS hardware interface’s command handling and error recovery.

Changes:

  • Serialize multi-frame commands to prevent concurrent callers from interleaving targets.
  • Validate commands, propagate transmission and stale-feedback errors, and release CAN sessions on error.
  • Add conversion and concurrency tests, build integration, and behavior documentation.
File Description
piper_cpp/​test/​piper_interface_v2_test.cpp Tests concurrent joint-command serialization.
piper_cpp/​src/​interface/​piper_interface_v2.cpp Locks multi-frame joint and pose commands.
piper_cpp/​include/​piper_cpp/​interface/​piper_interface_v2.h Declares the multi-frame command mutex.
piper_cpp_ros/​test/​command_conversion_test.cpp Tests command conversion and validation.
piper_cpp_ros/​src/​piper_hardware_interface.cpp Propagates errors and cleans up failed sessions.
piper_cpp_ros/​README.md Documents feedback and command error behavior.
piper_cpp_ros/​package.xml Adds the GoogleTest dependency.
piper_cpp_ros/​include/​piper_cpp_ros/​piper_hardware_interface.hpp Declares the lifecycle error callback.
piper_cpp_ros/​include/​piper_cpp_ros/​command_conversion.hpp Centralizes validated command conversions.
piper_cpp_ros/​CMakeLists.txt Registers conversion tests.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="piper_cpp/src/interface/piper_interface_v2.cpp" line_range="671" />
<code_context>
         joint_1_millideg, joint_2_millideg, joint_3_millideg,
         joint_4_millideg, joint_5_millideg, joint_6_millideg,
     };
+    std::lock_guard<std::mutex> lock(multipart_tx_mutex_);
     msg.type = ArmMsgType::JointCtrl12;
     if (!sendPiperMsg(msg))
</code_context>
<issue_to_address>
**Failed writes apply partial targets**

When a CAN send fails after an earlier joint frame or actuator target in the same write was accepted, `controlJoints()` can return false after earlier joint frames were sent, and `write()` sends the joints before the gripper. Returning `ERROR` and closing the session cannot undo those sends, so the arm receives a partial command instead of holding its last complete target, contrary to the README contract.

Make failed joint and gripper writes leave or restore the last complete target rather than relying on returning `ERROR` or closing the session.

Also at `piper_cpp_ros/src/piper_hardware_interface.cpp:508`, `piper_cpp_ros/src/piper_hardware_interface.cpp:511-513`, `piper_cpp_ros/src/piper_hardware_interface.cpp:516`, `piper_cpp_ros/src/piper_hardware_interface.cpp:518`, `piper_cpp_ros/README.md:248`.
</issue_to_address>

### Comment 2
<location path="piper_cpp_ros/include/piper_cpp_ros/command_conversion.hpp" line_range="36" />
<code_context>
+/// is far beyond any jaw.
+inline std::optional<int32_t> gripperCommandToMicrometres(double m)
+{
+    if (!std::isfinite(m) || std::fabs(m) > 1.0)
+        return std::nullopt;
+    return static_cast<int32_t>(std::lround(m * kMToUm));
</code_context>
<issue_to_address>
**Out-of-range jaw targets are sent**

When a finite gripper command falls outside the configured 0–0.035 m joint limits but remains within ±1 m, `gripperCommandToMicrometres()` accepts the command, including negative positions, and `write()` sends the out-of-range jaw target instead of returning an error.

Validate gripper commands against the configured 0–0.035 m joint limits before converting them.
</issue_to_address>

### Comment 3
<location path="piper_cpp/test/piper_interface_v2_test.cpp" line_range="234-241" />
<code_context>
+    {
+        pollfd pfd{bus.peer, POLLIN, 0};
+        can_frame f{};
+        if (poll(&pfd, 1, 2000) <= 0 || ::recv(bus.peer, &f, sizeof(f), 0) != static_cast<ssize_t>(sizeof(f)))
+        {
+            received_all = false;
+            break;
+        }
+        frames.push_back(f);
+    }
+    a.join();
+    b.join();
+    ASSERT_TRUE(received_all) << "bus went quiet after " << frames.size() << " frames";
</code_context>
<issue_to_address>
**Interleave test can hang on receive error**

When `poll` or `recv` fails before all expected frames have been consumed while sender threads still have frames to write, the receiver breaks out of its loop, then joins both sender threads while their blocking socket writes can still be filling the unread socket buffer. The senders block once that buffer fills, so the joins never return and the test hangs instead of reporting the receive failure.

On receive failure, stop the senders or close the sending socket and ensure blocked writes are released before joining the threads.
</issue_to_address>

### Comment 4
<location path="piper_cpp/test/piper_interface_v2_test.cpp" line_range="234-236" />
<code_context>
+    {
+        pollfd pfd{bus.peer, POLLIN, 0};
+        can_frame f{};
+        if (poll(&pfd, 1, 2000) <= 0 || ::recv(bus.peer, &f, sizeof(f), 0) != static_cast<ssize_t>(sizeof(f)))
+        {
+            received_all = false;
+            break;
+        }
</code_context>
<issue_to_address>
**Slow sends fail concurrency test**

When the sender threads are not scheduled or produce no frame for more than two seconds, `ConcurrentJointCommandsDoNotInterleave` marks `received_all` false after any two-second gap between frames, then fails at line 243 even if the correct sender threads resume and complete afterward. A heavily loaded runner can therefore fail this test without a command or interleaving defect.

Use a completion-aware wait or a more generous overall deadline so a temporary scheduling pause does not fail the test.
</issue_to_address>

### Comment 5
<location path="piper_cpp/test/piper_interface_v2_test.cpp" line_range="252-254" />
<code_context>
+        ASSERT_EQ(frames[i].can_id, 0x155u) << "triple " << i / 3 << " does not start with 0x155";
+        ASSERT_EQ(frames[i + 1].can_id, 0x156u);
+        ASSERT_EQ(frames[i + 2].can_id, 0x157u);
+        const auto target = firstJoint(frames[i]);
+        ASSERT_EQ(firstJoint(frames[i + 1]), target) << "triple " << i / 3 << " mixes two callers";
+        ASSERT_EQ(firstJoint(frames[i + 2]), target) << "triple " << i / 3 << " mixes two callers";
+    }
+}
</code_context>
<issue_to_address>
**Wrong targets pass concurrency test**

When `controlJoints()` sends the same constant value for every request, `ConcurrentJointCommandsDoNotInterleave` derives `target` from the first observed frame and compares only the other frames against it. If `controlJoints()` serializes the same default value for both callers instead of their requested targets, every comparison still passes.

Assert that each triple's target is one of the two requested values, as well as checking consistency across its frames.
</issue_to_address>

Sourcery assessment

Approval pending. 5 findings to address first.

Blocking findings: piper_cpp/src/interface/piper_interface_v2.cpp:671, piper_cpp_ros/include/piper_cpp_ros/command_conversion.hpp:36, piper_cpp/test/piper_interface_v2_test.cpp:241, piper_cpp/test/piper_interface_v2_test.cpp:236, piper_cpp/test/piper_interface_v2_test.cpp:254


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

joint_1_millideg, joint_2_millideg, joint_3_millideg,
joint_4_millideg, joint_5_millideg, joint_6_millideg,
};
std::lock_guard<std::mutex> lock(multipart_tx_mutex_);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Failed writes apply partial targets

When a CAN send fails after an earlier joint frame or actuator target in the same write was accepted, controlJoints() can return false after earlier joint frames were sent, and write() sends the joints before the gripper. Returning ERROR and closing the session cannot undo those sends, so the arm receives a partial command instead of holding its last complete target, contrary to the README contract.

Make failed joint and gripper writes leave or restore the last complete target rather than relying on returning ERROR or closing the session.

Also at piper_cpp_ros/src/piper_hardware_interface.cpp:508, piper_cpp_ros/src/piper_hardware_interface.cpp:511-513, piper_cpp_ros/src/piper_hardware_interface.cpp:516, piper_cpp_ros/src/piper_hardware_interface.cpp:518, piper_cpp_ros/README.md:248.

Prompt for AI agents
In `piper_cpp/src/interface/piper_interface_v2.cpp` at line 671:

**Failed writes apply partial targets**

When a CAN send fails after an earlier joint frame or actuator target in the same write was accepted, `controlJoints()` can return false after earlier joint frames were sent, and `write()` sends the joints before the gripper. Returning `ERROR` and closing the session cannot undo those sends, so the arm receives a partial command instead of holding its last complete target, contrary to the README contract.

Make failed joint and gripper writes leave or restore the last complete target rather than relying on returning `ERROR` or closing the session.

Also at `piper_cpp_ros/src/piper_hardware_interface.cpp:508`, `piper_cpp_ros/src/piper_hardware_interface.cpp:511-513`, `piper_cpp_ros/src/piper_hardware_interface.cpp:516`, `piper_cpp_ros/src/piper_hardware_interface.cpp:518`, `piper_cpp_ros/README.md:248`.

/// is far beyond any jaw.
inline std::optional<int32_t> gripperCommandToMicrometres(double m)
{
if (!std::isfinite(m) || std::fabs(m) > 1.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Out-of-range jaw targets are sent

When a finite gripper command falls outside the configured 0–0.035 m joint limits but remains within ±1 m, gripperCommandToMicrometres() accepts the command, including negative positions, and write() sends the out-of-range jaw target instead of returning an error.

Validate gripper commands against the configured 0–0.035 m joint limits before converting them.

Prompt for AI agents
In `piper_cpp_ros/include/piper_cpp_ros/command_conversion.hpp` at line 36:

**Out-of-range jaw targets are sent**

When a finite gripper command falls outside the configured 0–0.035 m joint limits but remains within ±1 m, `gripperCommandToMicrometres()` accepts the command, including negative positions, and `write()` sends the out-of-range jaw target instead of returning an error.

Validate gripper commands against the configured 0–0.035 m joint limits before converting them.

Comment on lines +234 to +241
if (poll(&pfd, 1, 2000) <= 0 || ::recv(bus.peer, &f, sizeof(f), 0) != static_cast<ssize_t>(sizeof(f)))
{
received_all = false;
break;
}
frames.push_back(f);
}
a.join();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Interleave test can hang on receive error

When poll or recv fails before all expected frames have been consumed while sender threads still have frames to write, the receiver breaks out of its loop, then joins both sender threads while their blocking socket writes can still be filling the unread socket buffer. The senders block once that buffer fills, so the joins never return and the test hangs instead of reporting the receive failure.

On receive failure, stop the senders or close the sending socket and ensure blocked writes are released before joining the threads.

Prompt for AI agents
In `piper_cpp/test/piper_interface_v2_test.cpp` at lines 234-241:

**Interleave test can hang on receive error**

When `poll` or `recv` fails before all expected frames have been consumed while sender threads still have frames to write, the receiver breaks out of its loop, then joins both sender threads while their blocking socket writes can still be filling the unread socket buffer. The senders block once that buffer fills, so the joins never return and the test hangs instead of reporting the receive failure.

On receive failure, stop the senders or close the sending socket and ensure blocked writes are released before joining the threads.

Comment on lines +234 to +236
if (poll(&pfd, 1, 2000) <= 0 || ::recv(bus.peer, &f, sizeof(f), 0) != static_cast<ssize_t>(sizeof(f)))
{
received_all = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Slow sends fail concurrency test

When the sender threads are not scheduled or produce no frame for more than two seconds, ConcurrentJointCommandsDoNotInterleave marks received_all false after any two-second gap between frames, then fails at line 243 even if the correct sender threads resume and complete afterward. A heavily loaded runner can therefore fail this test without a command or interleaving defect.

Use a completion-aware wait or a more generous overall deadline so a temporary scheduling pause does not fail the test.

Prompt for AI agents
In `piper_cpp/test/piper_interface_v2_test.cpp` at lines 234-236:

**Slow sends fail concurrency test**

When the sender threads are not scheduled or produce no frame for more than two seconds, `ConcurrentJointCommandsDoNotInterleave` marks `received_all` false after any two-second gap between frames, then fails at line 243 even if the correct sender threads resume and complete afterward. A heavily loaded runner can therefore fail this test without a command or interleaving defect.

Use a completion-aware wait or a more generous overall deadline so a temporary scheduling pause does not fail the test.

Comment on lines +252 to +254
const auto target = firstJoint(frames[i]);
ASSERT_EQ(firstJoint(frames[i + 1]), target) << "triple " << i / 3 << " mixes two callers";
ASSERT_EQ(firstJoint(frames[i + 2]), target) << "triple " << i / 3 << " mixes two callers";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Wrong targets pass concurrency test

When controlJoints() sends the same constant value for every request, ConcurrentJointCommandsDoNotInterleave derives target from the first observed frame and compares only the other frames against it. If controlJoints() serializes the same default value for both callers instead of their requested targets, every comparison still passes.

Assert that each triple's target is one of the two requested values, as well as checking consistency across its frames.

Prompt for AI agents
In `piper_cpp/test/piper_interface_v2_test.cpp` at lines 252-254:

**Wrong targets pass concurrency test**

When `controlJoints()` sends the same constant value for every request, `ConcurrentJointCommandsDoNotInterleave` derives `target` from the first observed frame and compares only the other frames against it. If `controlJoints()` serializes the same default value for both callers instead of their requested targets, every comparison still passes.

Assert that each triple's target is one of the two requested values, as well as checking consistency across its frames.

@justagist
justagist deleted the ros-error-propagation-main branch October 19, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants