rosotacom-dev 2.2__py3-none-any.whl

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.
Files changed (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,653 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # -- BEGIN LICENSE BLOCK ----------------------------------------------
4
+ # Redistribution and use in source and binary forms, with or without
5
+ # modification, are permitted provided that the following conditions are met:
6
+ #
7
+ # * Redistributions of source code must retain the above copyright
8
+ # notice, this list of conditions and the following disclaimer.
9
+ #
10
+ # * Redistributions in binary form must reproduce the above copyright
11
+ # notice, this list of conditions and the following disclaimer in the
12
+ # documentation and/or other materials provided with the distribution.
13
+ #
14
+ # * Neither the name of the {copyright_holder} nor the names of its
15
+ # contributors may be used to endorse or promote products derived from
16
+ # this software without specific prior written permission.
17
+ #
18
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
22
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28
+ # POSSIBILITY OF SUCH DAMAGE.
29
+ # -- END LICENSE BLOCK ------------------------------------------------
30
+ #
31
+ # ---------------------------------------------------------------------
32
+ # !\file
33
+ #
34
+ # \author Martin Gontscharow <gontscharow@fzi.de>
35
+ # \date 2024-11-13
36
+ #
37
+ #
38
+ # ---------------------------------------------------------------------
39
+
40
+ from array import array
41
+ import bz2
42
+ import re
43
+ import time
44
+ from threading import Lock
45
+ import zlib
46
+
47
+ import lz4.frame as lz4
48
+ import rclpy
49
+ import yaml
50
+ from rclpy.node import Node
51
+ from rclpy.qos import ReliabilityPolicy
52
+ from rclpy.serialization import serialize_message
53
+ from rclpy.executors import ExternalShutdownException
54
+ from rosidl_runtime_py.utilities import get_message
55
+
56
+ from com_py.qos import get_topic_qos, load_qos_config
57
+ # import zstandard as zstd
58
+ # zstandard is not available on the Ubuntu 26.04 / ROS 2 Lyrical setup.
59
+ # Reject zstd explicitly when a user configures it.
60
+
61
+ # Adjust the import below to your custom package and message name:
62
+ # e.g. from com_cpp.msg import CompressedData
63
+ from com_msgs.msg import CompressedData
64
+
65
+
66
+ def format_size(size: int) -> str:
67
+ """Format a byte size into a human-readable string."""
68
+ if size < 1024:
69
+ return f"{size} B"
70
+ elif size < 1048576:
71
+ return f"{size / 1024:.1f} KB"
72
+ elif size < 1073741824:
73
+ return f"{size / 1048576:.1f} MB"
74
+ else:
75
+ return f"{size / 1073741824:.1f} GB"
76
+
77
+ def compress_data(data: bytes, algorithm: str) -> bytes:
78
+ """
79
+ Compress raw bytes according to the chosen algorithm.
80
+ Recognized: 'bz2', 'zlib', 'lz4'.
81
+ """
82
+ if algorithm == 'bz2':
83
+ return bz2.compress(data)
84
+ elif algorithm == 'zlib':
85
+ return zlib.compress(data)
86
+ elif algorithm == 'lz4':
87
+ return lz4.compress(data)
88
+ elif algorithm == 'zstd':
89
+ raise ValueError(
90
+ "zstd compression is not supported on this Ubuntu 26.04 / ROS 2 Lyrical setup. "
91
+ "Choose bz2, zlib, or lz4 instead."
92
+ )
93
+ else:
94
+ raise ValueError(f"Unsupported compression algorithm: {algorithm}")
95
+
96
+
97
+ class UniversalCompressorNode(Node):
98
+ """
99
+ A ROS 2 node that dynamically searches for topics matching user-defined regex
100
+ and publishes a compressed version of their messages to a new topic.
101
+
102
+ Configuration is read from a YAML file specified via the 'config_file' parameter.
103
+ The YAML has a 'compression:' list of dictionaries, each specifying:
104
+ - topic_regex
105
+ - algorithm (optional, default="bz2")
106
+ - add_suffix (optional, default="/{algorithm}")
107
+ """
108
+
109
+ def __init__(self):
110
+ super().__init__('universal_compressor')
111
+
112
+ # Use the node's default callback group for timers and subscriptions.
113
+ # On this Lyrical/Fast DDS setup, dynamically created entities in
114
+ # custom callback groups were visible in the graph but did not keep
115
+ # dispatching reliably.
116
+
117
+ # 1) Declare + get parameter for the config file
118
+ self.declare_parameter('config_file', 'compression_config.yaml')
119
+ config_file = self.get_parameter('config_file').value
120
+
121
+ # Default compression algorithm (used when a YAML rule does not specify one)
122
+ self.declare_parameter('default_algorithm', 'bz2')
123
+ self.default_algorithm = self.get_parameter('default_algorithm').value
124
+
125
+ self.get_logger().info(
126
+ f"[universal_compressor] Starting with config_file='{config_file}'"
127
+ )
128
+ self.get_logger().info(
129
+ f"[universal_compressor] default_algorithm='{self.default_algorithm}'"
130
+ )
131
+
132
+ self.declare_parameter('stats_log_period_s', 10.0)
133
+ self.stats_log_period_s = float(
134
+ self.get_parameter('stats_log_period_s').value
135
+ )
136
+ self.declare_parameter('slow_callback_warn_ms', 1000.0)
137
+ self.slow_callback_warn_ms = float(
138
+ self.get_parameter('slow_callback_warn_ms').value
139
+ )
140
+ self.declare_parameter('no_input_warn_period_s', 15.0)
141
+ self.no_input_warn_period_s = float(
142
+ self.get_parameter('no_input_warn_period_s').value
143
+ )
144
+
145
+ # 3) Simple set to track topics we've already subscribed to
146
+ self.subscribed_topics = set()
147
+ # Keep our own references without touching rclpy.Node private fields.
148
+ self._owned_subscriptions = []
149
+ self._owned_publishers = []
150
+ self._owned_output_topics = set()
151
+ self._topic_stats = {}
152
+ self._stats_lock = Lock()
153
+ # 4) QoS config + roles (configured via qos.py YAML)
154
+ self.qos_config_file = self.declare_parameter('qos_config_file', '').value
155
+ self.sub_role = 'compressor_sub'
156
+ self.pub_role = 'compressor_pub'
157
+ self.qos_config = load_qos_config(self.get_logger(), self.qos_config_file)
158
+
159
+ # 5) Load YAML config
160
+ self.config = self.load_config(config_file)
161
+ compression_rules = self.config.get('compression', [])
162
+ if isinstance(compression_rules, list):
163
+ self.get_logger().info(
164
+ f"[universal_compressor] Loaded {len(compression_rules)} "
165
+ f"compression rule(s). "
166
+ f"stats_log_period_s={self.stats_log_period_s:.1f}, "
167
+ f"slow_callback_warn_ms={self.slow_callback_warn_ms:.1f}"
168
+ )
169
+ self.get_logger().info(
170
+ f"[universal_compressor] Node initialized with config file: {config_file}"
171
+ )
172
+
173
+ # 6) Set up a timer to periodically re-check the graph for new topics
174
+ self.timer = self.create_timer(
175
+ 5.0,
176
+ self.check_and_subscribe,
177
+ )
178
+
179
+ # Discovery is timer-driven once the executor is spinning. Calling
180
+ # get_topic_names_and_types() before spin can leave this node with
181
+ # graph entities that are visible but never dispatch callbacks on the
182
+ # Lyrical/Fast DDS setup used here.
183
+
184
+ def load_config(self, filename: str) -> dict:
185
+ """Load compression configuration from a YAML file into a Python dict."""
186
+ try:
187
+ with open(filename, 'r') as file:
188
+ data = yaml.safe_load(file)
189
+ self.get_logger().debug(f"Configuration loaded: {data}")
190
+ return data or {}
191
+ except Exception as e:
192
+ self.get_logger().error(f"Failed to load config from '{filename}': {e}")
193
+ return {}
194
+
195
+ def check_and_subscribe(self):
196
+ """
197
+ Checks the ROS graph for topics, matches them against config['compression'] entries,
198
+ and sets up new subscribers (with corresponding publishers) if not already subscribed.
199
+ """
200
+ try:
201
+ self._check_and_subscribe_impl()
202
+ except Exception as e:
203
+ self.get_logger().error(
204
+ f"[universal_compressor] Discovery error: {type(e).__name__}: {e}"
205
+ )
206
+
207
+ def _check_and_subscribe_impl(self):
208
+ self._warn_about_inactive_subscriptions()
209
+
210
+ if 'compression' not in self.config:
211
+ self.get_logger().warning(
212
+ "[universal_compressor] No 'compression' section in config."
213
+ )
214
+ return
215
+
216
+ # 1) Gather all active topics and their types
217
+ # e.g. [("/foo", ["std_msgs/msg/String"]), ...]
218
+ graph_start = time.monotonic()
219
+ all_topics = self.get_topic_names_and_types()
220
+ graph_ms = (time.monotonic() - graph_start) * 1000.0
221
+ if graph_ms > self.slow_callback_warn_ms:
222
+ self.get_logger().warning(
223
+ f"[universal_compressor] ROS graph lookup took "
224
+ f"{graph_ms:.1f} ms "
225
+ f"for {len(all_topics)} topic(s)."
226
+ )
227
+
228
+ # 2) Build a dictionary: topic_name -> first_type
229
+ topic_map = {}
230
+ for (tname, ttypes) in all_topics:
231
+ if len(ttypes) > 0:
232
+ topic_map[tname] = ttypes[0]
233
+
234
+ # 3) For each rule in config['compression'], check regex
235
+ for item in self.config['compression']:
236
+ topic_pattern = item.get('topic_regex', '')
237
+ # Default to node parameter if no algorithm provided:
238
+ algorithm = item.get('algorithm', self.default_algorithm)
239
+ add_suffix = item.get('add_suffix', f'/{algorithm}')
240
+
241
+ # Pre-compile the regex for efficiency if you like:
242
+ try:
243
+ rx = re.compile(topic_pattern)
244
+ except re.error as e:
245
+ self.get_logger().error(
246
+ f"[universal_compressor] Invalid "
247
+ f"topic_regex='{topic_pattern}': {e}"
248
+ )
249
+ continue
250
+
251
+ match_count = 0
252
+
253
+ for tname, type_name in topic_map.items():
254
+ if rx.search(tname):
255
+ match_count += 1
256
+ # Avoid recursively compressing our own compressed output topics.
257
+ if tname in self._owned_output_topics:
258
+ continue
259
+ if type_name == 'com_msgs/msg/CompressedData':
260
+ continue
261
+ if add_suffix and tname.endswith(add_suffix):
262
+ continue
263
+
264
+ # e.g. matched => /costmap/costmap.
265
+ out_topic = tname + add_suffix
266
+ subscription_key = (tname, out_topic)
267
+
268
+ if subscription_key not in self.subscribed_topics:
269
+ msg_class = self.get_message_class(type_name)
270
+ if msg_class is None:
271
+ # If we can't load or parse the type, skip
272
+ continue
273
+
274
+ # QoS from roles.
275
+ sub_qos = get_topic_qos(
276
+ self.get_logger(),
277
+ self.qos_config,
278
+ tname,
279
+ self.sub_role,
280
+ )
281
+ sub_qos = self._adapt_sub_qos_to_publishers(tname, sub_qos)
282
+ pub_qos = get_topic_qos(
283
+ self.get_logger(),
284
+ self.qos_config,
285
+ tname,
286
+ self.pub_role,
287
+ )
288
+
289
+ # Create publisher with CompressedData
290
+ pub = self.create_publisher(
291
+ CompressedData,
292
+ out_topic,
293
+ pub_qos,
294
+ )
295
+ self._owned_publishers.append(pub)
296
+ self._owned_output_topics.add(out_topic)
297
+
298
+ # Create subscription to the original message
299
+ # We'll pass a small lambda capturing the arguments
300
+ # so we know which publisher and algorithm to use.
301
+ # Pass 'tname' so logs identify the source topic.
302
+ sub = self.create_subscription(
303
+ msg_class,
304
+ tname,
305
+ self.make_compression_callback(
306
+ pub,
307
+ algorithm,
308
+ tname,
309
+ type_name,
310
+ ),
311
+ sub_qos,
312
+ )
313
+ self._owned_subscriptions.append(sub)
314
+
315
+ self.subscribed_topics.add(subscription_key)
316
+ self._init_topic_stats(
317
+ tname, out_topic, algorithm, type_name
318
+ )
319
+ self.get_logger().info(
320
+ f"[universal_compressor] Subscribed to '{tname}' "
321
+ f"(type={type_name}) => publishing compressed "
322
+ f"to '{out_topic}' "
323
+ f"with algorithm='{algorithm}'"
324
+ )
325
+
326
+ if match_count == 0:
327
+ self.get_logger().debug(
328
+ f"[universal_compressor] Rule topic_regex='{topic_pattern}' "
329
+ f"matched no topics "
330
+ f"among {len(topic_map)} discovered topic(s)."
331
+ )
332
+
333
+ self.get_logger().info(
334
+ f"[universal_compressor] Discovery pass complete; "
335
+ f"subscriptions={len(self.subscribed_topics)}"
336
+ )
337
+
338
+ def _qos_reliability_name(self, qos) -> str:
339
+ try:
340
+ return qos.reliability.name
341
+ except Exception:
342
+ return str(getattr(qos, 'reliability', 'unknown'))
343
+
344
+ def _adapt_sub_qos_to_publishers(self, topic: str, sub_qos):
345
+ """
346
+ Make the compressor subscription compatible with discovered publishers.
347
+
348
+ A RELIABLE subscription cannot receive from a BEST_EFFORT publisher. The
349
+ graph still shows both endpoints, which looks like a silent hang. For a
350
+ transparent compressor, prefer receiving data over enforcing reliability.
351
+ """
352
+ try:
353
+ publishers = self.get_publishers_info_by_topic(topic)
354
+ except Exception as e:
355
+ self.get_logger().warning(
356
+ f"[universal_compressor] Could not inspect publishers for "
357
+ f"'{topic}' QoS: {e}. Using configured subscriber QoS "
358
+ f"reliability={self._qos_reliability_name(sub_qos)}."
359
+ )
360
+ return sub_qos
361
+
362
+ if not publishers:
363
+ self.get_logger().info(
364
+ f"[universal_compressor] No publisher QoS info available yet "
365
+ f"for '{topic}'. Using configured subscriber QoS "
366
+ f"reliability={self._qos_reliability_name(sub_qos)}."
367
+ )
368
+ return sub_qos
369
+
370
+ offered = []
371
+ saw_best_effort = False
372
+ for info in publishers:
373
+ qos = getattr(info, 'qos_profile', None)
374
+ if qos is None:
375
+ continue
376
+ reliability = self._qos_reliability_name(qos)
377
+ offered.append(reliability)
378
+ if getattr(qos, 'reliability', None) == ReliabilityPolicy.BEST_EFFORT:
379
+ saw_best_effort = True
380
+
381
+ before = self._qos_reliability_name(sub_qos)
382
+ if saw_best_effort and sub_qos.reliability != ReliabilityPolicy.BEST_EFFORT:
383
+ sub_qos.reliability = ReliabilityPolicy.BEST_EFFORT
384
+ self.get_logger().warning(
385
+ f"[universal_compressor] '{topic}' has BEST_EFFORT publisher(s); "
386
+ f"using BEST_EFFORT compressor_sub QoS instead of {before}. "
387
+ f"Offered publisher reliabilities={offered}."
388
+ )
389
+ else:
390
+ self.get_logger().info(
391
+ f"[universal_compressor] '{topic}' compressor_sub QoS "
392
+ f"reliability={self._qos_reliability_name(sub_qos)}; "
393
+ f"offered publisher reliabilities={offered}."
394
+ )
395
+
396
+ return sub_qos
397
+
398
+ def get_message_class(self, ros1_style_path: str):
399
+ """
400
+ Convert 'package_name/msg/MessageName' into a Python class object.
401
+ E.g. 'std_msgs/msg/String' -> <class 'std_msgs.msg._string.String'>
402
+ """
403
+ try:
404
+ return get_message(ros1_style_path)
405
+ except Exception as e:
406
+ self.get_logger().error(
407
+ f"Could not load message class '{ros1_style_path}': {e}"
408
+ )
409
+ return None
410
+
411
+ def _init_topic_stats(
412
+ self,
413
+ topic: str,
414
+ out_topic: str,
415
+ algorithm: str,
416
+ msg_type: str,
417
+ ):
418
+ now = time.monotonic()
419
+ with self._stats_lock:
420
+ self._topic_stats.setdefault(topic, {
421
+ 'out_topic': out_topic,
422
+ 'algorithm': algorithm,
423
+ 'msg_type': msg_type,
424
+ 'received': 0,
425
+ 'published': 0,
426
+ 'errors': 0,
427
+ 'created_time': now,
428
+ 'last_no_input_warn_time': now,
429
+ 'last_log_time': now,
430
+ 'last_rx_time': None,
431
+ 'last_publish_time': None,
432
+ 'total_original_bytes': 0,
433
+ 'total_compressed_bytes': 0,
434
+ 'total_compress_ms': 0.0,
435
+ })
436
+
437
+ def _record_receive(self, topic: str) -> int:
438
+ now = time.monotonic()
439
+ with self._stats_lock:
440
+ stats = self._topic_stats.setdefault(topic, {
441
+ 'out_topic': '',
442
+ 'algorithm': '',
443
+ 'msg_type': '',
444
+ 'received': 0,
445
+ 'published': 0,
446
+ 'errors': 0,
447
+ 'created_time': now,
448
+ 'last_no_input_warn_time': now,
449
+ 'last_log_time': now,
450
+ 'last_rx_time': None,
451
+ 'last_publish_time': None,
452
+ 'total_original_bytes': 0,
453
+ 'total_compressed_bytes': 0,
454
+ 'total_compress_ms': 0.0,
455
+ })
456
+ stats['received'] += 1
457
+ stats['last_rx_time'] = now
458
+ return stats['received']
459
+
460
+ def _warn_about_inactive_subscriptions(self):
461
+ now = time.monotonic()
462
+ warnings = []
463
+ with self._stats_lock:
464
+ for topic, stats in self._topic_stats.items():
465
+ if stats['received'] > 0:
466
+ continue
467
+ age_s = now - stats['created_time']
468
+ since_warn_s = now - stats['last_no_input_warn_time']
469
+ if age_s < self.no_input_warn_period_s:
470
+ continue
471
+ if since_warn_s < self.no_input_warn_period_s:
472
+ continue
473
+ stats['last_no_input_warn_time'] = now
474
+ warnings.append((
475
+ topic,
476
+ stats['out_topic'],
477
+ stats['algorithm'],
478
+ stats['msg_type'],
479
+ age_s,
480
+ ))
481
+
482
+ for topic, out_topic, algorithm, msg_type, age_s in warnings:
483
+ self.get_logger().warning(
484
+ f"[{topic}] Subscribed {age_s:.1f}s ago but no input "
485
+ f"callbacks have fired yet. If the topic is publishing, "
486
+ f"check subscriber QoS role='{self.sub_role}', type='{msg_type}', "
487
+ f"algorithm='{algorithm}', out='{out_topic}'."
488
+ )
489
+
490
+ def _record_publish(
491
+ self,
492
+ topic: str,
493
+ original_size: int,
494
+ compressed_size: int,
495
+ compress_ms: float,
496
+ ):
497
+ now = time.monotonic()
498
+ with self._stats_lock:
499
+ stats = self._topic_stats[topic]
500
+ stats['published'] += 1
501
+ stats['last_publish_time'] = now
502
+ stats['total_original_bytes'] += original_size
503
+ stats['total_compressed_bytes'] += compressed_size
504
+ stats['total_compress_ms'] += compress_ms
505
+
506
+ due = (now - stats['last_log_time']) >= self.stats_log_period_s
507
+ if not due and stats['published'] != 1:
508
+ return None
509
+
510
+ window_s = max(now - stats['last_log_time'], 1e-9)
511
+ total_compressed = max(stats['total_compressed_bytes'], 1)
512
+ ratio = (
513
+ float(stats['total_original_bytes']) / float(total_compressed)
514
+ )
515
+ avg_ms = stats['total_compress_ms'] / max(stats['published'], 1)
516
+ hz = stats['published'] / window_s if due else 0.0
517
+ stats['last_log_time'] = now
518
+
519
+ if due:
520
+ return (
521
+ f"[{topic}] compressed stats: rx={stats['received']}, "
522
+ f"pub={stats['published']}, errors={stats['errors']}, "
523
+ f"avg_time={avg_ms:.1f} ms, ratio={ratio:.2f}, "
524
+ f"recent_pub_hz={hz:.2f}, out='{stats['out_topic']}'"
525
+ )
526
+ return (
527
+ f"[{topic}] first message compressed: "
528
+ f"original={format_size(original_size)}, "
529
+ f"compressed={format_size(compressed_size)}, ratio={ratio:.2f}, "
530
+ f"time={compress_ms:.1f} ms "
531
+ f"(algo={stats['algorithm']}, type='{stats['msg_type']}')"
532
+ )
533
+
534
+ def _record_error(self, topic: str):
535
+ with self._stats_lock:
536
+ stats = self._topic_stats.get(topic)
537
+ if stats is not None:
538
+ stats['errors'] += 1
539
+
540
+ def make_compression_callback(
541
+ self,
542
+ publisher,
543
+ algorithm: str,
544
+ original_topic: str,
545
+ msg_type_str: str,
546
+ ):
547
+ def callback(msg):
548
+ self.compression_callback(
549
+ msg,
550
+ publisher,
551
+ algorithm,
552
+ original_topic,
553
+ msg_type_str,
554
+ )
555
+
556
+ return callback
557
+
558
+ def compression_callback(
559
+ self,
560
+ msg,
561
+ publisher,
562
+ algorithm,
563
+ original_topic,
564
+ msg_type_str: str,
565
+ ):
566
+ """
567
+ Callback that:
568
+ 1) Serializes the incoming message
569
+ 2) Compresses the bytes
570
+ 3) Publishes them as 'com_msgs/msg/CompressedData'
571
+ """
572
+ callback_start = time.monotonic()
573
+ try:
574
+ receive_count = self._record_receive(original_topic)
575
+ if receive_count == 1:
576
+ self.get_logger().info(
577
+ f"[{original_topic}] First input message received; "
578
+ f"serializing and compressing "
579
+ f"type='{msg_type_str}' with algorithm='{algorithm}'."
580
+ )
581
+
582
+ # 1) Serialize the message into raw bytes
583
+ serialize_start = time.monotonic()
584
+ serialized = serialize_message(msg)
585
+ serialize_ms = (time.monotonic() - serialize_start) * 1000.0
586
+ original_size = len(serialized)
587
+ if serialize_ms > self.slow_callback_warn_ms:
588
+ self.get_logger().warning(
589
+ f"[{original_topic}] Serialization took "
590
+ f"{serialize_ms:.1f} ms "
591
+ f"for {format_size(original_size)}."
592
+ )
593
+
594
+ # 2) Compress
595
+ start_time = time.monotonic()
596
+ compressed_bytes = compress_data(serialized, algorithm)
597
+ elapsed_ms = (time.monotonic() - start_time) * 1000.0
598
+
599
+ compressed_size = len(compressed_bytes)
600
+ if elapsed_ms > self.slow_callback_warn_ms:
601
+ self.get_logger().warning(
602
+ f"[{original_topic}] Compression took "
603
+ f"{elapsed_ms:.1f} ms "
604
+ f"for {format_size(original_size)} -> "
605
+ f"{format_size(compressed_size)} "
606
+ f"(algo={algorithm})."
607
+ )
608
+
609
+ # 3) Publish as custom CompressedData.
610
+ out_msg = CompressedData()
611
+ out_msg.header.stamp = self.get_clock().now().to_msg()
612
+ out_msg.msg_type = msg_type_str
613
+ # Avoid list(compressed_bytes): it expands each byte into a Python
614
+ # int and can make large messages look like a hung compressor.
615
+ out_msg.data = array('B', compressed_bytes)
616
+
617
+ publisher.publish(out_msg)
618
+
619
+ callback_ms = (time.monotonic() - callback_start) * 1000.0
620
+ if callback_ms > self.slow_callback_warn_ms:
621
+ self.get_logger().warning(
622
+ f"[{original_topic}] Full compression callback took "
623
+ f"{callback_ms:.1f} ms "
624
+ f"(serialize={serialize_ms:.1f} ms, "
625
+ f"compress={elapsed_ms:.1f} ms, "
626
+ f"publish/assign="
627
+ f"{(callback_ms - serialize_ms - elapsed_ms):.1f} ms)."
628
+ )
629
+
630
+ stats_log = self._record_publish(
631
+ original_topic, original_size, compressed_size, elapsed_ms
632
+ )
633
+ if stats_log:
634
+ self.get_logger().info(stats_log)
635
+
636
+ except Exception as e:
637
+ self._record_error(original_topic)
638
+ self.get_logger().error(f"[{original_topic}] Compression error: {e}")
639
+
640
+
641
+ def main(args=None):
642
+ rclpy.init(args=args)
643
+ node = UniversalCompressorNode()
644
+
645
+ try:
646
+ rclpy.spin(node)
647
+ except ExternalShutdownException:
648
+ pass
649
+ except KeyboardInterrupt:
650
+ pass
651
+ finally:
652
+ node.destroy_node()
653
+ rclpy.shutdown()