diff --git a/.github/workflows/industrial_ci_action.yml b/.github/workflows/industrial_ci_action.yml new file mode 100644 index 0000000..cb4dafc --- /dev/null +++ b/.github/workflows/industrial_ci_action.yml @@ -0,0 +1,16 @@ +name: Industrial CI + +on: [push, pull_request] + +jobs: + industrial_ci: + strategy: + matrix: + env: + - {ROS_DISTRO: humble, ROS_REPO: testing} + - {ROS_DISTRO: humble, ROS_REPO: main} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: 'ros-industrial/industrial_ci@master' + env: ${{matrix.env}} \ No newline at end of file diff --git a/BRANCHING_MODEL.md b/BRANCHING_MODEL.md new file mode 100644 index 0000000..69ac93b --- /dev/null +++ b/BRANCHING_MODEL.md @@ -0,0 +1,11 @@ +# Branching Model + +We will follow a simple branching model as shown [here](https://nvie.com/posts/a-successful-git-branching-model/#the-main-branches). Based on this, we will maintain 2 main branches: +- `master` +- `develop` + +`master` always refers to the current production-ready state. `develop` refers to the latest development changes that will be part of the next release. This is the main integration branch. Contributors working on bug fixes and feature developments should create their own mini-branches for their development and then issue a pull request to merge their changes to the develop branch. Branch naming convention: +- If working on a bug fix, use the prefix, `fix-*`, e.g. `fix-listener-cpu-usage` +- If working on a feature, use the prefix, `feature-*`, e.g. `feature-gundam-support` + +Once we have figured out a CI/CD strategy, we will automate build and test processes to automatically trigger any commits to master to run a push to release. \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d2e4345 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [info@kabam.ai](mailto:info@kabam.ai). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at https://contributor-covenant.org/version/1/4 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0f68401 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# How to contribute? + +Thanks for thinking about contributing to this project! You can contribute in one of several ways: + +- [Find bugs!](#find-bugs-!) +- [Fix bugs!](#fix-bugs-!) +- [Add new features or improve an existing one!](#add-new-features-or-improve-an-existing-one!) +- [Do you have questions?](#Do-you-have-questions?) + +## Find bugs! + +Yes! The easiest way to contribute is to make sure the existing code works as expected! So if you think you found a bug, please report it so we can act on it! + +Ensure the bug was not already reported by searching on GitHub Issues. This helps prevent duplication. + +If you're unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. + +If possible, use the relevant bug report templates to create the issue. Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, and paste the content into the issue description (some examples below): + +- Error reporting tool issues + +- Error classification issues + +- Robot visualization issues + +- Listener agent issues + +- Remote intervention issues + +## Fix bugs! + +- Help us keep the code bug free! + +- Please look through existing issues and choose one to work on. These have already been triaged by maintainers to be important things to fix and have the most impact on the project. + +- Alternatively, work on a patch for a bug you found as well. + +- Please refer to the branching model on [BRANCHING_MODEL.md](/BRANCHING_MODEL.md) file for best practices on how to use branches for this project. + +- Please refer to the ROS C++ styling guide that is based on Google C++ style guide as much as possible to keep the code readable! + +- Work on the patch. + +- Open a new GitHub pull request with the patch. + +- Ensure the pull request description clearly describes the problem and solution. Include the relevant issue number if applicable. + +## Add new features or improve an existing one! + +- Do not open an issue on GitHub. These are primarily intended for bug reports and fixes. + +- Just follow the branching model on [BRANCHING_MODEL.md](/BRANCHING_MODEL.md) file for best practices on how to use branches for this project. + +- Please refer to the [ROS C++ styling guide](http://wiki.ros.org/CppStyleGuide) that is based on [Google C++ style guide](https://google.github.io/styleguide/cppguide.html) as much as possible to keep the code readable! + +## Do you have questions? + +- You are following the documentation and something still seems broken? Discuss on the discourse page (link to be added). + +- You are a contributor and want to contact the team about source code? Email at [info@kabam.ai](mailto:info@kabam.ai). Note that, support questions emailed won’t be addressed. Forums are the best place for that! + + +Thanks! + +**Team rosrect** \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e101b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM ros:humble-ros-base + +# Install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-humble-rmf-door-msgs \ + python3-pip && \ + pip3 install websockets websocket-client requests && \ + rm -rf /var/lib/apt/lists/* + +# Clone the repository +WORKDIR /door_adapter_megazo_ws/src +COPY ./door_adapter_megazo door_adapter_megazo/ + +# Setup the workspace +WORKDIR /door_adapter_megazo_ws +RUN apt-get update && rosdep install --from-paths src --ignore-src --rosdistro=$ROS_DISTRO -y \ + && rm -rf /var/lib/apt/lists/* + +# # Build the workspace +RUN . /opt/ros/$ROS_DISTRO/setup.sh \ + && colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release + +# # Ensure the entrypoint script sources the ROS setup +RUN echo 'source /door_adapter_megazo_ws/install/setup.bash' >> /ros_entrypoint.sh + +# # Ensure proper permissions for entrypoint +RUN chmod +x /ros_entrypoint.sh + +ENTRYPOINT ["/ros_entrypoint.sh"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a418ac3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + + 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 + + Copyright 2024 Kabam Pte Ltd, All Rights Reserved. + + 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..556de4c --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +[![build](https://github.com/KABAM-Robotics/door_adapter_megazo/actions/workflows/industrial_ci_action.yml/badge.svg)](https://github.com/KABAM-Robotics/door_adapter_megazo/actions/workflows/industrial_ci_action.yml) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +![Ubuntu](https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white) + +## **What Is This?** + +This repository contains the RMF Door Adapter that interfaces with Megazo's Door Access System ICAD v3. + +![](img/2024-10-23_rmf_door_megazo.gif) + +## **Dependencies** πŸ“š + +- ROS 2 `Humble` +- [Open-RMF on Humble Hawksbill - Sync 2023-12-29](https://github.com/open-rmf/rmf/releases/tag/release-humble-231229) + +## **Build** πŸ”¨ + +```bash +cd $HOME +``` + +```bash +git clone https://github.com/KABAM-Robotics/door_adapter_template.git --depth 1 --single-branch --branch main && cd door_adapter_megazo +``` + +```bash +docker build -t door_adapter_megazo:humble . +``` + +## **Run** βš™οΈ + +```bash +docker run -it --rm \ + --name door_adapter_megazo_c \ + --network host \ + -v /dev/shm:/dev/shm \ +door_adapter_megazo:humble /bin/bash -c "source /ros_entrypoint.sh && ros2 run door_adapter_megazo door_adapter" +``` + +## **Verify** βœ… + +Upon the running the command above, it should output similar to what is shown below: + +```bash +[INFO] [1729654097.436618736] [door_adapter_megazo]: Initialising [door_adapter_megazo]... +[INFO] [1729654097.472651911] [door_adapter_megazo]: Connected to door client API. +[INFO] [1729654098.498656692] [door_adapter_megazo]: Door [Lab_D01] [MODE_CLOSED] +[INFO] [1729654099.511240654] [door_adapter_megazo]: Door [Lab_D01] [MODE_CLOSED] +[INFO] [1729654100.514910118] [door_adapter_megazo]: Door [Lab_D01] [MODE_CLOSED] +[INFO] [1729654101.502347821] [door_adapter_megazo]: Door [Lab_D01] [MODE_CLOSED] +``` + +## **Configure** πŸ”§ + +To allow `door_adapter_megazo` to be configured for custom deployment, please edit the following in `config.yaml`: + +- door name and id +> Set the same as the door on your RMF Map `.building.yaml` file. + +- door_auto_closes +> Set to True if door remains closed until requested open, and automatically closes if it does not receive subsequent open requests. + +- door_signal_period +> Time taken for door signal to be effective, in seconds. + +- continuous_status_polling +> Whether to keep checking door state when there are no requests. + +## **Contributions** + +**We welcome contributions!** Please see the [contribution guidelines](/CONTRIBUTING.md). + +## **References** + +- https://osrf.github.io/ros2multirobotbook/integration_doors.html +- https://docs.python-requests.org/en/master/ + +## **Maintainer(s)** + +- Bey Hao Yun (Gary) \ No newline at end of file diff --git a/door_adapter_megazo/config.yaml b/door_adapter_megazo/config.yaml new file mode 100644 index 0000000..c6aaa9b --- /dev/null +++ b/door_adapter_megazo/config.yaml @@ -0,0 +1,23 @@ + +# RMF Megazo door parameters + +### template ### +doors: + "Lab_D01": # door name and id + door_auto_closes: False # Set to True if door remains closed until requested open, and automatically closes if it does not receive subsequent open requests. + door_signal_period: 3.0 # Time taken for door signal to be effective, in seconds. + continuous_status_polling: False # Whether to keep checking door state when there are no requests + +door_subscriber: + topic_name: "adapter_door_requests" + +door_publisher: + topic_name: "door_states" + door_state_publish_period: 1.0 # Seconds + +mock: False + +# Sample creds: +api_endpoint: "http://icad.megazo.io:8181" +header_key: "INSERT MEGAZO USER HERE" +header_value: "INSERT MEGAZO PASSWORD HERE" diff --git a/door_adapter_megazo/door_adapter_megazo/DoorClientAPI.py b/door_adapter_megazo/door_adapter_megazo/DoorClientAPI.py new file mode 100644 index 0000000..8a5a9e8 --- /dev/null +++ b/door_adapter_megazo/door_adapter_megazo/DoorClientAPI.py @@ -0,0 +1,380 @@ +# Copyright 2024 Kabam Pte Ltd, All Rights Reserved. +# +# 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. + +"""Module that authenticates, generates API payloads and sends to Megazo API REST points.""" + +import time +import json +from urllib.error import HTTPError +from datetime import datetime, timedelta +import requests +from rmf_door_msgs.msg import DoorMode + + +class DoorClientAPI: + """A Module that handles communication with Megazo API.""" + + def __init__(self, node, config, ros_logger): + self.timeout = 5 # seconds + self.debug = False + self.connected = False + self.node = node + self.config = config # use this config to establish connection + self.token = None + self.ros_logger = ros_logger + + count = 0 + self.connected = False + while not self.check_connection(): + if count >= self.timeout: + self.ros_logger.info("Unable to connect to door" + " client API.") + self.connected = False + break + self.ros_logger.warn("Unable to connect to door client API. " + "Attempting to reconnect...") + count += 1 + time.sleep(1) + self.ros_logger.info("Connected to door client API.") + self.connected = True + + def get_token(self): + """ + Retrieve an API token for authentication. + + This method sends a POST request to the configured API endpoint + with credentials from config and returns the received token if it + was previously requested. + If not, ignore this message. Otherwise return the previous token data + """ + path = self.config["api_endpoint"] + "/API/System/Login" + + token_req_payload = { + 'data': { + 'UserID': self.config['header_key'], + 'Password': self.config['header_value'] + } + } + + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(token_req_payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + return data["data"]["Token"] + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error}') + return None + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err}') + return None + return None + + def check_connection(self): + """Return True if connection to the door API server is successful.""" + self.token = self.get_token() + return bool(self.token) + + def sign_in_project(self, projectID: str): + """ + Signs in a user to an API endpoint for Megazo API. + + This method sends a POST request with authentication credentials and + project ID to the configured API endpoint. If successful, it returns the + received token data. + """ + path = self.config["api_endpoint"] + "/API/System/ProjectSignIn" + + payload = { + 'UserID': self.config["header_key"], + 'Token': self.token, + 'data': { + 'ProjectID': projectID + } + } + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + return data['data']['ProjectID'] + else: + self.ros_logger.error("\n[signInProject] - [FAIL]") + return None + + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error}') + return None + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err}') + return None + + def get_iced_list_deviceID(self, door_id: str): + """ + Retrieve device ID and project ID from ICED API based on provided door ID. + + Args + ---- + door_id (str): The ID of the door for which to retrieve device information. + + Returns + ------- + tuple: A tuple containing the device ID and project ID if found, otherwise None. + If an error occurs during API request or data parsing, returns None. + + Raises + ------ + requests.exceptions.ConnectionError: If a connection issue arises while making + the HTTP POST request. + HTTPError: If there's an HTTP-related error (e.g., 404 Not Found) when sending + the request. + + Note + ---- + This method assumes that the ICED API endpoint and authentication credentials + are properly configured in the class instance. It also expects the 'data' key + within the JSON response to contain a list of devices, where each device has an + 'ICEDName' attribute matching the provided door ID. + + """ + path = self.config["api_endpoint"] + "/API/ICED/GetICEDList" + + payload = { + 'UserID': self.config["header_key"], + 'Token': self.token + } + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + for device in data['data']: + if door_id in device['ICEDName']: + return device['ID'], device['ProjectID'] + self.ros_logger.warn(f"Unable to find ICEDName for door {door_id}. " + "Please ensure config.yaml contains the correct door name." + " - Returning None.") + return None + else: + self.ros_logger.warn("\n[get_iced_list_projectID] - [FAIL]") + return None + + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error}') + return None + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err}') + return None + + def open_door(self, door_id): + """Return True if the door API server is successful receive open door command.""" + device_id, project_id = self.get_iced_list_deviceID(door_id) + + if device_id is None or project_id is None: + self.ros_logger.error("Unable to retrieve Device/Project ID. Returning False") + return False + + project_id_2 = self.sign_in_project(project_id) + + if project_id_2 is None: + self.ros_logger.error("Unable to sign into Project. Returning False") + return False + + path = self.config["api_endpoint"] + "/API/Device/ICED/ControlDoor" + + NORMAL_OPEN = 1 + + payload = { + 'UserID': self.config["header_key"], + 'Token': self.token, + 'data': { + 'IDs': [device_id], + 'Operate': NORMAL_OPEN + } + } + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + self.ros_logger.info("OPEN DOOR Request Acknowledgement - [SUCCESS]") + return True + else: + self.ros_logger.warn("OPEN DOOR Request Acknowledgement - [FAIL]") + self.ros_logger.warn(f"Error: {data['errMsg']}") + return False + + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error}') + return False + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err}') + return False + + def close_door(self, door_id): + """Return True if the door API server is successful receive open door command.""" + device_id, project_id = self.get_iced_list_deviceID(door_id) + + if device_id is None or project_id is None: + self.ros_logger.error("Unable to retrieve Device/Project ID. Returning False") + return False + + project_id_2 = self.sign_in_project(project_id) + + if project_id_2 is None: + self.ros_logger.error("Unable to sign into Project. Returning False") + return False + + path = self.config["api_endpoint"] + "/API/Device/ICED/ControlDoor" + + FORCED_SHUTDOWN = 4 + + payload = { + 'UserID': self.config["header_key"], + 'Token': self.token, + 'data': { + 'IDs': [device_id], + 'Operate': FORCED_SHUTDOWN + } + } + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + self.ros_logger.info("CLOSE DOOR Request Acknowledgement - [SUCCESS]") + return True + else: + self.ros_logger.warn("CLOSE DOOR Request Acknowledgement - [FAIL]") + self.ros_logger.warn(f"Error: {data['errMsg']}") + return False + + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error}') + return False + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err}') + return False + + def get_mode(self, door_id): + """ + + Return the door status with reference rmf_door_msgs. + + Return DoorMode.MODE_CLOSED when door status is closed. + Return DoorMode.MODE_MOVING when door status is moving. + Return DoorMode.MODE_OPEN when door status is open. + Return DoorMode.MODE_OFFLINE when door status is offline. + Return DoorMode.MODE_UNKNOWN when door status is unknown. + """ + path = self.config["api_endpoint"] + "/API/ICED/GetICEDList" + + payload = { + 'UserID': self.config['header_key'], + 'Token': self.token + } + requestHeaders = {'Content-Type': 'application/json'} + + try: + r = requests.post( + path, + data=json.dumps(payload), + headers=requestHeaders, + timeout=self.timeout + ) + r.raise_for_status() + data = r.json() + + if data["IsSuccess"]: + for device in data['data']: + if door_id in device['ICEDName']: + # Check if door is online recently. + last_heartbeat_timestamp = datetime.strptime( + data['data'][0]['LastHeartbeatTime'], + "%Y-%m-%d %H:%M:%S") + current_time = datetime.now() + + if last_heartbeat_timestamp == '': + self.ros_logger.warn(f"Door [{door_id}] HeartBeat null." + " [MODE_UNKNOWN]") + return DoorMode.MODE_UNKNOWN + + time_difference = current_time - last_heartbeat_timestamp + + # Check if the difference is more than 15 minutes + if time_difference > timedelta(minutes=15): + self.ros_logger.warn(f"Door [{door_id}] HeartBeat" + " expired. [MODE_OFFLINE]") + return DoorMode.MODE_OFFLINE + else: + if data['data'][0]['DoorOpenStatus'] == 0: + self.ros_logger.info(f"Door [{door_id}] [MODE_CLOSED]") + return DoorMode.MODE_CLOSED + else: + self.ros_logger.info(f"Door [{door_id}] [MODE_OPENED]") + return DoorMode.MODE_OPEN + self.ros_logger.warn(f"\n[WARN] Unable to find ICEDName for door {door_id}." + " Please ensure config.yaml contains the correct door name." + " - [MODE UNKNOWN]") + else: + self.ros_logger.error(f"\nUnable to get Door [{door_id}] status - [MODE_UNKNOWN]") + except requests.exceptions.HTTPError as http_error: + self.ros_logger.warn(f'HTTP error: {http_error} - Renewing token...') + self.token = self.get_token() + except requests.exceptions.ConnectionError as connection_error: + self.ros_logger.warn(f'Connection error: {connection_error} - [MODE_UNKNOWN]') + except HTTPError as http_err: + self.ros_logger.warn(f'HTTP error: {http_err} - [MODE_UNKNOWN]') + + return DoorMode.MODE_UNKNOWN diff --git a/door_adapter_megazo/door_adapter_megazo/__init__.py b/door_adapter_megazo/door_adapter_megazo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/door_adapter_megazo/door_adapter_megazo/door_adapter.py b/door_adapter_megazo/door_adapter_megazo/door_adapter.py new file mode 100644 index 0000000..f208f8e --- /dev/null +++ b/door_adapter_megazo/door_adapter_megazo/door_adapter.py @@ -0,0 +1,255 @@ +# Copyright 2024 Kabam Pte Ltd, All Rights Reserved. +# +# 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. + +"""Module that runs a ROS 2 node for Megazo door to talk to RMF.""" + +import sys +import time +import argparse +import threading +import yaml + +import rclpy +from rclpy.node import Node +from rmf_door_msgs.msg import DoorRequest, DoorState, DoorMode +from door_adapter_megazo.DoorClientAPI import DoorClientAPI + + +class Door: + """A Module that contains RMF Door-specific information.""" + + def __init__(self, + door_id, + door_auto_closes, + door_signal_period, + continuous_status_polling): + self.id = door_id + self.door_mode = DoorMode.MODE_CLOSED + self.open_door = False + self.check_status = None # set to None if not enabled + self.door_auto_closes = door_auto_closes + self.door_signal_period = door_signal_period + if continuous_status_polling: + self.check_status = False + +############################################################################### + + +class DoorAdapter(Node): + """A Module that bridges between ROS 2 and Megazo Door API.""" + + def __init__(self, config_yaml): + super().__init__('door_adapter_megazo') + self.get_logger().info('Initialising [door_adapter_megazo]...') + + # Get value from config file + self.door_state_publish_period = config_yaml['door_publisher']['door_state_publish_period'] + + door_pub = config_yaml['door_publisher'] + door_sub = config_yaml['door_subscriber'] + self.mock_adapter = config_yaml.get('mock', False) + + # Connect to doors + if not self.mock_adapter: + self.api = DoorClientAPI(self, config_yaml, self.get_logger()) + + assert self.api.connected, "Unable to establish connection with door" + + # Keep track of doors + self.doors = {} + for door_id, door_data in config_yaml['doors'].items(): + # We support both door_auto_closes and the deprecated + # door_close_feature for backward compatibility + auto_close = door_data.get('door_auto_closes', None) + if auto_close is None: + if 'door_close_feature' in door_data: + auto_close = not door_data['door_close_feature'] + assert auto_close is not None + + self.doors[door_id] = Door(door_id, + auto_close, + door_data['door_signal_period'], + door_data.get('continuous_status_polling', False)) + + self.door_states_pub = self.create_publisher( + DoorState, door_pub['topic_name'], 100) + + self.door_request_sub = self.create_subscription( + DoorRequest, door_sub['topic_name'], self.door_request_cb, 100) + + self.periodic_timer = self.create_timer( + self.door_state_publish_period, self.time_cb) + + def door_open_command_request(self, door_data: Door): + """ + Continuously sends an API request to open a door until it's closed. + + Keeps attempting to open the specified door using the provided data. + It will wait for a period of time (door_signal_period) before retrying if the + previous attempt was unsuccessful or successful but not acknowledged by the system. + + Args: + ---- + door_data (Door): An object containing information about the door to be opened, + including its ID and signal period. + + """ + while door_data.open_door: + success = self.api.open_door(door_data.id) + if success: + self.get_logger().info(f"Request to open door [{door_data.id}] is successful") + else: + self.get_logger().warning(f"Request to open door [{door_data.id}] is unsuccessful") + time.sleep(door_data.door_signal_period) + + def time_cb(self): + """ + Periodically updates and publishes the status of all Megazo doors. + + This method is responsible for updating the state of each door by + querying its current mode. + It also takes into account whether continuous_status_polling is enabled, + which affects how often it checks the door's status. + If enabled, it only updates the door state when there are open or close requests. + Otherwise, it continuously polls the door's status. + + """ + if self.mock_adapter: + return + for door_id, door_data in self.doors.items(): + + if door_data.check_status is not None: + # If continuous_status_polling is enabled, we will only update + # the door state when there is a door open request. If there is + # a close door request and the door state is closed, we will + # assume the door state remains closed until the next door open + # request. This implementation reduces the number of calls made + # during state update. + if door_data.check_status: + door_data.door_mode = self.api.get_mode(door_id) + if door_data.door_mode == DoorMode.MODE_CLOSED and not door_data.open_door: + door_data.check_status = False + else: + # If continuous_status_polling is not enabled, we'll just + # update the door state as it is all the time + door_data.door_mode = self.api.get_mode(door_id) + state_msg = DoorState() + state_msg.door_time = self.get_clock().now().to_msg() + + # publish states of the door + state_msg.door_name = door_id + state_msg.current_mode.value = door_data.door_mode + self.door_states_pub.publish(state_msg) + + def door_request_cb(self, msg: DoorRequest): + """ + Handle incoming requests to open or close doors. + + This method processes messages of type `DoorRequest` and + updates the state of the corresponding door accordingly. + It checks if continuous status polling is enabled per-door basis + and toggles it as needed when a request is received. + If running in mock adapter mode, it automatically agrees to every + request without sending any commands to the API. + + Args: + ---- + msg (DoorRequest): A message containing information about the + requested door operation. + + """ + # Agree to every request automatically if this is a mock adapter + if self.mock_adapter: + state_msg = DoorState() + state_msg.door_time = self.get_clock().now().to_msg() + state_msg.door_name = msg.door_name + state_msg.current_mode.value = msg.requested_mode.value + self.door_states_pub.publish(state_msg) + return + + # Check if this door has been stored in the door adapter. If not, ignore + door_data = self.doors.get(msg.door_name) + if door_data is None: + return + + # When the adapter receives an open request, it will send an open + # command to API. When the adapter receives a close request, it will + # stop sending the open command to API + self.get_logger().info( + f"[{msg.door_name}] Door mode [{msg.requested_mode.value}]" + f"requested by {msg.requester_id}" + ) + if msg.requested_mode.value == DoorMode.MODE_OPEN: + # open door implementation + door_data.open_door = True + if door_data.check_status is not None: + # If check_status is enabled, we toggle it to true to allow + # door state updates + door_data.check_status = True + if not door_data.door_auto_closes: + self.api.open_door(msg.door_name) + else: + t = threading.Thread(target=self.door_open_command_request, + args=(door_data,)) + t.start() + elif msg.requested_mode.value == DoorMode.MODE_CLOSED: + # close door implementation + door_data.open_door = False + self.get_logger().info(f'[{msg.door_name}] Command to [CLOSE DOOR] received') + if not door_data.door_auto_closes: + self.api.close_door(msg.door_name) + else: + self.get_logger().error('Invalid door mode requested. Ignoring...') + +############################################################################### + + +def main(argv=sys.argv): + """ + Initialize the ROS2 environment. + + It also parses command-line arguments, loads configuration from a + YAML file, and spins up an instance of DoorAdapter. + It then runs the adapter until it's shut down by user request or error. + + Args: + ---- + argv (list): A list containing the command-line arguments. + Defaults to sys.argv if not provided. + + """ + rclpy.init(args=argv) + + args_without_ros = rclpy.utilities.remove_ros_args(argv) + parser = argparse.ArgumentParser( + prog="door_adapter", + description="Configure and spin up door adapter for door ") + parser.add_argument("-c", "--config_file", type=str, required=True, + help="Path to the config.yaml file for this door adapter") + args = parser.parse_args(args_without_ros[1:]) + config_path = args.config_file + + # Load config and nav graph yamls + with open(config_path, "r", encoding="utf-8") as f: + config_yaml = yaml.safe_load(f) + + door_adapter = DoorAdapter(config_yaml) + rclpy.spin(door_adapter) + + door_adapter.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main(sys.argv) diff --git a/door_adapter_megazo/package.xml b/door_adapter_megazo/package.xml new file mode 100644 index 0000000..4b855b7 --- /dev/null +++ b/door_adapter_megazo/package.xml @@ -0,0 +1,20 @@ + + + + door_adapter_megazo + 0.0.0 + A RMF door adapter for Megazo + + Apache License 2.0 + + rmf_door_msgs + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/door_adapter_megazo/resource/door_adapter_megazo b/door_adapter_megazo/resource/door_adapter_megazo new file mode 100644 index 0000000..e69de29 diff --git a/door_adapter_megazo/setup.cfg b/door_adapter_megazo/setup.cfg new file mode 100644 index 0000000..3ec1215 --- /dev/null +++ b/door_adapter_megazo/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/door_adapter_megazo +[install] +install_scripts=$base/lib/door_adapter_megazo diff --git a/door_adapter_megazo/setup.py b/door_adapter_megazo/setup.py new file mode 100644 index 0000000..5c55ef1 --- /dev/null +++ b/door_adapter_megazo/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup + +package_name = 'door_adapter_megazo' + +setup( + name=package_name, + version='1.0.0', + packages=[package_name], + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + ], + install_requires=['setuptools'], + zip_safe=True, + maintainer='Bey Hao Yun', + maintainer_email='gary.bey@kabam.ai', + description='A RMF door adapter for Megazo', + license='Apache License 2.0', + tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + 'door_adapter = door_adapter_megazo.door_adapter:main' + ], + }, +) diff --git a/door_adapter_megazo/test/test_DoorClientAPI.py b/door_adapter_megazo/test/test_DoorClientAPI.py new file mode 100644 index 0000000..b171f05 --- /dev/null +++ b/door_adapter_megazo/test/test_DoorClientAPI.py @@ -0,0 +1,64 @@ +# 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. + +# import sys +import yaml +import time +import rclpy +import unittest +from rclpy.node import Node +from door_adapter_megazo.DoorClientAPI import DoorClientAPI + + +class TestROS2Node(unittest.TestCase): + + @classmethod + def setUpClass(cls): + """Set up a ROS 2 node once for all test cases.""" + rclpy.init() # Initialize ROS 2 + cls.door_id = 'Lab_D01' + cls.test_node = Node('test_door_adapter') + config_path = "config.yaml" + with open(config_path, "r", encoding="utf-8") as f: + config_yaml = yaml.safe_load(f) + cls.api = DoorClientAPI(cls.test_node, config_yaml, cls.test_node.get_logger()) + while not cls.api.connected: + time.sleep(1) + + def test_DoorClientAPI_init(self): + """Test if the node was created properly.""" + self.assertEqual(self.api.connected, True) + self.assertEqual(self.api.debug, False) + self.assertEqual(self.api.timeout, 5) + + def test_DoorClientAPI_get_token(self): + """Test if the token was generated successfully.""" + self.assertEqual(self.api.get_token() is None, False) + + def test_DoorClientAPI_check_connection(self): + """Test if the connection to Megazo server was successful.""" + self.assertEqual(self.api.check_connection() is None, False) + + def test_DoorClientAPI_get_DeviceID_ProjectID(self): + """Test if the ICED Device and Project ID were successfully retrieved.""" + device_id, project_id = self.api.get_iced_list_deviceID(self.door_id) + project_id_2 = self.api.sign_in_project(project_id) + + self.assertEqual(device_id is None, False) + self.assertEqual(project_id is None, False) + self.assertEqual(project_id_2 is None, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/door_adapter_megazo/test/test_copyright.py b/door_adapter_megazo/test/test_copyright.py new file mode 100644 index 0000000..cc8ff03 --- /dev/null +++ b/door_adapter_megazo/test/test_copyright.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_copyright.main import main +import pytest + + +@pytest.mark.copyright +@pytest.mark.linter +def test_copyright(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found errors' diff --git a/door_adapter_megazo/test/test_door_adapter.py b/door_adapter_megazo/test/test_door_adapter.py new file mode 100644 index 0000000..bed9aa4 --- /dev/null +++ b/door_adapter_megazo/test/test_door_adapter.py @@ -0,0 +1,71 @@ +# 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. + +# import sys +import yaml +import unittest +from rmf_door_msgs.msg import DoorMode +from door_adapter_megazo.door_adapter import Door +from door_adapter_megazo.door_adapter import DoorAdapter + + +class TestROS2Node(unittest.TestCase): + + @classmethod + def setUpClass(cls): + """Set up a ROS 2 node once for all test cases.""" + cls.test_door = Door('Lab_D01', + False, + 3.0, + False) + + cls.test_door_status = Door('Lab_D01', + False, + 3.0, + True) + + config_path = "config.yaml" + with open(config_path, "r", encoding="utf-8") as f: + config_yaml = yaml.safe_load(f) + cls.door_adapter = DoorAdapter(config_yaml) + + def test_Door_init(self): + """Test if the node was created properly.""" + self.assertEqual(self.test_door.id, 'Lab_D01') + self.assertEqual(self.test_door.door_mode, DoorMode.MODE_CLOSED) + self.assertEqual(self.test_door.open_door, False) + self.assertEqual(self.test_door.check_status is None, True) + self.assertEqual(self.test_door.door_auto_closes, False) + self.assertEqual(self.test_door.door_signal_period, 3.0) + + def test_Door_init_continuous_status_polling(self): + """Test if the node was created properly.""" + self.assertEqual(self.test_door_status.id, 'Lab_D01') + self.assertEqual(self.test_door_status.door_mode, DoorMode.MODE_CLOSED) + self.assertEqual(self.test_door_status.open_door, False) + self.assertEqual(self.test_door_status.check_status, False) + self.assertEqual(self.test_door_status.door_auto_closes, False) + self.assertEqual(self.test_door_status.door_signal_period, 3.0) + + def test_DoorAdapter_init(self): + """Test if the node was created properly.""" + self.assertEqual(self.door_adapter.door_state_publish_period is None, False) + self.assertEqual(self.door_adapter.mock_adapter, False) + self.assertEqual(self.door_adapter.door_states_pub is None, False) + self.assertEqual(self.door_adapter.door_request_sub is None, False) + self.assertEqual(self.door_adapter.periodic_timer is None, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/door_adapter_megazo/test/test_flake8.py b/door_adapter_megazo/test/test_flake8.py new file mode 100644 index 0000000..27ee107 --- /dev/null +++ b/door_adapter_megazo/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/door_adapter_megazo/test/test_pep257.py b/door_adapter_megazo/test/test_pep257.py new file mode 100644 index 0000000..b234a38 --- /dev/null +++ b/door_adapter_megazo/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' diff --git a/img/2024-10-23_rmf_door_megazo.gif b/img/2024-10-23_rmf_door_megazo.gif new file mode 100644 index 0000000..fdc6974 Binary files /dev/null and b/img/2024-10-23_rmf_door_megazo.gif differ