pytest-ros 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_ros-0.1.0/.github/workflows/python-publish.yml +67 -0
- pytest_ros-0.1.0/.gitignore +209 -0
- pytest_ros-0.1.0/PKG-INFO +47 -0
- pytest_ros-0.1.0/README.md +28 -0
- pytest_ros-0.1.0/pyproject.toml +53 -0
- pytest_ros-0.1.0/src/pytest_ros/__init__.py +5 -0
- pytest_ros-0.1.0/src/pytest_ros/_version.py +24 -0
- pytest_ros-0.1.0/src/pytest_ros/monitor.py +387 -0
- pytest_ros-0.1.0/src/pytest_ros/plugin.py +32 -0
- pytest_ros-0.1.0/src/pytest_ros/process.py +108 -0
- pytest_ros-0.1.0/src/pytest_ros/video_recorder.py +108 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# This workflow uses actions that are not certified by GitHub.
|
|
2
|
+
# They are provided by a third-party and are governed by
|
|
3
|
+
# separate terms of service, privacy policy, and support
|
|
4
|
+
# documentation.
|
|
5
|
+
|
|
6
|
+
# GitHub recommends pinning actions to a commit SHA.
|
|
7
|
+
# To get a newer version, you will need to update the SHA.
|
|
8
|
+
# You can also reference a tag or branch, but the action may change without warning.
|
|
9
|
+
|
|
10
|
+
name: Upload Python Package
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
release-build:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v6
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.x"
|
|
30
|
+
|
|
31
|
+
- name: Build release distributions
|
|
32
|
+
run: |
|
|
33
|
+
# NOTE: put your own distribution build steps here.
|
|
34
|
+
python -m pip install build
|
|
35
|
+
python -m build
|
|
36
|
+
|
|
37
|
+
- name: Upload distributions
|
|
38
|
+
uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: release-dists
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
pypi-publish:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
|
|
46
|
+
needs:
|
|
47
|
+
- release-build
|
|
48
|
+
|
|
49
|
+
permissions:
|
|
50
|
+
# IMPORTANT: this permission is mandatory for trusted publishing
|
|
51
|
+
id-token: write
|
|
52
|
+
|
|
53
|
+
# Dedicated environments with protections for publishing are strongly recommended.
|
|
54
|
+
environment:
|
|
55
|
+
name: pypi
|
|
56
|
+
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
|
|
57
|
+
# url: https://pypi.org/p/YOURPROJECT
|
|
58
|
+
|
|
59
|
+
steps:
|
|
60
|
+
- name: Retrieve release distributions
|
|
61
|
+
uses: actions/download-artifact@v5
|
|
62
|
+
with:
|
|
63
|
+
name: release-dists
|
|
64
|
+
path: dist/
|
|
65
|
+
|
|
66
|
+
- name: Publish release distributions to PyPI
|
|
67
|
+
uses: pypa/gh-action-pypi-publish@v1.14.3
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
*_version.py
|
|
2
|
+
|
|
3
|
+
# Byte-compiled / optimized / DLL files
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[codz]
|
|
6
|
+
*$py.class
|
|
7
|
+
|
|
8
|
+
# C extensions
|
|
9
|
+
*.so
|
|
10
|
+
|
|
11
|
+
# Distribution / packaging
|
|
12
|
+
.Python
|
|
13
|
+
build/
|
|
14
|
+
develop-eggs/
|
|
15
|
+
dist/
|
|
16
|
+
downloads/
|
|
17
|
+
eggs/
|
|
18
|
+
.eggs/
|
|
19
|
+
lib/
|
|
20
|
+
lib64/
|
|
21
|
+
parts/
|
|
22
|
+
sdist/
|
|
23
|
+
var/
|
|
24
|
+
wheels/
|
|
25
|
+
share/python-wheels/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
.installed.cfg
|
|
28
|
+
*.egg
|
|
29
|
+
MANIFEST
|
|
30
|
+
|
|
31
|
+
# PyInstaller
|
|
32
|
+
# Usually these files are written by a python script from a template
|
|
33
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
34
|
+
*.manifest
|
|
35
|
+
*.spec
|
|
36
|
+
|
|
37
|
+
# Installer logs
|
|
38
|
+
pip-log.txt
|
|
39
|
+
pip-delete-this-directory.txt
|
|
40
|
+
|
|
41
|
+
# Unit test / coverage reports
|
|
42
|
+
htmlcov/
|
|
43
|
+
.tox/
|
|
44
|
+
.nox/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
.cache
|
|
48
|
+
nosetests.xml
|
|
49
|
+
coverage.xml
|
|
50
|
+
*.cover
|
|
51
|
+
*.py.cover
|
|
52
|
+
.hypothesis/
|
|
53
|
+
.pytest_cache/
|
|
54
|
+
cover/
|
|
55
|
+
|
|
56
|
+
# Translations
|
|
57
|
+
*.mo
|
|
58
|
+
*.pot
|
|
59
|
+
|
|
60
|
+
# Django stuff:
|
|
61
|
+
*.log
|
|
62
|
+
local_settings.py
|
|
63
|
+
db.sqlite3
|
|
64
|
+
db.sqlite3-journal
|
|
65
|
+
|
|
66
|
+
# Flask stuff:
|
|
67
|
+
instance/
|
|
68
|
+
.webassets-cache
|
|
69
|
+
|
|
70
|
+
# Scrapy stuff:
|
|
71
|
+
.scrapy
|
|
72
|
+
|
|
73
|
+
# Sphinx documentation
|
|
74
|
+
docs/_build/
|
|
75
|
+
|
|
76
|
+
# PyBuilder
|
|
77
|
+
.pybuilder/
|
|
78
|
+
target/
|
|
79
|
+
|
|
80
|
+
# Jupyter Notebook
|
|
81
|
+
.ipynb_checkpoints
|
|
82
|
+
|
|
83
|
+
# IPython
|
|
84
|
+
profile_default/
|
|
85
|
+
ipython_config.py
|
|
86
|
+
|
|
87
|
+
# pyenv
|
|
88
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
89
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
90
|
+
# .python-version
|
|
91
|
+
|
|
92
|
+
# pipenv
|
|
93
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
94
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
95
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
96
|
+
# install all needed dependencies.
|
|
97
|
+
#Pipfile.lock
|
|
98
|
+
|
|
99
|
+
# UV
|
|
100
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
101
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
102
|
+
# commonly ignored for libraries.
|
|
103
|
+
#uv.lock
|
|
104
|
+
|
|
105
|
+
# poetry
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
107
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
108
|
+
# commonly ignored for libraries.
|
|
109
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
110
|
+
#poetry.lock
|
|
111
|
+
#poetry.toml
|
|
112
|
+
|
|
113
|
+
# pdm
|
|
114
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
115
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
116
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
117
|
+
#pdm.lock
|
|
118
|
+
#pdm.toml
|
|
119
|
+
.pdm-python
|
|
120
|
+
.pdm-build/
|
|
121
|
+
|
|
122
|
+
# pixi
|
|
123
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
124
|
+
#pixi.lock
|
|
125
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
126
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
127
|
+
.pixi
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# SageMath parsed files
|
|
137
|
+
*.sage.py
|
|
138
|
+
|
|
139
|
+
# Environments
|
|
140
|
+
.env
|
|
141
|
+
.envrc
|
|
142
|
+
.venv
|
|
143
|
+
env/
|
|
144
|
+
venv/
|
|
145
|
+
ENV/
|
|
146
|
+
env.bak/
|
|
147
|
+
venv.bak/
|
|
148
|
+
|
|
149
|
+
# Spyder project settings
|
|
150
|
+
.spyderproject
|
|
151
|
+
.spyproject
|
|
152
|
+
|
|
153
|
+
# Rope project settings
|
|
154
|
+
.ropeproject
|
|
155
|
+
|
|
156
|
+
# mkdocs documentation
|
|
157
|
+
/site
|
|
158
|
+
|
|
159
|
+
# mypy
|
|
160
|
+
.mypy_cache/
|
|
161
|
+
.dmypy.json
|
|
162
|
+
dmypy.json
|
|
163
|
+
|
|
164
|
+
# Pyre type checker
|
|
165
|
+
.pyre/
|
|
166
|
+
|
|
167
|
+
# pytype static type analyzer
|
|
168
|
+
.pytype/
|
|
169
|
+
|
|
170
|
+
# Cython debug symbols
|
|
171
|
+
cython_debug/
|
|
172
|
+
|
|
173
|
+
# PyCharm
|
|
174
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
175
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
176
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
177
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
178
|
+
#.idea/
|
|
179
|
+
|
|
180
|
+
# Abstra
|
|
181
|
+
# Abstra is an AI-powered process automation framework.
|
|
182
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
183
|
+
# Learn more at https://abstra.io/docs
|
|
184
|
+
.abstra/
|
|
185
|
+
|
|
186
|
+
# Visual Studio Code
|
|
187
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
188
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
189
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
190
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
191
|
+
# .vscode/
|
|
192
|
+
|
|
193
|
+
# Ruff stuff:
|
|
194
|
+
.ruff_cache/
|
|
195
|
+
|
|
196
|
+
# PyPI configuration file
|
|
197
|
+
.pypirc
|
|
198
|
+
|
|
199
|
+
# Cursor
|
|
200
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
201
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
202
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
203
|
+
.cursorignore
|
|
204
|
+
.cursorindexingignore
|
|
205
|
+
|
|
206
|
+
# Marimo
|
|
207
|
+
marimo/_static/
|
|
208
|
+
marimo/_lsp/
|
|
209
|
+
__marimo__/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-ros
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pytest plugin for integration testing of ROS 2 systems
|
|
5
|
+
Author-email: Sean Fish <seanfish@gatech.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Framework :: Pytest
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Software Development :: Testing
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: pytest>=7.0.0
|
|
12
|
+
Requires-Dist: pyyaml
|
|
13
|
+
Requires-Dist: rclpy
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: black; extra == 'dev'
|
|
16
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pytest-ros
|
|
21
|
+
|
|
22
|
+
A Pytest plugin to provide fixtures and utilities for testing of ROS 2 systems.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- *Automated Lifecycle Management*: Automatically initialize and clean up launched programs with some additional safeguards against rogue processes, though not guaranteed
|
|
27
|
+
|
|
28
|
+
- *Fixtures*: Included fixtures for launching ROS processes such as launch files and for monitoring ROS topics
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
To install `pytest-ros`:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This plugin has an external undeclared dependency on ROS 2 `rclpy`. Make sure ROS 2 is installed and sourced.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Running Tests
|
|
44
|
+
|
|
45
|
+
Execute tests normally with `pytest`.
|
|
46
|
+
|
|
47
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# pytest-ros
|
|
2
|
+
|
|
3
|
+
A Pytest plugin to provide fixtures and utilities for testing of ROS 2 systems.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- *Automated Lifecycle Management*: Automatically initialize and clean up launched programs with some additional safeguards against rogue processes, though not guaranteed
|
|
8
|
+
|
|
9
|
+
- *Fixtures*: Included fixtures for launching ROS processes such as launch files and for monitoring ROS topics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
To install `pytest-ros`:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pip install .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This plugin has an external undeclared dependency on ROS 2 `rclpy`. Make sure ROS 2 is installed and sourced.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Running Tests
|
|
25
|
+
|
|
26
|
+
Execute tests normally with `pytest`.
|
|
27
|
+
|
|
28
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-ros"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "A pytest plugin for integration testing of ROS 2 systems"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Sean Fish", email = "seanfish@gatech.edu" },
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Framework :: Pytest",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pytest>=7.0.0",
|
|
18
|
+
"rclpy",
|
|
19
|
+
"pyyaml",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"hatch",
|
|
30
|
+
"black",
|
|
31
|
+
"pytest-cov",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.entry-points."pytest11"]
|
|
35
|
+
# This registers the plugin with pytest
|
|
36
|
+
pytest_ros = "pytest_ros.plugin"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
source = "vcs"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.hooks.vcs]
|
|
42
|
+
version-file = "src/pytest_ros/_version.py"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/pytest_ros"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.metadata]
|
|
48
|
+
allow-direct-references = true
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
log_cli = true
|
|
53
|
+
log_cli_level = "INFO"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
from pytest_ros import RCLPY_AVAILABLE
|
|
5
|
+
if RCLPY_AVAILABLE:
|
|
6
|
+
import rclpy
|
|
7
|
+
from rclpy.node import Node
|
|
8
|
+
from rclpy.executors import SingleThreadedExecutor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EpisodeMonitor:
|
|
15
|
+
"""
|
|
16
|
+
A class to monitor test episodes, enabling waiting for start conditions and monitoring for
|
|
17
|
+
termination conditions, both success and failure.
|
|
18
|
+
|
|
19
|
+
The conditions are defined as ROS topics with either a requirement for a message to be received
|
|
20
|
+
or a specific value to be met.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
timeout: float | None = None
|
|
27
|
+
):
|
|
28
|
+
|
|
29
|
+
self.name = name
|
|
30
|
+
self._logger = logging.getLogger(f'EpisodeMonitor.{self.name}')
|
|
31
|
+
|
|
32
|
+
self._conditions = {} # dict of condition name to its 'value' (True/False/None) and 'interface' (e.g. subscriber or service client)
|
|
33
|
+
self._statuses = {}
|
|
34
|
+
|
|
35
|
+
# Monitor start time
|
|
36
|
+
self._start_time = time.time()
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
|
|
39
|
+
# Initialize the internal ROS 2 node
|
|
40
|
+
self.node = Node(name)
|
|
41
|
+
|
|
42
|
+
# Setup threading and execution infrastructure
|
|
43
|
+
self._stop_event = threading.Event()
|
|
44
|
+
self._is_running = threading.Event()
|
|
45
|
+
|
|
46
|
+
self._executor = SingleThreadedExecutor()
|
|
47
|
+
self._executor.add_node(self.node)
|
|
48
|
+
|
|
49
|
+
self._spin_thread = threading.Thread(target=self._run, daemon=True)
|
|
50
|
+
self._spin_thread.start()
|
|
51
|
+
|
|
52
|
+
if not self._is_running.wait(timeout=2.0):
|
|
53
|
+
raise RuntimeError("ROS 2 background executor failed to start.")
|
|
54
|
+
|
|
55
|
+
# Create default statuses
|
|
56
|
+
self.register_condition(condition_name='episode_timeout')
|
|
57
|
+
self.register_status(status_name='timeout', conditions=['episode_timeout'], trigger='ANY') # Evaluates to True if any timeouts occur
|
|
58
|
+
self.register_status(status_name='complete', conditions=['timeout'], trigger='ANY') # A special status that can be triggered by any condition or externally to indicate episode completion
|
|
59
|
+
|
|
60
|
+
def set_timeout(self, timeout: float):
|
|
61
|
+
"""Sets a new timeout for the episode. If the timeout is breached, the 'timeout' status will be triggered."""
|
|
62
|
+
self._start_time = time.time() # Reset start time whenever timeout is set
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._logger.info(f"Episode timeout set to {timeout} seconds from time {self._start_time}.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register_condition(self, condition_name):
|
|
68
|
+
"""
|
|
69
|
+
Registers a simple condition that can be manually set to True or False.
|
|
70
|
+
|
|
71
|
+
Parameters:
|
|
72
|
+
condition_name (str): The name of the condition.
|
|
73
|
+
"""
|
|
74
|
+
self._conditions[condition_name] = {'value': None, 'interface': 'manual'} # Initialize condition value as None
|
|
75
|
+
|
|
76
|
+
def get_condition(self, condition_name):
|
|
77
|
+
"""
|
|
78
|
+
Retrieves the current value of a condition.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
condition_name (str): The name of the condition.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
bool: The current value of the condition, or None if the condition is not registered.
|
|
85
|
+
"""
|
|
86
|
+
condition_info = self._conditions.get(condition_name)
|
|
87
|
+
if condition_info:
|
|
88
|
+
return condition_info['value']
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def register_topic_condition(self, condition_name, topic_name, msg_type, value_check=None):
|
|
92
|
+
"""
|
|
93
|
+
Registers a condition based on a ROS topic.
|
|
94
|
+
Conditions can be True, False, or None (not yet received).
|
|
95
|
+
|
|
96
|
+
Parameters:
|
|
97
|
+
condition_name (str): The name of the condition.
|
|
98
|
+
topic_name (str): The name of the ROS topic to subscribe to.
|
|
99
|
+
msg_type: The ROS message type to expect (e.g., std_msgs.msg.String).
|
|
100
|
+
value_check (callable, optional): A function that takes a message and returns True if it meets the desired condition. If None, any message will suffice.
|
|
101
|
+
"""
|
|
102
|
+
self._conditions[condition_name] = {'value': None, 'interface': None} # Initialize condition value as None
|
|
103
|
+
self._conditions[condition_name]['interface'] = self._setup_subscription(
|
|
104
|
+
condition_name=condition_name,
|
|
105
|
+
topic_name=topic_name,
|
|
106
|
+
msg_type=msg_type,
|
|
107
|
+
value_check=value_check
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def register_service_condition(self,
|
|
113
|
+
condition_name,
|
|
114
|
+
service_name,
|
|
115
|
+
srv_type,
|
|
116
|
+
srv_req,
|
|
117
|
+
value_check=None,
|
|
118
|
+
period: float = 1.0,
|
|
119
|
+
start_on_status: str = None,
|
|
120
|
+
timeout: float = None,
|
|
121
|
+
timeout_is_fatal: bool = False
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Registers a condition based on a ROS service.
|
|
125
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
condition_name (str): The name of the condition.
|
|
128
|
+
service_name (str): The name of the ROS service to call.
|
|
129
|
+
srv_type: The ROS service type to expect (e.g., std_srvs.srv.Trigger).
|
|
130
|
+
srv_req (srv_type.Request or callable): The request object or a function that returns the request object for the service call.
|
|
131
|
+
value_check (callable, optional): A function that takes a service response and returns True if it meets the desired condition. If None, any response will suffice.
|
|
132
|
+
period (float): How often to call the service in seconds.
|
|
133
|
+
start_on_status (str, optional): If provided, the service calls will only start once this status is triggered.
|
|
134
|
+
timeout (float, optional): Maximum time to keep trying the service call before giving up. If None, it will keep trying indefinitely.
|
|
135
|
+
timeout_is_fatal (bool, optional): If True, the timeout will be reported as an episode timeout.
|
|
136
|
+
"""
|
|
137
|
+
self._conditions[condition_name] = {'value': None, 'interface': None} # Initialize condition value as None
|
|
138
|
+
if timeout is not None:
|
|
139
|
+
self._conditions[f'{condition_name}_timeout'] = {'value': False, 'interface': None}
|
|
140
|
+
if timeout_is_fatal:
|
|
141
|
+
self.add_condition_to_status('timeout', f'{condition_name}_timeout')
|
|
142
|
+
self._conditions[condition_name]['interface'] = self._setup_client(
|
|
143
|
+
condition_name=condition_name,
|
|
144
|
+
service_name=service_name,
|
|
145
|
+
srv_type=srv_type,
|
|
146
|
+
srv_req=srv_req,
|
|
147
|
+
value_check=value_check,
|
|
148
|
+
period=period,
|
|
149
|
+
start_on_status=start_on_status,
|
|
150
|
+
timeout=timeout
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def register_status(self, status_name, conditions, trigger='ALL'):
|
|
154
|
+
"""
|
|
155
|
+
Registers a status to monitor.
|
|
156
|
+
|
|
157
|
+
Parameters:
|
|
158
|
+
status_name (str): The name of the status.
|
|
159
|
+
conditions (list of str): A list of condition names that contribute to this status.
|
|
160
|
+
trigger (str): 'ANY' or 'ALL' - determines how multiple conditions are evaluated.
|
|
161
|
+
"""
|
|
162
|
+
# Prevent overwriting existing statuses
|
|
163
|
+
if status_name in self._statuses:
|
|
164
|
+
self.node.get_logger().warning(f"Status '{status_name}' is already registered. Skipping registration.")
|
|
165
|
+
return
|
|
166
|
+
self._statuses[status_name] = {
|
|
167
|
+
'trigger': trigger,
|
|
168
|
+
'conditions': conditions
|
|
169
|
+
}
|
|
170
|
+
self._conditions.setdefault(status_name, {'value': None, 'interface': 'status'}) # Ensure the status itself is also a condition for potential nesting
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def add_condition_to_status(self, status_name, condition_name):
|
|
174
|
+
"""
|
|
175
|
+
Dynamically adds a condition to an existing status.
|
|
176
|
+
|
|
177
|
+
Parameters:
|
|
178
|
+
status_name (str): The name of the status to update.
|
|
179
|
+
condition_name (str): The name of the condition to add.
|
|
180
|
+
"""
|
|
181
|
+
if status_name in self._statuses:
|
|
182
|
+
self._statuses[status_name]['conditions'].append(condition_name)
|
|
183
|
+
else:
|
|
184
|
+
self.node.get_logger().warning(f"Status '{status_name}' not found. Cannot add condition '{condition_name}'.")
|
|
185
|
+
|
|
186
|
+
def get_status(self, status_name):
|
|
187
|
+
"""
|
|
188
|
+
Checks the status by dynamically resolving its underlying condition values.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
bool: True if the logical trigger condition is met, False otherwise.
|
|
192
|
+
"""
|
|
193
|
+
status = self._statuses.get(status_name)
|
|
194
|
+
if not status:
|
|
195
|
+
self.node.get_logger().warning(f"Status '{status_name}' is not registered.")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
if len(status['conditions']) == 0:
|
|
199
|
+
return False # A status with no conditions is considered always False
|
|
200
|
+
|
|
201
|
+
# If a condition is actually another status, we need to evaluate it recursively by calling get_status on it.
|
|
202
|
+
# TODO: This could be optimized with memoization if we expect deep nesting, but for now we assume simple cases.
|
|
203
|
+
for cond in status['conditions']:
|
|
204
|
+
if self._conditions[cond]['interface'] == 'status':
|
|
205
|
+
# If the condition is actually another status, we need to evaluate it recursively
|
|
206
|
+
self._conditions[cond]['value'] = self.get_status(cond)
|
|
207
|
+
|
|
208
|
+
# Map the list of condition strings to their current real-time boolean states
|
|
209
|
+
# Using .get('value', False) handles uninitialized or pending states safely.
|
|
210
|
+
resolved_values = [
|
|
211
|
+
self._conditions.get(cond, {}).get('value', False)
|
|
212
|
+
for cond in status['conditions']
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
# 2. Evaluate the values against the gate logic
|
|
216
|
+
if status['trigger'] == 'ANY':
|
|
217
|
+
return any(resolved_values)
|
|
218
|
+
elif status['trigger'] == 'ALL':
|
|
219
|
+
return all(resolved_values)
|
|
220
|
+
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
def wait_for_status(self, status_name: str, poll_interval: float = 0.1) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Blocks synchronously until the status becomes True, 'complete' triggers,
|
|
226
|
+
or the configured timeout duration is exceeded.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
bool: True if the status was successfully reached, False otherwise.
|
|
230
|
+
"""
|
|
231
|
+
start_time = time.time()
|
|
232
|
+
|
|
233
|
+
while not self.get_status(status_name):
|
|
234
|
+
# 1. Check if an external mechanism or internal logic set 'complete'
|
|
235
|
+
if self.get_status("complete"):
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
# 2. Check if the time elapsed exceeds our timeout limit
|
|
239
|
+
if (time.time() - start_time) > self._timeout:
|
|
240
|
+
self._statuses["timeout"] = True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
# 3. Yield thread execution to allow rclpy executor thread
|
|
244
|
+
# or topic callbacks to flush and update data structures
|
|
245
|
+
time.sleep(poll_interval)
|
|
246
|
+
|
|
247
|
+
# Double check we didn't exit the loop due to a break condition failure
|
|
248
|
+
return self.get_status(status_name)
|
|
249
|
+
|
|
250
|
+
def _update_condition(self, condition_name, value):
|
|
251
|
+
"""
|
|
252
|
+
Updates the value of a condition and checks if the associated status is triggered.
|
|
253
|
+
"""
|
|
254
|
+
self._conditions[condition_name]['value'] = value
|
|
255
|
+
self._logger.info(f"Condition '{condition_name}' updated: {value}.")
|
|
256
|
+
|
|
257
|
+
def _setup_subscription(self, condition_name, topic_name, msg_type, value_check=None):
|
|
258
|
+
"""
|
|
259
|
+
Sets up a subscription to a topic and returns a function that checks for the desired condition.
|
|
260
|
+
"""
|
|
261
|
+
sub = self.node.create_subscription(
|
|
262
|
+
msg_type,
|
|
263
|
+
topic_name,
|
|
264
|
+
lambda msg: self._update_condition(condition_name, value_check(msg) if value_check else True),
|
|
265
|
+
10
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return sub
|
|
269
|
+
|
|
270
|
+
def _setup_client(self,
|
|
271
|
+
condition_name,
|
|
272
|
+
service_name,
|
|
273
|
+
srv_type,
|
|
274
|
+
srv_req,
|
|
275
|
+
value_check=None,
|
|
276
|
+
period: float = 1.0,
|
|
277
|
+
start_on_status: str = None,
|
|
278
|
+
timeout: float = None
|
|
279
|
+
):
|
|
280
|
+
"""
|
|
281
|
+
Sets up a service client that periodically calls the service and updates the condition based on the response.
|
|
282
|
+
"""
|
|
283
|
+
client = self.node.create_client(
|
|
284
|
+
srv_type,
|
|
285
|
+
service_name
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def service_poll_worker():
|
|
289
|
+
"""Background thread that periodically calls the service and updates the condition status."""
|
|
290
|
+
|
|
291
|
+
worker_start_time = None
|
|
292
|
+
|
|
293
|
+
def check_timeout_breach():
|
|
294
|
+
if timeout is not None and (time.time() - worker_start_time) > timeout:
|
|
295
|
+
self.node.get_logger().error(f"[{condition_name}] Master timeout of {timeout}s exceeded.")
|
|
296
|
+
# Explicitly fail the condition so the test state knows it timed out
|
|
297
|
+
self._update_condition(condition_name, False)
|
|
298
|
+
self._update_condition(f'{condition_name}_timeout', True) # Set the timeout flag
|
|
299
|
+
return True
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
if start_on_status:
|
|
303
|
+
# Wait until the specified status is triggered before starting the service calls
|
|
304
|
+
while rclpy.ok() and not self._stop_event.is_set():
|
|
305
|
+
if self.get_status(start_on_status):
|
|
306
|
+
worker_start_time = time.time()
|
|
307
|
+
break
|
|
308
|
+
time.sleep(0.1)
|
|
309
|
+
else:
|
|
310
|
+
worker_start_time = time.time()
|
|
311
|
+
|
|
312
|
+
# Wait for the service to become available
|
|
313
|
+
while rclpy.ok() and not self._stop_event.is_set():
|
|
314
|
+
if check_timeout_breach():
|
|
315
|
+
return
|
|
316
|
+
if client.wait_for_service(timeout_sec=1.0):
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
# Periodically call the service and check the response
|
|
320
|
+
while rclpy.ok() and not self._stop_event.is_set():
|
|
321
|
+
if check_timeout_breach():
|
|
322
|
+
return
|
|
323
|
+
start_time = time.time()
|
|
324
|
+
# Before making the service call, check if the condition has already been met to avoid unnecessary calls
|
|
325
|
+
if self._conditions[condition_name]['value']:
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
req = srv_req() if callable(srv_req) else srv_req
|
|
329
|
+
|
|
330
|
+
future = client.call_async(req)
|
|
331
|
+
|
|
332
|
+
timeout_limit = 5.0
|
|
333
|
+
while rclpy.ok() and not self._stop_event.is_set() and not future.done():
|
|
334
|
+
if time.time() - start_time > timeout_limit:
|
|
335
|
+
break
|
|
336
|
+
time.sleep(0.01) # Cooperatively yield execution slice
|
|
337
|
+
|
|
338
|
+
if future.done():
|
|
339
|
+
try:
|
|
340
|
+
response = future.result()
|
|
341
|
+
if value_check is None or value_check(response):
|
|
342
|
+
self._update_condition(condition_name, True)
|
|
343
|
+
break
|
|
344
|
+
except Exception as e:
|
|
345
|
+
self.node.get_logger().error(f"Service call failed: {e}")
|
|
346
|
+
else:
|
|
347
|
+
self.node.get_logger().error("Service call timed out")
|
|
348
|
+
|
|
349
|
+
elapsed = time.time() - start_time
|
|
350
|
+
time_to_sleep = max(0, period - elapsed)
|
|
351
|
+
time.sleep(time_to_sleep)
|
|
352
|
+
|
|
353
|
+
poll_thread = threading.Thread(target=service_poll_worker, daemon=True)
|
|
354
|
+
poll_thread.start()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run(self):
|
|
358
|
+
"""Internal background loop running the executor."""
|
|
359
|
+
self._is_running.set()
|
|
360
|
+
|
|
361
|
+
while rclpy.ok() and not self._stop_event.is_set():
|
|
362
|
+
# Safely process pending callbacks on the internal node
|
|
363
|
+
self._executor.spin_once(timeout_sec=0.0)
|
|
364
|
+
|
|
365
|
+
# Check for episode timeout
|
|
366
|
+
if self._timeout is not None and (time.time() - self._start_time) > self._timeout:
|
|
367
|
+
self.node.get_logger().error(f"Episode timeout of {self._timeout}s exceeded.")
|
|
368
|
+
self._update_condition('episode_timeout', True)
|
|
369
|
+
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def shutdown(self):
|
|
374
|
+
"""
|
|
375
|
+
Shuts down the internal ROS node and cleans up resources.
|
|
376
|
+
"""
|
|
377
|
+
self._stop_event.set()
|
|
378
|
+
|
|
379
|
+
if hasattr(self, '_executor'):
|
|
380
|
+
self._executor.shutdown()
|
|
381
|
+
|
|
382
|
+
if hasattr(self, '_spin_thread') and self._spin_thread.is_alive():
|
|
383
|
+
self._spin_thread.join(timeout=1.0)
|
|
384
|
+
|
|
385
|
+
# Clean up the node itself
|
|
386
|
+
if hasattr(self, 'node'):
|
|
387
|
+
self.node.destroy_node()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pytest_ros import RCLPY_AVAILABLE
|
|
4
|
+
if RCLPY_AVAILABLE:
|
|
5
|
+
import rclpy
|
|
6
|
+
|
|
7
|
+
from .process import ROSProcessManager
|
|
8
|
+
from .monitor import EpisodeMonitor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
13
|
+
def ros_context():
|
|
14
|
+
"""Session-scoped fixture to manage ROS context initialization and shutdown."""
|
|
15
|
+
rclpy.init()
|
|
16
|
+
yield
|
|
17
|
+
if rclpy.ok():
|
|
18
|
+
rclpy.shutdown()
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def ros_process_manager():
|
|
22
|
+
"""Factory fixture to manage ROS launch processes."""
|
|
23
|
+
manager = ROSProcessManager()
|
|
24
|
+
yield manager
|
|
25
|
+
manager.terminate_all() # Ensure all ROS processes are terminated at the end of the session
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def ros_monitor():
|
|
29
|
+
"""Fixture to provide an EpisodeMonitor instance for monitoring test conditions."""
|
|
30
|
+
monitor = EpisodeMonitor(name="episode_monitor")
|
|
31
|
+
yield monitor
|
|
32
|
+
monitor.shutdown()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import signal
|
|
2
|
+
import subprocess
|
|
3
|
+
import uuid
|
|
4
|
+
import os
|
|
5
|
+
import psutil
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MultiProcessManager:
|
|
9
|
+
"""
|
|
10
|
+
A utility class to manage multiple processes in a pytest session,
|
|
11
|
+
ensuring that all processes are properly terminated at the end of the session.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, tag="PYTEST_MULTIPROC_ID"):
|
|
14
|
+
self.tag = tag
|
|
15
|
+
self.session_id = str(uuid.uuid4())
|
|
16
|
+
self.processes = []
|
|
17
|
+
|
|
18
|
+
def runner(self, cmd):
|
|
19
|
+
# Inject the tag into the environment
|
|
20
|
+
env = os.environ.copy()
|
|
21
|
+
env[self.tag] = self.session_id
|
|
22
|
+
|
|
23
|
+
# Start the process group
|
|
24
|
+
proc = subprocess.Popen(
|
|
25
|
+
cmd,
|
|
26
|
+
env=env,
|
|
27
|
+
preexec_fn=os.setsid,
|
|
28
|
+
shell=True
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.processes.append(proc)
|
|
32
|
+
|
|
33
|
+
return proc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def stop_process(self, proc):
|
|
37
|
+
"""Kill a process group."""
|
|
38
|
+
try:
|
|
39
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
40
|
+
except ProcessLookupError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
if proc in self.processes:
|
|
44
|
+
self.processes.remove(proc)
|
|
45
|
+
|
|
46
|
+
def terminate_all(self):
|
|
47
|
+
"""Kills everything tagged with this session ID."""
|
|
48
|
+
for proc in list(self.processes):
|
|
49
|
+
try:
|
|
50
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
51
|
+
except ProcessLookupError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
for proc in psutil.process_iter(['environ']):
|
|
55
|
+
try:
|
|
56
|
+
if (env := proc.info.get('environ')) and env.get(self.tag) == self.session_id:
|
|
57
|
+
proc.send_signal(signal.SIGKILL)
|
|
58
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ROSProcessManager(MultiProcessManager):
|
|
63
|
+
"""
|
|
64
|
+
A specialized context manager for managing ROS processes, ensuring that all ROS nodes
|
|
65
|
+
launched during the pytest session are properly terminated at the end of the session.
|
|
66
|
+
|
|
67
|
+
Manages ROS 2 launch files.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
with orch.launch(package_name='my_package', launch_file='my_launch.py', arguments=['arg1:=value1']):
|
|
71
|
+
# Run tests that depend on the launched ROS nodes
|
|
72
|
+
"""
|
|
73
|
+
def __init__(self, ):
|
|
74
|
+
super().__init__(tag="ROS_SESSION_ID")
|
|
75
|
+
|
|
76
|
+
def launch(self, package_name, launch_file, arguments=None):
|
|
77
|
+
"""
|
|
78
|
+
Launches a ROS 2 launch file.
|
|
79
|
+
|
|
80
|
+
Returns a context manager that can be used to manage the lifecycle of the launched processes.
|
|
81
|
+
|
|
82
|
+
Example: orch.launch(arguments=['arg1:=value1', 'arg2:=value2'])
|
|
83
|
+
"""
|
|
84
|
+
cmd = ["ros2", "launch", package_name, launch_file]
|
|
85
|
+
if arguments:
|
|
86
|
+
cmd.extend(arguments)
|
|
87
|
+
|
|
88
|
+
cmd = ' '.join(cmd) # Join the command list into a single string for better logging
|
|
89
|
+
|
|
90
|
+
print(f"Launching ROS with command: {cmd}") # Debug statement to verify the command being launched
|
|
91
|
+
|
|
92
|
+
# This uses the 'runner' context manager from the base class
|
|
93
|
+
return self.runner(cmd)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run(self, package_name, run_process, arguments=None):
|
|
97
|
+
"""
|
|
98
|
+
Returns a context manager that can be used to manage the lifecycle of the run process.
|
|
99
|
+
|
|
100
|
+
Example: orch.run(package_name='my_package', run_process='my_run.py', arguments=['arg1:=value1', 'arg2:=value2'])
|
|
101
|
+
"""
|
|
102
|
+
cmd = ["ros2", "run", package_name, run_process]
|
|
103
|
+
if arguments:
|
|
104
|
+
cmd.extend(arguments)
|
|
105
|
+
|
|
106
|
+
# This uses the 'runner' context manager from the base class
|
|
107
|
+
return self.runner(cmd)
|
|
108
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import logging
|
|
3
|
+
import numpy as np
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from pytest_ros import RCLPY_AVAILABLE
|
|
7
|
+
if RCLPY_AVAILABLE:
|
|
8
|
+
import rclpy
|
|
9
|
+
from rclpy.node import Node
|
|
10
|
+
from sensor_msgs.msg import Image
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class VideoRecorder:
|
|
15
|
+
"""
|
|
16
|
+
This utility enables direct recording of ROS image topics to MP4 files for assessment purposes.
|
|
17
|
+
It is not intended for logging but rather for quick visual verification of test runs.
|
|
18
|
+
|
|
19
|
+
Subscribes to a ROS image topic and writes frames to an MP4 file.
|
|
20
|
+
|
|
21
|
+
Usage::
|
|
22
|
+
recorder = VideoRecorder(node, CAMERA_TOPIC, "/tmp/sim_logs/run.mp4")
|
|
23
|
+
recorder.start()
|
|
24
|
+
# ... run test ...
|
|
25
|
+
recorder.stop() # flushes and closes the file
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
node,
|
|
31
|
+
topic: str,
|
|
32
|
+
output_path: str,
|
|
33
|
+
fps: int = 12
|
|
34
|
+
):
|
|
35
|
+
if type(node) is str:
|
|
36
|
+
rclpy.init()
|
|
37
|
+
node = rclpy.create_node(node)
|
|
38
|
+
self._node = node
|
|
39
|
+
self._output_path = output_path
|
|
40
|
+
self._fps = fps
|
|
41
|
+
self._writer: cv2.VideoWriter | None = None
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._sub = None
|
|
44
|
+
self._topic = topic
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
self._sub = self._node.create_subscription(
|
|
48
|
+
Image,
|
|
49
|
+
self._topic,
|
|
50
|
+
self._on_image,
|
|
51
|
+
10,
|
|
52
|
+
)
|
|
53
|
+
logger.info(f"[VIDEO] Recording {self._topic} → {self._output_path}")
|
|
54
|
+
|
|
55
|
+
def stop(self) -> None:
|
|
56
|
+
if self._sub is not None:
|
|
57
|
+
self._node.destroy_subscription(self._sub)
|
|
58
|
+
self._sub = None
|
|
59
|
+
with self._lock:
|
|
60
|
+
if self._writer is not None:
|
|
61
|
+
self._writer.release()
|
|
62
|
+
self._writer = None
|
|
63
|
+
logger.info(f"[VIDEO] Saved {self._output_path}")
|
|
64
|
+
|
|
65
|
+
def _on_image(self, msg: Image) -> None:
|
|
66
|
+
"""Convert a sensor_msgs/Image to BGR and append to the video file."""
|
|
67
|
+
try:
|
|
68
|
+
frame = self._decode(msg)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.warning(f"[VIDEO] Frame decode error: {exc}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
with self._lock:
|
|
74
|
+
if self._writer is None:
|
|
75
|
+
h, w = frame.shape[:2]
|
|
76
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
77
|
+
self._writer = cv2.VideoWriter(
|
|
78
|
+
self._output_path, fourcc, self._fps, (w, h)
|
|
79
|
+
)
|
|
80
|
+
self._writer.write(frame)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _decode(msg: Image) -> np.ndarray:
|
|
84
|
+
"""Return a BGR uint8 ndarray from a sensor_msgs/Image."""
|
|
85
|
+
enc = msg.encoding.lower()
|
|
86
|
+
data = np.frombuffer(msg.data, dtype=np.uint8)
|
|
87
|
+
|
|
88
|
+
if enc in ("rgb8", "bgr8", "mono8"):
|
|
89
|
+
channels = 1 if enc == "mono8" else 3
|
|
90
|
+
frame = data.reshape((msg.height, msg.width, channels))
|
|
91
|
+
if enc == "rgb8":
|
|
92
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
|
93
|
+
elif enc == "mono8":
|
|
94
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
|
95
|
+
elif enc == "rgba8":
|
|
96
|
+
frame = data.reshape((msg.height, msg.width, 4))
|
|
97
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_RGBA2BGR)
|
|
98
|
+
elif enc in ("16uc1", "mono16"):
|
|
99
|
+
data16 = np.frombuffer(msg.data, dtype=np.uint16).reshape(
|
|
100
|
+
(msg.height, msg.width)
|
|
101
|
+
)
|
|
102
|
+
frame = cv2.cvtColor(
|
|
103
|
+
(data16 >> 8).astype(np.uint8), cv2.COLOR_GRAY2BGR
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"Unsupported encoding: {msg.encoding}")
|
|
107
|
+
|
|
108
|
+
return frame
|