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.
- rosotacom/__init__.py +32 -0
- rosotacom/__main__.py +8 -0
- rosotacom/anonymize.py +360 -0
- rosotacom/bag_ground_truth.py +395 -0
- rosotacom/benched_set.py +492 -0
- rosotacom/benchmark.py +1842 -0
- rosotacom/bundle_check.py +517 -0
- rosotacom/cli.py +8637 -0
- rosotacom/cli_benchmark.py +8211 -0
- rosotacom/deployment.py +196 -0
- rosotacom/ffmpeg_packet.py +123 -0
- rosotacom/forensics.py +1172 -0
- rosotacom/geomap.py +848 -0
- rosotacom/network_profiles.py +563 -0
- rosotacom/network_shaper.py +127 -0
- rosotacom/plots.py +609 -0
- rosotacom/py.typed +0 -0
- rosotacom/resources/benched-set.yaml +317 -0
- rosotacom/resources/examples/.gitignore +1 -0
- rosotacom/resources/examples/README.md +163 -0
- rosotacom/resources/examples/__init__.py +1 -0
- rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
- rosotacom/resources/examples/deployment.example.yaml +14 -0
- rosotacom/resources/examples/profiles.yaml +85 -0
- rosotacom/resources/examples/ros2docker.json +33 -0
- rosotacom/resources/examples/rosotacom.yaml +12 -0
- rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
- rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
- rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
- rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
- rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
- rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
- rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
- rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
- rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
- rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
- rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
- rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
- rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
- rosotacom/resources/ros2docker.json.example +33 -0
- rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
- rosotacom/resources/ws/.vscode/settings.json +8 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
- rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
- rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
- rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
- rosotacom/resources/ws/ota_configs/utils.py +74 -0
- rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
- rosotacom/resources/ws/ros2src/.gitkeep +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
- rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
- rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
- rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
- rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
- rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
- rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
- rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
- rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
- rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
- rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
- rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
- rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
- rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
- rosotacom/resources/ws/session/creation/run_session.py +447 -0
- rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
- rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
- rosotacom/status_eval.py +475 -0
- rosotacom/stream_stats.py +650 -0
- rosotacom/trace_profiles.py +561 -0
- rosotacom/transit.py +249 -0
- rosotacom/video_quality.py +906 -0
- rosotacom_dev-2.2.dist-info/METADATA +874 -0
- rosotacom_dev-2.2.dist-info/RECORD +195 -0
- rosotacom_dev-2.2.dist-info/WHEEL +5 -0
- rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
- rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
- 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)
|