├── test ├── __init__.py ├── params.yaml ├── test_get_image.py ├── test_get_data.py ├── conftest.py ├── test_pep257.py ├── test_flake8.py ├── test_conftest.py ├── test_copyright.py └── test_publishers.py ├── resource └── ros2_web_interface ├── ros2_web_interface ├── __init__.py ├── api │ ├── __init__.py │ ├── registry.py │ ├── topic_routes.py │ ├── service_routes.py │ ├── system_routes.py │ └── action_routes.py ├── ros │ ├── __init__.py │ ├── base.py │ ├── factory.py │ ├── system.py │ ├── service.py │ ├── action.py │ └── topic.py ├── main.py └── models.py ├── setup.cfg ├── .gitignore ├── setup.py ├── package.xml ├── README.md └── LICENSE /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resource/ros2_web_interface: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ros2_web_interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ros2_web_interface/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ros2_web_interface/ros/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [develop] 2 | script_dir=$base/lib/ros2_web_interface 3 | [install] 4 | install_scripts=$base/lib/ros2_web_interface 5 | -------------------------------------------------------------------------------- /test/params.yaml: -------------------------------------------------------------------------------- 1 | test_publisher_node: 2 | ros__parameters: 3 | topics: | 4 | chatter: 5 | name: "/ros2_web_service_test/chatter" 6 | type: "message" 7 | test_message: "Hello param!" 8 | camera_image: 9 | name: "/ros2_web_service_test/camera/image" 10 | type: "image" 11 | -------------------------------------------------------------------------------- /test/test_get_image.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def test_image_topic(test_config): 4 | url = f"http://localhost:8000/get_data?topic={test_config['image_topic']}" 5 | response = requests.get(url) 6 | assert response.status_code == 200 7 | assert response.headers["Content-Type"] == "image/png" 8 | assert len(response.content) > 1000 9 | -------------------------------------------------------------------------------- /test/test_get_data.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def test_chatter_topic(test_config): 4 | url = f"http://localhost:8000/get_data?topic={test_config['chatter_topic']}" 5 | response = requests.get(url) 6 | assert response.status_code == 200 7 | data = response.json() 8 | assert data["topic"] == test_config["chatter_topic"] 9 | assert data["message_type"] == "String" 10 | assert "data" in data["data"] 11 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # src/ros2_web_interface/test/conftest.py 2 | 3 | import pytest 4 | import yaml 5 | from pathlib import Path 6 | 7 | @pytest.fixture(scope="session") 8 | def test_config(): 9 | config_path = Path(__file__).parent / "params.yaml" 10 | with open(config_path, "r") as f: 11 | full_config = yaml.safe_load(f) 12 | 13 | topic_config = yaml.safe_load(full_config["test_publisher_node"]["ros__parameters"]["topics"]) 14 | return { 15 | "chatter_topic": topic_config["chatter"]["name"], 16 | "image_topic": topic_config["camera_image"]["name"] 17 | } 18 | -------------------------------------------------------------------------------- /ros2_web_interface/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from ros2_web_interface.api.registry import get_router 3 | import rclpy 4 | from rclpy.node import Node 5 | import uvicorn 6 | 7 | app = FastAPI() 8 | ros_node: Node = None 9 | 10 | @app.on_event("startup") 11 | def startup(): 12 | global ros_node 13 | rclpy.init() 14 | ros_node = ROSNode() 15 | app.include_router(get_router(ros_node)) 16 | 17 | class ROSNode(Node): 18 | def __init__(self): 19 | super().__init__('web_interface_node') 20 | 21 | 22 | def main(): 23 | uvicorn.run("ros2_web_interface.main:app", host="0.0.0.0", port=8000) 24 | 25 | if __name__ == "__main__": 26 | main() -------------------------------------------------------------------------------- /ros2_web_interface/ros/base.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import rclpy 3 | from abc import ABC, abstractmethod 4 | 5 | 6 | class ROSInterface(ABC): 7 | _spin_lock = threading.Lock() 8 | 9 | def __init__(self, node): 10 | self.node = node 11 | 12 | def spin_until_future_complete(self, future, timeout=None): 13 | with ROSInterface._spin_lock: 14 | return rclpy.spin_until_future_complete(self.node, future, timeout_sec=timeout) 15 | 16 | def spin_once(self, timeout_sec=0.1): 17 | with ROSInterface._spin_lock: 18 | rclpy.spin_once(self.node, timeout_sec=timeout_sec) 19 | 20 | @abstractmethod 21 | def call(self, name: str, *args, **kwargs): 22 | pass 23 | -------------------------------------------------------------------------------- /ros2_web_interface/api/registry.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from ros2_web_interface.api.system_routes import get_system_router 3 | from ros2_web_interface.api.topic_routes import get_topic_router 4 | from ros2_web_interface.api.service_routes import get_service_router 5 | from ros2_web_interface.api.action_routes import get_action_router 6 | from ros2_web_interface.api.action_routes import get_action_router 7 | 8 | 9 | def get_router(ros_node): 10 | router = APIRouter() 11 | router.include_router(get_system_router(ros_node)) 12 | router.include_router(get_topic_router(ros_node)) 13 | router.include_router(get_service_router(ros_node)) 14 | router.include_router(get_action_router(ros_node)) 15 | return router -------------------------------------------------------------------------------- /ros2_web_interface/ros/factory.py: -------------------------------------------------------------------------------- 1 | from ros2_web_interface.ros.topic import TopicHandler 2 | from ros2_web_interface.ros.system import SystemHandler 3 | from ros2_web_interface.ros.service import ServiceHandler 4 | from ros2_web_interface.ros.action import ActionHandler 5 | 6 | class ROSInterfaceFactory: 7 | @staticmethod 8 | def get_handler(kind: str, node): 9 | if kind == "topic": 10 | return TopicHandler(node) 11 | if kind == "system": 12 | return SystemHandler(node) 13 | if kind == "service": 14 | return ServiceHandler(node) 15 | if kind == "action": 16 | return ActionHandler(node) 17 | raise ValueError(f"Unsupported ROS interface kind: {kind}") -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | devel/ 2 | logs/ 3 | build/ 4 | bin/ 5 | lib/ 6 | msg_gen/ 7 | srv_gen/ 8 | msg/*Action.msg 9 | msg/*ActionFeedback.msg 10 | msg/*ActionGoal.msg 11 | msg/*ActionResult.msg 12 | msg/*Feedback.msg 13 | msg/*Goal.msg 14 | msg/*Result.msg 15 | msg/_*.py 16 | build_isolated/ 17 | devel_isolated/ 18 | 19 | # Generated by dynamic reconfigure 20 | *.cfgc 21 | /cfg/cpp/ 22 | /cfg/*.py 23 | 24 | # Ignore generated docs 25 | *.dox 26 | *.wikidoc 27 | 28 | # eclipse stuff 29 | .project 30 | .cproject 31 | 32 | # qcreator stuff 33 | CMakeLists.txt.user 34 | 35 | srv/_*.py 36 | *.pcd 37 | *.pyc 38 | qtcreator-* 39 | *.user 40 | 41 | /planning/cfg 42 | /planning/docs 43 | /planning/src 44 | 45 | *~ 46 | 47 | # Emacs 48 | .#* 49 | 50 | # Catkin custom files 51 | CATKIN_IGNORE 52 | -------------------------------------------------------------------------------- /test/test_pep257.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from ament_pep257.main import main 16 | import pytest 17 | 18 | 19 | @pytest.mark.linter 20 | @pytest.mark.pep257 21 | def test_pep257(): 22 | rc = main(argv=['.', 'test']) 23 | assert rc == 0, 'Found code style errors / warnings' 24 | -------------------------------------------------------------------------------- /ros2_web_interface/ros/system.py: -------------------------------------------------------------------------------- 1 | from ros2_web_interface.ros.base import ROSInterface 2 | 3 | class SystemHandler(ROSInterface): 4 | def __init__(self, node): 5 | super().__init__(node) 6 | 7 | def call(self, name: str, data=None): 8 | if name == "list_topics": 9 | all_topics = self.node.get_topic_names_and_types() 10 | filtered = [(topic, types) for topic, types in all_topics if "/_action/" not in topic] 11 | return {"topics": filtered} 12 | 13 | elif name == "list_nodes": 14 | return {"nodes": self.node.get_node_names_and_namespaces()} 15 | 16 | elif name == "list_services": 17 | all_services = self.node.get_service_names_and_types() 18 | filtered = [srv for srv, _ in all_services if "/_action/" not in srv] 19 | return filtered 20 | else: 21 | raise ValueError(f"Unknown system call: {name}") -------------------------------------------------------------------------------- /ros2_web_interface/api/topic_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Depends, Response 2 | from ros2_web_interface.ros.factory import ROSInterfaceFactory 3 | from ros2_web_interface.models import GetDataQuery, MessageResponse 4 | 5 | def get_topic_router(ros_node): 6 | router = APIRouter() 7 | 8 | @router.get("/get_data", response_model=MessageResponse, responses={200: {"description": "JSON or image response"}}) 9 | def get_data(query: GetDataQuery = Depends()): 10 | handler = ROSInterfaceFactory.get_handler("topic", ros_node) 11 | try: 12 | result = handler.call(query.topic, query.timeout) 13 | if isinstance(result, Response): 14 | return result 15 | return result 16 | except TimeoutError as e: 17 | raise HTTPException(status_code=504, detail=str(e)) 18 | except Exception as e: 19 | raise HTTPException(status_code=500, detail=str(e)) 20 | 21 | return router -------------------------------------------------------------------------------- /test/test_flake8.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from ament_flake8.main import main_with_errors 16 | import pytest 17 | 18 | 19 | @pytest.mark.flake8 20 | @pytest.mark.linter 21 | def test_flake8(): 22 | rc, errors = main_with_errors(argv=[]) 23 | assert rc == 0, \ 24 | 'Found %d code style errors / warnings:\n' % len(errors) + \ 25 | '\n'.join(errors) 26 | -------------------------------------------------------------------------------- /test/test_conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import yaml 3 | from pathlib import Path 4 | import requests 5 | import time 6 | 7 | @pytest.fixture(scope="session") 8 | def test_config(): 9 | # Load params.yaml and extract topic mappings 10 | param_file = Path(__file__).parent / "params.yaml" 11 | with open(param_file, "r") as f: 12 | full_config = yaml.safe_load(f) 13 | 14 | topics_yaml = full_config["test_publisher_node"]["ros__parameters"]["topics"] 15 | topics = yaml.safe_load(topics_yaml) 16 | return topics 17 | 18 | @pytest.fixture(scope="session", autouse=True) 19 | def wait_for_api(): 20 | print("[pytest] Waiting for FastAPI service...") 21 | for _ in range(30): 22 | try: 23 | res = requests.get("http://localhost:8000/list_topics") 24 | if res.status_code == 200: 25 | print("[pytest] API is up.") 26 | return 27 | except: 28 | pass 29 | time.sleep(1) 30 | pytest.fail("FastAPI server not available.") 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | package_name = 'ros2_web_interface' 4 | 5 | setup( 6 | name=package_name, 7 | version='0.0.0', 8 | packages=find_packages(exclude=['']), 9 | data_files=[ 10 | ('share/ament_index/resource_index/packages', 11 | ['resource/' + package_name]), 12 | ('share/' + package_name, ['package.xml']), 13 | ], 14 | install_requires=[ 15 | 'setuptools', 16 | 'fastapi', 17 | 'uvicorn', 18 | 'opencv-python', 19 | 'pytest', 20 | 'requests', 21 | ], 22 | zip_safe=True, 23 | maintainer='root', 24 | maintainer_email='root@todo.todo', 25 | description='TODO: Package description', 26 | license='TODO: License declaration', 27 | tests_require=['pytest'], 28 | entry_points={ 29 | 'console_scripts': [ 30 | 'ros2_web_interface = ros2_web_interface.main:main', 31 | 'test_publishers = test.test_publishers:main', 32 | ], 33 | }, 34 | ) 35 | -------------------------------------------------------------------------------- /test/test_copyright.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from ament_copyright.main import main 16 | import pytest 17 | 18 | 19 | # Remove the `skip` decorator once the source file(s) have a copyright header 20 | @pytest.mark.skip(reason='No copyright header has been placed in the generated source file.') 21 | @pytest.mark.copyright 22 | @pytest.mark.linter 23 | def test_copyright(): 24 | rc = main(argv=['.', 'test']) 25 | assert rc == 0, 'Found errors' 26 | -------------------------------------------------------------------------------- /ros2_web_interface/api/service_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Body, Depends 2 | from typing import Optional 3 | from ros2_web_interface.ros.factory import ROSInterfaceFactory 4 | from ros2_web_interface.models import CallServiceRequest, CallServiceResponse, ServiceQuery 5 | 6 | 7 | def get_service_router(ros_node): 8 | router = APIRouter() 9 | 10 | @router.post("/call_service", response_model=CallServiceResponse) 11 | def call_service( 12 | query: ServiceQuery = Depends(), 13 | req: Optional[CallServiceRequest] = Body(default=None, description="Service request body (unwrapped)") 14 | ): 15 | handler = ROSInterfaceFactory.get_handler("service", ros_node) 16 | payload = req.dict() if req else {} 17 | try: 18 | return handler.call(query.topic, payload, query.timeout) 19 | except TimeoutError as e: 20 | raise HTTPException(status_code=504, detail=str(e)) 21 | except Exception as e: 22 | raise HTTPException(status_code=500, detail=str(e)) 23 | 24 | return router -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ros2_web_interface 5 | 0.0.0 6 | TODO: Package description 7 | root 8 | TODO: License declaration 9 | 10 | 11 | rclpy 12 | sensor_msgs 13 | cv_bridge 14 | rosidl_runtime_py 15 | 16 | 17 | python3-pip 18 | python3-opencv 19 | python3-uvicorn 20 | python3-fastapi 21 | 22 | ament_copyright 23 | ament_flake8 24 | ament_pep257 25 | python3-pytest 26 | python3-requests 27 | 28 | 29 | ament_python 30 | 31 | 32 | -------------------------------------------------------------------------------- /ros2_web_interface/api/system_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from ros2_web_interface.ros.factory import ROSInterfaceFactory 3 | from ros2_web_interface.models import TopicListResponse, NodeListResponse, ServiceListResponse 4 | 5 | 6 | def get_system_router(ros_node): 7 | router = APIRouter() 8 | 9 | @router.get("/list_topics", response_model=TopicListResponse) 10 | def list_topics(): 11 | handler = ROSInterfaceFactory.get_handler("system", ros_node) 12 | try: 13 | raw = handler.call("list_topics") 14 | return {"topics": [{"name": t[0], "types": list(t[1])} for t in raw["topics"]]} 15 | except Exception as e: 16 | raise HTTPException(status_code=500, detail=str(e)) 17 | 18 | @router.get("/list_nodes", response_model=NodeListResponse) 19 | def list_nodes(): 20 | handler = ROSInterfaceFactory.get_handler("system", ros_node) 21 | try: 22 | raw = handler.call("list_nodes") 23 | return {"nodes": [{"name": n[0], "namespace": n[1]} for n in raw["nodes"]]} 24 | except Exception as e: 25 | raise HTTPException(status_code=500, detail=str(e)) 26 | 27 | @router.get("/list_services", response_model=ServiceListResponse) 28 | def list_services(): 29 | handler = ROSInterfaceFactory.get_handler("system", ros_node) 30 | try: 31 | raw = handler.call("list_services") 32 | return {"services": raw} 33 | except Exception as e: 34 | raise HTTPException(status_code=500, detail=str(e)) 35 | 36 | return router 37 | -------------------------------------------------------------------------------- /ros2_web_interface/api/action_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Body, Depends 2 | from typing import Optional 3 | from ros2_web_interface.ros.factory import ROSInterfaceFactory 4 | from ros2_web_interface.models import ( 5 | CallActionRequest, ActionQuery 6 | ) 7 | 8 | 9 | def get_action_router(ros_node): 10 | router = APIRouter() 11 | 12 | @router.post("/call_action") 13 | def call_action( 14 | query: ActionQuery = Depends(), 15 | req: Optional[CallActionRequest] = Body(default=None, description="Action goal fields (unwrapped)") 16 | ): 17 | handler = ROSInterfaceFactory.get_handler("action", ros_node) 18 | payload = req.dict() if req else {} 19 | try: 20 | result = handler.call(query.topic, payload, query.timeout) 21 | return result 22 | except TimeoutError as e: 23 | raise HTTPException(status_code=504, detail=str(e)) 24 | except Exception as e: 25 | raise HTTPException(status_code=500, detail=str(e)) 26 | 27 | @router.get("/list_actions") 28 | def list_actions(): 29 | handler = ROSInterfaceFactory.get_handler("action", ros_node) 30 | try: 31 | return {"actions": handler.list()} 32 | except Exception as e: 33 | raise HTTPException(status_code=500, detail=str(e)) 34 | 35 | @router.get("/action_result") 36 | def action_result(goal_id: str): 37 | handler = ROSInterfaceFactory.get_handler("action", ros_node) 38 | try: 39 | return handler.get_result(goal_id) 40 | except Exception as e: 41 | raise HTTPException(status_code=500, detail=str(e)) 42 | 43 | return router 44 | -------------------------------------------------------------------------------- /ros2_web_interface/ros/service.py: -------------------------------------------------------------------------------- 1 | import rclpy 2 | import importlib 3 | import time 4 | from rclpy.node import Node 5 | from rclpy.task import Future 6 | from rosidl_runtime_py.utilities import get_service 7 | from ros2_web_interface.ros.base import ROSInterface 8 | from rosidl_runtime_py.convert import message_to_ordereddict 9 | 10 | 11 | class ServiceHandler(ROSInterface): 12 | def __init__(self, node: Node): 13 | super().__init__(node) 14 | 15 | def call(self, name: str, data=None, timeout=30.0): 16 | srv_type_str = self._get_service_type(name) 17 | parts = srv_type_str.split("/") 18 | if len(parts) == 3 and parts[1] == "srv": 19 | pkg, _, srv = parts 20 | else: 21 | pkg, srv = parts 22 | 23 | module = importlib.import_module(f"{pkg}.srv") 24 | srv_class = getattr(module, srv) 25 | 26 | client = self.node.create_client(srv_class, name) 27 | if not client.wait_for_service(timeout_sec=5.0): 28 | raise TimeoutError(f"Service {name} not available") 29 | 30 | request = srv_class.Request() 31 | if data: 32 | for k, v in data.items(): 33 | setattr(request, k, v) 34 | 35 | future = client.call_async(request) 36 | self.spin_until_future_complete(future, timeout) 37 | 38 | result = future.result() 39 | self.node.destroy_client(client) 40 | return message_to_ordereddict(result) 41 | 42 | 43 | def _get_service_type(self, srv_name): 44 | for name, types in self.node.get_service_names_and_types(): 45 | if name == srv_name: 46 | return types[0] 47 | raise ValueError(f"Service {srv_name} not found") 48 | -------------------------------------------------------------------------------- /ros2_web_interface/ros/action.py: -------------------------------------------------------------------------------- 1 | import rclpy 2 | import importlib 3 | import time 4 | from rclpy.action import ActionClient, get_action_names_and_types 5 | from rclpy.node import Node 6 | from rosidl_runtime_py.utilities import get_action 7 | from rosidl_runtime_py.convert import message_to_ordereddict 8 | from ros2_web_interface.ros.base import ROSInterface 9 | 10 | 11 | class ActionHandler(ROSInterface): 12 | def __init__(self, node: Node): 13 | super().__init__(node) 14 | 15 | def call(self, name: str, data=None, timeout=60.0): 16 | action_type_str = self._get_action_type(name) 17 | parts = action_type_str.split("/") 18 | if len(parts) == 3 and parts[1] == "action": 19 | pkg, _, act = parts 20 | else: 21 | pkg, act = parts 22 | 23 | module = importlib.import_module(f"{pkg}.action") 24 | action_class = getattr(module, act) 25 | 26 | client = ActionClient(self.node, action_class, name) 27 | if not client.wait_for_server(timeout_sec=5.0): 28 | raise TimeoutError(f"Action server {name} not available") 29 | 30 | goal_msg = action_class.Goal() 31 | if data: 32 | for k, v in data.items(): 33 | setattr(goal_msg, k, v) 34 | 35 | send_goal_future = client.send_goal_async(goal_msg) 36 | self.spin_until_future_complete(send_goal_future, timeout) 37 | goal_handle = send_goal_future.result() 38 | 39 | if not goal_handle or not goal_handle.accepted: 40 | raise RuntimeError(f"Failed to send goal to action server {name}") 41 | 42 | get_result_future = goal_handle.get_result_async() 43 | self.spin_until_future_complete(get_result_future, timeout) 44 | result = get_result_future.result().result 45 | 46 | return message_to_ordereddict(result) 47 | 48 | def list(self): 49 | action_names_and_types = get_action_names_and_types(self.node) 50 | return [name for name, _ in action_names_and_types] 51 | 52 | def _get_action_type(self, action_name): 53 | for name, types in get_action_names_and_types(self.node): 54 | if name == action_name: 55 | return types[0] 56 | raise ValueError(f"Action {action_name} not found") -------------------------------------------------------------------------------- /ros2_web_interface/ros/topic.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from sensor_msgs.msg import Image as ROSImage 4 | from rosidl_runtime_py.utilities import get_message 5 | from rosidl_runtime_py.convert import message_to_ordereddict 6 | from cv_bridge import CvBridge 7 | import cv2 8 | from fastapi import Response 9 | from ros2_web_interface.models import MessageResponse 10 | from ros2_web_interface.ros.base import ROSInterface 11 | 12 | 13 | class TopicHandler(ROSInterface): 14 | def __init__(self, node): 15 | super().__init__(node) 16 | self.lock = threading.Lock() 17 | self.event = threading.Event() 18 | self.latest_msg = None 19 | 20 | def call(self, name: str, timeout: float): 21 | msg_type = self._get_msg_type(name) 22 | msg_class = get_message(msg_type) 23 | 24 | with self.lock: 25 | self.latest_msg = None 26 | self.event.clear() 27 | sub = self.node.create_subscription( 28 | msg_class, name, self._callback, qos_profile=10 29 | ) 30 | 31 | start = time.time() 32 | while not self.event.is_set(): 33 | if time.time() - start > timeout: 34 | self.node.destroy_subscription(sub) 35 | raise TimeoutError(f"Timeout waiting for message on {name}") 36 | self.spin_once(timeout_sec=0.1) 37 | 38 | self.node.destroy_subscription(sub) 39 | return self._format_response(name, self.latest_msg) 40 | 41 | def _callback(self, msg): 42 | if self.latest_msg is None: 43 | self.latest_msg = msg 44 | self.event.set() 45 | 46 | def _get_msg_type(self, topic): 47 | for name, types in self.node.get_topic_names_and_types(): 48 | if name == topic: 49 | return types[0] 50 | raise ValueError(f"Topic {topic} not found") 51 | 52 | def _format_response(self, topic, msg): 53 | if isinstance(msg, ROSImage): 54 | bridge = CvBridge() 55 | cv_image = bridge.imgmsg_to_cv2(msg, desired_encoding="bgr8") 56 | success, buffer = cv2.imencode(".png", cv_image) 57 | if not success: 58 | raise RuntimeError("Failed to encode image") 59 | return Response(content=buffer.tobytes(), media_type="image/png") 60 | else: 61 | return MessageResponse( 62 | topic=topic, 63 | message_type=msg.__class__.__name__, 64 | data=message_to_ordereddict(msg) 65 | ) 66 | -------------------------------------------------------------------------------- /test/test_publishers.py: -------------------------------------------------------------------------------- 1 | import rclpy 2 | from rclpy.node import Node 3 | from std_msgs.msg import String 4 | from sensor_msgs.msg import Image 5 | import numpy as np 6 | import cv2 7 | from cv_bridge import CvBridge 8 | import yaml 9 | 10 | 11 | class ConfigurableTestPublisherNode(Node): 12 | def __init__(self): 13 | super().__init__('test_publisher_node') 14 | self.bridge = CvBridge() 15 | self.chatter_count = 0 16 | 17 | # Get 'topics' param as a YAML string 18 | self.declare_parameter('topics', "") 19 | raw_param = self.get_parameter('topics').get_parameter_value().string_value 20 | 21 | if not raw_param: 22 | self.get_logger().error("No 'topics' parameter provided.") 23 | raise RuntimeError("Missing 'topics' parameter.") 24 | 25 | # Parse YAML string to dict 26 | self.config = yaml.safe_load(raw_param) 27 | self.topic_publishers = {} # ✅ Renamed to avoid conflict 28 | self.setup_publishers() 29 | 30 | # Start publishing loop 31 | self.timer = self.create_timer(0.5, self.publish_all) 32 | 33 | def setup_publishers(self): 34 | for topic_cfg in self.config.values(): 35 | topic_name = topic_cfg['name'] 36 | topic_type = topic_cfg['type'] 37 | 38 | if topic_type == 'message': 39 | pub = self.create_publisher(String, topic_name, 10) 40 | test_message = topic_cfg.get('test_message', 'Hello from test!') 41 | self.topic_publishers[topic_name] = { 42 | 'type': 'message', 43 | 'publisher': pub, 44 | 'test_message': test_message 45 | } 46 | self.get_logger().info(f"Configured String publisher for {topic_name}") 47 | 48 | elif topic_type == 'image': 49 | pub = self.create_publisher(Image, topic_name, 10) 50 | self.topic_publishers[topic_name] = { 51 | 'type': 'image', 52 | 'publisher': pub 53 | } 54 | self.get_logger().info(f"Configured Image publisher for {topic_name}") 55 | 56 | else: 57 | self.get_logger().warn(f"Unsupported type in config: {topic_type}") 58 | 59 | def publish_all(self): 60 | for topic_name, pub_info in self.topic_publishers.items(): 61 | pub = pub_info['publisher'] 62 | 63 | if pub_info['type'] == 'message': 64 | msg = String() 65 | msg.data = f"{pub_info['test_message']} Count: {self.chatter_count}" 66 | pub.publish(msg) 67 | 68 | elif pub_info['type'] == 'image': 69 | img = np.zeros((240, 320, 3), dtype=np.uint8) 70 | cv2.putText(img, "Test", (100, 120), cv2.FONT_HERSHEY_SIMPLEX, 71 | 1, (255, 255, 255), 2) 72 | ros_img = self.bridge.cv2_to_imgmsg(img, encoding='bgr8') 73 | pub.publish(ros_img) 74 | 75 | self.chatter_count += 1 76 | if self.chatter_count % 5 == 0: 77 | self.get_logger().info(f"Published {self.chatter_count} message cycles.") 78 | 79 | 80 | def main(args=None): 81 | rclpy.init(args=args) 82 | node = ConfigurableTestPublisherNode() 83 | rclpy.spin(node) 84 | node.destroy_node() 85 | rclpy.shutdown() 86 | 87 | 88 | if __name__ == '__main__': 89 | main() 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ROS 2 Web Interface 2 | 3 | [![License](https://img.shields.io/github/license/mschweig/ros2_web_interface.svg)](LICENSE) 4 | [![ROS 2](https://img.shields.io/badge/ROS%202-Humble-blue)](https://docs.ros.org/en/humble/index.html) 5 | [![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) 6 | 7 | A high-performance, modular web interface for interacting with ROS 2 topics, services, and actions via FastAPI. 8 | 9 | This project provides a robust and extensible framework for dynamic interaction with ROS 2 systems over HTTP, supporting quick data access, service execution, and action handling. Designed with clean software architecture principles, it integrates ROS 2 with modern web technologies to enhance robotics development workflows. 10 | 11 | --- 12 | 13 | ## 🚀 Key Features 14 | 15 | - ✅ **Dynamic Topic Subscription**: Auto-detects ROS 2 message types. 16 | - ✅ **Full Message Support**: Compatible with all ROS 2 message types, including `sensor_msgs/Image`. 17 | - ✅ **Media-Aware Responses**: Serves images as `image/png`, other messages as structured JSON. 18 | - ✅ **Action Handling**: Submit and monitor ROS 2 actions. 19 | - ✅ **Extensible Architecture**: Modular handler system for easy feature expansion. 20 | - ✅ **Persistent Node**: Background spinning ensures continuous ROS 2 interaction. 21 | - ✅ **API Versioning**: Structured routes ready for scalable API evolution. 22 | - ✅ **Interactive API Documentation**: Swagger UI available at `/docs`. 23 | 24 | --- 25 | 26 | ## 📦 Requirements 27 | 28 | - ROS 2 (Humble or newer) 29 | - Python 3.8 or higher 30 | 31 | > Install all dependencies via: 32 | ```bash 33 | rosdep install --from-paths src --ignore-src -r -y 34 | ``` 35 | 36 | --- 37 | 38 | ## 🔧 Installation 39 | 40 | ```bash 41 | cd ~/ros2_ws/src 42 | git clone https://github.com/mschweig/ros2_web_interface.git ros2_web_interface 43 | rosdep install --from-paths src --ignore-src -r -y 44 | cd ~/ros2_ws 45 | colcon build --packages-select ros2_web_interface 46 | source install/setup.bash 47 | ``` 48 | 49 | --- 50 | 51 | ## 🚀 Running the Web Server 52 | 53 | ```bash 54 | ros2 run ros2_web_interface ros2_web_interface 55 | ``` 56 | 57 | > Access the API at: `http://localhost:8000` 58 | 59 | --- 60 | 61 | ## 🔍 Interactive API Documentation 62 | 63 | This project provides interactive documentation via **Swagger UI**: 64 | 65 | - Access at: `http://localhost:8000/docs` 66 | - Visualize available endpoints. 67 | - Test API calls directly from the browser. 68 | - Auto-generated from Pydantic models for accuracy and clarity. 69 | 70 | --- 71 | 72 | ## 🧪 Testing Suite 73 | 74 | ```bash 75 | ros2 run ros2_web_interface test_publishers --ros-args --params-file src/ros2_web_interface/test/params.yaml 76 | pytest src/ros2_web_interface/test/ 77 | ``` 78 | 79 | --- 80 | 81 | ## 🏗️ Architecture Overview 82 | 83 | ``` 84 | main.py - ROS node + FastAPI startup logic 85 | models.py - Pydantic schemas for validation and documentation 86 | ros/ 87 | ├─ base.py - Abstract handler interface (ROSInterface) 88 | ├─ topic.py - TopicHandler for dynamic topic data 89 | ├─ service.py - ServiceHandler for ROS service calls 90 | ├─ action.py - ActionHandler for action management 91 | ├─ system.py - SystemHandler for metadata operations 92 | └─ factory.py - Dynamic handler factory 93 | api/ 94 | ├─ registry.py - Central route registry 95 | ├─ topic_routes.py 96 | ├─ service_routes.py 97 | └─ action_routes.py 98 | ``` 99 | 100 | --- 101 | 102 | ## 📚 License 103 | 104 | Apache License 2.0 — Optimized for professional ROS 2 deployments. 105 | 106 | --- 107 | 108 | ## 🤛 Contributing 109 | 110 | Contributions are welcome! Help us enhance ROS 2 web integration by submitting issues, feature requests, or pull requests. 111 | 112 | --- 113 | 114 | Crafted with ❤️ by robotics engineers for real-world ROS 2 applications. 115 | -------------------------------------------------------------------------------- /ros2_web_interface/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from typing import Dict, Any, List, Optional 3 | 4 | class GetDataQuery(BaseModel): 5 | """ 6 | Represents a query to get data from a ROS topic. 7 | 8 | Attributes: 9 | topic (str): The ROS topic name. 10 | timeout (float): Timeout in seconds for the query. 11 | """ 12 | topic: str = Field( 13 | ..., 14 | description="ROS topic name", 15 | example="/ros2_web_service_test/chatter" 16 | ) 17 | timeout: float = Field( 18 | 30.0, 19 | gt=0, 20 | description="Timeout in seconds", 21 | example=5.0 22 | ) 23 | 24 | 25 | class CallServiceRequest(BaseModel): 26 | """ 27 | Represents a request to call a ROS service. 28 | 29 | Attributes: 30 | __root__ (Dict[str, Any]): Dictionary of service request fields. 31 | """ 32 | __root__: Dict[str, Any] = Field( 33 | default_factory=dict, 34 | description="Dictionary of service request fields", 35 | example={"dock_id": 520} 36 | ) 37 | 38 | def dict(self, *args, **kwargs) -> Dict[str, Any]: 39 | """ 40 | Overrides the default dict method to return the root dictionary. 41 | 42 | Args: 43 | *args: Positional arguments for the parent dict method. 44 | **kwargs: Keyword arguments for the parent dict method. 45 | 46 | Returns: 47 | Dict[str, Any]: The root dictionary. 48 | """ 49 | return super().dict(*args, **kwargs).get("__root__", {}) 50 | 51 | 52 | class ServiceQuery(BaseModel): 53 | """ 54 | Represents a query to call a ROS service. 55 | 56 | Attributes: 57 | topic (str): The ROS service topic name. 58 | timeout (float): Timeout in seconds for the service call. 59 | """ 60 | topic: str = Field( 61 | ..., 62 | example="/robot/dock" 63 | ) 64 | timeout: float = Field( 65 | 30.0, 66 | gt=0, 67 | example=20.0 68 | ) 69 | 70 | 71 | class MessageResponse(BaseModel): 72 | """ 73 | Represents a response message from a ROS topic. 74 | 75 | Attributes: 76 | topic (str): The ROS topic name. 77 | message_type (str): The type of the message. 78 | data (Dict[str, Any]): The message data. 79 | """ 80 | topic: str = Field( 81 | ..., 82 | example="/ros2_web_service_test/chatter" 83 | ) 84 | message_type: str = Field( 85 | ..., 86 | example="String" 87 | ) 88 | data: Dict[str, Any] = Field( 89 | ..., 90 | example={"data": "Hello from test!"} 91 | ) 92 | 93 | 94 | class CallServiceResponse(BaseModel): 95 | """ 96 | Represents a response from a ROS service call. 97 | 98 | Attributes: 99 | success (bool): Whether the service call was successful. 100 | message (str): A message describing the result of the service call. 101 | """ 102 | success: bool = Field( 103 | ..., 104 | example=True 105 | ) 106 | message: str = Field( 107 | ..., 108 | example="Docking started." 109 | ) 110 | 111 | 112 | class TopicInfo(BaseModel): 113 | """ 114 | Represents information about a ROS topic. 115 | 116 | Attributes: 117 | name (str): The name of the topic. 118 | types (List[str]): The message types associated with the topic. 119 | """ 120 | name: str = Field( 121 | ..., 122 | example="/chatter" 123 | ) 124 | types: List[str] = Field( 125 | ..., 126 | example=["std_msgs/msg/String"] 127 | ) 128 | 129 | 130 | class TopicListResponse(BaseModel): 131 | """ 132 | Represents a response containing a list of ROS topics. 133 | 134 | Attributes: 135 | topics (List[TopicInfo]): A list of topic information. 136 | """ 137 | topics: List[TopicInfo] 138 | 139 | 140 | class NodeInfo(BaseModel): 141 | """ 142 | Represents information about a ROS node. 143 | 144 | Attributes: 145 | name (str): The name of the node. 146 | namespace (str): The namespace of the node. 147 | """ 148 | name: str = Field( 149 | ..., 150 | example="/my_node" 151 | ) 152 | namespace: str = Field( 153 | ..., 154 | example="/" 155 | ) 156 | 157 | 158 | class NodeListResponse(BaseModel): 159 | """ 160 | Represents a response containing a list of ROS nodes. 161 | 162 | Attributes: 163 | nodes (List[NodeInfo]): A list of node information. 164 | """ 165 | nodes: List[NodeInfo] 166 | 167 | 168 | class ServiceListResponse(BaseModel): 169 | """ 170 | Represents a response containing a list of available ROS services. 171 | 172 | Attributes: 173 | services (List[str]): A list of available ROS service names. 174 | """ 175 | services: List[str] = Field( 176 | ..., 177 | description="List of available ROS services", 178 | example=["/robot/dock", "/robot/undock"] 179 | ) 180 | 181 | 182 | class CallActionRequest(BaseModel): 183 | """ 184 | Represents a request to call a ROS action. 185 | 186 | Attributes: 187 | __root__ (Dict[str, Any]): Dictionary of action goal fields. 188 | """ 189 | __root__: Dict[str, Any] = Field( 190 | default_factory=dict, 191 | description="Dictionary of action goal fields", 192 | example={"target_pose": {"x": 1.0, "y": 2.0, "z": 0.0}} 193 | ) 194 | 195 | def dict(self, *args, **kwargs) -> Dict[str, Any]: 196 | """ 197 | Overrides the default dict method to return the root dictionary. 198 | 199 | Returns: 200 | Dict[str, Any]: The root dictionary representing the action goal. 201 | """ 202 | return super().dict(*args, **kwargs).get("__root__", {}) 203 | 204 | 205 | class CallActionResponseAsync(BaseModel): 206 | """ 207 | Represents an asynchronous response after sending a ROS action goal. 208 | 209 | Attributes: 210 | goal_id (str): Unique identifier for the action goal. 211 | accepted (bool): Whether the goal was accepted by the action server. 212 | """ 213 | goal_id: str = Field(..., description="Unique identifier for the action goal", example="abc-123") 214 | accepted: bool = Field(..., description="Whether the goal was accepted", example=True) 215 | 216 | 217 | class ActionResultResponse(BaseModel): 218 | """ 219 | Represents the result status of a ROS action. 220 | 221 | Attributes: 222 | status (str): The status of the action (e.g., 'pending', 'done'). 223 | result (Dict[str, Any], optional): The result of the action if done. 224 | """ 225 | status: str = Field(..., description="Status of the action", example="pending") 226 | result: Optional[Dict[str, Any]] = Field(None, description="Result of the action if done", example={"success": True}) 227 | 228 | 229 | class ActionListResponse(BaseModel): 230 | """ 231 | Represents a response containing a list of available ROS actions. 232 | 233 | Attributes: 234 | actions (List[str]): A list of available ROS action names. 235 | """ 236 | actions: List[str] = Field( 237 | ..., 238 | description="List of available ROS actions", 239 | example=["/robot/navigate_to_pose", "/robot/follow_path"] 240 | ) 241 | 242 | 243 | class ActionQuery(BaseModel): 244 | """ 245 | Represents a query to call a ROS action. 246 | 247 | Attributes: 248 | topic (str): The ROS action topic name. 249 | timeout (float): Timeout in seconds for the action call. 250 | """ 251 | topic: str = Field( 252 | ..., 253 | example="/robot/navigate_to_pose" 254 | ) 255 | timeout: float = Field( 256 | 60.0, 257 | gt=0, 258 | example=60.0 259 | ) 260 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------