rosotacom-dev 2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
rosotacom/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """rosotacom package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import Any
7
+
8
+ _DISTRIBUTION_NAME = "rosotacom"
9
+
10
+
11
+ def _package_version() -> str:
12
+ try:
13
+ return version(_DISTRIBUTION_NAME)
14
+ except PackageNotFoundError:
15
+ return "0+unknown"
16
+
17
+
18
+ __version__ = _package_version()
19
+
20
+
21
+ def __getattr__(name: str) -> Any:
22
+ """Lazily expose the historical root-module CLI attributes."""
23
+
24
+ from . import cli
25
+
26
+ try:
27
+ return getattr(cli, name)
28
+ except AttributeError as exc:
29
+ raise AttributeError(f"module 'rosotacom' has no attribute {name!r}") from exc
30
+
31
+
32
+ __all__ = ["__version__"]
rosotacom/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Module entry point for ``python -m rosotacom``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
rosotacom/anonymize.py ADDED
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HandoffTopic:
14
+ source_peer: str
15
+ target_peer: str
16
+ direction: str
17
+ source_topic: str
18
+ source_type: str | None
19
+ handoff_topic: str
20
+ handoff_type: str | None
21
+ generic_topic: str
22
+ expect: dict[str, Any] | None
23
+ qos: dict[str, Any] | None
24
+ zen_qos: dict[str, Any] | None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PlaybackTopic:
29
+ source_peer: str
30
+ bag_topic: str
31
+ publish_topic: str
32
+
33
+
34
+ def metadata_path_for_bag(path: Path) -> Path:
35
+ return path / "metadata.yaml" if path.is_dir() else path.parent / "metadata.yaml"
36
+
37
+
38
+ def load_bag_metadata(path: Path) -> dict[str, Any]:
39
+ metadata_path = metadata_path_for_bag(path)
40
+ if not metadata_path.exists():
41
+ raise FileNotFoundError(f"metadata.yaml not found for bag at {path}")
42
+ loaded = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) or {}
43
+ if not isinstance(loaded, dict):
44
+ raise RuntimeError(f"Bag metadata must contain a mapping: {metadata_path}")
45
+ return loaded
46
+
47
+
48
+ def bag_storage_id(metadata: dict[str, Any]) -> str:
49
+ return str((metadata.get("rosbag2_bagfile_information") or {}).get("storage_identifier") or "mcap")
50
+
51
+
52
+ def bag_topics_info(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
53
+ info = metadata.get("rosbag2_bagfile_information") or {}
54
+ topics: dict[str, dict[str, Any]] = {}
55
+ for entry in info.get("topics_with_message_count") or []:
56
+ meta = entry.get("topic_metadata") or {}
57
+ name = meta.get("name")
58
+ if not name:
59
+ continue
60
+ topics[str(name)] = {
61
+ "type": meta.get("type"),
62
+ "serialization_format": meta.get("serialization_format", "cdr"),
63
+ "offered_qos_profiles": meta.get("offered_qos_profiles", []),
64
+ "message_count": int(entry.get("message_count") or 0),
65
+ }
66
+ return topics
67
+
68
+
69
+ def _topic_entry_topic(entry: Any) -> str | None:
70
+ if isinstance(entry, str):
71
+ return entry
72
+ if isinstance(entry, dict) and isinstance(entry.get("topic"), str):
73
+ return str(entry["topic"])
74
+ return None
75
+
76
+
77
+ def _topic_entry_type(entry: Any) -> str | None:
78
+ if isinstance(entry, dict) and entry.get("type") is not None:
79
+ return str(entry["type"])
80
+ return None
81
+
82
+
83
+ def _topic_entry_mapping(entry: Any) -> dict[str, Any]:
84
+ if isinstance(entry, dict):
85
+ return dict(entry)
86
+ if isinstance(entry, str):
87
+ return {"topic": entry}
88
+ return {}
89
+
90
+
91
+ def _peer_keys(session_cfg: dict[str, Any]) -> list[str]:
92
+ peers = session_cfg.get("peers")
93
+ if not isinstance(peers, dict) or not peers:
94
+ raise RuntimeError("session configuration must define peers before anonymization planning")
95
+ keys = list(peers.keys())
96
+ if len(keys) != 2:
97
+ raise RuntimeError(f"anonymization planning currently supports exactly 2 peers, got {keys}")
98
+ return keys
99
+
100
+
101
+ def _planning_config(session_cfg: dict[str, Any]) -> dict[str, Any]:
102
+ cfg = copy.deepcopy(session_cfg)
103
+ shared = cfg.get("shared") or {}
104
+ if not isinstance(shared, dict):
105
+ shared = {}
106
+ shared["use_status_overview"] = True
107
+ cfg["shared"] = shared
108
+ return cfg
109
+
110
+
111
+ def _dummy_peer_addresses(peer_keys: list[str]) -> dict[str, str]:
112
+ return {peer: f"127.99.0.{idx + 1}" for idx, peer in enumerate(peer_keys)}
113
+
114
+
115
+ def _load_pipeline_spec(path: Path) -> dict[str, Any]:
116
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
117
+ if not isinstance(loaded, dict):
118
+ raise RuntimeError(f"pipeline spec must contain a mapping: {path}")
119
+ return loaded
120
+
121
+
122
+ def _handoff_stage(topic_spec: dict[str, Any]) -> dict[str, Any]:
123
+ stages = topic_spec.get("stages") or []
124
+ if not isinstance(stages, list):
125
+ raise RuntimeError(f"pipeline topic stages must be a list for {topic_spec.get('base')!r}")
126
+ for stage_name in ("processed", "native"):
127
+ for stage in stages:
128
+ if isinstance(stage, dict) and stage.get("stage") == stage_name:
129
+ return stage
130
+ raise RuntimeError(f"no outbound handoff stage found for {topic_spec.get('base')!r}")
131
+
132
+
133
+ def _outbound_specs_by_base(spec: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
134
+ by_base: dict[str, list[dict[str, Any]]] = {}
135
+ for topic_spec in spec.get("topics") or []:
136
+ if not isinstance(topic_spec, dict) or topic_spec.get("direction") != "outbound":
137
+ continue
138
+ base = topic_spec.get("base")
139
+ if not isinstance(base, str):
140
+ continue
141
+ by_base.setdefault(base, []).append(topic_spec)
142
+ return by_base
143
+
144
+
145
+ def plan_handoff_topics(session_cfg: dict[str, Any], session_gen: Any) -> list[HandoffTopic]:
146
+ peer_keys = _peer_keys(session_cfg)
147
+ topics_cfg = session_cfg.get("topics") or {}
148
+ if not isinstance(topics_cfg, dict):
149
+ raise RuntimeError("session configuration topics must be a mapping")
150
+
151
+ planned: list[HandoffTopic] = []
152
+ with tempfile.TemporaryDirectory(prefix="rosotacom-anonymize-plan-") as tmp:
153
+ tmp_path = Path(tmp)
154
+ session_gen.func(
155
+ session_config_obj=_planning_config(session_cfg),
156
+ output_dir=str(tmp_path),
157
+ peer_addresses=_dummy_peer_addresses(peer_keys),
158
+ )
159
+
160
+ for source_peer in peer_keys:
161
+ target_peer = peer_keys[0] if source_peer == peer_keys[1] else peer_keys[1]
162
+ direction = f"{source_peer}_to_{target_peer}"
163
+ entries = topics_cfg.get(direction) or []
164
+ if not isinstance(entries, list) or not entries:
165
+ continue
166
+
167
+ spec_path = tmp_path / source_peer / "pipeline_spec.yaml"
168
+ if not spec_path.exists():
169
+ raise RuntimeError(f"pipeline spec missing for peer {source_peer}: {spec_path}")
170
+ by_base = _outbound_specs_by_base(_load_pipeline_spec(spec_path))
171
+
172
+ for entry in entries:
173
+ source_topic = _topic_entry_topic(entry)
174
+ if not source_topic:
175
+ continue
176
+ candidates = by_base.get(source_topic) or []
177
+ if not candidates:
178
+ raise RuntimeError(
179
+ f"generated pipeline has no outbound topic for {direction} source topic {source_topic!r}"
180
+ )
181
+ topic_spec = candidates.pop(0)
182
+ handoff = _handoff_stage(topic_spec)
183
+ generic_topic = f"/topic{len(planned) + 1}"
184
+ entry_map = _topic_entry_mapping(entry)
185
+ expect = entry_map.get("expect")
186
+ qos = entry_map.get("qos")
187
+ zen_qos = entry_map.get("zen_qos")
188
+ planned.append(
189
+ HandoffTopic(
190
+ source_peer=source_peer,
191
+ target_peer=target_peer,
192
+ direction=direction,
193
+ source_topic=source_topic,
194
+ source_type=_topic_entry_type(entry),
195
+ handoff_topic=str(handoff["topic"]),
196
+ handoff_type=(str(handoff["type"]) if handoff.get("type") is not None else None),
197
+ generic_topic=generic_topic,
198
+ expect=copy.deepcopy(expect) if isinstance(expect, dict) else None,
199
+ qos=copy.deepcopy(qos) if isinstance(qos, dict) else None,
200
+ zen_qos=copy.deepcopy(zen_qos) if isinstance(zen_qos, dict) else None,
201
+ )
202
+ )
203
+
204
+ if not planned:
205
+ raise RuntimeError("session configuration does not define any anonymizable outbound topics")
206
+ return planned
207
+
208
+
209
+ def topics_map(plan: list[HandoffTopic]) -> dict[str, str]:
210
+ return {item.handoff_topic: item.generic_topic for item in plan}
211
+
212
+
213
+ def source_topics_by_peer(plan: list[HandoffTopic]) -> dict[str, list[str]]:
214
+ by_peer: dict[str, list[str]] = {}
215
+ for item in plan:
216
+ by_peer.setdefault(item.source_peer, []).append(item.generic_topic)
217
+ return by_peer
218
+
219
+
220
+ def playback_topics_by_peer(
221
+ replay_session_cfg: dict[str, Any],
222
+ plan: list[HandoffTopic],
223
+ session_gen: Any,
224
+ ) -> dict[str, list[PlaybackTopic]]:
225
+ replay_plan = plan_handoff_topics(replay_session_cfg, session_gen)
226
+ replay_publish_topics = {(item.source_peer, item.source_topic): item.handoff_topic for item in replay_plan}
227
+
228
+ by_peer: dict[str, list[PlaybackTopic]] = {}
229
+ for item in plan:
230
+ publish_topic = replay_publish_topics.get((item.source_peer, item.generic_topic))
231
+ if publish_topic is None:
232
+ raise RuntimeError(
233
+ "generated replay session has no outbound handoff topic for "
234
+ f"{item.source_peer} bag topic {item.generic_topic!r}"
235
+ )
236
+ by_peer.setdefault(item.source_peer, []).append(
237
+ PlaybackTopic(
238
+ source_peer=item.source_peer,
239
+ bag_topic=item.generic_topic,
240
+ publish_topic=publish_topic,
241
+ )
242
+ )
243
+ return by_peer
244
+
245
+
246
+ def missing_handoff_topics(plan: list[HandoffTopic], info_by_topic: dict[str, dict[str, Any]]) -> list[HandoffTopic]:
247
+ return [item for item in plan if item.handoff_topic not in info_by_topic]
248
+
249
+
250
+ def _replay_expect(expect: Any) -> dict[str, Any] | None:
251
+ if not isinstance(expect, dict):
252
+ return None
253
+ replay = copy.deepcopy(expect)
254
+ replay.pop("latency_ms", None)
255
+ return replay or None
256
+
257
+
258
+ def build_replay_session_config(session_cfg: dict[str, Any], plan: list[HandoffTopic]) -> dict[str, Any]:
259
+ cfg = copy.deepcopy(session_cfg)
260
+ plan_by_direction: dict[str, list[HandoffTopic]] = {}
261
+ for item in plan:
262
+ plan_by_direction.setdefault(item.direction, []).append(item)
263
+
264
+ replay_topics: dict[str, list[dict[str, Any]]] = {}
265
+ topics_cfg = session_cfg.get("topics") or {}
266
+ if not isinstance(topics_cfg, dict):
267
+ topics_cfg = {}
268
+
269
+ for direction, entries in topics_cfg.items():
270
+ if not isinstance(entries, list):
271
+ continue
272
+ direction_plan = list(plan_by_direction.get(direction) or [])
273
+ rewritten: list[dict[str, Any]] = []
274
+ for entry in entries:
275
+ if not direction_plan:
276
+ continue
277
+ item = direction_plan.pop(0)
278
+ new_entry = _topic_entry_mapping(entry)
279
+ new_entry["topic"] = item.generic_topic
280
+ if item.handoff_type:
281
+ new_entry["type"] = item.handoff_type
282
+ new_entry.pop("processing", None)
283
+ replay_expect = _replay_expect(new_entry.get("expect"))
284
+ if replay_expect is None:
285
+ new_entry.pop("expect", None)
286
+ else:
287
+ new_entry["expect"] = replay_expect
288
+ rewritten.append(new_entry)
289
+ replay_topics[direction] = rewritten
290
+
291
+ cfg["topics"] = replay_topics
292
+ return cfg
293
+
294
+
295
+ def _first_qos_profile(raw: Any) -> dict[str, Any]:
296
+ offered = raw
297
+ if isinstance(offered, str):
298
+ try:
299
+ offered = yaml.safe_load(offered)
300
+ except yaml.YAMLError:
301
+ return {}
302
+ if isinstance(offered, list) and offered and isinstance(offered[0], dict):
303
+ return dict(offered[0])
304
+ if isinstance(offered, dict):
305
+ return dict(offered)
306
+ return {}
307
+
308
+
309
+ def playback_qos_overrides(
310
+ plan: list[HandoffTopic],
311
+ info_by_topic: dict[str, dict[str, Any]],
312
+ ) -> dict[str, dict[str, Any]]:
313
+ allowed = {
314
+ "history",
315
+ "depth",
316
+ "reliability",
317
+ "durability",
318
+ "deadline",
319
+ "lifespan",
320
+ "liveliness",
321
+ "liveliness_lease_duration",
322
+ }
323
+ overrides: dict[str, dict[str, Any]] = {}
324
+ for item in plan:
325
+ info = info_by_topic.get(item.handoff_topic) or {}
326
+ qos = _first_qos_profile(info.get("offered_qos_profiles"))
327
+ clean = {key: value for key, value in qos.items() if key in allowed and value is not None}
328
+ if clean:
329
+ overrides[item.generic_topic] = clean
330
+ return overrides
331
+
332
+
333
+ def anonymization_manifest(
334
+ plan: list[HandoffTopic],
335
+ info_by_topic: dict[str, dict[str, Any]],
336
+ ) -> dict[str, Any]:
337
+ topics: list[dict[str, Any]] = []
338
+ for item in plan:
339
+ info = info_by_topic.get(item.handoff_topic) or {}
340
+ topics.append(
341
+ {
342
+ "source_peer": item.source_peer,
343
+ "target_peer": item.target_peer,
344
+ "direction": item.direction,
345
+ "source_topic": item.source_topic,
346
+ "source_type": item.source_type,
347
+ "handoff_topic": item.handoff_topic,
348
+ "handoff_type": item.handoff_type,
349
+ "generic_topic": item.generic_topic,
350
+ "message_count": info.get("message_count"),
351
+ "serialization_format": info.get("serialization_format"),
352
+ "qos_source": "bag.offered_qos_profiles" if info.get("offered_qos_profiles") else "none",
353
+ "offered_qos_profiles": info.get("offered_qos_profiles") or [],
354
+ }
355
+ )
356
+ return {
357
+ "schema_version": 1,
358
+ "mode": "processed_handoff_replay",
359
+ "topics": topics,
360
+ }