From 6737029190b9faa0f16e5262f3b1f0caedf0dcd2 Mon Sep 17 00:00:00 2001 From: Abdul Date: Sat, 8 Nov 2025 18:23:01 -0800 Subject: [PATCH 01/55] Created base package for the probe_stepper_communication --- .../src/probe_stepper_communication/LICENSE | 202 ++++++++++++++++++ .../probe_stepper_communication/package.xml | 20 ++ .../probe_stepper_communication/__init__.py | 0 .../resource/probe_stepper_communication | 0 .../src/probe_stepper_communication/setup.cfg | 4 + .../src/probe_stepper_communication/setup.py | 29 +++ .../test/test_copyright.py | 25 +++ .../test/test_flake8.py | 25 +++ .../test/test_pep257.py | 23 ++ 9 files changed, 328 insertions(+) create mode 100644 ros2_ws/src/probe_stepper_communication/LICENSE create mode 100644 ros2_ws/src/probe_stepper_communication/package.xml create mode 100644 ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py create mode 100644 ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication create mode 100644 ros2_ws/src/probe_stepper_communication/setup.cfg create mode 100644 ros2_ws/src/probe_stepper_communication/setup.py create mode 100644 ros2_ws/src/probe_stepper_communication/test/test_copyright.py create mode 100644 ros2_ws/src/probe_stepper_communication/test/test_flake8.py create mode 100644 ros2_ws/src/probe_stepper_communication/test/test_pep257.py diff --git a/ros2_ws/src/probe_stepper_communication/LICENSE b/ros2_ws/src/probe_stepper_communication/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ros2_ws/src/probe_stepper_communication/package.xml b/ros2_ws/src/probe_stepper_communication/package.xml new file mode 100644 index 0000000..330b6c6 --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/package.xml @@ -0,0 +1,20 @@ + + + + probe_stepper_communication + 0.0.0 + TODO: Package description + devasilmy + Apache-2.0 + + pymodbus + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication b/ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication new file mode 100644 index 0000000..e69de29 diff --git a/ros2_ws/src/probe_stepper_communication/setup.cfg b/ros2_ws/src/probe_stepper_communication/setup.cfg new file mode 100644 index 0000000..4fb220b --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/probe_stepper_communication +[install] +install_scripts=$base/lib/probe_stepper_communication diff --git a/ros2_ws/src/probe_stepper_communication/setup.py b/ros2_ws/src/probe_stepper_communication/setup.py new file mode 100644 index 0000000..d18043d --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/setup.py @@ -0,0 +1,29 @@ +from setuptools import find_packages, setup + +package_name = 'probe_stepper_communication' + +setup( + name=package_name, + version='0.0.0', + packages=find_packages(exclude=['test']), + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + ], + install_requires=['setuptools'], + zip_safe=True, + maintainer='devasilmy', + maintainer_email='devasilmy@todo.todo', + description='TODO: Package description', + license='Apache-2.0', + extras_require={ + 'test': [ + 'pytest', + ], + }, + entry_points={ + 'console_scripts': [ + ], + }, +) diff --git a/ros2_ws/src/probe_stepper_communication/test/test_copyright.py b/ros2_ws/src/probe_stepper_communication/test/test_copyright.py new file mode 100644 index 0000000..97a3919 --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/test/test_copyright.py @@ -0,0 +1,25 @@ +# Copyright 2015 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_copyright.main import main +import pytest + + +# Remove the `skip` decorator once the source file(s) have a copyright header +@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.') +@pytest.mark.copyright +@pytest.mark.linter +def test_copyright(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found errors' diff --git a/ros2_ws/src/probe_stepper_communication/test/test_flake8.py b/ros2_ws/src/probe_stepper_communication/test/test_flake8.py new file mode 100644 index 0000000..27ee107 --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/test/test_flake8.py @@ -0,0 +1,25 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_flake8.main import main_with_errors +import pytest + + +@pytest.mark.flake8 +@pytest.mark.linter +def test_flake8(): + rc, errors = main_with_errors(argv=[]) + assert rc == 0, \ + 'Found %d code style errors / warnings:\n' % len(errors) + \ + '\n'.join(errors) diff --git a/ros2_ws/src/probe_stepper_communication/test/test_pep257.py b/ros2_ws/src/probe_stepper_communication/test/test_pep257.py new file mode 100644 index 0000000..b234a38 --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/test/test_pep257.py @@ -0,0 +1,23 @@ +# Copyright 2015 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_pep257.main import main +import pytest + + +@pytest.mark.linter +@pytest.mark.pep257 +def test_pep257(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found code style errors / warnings' From 5f19131263e574c1c633da914eccc0d522e952dd Mon Sep 17 00:00:00 2001 From: Abdul Date: Sat, 8 Nov 2025 19:13:54 -0800 Subject: [PATCH 02/55] created node script for the drive with basic action-server functionality (feedback not implemented) --- .../probe_stepper_node.py | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py new file mode 100644 index 0000000..970e9dc --- /dev/null +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -0,0 +1,203 @@ +# "drive" in the comments refers to stepper motor drive, specifically the LMDCE421 + +import rclpy +from rclpy.node import Node +from rclpy.action import ActionServer + +from servo_interfaces.action import Servo + +from pymodbus.client import ModbusTcpClient + +# global variables +STEPS_PER_REVOLUTION = 51200 # default steps per revolution + +# drive function registers (in use) +MOVE_ABSOLUTE_REGISTER = 0x0043 +ERROR_FLAG_REGISTER = 0x001F +ERROR_VARIABLE_REGISTER = 0x0021 +DRIVE_ENABLE_REGISTER = 0x001C + + +MOTOR_IP = '192.168.33.1' # default drive IP +DRIVE_PORT = 502 # Standard MODBUS TCP port + +class SwitchCaseOperation: + + def __init__(self, logger): + self.logger_ = logger + + def operation_0(self): + self.logger_.info("[SHUTDOWN] [start]") + + # disconnect from the drive + self.client.close() + + # do: disconnect power to drive + + self.logger_.info("[SHUTDOWN] [success]") + return True + + def operation_1(self): + self.logger_.info("[BOOT] [start]") + + # establish connection to drive + self.client = ModbusTcpClient(DRIVE_IP, port=DRIVE_PORT) + self.client.connect() + if not(self.client.connected): + self.logger_.info("[BOOT] [error]") + return False + + # drive disable (ensure no motion through interrupted action requests) + response = self.client.write_registers(DRIVE_ENABLE_REGISTER, [0]) + if response.isError(): + self.logger_.info("[BOOT] [drive disable] [error]") + return False + else: + self.logger_.info("[BOOT] [drive disable] [success]") + + # do checks + # do drive checks + response = self.client.read_holding_registers(ERROR_FLAG_REGISTER) + if response.isError(): + self.logger_.info("[BOOT] [error flag read] [error]") + response = self.client.read_holding_registers(ERROR_VARIABLE_REGISTER) + if response.isError(): + self.logger_.info("[BOOT] [error variable read] [error]") + return False + else: + self.logger_info(f"[BOOT] [error variable read] [success] {response.registers}") + return False + else: + self.logger_.info(f"[BOOT] [error flag read] [success] {response.registers}") + + # do: do log checks + + #drive enable + response = self.client.write_registers(DRIVE_ENABLE_REGISTER, [1]) + if response.isError(): + self.logger_.info("[BOOT] [drive enable] [error]") + return False + else: + self.logger_.info("[BOOT] [drive enable] [success]") + + self.logger_.info("[BOOT] [success]") + + return True + + def operation_2(self): + self.logger_.info("[BOOT,TEST] [start]") + + if self.operation_1(): + if self.operation_5(): + self.logger_.info("[BOOT,TEST] [success]") + return True + + self.logger_.info("[BOOT,TEST] [error]") + + return False + + def operation_3(self): + self.logger_.info("[BOOT,TEST,REACH] [start]") + + if self.operation_1(): + if self.operation_5(): + if self.operation_6(): + self.logger_.info("[BOOT,TEST,REACH] [success]") + return True + + self.logger_.info("[BOOT,TEST,REACH] [success]") + return False + + def operation_4(self): + self.logger_.info("[BOOT,TEST,REACH,READ] [start]") + + if self.operation_1(): + if self.operation_5(): + if self.operation_6(): + if self.operation_7(): + self.logger_.info("[BOOT,TEST,REACH,READ] [success]") + return True + + self.logger_.info("[BOOT,TEST,REACH,READ] [error]") + return False + + def operation_5(self): + self.logger_.info("[TEST] [start]") + + self.logger_.info("[TEST] [success]") + return True + + def operation_6(self): + self.logger_.info("[REACH] [start]") + high = (STEPS_PER_REVOLUTION >> 16) & 0xFFFF + low = STEPS_PER_REVOLUTION & 0xFFFF + response = self.client.write_registers(MOVE_ABSOLUTE_REGISTER, [low, high]) + if response.isError(): + self.logger_.info("[REACH] [error]") + return False + else: + self.logger_.info("[REACH] [success]") + return True + + def operation_7(self): + self.logger_.info("[READ] [start]") + + # during read operation the drive essentially does nothing. The thermal probe makes readings at this point. + # this operation is just to publish the state of the drive which is in "READ operation" + + self.logger_.info("[READ] [success]") + return True + + def operation_8(self): + self.logger_.info("[RETRACT] [start]") + + high = ((-1 * STEPS_PER_REVOLUTION) >> 16) & 0xFFFF + low = (-1 * STEPS_PER_REVOLUTION) & 0xFFFF + response = self.client.write_registers(MOVE_ABSOLUTE_REGISTER, [low, high]) + if response.isError(): + self.logger_.info("[RETRACT] [error]") + return False + else: + self.logger_.info("[RETRACT] [success]") + + return True + + def do_operation(self, operation_ID): + operation_name = "operation_" + str(operation_ID) + operation = getattr(self, operation_name) + return operation() + + +class ProbeStepperNode(Node): + + def __init__(self): + super().__init__("probe_stepper") + + # do: servo parameters. global variables must be changed to node parameters where needed. + + # do: servo_feedback topic + + #servo_action_server + self.servo_operation = ActionServer(self, Servo, 'probe_stepper_operation', self.servo_callback) + + self.switchCaseOperation = SwitchCaseOperation(self.get_logger()) #instantiate switchCaseOperation once for servo_callback to use multiple times + + def servo_callback(self, goal_handle): + + operation_ID = goal_handle.request.operation + + self.get_logger().info(f"servo: operation[{operation_ID}]") + + # call operations based on received operation ID + result = Servo.Result() + result.success = self.switchCaseOperation.do_operation(operation_ID) + goal_handle.succeed() + return result + +def main(args=None): + rclpy.init(args=args) + node = ProbeStepperNode() + rclpy.spin(node) + +if __name__ == '__main__': + main() \ No newline at end of file From 0a78e4e1bfe06951c52bc5acda471d60e808cbf4 Mon Sep 17 00:00:00 2001 From: Abdul Date: Sat, 8 Nov 2025 19:22:42 -0800 Subject: [PATCH 03/55] custom action interface for Probe_stepper_node_operation action-server --- ros2_ws/src/msg_interface/CMakeLists.txt | 5 +++++ .../src/msg_interface/action/Probe_Stepper_Operation.action | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index 7d22076..660804b 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -12,6 +12,11 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" ) + +rosidl_generate_interfaces(${PROJECT_NAME} + "action/Probe_Stepper_Operation.action" +) + if(BUILD_TESTING) find_package(ament_lint_auto REQUIRED) # the following line skips the linter which checks for copyrights diff --git a/ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action b/ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action new file mode 100644 index 0000000..eacea70 --- /dev/null +++ b/ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action @@ -0,0 +1,5 @@ +int32 operation +--- +bool success +--- +int32 status \ No newline at end of file From 37f4c95a5f764786762b974967794a4bb4c80b12 Mon Sep 17 00:00:00 2001 From: Abdul Date: Sat, 8 Nov 2025 19:35:11 -0800 Subject: [PATCH 04/55] 1. Update 'servo' reference to 'drive' 2. Included custom msg interface for action-server 3. Included entry point for node --- .../probe_stepper_communication/package.xml | 1 + .../probe_stepper_node.py | 18 ++++++++---------- .../src/probe_stepper_communication/setup.py | 1 + 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ros2_ws/src/probe_stepper_communication/package.xml b/ros2_ws/src/probe_stepper_communication/package.xml index 330b6c6..897934e 100644 --- a/ros2_ws/src/probe_stepper_communication/package.xml +++ b/ros2_ws/src/probe_stepper_communication/package.xml @@ -8,6 +8,7 @@ Apache-2.0 pymodbus + msg_interface ament_copyright ament_flake8 diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py index 970e9dc..9dcb918 100644 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -4,7 +4,7 @@ from rclpy.node import Node from rclpy.action import ActionServer -from servo_interfaces.action import Servo +from msg_interface.action import Probe_Stepper_Operation from pymodbus.client import ModbusTcpClient @@ -173,23 +173,21 @@ class ProbeStepperNode(Node): def __init__(self): super().__init__("probe_stepper") - # do: servo parameters. global variables must be changed to node parameters where needed. + # do: drive parameters. global variables must be changed to node parameters where needed. - # do: servo_feedback topic + # do: drive_feedback topic - #servo_action_server - self.servo_operation = ActionServer(self, Servo, 'probe_stepper_operation', self.servo_callback) + #drive_action_server + self.drive_operation = ActionServer(self, Probe_Stepper_Operation, 'probe_stepper_operation', self.drive_operation_callback) - self.switchCaseOperation = SwitchCaseOperation(self.get_logger()) #instantiate switchCaseOperation once for servo_callback to use multiple times + self.switchCaseOperation = SwitchCaseOperation(self.get_logger()) #instantiate switchCaseOperation once for drive_operation_callback to use multiple times - def servo_callback(self, goal_handle): + def drive_operation_callback(self, goal_handle): operation_ID = goal_handle.request.operation - self.get_logger().info(f"servo: operation[{operation_ID}]") - # call operations based on received operation ID - result = Servo.Result() + result = Probe_Stepper_Operation.Result() result.success = self.switchCaseOperation.do_operation(operation_ID) goal_handle.succeed() return result diff --git a/ros2_ws/src/probe_stepper_communication/setup.py b/ros2_ws/src/probe_stepper_communication/setup.py index d18043d..2814357 100644 --- a/ros2_ws/src/probe_stepper_communication/setup.py +++ b/ros2_ws/src/probe_stepper_communication/setup.py @@ -24,6 +24,7 @@ }, entry_points={ 'console_scripts': [ + "probe_stepper = probe_stepper_communication.probe_stepper_node:main ", ], }, ) From c2896f450edc4c4ee3d1a670a118bb72cb4620ed Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 9 Jan 2026 21:25:32 -0800 Subject: [PATCH 05/55] action interface name corrected to match syntax, updated references too --- ros2_ws/src/msg_interface/CMakeLists.txt | 3 --- ...e_Stepper_Operation.action => ProbeStepperOperation.action} | 0 .../probe_stepper_communication/probe_stepper_node.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) rename ros2_ws/src/msg_interface/action/{Probe_Stepper_Operation.action => ProbeStepperOperation.action} (100%) diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index 660804b..05d547a 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -11,9 +11,6 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" -) - -rosidl_generate_interfaces(${PROJECT_NAME} "action/Probe_Stepper_Operation.action" ) diff --git a/ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action similarity index 100% rename from ros2_ws/src/msg_interface/action/Probe_Stepper_Operation.action rename to ros2_ws/src/msg_interface/action/ProbeStepperOperation.action diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py index 9dcb918..be59f46 100644 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -4,7 +4,7 @@ from rclpy.node import Node from rclpy.action import ActionServer -from msg_interface.action import Probe_Stepper_Operation +from msg_interface.action import ProbeStepperOperation from pymodbus.client import ModbusTcpClient From 7c628500cbf5afff514a10f0697f7d956ce7166e Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 9 Jan 2026 21:59:17 -0800 Subject: [PATCH 06/55] Updated a faulty reference --- .../probe_stepper_communication/probe_stepper_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py index be59f46..6064a75 100644 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -178,7 +178,7 @@ def __init__(self): # do: drive_feedback topic #drive_action_server - self.drive_operation = ActionServer(self, Probe_Stepper_Operation, 'probe_stepper_operation', self.drive_operation_callback) + self.drive_operation = ActionServer(self, ProbeStepperOperation, 'Probe_stepper_operation', self.drive_operation_callback) self.switchCaseOperation = SwitchCaseOperation(self.get_logger()) #instantiate switchCaseOperation once for drive_operation_callback to use multiple times From 7386a811195dafc5b24dd4bad065baa20aa850c6 Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 9 Jan 2026 22:07:32 -0800 Subject: [PATCH 07/55] Updated a faulty reference --- .../probe_stepper_communication/probe_stepper_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py index 6064a75..61a2edb 100644 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -187,7 +187,7 @@ def drive_operation_callback(self, goal_handle): operation_ID = goal_handle.request.operation # call operations based on received operation ID - result = Probe_Stepper_Operation.Result() + result = ProbeStepperOperation.Result() result.success = self.switchCaseOperation.do_operation(operation_ID) goal_handle.succeed() return result From cb0f4cd5ea91757db707a635857892ebbdd03d8e Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 9 Jan 2026 22:11:23 -0800 Subject: [PATCH 08/55] Updated a faulty reference --- .../probe_stepper_communication/probe_stepper_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py index 61a2edb..1ec9548 100644 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py @@ -18,7 +18,7 @@ DRIVE_ENABLE_REGISTER = 0x001C -MOTOR_IP = '192.168.33.1' # default drive IP +DRIVE_IP = '192.168.33.1' # default drive IP DRIVE_PORT = 502 # Standard MODBUS TCP port class SwitchCaseOperation: From eff6b938d2e9bffa75e2e974df2f6b3a862bd3ff Mon Sep 17 00:00:00 2001 From: Abdul Date: Sat, 10 Jan 2026 10:07:06 -0800 Subject: [PATCH 09/55] Updated faulty reference --- ros2_ws/src/msg_interface/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index 05d547a..e4aaccf 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -11,7 +11,7 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" - "action/Probe_Stepper_Operation.action" + "action/ProbeStepperOperation.action" ) if(BUILD_TESTING) From 1ef3a2c19f7041cf37f492df75a573cca0255f9b Mon Sep 17 00:00:00 2001 From: devasilmy Date: Tue, 27 Jan 2026 19:40:02 -0800 Subject: [PATCH 10/55] Raspberry Pi IP setup guide for motor --- Documentation/2025/rpi-ip-setup-for-motor.md | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Documentation/2025/rpi-ip-setup-for-motor.md diff --git a/Documentation/2025/rpi-ip-setup-for-motor.md b/Documentation/2025/rpi-ip-setup-for-motor.md new file mode 100644 index 0000000..6aca83a --- /dev/null +++ b/Documentation/2025/rpi-ip-setup-for-motor.md @@ -0,0 +1,101 @@ +# Setting a Static IP on Ubuntu 22.04 LTS (Server) for LMD Drive Connection + +This guide explains how to configure your Raspberry Pi to talk directly to an industrial LMD/Lexium drive using a static IPv4 address using the built-in Netplan. + +--- + +## Step 1: Identify your Ethernet Interface +Before editing configurations, you need the system name for your Ethernet port. + +```bash +ip link show + +``` + +Usually, this is `eth0`, but on some Ubuntu builds it may appear as `enp1s0` or similar. + +--- + +## Step 2: Locate the Netplan Configuration + +Netplan stores its settings in `.yaml` files. List them to find the one your system is using: + +```bash +ls /etc/netplan/ + +``` + +Common names: `01-netcfg.yaml`, `50-cloud-init.yaml`, or `00-installer-config.yaml`. + +--- + +## Step 3: Edit the Configuration + +Open the file with `nano`. **Note:** YAML files are strictly sensitive to spaces. Do not use tabs. + +```bash +sudo nano /etc/netplan/01-netcfg.yaml + +``` + +Replace the content with the following block. Adjust `eth0` to your interface name and `192.168.33.10` to your desired Pi IP. + +```yaml +network: + version: 2 + renderer: networkd + ethernets: + eth0: + dhcp4: no + addresses: + - 192.168.33.10/24 + +``` + +--- + +## Step 4: Validate and Apply + +Netplan has a built-in safety feature to prevent you from locking yourself out of the system. + +1. **Test the configuration:** + +```bash +sudo netplan try + +``` + +If you don't press 'Enter' within 120 seconds, it will roll back the changes. + +2. **Apply the changes permanently:** + +```bash +sudo netplan apply + +``` + +--- + +## Step 5: Verify the Link + +1. **Check that the IP is assigned:** + +```bash +ip addr show eth0 + +``` + +2. **Ping the LMD Drive:** +Replace `192.168.33.1` with the actual IP address of your drive. + +```bash +ping 192.168.33.1 + +``` + +--- + +## Troubleshooting Tips + +* **No Link:** If `ip link` shows `NO-CARRIER`, check your physical Ethernet cable. +* **YAML Errors:** If `netplan apply` throws an error, double-check your indentation. Every nested line must be indented by exactly two spaces. From 4ef312091a3bedd3fb68fa5ca0a66276a7802888 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Tue, 27 Jan 2026 19:45:54 -0800 Subject: [PATCH 11/55] Add prerequisites section to RPi IP setup guide Added prerequisites for Raspberry Pi setup guide. --- Documentation/2025/rpi-ip-setup-for-motor.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Documentation/2025/rpi-ip-setup-for-motor.md b/Documentation/2025/rpi-ip-setup-for-motor.md index 6aca83a..62bcef2 100644 --- a/Documentation/2025/rpi-ip-setup-for-motor.md +++ b/Documentation/2025/rpi-ip-setup-for-motor.md @@ -4,6 +4,10 @@ This guide explains how to configure your Raspberry Pi to talk directly to an in --- +## Prerequisite: +- [ ] rpi is connected to the LMD Drive via ethernet +- [ ] LMD Drive is powered with sufficient power + ## Step 1: Identify your Ethernet Interface Before editing configurations, you need the system name for your Ethernet port. From 4fe724434329f3f4c22a2423a6c3f6877ea4bc7a Mon Sep 17 00:00:00 2001 From: devasilmy Date: Tue, 27 Jan 2026 20:39:58 -0800 Subject: [PATCH 12/55] Add files via upload --- .../Probe Motor Implementation Overview.png | Bin 0 -> 32349 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Documentation/2025/Images/Probe Motor Implementation Overview.png diff --git a/Documentation/2025/Images/Probe Motor Implementation Overview.png b/Documentation/2025/Images/Probe Motor Implementation Overview.png new file mode 100644 index 0000000000000000000000000000000000000000..520f4ecb9c817201fa9c6eb70449dcd91fb729a3 GIT binary patch literal 32349 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�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 Date: Tue, 27 Jan 2026 21:53:47 -0800 Subject: [PATCH 13/55] probe motor documentation Expanded documentation for the Rover Probe Motor Control System, detailing system architecture, requirements, configuration, ROS2 interface, setup, logic flow, and troubleshooting steps. --- Documentation/2025/probe-motor.md | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Documentation/2025/probe-motor.md diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md new file mode 100644 index 0000000..d075f0c --- /dev/null +++ b/Documentation/2025/probe-motor.md @@ -0,0 +1,111 @@ +# Rover Probe Motor Control System (EMBR) + + +--- + +This is a hardware-abstracted ROS2 interface for controlling a linear probe motor (LMDCE421 Stepper). It features a **Factory Pattern** that allows for seamless switching between real hardware and simulated environments via a JSON configuration. + + +## 1. System Architecture +![Text](2025/Images/Probe%20Motor%20Implementation%20Overview.png) + +The system is divided into three layers: + +1. **Hardware/Simulation Layer**: Handles Modbus TCP communication or simulated physics. +2. **Abstraction Layer**: The `StepperFactory` uses `sensors.json` to decide which driver to instantiate. +3. **ROS2 Layer**: The `probeStepper` node acts as the server, while `nav` acts as the mission controller. + +--- + +## 2. Requirements + +### Hardware + +* **Motor**: LMDCE421 Stepper Motor. +* **Communication**: Ethernet/Modbus TCP (Default IP: `192.168.33.1`). + +### Software + +* **OS**: Ubuntu 22.04 LTS with ROS2 Humble. +* **Ethernet:** [static IP](2025/rpi-ip-setup-for-motor.md) +* **Python Dependencies**: +* [`pymodbus`](https://pymodbus.readthedocs.io/en/latest/index.html): For Modbus TCP communication. + + + +--- + +## 3. Configuration (`sensors.json`) + +All hardware parameters are stored in `config/sensors.json`. This allows for calibration without changing the source code. + +| Parameter | Description | +| --- | --- | +| `mode` | Set to `"real"` for hardware or `"sim"` for simulation. | +| `steps_per_distance` | Conversion factor (Steps per cm). Default: `51200`. | +| `motion_range` | Maximum vertical displacement allowed (cm). | +| `max_velocity` | Speed limit in steps/second. | + +--- + +## 4. ROS2 Interface + +### Subscribed Topics + +* **`/move_probe_motor`** (`msg_interface/MoveProbeMotor`): Receives target position commands (cm). +* **`/probe_motor_feedback`** (`msg_interface/ProbeMotorFeedback`): Receives position updates and movement confirmation. + +### Message Definitions + +* **MoveProbeMotor**: `int32 move_position` (Target depth in cm). +* **ProbeMotorFeedback**: `bool has_moved`, `int32 position` (Current depth in cm). + +--- + +## 5. Setup and Execution + +### Building the Package + +```bash +cd ~/ros2_ws +colcon build --packages-select embr msg_interface +source install/setup.bash + +``` + +### Running the System + +To launch all nodes (Temperature, Cube, Radio, and Motor) with the default configuration: + +```bash +ros2 launch embr embr_launch.py + +``` + +To specify a custom configuration file: + +```bash +ros2 launch embr embr_launch.py config_file:=/path/to/your_config.json + +``` + +--- + +## 6. Logic Flow: Navigation & Survey + +The `nav` node executes an autonomous survey mission: + +1. **Search Phase**: Randomly waits for a "hotspot" detection. +2. **Approach Phase**: Moves the rover toward the target. +3. **Deployment**: +* Publishes to `/move_probe_motor`. +* Uses a `threading.Event` to block until `/probe_motor_feedback` confirms the move. + + +4. **Retrieval**: Returns the probe to position `0` before continuing the search. + +--- + +## 7. Troubleshooting + +* **Connection Errors**: Ensure the LMDCE421 IP (`192.168.33.1`) is reachable from your host machine (`ping 192.168.33.1`) [see](2025/rpi-ip-setup-for-motor.md). From 8b5ddc6a8d34e80d3186ff4d787484635fa004e4 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Tue, 27 Jan 2026 21:54:47 -0800 Subject: [PATCH 14/55] Fix relative paths in probe-motor documentation --- Documentation/2025/probe-motor.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index d075f0c..ad5e2b1 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -7,7 +7,7 @@ This is a hardware-abstracted ROS2 interface for controlling a linear probe moto ## 1. System Architecture -![Text](2025/Images/Probe%20Motor%20Implementation%20Overview.png) +![Text](Images/Probe%20Motor%20Implementation%20Overview.png) The system is divided into three layers: @@ -27,7 +27,7 @@ The system is divided into three layers: ### Software * **OS**: Ubuntu 22.04 LTS with ROS2 Humble. -* **Ethernet:** [static IP](2025/rpi-ip-setup-for-motor.md) +* **Ethernet:** [static IP](rpi-ip-setup-for-motor.md) * **Python Dependencies**: * [`pymodbus`](https://pymodbus.readthedocs.io/en/latest/index.html): For Modbus TCP communication. @@ -108,4 +108,4 @@ The `nav` node executes an autonomous survey mission: ## 7. Troubleshooting -* **Connection Errors**: Ensure the LMDCE421 IP (`192.168.33.1`) is reachable from your host machine (`ping 192.168.33.1`) [see](2025/rpi-ip-setup-for-motor.md). +* **Connection Errors**: Ensure the LMDCE421 IP (`192.168.33.1`) is reachable from your host machine (`ping 192.168.33.1`) [see](rpi-ip-setup-for-motor.md). From d65c30ac98bdbec5854e6fe8f791534be0454d54 Mon Sep 17 00:00:00 2001 From: Abdul Date: Tue, 27 Jan 2026 22:21:57 -0800 Subject: [PATCH 15/55] revised probe-motor node, implemented probe-motor base and factory --- ros2_ws/src/embr/config/sensors.json | 12 + ros2_ws/src/embr/config/sensors_sim.json | 9 + ros2_ws/src/embr/embr/ProbeStepper.py | 95 +++++++ ros2_ws/src/embr/embr/nav.py | 80 ++++++ ros2_ws/src/embr/embr/sensors/__init__.py | 8 +- ros2_ws/src/embr/embr/sensors/base.py | 3 +- ros2_ws/src/embr/embr/sensors/factory.py | 115 ++++++++ .../src/embr/embr/sensors/probeStepperBase.py | 247 ++++++++++++++++++ ros2_ws/src/embr/package.xml | 3 + ros2_ws/src/embr/setup.py | 4 +- ros2_ws/src/msg_interface/CMakeLists.txt | 2 + .../action/ProbeStepperOperation.action | 2 +- .../src/msg_interface/msg/MoveProbeMotor.msg | 1 + .../msg_interface/msg/ProbeMotorFeedback.msg | 2 + .../probe_stepper_node.py | 201 -------------- .../src/probe_stepper_communication/setup.py | 2 +- 16 files changed, 579 insertions(+), 207 deletions(-) create mode 100644 ros2_ws/src/embr/embr/ProbeStepper.py create mode 100644 ros2_ws/src/embr/embr/nav.py create mode 100644 ros2_ws/src/embr/embr/sensors/probeStepperBase.py create mode 100644 ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg create mode 100644 ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg delete mode 100644 ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py diff --git a/ros2_ws/src/embr/config/sensors.json b/ros2_ws/src/embr/config/sensors.json index d6b26d2..78f8cad 100644 --- a/ros2_ws/src/embr/config/sensors.json +++ b/ros2_ws/src/embr/config/sensors.json @@ -13,5 +13,17 @@ "mode": "real", "device": "/dev/ttyAMA1", "baud": 57600 + }, + "probeStepper": { + "mode": "real", + "device": "", + "baud": "", + "params": { + "max_velocity": 768000, + "acceleration": 1000000, + "steps_per_distance": 51200, + "motion_range": 10 + + } } } diff --git a/ros2_ws/src/embr/config/sensors_sim.json b/ros2_ws/src/embr/config/sensors_sim.json index b1cf562..10f8383 100644 --- a/ros2_ws/src/embr/config/sensors_sim.json +++ b/ros2_ws/src/embr/config/sensors_sim.json @@ -23,5 +23,14 @@ "radio": { "mode": "sim" + }, + "probeStepper": { + "mode": "sim", + "params": { + "max_velocity": 768000, + "acceleration": 1000000, + "steps_per_distance": 51200, + "motion_range": 10 + } } } diff --git a/ros2_ws/src/embr/embr/ProbeStepper.py b/ros2_ws/src/embr/embr/ProbeStepper.py new file mode 100644 index 0000000..af33e08 --- /dev/null +++ b/ros2_ws/src/embr/embr/ProbeStepper.py @@ -0,0 +1,95 @@ +import rclpy +from rclpy.node import Node +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.executors import MultiThreadedExecutor + +from msg_interface.msg import MoveProbeMotor, ProbeMotorFeedback +from embr.sensors import create_probeStepper, ProbeStepperConfig, StepperFactory + + +class ProbeStepper(Node): + + def __init__(self): + super().__init__("probe_stepper_server") + + self._log = self.get_logger() + + self.callback_group = ReentrantCallbackGroup() + + # Declare parameter for config file path + self.declare_parameter('config_file', '') + config_file = self.get_parameter('config_file') + config_path = config_file.value + + # Create probeStepper + try: + self.probeStepper = create_probeStepper('probeStepper', config_path) + probeStepper_type = 'simulated' if isinstance(self.probeStepper.__class__.__name__, str) and 'Sim' in self.probeStepper.__class__.__name__ else 'real' + self._log.info(f'probeStepper initialized in {probeStepper_type} mode (using {probeStepper_type} probeStepper)') + if not self.probeStepper.start(): + self._log.error("Failed to start probe stepper") + except Exception as e: + self._log.error(f'Failed to initialize probeStepper: {e}') + + # to do : create parameters that is synchronized with ProbeStepper().CONFIG and does input checking + # steps_per_distance + # motion_range + # max_velocity + # acceleration + + # move topic + self.moveTopic = self.create_subscription(MoveProbeMotor, 'move_probe_motor', self.move_callback ,10, callback_group=self.callback_group) + + # feedback topic + self.feedbackTopic = self.create_publisher(ProbeMotorFeedback, 'probe_motor_feedback',10) + + + async def move_callback(self, receivedMsg) -> None: + responseMsg = ProbeMotorFeedback() + + try: + originalPosition = self.probeStepper.readPosition() # store position before changing it + if originalPosition == receivedMsg.move_position: + self._log.info(f"probe already at position {originalPosition}") + responseMsg.has_moved = False + responseMsg.position = originalPosition + + else: + success = await self.probeStepper.moveAbsolute(receivedMsg.move_position) + + newPosition = self.probeStepper.readPosition() + responseMsg.position = newPosition + responseMsg.has_moved = newPosition != originalPosition + self._log.info(f"probe moved {abs((originalPosition - newPosition) / (receivedMsg.move_position - originalPosition) * 100)}%, position: {newPosition}cm") + except Exception as e: + self._log.error(f"Failed to command probe motor: {e}") + + self.feedbackTopic.publish(responseMsg) + + def shutdown(self) -> None: + try: + if not self.probeStepper.stop(): + self._log.error("Failed to stop probe motor") + except Exception as e: + self._log.error(f"Failed to shutdown: {e}") + + +def main(args=None): + rclpy.init(args=args) + stepper_node = ProbeStepper() + + executor = MultiThreadedExecutor() + executor.add_node(stepper_node) + try: + executor.spin() + + except KeyboardInterrupt as e: + pass + + finally: + stepperNode.shutdown() + stepperNode.destroy_node() + rclpy.shutdown() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/nav.py b/ros2_ws/src/embr/embr/nav.py new file mode 100644 index 0000000..2ef27d6 --- /dev/null +++ b/ros2_ws/src/embr/embr/nav.py @@ -0,0 +1,80 @@ +import time +import threading +import random + +import rclpy +from rclpy.node import Node + +from msg_interface.msg import MoveProbeMotor, ProbeMotorFeedback + +class Nav(Node): + + def __init__(self): + super().__init__('nav') + self.moveProbeMotorTopic = self.create_publisher(MoveProbeMotor, 'move_probe_motor', 10) + self.probeMotorFeedbackTopic = self.create_subscription(ProbeMotorFeedback, 'probe_motor_feedback', self.probeMotorFeedback_callback, 10) + self.probe_motor_feedback_event = threading.Event() + + self.survey_thread = threading.Thread(target=self.startSurvey) + self.survey_thread.start() + + def probeMotorFeedback_callback(self, feedback): + self.probe_motor_feedback_event.has_moved = feedback.has_moved + self.probe_motor_feedback_event.position = feedback.position + self.probe_motor_feedback_event.set() + + + + + def startSurvey(self): + + # to do: initiate motion and related components + + # trigger probe to move + # below is an example where the probe is randomly triggered to move + while(True): + self.get_logger().info("searching for hotspot") + + time.sleep(random.randint(10,30)) + self.get_logger().info("found a hotspot") + self.get_logger().info("moving towards hotspot") + time.sleep(random.randint(5,10)) + self.get_logger().info("reached hotspot") + self.get_logger().info("extending probe") + if self.moveProbe(10): # displace probe to a position 5cm vertically downwards + self.get_logger().info(f"extended probe to {self.probe_motor_feedback_event.position}") + self.get_logger().info("reading temp values") + time.sleep(4) + self.get_logger().info("retracting probe") + if not self.moveProbe(0): + # to do: halt operation to avoid potential harm to probe + self.get_logger().error("Failed to retract probe") + self.get_logger().info("retracted probe") + + def moveProbe(self, toPosition) -> bool: + msg = MoveProbeMotor() + msg.move_position = toPosition + self.moveProbeMotorTopic.publish(msg) + self.probe_motor_feedback_event.wait() + self.probe_motor_feedback_event.clear() + if not self.probe_motor_feedback_event.has_moved and ( self.probe_motor_feedback_event.position != toPosition ): + self.get_logger().error(f"Probe failed to move position from {self.probe_motor_feedback_event.position} to {toPosition}") + return False + return True + + +def main(args=None): + rclpy.init(args=args) + navNode = Nav() + try: + rclpy.spin(navNode) + + except KeyboardInterrupt: + pass + + finally: + navNode.destroy_node() + rclpy.shutdown() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/sensors/__init__.py b/ros2_ws/src/embr/embr/sensors/__init__.py index 59f4d9f..b22a5b7 100644 --- a/ros2_ws/src/embr/embr/sensors/__init__.py +++ b/ros2_ws/src/embr/embr/sensors/__init__.py @@ -4,16 +4,20 @@ """ from .base import Sensor, SensorConfig -from .factory import SensorFactory, create_sensor +from .factory import SensorFactory, StepperFactory, create_sensor, create_probeStepper from .temperature import TemperatureSensor, RealTemperatureSensor, SimTemperatureSensor from .cube import CubeSensor, RealCubeSensor, SimCubeSensor from .radio import RadioConnection, RealRadioConnection, SimRadioConnection +from .probeStepperBase import ProbeStepperConfig, RealProbeStepper, SimProbeStepper __all__ = [ 'Sensor', 'SensorConfig', + 'ProbeStepperConfig', 'SensorFactory', + 'StepperFactory', 'create_sensor', + 'create_probeStepper', 'TemperatureSensor', 'RealTemperatureSensor', 'SimTemperatureSensor', @@ -23,4 +27,6 @@ 'RadioConnection', 'RealRadioConnection', 'SimRadioConnection', + 'RealProbeStepper', + 'SimProbeStepper' ] diff --git a/ros2_ws/src/embr/embr/sensors/base.py b/ros2_ws/src/embr/embr/sensors/base.py index 682510b..ea76354 100644 --- a/ros2_ws/src/embr/embr/sensors/base.py +++ b/ros2_ws/src/embr/embr/sensors/base.py @@ -12,8 +12,7 @@ class SensorConfig: device: Optional[str] = None baud: int = 9600 params: Dict[str, Any] = field(default_factory=dict) - - + class Sensor(ABC): """Abstract base class for all sensors.""" diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index b477cfb..24e3849 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -10,6 +10,7 @@ from .cube import RealCubeSensor, SimCubeSensor from .radio import RealRadioConnection, SimRadioConnection +from .probeStepperBase import ProbeStepperConfig, ProbeStepperBase, RealProbeStepper, SimProbeStepper class SensorFactory: """Factory for creating sensor instances.""" @@ -152,3 +153,117 @@ def create_sensor(sensor_type: str, config: Optional[SensorConfig] = None) -> Se Sensor instance """ return SensorFactory.create(sensor_type, config) + + +class StepperFactory: + + # Mapping of sensor types to their real/sim implementations + SENSOR_MAP = { + 'probeStepper': { + 'real': RealProbeStepper, + 'sim': SimProbeStepper, + } + } + + @classmethod + def create(cls, probe_type: str, config_path: Optional[str] = None) -> ProbeStepperBase: + if probe_type not in cls.SENSOR_MAP: + raise ValueError(f"Unknown probe type: {probe_type}") + + config = cls.load_config(config_path) + + # Determine mode - default to 'real' unless specified otherwise + mode = cls._determine_mode(probe_type, config) + + # Get the appropriate class + probeStepper_classes = cls.SENSOR_MAP[probe_type] + probeStepper_class = probeStepper_classes[mode] + + if not probeStepper_class: + raise ValueError(f"Unknown mode '{mode}' for probe stepper '{probe_type}'") + + # Create and return instance + try: + return probeStepper_class(config) + except Exception as e: + raise RuntimeError(f"Failed to create {probe_type} probe stepper in {mode} mode: {e}") + + @classmethod + def _determine_mode(cls, probeStepper_type: str, config: ProbeStepperConfig) -> str: + """ + Determine the probe stepper mode based on configuration. + + Priority order: + 1. Explicit mode in config (if 'real' or 'sim') + 2. Default to 'real' + + Returns: + 'real' or 'sim' + """ + # If mode is explicitly set to 'real' or 'sim', use it + if config["mode"] in ('real', 'sim'): + return config["mode"] + + # Default to real hardware + return 'real' + + @classmethod + def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeStepperConfig]: + """ + Load probe stepper configuration from JSON file. + + Args: + config_path: Path to config file (default: config/sensors.json in workspace) + + Returns: + Dictionary mapping sensor names to SensorConfig objects + """ + + if config_path is None: + # Look for config in common locations within workspace + search_paths = [ + 'config/sensors.json', + 'sensors.json', + os.path.expanduser('~/.embr/sensors.json'), + '/etc/embr/sensors.json', + ] + + for path in search_paths: + if os.path.exists(path): + config_path = path + break + + if config_path is None or not os.path.exists(config_path): + return {} + + try: + with open(config_path, 'r') as f: + data = json.load(f) + + if data["probeStepper"]: + return data["probeStepper"] + else: + raise ValueError(f"probeStepper config is undefined in {config_path}") + + except Exception as e: + raise RuntimeError(f"Failed to load config from {config_path}: {e}") + + +def create_probeStepper(probeStepper_type: str, config_path: Optional[str] = None) -> ProbeStepperBase: + """ + Convenience function to create a sensor. + + Args: + sensor_type: Type of sensor + config: Optional sensor configuration + + Returns: + Sensor instance + """ + return StepperFactory.create(probeStepper_type, config_path) + + + + + + diff --git a/ros2_ws/src/embr/embr/sensors/probeStepperBase.py b/ros2_ws/src/embr/embr/sensors/probeStepperBase.py new file mode 100644 index 0000000..2acaeff --- /dev/null +++ b/ros2_ws/src/embr/embr/sensors/probeStepperBase.py @@ -0,0 +1,247 @@ +### to do : handle case where physical connection to stepper is lost during operation + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from dataclasses import dataclass, field + +import time # for time.sleep() in SimProbeStepper::move() +import random + +from pymodbus.client import ModbusTcpClient + +@dataclass +class ProbeStepperConfig: + mode: str = "real" + device: Optional[str] = None + baud: Optional[str] = None + params: Dict[str, Any] = field(default_factory=dict) + + +class ProbeStepperBase(ABC): + + CONFIG = { # settings : value + "steps_per_distance" : -1, # unit in step/centi meter, translation of rotation(steps) to vertical displacement # to do: needs to be calliberated + "motion_range" : -1, # unit in centi meter + "max_velocity" : -1, # unit in step/second + "acceleration" : -1 # unit in step/second^2 + } + + def __init__(self, config: Optional[ProbeStepperConfig] = None): + # initialize CONFIG settings' value + params = config["params"] + if params: + for setting, value in params.items(): + if params[setting]: + self.setConfigSetting(setting, value) + else: + raise ValueError(f"Invalid configuration setting") + else: + raise ValueError(f"Probe motor configuration is empty") + + + self._running = False + + @abstractmethod + def start(self) -> bool: + pass + + @abstractmethod + async def moveAbsolute(self, distance) -> bool: + # to do: + # check distance is within motion range + pass + + @abstractmethod + def readPosition(self) -> int: + pass + + @abstractmethod + def setConfigSetting(self, setting, value) -> bool: + # to do: + # raise possible errors: + # motion_range > steps_per_distance + # all config values = 0 + pass + + @abstractmethod + def stop(self) -> bool: + pass + + + @property + def is_running(self) -> bool: + """Check if probe stepper is running.""" + # to do: override this RealProbeStepper to also check self.client.connected + return self._running + + def set_running(self) -> None: + self._running = True + + def stop_running(self) -> None: + self._running = False + + def getConfig(): + return + + +class RealProbeStepper(ProbeStepperBase): + + DRIVE_IP = '192.168.33.1' # default drive IP + DRIVE_PORT = 502 # Standard MODBUS TCP port + + REGISTERS = { + "move_absolute" : {"address" : 0x0043, "length" : 2}, + "position_counter" : {"address" : 0x0057, "length" : 2}, + "max_velocity" : {"address" : 0x008B, "length" : 2}, + "acceleration" : {"address" : 0x0000, "length" : 1} + } + + def __init__(self, config: Optional[ProbeStepperConfig] = None): + super().__init__(config) + self.client = None + + def _write_registers(self, register, value): + if register["length"] == 1: + return self.client.write_registers(register["address"], [value]) + elif register["length"] == 2: + return self.client.write_registers(register["address"], [low,high]) + + def _read_registers(self, register): + if register["length"] == 1: + response = self.client.read_holding_registers(register["address"]) + if not response.isError(): + return response, response.value + else: + return response, None + elif register["length"] == 2: + response = self.client.read_holding_registers(register["address"]) + if not response.isError(): + value = response.registers[0] | (response.registers[1] >> 16) + return response, value + else: + return response, None + + def _updateMotorSettings(setting : str, value : int) -> bool: + response = self._write_registers(REGISTERS["setting"], value) + if response.isError(): + return False + + + def start(self) -> bool: + + if self.is_running: + return True + + #Establish connection to the probe stepper + self.client = ModbusTcpClient(DRIVE_IP, port=DRIVE_PORT) + self.client.connect() + + if not self.client.connected: + return False + + self.set_running() + return True + + async def moveAbsolute(self, position) -> bool: + + if not self.is_running: + return False + + positionInSteps = position * self.CONFIG["steps_per_distance"] + + # spin the stepper by number of steps + response = self._write_registers(REGISTERS["move_absolute"], positionInSteps) + if response.isError(): + # log error + return False + + return True + + def readPosition(self) -> int: + + if not self.is_running: + raise Exception("probe motor is not running") + + response, positionInSteps = self._read_registers(REGISTERS["position_counter"]) + + if response.isError(): + raise Exception("met with response error") + + positionInDistance = positionInSteps * self.CONFIG["steps_per_distance"] + + return int(positionInDistance) + + def setConfigSetting(self, setting : str, value : int) -> bool: + if not self.CONFIG[setting]: + return False + + elif not _updateMotorSettings(setting,value): + return False + + else: + self.CONFIG[setting] = value + return True + + def stop(self) -> bool: + + if not self.is_running: + raise Exception("No active connection to probe motor") + + # disconnect from the drive + self.client.close() + + self.stop_running + return True + + +class SimProbeStepper(ProbeStepperBase): + + TIME_PER_DISTANCE = 0.5 # in second/distance + POSITION = 0 # in steps + + def __init__(self, config: Optional[ProbeStepperConfig] = None): + super().__init__(config) + + def start(self) -> bool: + if self.is_running: + return True + self.set_running() + return True + + async def moveAbsolute(self, position) -> bool: + if not self.is_running: + return False + + if position > self.POSITION: + # apply a 90% probability that the probe doesn't fully extend to the provided position + if random.random() > 0.9: + position = random.randint(0,position) + + # sleep to mimic the time a motor would take to complete spin + time.sleep(abs((position - (self.POSITION/self.CONFIG["steps_per_distance"] if self.POSITION else 0)) * self.TIME_PER_DISTANCE)) # /100 to convert mili sec to sec + + # update the position by converting received position from cm to steps + self.POSITION = position * self.CONFIG["steps_per_distance"] + return True + + def readPosition(self) -> int: + return int(self.POSITION / self.CONFIG["steps_per_distance"]) + + def setConfigSetting(self, setting : str, value : int) -> bool: + if not self.CONFIG[setting]: + return False + + else: + self.CONFIG[setting] = value + + return True + + + def stop(self) -> bool: + + if not self.is_running: + raise Exception("No active connection to probe motor") + + self.stop_running() + + return True diff --git a/ros2_ws/src/embr/package.xml b/ros2_ws/src/embr/package.xml index 0e3aa1f..dd4fb88 100644 --- a/ros2_ws/src/embr/package.xml +++ b/ros2_ws/src/embr/package.xml @@ -6,6 +6,9 @@ TODO: Package description ubuntu Apache-2.0 + + pymodbus + rclpy msg_interface sensor_msgs diff --git a/ros2_ws/src/embr/setup.py b/ros2_ws/src/embr/setup.py index ea861cd..9e40667 100644 --- a/ros2_ws/src/embr/setup.py +++ b/ros2_ws/src/embr/setup.py @@ -25,7 +25,9 @@ 'console_scripts': [ 'getCube = embr.getCube:main', 'getTemp = embr.getTemp:main', - 'radio = embr.radio:main' + 'radio = embr.radio:main', + 'probeStepper = embr.ProbeStepper:main', + 'nav = embr.nav:main' ], }, ) diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index e4aaccf..d09df24 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -12,6 +12,8 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" "action/ProbeStepperOperation.action" + "msg/MoveProbeMotor.msg" + "msg/ProbeMotorFeedback.msg" ) if(BUILD_TESTING) diff --git a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action index eacea70..cacdf7f 100644 --- a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action +++ b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action @@ -2,4 +2,4 @@ int32 operation --- bool success --- -int32 status \ No newline at end of file +string status \ No newline at end of file diff --git a/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg b/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg new file mode 100644 index 0000000..9349281 --- /dev/null +++ b/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg @@ -0,0 +1 @@ +int32 move_position \ No newline at end of file diff --git a/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg b/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg new file mode 100644 index 0000000..aa3dc88 --- /dev/null +++ b/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg @@ -0,0 +1,2 @@ +bool has_moved +int32 position \ No newline at end of file diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py deleted file mode 100644 index 1ec9548..0000000 --- a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/probe_stepper_node.py +++ /dev/null @@ -1,201 +0,0 @@ -# "drive" in the comments refers to stepper motor drive, specifically the LMDCE421 - -import rclpy -from rclpy.node import Node -from rclpy.action import ActionServer - -from msg_interface.action import ProbeStepperOperation - -from pymodbus.client import ModbusTcpClient - -# global variables -STEPS_PER_REVOLUTION = 51200 # default steps per revolution - -# drive function registers (in use) -MOVE_ABSOLUTE_REGISTER = 0x0043 -ERROR_FLAG_REGISTER = 0x001F -ERROR_VARIABLE_REGISTER = 0x0021 -DRIVE_ENABLE_REGISTER = 0x001C - - -DRIVE_IP = '192.168.33.1' # default drive IP -DRIVE_PORT = 502 # Standard MODBUS TCP port - -class SwitchCaseOperation: - - def __init__(self, logger): - self.logger_ = logger - - def operation_0(self): - self.logger_.info("[SHUTDOWN] [start]") - - # disconnect from the drive - self.client.close() - - # do: disconnect power to drive - - self.logger_.info("[SHUTDOWN] [success]") - return True - - def operation_1(self): - self.logger_.info("[BOOT] [start]") - - # establish connection to drive - self.client = ModbusTcpClient(DRIVE_IP, port=DRIVE_PORT) - self.client.connect() - if not(self.client.connected): - self.logger_.info("[BOOT] [error]") - return False - - # drive disable (ensure no motion through interrupted action requests) - response = self.client.write_registers(DRIVE_ENABLE_REGISTER, [0]) - if response.isError(): - self.logger_.info("[BOOT] [drive disable] [error]") - return False - else: - self.logger_.info("[BOOT] [drive disable] [success]") - - # do checks - # do drive checks - response = self.client.read_holding_registers(ERROR_FLAG_REGISTER) - if response.isError(): - self.logger_.info("[BOOT] [error flag read] [error]") - response = self.client.read_holding_registers(ERROR_VARIABLE_REGISTER) - if response.isError(): - self.logger_.info("[BOOT] [error variable read] [error]") - return False - else: - self.logger_info(f"[BOOT] [error variable read] [success] {response.registers}") - return False - else: - self.logger_.info(f"[BOOT] [error flag read] [success] {response.registers}") - - # do: do log checks - - #drive enable - response = self.client.write_registers(DRIVE_ENABLE_REGISTER, [1]) - if response.isError(): - self.logger_.info("[BOOT] [drive enable] [error]") - return False - else: - self.logger_.info("[BOOT] [drive enable] [success]") - - self.logger_.info("[BOOT] [success]") - - return True - - def operation_2(self): - self.logger_.info("[BOOT,TEST] [start]") - - if self.operation_1(): - if self.operation_5(): - self.logger_.info("[BOOT,TEST] [success]") - return True - - self.logger_.info("[BOOT,TEST] [error]") - - return False - - def operation_3(self): - self.logger_.info("[BOOT,TEST,REACH] [start]") - - if self.operation_1(): - if self.operation_5(): - if self.operation_6(): - self.logger_.info("[BOOT,TEST,REACH] [success]") - return True - - self.logger_.info("[BOOT,TEST,REACH] [success]") - return False - - def operation_4(self): - self.logger_.info("[BOOT,TEST,REACH,READ] [start]") - - if self.operation_1(): - if self.operation_5(): - if self.operation_6(): - if self.operation_7(): - self.logger_.info("[BOOT,TEST,REACH,READ] [success]") - return True - - self.logger_.info("[BOOT,TEST,REACH,READ] [error]") - return False - - def operation_5(self): - self.logger_.info("[TEST] [start]") - - self.logger_.info("[TEST] [success]") - return True - - def operation_6(self): - self.logger_.info("[REACH] [start]") - high = (STEPS_PER_REVOLUTION >> 16) & 0xFFFF - low = STEPS_PER_REVOLUTION & 0xFFFF - response = self.client.write_registers(MOVE_ABSOLUTE_REGISTER, [low, high]) - if response.isError(): - self.logger_.info("[REACH] [error]") - return False - else: - self.logger_.info("[REACH] [success]") - return True - - def operation_7(self): - self.logger_.info("[READ] [start]") - - # during read operation the drive essentially does nothing. The thermal probe makes readings at this point. - # this operation is just to publish the state of the drive which is in "READ operation" - - self.logger_.info("[READ] [success]") - return True - - def operation_8(self): - self.logger_.info("[RETRACT] [start]") - - high = ((-1 * STEPS_PER_REVOLUTION) >> 16) & 0xFFFF - low = (-1 * STEPS_PER_REVOLUTION) & 0xFFFF - response = self.client.write_registers(MOVE_ABSOLUTE_REGISTER, [low, high]) - if response.isError(): - self.logger_.info("[RETRACT] [error]") - return False - else: - self.logger_.info("[RETRACT] [success]") - - return True - - def do_operation(self, operation_ID): - operation_name = "operation_" + str(operation_ID) - operation = getattr(self, operation_name) - return operation() - - -class ProbeStepperNode(Node): - - def __init__(self): - super().__init__("probe_stepper") - - # do: drive parameters. global variables must be changed to node parameters where needed. - - # do: drive_feedback topic - - #drive_action_server - self.drive_operation = ActionServer(self, ProbeStepperOperation, 'Probe_stepper_operation', self.drive_operation_callback) - - self.switchCaseOperation = SwitchCaseOperation(self.get_logger()) #instantiate switchCaseOperation once for drive_operation_callback to use multiple times - - def drive_operation_callback(self, goal_handle): - - operation_ID = goal_handle.request.operation - - # call operations based on received operation ID - result = ProbeStepperOperation.Result() - result.success = self.switchCaseOperation.do_operation(operation_ID) - goal_handle.succeed() - return result - -def main(args=None): - rclpy.init(args=args) - node = ProbeStepperNode() - rclpy.spin(node) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/ros2_ws/src/probe_stepper_communication/setup.py b/ros2_ws/src/probe_stepper_communication/setup.py index 2814357..fc77cac 100644 --- a/ros2_ws/src/probe_stepper_communication/setup.py +++ b/ros2_ws/src/probe_stepper_communication/setup.py @@ -24,7 +24,7 @@ }, entry_points={ 'console_scripts': [ - "probe_stepper = probe_stepper_communication.probe_stepper_node:main ", + ], }, ) From 6dfd271ae142618c6946acf2d91509ec59d2d0f5 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 28 Jan 2026 20:20:59 -0800 Subject: [PATCH 16/55] Delete ros2_ws/src/probe_stepper_communication directory This ros2 node has been revised and added to src/embr/embr as probeMotor.py --- .../src/probe_stepper_communication/LICENSE | 202 ------------------ .../probe_stepper_communication/package.xml | 21 -- .../probe_stepper_communication/__init__.py | 0 .../resource/probe_stepper_communication | 0 .../src/probe_stepper_communication/setup.cfg | 4 - .../src/probe_stepper_communication/setup.py | 30 --- .../test/test_copyright.py | 25 --- .../test/test_flake8.py | 25 --- .../test/test_pep257.py | 23 -- 9 files changed, 330 deletions(-) delete mode 100644 ros2_ws/src/probe_stepper_communication/LICENSE delete mode 100644 ros2_ws/src/probe_stepper_communication/package.xml delete mode 100644 ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py delete mode 100644 ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication delete mode 100644 ros2_ws/src/probe_stepper_communication/setup.cfg delete mode 100644 ros2_ws/src/probe_stepper_communication/setup.py delete mode 100644 ros2_ws/src/probe_stepper_communication/test/test_copyright.py delete mode 100644 ros2_ws/src/probe_stepper_communication/test/test_flake8.py delete mode 100644 ros2_ws/src/probe_stepper_communication/test/test_pep257.py diff --git a/ros2_ws/src/probe_stepper_communication/LICENSE b/ros2_ws/src/probe_stepper_communication/LICENSE deleted file mode 100644 index d645695..0000000 --- a/ros2_ws/src/probe_stepper_communication/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/ros2_ws/src/probe_stepper_communication/package.xml b/ros2_ws/src/probe_stepper_communication/package.xml deleted file mode 100644 index 897934e..0000000 --- a/ros2_ws/src/probe_stepper_communication/package.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - probe_stepper_communication - 0.0.0 - TODO: Package description - devasilmy - Apache-2.0 - - pymodbus - msg_interface - - ament_copyright - ament_flake8 - ament_pep257 - python3-pytest - - - ament_python - - diff --git a/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py b/ros2_ws/src/probe_stepper_communication/probe_stepper_communication/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication b/ros2_ws/src/probe_stepper_communication/resource/probe_stepper_communication deleted file mode 100644 index e69de29..0000000 diff --git a/ros2_ws/src/probe_stepper_communication/setup.cfg b/ros2_ws/src/probe_stepper_communication/setup.cfg deleted file mode 100644 index 4fb220b..0000000 --- a/ros2_ws/src/probe_stepper_communication/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[develop] -script_dir=$base/lib/probe_stepper_communication -[install] -install_scripts=$base/lib/probe_stepper_communication diff --git a/ros2_ws/src/probe_stepper_communication/setup.py b/ros2_ws/src/probe_stepper_communication/setup.py deleted file mode 100644 index fc77cac..0000000 --- a/ros2_ws/src/probe_stepper_communication/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -from setuptools import find_packages, setup - -package_name = 'probe_stepper_communication' - -setup( - name=package_name, - version='0.0.0', - packages=find_packages(exclude=['test']), - data_files=[ - ('share/ament_index/resource_index/packages', - ['resource/' + package_name]), - ('share/' + package_name, ['package.xml']), - ], - install_requires=['setuptools'], - zip_safe=True, - maintainer='devasilmy', - maintainer_email='devasilmy@todo.todo', - description='TODO: Package description', - license='Apache-2.0', - extras_require={ - 'test': [ - 'pytest', - ], - }, - entry_points={ - 'console_scripts': [ - - ], - }, -) diff --git a/ros2_ws/src/probe_stepper_communication/test/test_copyright.py b/ros2_ws/src/probe_stepper_communication/test/test_copyright.py deleted file mode 100644 index 97a3919..0000000 --- a/ros2_ws/src/probe_stepper_communication/test/test_copyright.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2015 Open Source Robotics Foundation, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from ament_copyright.main import main -import pytest - - -# Remove the `skip` decorator once the source file(s) have a copyright header -@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.') -@pytest.mark.copyright -@pytest.mark.linter -def test_copyright(): - rc = main(argv=['.', 'test']) - assert rc == 0, 'Found errors' diff --git a/ros2_ws/src/probe_stepper_communication/test/test_flake8.py b/ros2_ws/src/probe_stepper_communication/test/test_flake8.py deleted file mode 100644 index 27ee107..0000000 --- a/ros2_ws/src/probe_stepper_communication/test/test_flake8.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2017 Open Source Robotics Foundation, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from ament_flake8.main import main_with_errors -import pytest - - -@pytest.mark.flake8 -@pytest.mark.linter -def test_flake8(): - rc, errors = main_with_errors(argv=[]) - assert rc == 0, \ - 'Found %d code style errors / warnings:\n' % len(errors) + \ - '\n'.join(errors) diff --git a/ros2_ws/src/probe_stepper_communication/test/test_pep257.py b/ros2_ws/src/probe_stepper_communication/test/test_pep257.py deleted file mode 100644 index b234a38..0000000 --- a/ros2_ws/src/probe_stepper_communication/test/test_pep257.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2015 Open Source Robotics Foundation, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from ament_pep257.main import main -import pytest - - -@pytest.mark.linter -@pytest.mark.pep257 -def test_pep257(): - rc = main(argv=['.', 'test']) - assert rc == 0, 'Found code style errors / warnings' From 0244adb411fc6e73abb4283681c55b220b6440d2 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Mon, 2 Feb 2026 11:04:31 -0800 Subject: [PATCH 17/55] Update LMD Drive power requirements in documentation Added power requirements for LMD Drive in the setup guide. --- Documentation/2025/rpi-ip-setup-for-motor.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Documentation/2025/rpi-ip-setup-for-motor.md b/Documentation/2025/rpi-ip-setup-for-motor.md index 62bcef2..08bbf2a 100644 --- a/Documentation/2025/rpi-ip-setup-for-motor.md +++ b/Documentation/2025/rpi-ip-setup-for-motor.md @@ -6,7 +6,10 @@ This guide explains how to configure your Raspberry Pi to talk directly to an in ## Prerequisite: - [ ] rpi is connected to the LMD Drive via ethernet -- [ ] LMD Drive is powered with sufficient power + +LMD Drive is powered with sufficient power: +- [ ] Voltage: 12VDC - 48VDC +- [ ] Current: 0.0Amp - 2.0 Amp ## Step 1: Identify your Ethernet Interface Before editing configurations, you need the system name for your Ethernet port. From 53f531ebee4251ff6f9f6efec8e75bdae74106e7 Mon Sep 17 00:00:00 2001 From: Abdul Date: Mon, 2 Feb 2026 11:09:43 -0800 Subject: [PATCH 18/55] Changed 'stepper' reference to 'motor', replaced custom MoveProbeMotor interface with Int32 --- ros2_ws/src/embr/config/sensors.json | 2 +- ros2_ws/src/embr/config/sensors_sim.json | 2 +- ros2_ws/src/embr/embr/ProbeMotor.py | 100 ++++++++++++++++++ ros2_ws/src/embr/embr/ProbeStepper.py | 95 ----------------- ros2_ws/src/embr/embr/nav.py | 9 +- ros2_ws/src/embr/embr/sensors/__init__.py | 14 +-- ros2_ws/src/embr/embr/sensors/factory.py | 54 +++++----- ...{probeStepperBase.py => probeMotorBase.py} | 26 ++--- ros2_ws/src/embr/setup.py | 2 +- .../src/msg_interface/msg/MoveProbeMotor.msg | 1 - 10 files changed, 157 insertions(+), 148 deletions(-) create mode 100644 ros2_ws/src/embr/embr/ProbeMotor.py delete mode 100644 ros2_ws/src/embr/embr/ProbeStepper.py rename ros2_ws/src/embr/embr/sensors/{probeStepperBase.py => probeMotorBase.py} (87%) delete mode 100644 ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg diff --git a/ros2_ws/src/embr/config/sensors.json b/ros2_ws/src/embr/config/sensors.json index 78f8cad..3fdbc06 100644 --- a/ros2_ws/src/embr/config/sensors.json +++ b/ros2_ws/src/embr/config/sensors.json @@ -14,7 +14,7 @@ "device": "/dev/ttyAMA1", "baud": 57600 }, - "probeStepper": { + "probeMotor": { "mode": "real", "device": "", "baud": "", diff --git a/ros2_ws/src/embr/config/sensors_sim.json b/ros2_ws/src/embr/config/sensors_sim.json index 10f8383..2581dcd 100644 --- a/ros2_ws/src/embr/config/sensors_sim.json +++ b/ros2_ws/src/embr/config/sensors_sim.json @@ -24,7 +24,7 @@ "radio": { "mode": "sim" }, - "probeStepper": { + "probeMotor": { "mode": "sim", "params": { "max_velocity": 768000, diff --git a/ros2_ws/src/embr/embr/ProbeMotor.py b/ros2_ws/src/embr/embr/ProbeMotor.py new file mode 100644 index 0000000..c0992f8 --- /dev/null +++ b/ros2_ws/src/embr/embr/ProbeMotor.py @@ -0,0 +1,100 @@ +import rclpy +from rclpy.node import Node +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.executors import MultiThreadedExecutor + +from std_msgs.msg import Int32 +from msg_interface.msg import ProbeMotorFeedback +from embr.sensors import create_probeMotor, ProbeMotorConfig, ProbeMotorFactory + + +class ProbeMotor(Node): + + def __init__(self): + super().__init__("probe_motor_server") + + self._log = self.get_logger() + + self.callback_group = ReentrantCallbackGroup() + + # Declare parameter for config file path + self.declare_parameter('config_file', '') + config_file = self.get_parameter('config_file') + config_path = config_file.value + + # Create probeMotor + try: + self.probeMotor = create_probeMotor('probeMotor', config_path) + probeMotor_type = 'simulated' if isinstance(self.probeMotor.__class__.__name__, str) and 'Sim' in self.probeMotor.__class__.__name__ else 'real' + self._log.info(f'probe motor initialized in {probeMotor_type} mode (using {probeMotor_type} probe motor)') + if not self.probeMotor.start(): + self._log.error("Failed to start probe motor") + except Exception as e: + self._log.error(f'Failed to initialize probe motor: {e}') + + # to do : create parameters that is synchronized with ProbeMotor().CONFIG and does input checking + # steps_per_distance + # motion_range + # max_velocity + # acceleration + + # move topic + self.moveTopic = self.create_subscription(Int32, 'move_probe_motor', self.move_callback ,10, callback_group=self.callback_group) + + # feedback topic + self.feedbackTopic = self.create_publisher(ProbeMotorFeedback, 'probe_motor_feedback',10) + + + async def move_callback(self, received_msg : Int32) -> None: + response_msg = ProbeMotorFeedback() + move_position = received_msg.data + + try: + + originalPosition = self.probeMotor.readPosition() + + # + if originalPosition == move_position: + self._log.info(f"probe already at position {originalPosition}") + response_msg.has_moved = False + response_msg.position = originalPosition + + else: + success = await self.probeMotor.moveAbsolute(move_position) + + newPosition = self.probeMotor.readPosition() + response_msg.position = newPosition + response_msg.has_moved = newPosition != originalPosition + self._log.info(f"probe moved {abs((originalPosition - newPosition) / (move_position - originalPosition) * 100)}%, position: {newPosition}cm") + except Exception as e: + self._log.error(f"Failed to command probe motor: {e}") + + self.feedbackTopic.publish(response_msg) + + def shutdown(self) -> None: + try: + if not self.probeMotor.stop(): + self._log.error("Failed to stop probe motor") + except Exception as e: + self._log.error(f"Failed to shutdown: {e}") + + +def main(args=None): + rclpy.init(args=args) + probeMotor_node = ProbeMotor() + + executor = MultiThreadedExecutor() + executor.add_node(probeMotor_node) + try: + executor.spin() + + except KeyboardInterrupt as e: + pass + + finally: + probeMotor_node.shutdown() + probeMotor_node.destroy_node() + rclpy.shutdown() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/ProbeStepper.py b/ros2_ws/src/embr/embr/ProbeStepper.py deleted file mode 100644 index af33e08..0000000 --- a/ros2_ws/src/embr/embr/ProbeStepper.py +++ /dev/null @@ -1,95 +0,0 @@ -import rclpy -from rclpy.node import Node -from rclpy.callback_groups import ReentrantCallbackGroup -from rclpy.executors import MultiThreadedExecutor - -from msg_interface.msg import MoveProbeMotor, ProbeMotorFeedback -from embr.sensors import create_probeStepper, ProbeStepperConfig, StepperFactory - - -class ProbeStepper(Node): - - def __init__(self): - super().__init__("probe_stepper_server") - - self._log = self.get_logger() - - self.callback_group = ReentrantCallbackGroup() - - # Declare parameter for config file path - self.declare_parameter('config_file', '') - config_file = self.get_parameter('config_file') - config_path = config_file.value - - # Create probeStepper - try: - self.probeStepper = create_probeStepper('probeStepper', config_path) - probeStepper_type = 'simulated' if isinstance(self.probeStepper.__class__.__name__, str) and 'Sim' in self.probeStepper.__class__.__name__ else 'real' - self._log.info(f'probeStepper initialized in {probeStepper_type} mode (using {probeStepper_type} probeStepper)') - if not self.probeStepper.start(): - self._log.error("Failed to start probe stepper") - except Exception as e: - self._log.error(f'Failed to initialize probeStepper: {e}') - - # to do : create parameters that is synchronized with ProbeStepper().CONFIG and does input checking - # steps_per_distance - # motion_range - # max_velocity - # acceleration - - # move topic - self.moveTopic = self.create_subscription(MoveProbeMotor, 'move_probe_motor', self.move_callback ,10, callback_group=self.callback_group) - - # feedback topic - self.feedbackTopic = self.create_publisher(ProbeMotorFeedback, 'probe_motor_feedback',10) - - - async def move_callback(self, receivedMsg) -> None: - responseMsg = ProbeMotorFeedback() - - try: - originalPosition = self.probeStepper.readPosition() # store position before changing it - if originalPosition == receivedMsg.move_position: - self._log.info(f"probe already at position {originalPosition}") - responseMsg.has_moved = False - responseMsg.position = originalPosition - - else: - success = await self.probeStepper.moveAbsolute(receivedMsg.move_position) - - newPosition = self.probeStepper.readPosition() - responseMsg.position = newPosition - responseMsg.has_moved = newPosition != originalPosition - self._log.info(f"probe moved {abs((originalPosition - newPosition) / (receivedMsg.move_position - originalPosition) * 100)}%, position: {newPosition}cm") - except Exception as e: - self._log.error(f"Failed to command probe motor: {e}") - - self.feedbackTopic.publish(responseMsg) - - def shutdown(self) -> None: - try: - if not self.probeStepper.stop(): - self._log.error("Failed to stop probe motor") - except Exception as e: - self._log.error(f"Failed to shutdown: {e}") - - -def main(args=None): - rclpy.init(args=args) - stepper_node = ProbeStepper() - - executor = MultiThreadedExecutor() - executor.add_node(stepper_node) - try: - executor.spin() - - except KeyboardInterrupt as e: - pass - - finally: - stepperNode.shutdown() - stepperNode.destroy_node() - rclpy.shutdown() - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/nav.py b/ros2_ws/src/embr/embr/nav.py index 2ef27d6..83c35b1 100644 --- a/ros2_ws/src/embr/embr/nav.py +++ b/ros2_ws/src/embr/embr/nav.py @@ -5,13 +5,14 @@ import rclpy from rclpy.node import Node -from msg_interface.msg import MoveProbeMotor, ProbeMotorFeedback +from std_msgs.msg import Int32 +from msg_interface.msg import ProbeMotorFeedback class Nav(Node): def __init__(self): super().__init__('nav') - self.moveProbeMotorTopic = self.create_publisher(MoveProbeMotor, 'move_probe_motor', 10) + self.moveProbeMotorTopic = self.create_publisher(Int32, 'move_probe_motor', 10) self.probeMotorFeedbackTopic = self.create_subscription(ProbeMotorFeedback, 'probe_motor_feedback', self.probeMotorFeedback_callback, 10) self.probe_motor_feedback_event = threading.Event() @@ -52,8 +53,8 @@ def startSurvey(self): self.get_logger().info("retracted probe") def moveProbe(self, toPosition) -> bool: - msg = MoveProbeMotor() - msg.move_position = toPosition + msg = Int32() + msg.data = toPosition self.moveProbeMotorTopic.publish(msg) self.probe_motor_feedback_event.wait() self.probe_motor_feedback_event.clear() diff --git a/ros2_ws/src/embr/embr/sensors/__init__.py b/ros2_ws/src/embr/embr/sensors/__init__.py index b22a5b7..6e68e34 100644 --- a/ros2_ws/src/embr/embr/sensors/__init__.py +++ b/ros2_ws/src/embr/embr/sensors/__init__.py @@ -4,20 +4,20 @@ """ from .base import Sensor, SensorConfig -from .factory import SensorFactory, StepperFactory, create_sensor, create_probeStepper +from .factory import SensorFactory, ProbeMotorFactory, create_sensor, create_probeMotor from .temperature import TemperatureSensor, RealTemperatureSensor, SimTemperatureSensor from .cube import CubeSensor, RealCubeSensor, SimCubeSensor from .radio import RadioConnection, RealRadioConnection, SimRadioConnection -from .probeStepperBase import ProbeStepperConfig, RealProbeStepper, SimProbeStepper +from .probeMotorBase import ProbeMotorConfig, RealProbeMotor, SimProbeMotor __all__ = [ 'Sensor', 'SensorConfig', - 'ProbeStepperConfig', + 'ProbeMotorConfig', 'SensorFactory', - 'StepperFactory', + 'MotorFactory', 'create_sensor', - 'create_probeStepper', + 'create_probeMotor', 'TemperatureSensor', 'RealTemperatureSensor', 'SimTemperatureSensor', @@ -27,6 +27,6 @@ 'RadioConnection', 'RealRadioConnection', 'SimRadioConnection', - 'RealProbeStepper', - 'SimProbeStepper' + 'RealProbeMotor', + 'SimProbeMotorr' ] diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index 24e3849..f366d1d 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -10,7 +10,7 @@ from .cube import RealCubeSensor, SimCubeSensor from .radio import RealRadioConnection, SimRadioConnection -from .probeStepperBase import ProbeStepperConfig, ProbeStepperBase, RealProbeStepper, SimProbeStepper +from .probeMotorBase import ProbeMotorConfig, ProbeMotorBase, RealProbeMotor, SimProbeMotor class SensorFactory: """Factory for creating sensor instances.""" @@ -155,43 +155,44 @@ def create_sensor(sensor_type: str, config: Optional[SensorConfig] = None) -> Se return SensorFactory.create(sensor_type, config) -class StepperFactory: +class ProbeMotorFactory: # Mapping of sensor types to their real/sim implementations SENSOR_MAP = { - 'probeStepper': { - 'real': RealProbeStepper, - 'sim': SimProbeStepper, + 'probeMotor': { + 'real': RealProbeMotor, + 'sim': SimProbeMotor, } } @classmethod - def create(cls, probe_type: str, config_path: Optional[str] = None) -> ProbeStepperBase: + def create(cls, probe_type: str, config_path: Optional[str] = None) -> ProbeMotorBase: if probe_type not in cls.SENSOR_MAP: raise ValueError(f"Unknown probe type: {probe_type}") - + config = cls.load_config(config_path) # Determine mode - default to 'real' unless specified otherwise mode = cls._determine_mode(probe_type, config) - + # Get the appropriate class - probeStepper_classes = cls.SENSOR_MAP[probe_type] - probeStepper_class = probeStepper_classes[mode] + probeMotor_classes = cls.SENSOR_MAP[probe_type] + + probeMotor_class = probeMotor_classes[mode] - if not probeStepper_class: - raise ValueError(f"Unknown mode '{mode}' for probe stepper '{probe_type}'") + if not probeMotor_class: + raise ValueError(f"Unknown mode '{mode}' for probe motor '{probe_type}'") # Create and return instance try: - return probeStepper_class(config) + return probeMotor_class(config) except Exception as e: - raise RuntimeError(f"Failed to create {probe_type} probe stepper in {mode} mode: {e}") + raise RuntimeError(f"Failed to create {probe_type} probe motor in {mode} mode: {e}") @classmethod - def _determine_mode(cls, probeStepper_type: str, config: ProbeStepperConfig) -> str: + def _determine_mode(cls, probeMotor_type: str, config: ProbeMotorConfig) -> str: """ - Determine the probe stepper mode based on configuration. + Determine the probe motor mode based on configuration. Priority order: 1. Explicit mode in config (if 'real' or 'sim') @@ -200,6 +201,7 @@ def _determine_mode(cls, probeStepper_type: str, config: ProbeStepperConfig) -> Returns: 'real' or 'sim' """ + # If mode is explicitly set to 'real' or 'sim', use it if config["mode"] in ('real', 'sim'): return config["mode"] @@ -208,9 +210,9 @@ def _determine_mode(cls, probeStepper_type: str, config: ProbeStepperConfig) -> return 'real' @classmethod - def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeStepperConfig]: + def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeMotorConfig]: """ - Load probe stepper configuration from JSON file. + Load probe motor configuration from JSON file. Args: config_path: Path to config file (default: config/sensors.json in workspace) @@ -218,7 +220,7 @@ def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeSteppe Returns: Dictionary mapping sensor names to SensorConfig objects """ - + if config_path is None: # Look for config in common locations within workspace search_paths = [ @@ -232,24 +234,26 @@ def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeSteppe if os.path.exists(path): config_path = path break - + if config_path is None or not os.path.exists(config_path): return {} + + try: with open(config_path, 'r') as f: data = json.load(f) - if data["probeStepper"]: - return data["probeStepper"] + if data["probeMotor"]: + return data["probeMotor"] else: - raise ValueError(f"probeStepper config is undefined in {config_path}") + raise ValueError(f"probeMotor config is undefined in {config_path}") except Exception as e: raise RuntimeError(f"Failed to load config from {config_path}: {e}") -def create_probeStepper(probeStepper_type: str, config_path: Optional[str] = None) -> ProbeStepperBase: +def create_probeMotor(probeMotor_type: str, config_path: Optional[str] = None) -> ProbeMotorBase: """ Convenience function to create a sensor. @@ -260,7 +264,7 @@ def create_probeStepper(probeStepper_type: str, config_path: Optional[str] = Non Returns: Sensor instance """ - return StepperFactory.create(probeStepper_type, config_path) + return ProbeMotorFactory.create(probeMotor_type, config_path) diff --git a/ros2_ws/src/embr/embr/sensors/probeStepperBase.py b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py similarity index 87% rename from ros2_ws/src/embr/embr/sensors/probeStepperBase.py rename to ros2_ws/src/embr/embr/sensors/probeMotorBase.py index 2acaeff..8a503a4 100644 --- a/ros2_ws/src/embr/embr/sensors/probeStepperBase.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py @@ -1,23 +1,23 @@ -### to do : handle case where physical connection to stepper is lost during operation +### to do : handle case where physical connection to motor is lost during operation from abc import ABC, abstractmethod from typing import Any, Dict, Optional from dataclasses import dataclass, field -import time # for time.sleep() in SimProbeStepper::move() +import time # for time.sleep() in SimProbeMotor::move() import random from pymodbus.client import ModbusTcpClient @dataclass -class ProbeStepperConfig: +class ProbeMotorConfig: mode: str = "real" device: Optional[str] = None baud: Optional[str] = None params: Dict[str, Any] = field(default_factory=dict) -class ProbeStepperBase(ABC): +class ProbeMotorBase(ABC): CONFIG = { # settings : value "steps_per_distance" : -1, # unit in step/centi meter, translation of rotation(steps) to vertical displacement # to do: needs to be calliberated @@ -26,7 +26,7 @@ class ProbeStepperBase(ABC): "acceleration" : -1 # unit in step/second^2 } - def __init__(self, config: Optional[ProbeStepperConfig] = None): + def __init__(self, config: Optional[ProbeMotorConfig] = None): # initialize CONFIG settings' value params = config["params"] if params: @@ -70,8 +70,8 @@ def stop(self) -> bool: @property def is_running(self) -> bool: - """Check if probe stepper is running.""" - # to do: override this RealProbeStepper to also check self.client.connected + """Check if probe motor is running.""" + # to do: override this RealProbeMotor to also check self.client.connected return self._running def set_running(self) -> None: @@ -84,7 +84,7 @@ def getConfig(): return -class RealProbeStepper(ProbeStepperBase): +class RealProbeMotor(ProbeMotorBase): DRIVE_IP = '192.168.33.1' # default drive IP DRIVE_PORT = 502 # Standard MODBUS TCP port @@ -96,7 +96,7 @@ class RealProbeStepper(ProbeStepperBase): "acceleration" : {"address" : 0x0000, "length" : 1} } - def __init__(self, config: Optional[ProbeStepperConfig] = None): + def __init__(self, config: Optional[ProbeMotorConfig] = None): super().__init__(config) self.client = None @@ -132,7 +132,7 @@ def start(self) -> bool: if self.is_running: return True - #Establish connection to the probe stepper + #Establish connection to the probe motor self.client = ModbusTcpClient(DRIVE_IP, port=DRIVE_PORT) self.client.connect() @@ -149,7 +149,7 @@ async def moveAbsolute(self, position) -> bool: positionInSteps = position * self.CONFIG["steps_per_distance"] - # spin the stepper by number of steps + # spin the motor by number of steps response = self._write_registers(REGISTERS["move_absolute"], positionInSteps) if response.isError(): # log error @@ -194,12 +194,12 @@ def stop(self) -> bool: return True -class SimProbeStepper(ProbeStepperBase): +class SimProbeMotor(ProbeMotorBase): TIME_PER_DISTANCE = 0.5 # in second/distance POSITION = 0 # in steps - def __init__(self, config: Optional[ProbeStepperConfig] = None): + def __init__(self, config: Optional[ProbeMotorConfig] = None): super().__init__(config) def start(self) -> bool: diff --git a/ros2_ws/src/embr/setup.py b/ros2_ws/src/embr/setup.py index 9e40667..8902758 100644 --- a/ros2_ws/src/embr/setup.py +++ b/ros2_ws/src/embr/setup.py @@ -26,7 +26,7 @@ 'getCube = embr.getCube:main', 'getTemp = embr.getTemp:main', 'radio = embr.radio:main', - 'probeStepper = embr.ProbeStepper:main', + 'probeMotor = embr.ProbeMotor:main', 'nav = embr.nav:main' ], }, diff --git a/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg b/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg deleted file mode 100644 index 9349281..0000000 --- a/ros2_ws/src/msg_interface/msg/MoveProbeMotor.msg +++ /dev/null @@ -1 +0,0 @@ -int32 move_position \ No newline at end of file From 0840f589e9efb3b9aa8eec40b9addcd457e7ac8e Mon Sep 17 00:00:00 2001 From: Abdul Date: Tue, 3 Feb 2026 13:02:46 -0800 Subject: [PATCH 19/55] merged probeStepperFactory into SensorFactory, optimised sensor bases --- ros2_ws/src/embr/embr/ProbeMotor.py | 12 ++--- ros2_ws/src/embr/embr/getCube.py | 28 ++--------- ros2_ws/src/embr/embr/getTemp.py | 28 ++--------- ros2_ws/src/embr/embr/radio.py | 26 ++--------- ros2_ws/src/embr/embr/sensors/base.py | 8 ++-- ros2_ws/src/embr/embr/sensors/cube.py | 6 +-- ros2_ws/src/embr/embr/sensors/factory.py | 46 ++++++++----------- .../src/embr/embr/sensors/probeMotorBase.py | 28 +++++++---- ros2_ws/src/embr/embr/sensors/radio.py | 4 +- ros2_ws/src/embr/embr/sensors/temperature.py | 6 +-- ros2_ws/src/msg_interface/CMakeLists.txt | 1 - 11 files changed, 68 insertions(+), 125 deletions(-) diff --git a/ros2_ws/src/embr/embr/ProbeMotor.py b/ros2_ws/src/embr/embr/ProbeMotor.py index c0992f8..fb01dc9 100644 --- a/ros2_ws/src/embr/embr/ProbeMotor.py +++ b/ros2_ws/src/embr/embr/ProbeMotor.py @@ -5,7 +5,7 @@ from std_msgs.msg import Int32 from msg_interface.msg import ProbeMotorFeedback -from embr.sensors import create_probeMotor, ProbeMotorConfig, ProbeMotorFactory +from embr.sensors import create_sensor, ProbeMotorConfig class ProbeMotor(Node): @@ -19,14 +19,14 @@ def __init__(self): # Declare parameter for config file path self.declare_parameter('config_file', '') - config_file = self.get_parameter('config_file') - config_path = config_file.value + config_path = self.get_parameter('config_file').value + # Create probeMotor try: - self.probeMotor = create_probeMotor('probeMotor', config_path) - probeMotor_type = 'simulated' if isinstance(self.probeMotor.__class__.__name__, str) and 'Sim' in self.probeMotor.__class__.__name__ else 'real' - self._log.info(f'probe motor initialized in {probeMotor_type} mode (using {probeMotor_type} probe motor)') + self.probeMotor = create_sensor('probeMotor', config_path) + mode_type = 'simulated' if isinstance(self.probeMotor.__class__.__name__, str) and 'Sim' in self.probeMotor.__class__.__name__ else 'real' + self._log.info(f'probe motor initialized in {mode_type} mode (using {mode_type} probe motor)') if not self.probeMotor.start(): self._log.error("Failed to start probe motor") except Exception as e: diff --git a/ros2_ws/src/embr/embr/getCube.py b/ros2_ws/src/embr/embr/getCube.py index ec562e8..56bc2a3 100755 --- a/ros2_ws/src/embr/embr/getCube.py +++ b/ros2_ws/src/embr/embr/getCube.py @@ -7,7 +7,7 @@ from rclpy.node import Node import time from msg_interface.msg import Gps -from embr.sensors import create_sensor, SensorConfig, SensorFactory +from embr.sensors import create_sensor, SensorConfig class AttitudePublisher(Node): @@ -16,33 +16,15 @@ def __init__(self): # Declare parameter for config file path self.declare_parameter('config_file', '') - config_file = self.get_parameter('config_file').value - - # Use default config path if not provided - if not config_file: - config_file = 'src/embr/config/sensors.json' - - # Try to load from config file - try: - configs = SensorFactory.load_config(config_file) - config = configs.get('cube') - - if config is None: - self.get_logger().error(f'Cube sensor not found in config file: {config_file}') - raise ValueError(f'Cube sensor configuration not found in {config_file}') - - self.get_logger().info(f'Loaded cube config from {config_file}') - except Exception as e: - self.get_logger().error(f'Failed to load config file: {e}') - raise + config_path = self.get_parameter('config_file').value # Create sensor try: - self.sensor = create_sensor('cube', config) + self.sensor = create_sensor('cube', config_path) self.sensor.start() - sensor_type = 'simulated' if 'Sim' in self.sensor.__class__.__name__ else 'real' - self.get_logger().info(f'Cube sensor initialized in {config.mode} mode (using {sensor_type} sensor)') + mode_type = 'simulated' if 'Sim' in self.sensor.__class__.__name__ else 'real' + self.get_logger().info(f'Cube sensor initialized in {mode_type} mode (using {mode_type} sensor)') except Exception as e: self.get_logger().error(f'Failed to initialize sensor: {e}') raise diff --git a/ros2_ws/src/embr/embr/getTemp.py b/ros2_ws/src/embr/embr/getTemp.py index c7ca4d2..09fffc0 100644 --- a/ros2_ws/src/embr/embr/getTemp.py +++ b/ros2_ws/src/embr/embr/getTemp.py @@ -15,33 +15,15 @@ def __init__(self): # Declare parameter for config file path self.declare_parameter('config_file', '') - config_file = self.get_parameter('config_file').value - - # Use default config path if not provided - if not config_file: - config_file = 'src/embr/config/sensors.json' - - # Try to load from config file - try: - configs = SensorFactory.load_config(config_file) - config = configs.get('temperature') - - if config is None: - self.get_logger().error(f'Temperature sensor not found in config file: {config_file}') - raise ValueError(f'Temperature sensor configuration not found in {config_file}') - - self.get_logger().info(f'Loaded temperature config from {config_file}') - except Exception as e: - self.get_logger().error(f'Failed to load config file: {e}') - raise - + config_path = self.get_parameter('config_file').value + # Create sensor try: - self.sensor = create_sensor('temperature', config) + self.sensor = create_sensor('temperature', config_path) self.sensor.start() - sensor_type = 'simulated' if isinstance(self.sensor.__class__.__name__, str) and 'Sim' in self.sensor.__class__.__name__ else 'real' - self.get_logger().info(f'Temperature sensor initialized in {config.mode} mode (using {sensor_type} sensor)') + mode_type = 'simulated' if isinstance(self.sensor.__class__.__name__, str) and 'Sim' in self.sensor.__class__.__name__ else 'real' + self.get_logger().info(f'Temperature sensor initialized in {mode_type} mode (using {mode_type} sensor)') except Exception as e: self.get_logger().error(f'Failed to initialize sensor: {e}') raise diff --git a/ros2_ws/src/embr/embr/radio.py b/ros2_ws/src/embr/embr/radio.py index 2e9c31e..1109cf1 100644 --- a/ros2_ws/src/embr/embr/radio.py +++ b/ros2_ws/src/embr/embr/radio.py @@ -15,7 +15,7 @@ from sensor_msgs.msg import PointCloud2 import sensor_msgs_py.point_cloud2 as pc2 -from embr.sensors import create_sensor, SensorConfig, SensorFactory +from embr.sensors import create_sensor, SensorConfig class CommSubscriber(Node): @@ -28,31 +28,13 @@ def __init__(self): self.declare_parameter('config_file', '') config_file = self.get_parameter('config_file').value - # Use default config path if not provided - if not config_file: - config_file = 'src/embr/config/sensors.json' - - # Try to load from config file - try: - configs = SensorFactory.load_config(config_file) - config = configs.get('radio') - - if config is None: - self.get_logger().error(f'Radio config not found in config file: {config_file}') - raise ValueError(f'Radio configuration not found in {config_file}') - - self.get_logger().info(f'Loaded Radio config from {config_file}') - except Exception as e: - self.get_logger().error(f'Failed to load config file: {e}') - raise - # Create Radio connection try: - self.radio_connection = create_sensor('radio', config) + self.radio_connection = create_sensor('radio', config_file) self.radio_connection.start() - conn_type = 'simulated' if 'Sim' in self.radio_connection.__class__.__name__ else 'real' - self.get_logger().info(f'Radio connection initialized in {config.mode} mode (using {conn_type} connection)') + mode_type = 'simulated' if 'Sim' in self.radio_connection.__class__.__name__ else 'real' + self.get_logger().info(f'Radio connection initialized in {mode_type} mode (using {mode_type} connection)') except Exception as e: self.get_logger().error(f'Failed to initialize Radio: {e}') raise diff --git a/ros2_ws/src/embr/embr/sensors/base.py b/ros2_ws/src/embr/embr/sensors/base.py index ea76354..15e3fd1 100644 --- a/ros2_ws/src/embr/embr/sensors/base.py +++ b/ros2_ws/src/embr/embr/sensors/base.py @@ -8,10 +8,10 @@ @dataclass class SensorConfig: """Configuration for a sensor instance.""" - mode: str = "real" # real, sim - device: Optional[str] = None - baud: int = 9600 - params: Dict[str, Any] = field(default_factory=dict) + mode: str # real, sim + device: Optional[str] + baud: int + params: Dict[str, Any] class Sensor(ABC): """Abstract base class for all sensors.""" diff --git a/ros2_ws/src/embr/embr/sensors/cube.py b/ros2_ws/src/embr/embr/sensors/cube.py index c67d709..737eaf4 100644 --- a/ros2_ws/src/embr/embr/sensors/cube.py +++ b/ros2_ws/src/embr/embr/sensors/cube.py @@ -26,8 +26,8 @@ class RealCubeSensor(CubeSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - self.device = config.device if config else '/dev/ttyAMA0' - self.baud = config.baud if config else 57600 + self.device = config["device"] if config else '/dev/ttyAMA0' + self.baud = config["baud"] if config else 57600 self.vehicle = None def start(self) -> None: @@ -70,7 +70,7 @@ class SimCubeSensor(CubeSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - params = config.params if config else {} + params = config["params"] if config else {} # Starting position (default: somewhere in US) self.start_lat = params.get('start_lat', 37.7749) self.start_lon = params.get('start_lon', -122.4194) diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index f366d1d..f329bde 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -29,15 +29,19 @@ class SensorFactory: 'real': RealRadioConnection, 'sim': SimRadioConnection, }, + 'probeMotor' : { + 'real' : RealProbeMotor, + 'sim': SimProbeMotor, + } } @classmethod - def create(cls, sensor_type: str, config: Optional[SensorConfig] = None) -> Sensor: + def create(cls, sensor_type: str, config_path: Optional[str] = None): """ Create a sensor instance. Args: - sensor_type: Type of sensor ('temperature', 'cube', 'radio') + sensor_type: Type of sensor ('temperature', 'cube', 'radio', 'probeMotor) config: Sensor configuration Returns: @@ -49,8 +53,9 @@ def create(cls, sensor_type: str, config: Optional[SensorConfig] = None) -> Sens """ if sensor_type not in cls.SENSOR_MAP: raise ValueError(f"Unknown sensor type: {sensor_type}") + - config = config or SensorConfig() + config = cls.load_config(sensor_type, config_path) # Determine mode - default to 'real' unless specified otherwise mode = cls._determine_mode(sensor_type, config) @@ -63,6 +68,7 @@ def create(cls, sensor_type: str, config: Optional[SensorConfig] = None) -> Sens raise ValueError(f"Unknown mode '{mode}' for sensor '{sensor_type}'") # Create and return instance + try: return sensor_class(config) except Exception as e: @@ -81,14 +87,14 @@ def _determine_mode(cls, sensor_type: str, config: SensorConfig) -> str: 'real' or 'sim' """ # If mode is explicitly set to 'real' or 'sim', use it - if config.mode in ('real', 'sim'): - return config.mode + if config["mode"] in ('real', 'sim'): + return config["mode"] # Default to real hardware return 'real' @classmethod - def load_config(cls, config_path: Optional[str] = None) -> Dict[str, SensorConfig]: + def load_config(cls, device_type: str ,config_path: Optional[str] = None) -> SensorConfig: """ Load sensor configuration from JSON file. @@ -98,10 +104,11 @@ def load_config(cls, config_path: Optional[str] = None) -> Dict[str, SensorConfi Returns: Dictionary mapping sensor names to SensorConfig objects """ - if config_path is None: + + if not config_path: # Look for config in common locations within workspace search_paths = [ - 'config/sensors.json', + 'src/embr/config/sensors.json', 'sensors.json', os.path.expanduser('~/.embr/sensors.json'), '/etc/embr/sensors.json', @@ -111,31 +118,14 @@ def load_config(cls, config_path: Optional[str] = None) -> Dict[str, SensorConfi if os.path.exists(path): config_path = path break - + if config_path is None or not os.path.exists(config_path): - return {} + raise RuntimeError(f"Failed to find configuration file: cannot find configuration file from {config_path} or default config paths") try: with open(config_path, 'r') as f: data = json.load(f) - - configs = {} - for sensor_name, sensor_data in data.items(): - # Skip non-sensor entries (like comments or metadata) - if not isinstance(sensor_data, dict): - continue - - # Get mode from config, default to 'real' - mode = sensor_data.get('mode', 'real') - - configs[sensor_name] = SensorConfig( - mode=mode, - device=sensor_data.get('device'), - baud=sensor_data.get('baud', 9600), - params=sensor_data.get('params', {}) - ) - - return configs + return data[device_type] except Exception as e: raise RuntimeError(f"Failed to load config from {config_path}: {e}") diff --git a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py index 8a503a4..898460e 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py @@ -9,12 +9,14 @@ from pymodbus.client import ModbusTcpClient +from embr.sensors import SensorConfig + @dataclass class ProbeMotorConfig: - mode: str = "real" - device: Optional[str] = None - baud: Optional[str] = None - params: Dict[str, Any] = field(default_factory=dict) + mode: str + device: Optional[str] + baud: Optional[str] + params: Dict[str, Any] class ProbeMotorBase(ABC): @@ -26,9 +28,15 @@ class ProbeMotorBase(ABC): "acceleration" : -1 # unit in step/second^2 } - def __init__(self, config: Optional[ProbeMotorConfig] = None): + def __init__(self, config: Optional[SensorConfig] = None): + + + self._running = False + self.start() + # initialize CONFIG settings' value params = config["params"] + print("params initialized") if params: for setting, value in params.items(): if params[setting]: @@ -39,7 +47,7 @@ def __init__(self, config: Optional[ProbeMotorConfig] = None): raise ValueError(f"Probe motor configuration is empty") - self._running = False + @abstractmethod def start(self) -> bool: @@ -121,8 +129,8 @@ def _read_registers(self, register): else: return response, None - def _updateMotorSettings(setting : str, value : int) -> bool: - response = self._write_registers(REGISTERS["setting"], value) + def _updateMotorSettings(self, setting : str, value : int) -> bool: + response = self._write_registers(self.REGISTERS["setting"], value) if response.isError(): return False @@ -133,7 +141,7 @@ def start(self) -> bool: return True #Establish connection to the probe motor - self.client = ModbusTcpClient(DRIVE_IP, port=DRIVE_PORT) + self.client = ModbusTcpClient(self.DRIVE_IP, port=self.DRIVE_PORT) self.client.connect() if not self.client.connected: @@ -175,7 +183,7 @@ def setConfigSetting(self, setting : str, value : int) -> bool: if not self.CONFIG[setting]: return False - elif not _updateMotorSettings(setting,value): + elif not self._updateMotorSettings(setting,value): return False else: diff --git a/ros2_ws/src/embr/embr/sensors/radio.py b/ros2_ws/src/embr/embr/sensors/radio.py index 5bce5ca..69575d4 100644 --- a/ros2_ws/src/embr/embr/sensors/radio.py +++ b/ros2_ws/src/embr/embr/sensors/radio.py @@ -15,8 +15,8 @@ class RealRadioConnection(RadioConnection): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - self.device = config.device if config else '/dev/ttyAMA1' - self.baud = config.baud if config else 57600 + self.device = config["device"] if config else '/dev/ttyAMA1' + self.baud = config["baud"] if config else 57600 self.connection = None self.mav = None diff --git a/ros2_ws/src/embr/embr/sensors/temperature.py b/ros2_ws/src/embr/embr/sensors/temperature.py index 5b654a6..101e1c3 100644 --- a/ros2_ws/src/embr/embr/sensors/temperature.py +++ b/ros2_ws/src/embr/embr/sensors/temperature.py @@ -17,8 +17,8 @@ class RealTemperatureSensor(TemperatureSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) self.ser = None - self.device = config.device if config else '/dev/ttyACM0' - self.baud = config.baud if config else 9600 + self.device = config["device"] if config else '/dev/ttyACM0' + self.baud = config["baud"] if config else 9600 def start(self) -> None: """Open serial connection to temperature sensor.""" @@ -62,7 +62,7 @@ class SimTemperatureSensor(TemperatureSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - params = config.params if config else {} + params = config["params"] if config else {} self.base_temp = params.get('base_temp', 22.0) self.variation = params.get('variation', 2.0) self.noise = params.get('noise', 0.1) diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index d09df24..63ca17c 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -12,7 +12,6 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" "action/ProbeStepperOperation.action" - "msg/MoveProbeMotor.msg" "msg/ProbeMotorFeedback.msg" ) From 37a1bef588ed194ef16899971300810f1c23cfac Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 6 Feb 2026 17:41:46 -0800 Subject: [PATCH 20/55] updated code --- ros2_ws/src/embr/embr/radio.py | 4 ++++ ros2_ws/src/embr/embr/sensors/probeMotorBase.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ros2_ws/src/embr/embr/radio.py b/ros2_ws/src/embr/embr/radio.py index 1109cf1..f54ccbb 100644 --- a/ros2_ws/src/embr/embr/radio.py +++ b/ros2_ws/src/embr/embr/radio.py @@ -50,6 +50,10 @@ def __init__(self): ) self.get_logger().info('Radio Subscriber node initialized') + + self.radio_connection.read() + self.get_logger().info('done reading') + def temperature_callback(self, msg): """Handle temperature messages.""" diff --git a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py index 898460e..192abc6 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py @@ -34,9 +34,12 @@ def __init__(self, config: Optional[SensorConfig] = None): self._running = False self.start() + print("before config init:") + for setting, value in self.CONFIG.items(): + print(f"{setting} : {value}") + # initialize CONFIG settings' value params = config["params"] - print("params initialized") if params: for setting, value in params.items(): if params[setting]: @@ -46,6 +49,10 @@ def __init__(self, config: Optional[SensorConfig] = None): else: raise ValueError(f"Probe motor configuration is empty") + print("after config init:") + for setting, value in self.CONFIG.items(): + print(f"{setting} : {value}") + @@ -106,12 +113,13 @@ class RealProbeMotor(ProbeMotorBase): def __init__(self, config: Optional[ProbeMotorConfig] = None): super().__init__(config) - self.client = None def _write_registers(self, register, value): if register["length"] == 1: return self.client.write_registers(register["address"], [value]) elif register["length"] == 2: + high = (value >> 16) & 0xFFFF + low = value & 0xFFFF return self.client.write_registers(register["address"], [low,high]) def _read_registers(self, register): From d296844c65dd69f0ebfaa22ccd60b434212ed4cf Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Sat, 14 Mar 2026 14:03:21 -0700 Subject: [PATCH 21/55] 1. Changed probeMotor abstract class to inherit Sensor base. 2. Added parameter for motion_range, max_velocity, and acceleration. 3. Added parameter change value validation to ensure values are within range. --- ros2_ws/src/embr/config/sensors.json | 2 +- ros2_ws/src/embr/embr/ProbeMotor.py | 75 +++-- ros2_ws/src/embr/embr/sensors/__init__.py | 7 +- ros2_ws/src/embr/embr/sensors/factory.py | 132 +------- ros2_ws/src/embr/embr/sensors/probeMotor.py | 296 ++++++++++++++++++ .../src/embr/embr/sensors/probeMotorBase.py | 263 ---------------- 6 files changed, 352 insertions(+), 423 deletions(-) create mode 100644 ros2_ws/src/embr/embr/sensors/probeMotor.py delete mode 100644 ros2_ws/src/embr/embr/sensors/probeMotorBase.py diff --git a/ros2_ws/src/embr/config/sensors.json b/ros2_ws/src/embr/config/sensors.json index 3fdbc06..467dc12 100644 --- a/ros2_ws/src/embr/config/sensors.json +++ b/ros2_ws/src/embr/config/sensors.json @@ -17,7 +17,7 @@ "probeMotor": { "mode": "real", "device": "", - "baud": "", + "baud": 0, "params": { "max_velocity": 768000, "acceleration": 1000000, diff --git a/ros2_ws/src/embr/embr/ProbeMotor.py b/ros2_ws/src/embr/embr/ProbeMotor.py index fb01dc9..90e5f33 100644 --- a/ros2_ws/src/embr/embr/ProbeMotor.py +++ b/ros2_ws/src/embr/embr/ProbeMotor.py @@ -5,14 +5,14 @@ from std_msgs.msg import Int32 from msg_interface.msg import ProbeMotorFeedback -from embr.sensors import create_sensor, ProbeMotorConfig - +from embr.sensors import create_sensor +from rcl_interfaces.msg import SetParametersResult class ProbeMotor(Node): def __init__(self): super().__init__("probe_motor_server") - + self._log = self.get_logger() self.callback_group = ReentrantCallbackGroup() @@ -21,62 +21,81 @@ def __init__(self): self.declare_parameter('config_file', '') config_path = self.get_parameter('config_file').value - - # Create probeMotor + # Create probeMotor interface try: self.probeMotor = create_sensor('probeMotor', config_path) mode_type = 'simulated' if isinstance(self.probeMotor.__class__.__name__, str) and 'Sim' in self.probeMotor.__class__.__name__ else 'real' self._log.info(f'probe motor initialized in {mode_type} mode (using {mode_type} probe motor)') - if not self.probeMotor.start(): - self._log.error("Failed to start probe motor") + self.probeMotor.start() except Exception as e: self._log.error(f'Failed to initialize probe motor: {e}') - # to do : create parameters that is synchronized with ProbeMotor().CONFIG and does input checking - # steps_per_distance - # motion_range - # max_velocity - # acceleration + config = self.probeMotor.CONFIG + + self.declare_parameter('motion_range', config["motion_range"]['value']) + self.declare_parameter('max_velocity', config['max_velocity']['value']) + self.declare_parameter('acceleration', config['acceleration']['value']) + + self.add_on_set_parameters_callback(self.parameter_callback) - # move topic - self.moveTopic = self.create_subscription(Int32, 'move_probe_motor', self.move_callback ,10, callback_group=self.callback_group) + # move probe topic + self.moveTopic = self.create_subscription(Int32, 'move_probe', self.move_callback ,10, callback_group=self.callback_group) - # feedback topic + # post-move probe feedback topic self.feedbackTopic = self.create_publisher(ProbeMotorFeedback, 'probe_motor_feedback',10) - + def parameter_callback(self, params): + + for param in params: + try: + self.probeMotor.setConfigSetting(param.name, param.value) + except Exception as e: + return SetParametersResult(successful=False, reason=str(e)) + + return SetParametersResult(successful=True) + async def move_callback(self, received_msg : Int32) -> None: + """ + Callback that handles move probe publishes. Commands the probe motor to move via its interface. + Publishes the feedback of probe motor after a command. + + Args: + received_msg(Int32): The position to move the probe to in cm. + """ response_msg = ProbeMotorFeedback() move_position = received_msg.data try: - originalPosition = self.probeMotor.readPosition() - # if originalPosition == move_position: self._log.info(f"probe already at position {originalPosition}") response_msg.has_moved = False response_msg.position = originalPosition else: - success = await self.probeMotor.moveAbsolute(move_position) - + await self.probeMotor.moveAbsolute(move_position) newPosition = self.probeMotor.readPosition() + response_msg.position = newPosition response_msg.has_moved = newPosition != originalPosition + self._log.info(f"probe moved {abs((originalPosition - newPosition) / (move_position - originalPosition) * 100)}%, position: {newPosition}cm") - except Exception as e: - self._log.error(f"Failed to command probe motor: {e}") + + except (Exception, ValueError) as e: + self._log.error(f"Communication failed with probe motor: {e}") + + except RuntimeError as e: + self._log.error(e) self.feedbackTopic.publish(response_msg) def shutdown(self) -> None: - try: - if not self.probeMotor.stop(): - self._log.error("Failed to stop probe motor") - except Exception as e: - self._log.error(f"Failed to shutdown: {e}") + """ + Terminate the connection and interface with the probe motor. + """ + self.probeMotor.stop() + def main(args=None): @@ -92,7 +111,7 @@ def main(args=None): pass finally: - probeMotor_node.shutdown() + probeMotor_node.shutdown() # close connection to probe motor before destroying node probeMotor_node.destroy_node() rclpy.shutdown() diff --git a/ros2_ws/src/embr/embr/sensors/__init__.py b/ros2_ws/src/embr/embr/sensors/__init__.py index 6e68e34..662726c 100644 --- a/ros2_ws/src/embr/embr/sensors/__init__.py +++ b/ros2_ws/src/embr/embr/sensors/__init__.py @@ -4,20 +4,17 @@ """ from .base import Sensor, SensorConfig -from .factory import SensorFactory, ProbeMotorFactory, create_sensor, create_probeMotor +from .factory import SensorFactory, create_sensor from .temperature import TemperatureSensor, RealTemperatureSensor, SimTemperatureSensor from .cube import CubeSensor, RealCubeSensor, SimCubeSensor from .radio import RadioConnection, RealRadioConnection, SimRadioConnection -from .probeMotorBase import ProbeMotorConfig, RealProbeMotor, SimProbeMotor +from .probeMotor import RealProbeMotor, SimProbeMotor __all__ = [ 'Sensor', 'SensorConfig', - 'ProbeMotorConfig', 'SensorFactory', - 'MotorFactory', 'create_sensor', - 'create_probeMotor', 'TemperatureSensor', 'RealTemperatureSensor', 'SimTemperatureSensor', diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index f329bde..c800763 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -9,8 +9,7 @@ from .temperature import RealTemperatureSensor, SimTemperatureSensor from .cube import RealCubeSensor, SimCubeSensor from .radio import RealRadioConnection, SimRadioConnection - -from .probeMotorBase import ProbeMotorConfig, ProbeMotorBase, RealProbeMotor, SimProbeMotor +from .probeMotor import ProbeMotorBase, RealProbeMotor, SimProbeMotor class SensorFactory: """Factory for creating sensor instances.""" @@ -63,12 +62,11 @@ def create(cls, sensor_type: str, config_path: Optional[str] = None): # Get the appropriate class sensor_classes = cls.SENSOR_MAP[sensor_type] sensor_class = sensor_classes.get(mode) - + if not sensor_class: raise ValueError(f"Unknown mode '{mode}' for sensor '{sensor_type}'") # Create and return instance - try: return sensor_class(config) except Exception as e: @@ -87,8 +85,8 @@ def _determine_mode(cls, sensor_type: str, config: SensorConfig) -> str: 'real' or 'sim' """ # If mode is explicitly set to 'real' or 'sim', use it - if config["mode"] in ('real', 'sim'): - return config["mode"] + if config.mode in ('real', 'sim'): + return config.mode # Default to real hardware return 'real' @@ -125,7 +123,7 @@ def load_config(cls, device_type: str ,config_path: Optional[str] = None) -> Sen try: with open(config_path, 'r') as f: data = json.load(f) - return data[device_type] + return SensorConfig(**data[device_type]) except Exception as e: raise RuntimeError(f"Failed to load config from {config_path}: {e}") @@ -142,122 +140,4 @@ def create_sensor(sensor_type: str, config: Optional[SensorConfig] = None) -> Se Returns: Sensor instance """ - return SensorFactory.create(sensor_type, config) - - -class ProbeMotorFactory: - - # Mapping of sensor types to their real/sim implementations - SENSOR_MAP = { - 'probeMotor': { - 'real': RealProbeMotor, - 'sim': SimProbeMotor, - } - } - - @classmethod - def create(cls, probe_type: str, config_path: Optional[str] = None) -> ProbeMotorBase: - if probe_type not in cls.SENSOR_MAP: - raise ValueError(f"Unknown probe type: {probe_type}") - - config = cls.load_config(config_path) - - # Determine mode - default to 'real' unless specified otherwise - mode = cls._determine_mode(probe_type, config) - - # Get the appropriate class - probeMotor_classes = cls.SENSOR_MAP[probe_type] - - probeMotor_class = probeMotor_classes[mode] - - if not probeMotor_class: - raise ValueError(f"Unknown mode '{mode}' for probe motor '{probe_type}'") - - # Create and return instance - try: - return probeMotor_class(config) - except Exception as e: - raise RuntimeError(f"Failed to create {probe_type} probe motor in {mode} mode: {e}") - - @classmethod - def _determine_mode(cls, probeMotor_type: str, config: ProbeMotorConfig) -> str: - """ - Determine the probe motor mode based on configuration. - - Priority order: - 1. Explicit mode in config (if 'real' or 'sim') - 2. Default to 'real' - - Returns: - 'real' or 'sim' - """ - - # If mode is explicitly set to 'real' or 'sim', use it - if config["mode"] in ('real', 'sim'): - return config["mode"] - - # Default to real hardware - return 'real' - - @classmethod - def load_config(cls, config_path: Optional[str] = None) -> Dict[str, ProbeMotorConfig]: - """ - Load probe motor configuration from JSON file. - - Args: - config_path: Path to config file (default: config/sensors.json in workspace) - - Returns: - Dictionary mapping sensor names to SensorConfig objects - """ - - if config_path is None: - # Look for config in common locations within workspace - search_paths = [ - 'config/sensors.json', - 'sensors.json', - os.path.expanduser('~/.embr/sensors.json'), - '/etc/embr/sensors.json', - ] - - for path in search_paths: - if os.path.exists(path): - config_path = path - break - - if config_path is None or not os.path.exists(config_path): - return {} - - - - try: - with open(config_path, 'r') as f: - data = json.load(f) - - if data["probeMotor"]: - return data["probeMotor"] - else: - raise ValueError(f"probeMotor config is undefined in {config_path}") - - except Exception as e: - raise RuntimeError(f"Failed to load config from {config_path}: {e}") - - -def create_probeMotor(probeMotor_type: str, config_path: Optional[str] = None) -> ProbeMotorBase: - """ - Convenience function to create a sensor. - - Args: - sensor_type: Type of sensor - config: Optional sensor configuration - - Returns: - Sensor instance - """ - return ProbeMotorFactory.create(probeMotor_type, config_path) - - - - - - + return SensorFactory.create(sensor_type, config) \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py new file mode 100644 index 0000000..c49a051 --- /dev/null +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -0,0 +1,296 @@ +### to do : handle case where physical connection to motor is lost during operation + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from dataclasses import dataclass, field + +import time # for time.sleep() in SimProbeMotor::move() +import random + +from pymodbus.client import ModbusTcpClient + +from embr.sensors import SensorConfig +from embr.sensors import Sensor + +class ProbeMotorBase(Sensor): + + CONFIG = { # settings : {value,range} + "steps_per_distance" : { # unit in step/centi meter, translation of + "value" : 51200, # rotation(steps) to vertical displacement + "range" : [0,51200] + }, + "motion_range" : { # unit in centi meter + "value" : 10, + "range" : [0,10] + }, + "max_velocity" : { # unit in step/second + "value" : 768000, + "range" : [0, 768000] + }, + "acceleration" : { # unit in step/second^2 + "value" : 1000000, + "range" : [0,1000000] + } + } + + def __init__(self, config: Optional[SensorConfig] = None): + super().__init__(config) + + # start to update the running state + self.start() + + # retrieve all the parameters from the supplied config + params = config.params + + # update the respective config settings using the parameters + if params: + for setting, value in params.items(): + if params[setting]: + self.setConfigSetting(setting, value) + else: + raise ValueError(f"Invalid configuration setting") + else: + raise ValueError(f"Probe motor configuration is empty") + + @abstractmethod + async def moveAbsolute(self, distance : int) -> None: + """ + Spin the motor in steps equivalent to the translated vertical distance of arg-distance. + + Args: + distance (int): distance in cm. + """ + + pass + + @abstractmethod + def setConfigSetting(self, setting : str, value : int) -> None: + """ + Sets the config setting value to arg-value and update the probe motor's setting if required. + + Args: + setting (string): Setting label + value (int): Setting value + """ + pass + + @property + def is_running(self) -> bool: + """Check if probe motor is running.""" + # to do: override this RealProbeMotor to also check self.client.connected + return self._running + + def set_running(self) -> None: + self._running = True + + def stop_running(self) -> None: + self._running = False + + def getConfig(): + return self.config + + + +class RealProbeMotor(ProbeMotorBase): + + DRIVE_IP = '192.168.33.1' # default drive IP + DRIVE_PORT = 502 # Standard MODBUS TCP port + + REGISTERS = { #length = number of registers + "move_absolute" : {"address" : 0x0043, "length" : 2}, + "position_counter" : {"address" : 0x0057, "length" : 2}, + "max_velocity" : {"address" : 0x008B, "length" : 2}, + "acceleration" : {"address" : 0x0000, "length" : 2}, + "moving" : {"address" : 0x004A, "length" : 1}, + } + + def __init__(self, config: Optional[SensorConfig] = None): + super().__init__(config) + + def _write_registers(self, register, value: int) -> None: + """ + Directly communicate with the probe motor via ethernet by manipulating the probe motor + registers to command it, change configuration, or mode. + + Args: + register (dict[str, [str: int, str: int]]): Register dictionary containing + the register address and number + of relavent registers + value (int): The value to write to the respective register(s). + """ + + if register["length"] == 1: + response = self.client.write_registers(register["address"], [value]) + + if response.isError(): + raise RuntimeError(f"Error writing {value} to register {register}") + + elif register["length"] == 2: + # encode int_32 into two int_16 values(one lower 16-bits, one upper 16-bits) + high = (value >> 16) & 0xFFFF + low = value & 0xFFFF + return self.client.write_registers(register["address"], [low,high]) + + + def _read_registers(self, register): + if register["length"] == 1: + response = self.client.read_holding_registers(register["address"], count = 1) + + if not response.isError(): + return response, response.registers[0] + + else: + raise RuntimeError(f"Error reading from register {register}") + + elif register["length"] == 2: + response = self.client.read_holding_registers(register["address"], count = 2) + + if not response.isError(): + # decode two int_16 values(one lower 16-bits, one upper 16-bits) into one int_32 value + value = response.registers[0] | (response.registers[1] << 16) + return response, value + + else: + raise RuntimeError(f"Error reading from register {register}") + + + def start(self) -> None: + #Establish connection to the probe motor + self.client = ModbusTcpClient(self.DRIVE_IP, port=self.DRIVE_PORT) + self.client.connect() + + if not self.client.connected: + raise RuntimeError("Failed to establish connection with probe motor") + + self.set_running() + + + async def _waitTillMoveFinish(self) -> None: + """ + Meant to be used by self.moveAbsolute(distance). + Wait till probe motor has finished moving to specified position. + """ + + # Assume (reasonably) probe motor is moving after a successful move command + is_moving = True + + # check every half a second for the move status of the probe motor + while(is_moving): + time.sleep(0.5) + response, value = self._read_registers(self.REGISTERS["moving"]) + is_moving = value + + + async def moveAbsolute(self, position) -> None: + if not self.is_running: + raise Exception("probe motor is not running") + + positionInSteps = position * self.CONFIG["steps_per_distance"] + + # spin the motor by number of steps + response = self._write_registers(self.REGISTERS["move_absolute"], positionInSteps) + + await self._waitTillMoveFinish() + return True + + + def readPosition(self) -> int: + if not self.is_running: + raise Exception("probe motor is not running") + + response, positionInSteps = self._read_registers(self.REGISTERS["position_counter"]) + + positionInDistance = positionInSteps / self.CONFIG["steps_per_distance"] + + return int(positionInDistance) + + def read(self) -> int: + return readPosition() + + + def setConfigSetting(self, setting : str, value : int) -> None: + if setting in self.REGISTERS: + lowerbound = self.CONFIG[setting]['range'][0] + upperbound = self.CONFIG[setting]['range'][1] + + if not (value in range(lowerbound,upperbound)): + raise ValueError(f'{setting} value {value} is not in range [{lowerbound}, {upperbound}]') + + response = self._write_registers(self.REGISTERS[setting], value) + self.CONFIG[setting]["value"] = value + + else: + if self.CONFIG[setting]["value"]: + self.CONFIG[setting]["value"] = value + + else: + raise ValueError(f"No such {setting} setting in config") + + + def stop(self) -> bool: + if not self.is_running: + raise Exception("No active connection to probe motor") + + # disconnect from the drive + self.client.close() + + self.stop_running() + + +class SimProbeMotor(ProbeMotorBase): + TIME_PER_DISTANCE = 0.5 # in second/distance + POSITION = 0 # in steps + + def __init__(self, config: Optional[SensorConfig] = None): + super().__init__(config) + + def start(self) -> None: + if not self.is_running: + self.set_running() + + + async def moveAbsolute(self, position) -> bool: + if not self.is_running: + raise Exception("No active connection to probe motor") + + if position > self.POSITION: + # apply a 90% probability that the probe doesn't fully extend to the provided position + if random.random() > 0.9: + position = random.randint(0,position) + + # sleep to mimic the time a motor would take to complete spin + time.sleep(abs((position + - (self.POSITION/self.CONFIG["steps_per_distance"] if self.POSITION else 0)) + * self.TIME_PER_DISTANCE)) # /100 to convert mili sec to sec + + # update the position by converting received position from cm to steps + self.POSITION = position * self.CONFIG["steps_per_distance"] + + + def readPosition(self) -> int: + return int(self.POSITION / self.CONFIG["steps_per_distance"]) + + def read(self) -> int: + return readPosition() + + + def setConfigSetting(self, setting : str, value : int) -> None: + + if not self.CONFIG[setting]: + raise ValueError(f"No such {setting} setting in config") + + else: + lowerbound = self.CONFIG[setting]['range'][0] + upperbound = self.CONFIG[setting]['range'][1] + + if not (lowerbound <= value <= upperbound): + raise Exception(f'{setting} value {value} is not in range [{lowerbound}, {upperbound}]') + + self.CONFIG[setting]["value"] = value + + + def stop(self) -> None: + if not self.is_running: + raise Exception("No active connection to probe motor") + + self.stop_running() \ No newline at end of file diff --git a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py b/ros2_ws/src/embr/embr/sensors/probeMotorBase.py deleted file mode 100644 index 192abc6..0000000 --- a/ros2_ws/src/embr/embr/sensors/probeMotorBase.py +++ /dev/null @@ -1,263 +0,0 @@ -### to do : handle case where physical connection to motor is lost during operation - -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional -from dataclasses import dataclass, field - -import time # for time.sleep() in SimProbeMotor::move() -import random - -from pymodbus.client import ModbusTcpClient - -from embr.sensors import SensorConfig - -@dataclass -class ProbeMotorConfig: - mode: str - device: Optional[str] - baud: Optional[str] - params: Dict[str, Any] - - -class ProbeMotorBase(ABC): - - CONFIG = { # settings : value - "steps_per_distance" : -1, # unit in step/centi meter, translation of rotation(steps) to vertical displacement # to do: needs to be calliberated - "motion_range" : -1, # unit in centi meter - "max_velocity" : -1, # unit in step/second - "acceleration" : -1 # unit in step/second^2 - } - - def __init__(self, config: Optional[SensorConfig] = None): - - - self._running = False - self.start() - - print("before config init:") - for setting, value in self.CONFIG.items(): - print(f"{setting} : {value}") - - # initialize CONFIG settings' value - params = config["params"] - if params: - for setting, value in params.items(): - if params[setting]: - self.setConfigSetting(setting, value) - else: - raise ValueError(f"Invalid configuration setting") - else: - raise ValueError(f"Probe motor configuration is empty") - - print("after config init:") - for setting, value in self.CONFIG.items(): - print(f"{setting} : {value}") - - - - - @abstractmethod - def start(self) -> bool: - pass - - @abstractmethod - async def moveAbsolute(self, distance) -> bool: - # to do: - # check distance is within motion range - pass - - @abstractmethod - def readPosition(self) -> int: - pass - - @abstractmethod - def setConfigSetting(self, setting, value) -> bool: - # to do: - # raise possible errors: - # motion_range > steps_per_distance - # all config values = 0 - pass - - @abstractmethod - def stop(self) -> bool: - pass - - - @property - def is_running(self) -> bool: - """Check if probe motor is running.""" - # to do: override this RealProbeMotor to also check self.client.connected - return self._running - - def set_running(self) -> None: - self._running = True - - def stop_running(self) -> None: - self._running = False - - def getConfig(): - return - - -class RealProbeMotor(ProbeMotorBase): - - DRIVE_IP = '192.168.33.1' # default drive IP - DRIVE_PORT = 502 # Standard MODBUS TCP port - - REGISTERS = { - "move_absolute" : {"address" : 0x0043, "length" : 2}, - "position_counter" : {"address" : 0x0057, "length" : 2}, - "max_velocity" : {"address" : 0x008B, "length" : 2}, - "acceleration" : {"address" : 0x0000, "length" : 1} - } - - def __init__(self, config: Optional[ProbeMotorConfig] = None): - super().__init__(config) - - def _write_registers(self, register, value): - if register["length"] == 1: - return self.client.write_registers(register["address"], [value]) - elif register["length"] == 2: - high = (value >> 16) & 0xFFFF - low = value & 0xFFFF - return self.client.write_registers(register["address"], [low,high]) - - def _read_registers(self, register): - if register["length"] == 1: - response = self.client.read_holding_registers(register["address"]) - if not response.isError(): - return response, response.value - else: - return response, None - elif register["length"] == 2: - response = self.client.read_holding_registers(register["address"]) - if not response.isError(): - value = response.registers[0] | (response.registers[1] >> 16) - return response, value - else: - return response, None - - def _updateMotorSettings(self, setting : str, value : int) -> bool: - response = self._write_registers(self.REGISTERS["setting"], value) - if response.isError(): - return False - - - def start(self) -> bool: - - if self.is_running: - return True - - #Establish connection to the probe motor - self.client = ModbusTcpClient(self.DRIVE_IP, port=self.DRIVE_PORT) - self.client.connect() - - if not self.client.connected: - return False - - self.set_running() - return True - - async def moveAbsolute(self, position) -> bool: - - if not self.is_running: - return False - - positionInSteps = position * self.CONFIG["steps_per_distance"] - - # spin the motor by number of steps - response = self._write_registers(REGISTERS["move_absolute"], positionInSteps) - if response.isError(): - # log error - return False - - return True - - def readPosition(self) -> int: - - if not self.is_running: - raise Exception("probe motor is not running") - - response, positionInSteps = self._read_registers(REGISTERS["position_counter"]) - - if response.isError(): - raise Exception("met with response error") - - positionInDistance = positionInSteps * self.CONFIG["steps_per_distance"] - - return int(positionInDistance) - - def setConfigSetting(self, setting : str, value : int) -> bool: - if not self.CONFIG[setting]: - return False - - elif not self._updateMotorSettings(setting,value): - return False - - else: - self.CONFIG[setting] = value - return True - - def stop(self) -> bool: - - if not self.is_running: - raise Exception("No active connection to probe motor") - - # disconnect from the drive - self.client.close() - - self.stop_running - return True - - -class SimProbeMotor(ProbeMotorBase): - - TIME_PER_DISTANCE = 0.5 # in second/distance - POSITION = 0 # in steps - - def __init__(self, config: Optional[ProbeMotorConfig] = None): - super().__init__(config) - - def start(self) -> bool: - if self.is_running: - return True - self.set_running() - return True - - async def moveAbsolute(self, position) -> bool: - if not self.is_running: - return False - - if position > self.POSITION: - # apply a 90% probability that the probe doesn't fully extend to the provided position - if random.random() > 0.9: - position = random.randint(0,position) - - # sleep to mimic the time a motor would take to complete spin - time.sleep(abs((position - (self.POSITION/self.CONFIG["steps_per_distance"] if self.POSITION else 0)) * self.TIME_PER_DISTANCE)) # /100 to convert mili sec to sec - - # update the position by converting received position from cm to steps - self.POSITION = position * self.CONFIG["steps_per_distance"] - return True - - def readPosition(self) -> int: - return int(self.POSITION / self.CONFIG["steps_per_distance"]) - - def setConfigSetting(self, setting : str, value : int) -> bool: - if not self.CONFIG[setting]: - return False - - else: - self.CONFIG[setting] = value - - return True - - - def stop(self) -> bool: - - if not self.is_running: - raise Exception("No active connection to probe motor") - - self.stop_running() - - return True From 6168a5136508e6b606ee3ac9b6a72934cad91bb3 Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Sat, 14 Mar 2026 14:15:16 -0700 Subject: [PATCH 22/55] added dummy fields to match the real sensors.json --- ros2_ws/src/embr/config/sensors_sim.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ros2_ws/src/embr/config/sensors_sim.json b/ros2_ws/src/embr/config/sensors_sim.json index 2581dcd..4176eea 100644 --- a/ros2_ws/src/embr/config/sensors_sim.json +++ b/ros2_ws/src/embr/config/sensors_sim.json @@ -24,8 +24,11 @@ "radio": { "mode": "sim" }, + "probeMotor": { "mode": "sim", + "device": "", + "baud": 0, "params": { "max_velocity": 768000, "acceleration": 1000000, From 42894538d707ef905882e4b50215c467597938cb Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Sat, 14 Mar 2026 14:22:04 -0700 Subject: [PATCH 23/55] Handle the case where extend probe requested without distance; resort to motion_range set in CONFIG. --- ros2_ws/src/embr/embr/ProbeMotor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ros2_ws/src/embr/embr/ProbeMotor.py b/ros2_ws/src/embr/embr/ProbeMotor.py index 90e5f33..9c3a5e1 100644 --- a/ros2_ws/src/embr/embr/ProbeMotor.py +++ b/ros2_ws/src/embr/embr/ProbeMotor.py @@ -3,6 +3,7 @@ from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.executors import MultiThreadedExecutor +from typing import Optional from std_msgs.msg import Int32 from msg_interface.msg import ProbeMotorFeedback from embr.sensors import create_sensor @@ -54,7 +55,7 @@ def parameter_callback(self, params): return SetParametersResult(successful=True) - async def move_callback(self, received_msg : Int32) -> None: + async def move_callback(self, received_msg : Optional[Int32] = None) -> None: """ Callback that handles move probe publishes. Commands the probe motor to move via its interface. Publishes the feedback of probe motor after a command. @@ -63,7 +64,8 @@ async def move_callback(self, received_msg : Int32) -> None: received_msg(Int32): The position to move the probe to in cm. """ response_msg = ProbeMotorFeedback() - move_position = received_msg.data + + move_position = received_msg.data or self.probeMotor.CONFIG['motion_range']['value'] # If range not specified use motion range try: originalPosition = self.probeMotor.readPosition() From 43c5ee2205d80ac1aa491ae761b670ef13f8c7f5 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 21 Mar 2026 09:16:09 -0700 Subject: [PATCH 24/55] Visual Breakdown of MODBUS/TCP --- Documentation/2025/Images/image.png | Bin 0 -> 63326 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Documentation/2025/Images/image.png diff --git a/Documentation/2025/Images/image.png b/Documentation/2025/Images/image.png new file mode 100644 index 0000000000000000000000000000000000000000..5cd3d7a11dc6d99a547db568f4f0dcb7b8ee7992 GIT binary patch literal 63326 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 rQ2aR&5z+a%SC7>?ctA#2YKV!Do2^ Date: Sat, 21 Mar 2026 09:17:03 -0700 Subject: [PATCH 25/55] Rename image.png to TCP-ADU.png --- .../2025/Images/{image.png => TCP-ADU.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename Documentation/2025/Images/{image.png => TCP-ADU.png} (100%) diff --git a/Documentation/2025/Images/image.png b/Documentation/2025/Images/TCP-ADU.png similarity index 100% rename from Documentation/2025/Images/image.png rename to Documentation/2025/Images/TCP-ADU.png From b4c43ca3d9f22616666f3cba1144928343afc131 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 21 Mar 2026 09:17:38 -0700 Subject: [PATCH 26/55] Visual Breakdown of MODBUS/TCP --- Documentation/2025/Images/image.png | Bin 0 -> 95605 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Documentation/2025/Images/image.png diff --git a/Documentation/2025/Images/image.png b/Documentation/2025/Images/image.png new file mode 100644 index 0000000000000000000000000000000000000000..bc8ad830c7c66f884ae58c2af14d3bced5dad1e3 GIT binary patch literal 95605 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 z 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%0D8pKJmXZ#f-10;By(#&FV{Q3s&3S^{!%SNb$~s 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 Date: Sat, 21 Mar 2026 09:18:14 -0700 Subject: [PATCH 27/55] Rename image.png to MODBUS.png --- Documentation/2025/Images/{image.png => MODBUS.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename Documentation/2025/Images/{image.png => MODBUS.png} (100%) diff --git a/Documentation/2025/Images/image.png b/Documentation/2025/Images/MODBUS.png similarity index 100% rename from Documentation/2025/Images/image.png rename to Documentation/2025/Images/MODBUS.png From 93e4c0d397129e891a37c09ee1d475d8444846ae Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 21 Mar 2026 09:28:36 -0700 Subject: [PATCH 28/55] Add documentation for probe-motor communication This document provides an overview of the LMDCE421 probe-motor communication, including its goals, functionality, and communication medium. --- .../2025/probe-motor-communication.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Documentation/2025/probe-motor-communication.md diff --git a/Documentation/2025/probe-motor-communication.md b/Documentation/2025/probe-motor-communication.md new file mode 100644 index 0000000..c375c10 --- /dev/null +++ b/Documentation/2025/probe-motor-communication.md @@ -0,0 +1,36 @@ +# Overview +This documentation serves as a resource to understand the implementation of the LMDCE421(probe-motor) communication. + +This documentation hopes to achieve 2 goals: +1. Educate the reader with just enough knowledge to make incremental changes to the probe-motor codebase +2. Educate the reader with enough knowledge to rebuild the probe-motor codebase + +With these goals in mind I have indicated (opt) next to topics or sections that I deem to be irrelevant to goal 1. + +Focus here is on the communication medium and the interface. + +# Table Of Content +Probe-Motor + - [LMDCE421](#lmdce421) + - [Function Support](#function-support) + +Communication Medium + - [MODBUS/TCP](#modbus/tcp) + - [pymodbus](#pymodbus) + +## Probe-Motor +The probe-motor's task is to provide linear motion to a temperature probe allowing the probe to take reading closer to the surface of the hotspot. The chosen +motor for this task is the LMDCE421, a stepper with high precision and high load capacity. + +### LMDCE421 + +### Function Support + + +## Communication Medium +The LMDCE421 offers an ethernet and serial interface. We have used the ethernet interface communication because there is extensive library support for encoding/decoding data +easily over this interface and communication can easily be /reproduced from any computer with an ethernet port. MODBUS/TCP is the chosen communication protocol for this interface. + +### MODBUS/TCP + +### pymodbus From c721e6a30d0317fb78813a5aa8e4158824f5ead2 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 21 Mar 2026 13:53:04 -0700 Subject: [PATCH 29/55] corrected reference --- ros2_ws/src/embr/embr/sensors/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/__init__.py b/ros2_ws/src/embr/embr/sensors/__init__.py index 662726c..333606d 100644 --- a/ros2_ws/src/embr/embr/sensors/__init__.py +++ b/ros2_ws/src/embr/embr/sensors/__init__.py @@ -25,5 +25,5 @@ 'RealRadioConnection', 'SimRadioConnection', 'RealProbeMotor', - 'SimProbeMotorr' + 'SimProbeMotor' ] From 29499603af7c53bfbf797914bb06f7392ab6438c Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 21 Mar 2026 14:29:18 -0700 Subject: [PATCH 30/55] updated incorrect references --- ros2_ws/src/embr/embr/ProbeMotor.py | 2 +- ros2_ws/src/embr/embr/nav.py | 2 +- ros2_ws/src/embr/embr/sensors/probeMotor.py | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ros2_ws/src/embr/embr/ProbeMotor.py b/ros2_ws/src/embr/embr/ProbeMotor.py index 9c3a5e1..c455fcd 100644 --- a/ros2_ws/src/embr/embr/ProbeMotor.py +++ b/ros2_ws/src/embr/embr/ProbeMotor.py @@ -65,7 +65,7 @@ async def move_callback(self, received_msg : Optional[Int32] = None) -> None: """ response_msg = ProbeMotorFeedback() - move_position = received_msg.data or self.probeMotor.CONFIG['motion_range']['value'] # If range not specified use motion range + move_position = received_msg.data try: originalPosition = self.probeMotor.readPosition() diff --git a/ros2_ws/src/embr/embr/nav.py b/ros2_ws/src/embr/embr/nav.py index 83c35b1..1f90457 100644 --- a/ros2_ws/src/embr/embr/nav.py +++ b/ros2_ws/src/embr/embr/nav.py @@ -12,7 +12,7 @@ class Nav(Node): def __init__(self): super().__init__('nav') - self.moveProbeMotorTopic = self.create_publisher(Int32, 'move_probe_motor', 10) + self.moveProbeMotorTopic = self.create_publisher(Int32, 'move_probe', 10) self.probeMotorFeedbackTopic = self.create_subscription(ProbeMotorFeedback, 'probe_motor_feedback', self.probeMotorFeedback_callback, 10) self.probe_motor_feedback_event = threading.Event() diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index c49a051..e6f4304 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -185,7 +185,11 @@ async def moveAbsolute(self, position) -> None: if not self.is_running: raise Exception("probe motor is not running") - positionInSteps = position * self.CONFIG["steps_per_distance"] + motion_range = self.CONFIG["motion_range"]["value"] + if not (0 <= position <= motion_range): + raise ValueError(f"Position {position}cm is out of motion range [0, {motion_range}]") + + positionInSteps = position * self.CONFIG["steps_per_distance"]["value"] # spin the motor by number of steps response = self._write_registers(self.REGISTERS["move_absolute"], positionInSteps) @@ -200,7 +204,7 @@ def readPosition(self) -> int: response, positionInSteps = self._read_registers(self.REGISTERS["position_counter"]) - positionInDistance = positionInSteps / self.CONFIG["steps_per_distance"] + positionInDistance = positionInSteps / self.CONFIG["steps_per_distance"]["value"] return int(positionInDistance) @@ -213,7 +217,7 @@ def setConfigSetting(self, setting : str, value : int) -> None: lowerbound = self.CONFIG[setting]['range'][0] upperbound = self.CONFIG[setting]['range'][1] - if not (value in range(lowerbound,upperbound)): + if not (lowerbound <= value <= upperbound): raise ValueError(f'{setting} value {value} is not in range [{lowerbound}, {upperbound}]') response = self._write_registers(self.REGISTERS[setting], value) @@ -260,15 +264,15 @@ async def moveAbsolute(self, position) -> bool: # sleep to mimic the time a motor would take to complete spin time.sleep(abs((position - - (self.POSITION/self.CONFIG["steps_per_distance"] if self.POSITION else 0)) + - (self.POSITION/self.CONFIG["steps_per_distance"]["value"] if self.POSITION else 0)) * self.TIME_PER_DISTANCE)) # /100 to convert mili sec to sec # update the position by converting received position from cm to steps - self.POSITION = position * self.CONFIG["steps_per_distance"] + self.POSITION = position * self.CONFIG["steps_per_distance"]["value"] def readPosition(self) -> int: - return int(self.POSITION / self.CONFIG["steps_per_distance"]) + return int(self.POSITION / self.CONFIG["steps_per_distance"]["value"]) def read(self) -> int: return readPosition() From a934b6256ebe4abc9f3bdb37e24b2794aa6ee082 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 1 Apr 2026 13:39:20 -0700 Subject: [PATCH 31/55] Revise probe-motor documentation for clarity and detail --- Documentation/2025/probe-motor.md | 248 ++++++++++++++++++++++++++++-- 1 file changed, 234 insertions(+), 14 deletions(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index ad5e2b1..de8dd2b 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -1,12 +1,119 @@ -# Rover Probe Motor Control System (EMBR) +# Rover Probe Motor Control System +# Overview +This documentation provides setup guide and serves as a resource to understand the implementation of the LMDCE421(probe-motor) communication. +This documentation hopes to achieve 2 goals: +1. Educate the reader with just enough knowledge to make incremental changes to the probe-motor codebase. +2. Educate the reader with enough knowledge to rebuild the probe-motor codebase. + +With these goals in mind I have indicated (opt) next to topics or sections that I deem to be irrelevant to goal 1. + +# Table Of Content +Probe-Motor + - [LMDCE421](#lmdce421) + - [Function Support](#function-support) + + +Communication Medium + - [MODBUS/TCP](#modbus/tcp) + - [ROS2 Network](#ros2-network) + - [pymodbus](#pymodbus) + +Setup + - Static IP Setup + +--- + +# Probe-Motor +The probe-motor's task is to provide linear motion to a temperature probe, allowing the probe to take readings closer to the surface of the hotspot. The chosen motor for this task is the **LMDCE421**, a stepper with high precision and high load capacity. + +## LMDCE421 +The LMDCE421 is an integrated Lexium MDrive (LMD) motor combining a stepper motor, drive electronics, and a controller into a single package. + +* **Operating Principle:** It uses microstepping to achieve high resolution. For our application, the motor translates rotary motion into linear motion via a lead screw. +* **Power Requirements:** Typically operates on **12–48 VDC**. +* **Networking:** The "E" in the model name signifies **Ethernet** connectivity, supporting industrial protocols like MODBUS/TCP. +* **Registers (opt):** Internally, the motor operates on a set of parameters (e.g., Velocity, Acceleration, Target Position) mapped to specific memory addresses. + + + +## Function Support +To achieve the goals of the probe-motor assembly, the codebase supports the following primary functions: + +1. **Relative/Absolute Movement:** Moving the probe to specific depths (steps or mm) relative to the current or home position. +2. **Status Monitoring:** Reading motor registers to check if the device is "Ready," "Moving," or in an "Error" state. +3. **Emergency Stop:** Immediate deceleration and torque-off command for safety. + +--- + +# Communication Medium +The LMDCE421 offers an ethernet and serial interface. We have used the ethernet interface communication because there is extensive library support for encoding/decoding data easily over this interface and communication can easily be reproduced from any computer with an ethernet port. **MODBUS/TCP** is the chosen communication protocol for this interface. + +## MODBUS/TCP +MODBUS/TCP is a variant of the Modbus family of vendor-neutral communication protocols intended for supervision and control of automation equipment. + +image + + +* **Structure:** It wraps a standard Modbus frame inside a TCP/IP packet (Port 502). +* **Client/Server Model:** In this setup, our control script acts as the **Client** (Master), and the LMDCE421 acts as the **Server** (Slave). +* **Protocol Data Unit (PDU):** This is the core of the message containing the instruction. + +image + + +### Protocol Data Unit Examples +The following table illustrates how the request parameters are structured within the PDU for common motor operations: + +| Operation | Function Code | Data Address | Data Quantity/Value | Observation | +| :--- | :--- | :--- | :--- | :--- | +| **Read Position** | `03` (Read Holding) | `00 64` (100) | `00 02` | Reads two registers to get a 32-bit position value. | +| **Check Status** | `04` (Read Input) | `00 0A` (10) | `00 01` | Checks a single register for the "Ready" flag. | +| **Set Velocity** | `06` (Write Single) | `01 2C` (300) | `03 E8` | Writes a fixed speed (e.g., 1000) to the velocity register. | +| **Multi-Move** | `10` (Write Multiple) | `02 58` (600) | `00 04` | Updates acceleration, deceleration, and target at once. | + + + +## pymodbus +`pymodbus` is a full Python implementation of the Modbus protocol. It abstracts the low-level socket handling into readable Python methods. + +### 1. read_holding_registers +Used to retrieve current settings or state data from the motor. +```python +# Read 2 registers starting at address 100 +response = client.read_holding_registers(address=100, count=2) +if not response.isError(): + print(response.registers) +``` + +### 2. write_register +Used to write a single 16-bit value to the motor. +```python +# Write the value 1 to register address 50 +client.write_register(address=50, value=1) +``` + +### 3. write_registers +Used for 32-bit values or simultaneous parameter updates. +```python +# Write a list of values to registers starting at address 200 +values = [1000, 500] +client.write_registers(address=200, values=values) +``` + +### 4. read_input_registers +Used to read hardware-level data that is typically read-only. +```python +# Read the current position from the input register +pos_data = client.read_input_registers(address=300, count=1) +``` --- This is a hardware-abstracted ROS2 interface for controlling a linear probe motor (LMDCE421 Stepper). It features a **Factory Pattern** that allows for seamless switching between real hardware and simulated environments via a JSON configuration. -## 1. System Architecture +## ROS2 Network ![Text](Images/Probe%20Motor%20Implementation%20Overview.png) The system is divided into three layers: @@ -17,14 +124,14 @@ The system is divided into three layers: --- -## 2. Requirements +### 1. Requirements -### Hardware +#### Hardware * **Motor**: LMDCE421 Stepper Motor. * **Communication**: Ethernet/Modbus TCP (Default IP: `192.168.33.1`). -### Software +#### Software * **OS**: Ubuntu 22.04 LTS with ROS2 Humble. * **Ethernet:** [static IP](rpi-ip-setup-for-motor.md) @@ -35,7 +142,7 @@ The system is divided into three layers: --- -## 3. Configuration (`sensors.json`) +### 2. Configuration (`sensors.json`) All hardware parameters are stored in `config/sensors.json`. This allows for calibration without changing the source code. @@ -48,23 +155,23 @@ All hardware parameters are stored in `config/sensors.json`. This allows for cal --- -## 4. ROS2 Interface +### 3. ROS2 Interface -### Subscribed Topics +#### Subscribed Topics * **`/move_probe_motor`** (`msg_interface/MoveProbeMotor`): Receives target position commands (cm). * **`/probe_motor_feedback`** (`msg_interface/ProbeMotorFeedback`): Receives position updates and movement confirmation. -### Message Definitions +#### Message Definitions * **MoveProbeMotor**: `int32 move_position` (Target depth in cm). * **ProbeMotorFeedback**: `bool has_moved`, `int32 position` (Current depth in cm). --- -## 5. Setup and Execution +### 4. Setup and Execution -### Building the Package +#### Building the Package ```bash cd ~/ros2_ws @@ -73,7 +180,7 @@ source install/setup.bash ``` -### Running the System +#### Running the System To launch all nodes (Temperature, Cube, Radio, and Motor) with the default configuration: @@ -91,7 +198,7 @@ ros2 launch embr embr_launch.py config_file:=/path/to/your_config.json --- -## 6. Logic Flow: Navigation & Survey +### 5. Logic Flow: Navigation & Survey The `nav` node executes an autonomous survey mission: @@ -106,6 +213,119 @@ The `nav` node executes an autonomous survey mission: --- -## 7. Troubleshooting +### 6. Troubleshooting * **Connection Errors**: Ensure the LMDCE421 IP (`192.168.33.1`) is reachable from your host machine (`ping 192.168.33.1`) [see](rpi-ip-setup-for-motor.md). + +--- + +# Setup + +## Static IP Setup + +This guide explains how to configure your Raspberry Pi to talk directly to an industrial LMD/Lexium drive using a static IPv4 address using the built-in Netplan. + +--- + +### Prerequisite: +- [ ] rpi is connected to the LMD Drive via ethernet + +LMD Drive is powered with sufficient power: +- [ ] Voltage: 12VDC - 48VDC +- [ ] Current: 0.0Amp - 2.0 Amp + +### Step 1: Identify your Ethernet Interface +Before editing configurations, you need the system name for your Ethernet port. + +```bash +ip link show + +``` + +Usually, this is `eth0`, but on some Ubuntu builds it may appear as `enp1s0` or similar. + +--- + +### Step 2: Locate the Netplan Configuration + +Netplan stores its settings in `.yaml` files. List them to find the one your system is using: + +```bash +ls /etc/netplan/ + +``` + +Common names: `01-netcfg.yaml`, `50-cloud-init.yaml`, or `00-installer-config.yaml`. + +--- + +### Step 3: Edit the Configuration + +Open the file with `nano`. **Note:** YAML files are strictly sensitive to spaces. Do not use tabs. + +```bash +sudo nano /etc/netplan/01-netcfg.yaml + +``` + +Replace the content with the following block. Adjust `eth0` to your interface name and `192.168.33.10` to your desired Pi IP. + +```yaml +network: + version: 2 + renderer: networkd + ethernets: + eth0: + dhcp4: no + addresses: + - 192.168.33.10/24 + +``` + +--- + +### Step 4: Validate and Apply + +Netplan has a built-in safety feature to prevent you from locking yourself out of the system. + +1. **Test the configuration:** + +```bash +sudo netplan try + +``` + +If you don't press 'Enter' within 120 seconds, it will roll back the changes. + +2. **Apply the changes permanently:** + +```bash +sudo netplan apply + +``` + +--- + +### Step 5: Verify the Link + +1. **Check that the IP is assigned:** + +```bash +ip addr show eth0 + +``` + +2. **Ping the LMD Drive:** +Replace `192.168.33.1` with the actual IP address of your drive. + +```bash +ping 192.168.33.1 + +``` + +--- + +### Troubleshooting Tips + +* **No Link:** If `ip link` shows `NO-CARRIER`, check your physical Ethernet cable. +* **YAML Errors:** If `netplan apply` throws an error, double-check your indentation. Every nested line must be indented by exactly two spaces. From 61119fa4304aa98412e8d801bbae417b29603293 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 1 Apr 2026 13:40:04 -0700 Subject: [PATCH 32/55] Update Static IP Setup link in probe-motor.md --- Documentation/2025/probe-motor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index de8dd2b..8df7dda 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -21,7 +21,7 @@ Communication Medium - [pymodbus](#pymodbus) Setup - - Static IP Setup + - [Static IP Setup](#static-ip-setup) --- From 7b499398d2dc798b3c624ee7a99f7deb5ea87e5b Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 1 Apr 2026 13:52:47 -0700 Subject: [PATCH 33/55] Added motor image --- Documentation/2025/probe-motor.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 8df7dda..13fc61f 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -29,7 +29,9 @@ Setup The probe-motor's task is to provide linear motion to a temperature probe, allowing the probe to take readings closer to the surface of the hotspot. The chosen motor for this task is the **LMDCE421**, a stepper with high precision and high load capacity. ## LMDCE421 -The LMDCE421 is an integrated Lexium MDrive (LMD) motor combining a stepper motor, drive electronics, and a controller into a single package. +image + +[LMDCE421](https://www.novantaims.com/liberty-mdrives/lm42-nema-17-ethernet-tcpip-ip20-lmoe42/) is an integrated Lexium MDrive (LMD) motor combining a stepper motor, drive electronics, and a controller into a single package. * **Operating Principle:** It uses microstepping to achieve high resolution. For our application, the motor translates rotary motion into linear motion via a lead screw. * **Power Requirements:** Typically operates on **12–48 VDC**. @@ -51,7 +53,7 @@ To achieve the goals of the probe-motor assembly, the codebase supports the foll The LMDCE421 offers an ethernet and serial interface. We have used the ethernet interface communication because there is extensive library support for encoding/decoding data easily over this interface and communication can easily be reproduced from any computer with an ethernet port. **MODBUS/TCP** is the chosen communication protocol for this interface. ## MODBUS/TCP -MODBUS/TCP is a variant of the Modbus family of vendor-neutral communication protocols intended for supervision and control of automation equipment. +[MODBUS/TCP](https://www.novantaims.com/application-note/modbustcp-vs-mcodetcp-overview/) is a variant of the Modbus family of vendor-neutral communication protocols intended for supervision and control of automation equipment. image @@ -76,7 +78,7 @@ The following table illustrates how the request parameters are structured within ## pymodbus -`pymodbus` is a full Python implementation of the Modbus protocol. It abstracts the low-level socket handling into readable Python methods. +[pymodbus](https://pymodbus.readthedocs.io/en/latest/) is a full Python implementation of the Modbus protocol. It abstracts the low-level socket handling into readable Python methods. ### 1. read_holding_registers Used to retrieve current settings or state data from the motor. From d4de8b8c295615398b3e41fd85f1f90d7ccb9bcf Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 1 Apr 2026 13:53:40 -0700 Subject: [PATCH 34/55] Delete Documentation/2025/probe-motor-communication.md Moved to probe-motor.md --- .../2025/probe-motor-communication.md | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 Documentation/2025/probe-motor-communication.md diff --git a/Documentation/2025/probe-motor-communication.md b/Documentation/2025/probe-motor-communication.md deleted file mode 100644 index c375c10..0000000 --- a/Documentation/2025/probe-motor-communication.md +++ /dev/null @@ -1,36 +0,0 @@ -# Overview -This documentation serves as a resource to understand the implementation of the LMDCE421(probe-motor) communication. - -This documentation hopes to achieve 2 goals: -1. Educate the reader with just enough knowledge to make incremental changes to the probe-motor codebase -2. Educate the reader with enough knowledge to rebuild the probe-motor codebase - -With these goals in mind I have indicated (opt) next to topics or sections that I deem to be irrelevant to goal 1. - -Focus here is on the communication medium and the interface. - -# Table Of Content -Probe-Motor - - [LMDCE421](#lmdce421) - - [Function Support](#function-support) - -Communication Medium - - [MODBUS/TCP](#modbus/tcp) - - [pymodbus](#pymodbus) - -## Probe-Motor -The probe-motor's task is to provide linear motion to a temperature probe allowing the probe to take reading closer to the surface of the hotspot. The chosen -motor for this task is the LMDCE421, a stepper with high precision and high load capacity. - -### LMDCE421 - -### Function Support - - -## Communication Medium -The LMDCE421 offers an ethernet and serial interface. We have used the ethernet interface communication because there is extensive library support for encoding/decoding data -easily over this interface and communication can easily be /reproduced from any computer with an ethernet port. MODBUS/TCP is the chosen communication protocol for this interface. - -### MODBUS/TCP - -### pymodbus From 29f1c2c574c7a00e4217bfa0eec4f318b711bc29 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 1 Apr 2026 13:54:01 -0700 Subject: [PATCH 35/55] Delete Documentation/2025/rpi-ip-setup-for-motor.md Contents moved to probe-motor.md --- Documentation/2025/rpi-ip-setup-for-motor.md | 108 ------------------- 1 file changed, 108 deletions(-) delete mode 100644 Documentation/2025/rpi-ip-setup-for-motor.md diff --git a/Documentation/2025/rpi-ip-setup-for-motor.md b/Documentation/2025/rpi-ip-setup-for-motor.md deleted file mode 100644 index 08bbf2a..0000000 --- a/Documentation/2025/rpi-ip-setup-for-motor.md +++ /dev/null @@ -1,108 +0,0 @@ -# Setting a Static IP on Ubuntu 22.04 LTS (Server) for LMD Drive Connection - -This guide explains how to configure your Raspberry Pi to talk directly to an industrial LMD/Lexium drive using a static IPv4 address using the built-in Netplan. - ---- - -## Prerequisite: -- [ ] rpi is connected to the LMD Drive via ethernet - -LMD Drive is powered with sufficient power: -- [ ] Voltage: 12VDC - 48VDC -- [ ] Current: 0.0Amp - 2.0 Amp - -## Step 1: Identify your Ethernet Interface -Before editing configurations, you need the system name for your Ethernet port. - -```bash -ip link show - -``` - -Usually, this is `eth0`, but on some Ubuntu builds it may appear as `enp1s0` or similar. - ---- - -## Step 2: Locate the Netplan Configuration - -Netplan stores its settings in `.yaml` files. List them to find the one your system is using: - -```bash -ls /etc/netplan/ - -``` - -Common names: `01-netcfg.yaml`, `50-cloud-init.yaml`, or `00-installer-config.yaml`. - ---- - -## Step 3: Edit the Configuration - -Open the file with `nano`. **Note:** YAML files are strictly sensitive to spaces. Do not use tabs. - -```bash -sudo nano /etc/netplan/01-netcfg.yaml - -``` - -Replace the content with the following block. Adjust `eth0` to your interface name and `192.168.33.10` to your desired Pi IP. - -```yaml -network: - version: 2 - renderer: networkd - ethernets: - eth0: - dhcp4: no - addresses: - - 192.168.33.10/24 - -``` - ---- - -## Step 4: Validate and Apply - -Netplan has a built-in safety feature to prevent you from locking yourself out of the system. - -1. **Test the configuration:** - -```bash -sudo netplan try - -``` - -If you don't press 'Enter' within 120 seconds, it will roll back the changes. - -2. **Apply the changes permanently:** - -```bash -sudo netplan apply - -``` - ---- - -## Step 5: Verify the Link - -1. **Check that the IP is assigned:** - -```bash -ip addr show eth0 - -``` - -2. **Ping the LMD Drive:** -Replace `192.168.33.1` with the actual IP address of your drive. - -```bash -ping 192.168.33.1 - -``` - ---- - -## Troubleshooting Tips - -* **No Link:** If `ip link` shows `NO-CARRIER`, check your physical Ethernet cable. -* **YAML Errors:** If `netplan apply` throws an error, double-check your indentation. Every nested line must be indented by exactly two spaces. From 0e07273aecfa0e8a939f106db177d879394e6b68 Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 3 Apr 2026 20:37:00 -0700 Subject: [PATCH 36/55] added empty parameters to avoid dictionary to data object conversion error --- ros2_ws/src/embr/config/sensors.json | 10 ++++++---- ros2_ws/src/embr/config/sensors_mixed.json | 6 ++++-- ros2_ws/src/embr/config/sensors_sim.json | 3 ++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ros2_ws/src/embr/config/sensors.json b/ros2_ws/src/embr/config/sensors.json index 467dc12..a0203c7 100644 --- a/ros2_ws/src/embr/config/sensors.json +++ b/ros2_ws/src/embr/config/sensors.json @@ -2,17 +2,20 @@ "temperature": { "mode": "real", "device": "/dev/ttyACM0", - "baud": 9600 + "baud": 9600, + "params": "" }, "cube": { "mode": "real", "device": "/dev/ttyAMA0", - "baud": 57600 + "baud": 57600, + "params": "" }, "radio": { "mode": "real", "device": "/dev/ttyAMA1", - "baud": 57600 + "baud": 57600, + "params": "" }, "probeMotor": { "mode": "real", @@ -23,7 +26,6 @@ "acceleration": 1000000, "steps_per_distance": 51200, "motion_range": 10 - } } } diff --git a/ros2_ws/src/embr/config/sensors_mixed.json b/ros2_ws/src/embr/config/sensors_mixed.json index 4e47b00..bfda6cb 100644 --- a/ros2_ws/src/embr/config/sensors_mixed.json +++ b/ros2_ws/src/embr/config/sensors_mixed.json @@ -4,7 +4,8 @@ "temperature": { "mode": "real", "device": "/dev/ttyACM0", - "baud": 9600 + "baud": 9600, + "params": "" }, "cube": { @@ -19,6 +20,7 @@ }, "mavlink": { - "mode": "sim" + "mode": "sim", + "params": "" } } diff --git a/ros2_ws/src/embr/config/sensors_sim.json b/ros2_ws/src/embr/config/sensors_sim.json index 4176eea..08793ef 100644 --- a/ros2_ws/src/embr/config/sensors_sim.json +++ b/ros2_ws/src/embr/config/sensors_sim.json @@ -22,7 +22,8 @@ }, "radio": { - "mode": "sim" + "mode": "sim", + "params": "" }, "probeMotor": { From 5ee9ffdca79878e667759038e8da0af052867a5b Mon Sep 17 00:00:00 2001 From: Abdul Date: Fri, 3 Apr 2026 20:38:19 -0700 Subject: [PATCH 37/55] fixed incorrect access of data class object as dictionary --- ros2_ws/src/embr/embr/sensors/cube.py | 4 ++-- ros2_ws/src/embr/embr/sensors/radio.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ros2_ws/src/embr/embr/sensors/cube.py b/ros2_ws/src/embr/embr/sensors/cube.py index 737eaf4..020b2d2 100644 --- a/ros2_ws/src/embr/embr/sensors/cube.py +++ b/ros2_ws/src/embr/embr/sensors/cube.py @@ -26,8 +26,8 @@ class RealCubeSensor(CubeSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - self.device = config["device"] if config else '/dev/ttyAMA0' - self.baud = config["baud"] if config else 57600 + self.device = config.device if config else '/dev/ttyAMA0' + self.baud = config.baud if config else 57600 self.vehicle = None def start(self) -> None: diff --git a/ros2_ws/src/embr/embr/sensors/radio.py b/ros2_ws/src/embr/embr/sensors/radio.py index 69575d4..5bce5ca 100644 --- a/ros2_ws/src/embr/embr/sensors/radio.py +++ b/ros2_ws/src/embr/embr/sensors/radio.py @@ -15,8 +15,8 @@ class RealRadioConnection(RadioConnection): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - self.device = config["device"] if config else '/dev/ttyAMA1' - self.baud = config["baud"] if config else 57600 + self.device = config.device if config else '/dev/ttyAMA1' + self.baud = config.baud if config else 57600 self.connection = None self.mav = None From 6b5885e3fe9de3edbe0aa03aab330bb7782e0c35 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Fri, 3 Apr 2026 23:47:41 -0700 Subject: [PATCH 38/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- Documentation/2025/probe-motor.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 13fc61f..7b6f07c 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -3,9 +3,7 @@ # Overview This documentation provides setup guide and serves as a resource to understand the implementation of the LMDCE421(probe-motor) communication. -This documentation hopes to achieve 2 goals: -1. Educate the reader with just enough knowledge to make incremental changes to the probe-motor codebase. -2. Educate the reader with enough knowledge to rebuild the probe-motor codebase. +This document will give the reader sufficient knowledge to modify/rebuild the probe-motor codebase. With these goals in mind I have indicated (opt) next to topics or sections that I deem to be irrelevant to goal 1. From e4c84f366933242f1d9cd5c8f6a7b5a46522ca40 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:05:31 -0700 Subject: [PATCH 39/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- Documentation/2025/probe-motor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 7b6f07c..4364525 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -71,7 +71,7 @@ The following table illustrates how the request parameters are structured within | **Read Position** | `03` (Read Holding) | `00 64` (100) | `00 02` | Reads two registers to get a 32-bit position value. | | **Check Status** | `04` (Read Input) | `00 0A` (10) | `00 01` | Checks a single register for the "Ready" flag. | | **Set Velocity** | `06` (Write Single) | `01 2C` (300) | `03 E8` | Writes a fixed speed (e.g., 1000) to the velocity register. | -| **Multi-Move** | `10` (Write Multiple) | `02 58` (600) | `00 04` | Updates acceleration, deceleration, and target at once. | +| **Multi-Move** | `10` (Write Multiple) | `02 58` (600) | `00 04` | Updates acceleration/deceleration, and target at once. | From 18221082aa7d9074ada58e3feac2fa5637b96886 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:05:55 -0700 Subject: [PATCH 40/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- Documentation/2025/probe-motor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 4364525..26672ff 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -120,7 +120,7 @@ The system is divided into three layers: 1. **Hardware/Simulation Layer**: Handles Modbus TCP communication or simulated physics. 2. **Abstraction Layer**: The `StepperFactory` uses `sensors.json` to decide which driver to instantiate. -3. **ROS2 Layer**: The `probeStepper` node acts as the server, while `nav` acts as the mission controller. +3. **ROS2 Layer**: The `probeMotor` node acts as the server, while `nav` acts as the mission controller. --- From b13150964e82ca43a6da19cba9daf3eb99ebbe8b Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:06:25 -0700 Subject: [PATCH 41/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/factory.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index c800763..0a0c904 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -140,4 +140,5 @@ def create_sensor(sensor_type: str, config: Optional[SensorConfig] = None) -> Se Returns: Sensor instance """ - return SensorFactory.create(sensor_type, config) \ No newline at end of file + return SensorFactory.create(sensor_type, config) + \ No newline at end of file From 21ee72d442ceecb431b5fc661288adb3afccb53f Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:06:49 -0700 Subject: [PATCH 42/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/probeMotor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index e6f4304..9bde2c8 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -122,8 +122,6 @@ def _write_registers(self, register, value: int) -> None: if register["length"] == 1: response = self.client.write_registers(register["address"], [value]) - if response.isError(): - raise RuntimeError(f"Error writing {value} to register {register}") elif register["length"] == 2: # encode int_32 into two int_16 values(one lower 16-bits, one upper 16-bits) From 18eb264c120124123a5355029d2e7c25daa386e5 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:07:06 -0700 Subject: [PATCH 43/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/probeMotor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index 9bde2c8..94fbd89 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -127,7 +127,10 @@ def _write_registers(self, register, value: int) -> None: # encode int_32 into two int_16 values(one lower 16-bits, one upper 16-bits) high = (value >> 16) & 0xFFFF low = value & 0xFFFF - return self.client.write_registers(register["address"], [low,high]) + response = self.client.write_registers(register["address"], [low,high]) + + if response.isError(): + raise RuntimeError(f"Error writing {value} to register {register}") def _read_registers(self, register): From 68e86730e99b78ca7120204dbde01663c5591397 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:07:24 -0700 Subject: [PATCH 44/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/probeMotor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index 94fbd89..92da23d 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -193,7 +193,7 @@ async def moveAbsolute(self, position) -> None: positionInSteps = position * self.CONFIG["steps_per_distance"]["value"] # spin the motor by number of steps - response = self._write_registers(self.REGISTERS["move_absolute"], positionInSteps) + self._write_registers(self.REGISTERS["move_absolute"], positionInSteps) await self._waitTillMoveFinish() return True From 75cdc7edeb963231dde56e1a3f9f2e9554de0e1b Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:07:38 -0700 Subject: [PATCH 45/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/probeMotor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index 92da23d..8ec494c 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -221,7 +221,7 @@ def setConfigSetting(self, setting : str, value : int) -> None: if not (lowerbound <= value <= upperbound): raise ValueError(f'{setting} value {value} is not in range [{lowerbound}, {upperbound}]') - response = self._write_registers(self.REGISTERS[setting], value) + self._write_registers(self.REGISTERS[setting], value) self.CONFIG[setting]["value"] = value else: From beee77c44929dd561a65aea13f5b0df56eb8f247 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:07:53 -0700 Subject: [PATCH 46/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/probeMotor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/probeMotor.py b/ros2_ws/src/embr/embr/sensors/probeMotor.py index 8ec494c..79eb9ef 100644 --- a/ros2_ws/src/embr/embr/sensors/probeMotor.py +++ b/ros2_ws/src/embr/embr/sensors/probeMotor.py @@ -298,4 +298,5 @@ def stop(self) -> None: if not self.is_running: raise Exception("No active connection to probe motor") - self.stop_running() \ No newline at end of file + self.stop_running() + \ No newline at end of file From 9ddd7ff5b69dfd4b5fb6c06e036082c2c8d769dd Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:08:10 -0700 Subject: [PATCH 47/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/embr/embr/sensors/temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/temperature.py b/ros2_ws/src/embr/embr/sensors/temperature.py index 101e1c3..7d8522e 100644 --- a/ros2_ws/src/embr/embr/sensors/temperature.py +++ b/ros2_ws/src/embr/embr/sensors/temperature.py @@ -62,7 +62,7 @@ class SimTemperatureSensor(TemperatureSensor): def __init__(self, config: Optional[SensorConfig] = None): super().__init__(config) - params = config["params"] if config else {} + params = config.params if config else {} self.base_temp = params.get('base_temp', 22.0) self.variation = params.get('variation', 2.0) self.noise = params.get('noise', 0.1) From 603848964e921666d87d718f1aaf7d8e3076a55f Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:08:27 -0700 Subject: [PATCH 48/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/msg_interface/action/ProbeStepperOperation.action | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action index cacdf7f..18e5e99 100644 --- a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action +++ b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action @@ -2,4 +2,4 @@ int32 operation --- bool success --- -string status \ No newline at end of file +string status From 1071d0df66b5802a0cb9e8af0b4876bf1e0ec46b Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:09:44 -0700 Subject: [PATCH 49/55] Apply suggestion from @maisonJar-dev Co-authored-by: Maison --- ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg b/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg index aa3dc88..442d041 100644 --- a/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg +++ b/ros2_ws/src/msg_interface/msg/ProbeMotorFeedback.msg @@ -1,2 +1,2 @@ bool has_moved -int32 position \ No newline at end of file +int32 position From 4cf90ec03a3ac4439c50eb81cae40e78abf821c1 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:10:51 -0700 Subject: [PATCH 50/55] Delete ros2_ws/src/msg_interface/action/ProbeStepperOperation.action The action was a previous implementation that was supposed to be removed. The communication type is now changed to a topic. --- .../src/msg_interface/action/ProbeStepperOperation.action | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 ros2_ws/src/msg_interface/action/ProbeStepperOperation.action diff --git a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action b/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action deleted file mode 100644 index 18e5e99..0000000 --- a/ros2_ws/src/msg_interface/action/ProbeStepperOperation.action +++ /dev/null @@ -1,5 +0,0 @@ -int32 operation ---- -bool success ---- -string status From 4921ba3b4ab455b75d6167780c6ea22456d688b3 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:17:08 -0700 Subject: [PATCH 51/55] Add pymodbus installation to dependencies script Added installation command for pymodbus to the script. --- Tools/Setup-Scripts/install-dependencies | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tools/Setup-Scripts/install-dependencies b/Tools/Setup-Scripts/install-dependencies index 41c7813..d2921d0 100644 --- a/Tools/Setup-Scripts/install-dependencies +++ b/Tools/Setup-Scripts/install-dependencies @@ -14,6 +14,9 @@ sudo apt install -y python3-pip echo "Installing libuvc-dev..." sudo apt install -y libuvc-dev +echo "Installing pymodbus..." +sudo apt install -y pymodbus + echo "=========================================" echo "Dependencies installed successfully!" echo "=========================================" From 4bdb62691f9d8b670619944df64759d447d37dbd Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:24:43 -0700 Subject: [PATCH 52/55] Update write_registers documentation for clarity Clarified documentation on write_registers function to specify Little-Endian format. --- Documentation/2025/probe-motor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 26672ff..798e215 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -95,7 +95,7 @@ client.write_register(address=50, value=1) ``` ### 3. write_registers -Used for 32-bit values or simultaneous parameter updates. +Used for 32-bit values or simultaneous parameter updates. Uses Little-Endian format[low,high] at the register level. ```python # Write a list of values to registers starting at address 200 values = [1000, 500] From 1a6cbe5e373a5924a01be42f47796403d60a90a1 Mon Sep 17 00:00:00 2001 From: devasilmy Date: Sat, 4 Apr 2026 00:37:57 -0700 Subject: [PATCH 53/55] Included breakdown of Little-Endian for read_holding_registers --- Documentation/2025/probe-motor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/2025/probe-motor.md b/Documentation/2025/probe-motor.md index 798e215..6a6441b 100644 --- a/Documentation/2025/probe-motor.md +++ b/Documentation/2025/probe-motor.md @@ -79,7 +79,7 @@ The following table illustrates how the request parameters are structured within [pymodbus](https://pymodbus.readthedocs.io/en/latest/) is a full Python implementation of the Modbus protocol. It abstracts the low-level socket handling into readable Python methods. ### 1. read_holding_registers -Used to retrieve current settings or state data from the motor. +Used to retrieve current settings or state data from the motor. Returns 2 registers in Little-Endian format: registers[0] = low, registers[1] = high. ```python # Read 2 registers starting at address 100 response = client.read_holding_registers(address=100, count=2) From 7f8952921ee021afb65dd7c2cac3fa606ed63f7e Mon Sep 17 00:00:00 2001 From: devasilmy Date: Wed, 8 Apr 2026 09:26:57 -0700 Subject: [PATCH 54/55] Deleted inactive action message interface --- ros2_ws/src/msg_interface/CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/ros2_ws/src/msg_interface/CMakeLists.txt b/ros2_ws/src/msg_interface/CMakeLists.txt index 63ca17c..852f5ef 100644 --- a/ros2_ws/src/msg_interface/CMakeLists.txt +++ b/ros2_ws/src/msg_interface/CMakeLists.txt @@ -11,7 +11,6 @@ find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/Gps.msg" - "action/ProbeStepperOperation.action" "msg/ProbeMotorFeedback.msg" ) From dcf6a62994de5c425f9a485bd3936c44ef6eb857 Mon Sep 17 00:00:00 2001 From: MacKenzie Richards <81762014+mrichards03@users.noreply.github.com> Date: Fri, 1 May 2026 13:20:43 -0700 Subject: [PATCH 55/55] Update ros2_ws/src/embr/embr/sensors/factory.py --- ros2_ws/src/embr/embr/sensors/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2_ws/src/embr/embr/sensors/factory.py b/ros2_ws/src/embr/embr/sensors/factory.py index 0a0c904..5d7ef38 100644 --- a/ros2_ws/src/embr/embr/sensors/factory.py +++ b/ros2_ws/src/embr/embr/sensors/factory.py @@ -40,7 +40,7 @@ def create(cls, sensor_type: str, config_path: Optional[str] = None): Create a sensor instance. Args: - sensor_type: Type of sensor ('temperature', 'cube', 'radio', 'probeMotor) + sensor_type: Type of sensor ('temperature', 'cube', 'radio', 'probeMotor') config: Sensor configuration Returns: