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,3217 @@
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 2026-01-09
36
+ #
37
+ #
38
+ # ---------------------------------------------------------------------
39
+
40
+ import argparse
41
+ import difflib
42
+ import os
43
+ import re
44
+ import sys
45
+ from dataclasses import dataclass, field
46
+ from typing import Any, Dict, List, Optional, Tuple
47
+
48
+ import yaml
49
+
50
+
51
+ BASE_PLUGIN_PATH_DEFAULT = "/ws/session/content/base/session_plugin_base.yaml"
52
+
53
+ VAR_PATTERN = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
54
+ FULL_VAR_PATTERN = re.compile(r"^\$\{([A-Za-z0-9_]+)\}$")
55
+
56
+ # Compression algorithms supported by com_py universal_{de}compressor nodes
57
+ ALLOWED_COMPRESSION_ALGORITHMS = {"bz2", "zlib", "lz4", "zstd"}
58
+ RMW_ALIASES = {
59
+ "cyclone": "rmw_cyclonedds_cpp",
60
+ "fastdds": "rmw_fastrtps_cpp",
61
+ "zenoh": "rmw_zenoh_cpp",
62
+ }
63
+ HEARTBEAT_MSG_TYPE = "com_msgs/msg/EchoHeartbeat"
64
+ COMPRESSED_MSG_TYPE = "com_msgs/msg/CompressedData"
65
+ OTA_STAMPED_MSG_TYPE = "com_msgs/msg/OtaStamped"
66
+ SENSOR_IMAGE_MSG_TYPE = "sensor_msgs/msg/Image"
67
+ TRANSPORT_OUTPUT_TYPES = {
68
+ "compressed": "sensor_msgs/msg/CompressedImage",
69
+ "ffmpeg": "ffmpeg_image_transport_msgs/msg/FFMPEGPacket",
70
+ "foxglove": "foxglove_msgs/msg/CompressedVideo",
71
+ }
72
+
73
+
74
+ @dataclass
75
+ class TransportSpec:
76
+ type: str
77
+ params: Dict[str, Any]
78
+ local_republish: bool = False
79
+
80
+
81
+ @dataclass
82
+ class TopicEntry:
83
+ base: str
84
+ msg_type: Optional[str]
85
+ processing: Dict[str, Any]
86
+ qos: Optional[Dict[str, Any]]
87
+ zen_qos: Optional[Dict[str, Any]]
88
+ index: int
89
+ # Declared behavioral contract (hz/latency_ms/loss_pct), surfaced into the
90
+ # status pipeline spec so the live status overview classifies against it.
91
+ expect: Optional[Dict[str, Any]] = None
92
+
93
+
94
+ @dataclass
95
+ class PluginBlock:
96
+ """
97
+ A semantic block of plugin.yaml parameters.
98
+
99
+ Rendering rule:
100
+ - Within a block: parameters are contiguous (no extra blank lines).
101
+ - Between consecutive non-empty blocks: exactly one blank line.
102
+ This makes formatting stable even when blocks are optional.
103
+ """
104
+
105
+ name: str
106
+ items: List[Tuple[str, Any]]
107
+
108
+
109
+ @dataclass
110
+ class YamlBlockScalar:
111
+ """
112
+ Represent a YAML block scalar value (e.g. "|1") that must be rendered verbatim.
113
+ """
114
+
115
+ header: str
116
+ content: str
117
+
118
+
119
+ # -----------------------------------------------------------------------------
120
+ # RMW schema
121
+ #
122
+ # Configs use a single `shared.rmw` block. Accepted forms:
123
+ #
124
+ # shared.rmw: <short-alias>
125
+ # Short-alias for {local: <x>, ota: <x>}. Must be cyclone | fastdds | zenoh
126
+ # (zenoh_ros2dds cannot be used as a shortcut — it is OTA-only).
127
+ #
128
+ # shared.rmw:
129
+ # local: <side-spec>
130
+ # ota: <side-spec>
131
+ #
132
+ # Each <side-spec> is either:
133
+ # - a string: cyclone | fastdds | zenoh_connect_endpoints | zenoh_ros2dds | <raw rmw string>
134
+ # (zenoh_ros2dds is allowed for ota only; raw rmw strings are for local only)
135
+ # - a tagged-union mapping with exactly one key (the RMW name):
136
+ # {cyclone: {config?: <fname.xml>, easy_mode_ip?: <address>, spdp_interval?: <duration>}}
137
+ # {fastdds: {config?: <fname.xml>, easy_mode_ip?: <address>}}
138
+ # {zenoh_connect_endpoints: {main_peer?: <peer_key>, main_port?: 7447}}
139
+ # {zenoh_ros2dds: {transport?: udp|tcp, main_peer?: <peer_key>, main_port?: 7447}}
140
+ # -----------------------------------------------------------------------------
141
+
142
+ _DDS_SHORTS = {"cyclone", "fastdds"}
143
+ _ZENOH_CONNECT_ENDPOINTS = "zenoh_connect_endpoints"
144
+ _NATIVE_ZENOH_OTA_SHORTS = {"zenoh", _ZENOH_CONNECT_ENDPOINTS}
145
+ _OTA_SHORTS = {"cyclone", "fastdds", *_NATIVE_ZENOH_OTA_SHORTS, "zenoh_ros2dds"}
146
+ _LOCAL_SHORTS = {"cyclone", "fastdds", "zenoh"}
147
+ _SHORTCUT_ALLOWED = {"cyclone", "fastdds", "zenoh"}
148
+
149
+ _DDS_CFG_KEYS = {"config", "easy_mode_ip", "spdp_interval"}
150
+ _ZEN_OTA_CFG_KEYS = {"main_peer", "main_port"}
151
+ _ZEN_R2D_CFG_KEYS = {"transport", "main_peer", "main_port"}
152
+
153
+ # OTA is cross-host by definition. The bare `cyclone`/`fastdds` OTA shortcuts
154
+ # would otherwise fall back to default DDS discovery (multicast on an
155
+ # auto-selected interface), which does not deliver across multi-homed hosts or
156
+ # overlay/tunnel networks that drop multicast. Default each DDS OTA side to its
157
+ # interface-pinned, unicast-peer config so cross-host delivery works out of the
158
+ # box; an explicit `ota: {cyclone|fastdds: {config: ...}}` still overrides this.
159
+ _DDS_OTA_DEFAULT_CONFIG = {
160
+ "cyclone": "cyclonedds_tuned.xml",
161
+ "fastdds": "fastdds_unicast.xml",
162
+ }
163
+
164
+
165
+ def _validate_positive_seconds(value: str, ctx: str) -> None:
166
+ text = value.strip().lower()
167
+ match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(ms|s)?", text)
168
+ if not match:
169
+ raise RuntimeError(f"{ctx}: expected e.g. '30s' or '500ms', got {value!r}.")
170
+ seconds = float(match.group(1)) / 1000.0 if match.group(2) == "ms" else float(match.group(1))
171
+ if seconds <= 0.0:
172
+ raise RuntimeError(f"{ctx} must be > 0, got {value!r}.")
173
+
174
+
175
+ def _is_native_zenoh_ota(impl: Optional[str]) -> bool:
176
+ return impl in _NATIVE_ZENOH_OTA_SHORTS
177
+
178
+
179
+ def _rmw_ota_runtime_impl(impl: Optional[str]) -> Optional[str]:
180
+ """Map user-facing OTA modes to the RMW implementation used by the base plugin."""
181
+ if _is_native_zenoh_ota(impl):
182
+ return "zenoh"
183
+ return impl
184
+
185
+
186
+ @dataclass
187
+ class RmwSideSpec:
188
+ """One side (local or ota) of the shared.rmw block."""
189
+
190
+ impl: Optional[str] = None
191
+ # DDS-specific (cyclone, fastdds)
192
+ dds_config: Optional[str] = None
193
+ dds_easy_mode_ip: Optional[str] = None
194
+ dds_spdp_interval: Optional[str] = None
195
+ # zenoh / zenoh_ros2dds
196
+ zen_main_peer: Optional[str] = None
197
+ # zenoh_ros2dds only
198
+ zen_transport: Optional[str] = None
199
+ zen_main_port: Optional[int] = None
200
+
201
+
202
+ @dataclass
203
+ class RmwSpec:
204
+ local: RmwSideSpec = field(default_factory=RmwSideSpec)
205
+ ota: RmwSideSpec = field(default_factory=RmwSideSpec)
206
+
207
+
208
+ def _parse_rmw_side(value: Any, ctx: str, *, is_local: bool) -> RmwSideSpec:
209
+ if value is None:
210
+ return RmwSideSpec()
211
+
212
+ if isinstance(value, str):
213
+ impl = value.strip()
214
+ if not impl:
215
+ return RmwSideSpec()
216
+ _validate_rmw_impl(impl, ctx, is_local=is_local)
217
+ return RmwSideSpec(impl=impl)
218
+
219
+ if not isinstance(value, dict):
220
+ raise RuntimeError(
221
+ f"{ctx} must be a string or a single-key mapping {{<rmw_name>: {{...}}}}, got {type(value)}."
222
+ )
223
+
224
+ if len(value) != 1:
225
+ raise RuntimeError(f"{ctx} mapping must have exactly one key (the RMW name); got {sorted(value.keys())}.")
226
+
227
+ ((impl_raw, cfg_raw),) = value.items()
228
+ if not isinstance(impl_raw, str) or not impl_raw.strip():
229
+ raise RuntimeError(f"{ctx} key must be a non-empty string (the RMW name).")
230
+ impl = impl_raw.strip()
231
+ _validate_rmw_impl(impl, ctx, is_local=is_local)
232
+
233
+ cfg: Dict[str, Any] = {}
234
+ if cfg_raw is not None:
235
+ if not isinstance(cfg_raw, dict):
236
+ raise RuntimeError(f"{ctx}.{impl} must be a mapping (or null).")
237
+ cfg = cfg_raw
238
+
239
+ spec = RmwSideSpec(impl=impl)
240
+ if impl in _DDS_SHORTS:
241
+ extra = set(cfg.keys()) - _DDS_CFG_KEYS
242
+ if extra:
243
+ raise RuntimeError(
244
+ f"{ctx}.{impl} contains unsupported keys {sorted(extra)}. Allowed: {sorted(_DDS_CFG_KEYS)}."
245
+ )
246
+ if cfg.get("config") is not None:
247
+ if not isinstance(cfg["config"], str) or not cfg["config"].strip():
248
+ raise RuntimeError(f"{ctx}.{impl}.config must be a non-empty string if provided.")
249
+ spec.dds_config = cfg["config"].strip()
250
+ if cfg.get("easy_mode_ip") is not None:
251
+ if not isinstance(cfg["easy_mode_ip"], str) or not cfg["easy_mode_ip"].strip():
252
+ raise RuntimeError(f"{ctx}.{impl}.easy_mode_ip must be a non-empty string if provided.")
253
+ spec.dds_easy_mode_ip = cfg["easy_mode_ip"].strip()
254
+ if cfg.get("spdp_interval") is not None:
255
+ if impl != "cyclone":
256
+ raise RuntimeError(f"{ctx}.{impl}.spdp_interval is only supported for CycloneDDS.")
257
+ if not isinstance(cfg["spdp_interval"], str) or not cfg["spdp_interval"].strip():
258
+ raise RuntimeError(f"{ctx}.{impl}.spdp_interval must be a non-empty duration if provided.")
259
+ _validate_positive_seconds(cfg["spdp_interval"], f"{ctx}.{impl}.spdp_interval")
260
+ spec.dds_spdp_interval = cfg["spdp_interval"].strip()
261
+ elif _is_native_zenoh_ota(impl):
262
+ extra = set(cfg.keys()) - _ZEN_OTA_CFG_KEYS
263
+ if extra:
264
+ raise RuntimeError(
265
+ f"{ctx}.{impl} contains unsupported keys {sorted(extra)}. Allowed: {sorted(_ZEN_OTA_CFG_KEYS)}."
266
+ )
267
+ if cfg.get("main_peer") is not None:
268
+ if not isinstance(cfg["main_peer"], str) or not cfg["main_peer"].strip():
269
+ raise RuntimeError(f"{ctx}.{impl}.main_peer must be a non-empty string if provided.")
270
+ spec.zen_main_peer = cfg["main_peer"].strip()
271
+ if cfg.get("main_port") is not None:
272
+ mp = cfg["main_port"]
273
+ if isinstance(mp, bool):
274
+ raise RuntimeError(f"{ctx}.{impl}.main_port must be an integer.")
275
+ try:
276
+ spec.zen_main_port = int(mp)
277
+ except Exception as e:
278
+ raise RuntimeError(f"{ctx}.{impl}.main_port must be an integer.") from e
279
+ elif impl == "zenoh_ros2dds":
280
+ extra = set(cfg.keys()) - _ZEN_R2D_CFG_KEYS
281
+ if extra:
282
+ raise RuntimeError(
283
+ f"{ctx}.zenoh_ros2dds contains unsupported keys {sorted(extra)}. Allowed: {sorted(_ZEN_R2D_CFG_KEYS)}."
284
+ )
285
+ if cfg.get("transport") is not None:
286
+ if not isinstance(cfg["transport"], str) or not cfg["transport"].strip():
287
+ raise RuntimeError(f"{ctx}.zenoh_ros2dds.transport must be a non-empty string.")
288
+ spec.zen_transport = cfg["transport"].strip()
289
+ if cfg.get("main_peer") is not None:
290
+ if not isinstance(cfg["main_peer"], str) or not cfg["main_peer"].strip():
291
+ raise RuntimeError(f"{ctx}.zenoh_ros2dds.main_peer must be a non-empty string.")
292
+ spec.zen_main_peer = cfg["main_peer"].strip()
293
+ if cfg.get("main_port") is not None:
294
+ mp = cfg["main_port"]
295
+ if isinstance(mp, bool):
296
+ raise RuntimeError(f"{ctx}.zenoh_ros2dds.main_port must be an integer.")
297
+ try:
298
+ spec.zen_main_port = int(mp)
299
+ except Exception as e:
300
+ raise RuntimeError(f"{ctx}.zenoh_ros2dds.main_port must be an integer.") from e
301
+ else:
302
+ # Raw RMW string (local only) — no config keys allowed.
303
+ if cfg:
304
+ raise RuntimeError(
305
+ f"{ctx}.{impl} does not accept any config keys (raw RMW string has no templated config)."
306
+ )
307
+
308
+ return spec
309
+
310
+
311
+ def _validate_rmw_impl(impl: str, ctx: str, *, is_local: bool) -> None:
312
+ if is_local:
313
+ if impl == "zenoh_ros2dds":
314
+ raise RuntimeError(f"{ctx}: 'zenoh_ros2dds' is OTA-only and cannot be used as the local RMW.")
315
+ if impl == _ZENOH_CONNECT_ENDPOINTS:
316
+ raise RuntimeError(
317
+ f"{ctx}: '{_ZENOH_CONNECT_ENDPOINTS}' is OTA-only; use local: zenoh for native rmw_zenoh_cpp."
318
+ )
319
+ # Everything else (including raw rmw strings) is permitted for local.
320
+ else:
321
+ if impl not in _OTA_SHORTS:
322
+ raise RuntimeError(f"{ctx} must be one of {sorted(_OTA_SHORTS)}; got {impl!r}.")
323
+
324
+
325
+ def _parse_rmw_block(value: Any, peer_keys: List[str]) -> RmwSpec:
326
+ """Parse `shared.rmw`. Returns an RmwSpec with both sides filled in (each
327
+ side may have impl=None if absent).
328
+ """
329
+ if value is None:
330
+ return RmwSpec()
331
+
332
+ if isinstance(value, str):
333
+ short = value.strip()
334
+ if not short:
335
+ return RmwSpec()
336
+ if short not in _SHORTCUT_ALLOWED:
337
+ raise RuntimeError(
338
+ f"shared.rmw string shortcut must be one of {sorted(_SHORTCUT_ALLOWED)}; got {short!r}. "
339
+ "Use the {local, ota} mapping form for other combinations."
340
+ )
341
+ side = RmwSideSpec(impl=short)
342
+ return RmwSpec(local=RmwSideSpec(impl=short), ota=RmwSideSpec(impl=short))
343
+
344
+ if not isinstance(value, dict):
345
+ raise RuntimeError(f"shared.rmw must be a string or a mapping with {{local, ota}} keys; got {type(value)}.")
346
+
347
+ extra = set(value.keys()) - {"local", "ota"}
348
+ if extra:
349
+ raise RuntimeError(f"shared.rmw contains unsupported keys {sorted(extra)}. Allowed: ['local', 'ota'].")
350
+
351
+ spec = RmwSpec(
352
+ local=_parse_rmw_side(value.get("local"), "shared.rmw.local", is_local=True),
353
+ ota=_parse_rmw_side(value.get("ota"), "shared.rmw.ota", is_local=False),
354
+ )
355
+
356
+ # main_peer references must be known peers.
357
+ for side, side_name in [(spec.local, "local"), (spec.ota, "ota")]:
358
+ if side.zen_main_peer is not None and side.zen_main_peer not in peer_keys:
359
+ raise RuntimeError(
360
+ f"shared.rmw.{side_name}.{side.impl}.main_peer must be one of the declared peers "
361
+ f"{peer_keys}, got {side.zen_main_peer!r}."
362
+ )
363
+ return spec
364
+
365
+
366
+ def _validate_rmw_spec_combination(spec: RmwSpec, *, ota_domain_id: Optional[int]) -> None:
367
+ """Cross-side validation of an RmwSpec after parsing."""
368
+ local_impl, ota_impl = spec.local.impl, spec.ota.impl
369
+ if _is_native_zenoh_ota(ota_impl):
370
+ # Native rmw_zenoh_cpp: local must also be zenoh (rmw_zenoh_cpp is not
371
+ # interoperable with DDS-based RMWs).
372
+ if local_impl not in (None, "zenoh", RMW_ALIASES["zenoh"]):
373
+ raise RuntimeError(
374
+ f"shared.rmw.ota={ota_impl} requires shared.rmw.local=zenoh "
375
+ "(rmw_zenoh_cpp is not interoperable with DDS-based RMWs)."
376
+ )
377
+ if ota_domain_id is None:
378
+ raise RuntimeError(
379
+ f"shared.rmw.ota={ota_impl} requires shared.ota_domain_id to be set. "
380
+ "Native rmw_zenoh_cpp's router forwards across ROS domains, so the OTA bridge "
381
+ "processes must run on a dedicated ROS_DOMAIN_ID that differs from every peer's "
382
+ "peer_settings.<peer>.domain_id (all three — both peer domains and ota_domain_id — "
383
+ "must be set and distinct)."
384
+ )
385
+ elif ota_impl == "zenoh_ros2dds":
386
+ # Bridge mode: local must be DDS (cyclone/fastdds). Raw RMW strings are
387
+ # rejected here to keep the `RMW_IMPLEMENTATION` switch in the plugin base deterministic.
388
+ if local_impl not in (None,) and local_impl not in _DDS_SHORTS:
389
+ raise RuntimeError(
390
+ f"shared.rmw.ota=zenoh_ros2dds requires shared.rmw.local in {sorted(_DDS_SHORTS)}; "
391
+ f"got {local_impl!r}. zenoh_ros2dds bridges DDS ↔ zenoh, so local processes must use DDS."
392
+ )
393
+ # DDS-on-DDS / cross-DDS combinations are unconstrained.
394
+
395
+
396
+ def _parse_optional_domain_id(value: Any, field_name: str) -> Optional[int]:
397
+ if value is None:
398
+ return None
399
+ if isinstance(value, bool):
400
+ raise RuntimeError(f"{field_name} must be an integer if provided.")
401
+ try:
402
+ out = int(value)
403
+ except Exception as e:
404
+ raise RuntimeError(f"{field_name} must be an integer if provided.") from e
405
+ if out < 0:
406
+ raise RuntimeError(f"{field_name} must be >= 0, got {out}.")
407
+ return out
408
+
409
+
410
+ # ---------------------------
411
+ # IO + normalization helpers
412
+ # ---------------------------
413
+
414
+
415
+ def _read_text(path: str) -> str:
416
+ with open(path, "r", encoding="utf-8") as f:
417
+ return f.read()
418
+
419
+
420
+ def _write_text(path: str, content: str) -> None:
421
+ os.makedirs(os.path.dirname(path), exist_ok=True)
422
+ with open(path, "w", encoding="utf-8") as f:
423
+ f.write(_ensure_trailing_newline(content))
424
+
425
+
426
+ def _ensure_trailing_newline(s: str) -> str:
427
+ return s if s.endswith("\n") else s + "\n"
428
+
429
+
430
+ def _normalize_topic_list_text(s: str) -> List[str]:
431
+ # Ignore trailing whitespace + trailing newline differences, keep stable order
432
+ out: List[str] = []
433
+ for ln in s.splitlines():
434
+ ln = ln.strip()
435
+ if not ln:
436
+ continue
437
+ out.append(ln)
438
+ return out
439
+
440
+
441
+ def _safe_load_yaml_text(text: str, path_hint: str) -> Any:
442
+ try:
443
+ return yaml.safe_load(text) if text.strip() else None
444
+ except Exception as e:
445
+ raise RuntimeError(f"Failed to parse YAML for {path_hint}: {e}") from e
446
+
447
+
448
+ def _yaml_canonical_dump(obj: Any) -> str:
449
+ # Stable, readable, diff-friendly
450
+ dumped = yaml.safe_dump(
451
+ obj,
452
+ sort_keys=True,
453
+ default_flow_style=False,
454
+ allow_unicode=True,
455
+ width=1000,
456
+ )
457
+ return _ensure_trailing_newline(dumped)
458
+
459
+
460
+ def _split_csv(value: Any) -> Optional[List[str]]:
461
+ if value is None:
462
+ return None
463
+ if not isinstance(value, str):
464
+ return None
465
+ items = [x.strip() for x in value.split(",")]
466
+ items = [x for x in items if x]
467
+ return items
468
+
469
+
470
+ def _normalize_csv_set(value: Any) -> Any:
471
+ items = _split_csv(value)
472
+ if items is None:
473
+ return value
474
+ return ",".join(sorted(set(items)))
475
+
476
+
477
+ def _normalize_regex_rule_yaml_obj(obj: Any) -> Any:
478
+ # Expected:
479
+ # compression:
480
+ # - topic_regex: "^/foo$"
481
+ # Keep order stable by sorting by topic_regex
482
+ if not isinstance(obj, dict):
483
+ return obj
484
+ for key in ("compression", "decompression", "ota_wrapper", "ota_unwrapper"):
485
+ if key in obj and isinstance(obj[key], list):
486
+ entries = obj[key]
487
+ norm_entries = []
488
+ for it in entries:
489
+ if isinstance(it, dict) and "topic_regex" in it:
490
+ norm_entries.append({"topic_regex": str(it["topic_regex"])})
491
+ else:
492
+ norm_entries.append(it)
493
+ norm_entries.sort(key=lambda d: d.get("topic_regex", "") if isinstance(d, dict) else str(d))
494
+ obj = dict(obj)
495
+ obj[key] = norm_entries
496
+ return obj
497
+
498
+
499
+ def _normalize_plugin_yaml_obj(obj: Any) -> Any:
500
+ # plugin.yaml structure:
501
+ # parameters: { key: value, ... }
502
+ # Normalize known CSV "set-like" parameters so ordering doesn't create diffs.
503
+ if not isinstance(obj, dict):
504
+ return obj
505
+ if "parameters" not in obj or not isinstance(obj["parameters"], dict):
506
+ return obj
507
+
508
+ params = dict(obj["parameters"])
509
+
510
+ csv_set_keys = {
511
+ "rs_restamp_topics",
512
+ "lat_topics",
513
+ "trickle_topics",
514
+ "fb_local_to_global_topics",
515
+ "fb_global_to_local_topics",
516
+ "fb_prefix_exclude_frames",
517
+ "fb_tf_filter_frames",
518
+ "fb_tf_throttle_links",
519
+ }
520
+ for k in csv_set_keys:
521
+ if k in params:
522
+ params[k] = _normalize_csv_set(params[k])
523
+
524
+ # Also normalize any accidental whitespace in string scalar values
525
+ for k, v in list(params.items()):
526
+ if isinstance(v, str):
527
+ params[k] = v.strip()
528
+
529
+ out = dict(obj)
530
+ out["parameters"] = params
531
+ return out
532
+
533
+
534
+ def _normalize_session_spec_yaml_obj(obj: Any) -> Any:
535
+ # session_specification.yaml:
536
+ # session_plugins:
537
+ # - ./plugin.yaml
538
+ # - /ws/session/content/base/session_plugin_base.yaml
539
+ if not isinstance(obj, dict):
540
+ return obj
541
+ if "session_plugins" in obj and isinstance(obj["session_plugins"], list):
542
+ # Strip accidental whitespace around strings
543
+ plugins = []
544
+ for p in obj["session_plugins"]:
545
+ plugins.append(p.strip() if isinstance(p, str) else p)
546
+ out = dict(obj)
547
+ out["session_plugins"] = plugins
548
+ return out
549
+ return obj
550
+
551
+
552
+ def _semantic_equal(path: str, existing_text: str, generated_text: str) -> Tuple[bool, str, str]:
553
+ """
554
+ Returns (equal, diff_from_text, diff_to_text)
555
+ diff_* are canonicalized representations used for unified diff when unequal.
556
+ """
557
+ lower = path.lower()
558
+
559
+ # Topic lists (.txt)
560
+ if lower.endswith("_topics.txt") or lower.endswith("topics.txt"):
561
+ a = _normalize_topic_list_text(existing_text)
562
+ b = _normalize_topic_list_text(generated_text)
563
+ if a == b:
564
+ return True, "", ""
565
+ # diff as normalized lines (one per line)
566
+ return False, _ensure_trailing_newline("\n".join(a)), _ensure_trailing_newline("\n".join(b))
567
+
568
+ # YAML files
569
+ if lower.endswith(".yaml") or lower.endswith(".yml"):
570
+ a_obj = _safe_load_yaml_text(existing_text, path)
571
+ b_obj = _safe_load_yaml_text(generated_text, path)
572
+
573
+ # File-specific normalization
574
+ base = os.path.basename(lower)
575
+ if base == "plugin.yaml":
576
+ a_obj = _normalize_plugin_yaml_obj(a_obj)
577
+ b_obj = _normalize_plugin_yaml_obj(b_obj)
578
+ elif base in ("compression.yaml", "decompression.yaml", "ota_wrapper.yaml", "ota_unwrapper.yaml"):
579
+ a_obj = _normalize_regex_rule_yaml_obj(a_obj)
580
+ b_obj = _normalize_regex_rule_yaml_obj(b_obj)
581
+ elif base == "session_specification.yaml":
582
+ a_obj = _normalize_session_spec_yaml_obj(a_obj)
583
+ b_obj = _normalize_session_spec_yaml_obj(b_obj)
584
+
585
+ if a_obj == b_obj:
586
+ return True, "", ""
587
+
588
+ return False, _yaml_canonical_dump(a_obj), _yaml_canonical_dump(b_obj)
589
+
590
+ # Fallback: text compare with trailing newline normalized
591
+ a = _ensure_trailing_newline(existing_text)
592
+ b = _ensure_trailing_newline(generated_text)
593
+ return (a == b), a, b
594
+
595
+
596
+ def _print_unified_diff(path: str, from_text: str, to_text: str) -> None:
597
+ diff = difflib.unified_diff(
598
+ from_text.splitlines(True),
599
+ to_text.splitlines(True),
600
+ fromfile=f"{path} (existing, canonical)",
601
+ tofile=f"{path} (generated, canonical)",
602
+ )
603
+ sys.stdout.writelines(diff)
604
+
605
+
606
+ def _write_generated_files(generated: List[Tuple[str, str]], force: bool, rewrite_formatting: bool) -> None:
607
+ """
608
+ Write generated files with semantic diff handling.
609
+ - Formatting-only differences can be rewritten with --rewrite-formatting.
610
+ - Semantic mismatches require --force.
611
+ """
612
+ semantic_mismatches = 0
613
+ for path, gen_content in generated:
614
+ if os.path.exists(path):
615
+ existing_content = _read_text(path)
616
+
617
+ equal, from_text, to_text = _semantic_equal(path, existing_content, gen_content)
618
+ if equal:
619
+ if rewrite_formatting and existing_content != gen_content:
620
+ _write_text(path, gen_content)
621
+ print(f"[REWRITE] {path}")
622
+ else:
623
+ print(f"[OK] {path}")
624
+ continue
625
+
626
+ semantic_mismatches += 1
627
+ print(f"[DIFF] {path}")
628
+ _print_unified_diff(path, from_text, to_text)
629
+
630
+ if not force:
631
+ print("\nRefusing to overwrite due to semantic mismatch.")
632
+ print("Fix session-template/session-config, or rerun with --force.\n")
633
+ continue
634
+
635
+ if (not os.path.exists(path)) or force:
636
+ _write_text(path, gen_content)
637
+ print(f"[WRITE] {path}")
638
+
639
+ if semantic_mismatches and not force:
640
+ sys.exit(2)
641
+
642
+
643
+ # ---------------------------
644
+ # Session config logic
645
+ # ---------------------------
646
+
647
+
648
+ def _resolve_session_template_path(param_dir: str, template_path: str) -> str:
649
+ """
650
+ Resolve a session template path:
651
+ - absolute path: used as-is (must exist)
652
+ - relative path: resolved relative to the directory containing the session config input file
653
+ """
654
+ if not isinstance(template_path, str) or not template_path.strip():
655
+ raise RuntimeError("load_template.filepath must be a non-empty string.")
656
+ p = template_path.strip()
657
+
658
+ # Support container-style logical paths when running on the host.
659
+ # Many configs refer to templates as "/session/..." (inside container) which maps to "<repo>/session/..." on host.
660
+ if p.startswith("/session/") or p == "/session":
661
+ # find the nearest ".../session" ancestor of param_dir
662
+ d = os.path.abspath(param_dir)
663
+ session_root = None
664
+ while True:
665
+ if os.path.basename(d) == "session":
666
+ session_root = d
667
+ break
668
+ parent = os.path.dirname(d)
669
+ if parent == d:
670
+ break
671
+ d = parent
672
+ if session_root:
673
+ rel = p[len("/session/") :] if p.startswith("/session/") else ""
674
+ mapped = os.path.join(session_root, rel)
675
+ if os.path.exists(mapped):
676
+ return mapped
677
+
678
+ # If we're actually in the container, the literal path may exist.
679
+ if os.path.exists(p):
680
+ return p
681
+
682
+ raise FileNotFoundError(
683
+ f"load_template points to missing file '{p}'. "
684
+ f"Tried mapping it to the repo's session dir but couldn't find it. "
685
+ f"Provide an existing path (absolute, /session/..., or relative to '{param_dir}')."
686
+ )
687
+
688
+ if p.startswith("/"):
689
+ if not os.path.exists(p):
690
+ raise FileNotFoundError(
691
+ f"load_template points to missing file '{p}'. "
692
+ f"Provide an existing absolute path, or a path relative to '{param_dir}'."
693
+ )
694
+ return p
695
+ return os.path.abspath(os.path.join(param_dir, p))
696
+
697
+
698
+ def _parse_session_config_template_spec(param: Dict[str, Any], param_dir: str) -> Tuple[str, Dict[str, Any]]:
699
+ """
700
+ Parse a session *parametrization* into:
701
+ - session_template_fs: resolved filesystem path to the session template YAML
702
+ - provided_params: mapping of parameters explicitly set in the parametrization
703
+
704
+ Supported formats:
705
+ load_template:
706
+ filepath: /path/to/template.yaml
707
+ parameters:
708
+ ... template input parameters ...
709
+ """
710
+ spec = (param or {}).get("load_template")
711
+ if spec is None:
712
+ raise RuntimeError(
713
+ "Missing 'load_template'. "
714
+ "If you want a self-contained definition (no template), omit load_template and provide "
715
+ "a session-definition-like structure directly (peers/shared/topics/peer_settings)."
716
+ )
717
+
718
+ if not isinstance(spec, dict):
719
+ raise RuntimeError("load_template must be a mapping with keys {filepath, parameters}. ")
720
+
721
+ filepath = spec.get("filepath")
722
+ session_template_fs = _resolve_session_template_path(param_dir, filepath)
723
+
724
+ provided_params = dict(spec.get("parameters", {}) or {})
725
+ if not isinstance(provided_params, dict):
726
+ raise RuntimeError("load_template.parameters must be a mapping.")
727
+
728
+ return session_template_fs, provided_params
729
+
730
+
731
+ def _build_vars_map_from_template(cfg_raw: Dict[str, Any], provided_params: Dict[str, Any]) -> Dict[str, Any]:
732
+ """
733
+ Build vars_map for template substitution from cfg_raw.input_parameters.
734
+
735
+ Rules:
736
+ - Extra parameters in session-config that are not in template input_parameters => error
737
+ - Missing required template parameters (no default) => error
738
+ - Optional template parameters (default provided) may be omitted; default is used
739
+ """
740
+ input_params = (cfg_raw or {}).get("input_parameters", {}) or {}
741
+ if not isinstance(input_params, dict):
742
+ raise RuntimeError("session-template input_parameters must be a mapping.")
743
+
744
+ extra = sorted([k for k in (provided_params or {}).keys() if k not in input_params])
745
+ if extra:
746
+ raise RuntimeError(
747
+ f"session-config provides unknown parameters not declared in the session template input_parameters: {extra}"
748
+ )
749
+
750
+ missing_required: List[str] = []
751
+ vars_map: Dict[str, Any] = {}
752
+ for k, spec in input_params.items():
753
+ # spec should be a mapping like: {type: str, default: ..., description: ...}
754
+ if k in (provided_params or {}):
755
+ vars_map[k] = provided_params[k]
756
+ continue
757
+ if isinstance(spec, dict) and "default" in spec:
758
+ vars_map[k] = spec.get("default")
759
+ continue
760
+ missing_required.append(k)
761
+
762
+ if missing_required:
763
+ raise RuntimeError(
764
+ "Missing required session parameters (declared in template input_parameters without a default): "
765
+ f"{sorted(missing_required)}"
766
+ )
767
+
768
+ return vars_map
769
+
770
+
771
+ def _render_session_dir(param_dir: str) -> str:
772
+ """
773
+ Render the session directory path that gets written into generated YAML.
774
+
775
+ - In-container, session dirs live under /session/...
776
+ - On-host, we still want generated files to reference /session/... so the same
777
+ session directory works when mounted into the container.
778
+ """
779
+ d = os.path.abspath(param_dir)
780
+
781
+ session_configs_root = os.environ.get("SESSION_CONFIGS_DIR")
782
+ if session_configs_root:
783
+ root = os.path.abspath(session_configs_root)
784
+ rel = os.path.relpath(d, root)
785
+ if rel == ".":
786
+ return "/session/configs"
787
+ if not rel.startswith(f"..{os.sep}") and rel != "..":
788
+ return f"/session/configs/{rel}"
789
+
790
+ session_root = None
791
+ cur = d
792
+ while True:
793
+ if os.path.basename(cur) == "session":
794
+ session_root = cur
795
+ break
796
+ parent = os.path.dirname(cur)
797
+ if parent == cur:
798
+ break
799
+ cur = parent
800
+ if session_root:
801
+ rel = os.path.relpath(d, session_root)
802
+ rel = "" if rel == "." else rel
803
+ return "/session" if not rel else f"/session/{rel}"
804
+
805
+ # Support ros_communication_devcontainer workspace mapping:
806
+ # On-host, session configs often live under "<repo>/ros_communication_devcontainer/ws/...".
807
+ # In-container, that workspace is mounted at "/ws/...".
808
+ cur = d
809
+ ws_root = None
810
+ while True:
811
+ if os.path.basename(cur) == "ws" and os.path.basename(os.path.dirname(cur)) == "ros_communication_devcontainer":
812
+ ws_root = cur
813
+ break
814
+ parent = os.path.dirname(cur)
815
+ if parent == cur:
816
+ break
817
+ cur = parent
818
+ if ws_root:
819
+ rel = os.path.relpath(d, ws_root)
820
+ rel = "" if rel == "." else rel
821
+ return "/ws" if not rel else f"/ws/{rel}"
822
+ return d
823
+
824
+
825
+ def _substitute(obj: Any, vars_map: Dict[str, Any]) -> Any:
826
+ if isinstance(obj, dict):
827
+ # Substitute both keys and values.
828
+ # This enables templates to parameterize peer names / topic direction keys via ${...}.
829
+ out: Dict[Any, Any] = {}
830
+ for k, v in obj.items():
831
+ nk = k
832
+ if isinstance(k, str):
833
+ nk_sub = _substitute(k, vars_map)
834
+ # YAML mapping keys must be hashable; we only support string keys here.
835
+ nk = nk_sub if isinstance(nk_sub, str) else str(nk_sub)
836
+ if not nk:
837
+ raise ValueError("Template substitution produced an empty mapping key, which is not allowed.")
838
+ if nk in out:
839
+ raise ValueError(f"Template substitution produced duplicate mapping key '{nk}'.")
840
+ out[nk] = _substitute(v, vars_map)
841
+ return out
842
+ if isinstance(obj, list):
843
+ return [_substitute(v, vars_map) for v in obj]
844
+ if isinstance(obj, str):
845
+ m = FULL_VAR_PATTERN.match(obj.strip())
846
+ if m:
847
+ key = m.group(1)
848
+ if key not in vars_map:
849
+ raise KeyError(f"Unknown variable '{key}' in '{obj}'")
850
+ return vars_map[key]
851
+
852
+ def repl(match: re.Match) -> str:
853
+ key = match.group(1)
854
+ if key not in vars_map:
855
+ raise KeyError(f"Unknown variable '{key}' in '{obj}'")
856
+ return str(vars_map[key])
857
+
858
+ return VAR_PATTERN.sub(repl, obj)
859
+
860
+ return obj
861
+
862
+
863
+ def _parse_condition(cond: Any, vars_map: Dict[str, Any]) -> bool:
864
+ if cond is None:
865
+ return False
866
+ if isinstance(cond, bool):
867
+ return cond
868
+ if isinstance(cond, (int, float)):
869
+ return bool(cond)
870
+ if isinstance(cond, str):
871
+ s = cond.strip()
872
+ if s.lower() in ("true", "yes", "on"):
873
+ return True
874
+ if s.lower() in ("false", "no", "off", ""):
875
+ return False
876
+ if s in vars_map:
877
+ return bool(vars_map[s])
878
+ raise ValueError(f"Unsupported condition string '{cond}' (expected bool/true/false or a variable name)")
879
+ raise TypeError(f"Unsupported condition type {type(cond)}")
880
+
881
+
882
+ def _load_yaml(path: str) -> Any:
883
+ with open(path, "r", encoding="utf-8") as f:
884
+ return yaml.safe_load(f)
885
+
886
+
887
+ def _assert_mapping(obj: Any, ctx: str) -> Dict[str, Any]:
888
+ if not isinstance(obj, dict):
889
+ raise RuntimeError(f"{ctx} must be a mapping, got {type(obj)}")
890
+ return obj
891
+
892
+
893
+ def _assert_allowed_keys(ctx: str, obj: Any, allowed: set) -> None:
894
+ if obj is None:
895
+ return
896
+ if not isinstance(obj, dict):
897
+ raise RuntimeError(f"{ctx} must be a mapping to validate keys, got {type(obj)}")
898
+ extra = sorted([k for k in obj.keys() if k not in allowed])
899
+ if extra:
900
+ raise RuntimeError(f"Unsupported keys in {ctx}: {extra}. Allowed keys: {sorted(allowed)}")
901
+
902
+
903
+ def _validate_session_template_cfg(cfg: Dict[str, Any]) -> None:
904
+ """
905
+ Validate that the substituted session-template only contains supported keys and shapes.
906
+ This is intentionally strict: unknown keys are treated as errors so configs don't silently do nothing.
907
+ """
908
+ cfg = _assert_mapping(cfg, "session-template root")
909
+
910
+ _assert_allowed_keys(
911
+ "session-template root",
912
+ cfg,
913
+ {
914
+ "input_parameters",
915
+ "peers",
916
+ "shared",
917
+ "peer_settings",
918
+ "topics",
919
+ # Test-suite annotations. `local_check` is the one-host fast-check
920
+ # opt-out; OTA suite membership is default unless `experimental` is
921
+ # true. Both are ignored by generation.
922
+ "local_check",
923
+ "experimental",
924
+ },
925
+ )
926
+ if "local_check" in cfg and cfg["local_check"] is not None and not isinstance(cfg["local_check"], bool):
927
+ raise RuntimeError("local_check must be boolean if provided.")
928
+ if "experimental" in cfg and cfg["experimental"] is not None and not isinstance(cfg["experimental"], bool):
929
+ raise RuntimeError("experimental must be boolean if provided.")
930
+
931
+ peers = _assert_mapping(cfg.get("peers"), "peers")
932
+ if not peers:
933
+ raise RuntimeError("peers must be a non-empty mapping.")
934
+ for peer_key, peer_obj in peers.items():
935
+ _assert_allowed_keys(f"peers.{peer_key}", peer_obj, {"host", "com-name"})
936
+ peer_obj = _assert_mapping(peer_obj, f"peers.{peer_key}")
937
+ if "host" in peer_obj and (
938
+ not isinstance(peer_obj["host"], str) or not peer_obj["host"].strip()
939
+ ):
940
+ raise RuntimeError(f"peers.{peer_key}.host must be a non-empty string if provided")
941
+ if (
942
+ "com-name" in peer_obj
943
+ and peer_obj["com-name"] is not None
944
+ and not isinstance(peer_obj["com-name"], (str, int, float, bool))
945
+ ):
946
+ raise RuntimeError(f"peers.{peer_key}.com-name must be a scalar if provided")
947
+
948
+ # shared is optional
949
+ if "shared" in cfg and cfg["shared"] is not None:
950
+ shared = _assert_mapping(cfg.get("shared"), "shared")
951
+ # Reject legacy keys with a clear message so configs that predate the
952
+ # shared.rmw / peer_settings.<peer>.domain_id schema fail loudly.
953
+ _LEGACY_SHARED_KEYS = {
954
+ "rmw_ota": "shared.rmw.ota",
955
+ "rmw_local": "shared.rmw.local",
956
+ "zenoh": "shared.rmw.ota.zenoh_ros2dds (tagged-union form)",
957
+ }
958
+ for legacy, replacement in _LEGACY_SHARED_KEYS.items():
959
+ if legacy in shared:
960
+ raise RuntimeError(f"shared.{legacy} is no longer supported. Use {replacement} instead.")
961
+
962
+ _assert_allowed_keys(
963
+ "shared",
964
+ shared,
965
+ {
966
+ "use_topic_monitor",
967
+ "use_status_overview",
968
+ "use_heartbeat",
969
+ "heartbeat",
970
+ "metric_backbone",
971
+ "link_trace",
972
+ "use_in",
973
+ "use_out",
974
+ "rmw",
975
+ "ota_domain_id",
976
+ "local_domain_id",
977
+ "heartbeat_position",
978
+ "processing_suffixes",
979
+ "compression",
980
+ "qos",
981
+ },
982
+ )
983
+ if (
984
+ "use_status_overview" in shared
985
+ and shared["use_status_overview"] is not None
986
+ and not isinstance(shared["use_status_overview"], bool)
987
+ ):
988
+ raise RuntimeError("shared.use_status_overview must be boolean if provided.")
989
+ if "use_in" in shared and shared["use_in"] is not None and not isinstance(shared["use_in"], bool):
990
+ raise RuntimeError("shared.use_in must be boolean if provided.")
991
+ if "use_out" in shared and shared["use_out"] is not None and not isinstance(shared["use_out"], bool):
992
+ raise RuntimeError("shared.use_out must be boolean if provided.")
993
+ if "rmw" in shared and shared["rmw"] is not None:
994
+ # Full parse (exercises all sub-validators). The cross-side
995
+ # combination check runs later in build time once peer settings
996
+ # are known.
997
+ _parse_rmw_block(shared.get("rmw"), list(peers.keys()))
998
+ if "ota_domain_id" in shared and shared["ota_domain_id"] is not None:
999
+ _parse_optional_domain_id(shared["ota_domain_id"], "shared.ota_domain_id")
1000
+ if "local_domain_id" in shared and shared["local_domain_id"] is not None:
1001
+ _parse_optional_domain_id(shared["local_domain_id"], "shared.local_domain_id")
1002
+ if "processing_suffixes" in shared and shared["processing_suffixes"] is not None:
1003
+ suffixes = _assert_mapping(shared.get("processing_suffixes"), "shared.processing_suffixes")
1004
+ _assert_allowed_keys(
1005
+ "shared.processing_suffixes",
1006
+ suffixes,
1007
+ {"restamped", "latched", "framebridge_global", "ota_stamped"},
1008
+ )
1009
+ if "compression" in shared and shared["compression"] is not None:
1010
+ comp = _assert_mapping(shared.get("compression"), "shared.compression")
1011
+ _assert_allowed_keys("shared.compression", comp, {"algorithm", "remove_algorithm_suffix_on_decompression"})
1012
+ if "qos" in shared and shared["qos"] is not None:
1013
+ qos = _assert_mapping(shared.get("qos"), "shared.qos")
1014
+ _assert_allowed_keys("shared.qos", qos, {"defaults", "for_role"})
1015
+ if "heartbeat" in shared and shared["heartbeat"] is not None:
1016
+ hb = _assert_mapping(shared.get("heartbeat"), "shared.heartbeat")
1017
+ _assert_allowed_keys("shared.heartbeat", hb, {"expect"})
1018
+ if "expect" in hb and hb["expect"] is not None and not isinstance(hb["expect"], dict):
1019
+ raise RuntimeError("shared.heartbeat.expect must be a mapping.")
1020
+ if "link_trace" in shared and shared["link_trace"] is not None:
1021
+ link_trace = _assert_mapping(shared.get("link_trace"), "shared.link_trace")
1022
+ _assert_allowed_keys(
1023
+ "shared.link_trace",
1024
+ link_trace,
1025
+ {"enabled", "interval_s", "modem_metrics_command", "modem_metrics_timeout_s"},
1026
+ )
1027
+ if "enabled" in link_trace and not isinstance(link_trace["enabled"], bool):
1028
+ raise RuntimeError("shared.link_trace.enabled must be boolean if provided.")
1029
+ if "interval_s" in link_trace and link_trace["interval_s"] is not None:
1030
+ if not isinstance(link_trace["interval_s"], (int, float)) or float(link_trace["interval_s"]) <= 0:
1031
+ raise RuntimeError("shared.link_trace.interval_s must be a positive number if provided.")
1032
+ if "modem_metrics_command" in link_trace and link_trace["modem_metrics_command"] is not None:
1033
+ if not isinstance(link_trace["modem_metrics_command"], str):
1034
+ raise RuntimeError("shared.link_trace.modem_metrics_command must be a string if provided.")
1035
+ if "modem_metrics_timeout_s" in link_trace and link_trace["modem_metrics_timeout_s"] is not None:
1036
+ if (
1037
+ not isinstance(link_trace["modem_metrics_timeout_s"], (int, float))
1038
+ or float(link_trace["modem_metrics_timeout_s"]) <= 0
1039
+ ):
1040
+ raise RuntimeError(
1041
+ "shared.link_trace.modem_metrics_timeout_s must be a positive number if provided."
1042
+ )
1043
+
1044
+ # peer_settings is optional
1045
+ if "peer_settings" in cfg and cfg["peer_settings"] is not None:
1046
+ peer_settings = _assert_mapping(cfg.get("peer_settings"), "peer_settings")
1047
+ peer_keys = list(peers.keys())
1048
+ extra_peers = sorted([k for k in peer_settings.keys() if k not in peer_keys])
1049
+ if extra_peers:
1050
+ raise RuntimeError(f"peer_settings contains unsupported peer keys {extra_peers}. Known peers: {peer_keys}")
1051
+ for p, ps in peer_settings.items():
1052
+ ps = _assert_mapping(ps, f"peer_settings.{p}")
1053
+ _assert_allowed_keys(
1054
+ f"peer_settings.{p}",
1055
+ ps,
1056
+ {"heartbeat_topic", "inbound", "outbound", "framebridge", "domain_id"},
1057
+ )
1058
+ if "domain_id" in ps and ps["domain_id"] is not None:
1059
+ _parse_optional_domain_id(ps["domain_id"], f"peer_settings.{p}.domain_id")
1060
+ if "framebridge" in ps and ps["framebridge"] is not None:
1061
+ fb_ps = _assert_mapping(ps["framebridge"], f"peer_settings.{p}.framebridge")
1062
+ _assert_allowed_keys(
1063
+ f"peer_settings.{p}.framebridge",
1064
+ fb_ps,
1065
+ {"global_frame_prefix", "prefix_exclude_frames", "tf_filter_frames", "tf_throttle_links"},
1066
+ )
1067
+
1068
+ # topics is optional
1069
+ if "topics" in cfg and cfg["topics"] is not None:
1070
+ topics = _assert_mapping(cfg.get("topics"), "topics")
1071
+ for dir_key, entries in topics.items():
1072
+ if not isinstance(dir_key, str):
1073
+ raise RuntimeError("topics keys must be strings like '<src>_to_<dst>'.")
1074
+ if not isinstance(entries, list):
1075
+ raise RuntimeError(f"topics.{dir_key} must be a list.")
1076
+ for i, item in enumerate(entries):
1077
+ if isinstance(item, str):
1078
+ continue
1079
+ if isinstance(item, dict):
1080
+ # `expect` declares the topic's intended delivered behavior
1081
+ # (hz/latency/loss). It is consumed by the status overview and
1082
+ # by `rosotacom test`; generation otherwise ignores it.
1083
+ _assert_allowed_keys(
1084
+ f"topics.{dir_key}[{i}]", item, {"topic", "type", "processing", "qos", "zen_qos", "expect"}
1085
+ )
1086
+ if "topic" not in item or not isinstance(item["topic"], str) or not item["topic"].strip():
1087
+ raise RuntimeError(f"topics.{dir_key}[{i}].topic must be a non-empty string.")
1088
+ if "type" in item and item["type"] is not None:
1089
+ if not isinstance(item["type"], str) or not item["type"].strip():
1090
+ raise RuntimeError(f"topics.{dir_key}[{i}].type must be a non-empty string if provided.")
1091
+ if (
1092
+ "processing" in item
1093
+ and item["processing"] is not None
1094
+ and not isinstance(item["processing"], dict)
1095
+ ):
1096
+ raise RuntimeError(f"topics.{dir_key}[{i}].processing must be a mapping.")
1097
+ if "qos" in item and item["qos"] is not None and not isinstance(item["qos"], dict):
1098
+ raise RuntimeError(f"topics.{dir_key}[{i}].qos must be a mapping.")
1099
+ if "zen_qos" in item and item["zen_qos"] is not None and not isinstance(item["zen_qos"], dict):
1100
+ raise RuntimeError(f"topics.{dir_key}[{i}].zen_qos must be a mapping.")
1101
+ if "expect" in item and item["expect"] is not None and not isinstance(item["expect"], dict):
1102
+ raise RuntimeError(f"topics.{dir_key}[{i}].expect must be a mapping.")
1103
+ continue
1104
+ raise RuntimeError(f"Unsupported topic entry at topics.{dir_key}[{i}]: {type(item)}")
1105
+
1106
+
1107
+ def _topic_entries(cfg: Dict[str, Any], direction: str) -> List[TopicEntry]:
1108
+ topics = (cfg.get("topics") or {}) if isinstance(cfg, dict) else {}
1109
+ if not isinstance(topics, dict):
1110
+ raise RuntimeError(f"topics must be a mapping if provided, got {type(topics)}")
1111
+ lst = topics.get(direction, [])
1112
+ if lst is None:
1113
+ lst = []
1114
+ if not isinstance(lst, list):
1115
+ raise RuntimeError(f"topics.{direction} must be a list, got {type(lst)}")
1116
+ out: List[TopicEntry] = []
1117
+ for i, item in enumerate(lst):
1118
+ if isinstance(item, str):
1119
+ out.append(TopicEntry(base=item, msg_type=None, processing={}, qos=None, zen_qos=None, index=i))
1120
+ elif isinstance(item, dict):
1121
+ out.append(
1122
+ TopicEntry(
1123
+ base=item["topic"],
1124
+ msg_type=(str(item.get("type")).strip() if item.get("type") is not None else None),
1125
+ processing=item.get("processing", {}) or {},
1126
+ qos=item.get("qos"),
1127
+ zen_qos=item.get("zen_qos"),
1128
+ index=i,
1129
+ expect=item.get("expect"),
1130
+ )
1131
+ )
1132
+ else:
1133
+ raise TypeError(f"Unsupported topic entry at topics.{direction}[{i}]: {type(item)}")
1134
+ return out
1135
+
1136
+
1137
+ def _dedup_keep_order(items: List[str]) -> List[str]:
1138
+ seen = set()
1139
+ out: List[str] = []
1140
+ for x in items:
1141
+ if x in seen:
1142
+ continue
1143
+ seen.add(x)
1144
+ out.append(x)
1145
+ return out
1146
+
1147
+
1148
+ def _format_yaml_kv(indent: int, k: str, v: Any) -> str:
1149
+ pad = " " * indent
1150
+ if isinstance(v, YamlBlockScalar):
1151
+ # Render a block scalar. We intentionally do not serialize via yaml.safe_dump
1152
+ # because plugin.yaml formatting is stable + hand-crafted in this generator.
1153
+ out = f"{pad}{k}: {v.header}\n"
1154
+ # Keep content readable and stable: indent by 9 spaces (matches existing examples)
1155
+ content_indent = 9
1156
+ for ln in (v.content or "").splitlines():
1157
+ out += (" " * content_indent) + ln + "\n"
1158
+ return out
1159
+ if isinstance(v, bool):
1160
+ vv = "true" if v else "false"
1161
+ return f"{pad}{k}: {vv}\n"
1162
+ if isinstance(v, (int, float)):
1163
+ return f"{pad}{k}: {v}\n"
1164
+ if v is None:
1165
+ return f"{pad}{k}:\n"
1166
+ return f"{pad}{k}: {v}\n"
1167
+
1168
+
1169
+ def _render_plugin_yaml(blocks: List[PluginBlock]) -> str:
1170
+ out = "parameters:\n"
1171
+ first = True
1172
+ for blk in blocks:
1173
+ if not blk.items:
1174
+ continue
1175
+ if not first:
1176
+ out += "\n"
1177
+ first = False
1178
+ for k, v in blk.items:
1179
+ out += _format_yaml_kv(2, k, v)
1180
+ return _ensure_trailing_newline(out)
1181
+
1182
+
1183
+ def _render_session_spec(base_plugin_path: str) -> str:
1184
+ return _ensure_trailing_newline(f"session_plugins:\n - ./plugin.yaml\n - {base_plugin_path}\n")
1185
+
1186
+
1187
+ def _render_regex_list(key: str, topics: List[str]) -> str:
1188
+ lines = [f"{key}:\n"]
1189
+ for t in topics:
1190
+ lines.append(f' - topic_regex: "^{t}$"\n')
1191
+ return _ensure_trailing_newline("".join(lines))
1192
+
1193
+
1194
+ def _render_qos_yaml(shared_qos: Dict[str, Any], qos_overrides: Dict[str, Dict[str, Any]]) -> str:
1195
+ """
1196
+ Render QoS YAML in the schema consumed by `com_py/qos.py`:
1197
+ default: <fields>
1198
+ role_defaults: <role -> fields>
1199
+ topics:
1200
+ /topic:
1201
+ <topic-wide fields> # apply to ALL roles
1202
+ roles:
1203
+ <role>:
1204
+ <fields>
1205
+
1206
+ Backwards compatible input for topic overrides:
1207
+ - topic_cfg.for_role (legacy template naming) is mapped to topic_cfg.roles.
1208
+ """
1209
+
1210
+ default = shared_qos.get("defaults", {}) or {}
1211
+ role_defaults = shared_qos.get("for_role", {}) or {}
1212
+
1213
+ # Normalize topic override shape:
1214
+ # - Accept both {roles: {...}} and {for_role: {...}} (template-facing)
1215
+ # - Keep any additional topic-level fields alongside roles.
1216
+ topics_out: Dict[str, Any] = {}
1217
+ for topic, cfg in (qos_overrides or {}).items():
1218
+ if not isinstance(cfg, dict):
1219
+ raise RuntimeError(f"qos override for topic '{topic}' must be a mapping, got {type(cfg)}")
1220
+ cfg_out: Dict[str, Any] = {}
1221
+ # copy all non-role keys
1222
+ for k, v in cfg.items():
1223
+ if k in ("for_role", "roles"):
1224
+ continue
1225
+ cfg_out[k] = v
1226
+
1227
+ roles = None
1228
+ if "roles" in cfg and cfg.get("roles") is not None:
1229
+ roles = cfg.get("roles")
1230
+ elif "for_role" in cfg and cfg.get("for_role") is not None:
1231
+ roles = cfg.get("for_role")
1232
+
1233
+ if roles is not None:
1234
+ if not isinstance(roles, dict):
1235
+ raise RuntimeError(f"qos override roles for topic '{topic}' must be a mapping, got {type(roles)}")
1236
+ cfg_out["roles"] = roles
1237
+
1238
+ topics_out[topic] = cfg_out
1239
+
1240
+ qos_obj = {
1241
+ "default": default,
1242
+ "role_defaults": role_defaults,
1243
+ "topics": topics_out,
1244
+ }
1245
+ # Use canonical YAML dump so nested dicts are valid YAML (not Python dict repr).
1246
+ return _yaml_canonical_dump(qos_obj)
1247
+
1248
+
1249
+ def _final_topic_type(entry: TopicEntry, pipe: Dict[str, Any]) -> str:
1250
+ transport = pipe.get("transport")
1251
+ if transport is not None:
1252
+ assert isinstance(transport, TransportSpec)
1253
+ if transport.type not in TRANSPORT_OUTPUT_TYPES:
1254
+ raise RuntimeError(
1255
+ f"Unsupported transport type '{transport.type}' for topic '{entry.base}'. "
1256
+ f"Known output type mappings: {sorted(TRANSPORT_OUTPUT_TYPES)}"
1257
+ )
1258
+ return TRANSPORT_OUTPUT_TYPES[transport.type]
1259
+
1260
+ if pipe.get("ota_wrap"):
1261
+ return OTA_STAMPED_MSG_TYPE
1262
+
1263
+ if pipe.get("compress"):
1264
+ return COMPRESSED_MSG_TYPE
1265
+
1266
+ msg_type = str(entry.msg_type or "").strip()
1267
+ if not msg_type:
1268
+ raise RuntimeError(
1269
+ f"Topic '{entry.base}' requires a 'type' when peer_settings.<peer>.domain_id/shared.ota_domain_id are used."
1270
+ )
1271
+ return msg_type
1272
+
1273
+
1274
+ def _heartbeat_monitor_overrides(heartbeat_expect: Optional[Dict[str, Any]]) -> List[Tuple[str, Any]]:
1275
+ """Override the heartbeat_echo status thresholds from a declared
1276
+ `shared.heartbeat.expect`. Only latency and loss map cleanly to the monitor's
1277
+ threshold model; the expected rate is the publish rate (heartbeat_out_hz) and
1278
+ `expect.hz` (min/max) is enforced separately by the status overview."""
1279
+ expect = heartbeat_expect if isinstance(heartbeat_expect, dict) else {}
1280
+ overrides: List[Tuple[str, Any]] = []
1281
+ latency = expect.get("latency_ms")
1282
+ if isinstance(latency, dict) and "max" in latency:
1283
+ overrides.append(("heartbeat_delay_bad_ms", float(latency["max"])))
1284
+ loss = expect.get("loss_pct")
1285
+ if isinstance(loss, dict) and "max" in loss:
1286
+ overrides.append(("heartbeat_loss3_bad_pct", float(loss["max"])))
1287
+ return overrides
1288
+
1289
+
1290
+ def _build_status_pipeline_spec(
1291
+ *,
1292
+ local: str,
1293
+ remote: str,
1294
+ peer_name: Dict[str, str],
1295
+ out_entries: List["TopicEntry"],
1296
+ out_pipes: List[Dict[str, Any]],
1297
+ in_entries: List["TopicEntry"],
1298
+ in_pipes: List[Dict[str, Any]],
1299
+ use_target_prefix: bool,
1300
+ remote_uses_target_prefix: bool,
1301
+ native_have_source_prefix: bool,
1302
+ inbound_keep_source_prefix: bool,
1303
+ local_domain_id: Optional[int],
1304
+ ota_domain_id: Optional[int],
1305
+ uses_domain_bridge: bool,
1306
+ hb_topic: Dict[str, str],
1307
+ use_heartbeat: bool,
1308
+ heartbeat_expect: Optional[Dict[str, Any]],
1309
+ out_enabled: bool,
1310
+ in_enabled: bool,
1311
+ final_topic_type,
1312
+ ) -> str:
1313
+ """
1314
+ Build the per-peer pipeline_spec.yaml consumed by the status_overview node.
1315
+
1316
+ For each configured topic it enumerates the ordered, locally-observable
1317
+ pipeline stages (Phase 1): outbound stages reach up to the /ota topic the
1318
+ local peer publishes ("sent"); inbound stages cover the /ota topic the local
1319
+ peer receives through to the republished application topic.
1320
+
1321
+ Stage topic names mirror the runtime resolution in topic_resolution.py so the
1322
+ node can subscribe directly without recomputing names.
1323
+ """
1324
+ local_name = peer_name[local]
1325
+ remote_name = peer_name[remote]
1326
+
1327
+ def _safe_type(entry: "TopicEntry", pipe: Dict[str, Any], fallback_base_type: bool = False) -> Optional[str]:
1328
+ if fallback_base_type:
1329
+ base_type = str(entry.msg_type or "").strip()
1330
+ if base_type:
1331
+ return base_type
1332
+ try:
1333
+ return final_topic_type(entry, pipe)
1334
+ except Exception:
1335
+ t = str(entry.msg_type or "").strip()
1336
+ return t or None
1337
+
1338
+ def _expected_hz(pipe: Dict[str, Any]) -> Optional[float]:
1339
+ if pipe.get("throttle") is not None:
1340
+ return float(pipe["throttle"])
1341
+ if pipe.get("trickle_hz") is not None:
1342
+ return float(pipe["trickle_hz"])
1343
+ return None
1344
+
1345
+ def _postprocessed_topic(pipe: Dict[str, Any], final: str) -> str:
1346
+ if pipe.get("trickle_hz") is not None:
1347
+ # Receiver-side trickle re-publishes the delivered OTA `final` at a fixed
1348
+ # rate to `<final>/trickle` (the trickle node's trickle_topic_suffix). That
1349
+ # republished topic is the real delivered output, so it is the monitored
1350
+ # native_in stage -- otherwise the trickle re-publish is an observability
1351
+ # blind spot (its rate cannot be asserted).
1352
+ return final + "/trickle"
1353
+ if pipe.get("compress"):
1354
+ return str(pipe["comp_in"])
1355
+ if pipe.get("ota_wrap"):
1356
+ return str(pipe["ota_in"])
1357
+ if pipe.get("framebridge") == "global_to_local":
1358
+ # global_to_local runs on the receiver: the OTA `final` is the global
1359
+ # (globalframe) topic, and the framebridge transforms it down to the
1360
+ # local base topic -- that base is the post-framebridge native_in.
1361
+ return str(pipe["fb_g2l_base"])
1362
+ transport = pipe.get("transport")
1363
+ if isinstance(transport, TransportSpec) and transport.local_republish:
1364
+ # image_transport reverse republish decodes `<final>` into
1365
+ # `<final>/raw`; PSNR/SSIM and application-level checks need that
1366
+ # decoded sensor_msgs/Image stage, not the encoded packet.
1367
+ return final + "/raw"
1368
+ return final
1369
+
1370
+ def _relay_in_local_topic(topic: str) -> str:
1371
+ if inbound_keep_source_prefix:
1372
+ return f"/{remote_name}{topic}"
1373
+ return topic
1374
+
1375
+ topics: List[Dict[str, Any]] = []
1376
+
1377
+ # --- Outbound: local peer is the source ---
1378
+ if out_enabled:
1379
+ outbound_items: List[Tuple["TopicEntry", Dict[str, Any]]] = []
1380
+ hb_local = hb_topic.get(local, "")
1381
+ for e, p in zip(out_entries, out_pipes):
1382
+ if use_heartbeat and hb_local and e.base == hb_local:
1383
+ continue
1384
+ outbound_items.append((e, p))
1385
+ if use_heartbeat and hb_local:
1386
+ hb_entry = TopicEntry(
1387
+ base=hb_local, msg_type=HEARTBEAT_MSG_TYPE, processing={}, qos=None, zen_qos=None, index=-1,
1388
+ expect=heartbeat_expect,
1389
+ )
1390
+ hb_pipe = {"final": hb_local}
1391
+ outbound_items.insert(0, (hb_entry, hb_pipe))
1392
+
1393
+ for e, p in outbound_items:
1394
+ base = e.base
1395
+ final = p.get("final", base)
1396
+ forward_final = f"/to_{remote_name}{final}" if use_target_prefix else final
1397
+ forward_ns = forward_final.lstrip("/")
1398
+ # The application publishes target-prefixed topics when use_target_prefix
1399
+ # (native_have_outgoing_target_prefix == use_target_prefix, enforced
1400
+ # above), so the native/processed stages -- and relay_out's input -- must
1401
+ # carry that /to_<remote> prefix too, or the status `native` stage and
1402
+ # the relay disagree and nothing forwards.
1403
+ target_pfx = f"/to_{remote_name}" if use_target_prefix else ""
1404
+ app_native = f"{target_pfx}/{local_name}{base}" if native_have_source_prefix else f"{target_pfx}{base}"
1405
+ app_processed = f"{target_pfx}/{local_name}{final}" if native_have_source_prefix else f"{target_pfx}{final}"
1406
+ base_type = _safe_type(e, p, fallback_base_type=True)
1407
+ final_type = _safe_type(e, p)
1408
+
1409
+ # global_to_local is a receiver-side transform: the sender's application
1410
+ # produces the global (globalframe) topic directly, so its native stage
1411
+ # IS the OTA `final` and there is no sender-side preprocessing stage.
1412
+ is_g2l = p.get("framebridge") == "global_to_local"
1413
+ native_topic = app_processed if is_g2l else app_native
1414
+ native_type = final_type if is_g2l else base_type
1415
+
1416
+ stages: List[Dict[str, Any]] = [
1417
+ {
1418
+ "stage": "native",
1419
+ "topic": native_topic,
1420
+ "type": native_type,
1421
+ "domain": "local",
1422
+ "produced_by": "application",
1423
+ },
1424
+ ]
1425
+ if final != base and not is_g2l:
1426
+ stages.append(
1427
+ {
1428
+ "stage": "processed",
1429
+ "topic": app_processed,
1430
+ "type": final_type,
1431
+ "domain": "local",
1432
+ "produced_by": "preprocessing",
1433
+ }
1434
+ )
1435
+ stages.append(
1436
+ {
1437
+ "stage": "com_out",
1438
+ "topic": f"/com/out/{local_name}/{forward_ns}",
1439
+ "type": final_type,
1440
+ "domain": "local",
1441
+ "produced_by": "relay_out",
1442
+ }
1443
+ )
1444
+ stages.append(
1445
+ {
1446
+ "stage": "ota_sent",
1447
+ "topic": f"/ota/{local_name}/{forward_ns}",
1448
+ "type": final_type,
1449
+ "domain": "ota",
1450
+ "produced_by": "bridge_out",
1451
+ }
1452
+ )
1453
+
1454
+ topics.append(
1455
+ {
1456
+ "base": base,
1457
+ "direction": "outbound",
1458
+ "source": local_name,
1459
+ "target": remote_name,
1460
+ "type": final_type,
1461
+ "expected_hz": _expected_hz(p),
1462
+ "expect": e.expect,
1463
+ "stages": stages,
1464
+ }
1465
+ )
1466
+
1467
+ # --- Inbound: remote peer is the source, local peer is the target ---
1468
+ if in_enabled:
1469
+ inbound_items: List[Tuple["TopicEntry", Dict[str, Any]]] = []
1470
+ hb_remote = hb_topic.get(remote, "")
1471
+ for e, p in zip(in_entries, in_pipes):
1472
+ if use_heartbeat and hb_remote and e.base == hb_remote:
1473
+ continue
1474
+ inbound_items.append((e, p))
1475
+ if use_heartbeat and hb_remote:
1476
+ hb_entry = TopicEntry(
1477
+ base=hb_remote, msg_type=HEARTBEAT_MSG_TYPE, processing={}, qos=None, zen_qos=None, index=-1,
1478
+ expect=heartbeat_expect,
1479
+ )
1480
+ hb_pipe = {"final": hb_remote}
1481
+ inbound_items.insert(0, (hb_entry, hb_pipe))
1482
+
1483
+ for e, p in inbound_items:
1484
+ base = e.base
1485
+ final = p.get("final", base)
1486
+ forward_final = f"/to_{local_name}{final}" if remote_uses_target_prefix else final
1487
+ forward_ns = forward_final.lstrip("/")
1488
+ app_in = _relay_in_local_topic(final)
1489
+ postprocessed = _postprocessed_topic(p, final)
1490
+ base_type = _safe_type(e, p, fallback_base_type=True)
1491
+ final_type = _safe_type(e, p)
1492
+
1493
+ stages = [
1494
+ {
1495
+ "stage": "ota_recv",
1496
+ "topic": f"/ota/{remote_name}/{forward_ns}",
1497
+ "type": final_type,
1498
+ "domain": "ota",
1499
+ "produced_by": "transport",
1500
+ },
1501
+ {
1502
+ "stage": "com_in",
1503
+ "topic": f"/com/in/{remote_name}/{forward_ns}",
1504
+ "type": final_type,
1505
+ "domain": "local",
1506
+ "produced_by": "bridge_in",
1507
+ },
1508
+ {
1509
+ "stage": "app_in",
1510
+ "topic": app_in,
1511
+ "type": final_type,
1512
+ "domain": "local",
1513
+ "produced_by": "relay_in",
1514
+ },
1515
+ ]
1516
+ if postprocessed != final:
1517
+ native_in_type = base_type
1518
+ transport = p.get("transport")
1519
+ if isinstance(transport, TransportSpec) and transport.local_republish:
1520
+ native_in_type = SENSOR_IMAGE_MSG_TYPE
1521
+ stages.append(
1522
+ {
1523
+ "stage": "native_in",
1524
+ "topic": _relay_in_local_topic(postprocessed),
1525
+ "type": native_in_type,
1526
+ "domain": "local",
1527
+ "produced_by": "postprocessing",
1528
+ }
1529
+ )
1530
+
1531
+ topics.append(
1532
+ {
1533
+ "base": base,
1534
+ "direction": "inbound",
1535
+ "source": remote_name,
1536
+ "target": local_name,
1537
+ "type": final_type,
1538
+ "expected_hz": _expected_hz(p),
1539
+ "expect": e.expect,
1540
+ "stages": stages,
1541
+ }
1542
+ )
1543
+
1544
+ spec: Dict[str, Any] = {
1545
+ "schema_version": 1,
1546
+ "peer": local_name,
1547
+ "remote": remote_name,
1548
+ "local_domain_id": local_domain_id,
1549
+ "ota_domain_id": ota_domain_id,
1550
+ "uses_domain_bridge": uses_domain_bridge,
1551
+ "topics": topics,
1552
+ }
1553
+ return _yaml_canonical_dump(spec)
1554
+
1555
+
1556
+ def _dedup_topic_type_items(items: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
1557
+ seen: Dict[str, str] = {}
1558
+ out: List[Tuple[str, str]] = []
1559
+ for topic_name, msg_type in items:
1560
+ existing = seen.get(topic_name)
1561
+ if existing is not None:
1562
+ if existing != msg_type:
1563
+ raise RuntimeError(
1564
+ f"Conflicting message types for generated topic '{topic_name}': '{existing}' vs '{msg_type}'."
1565
+ )
1566
+ continue
1567
+ seen[topic_name] = msg_type
1568
+ out.append((topic_name, msg_type))
1569
+ return out
1570
+
1571
+
1572
+ def _render_domain_bridge_yaml(name: str, topics: Dict[str, Dict[str, Any]]) -> str:
1573
+ cfg: Dict[str, Any] = {"name": name, "topics": topics}
1574
+ return _yaml_canonical_dump(cfg)
1575
+
1576
+
1577
+ def _compute_pipeline(
1578
+ entry: TopicEntry,
1579
+ vars_map: Dict[str, Any],
1580
+ restamped_suffix: str,
1581
+ latched_suffix: str,
1582
+ globalframe_suffix: str,
1583
+ comp_alg_suffix: str,
1584
+ ota_suffix: str,
1585
+ ) -> Dict[str, Any]:
1586
+ proc = entry.processing or {}
1587
+
1588
+ known_keys = {
1589
+ "restamp_if",
1590
+ "latch",
1591
+ "trickle_hz",
1592
+ "drop",
1593
+ "framebridge",
1594
+ "normalize_on_target",
1595
+ "compress",
1596
+ "use_ota_wrapper",
1597
+ "throttle_hz",
1598
+ "pixel_cap_preset",
1599
+ "transport",
1600
+ }
1601
+ unknown = set(proc.keys()) - known_keys
1602
+ if unknown:
1603
+ raise ValueError(f"Unknown processing keys for topic '{entry.base}': {sorted(unknown)}")
1604
+
1605
+ restamp = _parse_condition(proc.get("restamp_if"), vars_map) if "restamp_if" in proc else False
1606
+
1607
+ framebridge = proc.get("framebridge")
1608
+ normalize = bool(proc.get("normalize_on_target")) if "normalize_on_target" in proc else False
1609
+ compress = bool(proc.get("compress")) if "compress" in proc else False
1610
+ ota_wrap = bool(proc.get("use_ota_wrapper")) if "use_ota_wrapper" in proc else False
1611
+
1612
+ throttle = proc.get("throttle_hz")
1613
+ if throttle is not None:
1614
+ throttle = int(throttle)
1615
+ if throttle <= 0:
1616
+ raise ValueError(f"throttle_hz must be > 0 for topic '{entry.base}'")
1617
+
1618
+ pixel = proc.get("pixel_cap_preset")
1619
+
1620
+ transport: Optional[TransportSpec] = None
1621
+ if proc.get("transport") is not None:
1622
+ t = proc["transport"]
1623
+ if not isinstance(t, dict) or "type" not in t:
1624
+ raise ValueError(f"transport must be dict with 'type' for topic '{entry.base}'")
1625
+ ttype = t["type"]
1626
+ local_republish = bool(t.get("local_republish", False))
1627
+ params = {k: v for k, v in t.items() if k not in ("type", "local_republish")}
1628
+ transport = TransportSpec(type=ttype, params=params, local_republish=local_republish)
1629
+
1630
+ topic = entry.base
1631
+
1632
+ restamp_in = topic
1633
+ if restamp:
1634
+ topic = topic + restamped_suffix
1635
+ restamp_out = topic
1636
+
1637
+ latch = bool(proc.get("latch")) if "latch" in proc else False
1638
+ lat_in = None
1639
+ if latch:
1640
+ lat_in = topic
1641
+ topic = topic + latched_suffix
1642
+
1643
+ trickle_hz = proc.get("trickle_hz")
1644
+ if trickle_hz is not None:
1645
+ trickle_hz = float(trickle_hz)
1646
+ if trickle_hz <= 0:
1647
+ raise ValueError(f"trickle_hz must be > 0 for topic '{entry.base}'")
1648
+
1649
+ drop_count = None
1650
+ window_size = None
1651
+ drp_in = None
1652
+ drp_out = None
1653
+ if proc.get("drop") is not None:
1654
+ drop_cfg = proc["drop"]
1655
+ if not isinstance(drop_cfg, dict):
1656
+ raise ValueError(f"drop must be a dict with 'drop_count' and 'window_size' for topic '{entry.base}'")
1657
+ drop_count = drop_cfg.get("drop_count")
1658
+ window_size = drop_cfg.get("window_size")
1659
+ if drop_count is None or window_size is None:
1660
+ raise ValueError(f"drop.drop_count and drop.window_size are required for topic '{entry.base}'")
1661
+ drop_count = int(drop_count)
1662
+ window_size = int(window_size)
1663
+ if drop_count < 0 or window_size <= 0:
1664
+ raise ValueError(f"drop.drop_count must be >= 0 and drop.window_size must be > 0 for topic '{entry.base}'")
1665
+ if drop_count >= window_size:
1666
+ raise ValueError(f"drop.drop_count must be < drop.window_size for topic '{entry.base}'")
1667
+ drp_in = topic
1668
+ drp_out = topic + f"/drop{drop_count}of{window_size}"
1669
+ topic = drp_out
1670
+
1671
+ thr_in = None
1672
+ thr_out = None
1673
+ if throttle is not None:
1674
+ thr_in = topic
1675
+ thr_out = topic + f"/max{throttle}hz"
1676
+ topic = thr_out
1677
+
1678
+ ipx_in = None
1679
+ ipx_out = None
1680
+ if pixel is not None:
1681
+ ipx_in = topic
1682
+ ipx_out = topic + f"/{pixel}"
1683
+ topic = ipx_out
1684
+
1685
+ fb_l2g_in = None
1686
+ fb_g2l_base = None
1687
+ if framebridge:
1688
+ if framebridge not in ("local_to_global", "global_to_local"):
1689
+ raise ValueError(f"Unknown framebridge '{framebridge}' for topic '{entry.base}'")
1690
+
1691
+ if framebridge == "local_to_global":
1692
+ # Sender-side transform: the globalframe topic is what crosses OTA.
1693
+ fb_l2g_in = topic
1694
+ topic = topic + globalframe_suffix
1695
+ else:
1696
+ # global_to_local runs on the RECEIVER: the sender ships the global
1697
+ # (globalframe) topic -- which IS the OTA `final` -- and the receiver's
1698
+ # framebridge transforms it down to the local base topic (its native_in,
1699
+ # see _postprocessed_topic). The center application produces the global
1700
+ # topic directly, so there is no sender-side processing stage.
1701
+ fb_g2l_base = entry.base
1702
+ topic = entry.base + globalframe_suffix
1703
+
1704
+ comp_in = None
1705
+ if compress:
1706
+ comp_in = topic
1707
+ topic = topic + comp_alg_suffix
1708
+
1709
+ ota_in = None
1710
+ if ota_wrap:
1711
+ ota_in = topic
1712
+ topic = topic + ota_suffix
1713
+
1714
+ it_in = None
1715
+ irt_in = None
1716
+ if transport:
1717
+ it_in = topic
1718
+ topic = topic + f"/{transport.type}"
1719
+ if transport.local_republish:
1720
+ irt_in = topic
1721
+
1722
+ return {
1723
+ "final": topic,
1724
+ "restamp": restamp,
1725
+ "restamp_in": restamp_in,
1726
+ "restamp_out": restamp_out,
1727
+ "latch": latch,
1728
+ "lat_in": lat_in,
1729
+ "trickle_hz": trickle_hz,
1730
+ "drop_count": drop_count,
1731
+ "window_size": window_size,
1732
+ "drp_in": drp_in,
1733
+ "drp_out": drp_out,
1734
+ "framebridge": framebridge,
1735
+ "fb_l2g_in": fb_l2g_in,
1736
+ "fb_g2l_base": fb_g2l_base,
1737
+ "normalize": normalize,
1738
+ "compress": compress,
1739
+ "comp_in": comp_in,
1740
+ "ota_wrap": ota_wrap,
1741
+ "ota_in": ota_in,
1742
+ "throttle": throttle,
1743
+ "thr_in": thr_in,
1744
+ "thr_out": thr_out,
1745
+ "pixel": pixel,
1746
+ "ipx_in": ipx_in,
1747
+ "ipx_out": ipx_out,
1748
+ "transport": transport,
1749
+ "it_in": it_in,
1750
+ "irt_in": irt_in,
1751
+ }
1752
+
1753
+
1754
+ # ---------------------------
1755
+ # Main
1756
+ # ---------------------------
1757
+
1758
+
1759
+ def func(
1760
+ session_config_yaml: str = "",
1761
+ force: bool = False,
1762
+ rewrite_formatting: bool = False,
1763
+ base_plugin_path: str = BASE_PLUGIN_PATH_DEFAULT,
1764
+ session_config_obj: Optional[Dict[str, Any]] = None,
1765
+ output_dir: Optional[str] = None,
1766
+ write_resolved_definition: Optional[bool] = None,
1767
+ peer_addresses: Optional[Dict[str, str]] = None,
1768
+ ) -> None:
1769
+ session_config_yaml = os.path.abspath(session_config_yaml) if session_config_yaml else ""
1770
+ if output_dir:
1771
+ param_dir = os.path.abspath(output_dir)
1772
+ elif session_config_yaml:
1773
+ param_dir = os.path.dirname(session_config_yaml)
1774
+ else:
1775
+ raise RuntimeError("Either session_config_yaml or output_dir must be provided.")
1776
+ param_name = os.path.basename(param_dir)
1777
+
1778
+ if session_config_obj is not None:
1779
+ if not isinstance(session_config_obj, dict):
1780
+ raise RuntimeError(f"session_config_obj must be a mapping, got {type(session_config_obj)}")
1781
+ vars_map = {}
1782
+ cfg = dict(session_config_obj)
1783
+ template_mode = False
1784
+ else:
1785
+ param = _load_yaml(session_config_yaml)
1786
+
1787
+ if not isinstance(param, dict):
1788
+ raise RuntimeError(
1789
+ f"Session config input YAML must be a mapping, got {type(param)} for '{session_config_yaml}'"
1790
+ )
1791
+
1792
+ # session-config supports template-driven configs, but the resulting cfg is validated+handled
1793
+ # with optional blocks: peers is required; shared/topics/peer_settings are optional.
1794
+ template_mode = "load_template" in param
1795
+ if template_mode:
1796
+ session_template_fs, provided_params = _parse_session_config_template_spec(param, param_dir)
1797
+ cfg_raw = _load_yaml(session_template_fs)
1798
+ vars_map = _build_vars_map_from_template(cfg_raw, provided_params)
1799
+ cfg = _substitute(cfg_raw, vars_map)
1800
+ else:
1801
+ vars_map = {}
1802
+ cfg = dict(param)
1803
+
1804
+ _validate_session_template_cfg(cfg)
1805
+
1806
+ # If the user provided a parametrization (template + parameters), write the resolved, self-contained
1807
+ # session definition next to the generated files so it can be inspected/versioned.
1808
+ session_definition_yaml: Optional[str] = None
1809
+ if write_resolved_definition is None:
1810
+ write_resolved_definition = template_mode
1811
+ if write_resolved_definition:
1812
+ cfg_definition = dict(cfg)
1813
+ cfg_definition.pop("input_parameters", None)
1814
+ session_definition_yaml = _yaml_canonical_dump(cfg_definition)
1815
+
1816
+ # ---------------------------
1817
+ # Peer + direction discovery (NO role hardcoding)
1818
+ # ---------------------------
1819
+
1820
+ if "peers" not in cfg or not isinstance(cfg["peers"], dict) or not cfg["peers"]:
1821
+ raise RuntimeError("session-config must define a mapping 'peers: { <peer_key>: {...} }'.")
1822
+
1823
+ peer_keys = list(cfg["peers"].keys())
1824
+ if len(peer_keys) != 2:
1825
+ raise RuntimeError(
1826
+ "This generator currently supports exactly 2 peers (because session_plugin_base.yaml is 1-remote). "
1827
+ f"Got peers={peer_keys}"
1828
+ )
1829
+
1830
+ peer_addresses = peer_addresses or {}
1831
+ unknown_addresses = sorted(set(peer_addresses) - set(peer_keys))
1832
+ if unknown_addresses:
1833
+ raise RuntimeError(
1834
+ f"peer_addresses contains unknown peer(s) {unknown_addresses}. Known peers: {peer_keys}"
1835
+ )
1836
+ missing_addresses = [peer for peer in peer_keys if not str(peer_addresses.get(peer, "")).strip()]
1837
+ if missing_addresses:
1838
+ raise RuntimeError(
1839
+ f"Missing resolved addresses for peer(s) {missing_addresses}. "
1840
+ "Resolve deployment hosts or pass --peer-address before generating a session."
1841
+ )
1842
+
1843
+ def _peer_address(peer_key: str) -> str:
1844
+ return str(peer_addresses[peer_key]).strip()
1845
+
1846
+ def _peer_com_name(peer_key: str) -> str:
1847
+ """
1848
+ Resolve a peer's com-name for plugin.yaml:
1849
+ - peers.<peer>.com-name if provided and non-empty
1850
+ - else the peer key itself
1851
+ """
1852
+ v = None
1853
+ try:
1854
+ v = (cfg["peers"][peer_key] or {}).get("com-name")
1855
+ except Exception:
1856
+ v = None
1857
+ if v is None:
1858
+ return peer_key
1859
+ if isinstance(v, str):
1860
+ s = v.strip()
1861
+ return s if s else peer_key
1862
+ return str(v) if v else peer_key
1863
+
1864
+ peer_ip = {p: _peer_address(p) for p in peer_keys}
1865
+ peer_name = {p: _peer_com_name(p) for p in peer_keys}
1866
+
1867
+ # Optional blocks (may be absent in both template-driven and direct configs)
1868
+ shared = cfg.get("shared", {}) or {}
1869
+ if not isinstance(shared, dict):
1870
+ raise RuntimeError(f"shared must be a mapping if provided, got {type(shared)}")
1871
+ peer_settings_all = cfg.get("peer_settings", {}) or {}
1872
+ if not isinstance(peer_settings_all, dict):
1873
+ raise RuntimeError(f"peer_settings must be a mapping if provided, got {type(peer_settings_all)}")
1874
+ topics_cfg = cfg.get("topics", {}) or {}
1875
+ if not isinstance(topics_cfg, dict):
1876
+ raise RuntimeError(f"topics must be a mapping if provided, got {type(topics_cfg)}")
1877
+
1878
+ # Optional shared toggles
1879
+ use_topic_monitor = bool(shared.get("use_topic_monitor", False))
1880
+ use_status_overview = bool(shared.get("use_status_overview", False))
1881
+ use_heartbeat = bool(shared.get("use_heartbeat", False))
1882
+ metric_backbone = shared.get("metric_backbone", {}) or {}
1883
+ if not isinstance(metric_backbone, dict):
1884
+ raise RuntimeError("shared.metric_backbone must be a mapping if provided.")
1885
+ unknown_metric_keys = sorted(set(metric_backbone) - {"record_stages"})
1886
+ if unknown_metric_keys:
1887
+ raise RuntimeError(
1888
+ f"shared.metric_backbone has unsupported keys {unknown_metric_keys}. "
1889
+ "Allowed: ['record_stages']."
1890
+ )
1891
+ record_metric_stages = bool(metric_backbone.get("record_stages", False))
1892
+ if record_metric_stages and not use_status_overview:
1893
+ raise RuntimeError(
1894
+ "shared.metric_backbone.record_stages requires shared.use_status_overview=true "
1895
+ "so the generated pipeline defines the stage topics."
1896
+ )
1897
+ link_trace = shared.get("link_trace", {}) or {}
1898
+ if not isinstance(link_trace, dict):
1899
+ raise RuntimeError("shared.link_trace must be a mapping if provided.")
1900
+ link_trace_enabled = bool(link_trace.get("enabled", False))
1901
+ link_trace_interval_s = float(link_trace.get("interval_s", 1.0))
1902
+ link_trace_modem_command = str(link_trace.get("modem_metrics_command") or "")
1903
+ link_trace_modem_timeout_s = float(link_trace.get("modem_metrics_timeout_s", 2.0))
1904
+ if link_trace_enabled and not use_status_overview:
1905
+ raise RuntimeError("shared.link_trace.enabled requires shared.use_status_overview=true.")
1906
+ shared_use_in = shared.get("use_in", None)
1907
+ shared_use_out = shared.get("use_out", None)
1908
+
1909
+ # RMW spec: the unified `shared.rmw` block. Either a string shortcut (e.g.
1910
+ # `rmw: zenoh`) or a mapping `{local, ota}` per side. See `_parse_rmw_block`
1911
+ # for the full schema.
1912
+ rmw_spec = _parse_rmw_block(shared.get("rmw"), peer_keys)
1913
+
1914
+ ota_domain_id = _parse_optional_domain_id(shared.get("ota_domain_id"), "shared.ota_domain_id")
1915
+ # Per-peer local domain id. Resolution order:
1916
+ # 1. peer_settings.<peer>.domain_id (per-peer explicit)
1917
+ # 2. shared.local_domain_id (shortcut applied to every peer that did not set its own)
1918
+ # If both are set for the same peer and disagree, that's an error — either the
1919
+ # user meant the shortcut (drop the per-peer value) or they meant to override it
1920
+ # (drop the shared one), but silently letting them diverge would hide mistakes.
1921
+ shared_local_domain_id = _parse_optional_domain_id(shared.get("local_domain_id"), "shared.local_domain_id")
1922
+ peer_local_domain_id: Dict[str, Optional[int]] = {}
1923
+ for p in peer_keys:
1924
+ per_peer = _parse_optional_domain_id(
1925
+ (peer_settings_all.get(p) or {}).get("domain_id"),
1926
+ f"peer_settings.{p}.domain_id",
1927
+ )
1928
+ if per_peer is not None and shared_local_domain_id is not None and per_peer != shared_local_domain_id:
1929
+ raise RuntimeError(
1930
+ f"peer_settings.{p}.domain_id ({per_peer}) conflicts with "
1931
+ f"shared.local_domain_id ({shared_local_domain_id}). "
1932
+ f"Set only one, or make them agree."
1933
+ )
1934
+ peer_local_domain_id[p] = per_peer if per_peer is not None else shared_local_domain_id
1935
+
1936
+ _validate_rmw_spec_combination(rmw_spec, ota_domain_id=ota_domain_id)
1937
+
1938
+ # Native rmw_zenoh_cpp: require distinct peer domain_ids, a distinct
1939
+ # ota_domain_id, and distinct IP keys. rmw_zenoh_cpp's router forwards
1940
+ # across ROS domains, so the only way to keep each peer's local graph
1941
+ # isolated from the other peer's and from the OTA bridge traffic is to
1942
+ # pin every logical graph to its own domain.
1943
+ if _is_native_zenoh_ota(rmw_spec.ota.impl):
1944
+ ota_label = rmw_spec.ota.impl
1945
+ missing = [p for p in peer_keys if peer_local_domain_id[p] is None]
1946
+ if missing:
1947
+ raise RuntimeError(
1948
+ f"shared.rmw.ota={ota_label} requires peer_settings.<peer>.domain_id for every peer; "
1949
+ f"missing: {missing}."
1950
+ )
1951
+ # ota_domain_id presence is already enforced by _validate_rmw_spec_combination.
1952
+ assert ota_domain_id is not None
1953
+ seen_ids: Dict[int, str] = {}
1954
+ for p in peer_keys:
1955
+ d = peer_local_domain_id[p]
1956
+ assert d is not None
1957
+ if d == ota_domain_id:
1958
+ raise RuntimeError(
1959
+ f"shared.rmw.ota={ota_label} requires shared.ota_domain_id ({ota_domain_id}) to differ "
1960
+ f"from every peer_settings.<peer>.domain_id; peer '{p}' also uses {d}."
1961
+ )
1962
+ if d in seen_ids:
1963
+ raise RuntimeError(
1964
+ f"shared.rmw.ota={ota_label} requires distinct peer_settings.<peer>.domain_id across peers; "
1965
+ f"peers '{seen_ids[d]}' and '{p}' both use {d}."
1966
+ )
1967
+ seen_ids[d] = p
1968
+ seen_ips: Dict[str, str] = {}
1969
+ for p in peer_keys:
1970
+ ipk = peer_ip[p]
1971
+ if ipk in seen_ips:
1972
+ raise RuntimeError(
1973
+ f"shared.rmw.ota={ota_label} requires distinct peers.<peer>.address across peers; "
1974
+ f"peers '{seen_ips[ipk]}' and '{p}' both use {ipk!r}."
1975
+ )
1976
+ seen_ips[ipk] = p
1977
+
1978
+ def _use_domain_bridge(peer: str) -> bool:
1979
+ d = peer_local_domain_id[peer]
1980
+ return d is not None and ota_domain_id is not None and d != ota_domain_id
1981
+
1982
+ # Global "any peer uses the domain bridge" flag: domain-bridge-driven
1983
+ # topic lists (with types) are assembled once and shared across peers.
1984
+ use_domain_bridge = any(_use_domain_bridge(p) for p in peer_keys)
1985
+
1986
+ # session_dir is the on-disk directory containing the session config input YAML
1987
+ # (session-definition.yaml / session-parametrization.yaml)
1988
+ session_dir = _render_session_dir(param_dir)
1989
+
1990
+ def _build_local_rmw_items(peer: str) -> List[Tuple[str, Any]]:
1991
+ """Plugin.yaml block for local-side RMW (rmw_local + optional DDS config)."""
1992
+ side = rmw_spec.local
1993
+ # Effective impl: the stock `domain_bridge` binary needs a
1994
+ # deterministic DDS RMW. Fall back to cyclone when split-domain
1995
+ # bridging is in play on this peer but the user didn't pin a local RMW.
1996
+ effective_impl = side.impl
1997
+ if effective_impl is None and _use_domain_bridge(peer):
1998
+ effective_impl = "cyclone"
1999
+ items: List[Tuple[str, Any]] = []
2000
+ if effective_impl is not None:
2001
+ items.append(("rmw_local", effective_impl))
2002
+ if side.dds_config:
2003
+ items.append(("local_config_template", side.dds_config))
2004
+ items.append(("local_config_file", "${peer_dir}/local_dds.xml"))
2005
+ if side.impl == "fastdds" and side.dds_config == "fastdds_easy_mode.xml":
2006
+ items.append(
2007
+ (
2008
+ "local_easy_mode_ip",
2009
+ side.dds_easy_mode_ip or peer_ip[peer_keys[0]],
2010
+ )
2011
+ )
2012
+ if side.impl == "cyclone" and side.dds_spdp_interval:
2013
+ items.append(("local_spdp_interval", side.dds_spdp_interval))
2014
+ return items
2015
+
2016
+ def _build_ota_rmw_items() -> List[Tuple[str, Any]]:
2017
+ """Plugin.yaml block for OTA-side RMW + derived use_zenoh_* flags."""
2018
+ ota = rmw_spec.ota
2019
+ local = rmw_spec.local
2020
+ items: List[Tuple[str, Any]] = []
2021
+ runtime_impl = _rmw_ota_runtime_impl(ota.impl)
2022
+ if runtime_impl is not None:
2023
+ items.append(("rmw_ota", runtime_impl))
2024
+ # Native rmw_zenoh_cpp router (ZEN window): enabled if either side uses native zenoh.
2025
+ items.append(("use_zenoh_rmw", _is_native_zenoh_ota(ota.impl) or local.impl == "zenoh"))
2026
+ # zenoh_bridge_ros2dds router (Z2D window): enabled for OTA only.
2027
+ items.append(("use_zenoh_ros2dds", ota.impl == "zenoh_ros2dds"))
2028
+ ota_dds_config = ota.dds_config or _DDS_OTA_DEFAULT_CONFIG.get(ota.impl or "")
2029
+ if ota_dds_config:
2030
+ items.append(("ota_config_template", ota_dds_config))
2031
+ items.append(("ota_config_file", "${peer_dir}/ota_dds.xml"))
2032
+ if ota.impl == "fastdds" and ota_dds_config == "fastdds_easy_mode.xml":
2033
+ items.append(
2034
+ (
2035
+ "ota_easy_mode_ip",
2036
+ ota.dds_easy_mode_ip or peer_ip[peer_keys[0]],
2037
+ )
2038
+ )
2039
+ if ota.impl == "cyclone" and ota.dds_spdp_interval:
2040
+ items.append(("ota_spdp_interval", ota.dds_spdp_interval))
2041
+ return items
2042
+
2043
+ def _build_zenoh_block(
2044
+ local_peer: str,
2045
+ remote_peer: str,
2046
+ out_entries_pipes: Optional[List[Tuple[TopicEntry, Dict[str, Any]]]] = None,
2047
+ ) -> Optional[PluginBlock]:
2048
+ """Plugin.yaml `zenoh` block for native zenoh or zenoh_ros2dds. Returns
2049
+ None when no zenoh transport is configured on the OTA side.
2050
+ """
2051
+ ota = rmw_spec.ota
2052
+ if _is_native_zenoh_ota(ota.impl):
2053
+ main_peer = ota.zen_main_peer or peer_keys[0]
2054
+ main_port = ota.zen_main_port or 7447
2055
+ return PluginBlock(
2056
+ "zenoh",
2057
+ [
2058
+ ("zen_main_ip", peer_ip[main_peer]),
2059
+ ("zen_main_port", main_port),
2060
+ # Only the non-main peer opens a TCP connect endpoint.
2061
+ ("zen_connect", local_peer != main_peer),
2062
+ ],
2063
+ )
2064
+ if ota.impl == "zenoh_ros2dds":
2065
+ main_peer = ota.zen_main_peer or peer_keys[0]
2066
+ main_port = ota.zen_main_port or 7447
2067
+ transport = ota.zen_transport or "udp"
2068
+ endpoint_role = "listen" if local_peer == main_peer else "connect"
2069
+ items: List[Tuple[str, Any]] = [
2070
+ ("zen_pub_allow", f"/ota/{peer_name[local_peer]}/.*"),
2071
+ ("zen_sub_allow", f"/ota/{peer_name[remote_peer]}/.*"),
2072
+ ("zen_transport", transport),
2073
+ ("zen_mode", "router"),
2074
+ ("zen_endpoint_role", endpoint_role),
2075
+ ("zen_main_ip", peer_ip[main_peer]),
2076
+ ("zen_main_port", main_port),
2077
+ ]
2078
+ if out_entries_pipes is not None:
2079
+ qos_block = _zenoh_qos_pub_block(out_entries_pipes, publisher_peer=local_peer)
2080
+ if qos_block is not None:
2081
+ items.append(("zen_qos_pub", qos_block))
2082
+ return PluginBlock("zenoh", items)
2083
+ return None
2084
+
2085
+ # Heartbeat topics (only if enabled). If not configured, default to /heartbeat_<com-name>.
2086
+ hb_topic: Dict[str, str] = {}
2087
+ if use_heartbeat:
2088
+ for p in peer_keys:
2089
+ v = None
2090
+ try:
2091
+ v = (peer_settings_all.get(p) or {}).get("heartbeat_topic")
2092
+ except Exception:
2093
+ v = None
2094
+ if v is None or (isinstance(v, str) and not v.strip()):
2095
+ hb_topic[p] = f"/heartbeat_{peer_name[p]}"
2096
+ else:
2097
+ hb_topic[p] = str(v).strip()
2098
+
2099
+ # ---------------------------------------------------------------------
2100
+ # Topics are optional. If no directions are defined, generate minimal plugin.yaml
2101
+ # (peers + optional heartbeat/topic_monitor), and always session_specification.yaml.
2102
+ # ---------------------------------------------------------------------
2103
+ direction_key_for: Dict[Tuple[str, str], str] = {}
2104
+ for k in topics_cfg.keys():
2105
+ if not isinstance(k, str):
2106
+ raise RuntimeError("topics keys must be strings like '<src>_to_<dst>'.")
2107
+ parts = k.split("_to_")
2108
+ if len(parts) != 2:
2109
+ raise RuntimeError(
2110
+ f"topics key '{k}' must match '<src>_to_<dst>' so the generator can derive peers generically."
2111
+ )
2112
+ src, dst = parts[0], parts[1]
2113
+ if src not in peer_ip or dst not in peer_ip:
2114
+ raise RuntimeError(f"topics key '{k}' refers to unknown peer(s) '{src}'/'{dst}'. Known peers: {peer_keys}")
2115
+ if (src, dst) in direction_key_for:
2116
+ raise RuntimeError(f"Duplicate topics direction for {src}_to_{dst}.")
2117
+ direction_key_for[(src, dst)] = k
2118
+
2119
+ # Heartbeat implies bidirectional topic bridging:
2120
+ # - ensure both directions exist (even if empty), so we can generate topic lists containing heartbeat topics
2121
+ # - force in/out unless user explicitly disabled them (then error)
2122
+ if use_heartbeat:
2123
+ if shared_use_in is False or shared_use_out is False:
2124
+ raise RuntimeError(
2125
+ "shared.use_heartbeat=true requires in/out. Remove shared.use_in/use_out overrides or set both to true."
2126
+ )
2127
+ if shared_use_in is None:
2128
+ shared_use_in = True
2129
+ if shared_use_out is None:
2130
+ shared_use_out = True
2131
+
2132
+ a, b = peer_keys[0], peer_keys[1]
2133
+ direction_key_for.setdefault((a, b), f"{a}_to_{b}")
2134
+ direction_key_for.setdefault((b, a), f"{b}_to_{a}")
2135
+
2136
+ session_spec_yaml = _render_session_spec(base_plugin_path)
2137
+
2138
+ if not direction_key_for:
2139
+ if shared_use_in is True or shared_use_out is True:
2140
+ raise RuntimeError("shared.use_in/use_out cannot be true without defining any topics directions.")
2141
+
2142
+ generated: List[Tuple[str, str]] = []
2143
+ if session_definition_yaml is not None:
2144
+ generated.append((os.path.join(param_dir, "session-definition.yaml"), session_definition_yaml))
2145
+ for local in peer_keys:
2146
+ remote = peer_keys[0] if local == peer_keys[1] else peer_keys[1]
2147
+ blocks: List[PluginBlock] = []
2148
+ blocks.append(
2149
+ PluginBlock(
2150
+ "paths",
2151
+ [
2152
+ ("session_dir", session_dir),
2153
+ ("peer_dir", f"${{session_dir}}/{local}"),
2154
+ ],
2155
+ )
2156
+ )
2157
+ blocks.append(
2158
+ PluginBlock(
2159
+ "peers",
2160
+ [
2161
+ ("ip_local", peer_ip[local]),
2162
+ ("local_name", peer_name[local]),
2163
+ ("ip_remote", peer_ip[remote]),
2164
+ ("remote_name", peer_name[remote]),
2165
+ ],
2166
+ )
2167
+ )
2168
+ local_items = _build_local_rmw_items(local)
2169
+ if local_items:
2170
+ blocks.append(PluginBlock("rmw_local", local_items))
2171
+ zen_block = _build_zenoh_block(local, remote)
2172
+ if zen_block is not None:
2173
+ blocks.append(zen_block)
2174
+ if _use_domain_bridge(local):
2175
+ ldid = peer_local_domain_id[local]
2176
+ assert ldid is not None and ota_domain_id is not None
2177
+ blocks.append(
2178
+ PluginBlock(
2179
+ "domain_bridge",
2180
+ [
2181
+ ("use_domain_bridge", True),
2182
+ ("local_domain_id", ldid),
2183
+ ("ota_domain_id", ota_domain_id),
2184
+ ],
2185
+ )
2186
+ )
2187
+ elif peer_local_domain_id[local] is not None:
2188
+ # Still export ROS_DOMAIN_ID for the peer's local graph even
2189
+ # if no domain bridging is active.
2190
+ blocks.append(PluginBlock("domain", [("local_domain_id", peer_local_domain_id[local])]))
2191
+ blocks.append(PluginBlock("rmw", _build_ota_rmw_items()))
2192
+ if use_topic_monitor:
2193
+ blocks.append(PluginBlock("topic_monitor", [("topic_monitor", True)]))
2194
+ if use_heartbeat:
2195
+ blocks.append(
2196
+ PluginBlock(
2197
+ "heartbeat",
2198
+ [
2199
+ ("heartbeat", True),
2200
+ ("heartbeat_local_topic", hb_topic[local]),
2201
+ ("heartbeat_remote_topic", hb_topic[remote]),
2202
+ *_heartbeat_monitor_overrides((shared.get("heartbeat") or {}).get("expect")),
2203
+ ],
2204
+ )
2205
+ )
2206
+ if use_status_overview:
2207
+ generated.append(
2208
+ (
2209
+ os.path.join(param_dir, local, "pipeline_spec.yaml"),
2210
+ _build_status_pipeline_spec(
2211
+ local=local,
2212
+ remote=remote,
2213
+ peer_name=peer_name,
2214
+ out_entries=[],
2215
+ out_pipes=[],
2216
+ in_entries=[],
2217
+ in_pipes=[],
2218
+ use_target_prefix=False,
2219
+ remote_uses_target_prefix=False,
2220
+ native_have_source_prefix=False,
2221
+ inbound_keep_source_prefix=False,
2222
+ local_domain_id=peer_local_domain_id[local],
2223
+ ota_domain_id=ota_domain_id,
2224
+ uses_domain_bridge=_use_domain_bridge(local),
2225
+ hb_topic=hb_topic,
2226
+ use_heartbeat=use_heartbeat,
2227
+ heartbeat_expect=(shared.get("heartbeat") or {}).get("expect"),
2228
+ out_enabled=False,
2229
+ in_enabled=False,
2230
+ final_topic_type=_final_topic_type,
2231
+ ),
2232
+ )
2233
+ )
2234
+ blocks.append(
2235
+ PluginBlock(
2236
+ "status_overview",
2237
+ [
2238
+ ("status_overview", True),
2239
+ ("status_spec_file", "${peer_dir}/pipeline_spec.yaml"),
2240
+ ]
2241
+ + (
2242
+ [
2243
+ ("status_write_interval_s", link_trace_interval_s),
2244
+ ("link_trace", True),
2245
+ ("link_trace_interval_s", link_trace_interval_s),
2246
+ ("link_trace_modem_command", link_trace_modem_command),
2247
+ ("link_trace_modem_timeout_s", link_trace_modem_timeout_s),
2248
+ ]
2249
+ if link_trace_enabled
2250
+ else []
2251
+ ),
2252
+ )
2253
+ )
2254
+ generated.append((os.path.join(param_dir, local, "plugin.yaml"), _render_plugin_yaml(blocks)))
2255
+ generated.append((os.path.join(param_dir, local, "session_specification.yaml"), session_spec_yaml))
2256
+ _write_generated_files(generated, force=force, rewrite_formatting=rewrite_formatting)
2257
+ return
2258
+
2259
+ # Topics exist. From here on we generate direction topic lists + per-peer plugin.yaml.
2260
+ # (Directions may be one-way; that's OK.)
2261
+
2262
+ suffixes = (shared.get("processing_suffixes", {}) or {}) if isinstance(shared, dict) else {}
2263
+ if not isinstance(suffixes, dict):
2264
+ raise RuntimeError(f"shared.processing_suffixes must be a mapping if provided, got {type(suffixes)}")
2265
+ restamped_suffix = str(suffixes.get("restamped", "/restamped"))
2266
+ latched_suffix = str(suffixes.get("latched", "/latched"))
2267
+ # Latch keepalive (OPT-IN, default OFF). The latch is on-change by design --
2268
+ # that is the bandwidth feature, so we do NOT re-stream static values by
2269
+ # default. This is only an escape hatch for an OTA path that cannot carry the
2270
+ # held value via transient_local: set shared.latch_keepalive_hz > 0 to re-assert
2271
+ # each held value at that rate. Prefer fixing transient_local delivery instead.
2272
+ latch_keepalive_hz = shared.get("latch_keepalive_hz", 0.0) if isinstance(shared, dict) else 0.0
2273
+ globalframe_suffix = str(suffixes.get("framebridge_global", "/globalframe"))
2274
+ ota_suffix = str(suffixes.get("ota_stamped", "/ota_stamped"))
2275
+
2276
+ comp_cfg = (shared.get("compression", {}) or {}) if isinstance(shared, dict) else {}
2277
+ if not isinstance(comp_cfg, dict):
2278
+ raise RuntimeError(f"shared.compression must be a mapping if provided, got {type(comp_cfg)}")
2279
+ comp_algorithm = str(comp_cfg.get("algorithm", "bz2") or "bz2").strip()
2280
+ if not comp_algorithm:
2281
+ comp_algorithm = "bz2"
2282
+ if comp_algorithm not in ALLOWED_COMPRESSION_ALGORITHMS:
2283
+ raise RuntimeError(
2284
+ "shared.compression.algorithm must be one of "
2285
+ f"{sorted(ALLOWED_COMPRESSION_ALGORITHMS)}, got '{comp_algorithm}'."
2286
+ )
2287
+ comp_alg_suffix = "/" + comp_algorithm
2288
+
2289
+ # Compression behavior flag: only the default mode is implemented currently.
2290
+ # If false, we'd need to keep the algorithm suffix and introduce a new decompression suffix, which is not supported.
2291
+ if not bool(comp_cfg.get("remove_algorithm_suffix_on_decompression", True)):
2292
+ raise RuntimeError(
2293
+ "shared.compression.remove_algorithm_suffix_on_decompression=false is not implemented yet. "
2294
+ "Set it to true (default)."
2295
+ )
2296
+
2297
+ # Heartbeat list position per direction (prepend/append).
2298
+ # Default is "prepend" for all directions unless configured in the config.
2299
+ hb_pos_cfg = (shared.get("heartbeat_position", {}) or {}) if isinstance(shared, dict) else {}
2300
+ if hb_pos_cfg and not isinstance(hb_pos_cfg, dict):
2301
+ raise RuntimeError("shared.heartbeat_position must be a mapping: { '<src>_to_<dst>': 'prepend'|'append' }")
2302
+
2303
+ def _hb_position(direction_key: str) -> str:
2304
+ v = hb_pos_cfg.get(direction_key, "prepend")
2305
+ if not isinstance(v, str):
2306
+ raise RuntimeError(f"shared.heartbeat_position['{direction_key}'] must be a string.")
2307
+ vv = v.strip().lower()
2308
+ if vv not in ("prepend", "append"):
2309
+ raise RuntimeError(
2310
+ f"shared.heartbeat_position['{direction_key}'] must be 'prepend' or 'append', got '{v}'."
2311
+ )
2312
+ return vv
2313
+
2314
+ # Precompute entries/pipes per direction
2315
+ dir_entries: Dict[Tuple[str, str], List[TopicEntry]] = {}
2316
+ dir_pipes: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
2317
+ for (src, dst), direction_key in direction_key_for.items():
2318
+ entries = _topic_entries(cfg, direction_key)
2319
+ pipes = [
2320
+ _compute_pipeline(
2321
+ e,
2322
+ vars_map,
2323
+ restamped_suffix,
2324
+ latched_suffix,
2325
+ globalframe_suffix,
2326
+ comp_alg_suffix,
2327
+ ota_suffix,
2328
+ )
2329
+ for e in entries
2330
+ ]
2331
+ dir_entries[(src, dst)] = entries
2332
+ dir_pipes[(src, dst)] = pipes
2333
+
2334
+ # ---------------------------
2335
+ # Zenoh helpers (optional)
2336
+ # ---------------------------
2337
+
2338
+ def _zenoh_key_expr_for_topic(publisher_peer: str, entry: TopicEntry, pipe: Dict[str, Any]) -> str:
2339
+ base = str(entry.base or "").strip()
2340
+ if not base.startswith("/"):
2341
+ # Be permissive: allow topics without leading '/', but normalize to match key expr conventions.
2342
+ base = "/" + base
2343
+ path = base.lstrip("/")
2344
+ if not path:
2345
+ raise RuntimeError("zen_qos topic cannot be empty.")
2346
+ parts = [p for p in path.split("/") if p]
2347
+ if not parts:
2348
+ raise RuntimeError("zen_qos topic cannot be empty.")
2349
+
2350
+ suffixes: List[str] = []
2351
+ if pipe.get("restamp"):
2352
+ suffixes.append("restamped")
2353
+ if pipe.get("latch"):
2354
+ suffixes.append("latched")
2355
+ if pipe.get("throttle") is not None:
2356
+ suffixes.append(f"max{int(pipe['throttle'])}hz")
2357
+ if pipe.get("pixel") is not None:
2358
+ suffixes.append(str(pipe["pixel"]))
2359
+ if pipe.get("framebridge"):
2360
+ suffixes.append("globalframe")
2361
+ if pipe.get("compress"):
2362
+ suffixes.append("compressed")
2363
+ if pipe.get("ota_wrap"):
2364
+ suffixes.append("ota_stamped")
2365
+ if pipe.get("transport") is not None:
2366
+ ts = pipe.get("transport")
2367
+ assert isinstance(ts, TransportSpec)
2368
+ suffixes.append(ts.type)
2369
+
2370
+ last = parts[-1]
2371
+ if suffixes:
2372
+ last = last + "_" + "_".join(suffixes)
2373
+ parts[-1] = last
2374
+
2375
+ key_expr_path = "/".join(parts)
2376
+ return f"ota/{peer_name[publisher_peer]}/{key_expr_path}"
2377
+
2378
+ def _zenoh_qos_pub_block(
2379
+ entries_and_pipes: List[Tuple[TopicEntry, Dict[str, Any]]], publisher_peer: str
2380
+ ) -> Optional[YamlBlockScalar]:
2381
+ items: List[Tuple[str, Dict[str, Any]]] = []
2382
+ for e, p in entries_and_pipes:
2383
+ if not e.zen_qos:
2384
+ continue
2385
+ if not isinstance(e.zen_qos, dict):
2386
+ raise RuntimeError(f"zen_qos for topic '{e.base}' must be a mapping.")
2387
+ zq = dict(e.zen_qos)
2388
+ allowed = {"priority", "express"}
2389
+ extra = sorted([k for k in zq.keys() if k not in allowed])
2390
+ if extra:
2391
+ raise RuntimeError(
2392
+ f"zen_qos for topic '{e.base}' has unsupported keys {extra}. Allowed: {sorted(allowed)}"
2393
+ )
2394
+ priority_raw = zq.get("priority")
2395
+ if priority_raw is None:
2396
+ raise RuntimeError("zen_qos.priority is required when zen_qos is provided.")
2397
+ priority = str(priority_raw).strip()
2398
+ if not priority:
2399
+ raise RuntimeError("zen_qos.priority must be a non-empty string when provided.")
2400
+ express = zq.get("express", None)
2401
+ if express is not None and not isinstance(express, bool):
2402
+ raise RuntimeError(f"zen_qos.express for topic '{e.base}' must be boolean if provided.")
2403
+
2404
+ key_expr = _zenoh_key_expr_for_topic(publisher_peer, e, p)
2405
+ cfg: Dict[str, Any] = {"priority": priority}
2406
+ if express:
2407
+ cfg["express"] = True
2408
+ items.append((key_expr, cfg))
2409
+
2410
+ if not items:
2411
+ return None
2412
+
2413
+ # Stable ordering (example expects costmap before heartbeat)
2414
+ items.sort(key=lambda x: x[0])
2415
+
2416
+ rendered_entries: List[str] = []
2417
+ for key_expr, cfg in items:
2418
+ parts_cfg = [f'priority: "{cfg["priority"]}"']
2419
+ if cfg.get("express"):
2420
+ parts_cfg.append("express: true")
2421
+ cfg_inline = "{ " + ", ".join(parts_cfg) + " }"
2422
+ rendered_entries.append(f'{{\n key_exprs: [ "{key_expr}" ],\n config: {cfg_inline}\n}}')
2423
+
2424
+ # Join as a comma-separated list of objects (as expected by zenoh.json5.template)
2425
+ content = ",\n".join(rendered_entries)
2426
+ return YamlBlockScalar(header="|1", content=content)
2427
+
2428
+ # Determine whether we should generate qos.yaml:
2429
+ # - if shared.qos is explicitly provided, OR
2430
+ # - if any topic specifies a qos override
2431
+ qos_overrides: Dict[str, Dict[str, Any]] = {}
2432
+ for entries in dir_entries.values():
2433
+ for e in entries:
2434
+ if not e.qos:
2435
+ continue
2436
+ q = dict(e.qos)
2437
+ qos_overrides[e.base] = q
2438
+ write_qos = ("qos" in shared and shared.get("qos") is not None) or bool(qos_overrides)
2439
+
2440
+ def topic_list(entries: List[TopicEntry], pipes: List[Dict[str, Any]], hb: str, pos: str) -> List[str]:
2441
+ lines = []
2442
+ for e, p in zip(entries, pipes):
2443
+ if e.base == hb:
2444
+ continue
2445
+ lines.append(p["final"])
2446
+ lines = _dedup_keep_order(lines)
2447
+ if use_heartbeat and hb:
2448
+ if pos == "prepend":
2449
+ return [hb] + [x for x in lines if x != hb]
2450
+ return [x for x in lines if x != hb] + [hb]
2451
+ return lines
2452
+
2453
+ def topic_list_with_types(
2454
+ entries: List[TopicEntry],
2455
+ pipes: List[Dict[str, Any]],
2456
+ hb: str,
2457
+ pos: str,
2458
+ ) -> List[Tuple[str, str]]:
2459
+ lines: List[Tuple[str, str]] = []
2460
+ for e, p in zip(entries, pipes):
2461
+ if e.base == hb:
2462
+ continue
2463
+ lines.append((p["final"], _final_topic_type(e, p)))
2464
+ lines = _dedup_topic_type_items(lines)
2465
+ if use_heartbeat and hb:
2466
+ hb_item = (hb, HEARTBEAT_MSG_TYPE)
2467
+ if pos == "prepend":
2468
+ return [hb_item] + [x for x in lines if x[0] != hb]
2469
+ return [x for x in lines if x[0] != hb] + [hb_item]
2470
+ return lines
2471
+
2472
+ # ---------------------------
2473
+ # Generate topic lists for each direction (written to <src>_to_<dst>_topics.txt)
2474
+ # ---------------------------
2475
+ dir_topic_list: Dict[Tuple[str, str], List[str]] = {}
2476
+ dir_topic_list_with_types: Dict[Tuple[str, str], List[Tuple[str, str]]] = {}
2477
+ # Effective durability of each forwarded topic (by its final name), so the
2478
+ # domain_bridge can preserve transient_local instead of auto-detecting it
2479
+ # (which races the publisher and defaults to volatile).
2480
+ dir_durability: Dict[Tuple[str, str], Dict[str, Optional[str]]] = {}
2481
+ for (src, dst), direction_key in direction_key_for.items():
2482
+ pos = _hb_position(direction_key)
2483
+ hb = hb_topic.get(src, "")
2484
+ dir_topic_list[(src, dst)] = topic_list(
2485
+ dir_entries[(src, dst)],
2486
+ dir_pipes[(src, dst)],
2487
+ hb,
2488
+ pos,
2489
+ )
2490
+ if use_domain_bridge:
2491
+ dir_topic_list_with_types[(src, dst)] = topic_list_with_types(
2492
+ dir_entries[(src, dst)],
2493
+ dir_pipes[(src, dst)],
2494
+ hb,
2495
+ pos,
2496
+ )
2497
+ dir_durability[(src, dst)] = {
2498
+ p["final"]: ((e.qos or {}).get("durability") if isinstance(e.qos, dict) else None)
2499
+ for e, p in zip(dir_entries[(src, dst)], dir_pipes[(src, dst)])
2500
+ }
2501
+
2502
+ # ---------------------------
2503
+ # Per-peer plugin.yaml generation
2504
+ # ---------------------------
2505
+ per_peer_plugin: Dict[str, str] = {}
2506
+ per_peer_comp_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2507
+ per_peer_deco_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2508
+ per_peer_otaw_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2509
+ per_peer_otau_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2510
+ per_peer_domain_bridge_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2511
+ per_peer_status_spec_yaml: Dict[str, Optional[str]] = {p: None for p in peer_keys}
2512
+
2513
+ def _prefix_with_source_name_if_needed(target_peer: str, source_peer: str, topic: str) -> str:
2514
+ ps = peer_settings_all.get(target_peer, {}) or {}
2515
+ keep_source = (
2516
+ bool((ps.get("inbound", {}) or {}).get("keep_source_prefix", False)) if isinstance(ps, dict) else False
2517
+ )
2518
+ prefix = f"/{peer_name[source_peer]}" if keep_source else ""
2519
+ return f"{prefix}{topic}"
2520
+
2521
+ def _com_topic(direction: str, source_name: str, forward_topic: str) -> str:
2522
+ return f"/com/{direction}/{source_name}/{forward_topic.lstrip('/')}"
2523
+
2524
+ for local in peer_keys:
2525
+ remote = peer_keys[0] if local == peer_keys[1] else peer_keys[1]
2526
+ out_dir_key = direction_key_for.get((local, remote))
2527
+ in_dir_key = direction_key_for.get((remote, local))
2528
+
2529
+ out_entries = dir_entries.get((local, remote), [])
2530
+ out_pipes = dir_pipes.get((local, remote), [])
2531
+ in_entries = dir_entries.get((remote, local), [])
2532
+ in_pipes = dir_pipes.get((remote, local), [])
2533
+
2534
+ # Local outbound derived lists
2535
+ rs_topics = _dedup_keep_order([e.base for e, p in zip(out_entries, out_pipes) if p["restamp"]])
2536
+ lat_topics = _dedup_keep_order([p["lat_in"] for p in out_pipes if p["lat_in"]])
2537
+
2538
+ # Trickle: local-only periodic republisher (receiver side only).
2539
+ # Subscribes to inbound final topics (with source-name prefix when applicable)
2540
+ # and republishes at a fixed rate for visualization software.
2541
+ trickle_in_items = [
2542
+ (_prefix_with_source_name_if_needed(local, remote, p["final"]), p["trickle_hz"])
2543
+ for p in in_pipes
2544
+ if p["trickle_hz"] is not None
2545
+ ]
2546
+ trickle_all_topics = _dedup_keep_order([t for t, _ in trickle_in_items])
2547
+ # Use the first configured rate (all trickle topics share a single timer).
2548
+ trickle_hz = None
2549
+ for _, hz in trickle_in_items:
2550
+ if hz is not None:
2551
+ trickle_hz = hz
2552
+ break
2553
+
2554
+ fb_l2g = _dedup_keep_order([p["fb_l2g_in"] for p in out_pipes if p["fb_l2g_in"]])
2555
+ # Local inbound derived lists for framebridge (global_to_local topics are specified on inbound direction)
2556
+ fb_g2l = _dedup_keep_order([p["fb_g2l_base"] for p in in_pipes if p["fb_g2l_base"]])
2557
+
2558
+ drp_items = [(p["drp_in"], p["drop_count"], p["window_size"]) for p in out_pipes if p["drp_in"]]
2559
+ thr_items = [(p["thr_in"], p["throttle"]) for p in out_pipes if p["thr_in"]]
2560
+ ipx_items = [(p["ipx_in"], p["pixel"]) for p in out_pipes if p["ipx_in"]]
2561
+ it_items = [(p["it_in"], p["transport"]) for p in out_pipes if p["it_in"]]
2562
+ irt_items_local = [(p["irt_in"], p["transport"]) for p in out_pipes if p["irt_in"]]
2563
+
2564
+ # Compression happens on the sender side for topics marked with processing.compress
2565
+ comp_topics = _dedup_keep_order([p["comp_in"] for p in out_pipes if p["comp_in"]])
2566
+ if comp_topics:
2567
+ per_peer_comp_yaml[local] = _render_regex_list("compression", comp_topics)
2568
+
2569
+ ota_topics = _dedup_keep_order([p["ota_in"] for p in out_pipes if p["ota_in"]])
2570
+ if ota_topics:
2571
+ per_peer_otaw_yaml[local] = _render_regex_list("ota_wrapper", ota_topics)
2572
+
2573
+ # Decompression happens on the receiver side for inbound topics that were compressed by the remote.
2574
+ inbound_comp_topics = _dedup_keep_order([p["comp_in"] for p in in_pipes if p["comp_in"]])
2575
+ if inbound_comp_topics:
2576
+ deco_topics = [
2577
+ _prefix_with_source_name_if_needed(local, remote, t) + comp_alg_suffix for t in inbound_comp_topics
2578
+ ]
2579
+ per_peer_deco_yaml[local] = _render_regex_list("decompression", deco_topics)
2580
+
2581
+ inbound_ota_topics = _dedup_keep_order([p["ota_in"] for p in in_pipes if p["ota_in"]])
2582
+ if inbound_ota_topics:
2583
+ otau_topics = [
2584
+ _prefix_with_source_name_if_needed(local, remote, t) + ota_suffix for t in inbound_ota_topics
2585
+ ]
2586
+ per_peer_otau_yaml[local] = _render_regex_list("ota_unwrapper", otau_topics)
2587
+
2588
+ # Normalization happens on receiver side for topics with normalize_on_target.
2589
+ nor_items = []
2590
+ for e, p in zip(in_entries, in_pipes):
2591
+ if not p["normalize"]:
2592
+ continue
2593
+ base = e.base.lstrip("/")
2594
+ suffix_source = p["comp_in"] if p["comp_in"] else p["final"]
2595
+ suffix = suffix_source[len(e.base) :]
2596
+ nor_items.append((base, suffix))
2597
+
2598
+ # Hard limits from session_plugin_base.yaml.
2599
+ def _assert_max(label: str, items: List[Any], limit: int) -> None:
2600
+ if len(items) > limit:
2601
+ raise RuntimeError(
2602
+ f"peer '{local}': too many '{label}' entries ({len(items)}). "
2603
+ f"The base session plugin supports at most {limit}."
2604
+ )
2605
+
2606
+ _assert_max("drp", drp_items, 8)
2607
+ _assert_max("thr", thr_items, 4)
2608
+ _assert_max("ipx", ipx_items, 4)
2609
+ _assert_max("it", it_items, 4)
2610
+ _assert_max("nor", nor_items, 4)
2611
+
2612
+ # Remote-side reverse transport is configured (if requested via local_republish) on the receiver, too.
2613
+ irt_items_remote = []
2614
+ for e, p in zip(in_entries, in_pipes):
2615
+ if not p["irt_in"]:
2616
+ continue
2617
+ tspec = p["transport"]
2618
+ assert tspec is not None
2619
+ topic = _prefix_with_source_name_if_needed(local, remote, p["irt_in"])
2620
+ irt_items_remote.append((topic, tspec.type))
2621
+
2622
+ # Build plugin.yaml blocks
2623
+ blocks: List[PluginBlock] = []
2624
+ blocks.append(
2625
+ PluginBlock(
2626
+ "paths",
2627
+ [
2628
+ ("session_dir", session_dir),
2629
+ ("peer_dir", f"${{session_dir}}/{local}"),
2630
+ ],
2631
+ )
2632
+ )
2633
+ blocks.append(
2634
+ PluginBlock(
2635
+ "peers",
2636
+ [
2637
+ ("ip_local", peer_ip[local]),
2638
+ ("local_name", peer_name[local]),
2639
+ ("ip_remote", peer_ip[remote]),
2640
+ ("remote_name", peer_name[remote]),
2641
+ ],
2642
+ )
2643
+ )
2644
+ local_items = _build_local_rmw_items(local)
2645
+ if local_items:
2646
+ blocks.append(PluginBlock("rmw_local", local_items))
2647
+ if write_qos:
2648
+ blocks.append(PluginBlock("qos", [("qos_config_file", "${session_dir}/qos.yaml")]))
2649
+
2650
+ ps_local = peer_settings_all.get(local, {}) or {}
2651
+ ps_local = ps_local if isinstance(ps_local, dict) else {}
2652
+ inbound_cfg = (ps_local.get("inbound", {}) or {}) if isinstance(ps_local, dict) else {}
2653
+ outbound_cfg = (ps_local.get("outbound", {}) or {}) if isinstance(ps_local, dict) else {}
2654
+ outbound_source_cfg = (outbound_cfg.get("source_prefix", {}) or {}) if isinstance(outbound_cfg, dict) else {}
2655
+ outbound_target_cfg = (outbound_cfg.get("target_prefix", {}) or {}) if isinstance(outbound_cfg, dict) else {}
2656
+
2657
+ # Remote target-prefix config (used for inbound targeting + topic monitor)
2658
+ ps_remote = peer_settings_all.get(remote, {}) or {}
2659
+ ps_remote = ps_remote if isinstance(ps_remote, dict) else {}
2660
+ remote_outbound = (ps_remote.get("outbound", {}) or {}) if isinstance(ps_remote, dict) else {}
2661
+ remote_outbound_target = (
2662
+ (remote_outbound.get("target_prefix", {}) or {}) if isinstance(remote_outbound, dict) else {}
2663
+ )
2664
+ remote_uses_target_prefix = bool(remote_outbound_target.get("use_target_prefix", False))
2665
+
2666
+ # ---------------------------
2667
+ # Validate / normalize source + target prefix settings
2668
+ # ---------------------------
2669
+
2670
+ # Source prefix:
2671
+ use_source_prefix = outbound_source_cfg.get("use_source_prefix", True)
2672
+ if not isinstance(use_source_prefix, bool):
2673
+ raise RuntimeError(f"peer_settings.{local}.outbound.source_prefix.use_source_prefix must be boolean.")
2674
+ if "use_source_prefix" in outbound_source_cfg and not use_source_prefix:
2675
+ raise RuntimeError(
2676
+ f"peer_settings.{local}.outbound.source_prefix.use_source_prefix=false is not implemented yet."
2677
+ )
2678
+ native_have_source_prefix = outbound_source_cfg.get("native_have_source_prefix", False)
2679
+ if not isinstance(native_have_source_prefix, bool):
2680
+ raise RuntimeError(
2681
+ f"peer_settings.{local}.outbound.source_prefix.native_have_source_prefix must be boolean."
2682
+ )
2683
+
2684
+ # Target prefix:
2685
+ # - native_have_outgoing_target_prefix defaults to use_target_prefix
2686
+ # - any mismatch is not supported by the current plugin.yaml semantics
2687
+ use_target_prefix = outbound_target_cfg.get("use_target_prefix", False)
2688
+ if not isinstance(use_target_prefix, bool):
2689
+ raise RuntimeError(f"peer_settings.{local}.outbound.target_prefix.use_target_prefix must be boolean.")
2690
+ native_have_outgoing_target_prefix = outbound_target_cfg.get(
2691
+ "native_have_outgoing_target_prefix", use_target_prefix
2692
+ )
2693
+ if not isinstance(native_have_outgoing_target_prefix, bool):
2694
+ raise RuntimeError(
2695
+ f"peer_settings.{local}.outbound.target_prefix.native_have_outgoing_target_prefix must be boolean."
2696
+ )
2697
+ if native_have_outgoing_target_prefix != use_target_prefix:
2698
+ raise RuntimeError(
2699
+ f"peer_settings.{local}.outbound.target_prefix has unsupported combination: "
2700
+ f"native_have_outgoing_target_prefix={native_have_outgoing_target_prefix} but use_target_prefix={use_target_prefix}. "
2701
+ "Currently they must be equal (both true or both false)."
2702
+ )
2703
+
2704
+ # Inbound keep_target_prefix validation:
2705
+ if not isinstance(inbound_cfg, dict):
2706
+ raise RuntimeError(f"peer_settings.{local}.inbound must be a mapping.")
2707
+ keep_target_prefix_specified = "keep_target_prefix" in inbound_cfg
2708
+ keep_target_prefix = bool(inbound_cfg.get("keep_target_prefix", False))
2709
+ if keep_target_prefix_specified and not remote_uses_target_prefix:
2710
+ raise RuntimeError(
2711
+ f"peer_settings.{local}.inbound.keep_target_prefix is specified but peer '{remote}' "
2712
+ "does not use target prefixes on outbound. Remove keep_target_prefix from the config."
2713
+ )
2714
+ if remote_uses_target_prefix and keep_target_prefix:
2715
+ raise RuntimeError(
2716
+ f"peer_settings.{local}.inbound.keep_target_prefix=true is not implemented yet. "
2717
+ "Set it to false (strip target prefix)."
2718
+ )
2719
+ local_explicitly_targeted_inbound = remote_uses_target_prefix and (not keep_target_prefix)
2720
+
2721
+ # IN: only if inbound direction exists (or explicitly requested, in which case error if missing)
2722
+ in_list = dir_topic_list.get((remote, local), []) if in_dir_key else []
2723
+ in_list_with_types = (
2724
+ dir_topic_list_with_types.get((remote, local), []) if use_domain_bridge and in_dir_key else []
2725
+ )
2726
+ in_enabled = bool(shared_use_in) if shared_use_in is not None else (len(in_list) > 0)
2727
+ if in_enabled and not in_dir_key:
2728
+ raise RuntimeError(f"shared.use_in=true but no inbound topics direction '{remote}_to_{local}' is defined.")
2729
+ if in_dir_key:
2730
+ blocks.append(
2731
+ PluginBlock(
2732
+ "in",
2733
+ (
2734
+ [("in", in_enabled), ("topic_list_paths_inbound", f"${{session_dir}}/{in_dir_key}_topics.txt")]
2735
+ + ([("local_explicitly_targeted_inbound", True)] if local_explicitly_targeted_inbound else [])
2736
+ + (
2737
+ [("app_keep_source_name_inbound", True)]
2738
+ if bool(inbound_cfg.get("keep_source_prefix", False))
2739
+ else []
2740
+ )
2741
+ ),
2742
+ )
2743
+ )
2744
+
2745
+ # OUT: only if outbound direction exists (or explicitly requested, in which case error if missing)
2746
+ out_list = dir_topic_list.get((local, remote), []) if out_dir_key else []
2747
+ out_list_with_types = (
2748
+ dir_topic_list_with_types.get((local, remote), []) if use_domain_bridge and out_dir_key else []
2749
+ )
2750
+ out_enabled = bool(shared_use_out) if shared_use_out is not None else (len(out_list) > 0)
2751
+ if out_enabled and not out_dir_key:
2752
+ raise RuntimeError(
2753
+ f"shared.use_out=true but no outbound topics direction '{local}_to_{remote}' is defined."
2754
+ )
2755
+ if out_dir_key:
2756
+ out_items: List[Tuple[str, Any]] = [
2757
+ ("out", out_enabled),
2758
+ ("topic_list_paths_outbound", f"${{session_dir}}/{out_dir_key}_topics.txt"),
2759
+ ]
2760
+ if use_target_prefix:
2761
+ out_items.append(("remote_explicitly_targeted_name_outbound", peer_name[remote]))
2762
+ if native_have_source_prefix:
2763
+ out_items.append(("app_has_source_name_outbound", True))
2764
+ blocks.append(PluginBlock("out", out_items))
2765
+
2766
+ if _use_domain_bridge(local):
2767
+ ldid = peer_local_domain_id[local]
2768
+ assert ldid is not None and ota_domain_id is not None
2769
+
2770
+ def _db_entry(msg_type: str, from_domain: int, to_domain: int, durability: Optional[str]) -> Dict[str, Any]:
2771
+ entry: Dict[str, Any] = {
2772
+ "type": msg_type,
2773
+ "from_domain": from_domain,
2774
+ "to_domain": to_domain,
2775
+ }
2776
+ # The stock domain_bridge auto-detects QoS from the live publisher
2777
+ # and races it, defaulting to volatile -- which drops a transient_local
2778
+ # held value (latched/static one-shots) and mismatches the
2779
+ # transient_local OTA subscriber. Pin transient_local so the bridge
2780
+ # carries the held value across domains. Volatile streams are left to
2781
+ # the default (best_effort), which crosses fine.
2782
+ if durability == "transient_local":
2783
+ entry["qos"] = {"durability": "transient_local", "reliability": "reliable"}
2784
+ return entry
2785
+
2786
+ out_durability = dir_durability.get((local, remote), {})
2787
+ in_durability = dir_durability.get((remote, local), {})
2788
+ domain_bridge_topics: Dict[str, Dict[str, Any]] = {}
2789
+ if out_enabled:
2790
+ for topic_name, msg_type in out_list_with_types:
2791
+ forward_topic = f"/to_{peer_name[remote]}{topic_name}" if use_target_prefix else topic_name
2792
+ domain_bridge_topics[_com_topic("out", peer_name[local], forward_topic)] = _db_entry(
2793
+ msg_type, ldid, ota_domain_id, out_durability.get(topic_name)
2794
+ )
2795
+ if in_enabled:
2796
+ for topic_name, msg_type in in_list_with_types:
2797
+ forward_topic = f"/to_{peer_name[local]}{topic_name}" if remote_uses_target_prefix else topic_name
2798
+ domain_bridge_topics[_com_topic("in", peer_name[remote], forward_topic)] = _db_entry(
2799
+ msg_type, ota_domain_id, ldid, in_durability.get(topic_name)
2800
+ )
2801
+
2802
+ if domain_bridge_topics:
2803
+ per_peer_domain_bridge_yaml[local] = _render_domain_bridge_yaml(
2804
+ f"{peer_name[local]}_com_domain_bridge",
2805
+ domain_bridge_topics,
2806
+ )
2807
+ blocks.append(
2808
+ PluginBlock(
2809
+ "domain_bridge",
2810
+ [
2811
+ ("use_domain_bridge", True),
2812
+ ("local_domain_id", ldid),
2813
+ ("ota_domain_id", ota_domain_id),
2814
+ ("domain_bridge_config_file", "${peer_dir}/domain_bridge.yaml"),
2815
+ ],
2816
+ )
2817
+ )
2818
+ elif peer_local_domain_id[local] is not None:
2819
+ # No domain bridging but the peer still has a pinned ROS_DOMAIN_ID.
2820
+ blocks.append(PluginBlock("domain", [("local_domain_id", peer_local_domain_id[local])]))
2821
+
2822
+ zen_block = _build_zenoh_block(
2823
+ local,
2824
+ remote,
2825
+ out_entries_pipes=list(zip(out_entries, out_pipes)),
2826
+ )
2827
+ if zen_block is not None:
2828
+ blocks.append(zen_block)
2829
+
2830
+ blocks.append(PluginBlock("rmw", _build_ota_rmw_items()))
2831
+
2832
+ # Topic monitor: set to_adressant only when target-prefix addressing is in play
2833
+ tm_in_to = peer_name[local] if bool(remote_outbound_target.get("use_target_prefix", False)) else None
2834
+ tm_out_to = peer_name[remote] if use_target_prefix else None
2835
+ if use_topic_monitor:
2836
+ blocks.append(
2837
+ PluginBlock(
2838
+ "topic_monitor",
2839
+ (
2840
+ [("topic_monitor", True)]
2841
+ + ([("tm_in_to_adressant", tm_in_to)] if tm_in_to is not None else [])
2842
+ + ([("tm_out_to_adressant", tm_out_to)] if tm_out_to is not None else [])
2843
+ ),
2844
+ )
2845
+ )
2846
+
2847
+ if use_status_overview:
2848
+ per_peer_status_spec_yaml[local] = _build_status_pipeline_spec(
2849
+ local=local,
2850
+ remote=remote,
2851
+ peer_name=peer_name,
2852
+ out_entries=out_entries,
2853
+ out_pipes=out_pipes,
2854
+ in_entries=in_entries,
2855
+ in_pipes=in_pipes,
2856
+ use_target_prefix=use_target_prefix,
2857
+ remote_uses_target_prefix=remote_uses_target_prefix,
2858
+ native_have_source_prefix=native_have_source_prefix,
2859
+ inbound_keep_source_prefix=bool(inbound_cfg.get("keep_source_prefix", False)),
2860
+ local_domain_id=peer_local_domain_id[local],
2861
+ ota_domain_id=ota_domain_id,
2862
+ uses_domain_bridge=_use_domain_bridge(local),
2863
+ hb_topic=hb_topic,
2864
+ use_heartbeat=use_heartbeat,
2865
+ heartbeat_expect=(shared.get("heartbeat") or {}).get("expect"),
2866
+ out_enabled=out_enabled,
2867
+ in_enabled=in_enabled,
2868
+ final_topic_type=_final_topic_type,
2869
+ )
2870
+ blocks.append(
2871
+ PluginBlock(
2872
+ "status_overview",
2873
+ [
2874
+ ("status_overview", True),
2875
+ ("status_spec_file", "${peer_dir}/pipeline_spec.yaml"),
2876
+ ]
2877
+ + (
2878
+ [
2879
+ ("status_write_interval_s", link_trace_interval_s),
2880
+ ("link_trace", True),
2881
+ ("link_trace_interval_s", link_trace_interval_s),
2882
+ ("link_trace_modem_command", link_trace_modem_command),
2883
+ ("link_trace_modem_timeout_s", link_trace_modem_timeout_s),
2884
+ ]
2885
+ if link_trace_enabled
2886
+ else []
2887
+ ),
2888
+ )
2889
+ )
2890
+ if record_metric_stages:
2891
+ status_spec = yaml.safe_load(per_peer_status_spec_yaml[local] or "") or {}
2892
+ metric_stage_topics = _dedup_keep_order(
2893
+ [
2894
+ str(stage["topic"])
2895
+ for topic in status_spec.get("topics", [])
2896
+ for stage in topic.get("stages", [])
2897
+ if stage.get("domain", "local") == "local"
2898
+ ]
2899
+ + [
2900
+ str(p["it_in"])
2901
+ for p in out_pipes
2902
+ if p.get("it_in")
2903
+ ]
2904
+ + [
2905
+ f'{p["irt_in"]}/raw'
2906
+ for p in out_pipes
2907
+ if p.get("irt_in")
2908
+ ]
2909
+ )
2910
+ blocks.append(
2911
+ PluginBlock(
2912
+ "metric_stage_bag",
2913
+ [
2914
+ ("metric_stage_bag", True),
2915
+ ("metric_stage_topics", ",".join(metric_stage_topics)),
2916
+ ],
2917
+ )
2918
+ )
2919
+
2920
+ if use_heartbeat:
2921
+ # Heartbeat topics are base topics, but may be explicitly targeted on outbound and/or source-prefixed on inbound.
2922
+ hb_out = hb_topic[local]
2923
+ if use_target_prefix:
2924
+ hb_out = f"/to_{peer_name[remote]}{hb_out}"
2925
+ hb_in = hb_topic[remote]
2926
+ if bool(inbound_cfg.get("keep_source_prefix", False)):
2927
+ hb_in = f"/{peer_name[remote]}{hb_in}"
2928
+ blocks.append(
2929
+ PluginBlock(
2930
+ "heartbeat",
2931
+ [
2932
+ ("heartbeat", True),
2933
+ ("heartbeat_local_topic", hb_out),
2934
+ ("heartbeat_remote_topic", hb_in),
2935
+ *_heartbeat_monitor_overrides((shared.get("heartbeat") or {}).get("expect")),
2936
+ ],
2937
+ )
2938
+ )
2939
+
2940
+ if rs_topics:
2941
+ blocks.append(
2942
+ PluginBlock(
2943
+ "rs",
2944
+ [
2945
+ ("rs", True),
2946
+ ("rs_restamp_topics", ",".join(rs_topics)),
2947
+ ("rs_topic_suffix", restamped_suffix),
2948
+ ],
2949
+ )
2950
+ )
2951
+
2952
+ if lat_topics:
2953
+ blocks.append(
2954
+ PluginBlock(
2955
+ "lat",
2956
+ [
2957
+ ("lat", True),
2958
+ ("lat_topics", ",".join(lat_topics)),
2959
+ ("lat_topic_suffix", latched_suffix),
2960
+ # Keep latched statics alive as a low-rate stream so the
2961
+ # held value actually crosses the OTA pipeline.
2962
+ ("lat_keepalive_hz", latch_keepalive_hz),
2963
+ ],
2964
+ )
2965
+ )
2966
+
2967
+ if trickle_all_topics and trickle_hz is not None:
2968
+ blocks.append(
2969
+ PluginBlock(
2970
+ "trickle",
2971
+ [
2972
+ ("trickle", True),
2973
+ ("trickle_topics", ",".join(trickle_all_topics)),
2974
+ ("trickle_topic_suffix", "/trickle"),
2975
+ ("trickle_rate_hz", trickle_hz),
2976
+ ],
2977
+ )
2978
+ )
2979
+
2980
+ if fb_l2g or fb_g2l:
2981
+ fb_cfg = (ps_local.get("framebridge", {}) or {}) if isinstance(ps_local, dict) else {}
2982
+ global_frame_prefix = str(fb_cfg.get("global_frame_prefix", peer_name[local])).rstrip("_")
2983
+ exclude_frames = fb_cfg.get("prefix_exclude_frames", []) or []
2984
+ tf_filter_frames = fb_cfg.get("tf_filter_frames", []) or []
2985
+ tf_throttle_links = fb_cfg.get("tf_throttle_links", []) or []
2986
+ items: List[Tuple[str, Any]] = [
2987
+ ("fb", True),
2988
+ ("fb_global_frame_prefix", global_frame_prefix),
2989
+ ("fb_prefix_exclude_frames", ",".join(exclude_frames)),
2990
+ ]
2991
+ if tf_filter_frames:
2992
+ items.append(("fb_tf_filter_frames", ",".join(tf_filter_frames)))
2993
+ if tf_throttle_links:
2994
+ items.append(("fb_tf_throttle_links", ",".join(tf_throttle_links)))
2995
+ if fb_l2g:
2996
+ items.append(("fb_local_to_global_topics", ",".join(fb_l2g)))
2997
+ if fb_g2l:
2998
+ items.append(("fb_global_to_local_topics", ",".join(fb_g2l)))
2999
+ items.append(("fb_global_topic_suffix", globalframe_suffix))
3000
+ blocks.append(PluginBlock("fb", items))
3001
+
3002
+ if comp_topics:
3003
+ blocks.append(
3004
+ PluginBlock(
3005
+ "comp",
3006
+ [
3007
+ ("comp", True),
3008
+ ("comp_config_file", "${peer_dir}/compression.yaml"),
3009
+ ("comp_algorithm", comp_algorithm),
3010
+ ],
3011
+ )
3012
+ )
3013
+
3014
+ if ota_topics:
3015
+ blocks.append(
3016
+ PluginBlock(
3017
+ "otaw",
3018
+ [
3019
+ ("otaw", True),
3020
+ ("otaw_config_file", "${peer_dir}/ota_wrapper.yaml"),
3021
+ ("otaw_suffix", ota_suffix),
3022
+ ],
3023
+ )
3024
+ )
3025
+
3026
+ if inbound_comp_topics:
3027
+ blocks.append(
3028
+ PluginBlock(
3029
+ "deco",
3030
+ [
3031
+ ("deco", True),
3032
+ ("deco_config_file", "${peer_dir}/decompression.yaml"),
3033
+ ("deco_algorithm", comp_algorithm),
3034
+ ],
3035
+ )
3036
+ )
3037
+
3038
+ if inbound_ota_topics:
3039
+ blocks.append(
3040
+ PluginBlock(
3041
+ "otau",
3042
+ [
3043
+ ("otau", True),
3044
+ ("otau_config_file", "${peer_dir}/ota_unwrapper.yaml"),
3045
+ ("otau_suffix", ota_suffix),
3046
+ ],
3047
+ )
3048
+ )
3049
+
3050
+ if drp_items:
3051
+ items2: List[Tuple[str, Any]] = [("drp", True)]
3052
+ if len(drp_items) > 4:
3053
+ items2.append(("drp2", True))
3054
+ for i, (t, dc, ws) in enumerate(drp_items, 1):
3055
+ items2.append((f"drp_topic_{i}", t))
3056
+ items2.append((f"drp_drop_count_{i}", dc))
3057
+ items2.append((f"drp_window_size_{i}", ws))
3058
+ blocks.append(PluginBlock("drp", items2))
3059
+
3060
+ if thr_items:
3061
+ items2 = [("thr", True)]
3062
+ for i, (t, rate) in enumerate(thr_items, 1):
3063
+ items2.append((f"thr_topic_{i}", t))
3064
+ items2.append((f"thr_rate_{i}", rate))
3065
+ blocks.append(PluginBlock("thr", items2))
3066
+
3067
+ if ipx_items:
3068
+ items2 = [("ipx", True)]
3069
+ for i, (t, preset) in enumerate(ipx_items, 1):
3070
+ items2.append((f"ipx_{i}_topics", t))
3071
+ items2.append((f"ipx_{i}_preset", preset))
3072
+ blocks.append(PluginBlock("ipx", items2))
3073
+
3074
+ if it_items:
3075
+ items2 = [("it", True)]
3076
+ for i, (t, tspec) in enumerate(it_items, 1):
3077
+ assert tspec is not None
3078
+ items2.append((f"it_{i}_topic", t))
3079
+ items2.append((f"it_{i}_transport", tspec.type))
3080
+ if tspec.type == "ffmpeg":
3081
+ if "gop_size" in tspec.params:
3082
+ items2.append((f"it_{i}_ffmpeg_gop_size", int(tspec.params["gop_size"])))
3083
+ if "bit_rate" in tspec.params:
3084
+ items2.append((f"it_{i}_ffmpeg_bit_rate", int(tspec.params["bit_rate"])))
3085
+ if "encoder_av_options" in tspec.params:
3086
+ items2.append((f"it_{i}_ffmpeg_encoder_av_options", tspec.params["encoder_av_options"]))
3087
+ elif tspec.type == "foxglove":
3088
+ # image_transport "foxglove" transport (CompressedVideo) tunables
3089
+ if "gop_size" in tspec.params:
3090
+ items2.append((f"it_{i}_foxglove_gop_size", int(tspec.params["gop_size"])))
3091
+ if "encoder_av_options" in tspec.params:
3092
+ items2.append((f"it_{i}_foxglove_encoder_av_options", tspec.params["encoder_av_options"]))
3093
+ if "bit_rate" in tspec.params:
3094
+ items2.append((f"it_{i}_foxglove_bit_rate", int(tspec.params["bit_rate"])))
3095
+ if "qmax" in tspec.params:
3096
+ items2.append((f"it_{i}_foxglove_qmax", int(tspec.params["qmax"])))
3097
+ elif tspec.type == "compressed":
3098
+ if "jpeg_quality" in tspec.params:
3099
+ items2.append((f"it_{i}_compressed_jpeg_quality", int(tspec.params["jpeg_quality"])))
3100
+ else:
3101
+ raise ValueError(f"Unsupported transport type '{tspec.type}'")
3102
+ blocks.append(PluginBlock("it", items2))
3103
+
3104
+ # Merge reverse-transport config for (a) local reconstruction and (b) inbound remote topics (if requested).
3105
+ irt_all: List[Tuple[str, str, str]] = []
3106
+ for t, tspec in irt_items_local:
3107
+ assert tspec is not None
3108
+ irt_all.append((t, tspec.type, "raw"))
3109
+ irt_all.extend((topic, ttype, "raw") for topic, ttype in irt_items_remote)
3110
+ _assert_max("irt", irt_all, 4)
3111
+ if irt_all:
3112
+ items2 = [("irt", True)]
3113
+ for i, (topic, _ttype, _out_transport) in enumerate(irt_all, 1):
3114
+ items2.append((f"irt_{i}_topic", topic))
3115
+ for i, (_topic, ttype, out_transport) in enumerate(irt_all, 1):
3116
+ items2.append((f"irt_{i}_transport", ttype))
3117
+ items2.append((f"irt_{i}_out_transport", out_transport))
3118
+ blocks.append(PluginBlock("irt", items2))
3119
+
3120
+ if nor_items:
3121
+ items2 = [("nor", True)]
3122
+ nor_prefix = f"/{peer_name[remote]}" if bool(inbound_cfg.get("keep_source_prefix", False)) else ""
3123
+ for i, (base, suffix) in enumerate(nor_items, 1):
3124
+ items2.append((f"nor_topic_{i}_prefix", nor_prefix))
3125
+ items2.append((f"nor_topic_{i}_base", base))
3126
+ items2.append((f"nor_topic_{i}_suffix", suffix))
3127
+ blocks.append(PluginBlock("nor", items2))
3128
+
3129
+ per_peer_plugin[local] = _render_plugin_yaml(blocks)
3130
+
3131
+ qos_yaml = (
3132
+ _render_qos_yaml(shared.get("qos", {}) if isinstance(shared, dict) else {}, qos_overrides) if write_qos else ""
3133
+ )
3134
+
3135
+ # session_specification.yaml
3136
+ session_spec_yaml = _render_session_spec(base_plugin_path)
3137
+
3138
+ generated: List[Tuple[str, str]] = []
3139
+ if session_definition_yaml is not None:
3140
+ generated.append((os.path.join(param_dir, "session-definition.yaml"), session_definition_yaml))
3141
+ # Direction topic lists
3142
+ for (src, dst), direction_key in direction_key_for.items():
3143
+ generated.append(
3144
+ (
3145
+ os.path.join(param_dir, f"{direction_key}_topics.txt"),
3146
+ "\n".join(dir_topic_list[(src, dst)]) + "\n",
3147
+ )
3148
+ )
3149
+
3150
+ if write_qos:
3151
+ generated.append((os.path.join(param_dir, "qos.yaml"), qos_yaml))
3152
+
3153
+ # Per-peer outputs
3154
+ for p in peer_keys:
3155
+ generated.append((os.path.join(param_dir, p, "plugin.yaml"), per_peer_plugin[p]))
3156
+ generated.append((os.path.join(param_dir, p, "session_specification.yaml"), session_spec_yaml))
3157
+
3158
+ # compression/decompression YAMLs are generated only if needed
3159
+ if per_peer_comp_yaml.get(p):
3160
+ generated.append((os.path.join(param_dir, p, "compression.yaml"), per_peer_comp_yaml[p] or ""))
3161
+ if per_peer_deco_yaml.get(p):
3162
+ generated.append((os.path.join(param_dir, p, "decompression.yaml"), per_peer_deco_yaml[p] or ""))
3163
+ if per_peer_otaw_yaml.get(p):
3164
+ generated.append((os.path.join(param_dir, p, "ota_wrapper.yaml"), per_peer_otaw_yaml[p] or ""))
3165
+ if per_peer_otau_yaml.get(p):
3166
+ generated.append((os.path.join(param_dir, p, "ota_unwrapper.yaml"), per_peer_otau_yaml[p] or ""))
3167
+ if per_peer_domain_bridge_yaml.get(p):
3168
+ generated.append((os.path.join(param_dir, p, "domain_bridge.yaml"), per_peer_domain_bridge_yaml[p] or ""))
3169
+ if per_peer_status_spec_yaml.get(p):
3170
+ generated.append((os.path.join(param_dir, p, "pipeline_spec.yaml"), per_peer_status_spec_yaml[p] or ""))
3171
+
3172
+ _write_generated_files(generated, force=force, rewrite_formatting=rewrite_formatting)
3173
+
3174
+
3175
+ if __name__ == "__main__":
3176
+ parser = argparse.ArgumentParser(
3177
+ description="Generate session files from a session definition or parametrization (semantic diff, newline-safe)."
3178
+ )
3179
+ parser.add_argument(
3180
+ "-c",
3181
+ "--session-config-yaml",
3182
+ dest="session_config_yaml",
3183
+ help="Path to session-definition.yaml, session-parametrization.yaml",
3184
+ )
3185
+ parser.add_argument(
3186
+ "-f",
3187
+ "--force",
3188
+ action="store_true",
3189
+ help="Overwrite existing files if they differ semantically (format-only differences are ignored).",
3190
+ )
3191
+ parser.add_argument(
3192
+ "--rewrite-formatting",
3193
+ action="store_true",
3194
+ help="Rewrite files if content differs textually even when semantically equal (e.g. blank lines / ordering).",
3195
+ )
3196
+ parser.add_argument(
3197
+ "--base-plugin-path",
3198
+ help="Path to session_plugin_base.yaml to include in session_specification.yaml",
3199
+ default=BASE_PLUGIN_PATH_DEFAULT,
3200
+ )
3201
+ parser.add_argument(
3202
+ "--peer-address",
3203
+ action="append",
3204
+ default=[],
3205
+ metavar="PEER=ADDRESS",
3206
+ help="Resolved peer address. Pass once for every logical peer.",
3207
+ )
3208
+ args = parser.parse_args()
3209
+ kwargs = {k: v for k, v in vars(args).items() if v is not None}
3210
+ raw_addresses = kwargs.pop("peer_address")
3211
+ peer_addresses = {}
3212
+ for raw in raw_addresses:
3213
+ peer, separator, address = raw.partition("=")
3214
+ if separator != "=" or not peer.strip() or not address.strip():
3215
+ raise RuntimeError("--peer-address must use PEER=ADDRESS.")
3216
+ peer_addresses[peer.strip()] = address.strip()
3217
+ func(peer_addresses=peer_addresses, **kwargs)