diff --git a/README.md b/README.md index 5ddce20..46c809e 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ -# ros2mass \ No newline at end of file +# ros2-to-mass-amr-interop + +Configuration-based ROS2 package for sending Mass [AMR Standard messages](https://github.com/MassRobotics-AMR/AMR_Interop_Standard) to complaint receivers. + +## Installing + +Make sure `ros2` is installed properly. Clone this repository inside your `src` folder on your local workspace and build the package: + +```bash +mkdir -p ~/ros2_ws/src && cd ros2_ws/ +git clone https://github.com/inorbit-ai/ros2-to-mass-amr-interop.git ./src +colcon build --packages-select ros2-to-mass-amr-interop +``` + +## Running the node + +The node takes the Mass AMR config file path as parameter. If not provided, it is assumed the file is on the current directory. + +```bash +# Source the local overlay by running `. install/setup.sh` if +# using bash or `. install/setup.zsh` if using zsh. +ros2 run ros2_to_mass_amr_interop ros2_to_mass_node \ + --ros-args -p config_file:=/path/to/config.yaml +``` + +## Running tests + +On you local workspace: + +```bash +colcon test --packages-select ros2-to-mass-amr-interop +colcon test-result --verbose +``` diff --git a/package.xml b/package.xml new file mode 100644 index 0000000..490f232 --- /dev/null +++ b/package.xml @@ -0,0 +1,21 @@ + + + + ros2-to-mass-amr-interop + 0.0.0 + TODO: Package description + leandro + TODO: License declaration + + python3-websockets + rclpy + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/resource/ros2_to_mass_amr_interop b/resource/ros2_to_mass_amr_interop new file mode 100644 index 0000000..e69de29 diff --git a/ros2_to_mass_amr_interop/__init__.py b/ros2_to_mass_amr_interop/__init__.py new file mode 100644 index 0000000..4f79388 --- /dev/null +++ b/ros2_to_mass_amr_interop/__init__.py @@ -0,0 +1,59 @@ +import logging +import websockets +import json +import asyncio +from pathlib import Path +from rclpy.node import Node +from .config import MassAMRInteropConfig +from .messages import IdentityReport + +logging.basicConfig(level=logging.DEBUG) + + +class MassAMRInteropNode(Node): + """ROS node implementing WebSocket communication to Mass. + + The node configuration is obtained from a configuration file + that can be provided externally. Then it subscribes to various + topics and sends relevant data to a Mass server by using a + WebSocket connection. + + Args: + Node (obj:`str`, optional): configuration file path. Defaults to `./config.yaml`. + """ + def __init__(self, **kwargs) -> None: + super().__init__(node_name=self.__class__.__name__, **kwargs) + self.logger = logging.getLogger(self.__class__.__name__) + + self.declare_parameter('config_file', './config.yaml') + config_file = self.get_parameter('config_file').get_parameter_value().string_value + config_file = Path(config_file).resolve() + if not config_file.is_file(): + raise ValueError(f"Configuration file '{config_file}' doesn't exist!") + + self.logger.info(f"Using configuration file '{config_file}'") + self._config = MassAMRInteropConfig(str(config_file)) + + self._uri = self._config.server + self.logger.debug(f"Connecting to Mass server '{self._uri}'") + self._wss_conn = websockets.connect(self._uri) + self.logger.debug(f"Connection successful!") + + async def _send_identity_report(self): + + identity_report_data = { + 'uuid': self._config.get_parameter_value('uuid'), + 'manufacturer_name': self._config.get_parameter_value('manufacturerName'), + 'robot_model': self._config.get_parameter_value('robotModel'), + 'robot_serial_number': self._config.get_parameter_value('robotSerialNumber'), + 'base_robot_envelop': self._config.get_parameter_value('baseRobotEnvelope'), + } + + identity_report = IdentityReport(**identity_report_data) + + async with self._wss_conn as websocket: + await websocket.send(json.dumps(identity_report.data)) + + def send_identity_report(self): + loop = asyncio.get_event_loop() + loop.run_until_complete(self._send_identity_report()) diff --git a/ros2_to_mass_amr_interop/config.py b/ros2_to_mass_amr_interop/config.py new file mode 100644 index 0000000..a2636a0 --- /dev/null +++ b/ros2_to_mass_amr_interop/config.py @@ -0,0 +1,135 @@ +import yaml +import logging +import os + +# Config files may have non static values that are +# ought to be extracted from a different source +# other than the config file +CFG_PARAMETER_STATIC = "static" +CFG_PARAMETER_ROS_TOPIC = "rosTopic" +CFG_PARAMETER_ROS_PARAMETER = "rosParameter" +CFG_PARAMETER_ENVVAR = "envVar" + +SUPPORTED_EXTERNAL_VALUES = [ + CFG_PARAMETER_ROS_TOPIC, + CFG_PARAMETER_ROS_PARAMETER, + CFG_PARAMETER_ENVVAR +] + + +class MassAMRInteropConfig: + """ + Configuration file parsing and value gathering. + + Parses yaml configuration file and deals with parameters + with values that are not static i.e. parameter values that + are obtained from environment variables or ROS2 topics. + + Attributes + ---------- + server (str): Mass WebSocket server URI + mapping (:obj:`dict`): parameter name and value mapping + + """ + + def __init__(self, path=None) -> None: + self.logger = logging.getLogger(__class__.__name__) + _config = self._load(path) + + self.server = _config['server'] + self.mapping = _config['mapping'] + + def _load(self, path) -> None: + config = dict() + with open(path, "r") as fd: + try: + config = yaml.safe_load(fd) + except yaml.YAMLError as ex: + self.logger.error("Failed to parse YAML config file", ex) + + self.logger.debug(f"Config file '{path}' loaded") + + # Ignoring config file root key value as it's not relevant + k = next(iter(config)) + config = config[k] + return config + + def get_parameter_source(self, name): + """ + Return parameter source. + + Args: + ---- + name (str): parameter name + + Raises + ------ + ValueError: when parameter does not exist or it is invalid + + Returns + ------- + str: parameter source + + """ + if isinstance(self.mapping[name], str): + return CFG_PARAMETER_STATIC + + if isinstance(self.mapping[name], dict): + # Evaluate is the parameter is non static + if 'valueFrom' in self.mapping[name]: + param_source = self.mapping[name]['valueFrom'] + + # param_source is a dict whose first key + # is the external parameter type + if not isinstance(param_source, dict): + raise ValueError(f"Invalid 'valueFrom' configuration: '{name}'") + param_source = next(iter(param_source)) + + if param_source in SUPPORTED_EXTERNAL_VALUES: + return param_source + + # If no supported external valueFrom configs were found, + # assume that the parameter is static if it is an object + if isinstance(self.mapping[name], dict): + return CFG_PARAMETER_STATIC + + raise ValueError("Invalid parameter") + + def get_parameter_value(self, name): + """ + Return configuration parameter value. + + It also deals with the complexity of getting + the value from different sources. + + Args: + ---- + name (Union[str, dict]): configuration parameter value + + Raises + ------ + ValueError: when parameter value does not exist or it is invalid + + Returns + ------- + str: parameter value + + """ + param_source = self.get_parameter_source(name) + self.logger.debug(f"Parameter '{name}' source: {param_source}") + + if param_source == CFG_PARAMETER_STATIC: + return self.mapping[name] + + if param_source == CFG_PARAMETER_ENVVAR: + envvar_name = self.mapping[name]['valueFrom'][param_source] + param_value = os.getenv(envvar_name) + if not param_value: + raise ValueError(f"Empty or undefined environment variable: '{envvar_name}'") + return param_value + + if param_source == CFG_PARAMETER_ROS_TOPIC: + return self.mapping[name]['valueFrom'][param_source] + + if param_source == CFG_PARAMETER_ROS_PARAMETER: + return self.mapping[name]['valueFrom'][param_source] diff --git a/ros2_to_mass_amr_interop/messages/AMR_Interop_Standard.json b/ros2_to_mass_amr_interop/messages/AMR_Interop_Standard.json new file mode 100644 index 0000000..cba1c17 --- /dev/null +++ b/ros2_to_mass_amr_interop/messages/AMR_Interop_Standard.json @@ -0,0 +1,313 @@ +{ + "definitions": { + "quaternion": { + "description": "Quaternion representation of an angle", + "type": "object", + "required": [ + "x", + "y", + "z", + "w" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + } + }, + "location": { + "description": "Location of an object or AMR", + "type": "object", + "required": [ + "x", + "y", + "angle", + "planarDatum" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number", + "default": 0 + }, + "angle": { + "$ref": "#/definitions/quaternion" + }, + "planarDatum": { + "description": "Id of planarDatum AMR is referencing", + "type": "string", + "format": "uuid" + } + } + }, + "predictedLocation": { + "description": "Predicted future location of an object or AMR", + "type": "object", + "required": [ + "timestamp", + "x", + "y", + "angle" + ], + "properties": { + "timestamp": { + "description": "Predicted UTC time AMR will reach this location", + "type": "string", + "format": "date-time" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number", + "default": 0 + }, + "angle": { + "$ref": "#/definitions/quaternion" + }, + "planarDatumUUID": { + "description": "Only necessary if different from AMRs current planarDatum", + "type": "string", + "format": "uuid" + } + } + } + }, + "identityReport": { + "type": "object", + "required": [ + "uuid", + "timestamp", + "manufacturerName", + "robotModel", + "robotSerialNumber", + "baseRobotEnvelope" + ], + "properties": { + "uuid": { + "description": "UUID specified by RFC4122 that all subsequent messages should reference", + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "manufacturerName": { + "type": "string" + }, + "robotModel": { + "type": "string" + }, + "robotSerialNumber": { + "description": "Unique robot identifier that ideally can be physically linked to the AMR", + "type": "string" + }, + "baseRobotEnvelope": { + "description": "Footprint of robot based on orientation - centered on current location.", + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number", + "default": 0 + } + } + }, + "maxSpeed": { + "description": "Max robot speed in m/s", + "type": "number" + }, + "maxRunTime": { + "description": "Estimated Runtime in hours", + "type": "number" + }, + "emergencyContactInformation": { + "description": "Emergency Contact - preferrably phone number", + "type": "string" + }, + "chargerType": { + "description": "Type of charger", + "type": "string" + }, + "supportVendorName": { + "description": "Vendor that supplied robot", + "type": "string" + }, + "supportVendorContactInformation": { + "description": "Contect information for vendor", + "type": "string" + }, + "productDocumentation": { + "description": "Link to product documenation", + "type": "string", + "format": "uri" + }, + "thumbnailImage": { + "description": "Link to thumbnail graphic stored as PNG", + "type": "string", + "format": "uri" + }, + "cargoType": { + "description": "Discription of cargo", + "type": "string" + }, + "cargoMaxVolume": { + "description": "Max volume of cargo in meters", + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number", + "default": 0 + } + } + }, + "cargoMaxWeight": { + "description": "Max weight of cargo in kg", + "type": "string" + } + } + }, + "statusReport": { + "type": "object", + "required": [ + "uuid", + "timestamp", + "operationalState", + "location" + ], + "properties": { + "uuid": { + "description": "UUID specified in the identityAndCapability message", + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "operationalState": { + "description": "Current action the robot is performing", + "type": "string", + "enum": [ + "navigating", + "idle", + "disabled", + "offline", + "charging", + "waiting", + "loadingUnloading", + "manualOveride" + ] + }, + "location": { + "description": "Current Location of AMR", + "$ref": "#/definitions/location" + }, + "velocity": { + "description": "Current velocity of AMR", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "description": "Linear velocity in m/s in heading direction, forward is postive", + "type": "number" + }, + "angular": { + "description": "Angular velocity in quaternions per second", + "$ref": "#/definitions/quaternion" + } + } + }, + "batteryPercentage": { + "description": "Percentage of battery remaining", + "type": "number", + "minimum": 0, + "inclusiveMaximum": 100 + }, + "remainingRunTime": { + "description": "Estimated remaining runtime in hours", + "type": "number", + "minimum": 0 + }, + "loadPercentageStillAvailable": { + "description": "Percentage of capacity still available", + "type": "number", + "minimum": 0, + "inclusiveMaximum": 100 + }, + "errorCodes": { + "description": "List of current error states - should be omitted for normal operation", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "destinations": { + "description": "Target destination(s) of AGV", + "type": "array", + "items": { + "$ref": "#/definitions/predictedLocation" + }, + "maxItems": 10, + "uniqueItems": true + }, + "path": { + "description": "Short term path of AGV ~10 sec", + "type": "array", + "items": { + "$ref": "#/definitions/predictedLocation" + }, + "maxItems": 10, + "uniqueItems": true + } + } + }, + "oneOf": [ + { + "$ref": "#/identityReport" + }, + { + "$ref": "#/statusReport" + } + ] +} \ No newline at end of file diff --git a/ros2_to_mass_amr_interop/messages/__init__.py b/ros2_to_mass_amr_interop/messages/__init__.py new file mode 100644 index 0000000..2767b9a --- /dev/null +++ b/ros2_to_mass_amr_interop/messages/__init__.py @@ -0,0 +1,33 @@ +import logging +from datetime import datetime + + +class MassObject: + def __init__(self) -> None: + pass + + def _validate_schema(self, mass_object_type): + pass + + +class IdentityReport(MassObject): + def __init__(self, uuid, manufacturer_name, + robot_model, robot_serial_number, + base_robot_envelop, **kwargs) -> None: + self.logger = logging.getLogger(self.__class__.__name__) + self.data = {} + self.data['uuid'] = uuid + self.data['manufacturerName'] = manufacturer_name + self.data['robotModel'] = robot_model + self.data['robotSerialNumber'] = robot_serial_number + self.data['baseRobotEnvelope'] = base_robot_envelop + + # As per Mass example, data format is ISO8601 + # with timezone offset e.g. 2012-04-21T18:25:43-05:00 + self.data['timestamp'] = datetime.now().replace(microsecond=0).astimezone().isoformat() + + # Add other optional identity report properties + for k, v in kwargs: + self.data[k] = v + + self._validate_schema(mass_object_type='identityReport') diff --git a/ros2_to_mass_amr_interop/ros2_to_mass_node.py b/ros2_to_mass_amr_interop/ros2_to_mass_node.py new file mode 100644 index 0000000..bb0763e --- /dev/null +++ b/ros2_to_mass_amr_interop/ros2_to_mass_node.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + +import rclpy +from . import MassAMRInteropNode + + +def main(args=None): + rclpy.init(args=args) + node = MassAMRInteropNode() + node.send_identity_report() + rclpy.spin(node) + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/sample_config.yaml b/sample_config.yaml new file mode 100644 index 0000000..d3f87bc --- /dev/null +++ b/sample_config.yaml @@ -0,0 +1,81 @@ +# ================================================================== +# ROS2 to Mass AMR Interoperability Standard node configuration file +# ================================================================== +# +# Parameters that are used to configure ROS2 node for connecting to Mass compatible servers. +# The `server` section expects a string with a WebSocket server URI, while the `mapping` section +# contains a list of paramaters for configuring the ROS2 node. Mandatory parameters are `uuid`, +# `manufacturerName`, `robotModel`, `robotSerialNumber` and `baseRobotEnvelope` (see full spec +# https://github.com/MassRobotics-AMR/AMR_Interop_Standard/blob/main/AMR_Interop_Standard.json). +# +# In addition to static values i.e. strings or objects, the `mapping` section supports a variety of +# sources from where the parameter value can be obtained: `envVar`, `rosTopic` and `rosParameter`. + +config: + server: "wss://localhost:3000" + mapping: + # Identity + uuid: + valueFrom: + envVar: MY_UUID + manufacturerName: Spoonlift + robotModel: "spoony1.0" + robotSerialNumber: "2172837" + baseRobotEnvelope: + x: 2 + y: 1 + z: 3 + maxSpeed: 2.5 + maxRunTime: 8 + emergencyContactInformation: "555-5555" + chargerType: "24V plus" + supportVendorName: "We-B-Robots" + supportVendorContactInformation: "support@we-b-robots.com" + productDocumentation: "https://spoon.lift/support/docs/spoony1.0" + thumbnailImage: "https://spoon.lift/media/spoony1.0.png" + cargoType: "Anything solid or liquid" + cargoMaxVolume: + value: + x: 2 + y: 2 + z: 1 + cargoMaxWeight: 4000 + # Status + operationalState: + valueFrom: + rosTopic: /we_b_robots/mode + # std_msgs/String + pose: + valueFrom: + rosTopic: /magic_nav/current_pose + # geometry_msgs/PoseStamped + velocity: + valueFrom: + rosTopic: /good_sensors/vel + # geometry_msgs/TwistStamped + batteryPercentage: + valueFrom: + rosTopic: /good_sensors/bat + # std_msgs/Float32 + remainingRunTime: + valueFrom: + rosTopic: /we_b_robots/remaining_time + # std_msgs/Float32 + loadPercentageStillAvailable: + valueFrom: + rosTopic: /spoonlift/available_capacity_percentage + # std_msgs/Float32 + errorCodes: TBD / Juli / Flor + destinations: + valueFrom: + rosTopic: /we_b_robots/destinations + # nav_msgs/Path + path: + valueFrom: + rosTopic: /magic_nav/path + # nav_msgs/Path + rosFrameToPlanarDatumUUID: + # required since Mass expects frames to be referenced using uuids + map: "096522ad-61fa-4796-9b31-e35b0f8d0b26" + floor1: "096522ad-61fa-4796-9b31-e35b0f8d0b26" + floor2: "6ec7a6d0-21a9-4f04-b680-e7c640a0687e" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f5fd2fc --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script-dir=$base/lib/ros2_to_mass_amr_interop +[install] +install-scripts=$base/lib/ros2_to_mass_amr_interop \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8989c00 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup, find_packages + +package_name = 'ros2_to_mass_amr_interop' + +setup( + name=package_name, + packages=find_packages(), + version='0.1.0', + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + ], + install_requires=['setuptools'], + zip_safe=True, + maintainer='InOrbit', + maintainer_email='support@inorbit.ai', + description='ROS2 node implementing MassRobotics AMR Interoperability Standard', + license='TODO: License declaration', + tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + 'ros2_to_mass_node = ros2_to_mass_amr_interop.ros2_to_mass_node:main' + ], + }, +) diff --git a/test/test_copyright.py b/test/test_copyright.py new file mode 100644 index 0000000..7019a72 --- /dev/null +++ b/test/test_copyright.py @@ -0,0 +1,24 @@ +# 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.skip(reason="Licensing") +@pytest.mark.copyright +@pytest.mark.linter +def test_copyright(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found errors' diff --git a/test/test_data/config.yaml b/test/test_data/config.yaml new file mode 100644 index 0000000..30ab8f8 --- /dev/null +++ b/test/test_data/config.yaml @@ -0,0 +1,68 @@ +config: + server: "wss://mass.inorbit.ai/receiver/aGVsbG8gd29ybGQgSSBsaWtlIHJvYm90cwo=" + mapping: + # Identity + uuid: + valueFrom: + envVar: MY_UUID + manufacturerName: Spoonlift + robotModel: "spoony1.0" + robotSerialNumber: "2172837" + baseRobotEnvelope: + x: 2 + y: 1 + z: 3 + maxSpeed: 2.5 + maxRunTime: 8 + emergencyContactInformation: "555-5555" + chargerType: "24V plus" + supportVendorName: "We-B-Robots" + supportVendorContactInformation: "support@we-b-robots.com" + productDocumentation: "https://spoon.lift/support/docs/spoony1.0" + thumbnailImage: "https://spoon.lift/media/spoony1.0.png" + cargoType: "Anything solid or liquid" + cargoMaxVolume: + value: + x: 2 + y: 2 + z: 1 + cargoMaxWeight: 4000 + # Status + operationalState: + valueFrom: + rosTopic: /we_b_robots/mode + # std_msgs/String + pose: + valueFrom: + rosTopic: /magic_nav/current_pose + # geometry_msgs/PoseStamped + velocity: + valueFrom: + rosTopic: /good_sensors/vel + # geometry_msgs/TwistStamped + batteryPercentage: + valueFrom: + rosTopic: /good_sensors/bat + # std_msgs/Float32 + remainingRunTime: + valueFrom: + rosTopic: /we_b_robots/remaining_time + # std_msgs/Float32 + loadPercentageStillAvailable: + valueFrom: + rosTopic: /spoonlift/available_capacity_percentage + # std_msgs/Float32 + errorCodes: TBD / Juli / Flor + destinations: + valueFrom: + rosTopic: /we_b_robots/destinations + # nav_msgs/Path + path: + valueFrom: + rosTopic: /magic_nav/path + # nav_msgs/Path + rosFrameToPlanarDatumUUID: + # required since Mass expects frames to be referenced using uuids + map: "096522ad-61fa-4796-9b31-e35b0f8d0b26" + floor1: "096522ad-61fa-4796-9b31-e35b0f8d0b26" + floor2: "6ec7a6d0-21a9-4f04-b680-e7c640a0687e" diff --git a/test/test_flake8.py b/test/test_flake8.py new file mode 100644 index 0000000..27ee107 --- /dev/null +++ b/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/test/test_mass_interop.py b/test/test_mass_interop.py new file mode 100644 index 0000000..8bf189d --- /dev/null +++ b/test/test_mass_interop.py @@ -0,0 +1,23 @@ +import pytest +import rclpy +from rclpy import Parameter +from pathlib import Path +from ros2_to_mass_amr_interop import MassAMRInteropNode + +cwd = Path(__file__).resolve().parent +config_file_test = cwd / 'test_data' / 'config.yaml' + + +def test_mass_config_load(): + rclpy.init() + MassAMRInteropNode(parameter_overrides=[ + Parameter("config_file", value=str(config_file_test)) + ]) + rclpy.shutdown() + + +def test_mass_config_load_fails_on_missing_config_file(): + rclpy.init() + with pytest.raises(ValueError): + MassAMRInteropNode() + rclpy.shutdown() diff --git a/test/test_mass_interop_config.py b/test/test_mass_interop_config.py new file mode 100644 index 0000000..221f45e --- /dev/null +++ b/test/test_mass_interop_config.py @@ -0,0 +1,38 @@ +import pytest +from pathlib import Path +from ros2_to_mass_amr_interop.config import MassAMRInteropConfig +from ros2_to_mass_amr_interop.config import CFG_PARAMETER_STATIC +from ros2_to_mass_amr_interop.config import CFG_PARAMETER_ROS_TOPIC +from ros2_to_mass_amr_interop.config import CFG_PARAMETER_ENVVAR + +cwd = Path(__file__).resolve().parent + + +def test_mass_config_load(): + cfg_file_path = Path(cwd) / "test_data" / "config.yaml" + assert MassAMRInteropConfig(str(cfg_file_path)).mapping != {} + + +@pytest.mark.parametrize("param_name, param_type", [ + ("uuid", CFG_PARAMETER_ENVVAR), + ("robotModel", CFG_PARAMETER_STATIC), + ("operationalState", CFG_PARAMETER_ROS_TOPIC), + ("baseRobotEnvelope", CFG_PARAMETER_STATIC) +]) +def test_mass_config_get_parameter_type(param_name, param_type): + cfg_file_path = Path(cwd) / "test_data" / "config.yaml" + mass_config = MassAMRInteropConfig(str(cfg_file_path)) + assert mass_config.get_parameter_source(param_name) == param_type + + +@pytest.mark.parametrize("param_name, value", [ + ("uuid", "foo"), + ("robotModel", "spoony1.0"), + ("operationalState", "/we_b_robots/mode"), + ("baseRobotEnvelope", {'x': 2, 'y': 1, 'z': 3}) +]) +def test_mass_config_get_parameter_value(monkeypatch, param_name, value): + monkeypatch.setenv("MY_UUID", "foo") # Environment variable used on config file + cfg_file_path = Path(cwd) / "test_data" / "config.yaml" + mass_config = MassAMRInteropConfig(str(cfg_file_path)) + assert mass_config.get_parameter_value(param_name) == value diff --git a/test/test_pep257.py b/test/test_pep257.py new file mode 100644 index 0000000..b234a38 --- /dev/null +++ b/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'