rosotacom-dev 2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,196 @@
1
+ """Deployment host configuration and peer binding resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ VALUE_PREFIX = "value:"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class DeploymentHost:
17
+ name: str
18
+ address: str
19
+ ssh: str | None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DeploymentConfig:
24
+ path: Path
25
+ hosts: dict[str, DeploymentHost]
26
+ values: dict[str, Any]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PeerBinding:
31
+ peer: str
32
+ address: str
33
+ ssh: str | None
34
+ host: str | None = None
35
+
36
+
37
+ def _load_mapping(path: Path) -> dict[str, Any]:
38
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
39
+ if not isinstance(loaded, dict):
40
+ raise RuntimeError(f"Deployment file must contain a mapping: {path}")
41
+ return loaded
42
+
43
+
44
+ def load_deployment(path: Path | None) -> DeploymentConfig | None:
45
+ if path is None:
46
+ return None
47
+ raw = _load_mapping(path)
48
+ unknown = sorted(set(raw) - {"hosts", "values"})
49
+ if unknown:
50
+ raise RuntimeError(f"Unsupported deployment keys {unknown}; allowed keys are ['hosts', 'values'].")
51
+
52
+ hosts_raw = raw.get("hosts")
53
+ values = raw.get("values")
54
+ if hosts_raw is None:
55
+ hosts_raw = {}
56
+ if values is None:
57
+ values = {}
58
+ if not isinstance(hosts_raw, dict):
59
+ raise RuntimeError("deployment.hosts must be a mapping.")
60
+ if not isinstance(values, dict):
61
+ raise RuntimeError("deployment.values must be a mapping.")
62
+
63
+ hosts: dict[str, DeploymentHost] = {}
64
+ for raw_name, raw_host in hosts_raw.items():
65
+ name = str(raw_name).strip()
66
+ if not name:
67
+ raise RuntimeError("deployment host names must be non-empty.")
68
+ if not isinstance(raw_host, dict):
69
+ raise RuntimeError(f"deployment.hosts.{name} must be a mapping.")
70
+ extra = sorted(set(raw_host) - {"address", "ssh"})
71
+ if extra:
72
+ raise RuntimeError(
73
+ f"Unsupported deployment.hosts.{name} keys {extra}; allowed keys are ['address', 'ssh']."
74
+ )
75
+ address = raw_host.get("address")
76
+ if not isinstance(address, str) or not address.strip():
77
+ raise RuntimeError(f"deployment.hosts.{name}.address must be a non-empty string.")
78
+ ssh = raw_host.get("ssh")
79
+ if ssh is not None and (not isinstance(ssh, str) or not ssh.strip()):
80
+ raise RuntimeError(f"deployment.hosts.{name}.ssh must be a non-empty string or null.")
81
+ hosts[name] = DeploymentHost(name=name, address=address.strip(), ssh=ssh.strip() if ssh else None)
82
+
83
+ return DeploymentConfig(path=path, hosts=hosts, values=copy.deepcopy(values))
84
+
85
+
86
+ def parse_assignments(raw_values: list[str] | None, *, option: str) -> dict[str, str]:
87
+ result: dict[str, str] = {}
88
+ for raw in raw_values or []:
89
+ key, separator, value = (raw or "").partition("=")
90
+ key = key.strip()
91
+ value = value.strip()
92
+ if separator != "=" or not key or not value:
93
+ raise RuntimeError(f"{option} must use '<peer>=<value>', for example '{option} a=machine-a'.")
94
+ if key in result:
95
+ raise RuntimeError(f"Duplicate {option} assignment for peer '{key}'.")
96
+ result[key] = value
97
+ return result
98
+
99
+
100
+ def resolve_value(expression: str, deployment: DeploymentConfig | None) -> str:
101
+ value = str(expression).strip()
102
+ if not value:
103
+ raise RuntimeError("Deployment values must be non-empty strings.")
104
+ if not value.startswith(VALUE_PREFIX):
105
+ return value
106
+ key = value[len(VALUE_PREFIX) :].strip()
107
+ if not key:
108
+ raise RuntimeError("value: references must include a key.")
109
+ if deployment is None:
110
+ raise RuntimeError(f"Cannot resolve '{value}' because no deployment file is configured.")
111
+
112
+ current: Any = deployment.values
113
+ for part in key.split("."):
114
+ if not isinstance(current, dict) or part not in current:
115
+ raise RuntimeError(f"Deployment value '{key}' was not found in {deployment.path}.")
116
+ current = current[part]
117
+ if isinstance(current, (dict, list)) or current is None:
118
+ raise RuntimeError(f"Deployment value '{key}' must resolve to a scalar.")
119
+ return str(current)
120
+
121
+
122
+ def resolve_peer_bindings(
123
+ cfg: dict[str, Any],
124
+ deployment: DeploymentConfig | None,
125
+ *,
126
+ peer_assignments: dict[str, str] | None = None,
127
+ address_overrides: dict[str, str] | None = None,
128
+ ssh_overrides: dict[str, str] | None = None,
129
+ require_addresses: bool = True,
130
+ ) -> dict[str, PeerBinding]:
131
+ peers = cfg.get("peers")
132
+ if not isinstance(peers, dict) or not peers:
133
+ raise RuntimeError("Session config must define a non-empty peers mapping.")
134
+
135
+ peer_assignments = peer_assignments or {}
136
+ address_overrides = address_overrides or {}
137
+ ssh_overrides = ssh_overrides or {}
138
+ known = set(str(peer) for peer in peers)
139
+ for option, assignments in (
140
+ ("--peer", peer_assignments),
141
+ ("--peer-address", address_overrides),
142
+ ("--peer-ssh", ssh_overrides),
143
+ ):
144
+ unknown = sorted(set(assignments) - known)
145
+ if unknown:
146
+ raise RuntimeError(f"{option} references unknown peer(s) {unknown}. Known peers: {sorted(known)}")
147
+
148
+ bindings: dict[str, PeerBinding] = {}
149
+ missing: list[str] = []
150
+ for raw_peer, raw_peer_cfg in peers.items():
151
+ peer = str(raw_peer)
152
+ peer_cfg = raw_peer_cfg or {}
153
+ if not isinstance(peer_cfg, dict):
154
+ raise RuntimeError(f"peers.{peer} must be a mapping.")
155
+ host_name = peer_assignments.get(peer)
156
+ if host_name is None:
157
+ configured_host = peer_cfg.get("host")
158
+ host_name = str(configured_host).strip() if configured_host is not None else None
159
+
160
+ address: str | None = None
161
+ ssh: str | None = None
162
+ if host_name:
163
+ if deployment is None:
164
+ if peer in peer_assignments or peer not in address_overrides:
165
+ raise RuntimeError(
166
+ f"Peer '{peer}' selects deployment host '{host_name}', but no deployment file is configured."
167
+ )
168
+ host_name = None
169
+ else:
170
+ try:
171
+ host = deployment.hosts[host_name]
172
+ except KeyError as exc:
173
+ raise RuntimeError(
174
+ f"Unknown deployment host '{host_name}' for peer '{peer}'. "
175
+ f"Known hosts: {sorted(deployment.hosts)}"
176
+ ) from exc
177
+ address = resolve_value(host.address, deployment)
178
+ ssh = host.ssh
179
+
180
+ if peer in address_overrides:
181
+ address = resolve_value(address_overrides[peer], deployment)
182
+ if peer in ssh_overrides:
183
+ ssh_value = ssh_overrides[peer]
184
+ ssh = None if ssh_value == "local" else ssh_value
185
+
186
+ if address is None:
187
+ missing.append(peer)
188
+ continue
189
+ bindings[peer] = PeerBinding(peer=peer, address=address, ssh=ssh, host=host_name)
190
+
191
+ if missing and require_addresses:
192
+ raise RuntimeError(
193
+ "Missing deployment address for peer(s) "
194
+ f"{missing}. Use --peer PEER=HOST with a deployment file, or --peer-address PEER=ADDRESS."
195
+ )
196
+ return bindings
@@ -0,0 +1,123 @@
1
+ """Keyframe identification for ffmpeg camera streams (FFMPEGPacket).
2
+
3
+ An ffmpeg-transported camera stream is a sequence of
4
+ ``ffmpeg_image_transport_msgs/msg/FFMPEGPacket`` messages with a periodic GOP
5
+ structure: one keyframe (I-frame, self-contained, large) followed by
6
+ ``gop_size - 1`` delta frames (P-frames, tiny). The two classes differ in size
7
+ by one to two orders of magnitude, so distinguishing them is essential for
8
+ network analysis -- the keyframes are the bursts a link actually has to absorb.
9
+
10
+ Primary algorithm: the packet's ``flags`` field mirrors libav's packet flags;
11
+ bit 0 is ``AV_PKT_FLAG_KEY`` -- set exactly on keyframes. ``rosotacom
12
+ anonymize`` preserves ``flags`` (and ``pts``) when it zeroes payloads, so this
13
+ works on live streams, recorded handoff traces, and anonymized replay bags
14
+ alike. See docs/ffmpeg-keyframes.md.
15
+
16
+ This module parses the serialized CDR bytes directly (as stored in mcap
17
+ recordings or delivered by raw subscriptions), so offline tooling needs no ROS
18
+ runtime. Only little-endian CDR (the ROS 2 default on all supported platforms)
19
+ is handled.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+ from dataclasses import dataclass
26
+ from statistics import median
27
+
28
+ AV_PKT_FLAG_KEY = 0x1
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class FFMPEGPacketInfo:
33
+ """Structure of one serialized FFMPEGPacket, without its payload bytes."""
34
+
35
+ stamp_sec: int
36
+ stamp_nanosec: int
37
+ frame_id: str
38
+ width: int
39
+ height: int
40
+ encoding: str
41
+ pts: int
42
+ flags: int
43
+ payload_size: int
44
+
45
+ @property
46
+ def is_keyframe(self) -> bool:
47
+ return bool(self.flags & AV_PKT_FLAG_KEY)
48
+
49
+
50
+ def _align(offset: int, alignment: int) -> int:
51
+ return (offset + alignment - 1) // alignment * alignment
52
+
53
+
54
+ def parse_ffmpeg_packet(cdr: bytes) -> FFMPEGPacketInfo:
55
+ """Parse a serialized ``FFMPEGPacket`` (CDR bytes as stored in a bag).
56
+
57
+ Field order per the message definition: ``std_msgs/Header header``,
58
+ ``int32 width``, ``int32 height``, ``string encoding``, ``uint64 pts``,
59
+ ``uint8 flags``, ``bool is_bigendian``, ``uint8[] data``. CDR alignment is
60
+ relative to the payload start, after the 4-byte encapsulation header.
61
+ """
62
+ if len(cdr) < 4 or cdr[1] != 0x01:
63
+ raise ValueError("expected little-endian CDR encapsulation (0x00 0x01 ...)")
64
+ body = cdr[4:]
65
+ offset = 0
66
+
67
+ stamp_sec, stamp_nanosec = struct.unpack_from("<iI", body, offset)
68
+ offset += 8
69
+
70
+ offset = _align(offset, 4)
71
+ (str_len,) = struct.unpack_from("<I", body, offset)
72
+ offset += 4
73
+ frame_id = body[offset : offset + str_len - 1].decode("utf-8", errors="replace")
74
+ offset += str_len
75
+
76
+ offset = _align(offset, 4)
77
+ width, height = struct.unpack_from("<ii", body, offset)
78
+ offset += 8
79
+
80
+ offset = _align(offset, 4)
81
+ (str_len,) = struct.unpack_from("<I", body, offset)
82
+ offset += 4
83
+ encoding = body[offset : offset + str_len - 1].decode("utf-8", errors="replace")
84
+ offset += str_len
85
+
86
+ offset = _align(offset, 8)
87
+ (pts,) = struct.unpack_from("<Q", body, offset)
88
+ offset += 8
89
+
90
+ flags = body[offset]
91
+ offset += 2 # flags + is_bigendian
92
+
93
+ offset = _align(offset, 4)
94
+ (payload_size,) = struct.unpack_from("<I", body, offset)
95
+
96
+ return FFMPEGPacketInfo(
97
+ stamp_sec=stamp_sec,
98
+ stamp_nanosec=stamp_nanosec,
99
+ frame_id=frame_id,
100
+ width=width,
101
+ height=height,
102
+ encoding=encoding,
103
+ pts=pts,
104
+ flags=flags,
105
+ payload_size=payload_size,
106
+ )
107
+
108
+
109
+ def keyframes_by_size(sizes: list[int] | list[float], *, min_ratio: float = 3.0) -> list[bool]:
110
+ """Size-bimodality fallback when ``flags`` is unavailable or was zeroed.
111
+
112
+ Marks a frame as keyframe iff its size exceeds ``min_ratio`` times the
113
+ stream's median frame size. Works because delta frames dominate the stream
114
+ (a GOP of N has N-1 of them), pinning the median to the delta-frame mode,
115
+ while keyframes are typically 5-100x larger. Misclassifies only when the
116
+ encoder emits unusually large delta frames (heavy motion) or near-empty
117
+ keyframes (static scene at very low bitrate) -- prefer ``flags`` whenever
118
+ present.
119
+ """
120
+ if not sizes:
121
+ return []
122
+ threshold = min_ratio * median(sizes)
123
+ return [size > threshold for size in sizes]