rosotacom-dev 2.5.dev33__py3-none-any.whl → 2.5.dev36__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/cli.py +20 -0
- rosotacom/resources/examples/ros2docker.json +0 -1
- rosotacom/resources/ros2docker.json.example +0 -1
- rosotacom/resources/ws/ota_configs/cyclonedds_local_participants.xml.template +37 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +47 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +20 -13
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +275 -5
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +31 -9
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +17 -6
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh +44 -0
- rosotacom/resources/ws/session/creation/generate_session_files.py +7 -1
- rosotacom/transit.py +57 -17
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/METADATA +29 -4
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/RECORD +18 -17
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/WHEEL +0 -0
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/entry_points.txt +0 -0
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/licenses/LICENSE.txt +0 -0
- {rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/top_level.txt +0 -0
rosotacom/cli.py
CHANGED
|
@@ -1083,10 +1083,30 @@ def _mark_interactive_smoke_stopped(instance_dir: Path, target_type: str, target
|
|
|
1083
1083
|
|
|
1084
1084
|
|
|
1085
1085
|
def _write_docker_log(container_name: str, instance: SessionInstance, identity: str) -> None:
|
|
1086
|
+
"""Snapshot the peer container's own stdout into ``logs/<peer>/docker.log``.
|
|
1087
|
+
|
|
1088
|
+
In `attach` mode the container is `--rm` and already gone by the time this
|
|
1089
|
+
runs, so `docker logs` answers "No such container". That answer used to be
|
|
1090
|
+
written verbatim, which produced a 75-byte file that reads like the record
|
|
1091
|
+
of the run and says nothing -- it was the whole of `logs/<peer>/` besides
|
|
1092
|
+
`launcher.log` when a field session had to be diagnosed after the fact
|
|
1093
|
+
(#266). The catmux per-pane logs under `catmux/` are the actual record, so
|
|
1094
|
+
say that instead of pretending this file is one.
|
|
1095
|
+
"""
|
|
1086
1096
|
try:
|
|
1087
1097
|
result = subprocess.run(["docker", "logs", container_name], text=True, capture_output=True, check=False)
|
|
1088
1098
|
except Exception:
|
|
1089
1099
|
return
|
|
1100
|
+
if result.returncode != 0:
|
|
1101
|
+
stderr = (result.stderr or "").strip()
|
|
1102
|
+
_append_log(
|
|
1103
|
+
_peer_docker_log(instance, identity),
|
|
1104
|
+
f"[rosotacom] docker logs {container_name}: {stderr or 'unavailable'}\n"
|
|
1105
|
+
"[rosotacom] The container is not (or no longer) present -- in attach mode it is\n"
|
|
1106
|
+
"[rosotacom] `--rm` and exits with the foreground command. What each pane inside\n"
|
|
1107
|
+
f"[rosotacom] it printed is under {_peer_docker_log(instance, identity).parent / 'catmux'}.",
|
|
1108
|
+
)
|
|
1109
|
+
return
|
|
1090
1110
|
logs = (result.stdout or "") + (result.stderr or "")
|
|
1091
1111
|
if logs:
|
|
1092
1112
|
_append_log(_peer_docker_log(instance, identity), logs)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
Local-domain CycloneDDS configuration: default discovery, more participants.
|
|
4
|
+
|
|
5
|
+
Set it as `shared.rmw.local.cyclone.config` (or a peer's `local_config_template`)
|
|
6
|
+
on a machine where many processes share the communication peer's local domain.
|
|
7
|
+
|
|
8
|
+
Why: Cyclone gives each process one participant index per domain, and its default
|
|
9
|
+
MaxAutoParticipantIndex is 32 - 33 slots per domain per host. A control centre
|
|
10
|
+
spends them faster than that sounds. The processes that start last then lose the
|
|
11
|
+
race, and the error names the domain rather than the cause:
|
|
12
|
+
|
|
13
|
+
[46] heartbeat_: Failed to find a free participant index for domain 46
|
|
14
|
+
[ERROR] [rmw_cyclonedds_cpp]: rmw_create_node: failed to create domain, error Error
|
|
15
|
+
|
|
16
|
+
On 2026-08-13 that killed the centre's `heartbeat_echo` and one `topic_monitor`
|
|
17
|
+
two seconds after start. catmux left both panes at a shell prompt, so the session
|
|
18
|
+
ran for two hours with no heartbeat publisher: no RTT, no clock-offset estimate,
|
|
19
|
+
and every EchoHeartbeat the far side received carried `echo_valid=false`.
|
|
20
|
+
|
|
21
|
+
119, not higher: Cyclone's unicast discovery ports are
|
|
22
|
+
7400 + 250*domain + 10 + 2*index, so 119 is the last index whose ports stay
|
|
23
|
+
inside this domain's 250-port block. Index 120 would answer on the next
|
|
24
|
+
domain's ports.
|
|
25
|
+
|
|
26
|
+
Nothing else is set. This is the *local* domain -- intra-host discovery must keep
|
|
27
|
+
working, so unlike the OTA templates it pins no interface, disables no multicast,
|
|
28
|
+
and sets no peer list.
|
|
29
|
+
-->
|
|
30
|
+
<CycloneDDS xmlns="https://cdds.io/config">
|
|
31
|
+
<Domain Id="any">
|
|
32
|
+
<Discovery>
|
|
33
|
+
<ParticipantIndex>auto</ParticipantIndex>
|
|
34
|
+
<MaxAutoParticipantIndex>119</MaxAutoParticipantIndex>
|
|
35
|
+
</Discovery>
|
|
36
|
+
</Domain>
|
|
37
|
+
</CycloneDDS>
|
|
@@ -78,6 +78,19 @@ def parse_ip_addr_for_ip(text: str, ip: str) -> Optional[str]:
|
|
|
78
78
|
return None
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
#: Interface names that carry intra-host traffic only. Sampling one of these and
|
|
82
|
+
#: calling the result "link bandwidth" is the failure of issue #267: the number
|
|
83
|
+
#: is real, it is just not the link. On a machine that also runs a `-a` recorder
|
|
84
|
+
#: it reads ~28 Gbit/s next to a 6 Mbit/s OTA link.
|
|
85
|
+
LOOPBACK_INTERFACES = frozenset({"lo", "lo0"})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_loopback_address(ip: str) -> bool:
|
|
89
|
+
"""Is ``ip`` a loopback address (so a loopback interface would be correct)?"""
|
|
90
|
+
ip = (ip or "").strip()
|
|
91
|
+
return ip.startswith("127.") or ip in {"::1", "0:0:0:0:0:0:0:1"}
|
|
92
|
+
|
|
93
|
+
|
|
81
94
|
def find_interface_for_ip(ip: str) -> Optional[str]:
|
|
82
95
|
"""The interface owning ``ip``, via `ip -o addr show` (None if not found)."""
|
|
83
96
|
if not ip:
|
|
@@ -91,6 +104,40 @@ def find_interface_for_ip(ip: str) -> Optional[str]:
|
|
|
91
104
|
return parse_ip_addr_for_ip(out, ip)
|
|
92
105
|
|
|
93
106
|
|
|
107
|
+
def resolve_link_interface(
|
|
108
|
+
explicit: str,
|
|
109
|
+
host_ip: str,
|
|
110
|
+
finder=find_interface_for_ip,
|
|
111
|
+
) -> Tuple[str, str]:
|
|
112
|
+
"""The OTA link interface and where it came from, or raise.
|
|
113
|
+
|
|
114
|
+
``explicit`` wins when given; otherwise the interface is the one owning
|
|
115
|
+
``host_ip`` (the peer's own OTA address). A loopback interface for a
|
|
116
|
+
non-loopback address is refused rather than reported: loopback carries this
|
|
117
|
+
host's own ROS traffic, which on a machine running an ``-a`` recorder is
|
|
118
|
+
three orders of magnitude above the link and looks like a working
|
|
119
|
+
measurement. That mistake is issue #267; it survived a field session and an
|
|
120
|
+
offline analysis before anyone doubted the number.
|
|
121
|
+
"""
|
|
122
|
+
interface = (explicit or "").strip()
|
|
123
|
+
provenance = "given explicitly"
|
|
124
|
+
if not interface:
|
|
125
|
+
interface = (finder(host_ip) or "").strip()
|
|
126
|
+
provenance = f"resolved from {host_ip}"
|
|
127
|
+
if not interface:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"no interface owns the OTA address '{host_ip}'; "
|
|
130
|
+
"pass ip_local (preferred) or interface"
|
|
131
|
+
)
|
|
132
|
+
if interface in LOOPBACK_INTERFACES and not is_loopback_address(host_ip):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"refusing to report loopback interface '{interface}' as the link for OTA "
|
|
135
|
+
f"address '{host_ip}' ({provenance}). Loopback carries this host's own ROS "
|
|
136
|
+
"traffic, not the link."
|
|
137
|
+
)
|
|
138
|
+
return interface, provenance
|
|
139
|
+
|
|
140
|
+
|
|
94
141
|
def read_iface_counters(iface: str, proc_path: str = PROC_NET_DEV) -> Optional[Tuple[int, int]]:
|
|
95
142
|
"""Return (rx_bytes, tx_bytes) for ``iface`` now, or None if unavailable."""
|
|
96
143
|
row = read_iface_counter_row(iface, proc_path)
|
|
@@ -67,9 +67,10 @@ from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPo
|
|
|
67
67
|
from rclpy.serialization import serialize_message
|
|
68
68
|
from rosidl_runtime_py.utilities import get_message
|
|
69
69
|
|
|
70
|
-
from com_py.link_bytes import LinkByteSampler,
|
|
70
|
+
from com_py.link_bytes import LinkByteSampler, resolve_link_interface
|
|
71
71
|
from com_py.link_trace import LinkTraceRecorder
|
|
72
72
|
from com_py.status_overview_core import (
|
|
73
|
+
STARTUP_GRACE_S,
|
|
73
74
|
ClockOffsetEstimator,
|
|
74
75
|
StageObservation,
|
|
75
76
|
StatusAggregator,
|
|
@@ -334,6 +335,8 @@ class StatusOverview(Node):
|
|
|
334
335
|
self.declare_parameter("link_trace_interval_s", 1.0)
|
|
335
336
|
self.declare_parameter("link_trace_modem_command", "")
|
|
336
337
|
self.declare_parameter("link_trace_modem_timeout_s", 2.0)
|
|
338
|
+
# Grace before the one-shot startup verdict (#266). 0 disables it.
|
|
339
|
+
self.declare_parameter("startup_grace_s", STARTUP_GRACE_S)
|
|
337
340
|
|
|
338
341
|
spec_file = str(self.get_parameter("status_spec_file").value or "").strip()
|
|
339
342
|
output_dir = str(self.get_parameter("output_dir").value or "").strip()
|
|
@@ -410,20 +413,23 @@ class StatusOverview(Node):
|
|
|
410
413
|
|
|
411
414
|
ota_interface = str(self.get_parameter("ota_interface").value or "").strip()
|
|
412
415
|
ota_local_ip = str(self.get_parameter("ota_local_ip").value or "").strip()
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
link_sampler = None
|
|
417
|
+
if ota_interface or ota_local_ip:
|
|
418
|
+
# Same resolution and the same loopback refusal as topic_monitor: a
|
|
419
|
+
# link number nobody can tell apart from loopback traffic is worse
|
|
420
|
+
# than no link number, so this reports nothing rather than that.
|
|
421
|
+
try:
|
|
422
|
+
ota_interface, provenance = resolve_link_interface(ota_interface, ota_local_ip)
|
|
423
|
+
except ValueError as exc:
|
|
424
|
+
self.get_logger().warning(
|
|
425
|
+
f"status_overview: {exc}; link-overhead measurement disabled"
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
link_sampler = LinkByteSampler(ota_interface)
|
|
416
429
|
self.get_logger().info(
|
|
417
|
-
f"status_overview:
|
|
418
|
-
"
|
|
430
|
+
f"status_overview: link-overhead sampling on interface "
|
|
431
|
+
f"'{ota_interface}' ({provenance})"
|
|
419
432
|
)
|
|
420
|
-
link_sampler = None
|
|
421
|
-
if ota_interface:
|
|
422
|
-
link_sampler = LinkByteSampler(ota_interface)
|
|
423
|
-
self.get_logger().info(
|
|
424
|
-
f"status_overview: link-overhead sampling on interface '{ota_interface}'"
|
|
425
|
-
+ (f" (resolved from {ota_local_ip})" if ota_local_ip else "")
|
|
426
|
-
)
|
|
427
433
|
|
|
428
434
|
link_trace_recorder = None
|
|
429
435
|
if bool(self.get_parameter("link_trace").value):
|
|
@@ -450,6 +456,7 @@ class StatusOverview(Node):
|
|
|
450
456
|
link_sampler=link_sampler,
|
|
451
457
|
clock_estimator=clock_estimator,
|
|
452
458
|
link_trace_recorder=link_trace_recorder,
|
|
459
|
+
startup_grace_s=float(self.get_parameter("startup_grace_s").value),
|
|
453
460
|
)
|
|
454
461
|
|
|
455
462
|
self.create_timer(self.write_interval_s, self._on_write)
|
|
@@ -175,6 +175,128 @@ def stamp_delay(raw_delay_s: float, clock_offset_s: Optional[float] = None) -> O
|
|
|
175
175
|
return None
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
#: How long a peer is given to bring its own publishers up before their absence
|
|
179
|
+
#: is called a defect. Node start, DDS discovery and the first `refresh_interval`
|
|
180
|
+
#: of the observers all happen inside it; 20 s is roughly four times what a
|
|
181
|
+
#: healthy bring-up needs.
|
|
182
|
+
STARTUP_GRACE_S = 20.0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
#: Stage producers rosotacom starts itself, and can therefore promise. The
|
|
186
|
+
#: application that feeds a link is the user's, and a session that declares a
|
|
187
|
+
#: topic says nothing about whether its publisher is running right now -- a
|
|
188
|
+
#: smoke run legitimately exercises the peers without the data source.
|
|
189
|
+
ROSOTACOM_STAGE_PRODUCERS = frozenset({"heartbeat_echo", "preprocessing", "relay_out", "bridge_out"})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def outbound_startup_gaps(snapshot: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
193
|
+
"""Where each outbound topic first has no publisher at all, and whose fault it is.
|
|
194
|
+
|
|
195
|
+
Only outbound topics: their stages are produced on this machine. Inbound
|
|
196
|
+
stages depend on the peer and the link, which the per-topic rollup already
|
|
197
|
+
diagnoses.
|
|
198
|
+
|
|
199
|
+
Only the *first* absent stage per topic, because the ones behind it are
|
|
200
|
+
absent for the trivial reason that nothing reached them, and reporting a
|
|
201
|
+
cascade as four findings buries the one that matters.
|
|
202
|
+
|
|
203
|
+
`owner` is what makes the finding actionable, and it takes three values that
|
|
204
|
+
look identical in the graph:
|
|
205
|
+
|
|
206
|
+
* ``rosotacom`` -- a stage this peer starts itself, whose input is arriving.
|
|
207
|
+
It had something to forward and forwarded nothing. This is the defect.
|
|
208
|
+
* ``application`` -- the native stage of a topic the user's application (or a
|
|
209
|
+
replay) publishes. Not this peer's promise.
|
|
210
|
+
* ``upstream`` -- a stage this peer starts whose input has not delivered a
|
|
211
|
+
message yet. Its publisher appears on discovery of the input, so there is
|
|
212
|
+
nothing wrong: `universal_ota_wrapper` has no `…/ota_stamped` publisher
|
|
213
|
+
until something publishes the topic it wraps.
|
|
214
|
+
|
|
215
|
+
This exists because of a failure that produced no error anywhere. On
|
|
216
|
+
2026-08-13 the centre's `heartbeat_echo` and one `topic_monitor` died two
|
|
217
|
+
seconds after start -- Cyclone had no free participant index left on the
|
|
218
|
+
local domain -- and catmux left both panes sitting at a shell prompt. The
|
|
219
|
+
session ran for two hours with no heartbeat publisher, so the run has no RTT
|
|
220
|
+
and no clock-offset measurement, and the far side reported `status=LOST
|
|
221
|
+
reason=age hz=0.00` about a peer that was otherwise healthy. The heartbeat is
|
|
222
|
+
the one outbound topic rosotacom publishes itself, which is why the
|
|
223
|
+
generator marks its native stage `heartbeat_echo` rather than `application`.
|
|
224
|
+
"""
|
|
225
|
+
gaps: List[Dict[str, Any]] = []
|
|
226
|
+
for topic in snapshot.get("topics", []):
|
|
227
|
+
if topic.get("direction") != "outbound":
|
|
228
|
+
continue
|
|
229
|
+
upstream: Optional[Dict[str, Any]] = None
|
|
230
|
+
for stage in topic.get("stages", []):
|
|
231
|
+
if stage.get("state") != ABSENT:
|
|
232
|
+
upstream = stage
|
|
233
|
+
continue
|
|
234
|
+
producer = stage.get("produced_by")
|
|
235
|
+
if producer not in ROSOTACOM_STAGE_PRODUCERS:
|
|
236
|
+
owner = "application"
|
|
237
|
+
reason = "the application that publishes this topic is not running"
|
|
238
|
+
elif upstream is not None and upstream.get("state") not in (FLOWING, STALE):
|
|
239
|
+
owner = "upstream"
|
|
240
|
+
reason = (
|
|
241
|
+
f"nothing has arrived on {upstream.get('topic')} "
|
|
242
|
+
f"(stage {upstream.get('stage')}), so there is nothing to publish yet"
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
owner = "rosotacom"
|
|
246
|
+
reason = f"{producer} did not come up"
|
|
247
|
+
gaps.append(
|
|
248
|
+
{
|
|
249
|
+
"base": topic.get("base"),
|
|
250
|
+
"stage": stage.get("stage"),
|
|
251
|
+
"topic": stage.get("topic"),
|
|
252
|
+
"domain": stage.get("domain"),
|
|
253
|
+
"produced_by": producer,
|
|
254
|
+
"owner": owner,
|
|
255
|
+
"reason": reason,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
break
|
|
259
|
+
return gaps
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
#: How far back a sequence number may jump and still be called reordering.
|
|
263
|
+
#: `universal_ota_wrapper` counts per topic from zero and increments by one, so
|
|
264
|
+
#: a receiver sees a backward jump only when the wire reordered a message or
|
|
265
|
+
#: when the publishing peer restarted. Reordering is bounded by the transport's
|
|
266
|
+
#: history depth (single digits for the QoS this stack uses); a restart lands
|
|
267
|
+
#: thousands of sequence numbers back. 64 sits between the two by two orders of
|
|
268
|
+
#: magnitude on both sides.
|
|
269
|
+
SEQUENCE_REORDER_TOLERANCE = 64
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def is_sequence_epoch_reset(
|
|
273
|
+
seq: int,
|
|
274
|
+
expected_next_seq: Optional[int],
|
|
275
|
+
tolerance: int = SEQUENCE_REORDER_TOLERANCE,
|
|
276
|
+
) -> bool:
|
|
277
|
+
"""Does ``seq`` begin a new sequence epoch (the peer's wrapper restarted)?
|
|
278
|
+
|
|
279
|
+
Zero is unambiguous and was the only case handled before. It is also the
|
|
280
|
+
case that almost never arrives: DDS discovery takes long enough that the
|
|
281
|
+
first tens of messages of a fresh publisher are written before the reader
|
|
282
|
+
has matched. In the 2026-08-13 field instance the peer restarted four times
|
|
283
|
+
and the first arrival after each restart carried seq 37, 39, 39 and 41 --
|
|
284
|
+
never 0. Every one of them was therefore counted as `reordered`, and
|
|
285
|
+
because the baseline was never reset, so was every message after it: 26,453
|
|
286
|
+
of 55,437 transits on one 10 Hz topic, for the rest of the instance.
|
|
287
|
+
|
|
288
|
+
So a large backward jump counts as a restart too. It is corroborated by an
|
|
289
|
+
arrival gap (105 s, 32 s, 44 s and 195 s in that instance), but the gap is
|
|
290
|
+
not required: a fast restart is still a restart, and the jump alone already
|
|
291
|
+
separates the two causes by orders of magnitude.
|
|
292
|
+
"""
|
|
293
|
+
if expected_next_seq is None or seq >= expected_next_seq:
|
|
294
|
+
return False
|
|
295
|
+
if seq == 0:
|
|
296
|
+
return True
|
|
297
|
+
return (expected_next_seq - seq) > tolerance
|
|
298
|
+
|
|
299
|
+
|
|
178
300
|
# --- Link overhead (session-level) -----------------------------------------
|
|
179
301
|
|
|
180
302
|
def _payload_kbps(stage: Optional[Dict[str, Any]]) -> float:
|
|
@@ -258,6 +380,8 @@ class StageObservation:
|
|
|
258
380
|
expected_next_seq: Optional[int] = None
|
|
259
381
|
last_seq: Optional[int] = None
|
|
260
382
|
tracks_sequence: bool = False
|
|
383
|
+
epoch: int = 0
|
|
384
|
+
epoch_resets: int = 0
|
|
261
385
|
total_expected: int = 0
|
|
262
386
|
total_missing: int = 0
|
|
263
387
|
total_reordered: int = 0
|
|
@@ -302,11 +426,15 @@ class StageObservation:
|
|
|
302
426
|
if self.expected_next_seq is None:
|
|
303
427
|
expected_delta = 1
|
|
304
428
|
self.expected_next_seq = seq + 1
|
|
305
|
-
elif seq
|
|
306
|
-
# A publisher restart begins a new sequence epoch
|
|
307
|
-
#
|
|
429
|
+
elif is_sequence_epoch_reset(seq, self.expected_next_seq):
|
|
430
|
+
# A publisher restart begins a new sequence epoch: the
|
|
431
|
+
# wrapper counts from zero again. See
|
|
432
|
+
# `is_sequence_epoch_reset` for why the first arrival after
|
|
433
|
+
# a restart is almost never seq 0, and what that cost.
|
|
434
|
+
self.epoch += 1
|
|
435
|
+
self.epoch_resets += 1
|
|
308
436
|
expected_delta = 1
|
|
309
|
-
self.expected_next_seq = 1
|
|
437
|
+
self.expected_next_seq = seq + 1
|
|
310
438
|
elif seq >= self.expected_next_seq:
|
|
311
439
|
missing = seq - self.expected_next_seq
|
|
312
440
|
missing_start = self.expected_next_seq if missing else None
|
|
@@ -331,6 +459,10 @@ class StageObservation:
|
|
|
331
459
|
"topic": transit.get("topic"),
|
|
332
460
|
"direction": transit.get("direction"),
|
|
333
461
|
"stage": transit.get("stage"),
|
|
462
|
+
# Which run of the peer's wrapper produced this sequence
|
|
463
|
+
# number. Without it the offline join collapses seq 0..N of
|
|
464
|
+
# every epoch after the first onto the first epoch's rows.
|
|
465
|
+
"epoch": self.epoch,
|
|
334
466
|
}
|
|
335
467
|
if missing_start is not None:
|
|
336
468
|
for missing_seq in range(missing_start, int(seq)):
|
|
@@ -421,6 +553,8 @@ class StageObservation:
|
|
|
421
553
|
total_missing = self.total_missing
|
|
422
554
|
total_reordered = self.total_reordered
|
|
423
555
|
total_max_burst = self.max_burst_missing
|
|
556
|
+
epoch = self.epoch
|
|
557
|
+
epoch_resets = self.epoch_resets
|
|
424
558
|
hz = count / window_s if window_s > 0 else 0.0
|
|
425
559
|
mean_size = (total_bytes / count) if count > 0 else 0.0
|
|
426
560
|
age = (now_mono - last_recv_mono) if last_recv_mono is not None else None
|
|
@@ -441,6 +575,8 @@ class StageObservation:
|
|
|
441
575
|
"loss_total_pct": _pct(total_missing, total_expected) if tracks_sequence else None,
|
|
442
576
|
"reordered_total": total_reordered if tracks_sequence else None,
|
|
443
577
|
"max_burst_missing_total": total_max_burst if tracks_sequence else None,
|
|
578
|
+
"epoch": epoch if tracks_sequence else None,
|
|
579
|
+
"epoch_resets": epoch_resets if tracks_sequence else None,
|
|
444
580
|
}
|
|
445
581
|
|
|
446
582
|
def drain_transit_records(self) -> List[Dict[str, Any]]:
|
|
@@ -464,7 +600,9 @@ class StatusAggregator:
|
|
|
464
600
|
*, liveness_window_s: float = 3.0, stale_after_s: float = 3.0,
|
|
465
601
|
delay_good_ms: float = 100.0, delay_bad_ms: float = 200.0,
|
|
466
602
|
link_sampler: Any = None, clock_estimator: Any = None,
|
|
467
|
-
link_trace_recorder: Any = None
|
|
603
|
+
link_trace_recorder: Any = None,
|
|
604
|
+
startup_grace_s: float = STARTUP_GRACE_S,
|
|
605
|
+
started_mono: Optional[float] = None):
|
|
468
606
|
self._log = logger
|
|
469
607
|
self._spec = spec
|
|
470
608
|
self._output_dir = output_dir
|
|
@@ -484,6 +622,21 @@ class StatusAggregator:
|
|
|
484
622
|
self._json_path = os.path.join(self._output_dir, "status.json")
|
|
485
623
|
self._txt_path = os.path.join(self._output_dir, "status.txt")
|
|
486
624
|
self._events_path = os.path.join(self._output_dir, "events.jsonl")
|
|
625
|
+
self._startup_path = os.path.join(self._output_dir, "startup_check.json")
|
|
626
|
+
# `pane_failures.log` is written by catmux_log_setup.sh one level up, in
|
|
627
|
+
# logs/<peer>/. The verdict quotes it because a pane that exited is the
|
|
628
|
+
# most common reason a promised publisher never appears, and the two
|
|
629
|
+
# facts are useless apart.
|
|
630
|
+
self._pane_failure_path = os.path.join(
|
|
631
|
+
os.path.dirname(os.path.abspath(self._output_dir)), "pane_failures.log"
|
|
632
|
+
)
|
|
633
|
+
self._startup_grace_s = startup_grace_s
|
|
634
|
+
self._started_mono = time.monotonic() if started_mono is None else started_mono
|
|
635
|
+
self._startup_check: Optional[Dict[str, Any]] = None
|
|
636
|
+
#: (when, topics) of the first evaluation that found a defect. A verdict
|
|
637
|
+
#: is only committed once the same defect survives a second look; see
|
|
638
|
+
#: `_maybe_run_startup_check`.
|
|
639
|
+
self._startup_pending: Optional[Tuple[float, set]] = None
|
|
487
640
|
|
|
488
641
|
# -- observation lookup --
|
|
489
642
|
def _obs_for(self, stage: Dict[str, Any]) -> Optional[StageObservation]:
|
|
@@ -516,6 +669,8 @@ class StatusAggregator:
|
|
|
516
669
|
"reordered_total": None,
|
|
517
670
|
"max_burst_missing": None,
|
|
518
671
|
"max_burst_missing_total": None,
|
|
672
|
+
"epoch": None,
|
|
673
|
+
"epoch_resets": None,
|
|
519
674
|
"age_s": None,
|
|
520
675
|
"last_message_wall": None,
|
|
521
676
|
"messages_total": 0,
|
|
@@ -551,6 +706,8 @@ class StatusAggregator:
|
|
|
551
706
|
"reordered_total",
|
|
552
707
|
"max_burst_missing",
|
|
553
708
|
"max_burst_missing_total",
|
|
709
|
+
"epoch",
|
|
710
|
+
"epoch_resets",
|
|
554
711
|
):
|
|
555
712
|
result[key] = m[key]
|
|
556
713
|
if m["last_recv_wall"] is not None:
|
|
@@ -589,6 +746,8 @@ class StatusAggregator:
|
|
|
589
746
|
"reordered_total",
|
|
590
747
|
"max_burst_missing",
|
|
591
748
|
"max_burst_missing_total",
|
|
749
|
+
"epoch",
|
|
750
|
+
"epoch_resets",
|
|
592
751
|
"age_s",
|
|
593
752
|
"last_message_wall",
|
|
594
753
|
"messages_total",
|
|
@@ -838,6 +997,7 @@ class StatusAggregator:
|
|
|
838
997
|
"summary": counts,
|
|
839
998
|
"link": compute_link_overview(topics_out, link_sample),
|
|
840
999
|
"clock_sync": self._clock_snapshot(now_mono),
|
|
1000
|
+
"startup_check": self._startup_check,
|
|
841
1001
|
"topics": topics_out,
|
|
842
1002
|
}
|
|
843
1003
|
|
|
@@ -915,6 +1075,20 @@ class StatusAggregator:
|
|
|
915
1075
|
f"summary: OK={s.get('OK', 0)} PARTIAL={s.get('PARTIAL', 0)} "
|
|
916
1076
|
f"STALLED={s.get('STALLED', 0)} ABSENT={s.get('ABSENT', 0)} (Phase 1: local observation)"
|
|
917
1077
|
)
|
|
1078
|
+
# The STAT watcher pane shows this file, so a failed startup check has to
|
|
1079
|
+
# be readable here or it is not surfaced where anybody looks.
|
|
1080
|
+
check = snapshot.get("startup_check")
|
|
1081
|
+
if check and check.get("verdict") != "ok":
|
|
1082
|
+
gaps = check.get("missing_outbound_stages") or []
|
|
1083
|
+
panes = check.get("pane_failures") or []
|
|
1084
|
+
lines.append(
|
|
1085
|
+
f"STARTUP CHECK FAILED after {check.get('checked_after_s')}s: "
|
|
1086
|
+
f"{len(gaps)} outbound stage(s) without a publisher, {len(panes)} pane(s) exited"
|
|
1087
|
+
)
|
|
1088
|
+
for gap in gaps:
|
|
1089
|
+
lines.append(f" no publisher: {gap.get('topic')} ({gap.get('produced_by')})")
|
|
1090
|
+
for line in panes:
|
|
1091
|
+
lines.append(f" pane exited: {line}")
|
|
918
1092
|
lines.append("")
|
|
919
1093
|
for t in snapshot["topics"]:
|
|
920
1094
|
arrow = "->" if t["direction"] == "outbound" else "<-"
|
|
@@ -962,6 +1136,101 @@ class StatusAggregator:
|
|
|
962
1136
|
fp.write(text)
|
|
963
1137
|
os.replace(tmp, path)
|
|
964
1138
|
|
|
1139
|
+
def _read_pane_failures(self) -> List[str]:
|
|
1140
|
+
try:
|
|
1141
|
+
with open(self._pane_failure_path, "r", encoding="utf-8", errors="replace") as fp:
|
|
1142
|
+
return [line.rstrip("\n") for line in fp if line.strip()]
|
|
1143
|
+
except OSError:
|
|
1144
|
+
return []
|
|
1145
|
+
|
|
1146
|
+
def _maybe_run_startup_check(
|
|
1147
|
+
self, snapshot: Dict[str, Any], now_mono: Optional[float]
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""Once, after the grace period: did this peer bring up what it promised?
|
|
1150
|
+
|
|
1151
|
+
Written as a verdict at a defined moment rather than left implicit in a
|
|
1152
|
+
table that is refreshed every two seconds. `status.json` already carried
|
|
1153
|
+
the evidence on 2026-08-13 -- 13 stages STALLED, `clock_sync: null` --
|
|
1154
|
+
and nobody read it, because nothing said "this is wrong now".
|
|
1155
|
+
"""
|
|
1156
|
+
if self._startup_check is not None or self._startup_grace_s <= 0.0:
|
|
1157
|
+
return
|
|
1158
|
+
now = time.monotonic() if now_mono is None else now_mono
|
|
1159
|
+
if now - self._started_mono < self._startup_grace_s:
|
|
1160
|
+
return
|
|
1161
|
+
|
|
1162
|
+
gaps = outbound_startup_gaps(snapshot)
|
|
1163
|
+
mine = [gap for gap in gaps if gap["owner"] == "rosotacom"]
|
|
1164
|
+
theirs = [gap for gap in gaps if gap["owner"] != "rosotacom"]
|
|
1165
|
+
pane_failures = self._read_pane_failures()
|
|
1166
|
+
|
|
1167
|
+
# A stage can be late rather than dead. `relay_out` and the wrapper
|
|
1168
|
+
# create their publisher on *discovering* their input, on a refresh
|
|
1169
|
+
# interval, so an input that started publishing shortly before this
|
|
1170
|
+
# moment leaves a gap that is about to close on its own. Waiting a
|
|
1171
|
+
# second grace period and reporting only what is still missing
|
|
1172
|
+
# separates the two without weakening either: a node that exited never
|
|
1173
|
+
# recovers. CI found this the way it should be found -- the smoke that
|
|
1174
|
+
# starts its payload publisher last failed on a topic whose relay was
|
|
1175
|
+
# working seconds later.
|
|
1176
|
+
if mine and self._startup_pending is None:
|
|
1177
|
+
self._startup_pending = (now, {gap["topic"] for gap in mine})
|
|
1178
|
+
return
|
|
1179
|
+
if mine and self._startup_pending is not None:
|
|
1180
|
+
first_seen_at, first_seen = self._startup_pending
|
|
1181
|
+
if now - first_seen_at < self._startup_grace_s:
|
|
1182
|
+
return
|
|
1183
|
+
mine = [gap for gap in mine if gap["topic"] in first_seen]
|
|
1184
|
+
verdict = "ok" if not mine and not pane_failures else "incomplete"
|
|
1185
|
+
self._startup_check = {
|
|
1186
|
+
"verdict": verdict,
|
|
1187
|
+
"checked_after_s": round(now - self._started_mono, 1),
|
|
1188
|
+
"generated_at": snapshot.get("generated_at"),
|
|
1189
|
+
"peer": snapshot.get("peer"),
|
|
1190
|
+
"missing_outbound_stages": mine,
|
|
1191
|
+
"waiting_for_input": theirs,
|
|
1192
|
+
"pane_failures": pane_failures,
|
|
1193
|
+
}
|
|
1194
|
+
snapshot["startup_check"] = self._startup_check
|
|
1195
|
+
try:
|
|
1196
|
+
self._atomic_write(
|
|
1197
|
+
self._startup_path, json.dumps(self._startup_check, indent=2) + "\n"
|
|
1198
|
+
)
|
|
1199
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
1200
|
+
if self._log is not None:
|
|
1201
|
+
self._log.warning(f"status_overview: failed to write startup check: {exc}")
|
|
1202
|
+
if self._log is None:
|
|
1203
|
+
return
|
|
1204
|
+
if theirs:
|
|
1205
|
+
# Not a defect and deliberately not an error: whether the data source
|
|
1206
|
+
# is publishing is the application's business, not this peer's, and a
|
|
1207
|
+
# stage with no input has nothing to publish.
|
|
1208
|
+
self._log.info(
|
|
1209
|
+
f"status_overview: startup check -- {len(theirs)} outbound topic(s) are "
|
|
1210
|
+
"waiting for input: "
|
|
1211
|
+
+ ", ".join(gap["topic"] for gap in theirs[:8])
|
|
1212
|
+
+ (" ..." if len(theirs) > 8 else "")
|
|
1213
|
+
)
|
|
1214
|
+
if verdict == "ok":
|
|
1215
|
+
self._log.info(
|
|
1216
|
+
"status_overview: startup check OK -- every stage this peer produces "
|
|
1217
|
+
f"for {snapshot.get('peer')} has a publisher"
|
|
1218
|
+
)
|
|
1219
|
+
return
|
|
1220
|
+
self._log.error(
|
|
1221
|
+
f"status_overview: STARTUP CHECK FAILED for peer {snapshot.get('peer')} "
|
|
1222
|
+
f"after {self._startup_check['checked_after_s']}s -- "
|
|
1223
|
+
f"{len(mine)} stage(s) this peer produces have no publisher, "
|
|
1224
|
+
f"{len(pane_failures)} pane(s) exited. See {self._startup_path}"
|
|
1225
|
+
)
|
|
1226
|
+
for gap in mine:
|
|
1227
|
+
self._log.error(
|
|
1228
|
+
f"status_overview: no publisher on {gap['topic']} "
|
|
1229
|
+
f"(stage {gap['stage']}, produced_by {gap['produced_by']})"
|
|
1230
|
+
)
|
|
1231
|
+
for line in pane_failures:
|
|
1232
|
+
self._log.error(f"status_overview: pane exited: {line}")
|
|
1233
|
+
|
|
965
1234
|
def write(self, now_mono: Optional[float] = None) -> int:
|
|
966
1235
|
"""Build a snapshot, write artifacts, and return number of transition events."""
|
|
967
1236
|
link_sample = None
|
|
@@ -972,6 +1241,7 @@ class StatusAggregator:
|
|
|
972
1241
|
if self._log is not None:
|
|
973
1242
|
self._log.warning(f"status_overview: link sampling failed: {exc}")
|
|
974
1243
|
snapshot = self.build_snapshot(now_mono, link_sample=link_sample)
|
|
1244
|
+
self._maybe_run_startup_check(snapshot, now_mono)
|
|
975
1245
|
if self._link_trace_recorder is not None:
|
|
976
1246
|
try:
|
|
977
1247
|
self._link_trace_recorder.maybe_write(snapshot, link_sample)
|
|
@@ -47,7 +47,7 @@ from rosidl_runtime_py.utilities import get_message
|
|
|
47
47
|
from rclpy.serialization import serialize_message
|
|
48
48
|
from std_msgs.msg import Float64
|
|
49
49
|
|
|
50
|
-
from com_py.link_bytes import LinkByteSampler
|
|
50
|
+
from com_py.link_bytes import LinkByteSampler, resolve_link_interface
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def is_internal_transport_topic(topic_name: str) -> bool:
|
|
@@ -99,7 +99,9 @@ class TopicMonitor(Node):
|
|
|
99
99
|
self.declare_parameter('to_adressant', '') # optional, e.g., 'shuttle_ella' for /to_shuttle_ella
|
|
100
100
|
self.declare_parameter("ip_local", "")
|
|
101
101
|
self.declare_parameter("ip_remote", "")
|
|
102
|
-
|
|
102
|
+
# Explicit override for the OTA link interface. Normally left empty:
|
|
103
|
+
# it is resolved from ip_local below.
|
|
104
|
+
self.declare_parameter("interface", "")
|
|
103
105
|
# Accepted for launch compatibility; the /proc/net/dev sampler needs no
|
|
104
106
|
# capture window, so this tshark-era buffer is no longer used.
|
|
105
107
|
self.declare_parameter("link_duration_buffer_s", 1.0)
|
|
@@ -109,9 +111,9 @@ class TopicMonitor(Node):
|
|
|
109
111
|
self.sourcename = self.get_parameter('sourcename').value
|
|
110
112
|
self.direction = self.get_parameter('direction').value
|
|
111
113
|
self.to_adressant = self.get_parameter('to_adressant').value
|
|
112
|
-
self.host_ip = self.get_parameter("ip_local").value
|
|
113
|
-
self.peer_ip = self.get_parameter("ip_remote").value
|
|
114
|
-
self.interface = self.get_parameter("interface").value
|
|
114
|
+
self.host_ip = str(self.get_parameter("ip_local").value or "").strip()
|
|
115
|
+
self.peer_ip = str(self.get_parameter("ip_remote").value or "").strip()
|
|
116
|
+
self.interface = str(self.get_parameter("interface").value or "")
|
|
115
117
|
|
|
116
118
|
# Track actual elapsed time between prints (avoid timer drift issues)
|
|
117
119
|
self._last_print_t = time.monotonic()
|
|
@@ -131,8 +133,22 @@ class TopicMonitor(Node):
|
|
|
131
133
|
else:
|
|
132
134
|
self.topic_prefix = f"/com/{self.direction}/{self.sourcename}/"
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
# The OTA link interface. `ip_local` is the peer's own OTA address, so
|
|
137
|
+
# the interface follows from it -- the same resolution status_overview
|
|
138
|
+
# does, in the same place, from the same helper. `interface` stays an
|
|
139
|
+
# explicit override for a setup the address cannot describe.
|
|
140
|
+
#
|
|
141
|
+
# It used to be resolved in the session template instead, by a shell
|
|
142
|
+
# line that expanded a catmux parameter with bash's substring-replace
|
|
143
|
+
# syntax. catmux substitutes `${name}` and nothing else, so the pattern
|
|
144
|
+
# stayed literal, bash expanded an unset variable to nothing, and the
|
|
145
|
+
# awk program collapsed to "first address line with a prefix length" --
|
|
146
|
+
# `lo`, on every machine. See docs/link-bandwidth.md.
|
|
147
|
+
try:
|
|
148
|
+
self.interface, provenance = resolve_link_interface(self.interface, self.host_ip)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise ValueError(f"topic_monitor: {exc}") from exc
|
|
151
|
+
self.get_logger().info(f"OTA link interface '{self.interface}' ({provenance})")
|
|
136
152
|
|
|
137
153
|
# Link bandwidth is measured from the kernel's own interface byte counters
|
|
138
154
|
# (/proc/net/dev) via the shared link_bytes sampler -- no tshark, no sudo,
|
|
@@ -163,8 +179,14 @@ class TopicMonitor(Node):
|
|
|
163
179
|
)
|
|
164
180
|
|
|
165
181
|
self.get_logger().info(f"topic_monitor node started. Searching for topics with prefix: {self.topic_prefix}")
|
|
166
|
-
self.get_logger().info(
|
|
167
|
-
|
|
182
|
+
self.get_logger().info(
|
|
183
|
+
f"Publishing ROS topic bandwidth to: {self.ros_topic_bandwidth_topic} "
|
|
184
|
+
f"(serialized payload of {self.topic_prefix}*, Kibit/s)"
|
|
185
|
+
)
|
|
186
|
+
self.get_logger().info(
|
|
187
|
+
f"Publishing link bandwidth to: {self.link_bandwidth_topic} "
|
|
188
|
+
f"(wire bytes on '{self.interface}', {'tx' if self.direction == 'out' else 'rx'}, Kibit/s)"
|
|
189
|
+
)
|
|
168
190
|
|
|
169
191
|
# Prime the link sampler so the first print reports a real delta.
|
|
170
192
|
self._link_sampler.sample()
|
|
@@ -62,6 +62,9 @@ parameters:
|
|
|
62
62
|
status_write_interval_s: 2.0
|
|
63
63
|
status_liveness_window_s: 3.0
|
|
64
64
|
status_stale_after_s: 3.0
|
|
65
|
+
# One-shot verdict N seconds after start: does every outbound stage this peer
|
|
66
|
+
# promises actually have a publisher, and did any pane exit? 0 disables it.
|
|
67
|
+
status_startup_grace_s: 20.0
|
|
65
68
|
link_trace: false
|
|
66
69
|
link_trace_interval_s: 1.0
|
|
67
70
|
link_trace_modem_command: ""
|
|
@@ -330,12 +333,16 @@ windows:
|
|
|
330
333
|
- name: TM
|
|
331
334
|
if: topic_monitor
|
|
332
335
|
splits:
|
|
336
|
+
# No `interface=` line: the node resolves the OTA interface from ip_local
|
|
337
|
+
# itself (com_py.link_bytes.find_interface_for_ip), exactly as the STAT
|
|
338
|
+
# window's status_overview does. The line that used to stand here expanded
|
|
339
|
+
# a catmux parameter with bash substring-replace syntax, which catmux does
|
|
340
|
+
# not substitute -- so it resolved `lo` on every machine and reported this
|
|
341
|
+
# host's own ROS traffic as link bandwidth (#267).
|
|
333
342
|
- commands:
|
|
334
|
-
-
|
|
335
|
-
- ros2 run com_py topic_monitor --ros-args -p sourcename:=${remote_name} -p direction:=in -p to_adressant:='${tm_in_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p interface:=${interface} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
|
|
343
|
+
- ros2 run com_py topic_monitor --ros-args -p sourcename:=${remote_name} -p direction:=in -p to_adressant:='${tm_in_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
|
|
336
344
|
- commands:
|
|
337
|
-
-
|
|
338
|
-
- ros2 run com_py topic_monitor --ros-args -p sourcename:=${local_name} -p direction:=out -p to_adressant:='${tm_out_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p interface:=${interface} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
|
|
345
|
+
- ros2 run com_py topic_monitor --ros-args -p sourcename:=${local_name} -p direction:=out -p to_adressant:='${tm_out_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
|
|
339
346
|
- name: STAT
|
|
340
347
|
if: status_overview
|
|
341
348
|
layout: even-vertical
|
|
@@ -352,6 +359,7 @@ windows:
|
|
|
352
359
|
-p write_interval_s:=${status_write_interval_s}
|
|
353
360
|
-p liveness_window_s:=${status_liveness_window_s}
|
|
354
361
|
-p stale_after_s:=${status_stale_after_s}
|
|
362
|
+
-p startup_grace_s:=${status_startup_grace_s}
|
|
355
363
|
-p ota_local_ip:=${ip_local}
|
|
356
364
|
-p link_trace:=${link_trace}
|
|
357
365
|
-p link_trace_interval_s:=${link_trace_interval_s}
|
|
@@ -360,12 +368,15 @@ windows:
|
|
|
360
368
|
- commands:
|
|
361
369
|
- status_out_dir="$(dirname "${ROSOTACOM_CATMUX_LOG_DIR:-/tmp/rosotacom_logs/catmux}")/status"
|
|
362
370
|
- status_txt="$status_out_dir/status.txt"
|
|
363
|
-
- 'while true; do clear; echo "[INFO] watching status overview file: $status_txt"; if [ -f "$status_txt" ]; then cat "$status_txt"; else echo "[INFO] waiting for status_overview to write status.txt"; fi; sleep "${status_write_interval_s
|
|
371
|
+
- 'while true; do clear; echo "[INFO] watching status overview file: $status_txt"; if [ -f "$status_txt" ]; then cat "$status_txt"; else echo "[INFO] waiting for status_overview to write status.txt"; fi; sleep "${status_write_interval_s}"; done'
|
|
364
372
|
- name: NET
|
|
365
373
|
if: network_monitor
|
|
366
374
|
splits:
|
|
367
375
|
- commands:
|
|
368
|
-
|
|
376
|
+
# Exact match on the address column, with the address passed in through
|
|
377
|
+
# awk -v: no shell pattern expansion, so nothing here depends on catmux
|
|
378
|
+
# substituting a form it does not know (#267).
|
|
379
|
+
- interface=$(ip -o -4 addr show | awk -v ip='${ip_local}' '{split($4, a, "/"); if (a[1] == ip) {print $2; exit}}')
|
|
369
380
|
- sudo iftop -i "$interface" -f "host ${ip_local} and host ${ip_remote}"
|
|
370
381
|
- name: METRIC_BAG
|
|
371
382
|
if: metric_stage_bag
|
|
@@ -52,6 +52,50 @@ fi
|
|
|
52
52
|
tmux pipe-pane -o -t "${TMUX_PANE}" \
|
|
53
53
|
"${__rosotacom_pipe_filter} >> '${__rosotacom_pane_log_file//\'/\'\\\'\'}'"
|
|
54
54
|
|
|
55
|
+
# --- a pane whose command dies must say so -----------------------------------
|
|
56
|
+
#
|
|
57
|
+
# A pane log is only read by somebody who already suspects the pane. On
|
|
58
|
+
# 2026-08-13 the centre's `heartbeat_echo` and one `topic_monitor` exited two
|
|
59
|
+
# seconds after start (Cyclone had no free participant index left on domain 46),
|
|
60
|
+
# catmux left both panes at a shell prompt, and the session ran for two hours
|
|
61
|
+
# with no heartbeat publisher. Nothing anywhere said so: `docker ps` showed a
|
|
62
|
+
# healthy container, the other panes worked, and the far side blamed the link.
|
|
63
|
+
#
|
|
64
|
+
# So every non-zero exit of a pane command is appended to one file per peer,
|
|
65
|
+
# which the status overview's startup check reads and quotes. 130 (Ctrl-C) is
|
|
66
|
+
# excluded: that is an operator stopping a pane on purpose.
|
|
67
|
+
ROSOTACOM_PANE_FAILURE_LOG="$(dirname "${ROSOTACOM_CATMUX_LOG_DIR}")/pane_failures.log"
|
|
68
|
+
ROSOTACOM_PANE_LABEL="${__rosotacom_window_prefix}-${__rosotacom_window_safe}/${__rosotacom_pane}"
|
|
69
|
+
export ROSOTACOM_PANE_FAILURE_LOG ROSOTACOM_PANE_LABEL
|
|
70
|
+
|
|
71
|
+
__rosotacom_note_exit() {
|
|
72
|
+
local __status=$?
|
|
73
|
+
if [ "${__status}" -ne 0 ] && [ "${__status}" -ne 130 ]; then
|
|
74
|
+
local __cmd
|
|
75
|
+
__cmd="$(HISTTIMEFORMAT='' builtin history 1 2>/dev/null | sed 's/^[[:space:]]*[0-9]*[[:space:]]*//')"
|
|
76
|
+
printf '%s\tpane=%s\texit=%s\tcommand=%s\n' \
|
|
77
|
+
"$(date -Iseconds)" "${ROSOTACOM_PANE_LABEL}" "${__status}" "${__cmd}" \
|
|
78
|
+
>> "${ROSOTACOM_PANE_FAILURE_LOG}" 2>/dev/null
|
|
79
|
+
printf '\n[rosotacom] pane %s: command exited %s -- this pane is no longer doing its job.\n' \
|
|
80
|
+
"${ROSOTACOM_PANE_LABEL}" "${__status}" >&2
|
|
81
|
+
printf '[rosotacom] recorded in %s\n\n' "${ROSOTACOM_PANE_FAILURE_LOG}" >&2
|
|
82
|
+
fi
|
|
83
|
+
return "${__status}"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case "$-" in
|
|
87
|
+
*i*)
|
|
88
|
+
# Deliberately not exported: a child shell would inherit the hook without
|
|
89
|
+
# the function and complain at every prompt.
|
|
90
|
+
if [[ ";${PROMPT_COMMAND:-};" != *";__rosotacom_note_exit;"* ]]; then
|
|
91
|
+
PROMPT_COMMAND="__rosotacom_note_exit;${PROMPT_COMMAND:+${PROMPT_COMMAND}}"
|
|
92
|
+
fi
|
|
93
|
+
;;
|
|
94
|
+
*)
|
|
95
|
+
echo "[rosotacom_catmux_log] non-interactive shell; pane-exit reporting disabled" >&2
|
|
96
|
+
;;
|
|
97
|
+
esac
|
|
98
|
+
|
|
55
99
|
{
|
|
56
100
|
printf '\n--- rosotacom catmux pipe-pane started %s ---\n' "$(date -Iseconds)"
|
|
57
101
|
printf ' window=%s (#%s) pane=%s\n' \
|
|
@@ -1413,6 +1413,12 @@ def _build_status_pipeline_spec(
|
|
|
1413
1413
|
outbound_items.insert(0, (hb_entry, hb_pipe))
|
|
1414
1414
|
|
|
1415
1415
|
for e, p in outbound_items:
|
|
1416
|
+
# Who publishes the native stage decides who is answerable when it is
|
|
1417
|
+
# missing. The heartbeat is the one outbound topic rosotacom publishes
|
|
1418
|
+
# itself (`heartbeat_echo`), so its absence is a defect here rather
|
|
1419
|
+
# than an application that has not started -- which is exactly the
|
|
1420
|
+
# 2026-08-13 failure the startup check exists for.
|
|
1421
|
+
native_produced_by = "heartbeat_echo" if e.index == -1 else "application"
|
|
1416
1422
|
base = e.base
|
|
1417
1423
|
final = p.get("final", base)
|
|
1418
1424
|
forward_final = f"/to_{remote_name}{final}" if use_target_prefix else final
|
|
@@ -1441,7 +1447,7 @@ def _build_status_pipeline_spec(
|
|
|
1441
1447
|
"topic": native_topic,
|
|
1442
1448
|
"type": native_type,
|
|
1443
1449
|
"domain": "local",
|
|
1444
|
-
"produced_by":
|
|
1450
|
+
"produced_by": native_produced_by,
|
|
1445
1451
|
},
|
|
1446
1452
|
]
|
|
1447
1453
|
if final != base and not is_g2l:
|
rosotacom/transit.py
CHANGED
|
@@ -32,15 +32,34 @@ def load_transit_records(paths: Iterable[Path]) -> list[dict[str, Any]]:
|
|
|
32
32
|
return records
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def record_epoch(record: dict[str, Any]) -> int:
|
|
36
|
+
"""Which run of the publishing peer's wrapper produced this record.
|
|
37
|
+
|
|
38
|
+
A peer restart resets ``seq`` to zero mid-instance, so ``(source, topic,
|
|
39
|
+
seq)`` is not unique over an instance that outlived one. Records written
|
|
40
|
+
before the field learned this carry no ``epoch``; they are all epoch 0,
|
|
41
|
+
which is what a single-epoch instance produces anyway.
|
|
42
|
+
"""
|
|
43
|
+
value = record.get("epoch")
|
|
44
|
+
return 0 if value is None else int(value)
|
|
45
|
+
|
|
46
|
+
|
|
35
47
|
def join_transit_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
36
|
-
"""Join duplicate observations by ``(topic, seq)
|
|
37
|
-
|
|
48
|
+
"""Join duplicate observations by ``(source, topic, epoch, seq)``.
|
|
49
|
+
|
|
50
|
+
Keeps the richest row per key. ``epoch`` is part of the key because it is
|
|
51
|
+
what makes the key unique: on 2026-08-13 the vehicle restarted four times
|
|
52
|
+
inside one centre instance, and joining on ``(source, topic, seq)`` alone
|
|
53
|
+
silently dropped 123,253 of 304,876 raw transit lines -- 40% of the run,
|
|
54
|
+
concentrated in exactly the mission window the analysis was about.
|
|
55
|
+
"""
|
|
56
|
+
joined: dict[tuple[str, str, int, int], dict[str, Any]] = {}
|
|
38
57
|
status_rank = {"lost": 0, "reordered": 1, "delivered": 2}
|
|
39
58
|
for record in records:
|
|
40
59
|
topic = str(record.get("topic") or "")
|
|
41
60
|
source = str(record.get("source") or "")
|
|
42
61
|
seq = int(record["seq"])
|
|
43
|
-
key = (source, topic, seq)
|
|
62
|
+
key = (source, topic, record_epoch(record), seq)
|
|
44
63
|
current = joined.get(key)
|
|
45
64
|
if current is None:
|
|
46
65
|
joined[key] = dict(record)
|
|
@@ -67,19 +86,26 @@ def filter_transit_records_by_publish_window(
|
|
|
67
86
|
) -> list[dict[str, Any]]:
|
|
68
87
|
"""Keep records whose publish time falls inside a measurement window.
|
|
69
88
|
|
|
70
|
-
Lost records do not carry timestamps. For each source/topic/
|
|
71
|
-
keep lost sequence records only when they are bounded by
|
|
72
|
-
records inside the window. This excludes startup and
|
|
73
|
-
without hiding losses that happened during the
|
|
89
|
+
Lost records do not carry timestamps. For each source/target/topic/epoch
|
|
90
|
+
stream, keep lost sequence records only when they are bounded by
|
|
91
|
+
delivered/reordered records inside the window. This excludes startup and
|
|
92
|
+
teardown sequence gaps without hiding losses that happened during the
|
|
93
|
+
measured interval.
|
|
94
|
+
|
|
95
|
+
The epoch belongs in the grouping key for the same reason it belongs in the
|
|
96
|
+
join key: sequence numbers only order messages within one run of the
|
|
97
|
+
publishing wrapper, so a post-restart seq 40 must not bound a pre-restart
|
|
98
|
+
seq 40's loss.
|
|
74
99
|
"""
|
|
75
100
|
if end_s < start_s:
|
|
76
101
|
raise ValueError("end_s must be >= start_s")
|
|
77
|
-
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
|
|
102
|
+
grouped: dict[tuple[str, str, str, int], list[dict[str, Any]]] = {}
|
|
78
103
|
for record in join_transit_records(records):
|
|
79
104
|
key = (
|
|
80
105
|
str(record.get("source") or ""),
|
|
81
106
|
str(record.get("target") or ""),
|
|
82
107
|
str(record.get("topic") or ""),
|
|
108
|
+
record_epoch(record),
|
|
83
109
|
)
|
|
84
110
|
grouped.setdefault(key, []).append(record)
|
|
85
111
|
|
|
@@ -111,6 +137,7 @@ def filter_transit_records_by_publish_window(
|
|
|
111
137
|
str(record.get("source") or ""),
|
|
112
138
|
str(record.get("target") or ""),
|
|
113
139
|
str(record.get("topic") or ""),
|
|
140
|
+
record_epoch(record),
|
|
114
141
|
int(record["seq"]),
|
|
115
142
|
),
|
|
116
143
|
)
|
|
@@ -173,15 +200,24 @@ def _latency_trend_ms(
|
|
|
173
200
|
|
|
174
201
|
|
|
175
202
|
def _nominal_send_period_ms(topic_records: list[dict[str, Any]], *, time_field: str = "t_wrap") -> float | None:
|
|
176
|
-
"""Median publish spacing per sequence step — the send cadence of the stream.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
periods = [
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
"""Median publish spacing per sequence step — the send cadence of the stream.
|
|
204
|
+
|
|
205
|
+
Per epoch: a sequence step only means "one message" inside one run of the
|
|
206
|
+
publishing wrapper. Across a restart the step is a reset, and dividing the
|
|
207
|
+
wall-clock gap by it produces a period that describes nothing.
|
|
208
|
+
"""
|
|
209
|
+
periods: list[float] = []
|
|
210
|
+
for epoch in {record_epoch(record) for record in topic_records}:
|
|
211
|
+
stamped = sorted(
|
|
212
|
+
(int(record["seq"]), float(record[time_field]))
|
|
213
|
+
for record in topic_records
|
|
214
|
+
if record.get(time_field) is not None and record_epoch(record) == epoch
|
|
215
|
+
)
|
|
216
|
+
periods.extend(
|
|
217
|
+
(t1 - t0) / (s1 - s0)
|
|
218
|
+
for (s0, t0), (s1, t1) in zip(stamped, stamped[1:], strict=False)
|
|
219
|
+
if s1 > s0 and t1 >= t0
|
|
220
|
+
)
|
|
185
221
|
if not periods:
|
|
186
222
|
return None
|
|
187
223
|
ordered = sorted(periods)
|
|
@@ -239,6 +275,10 @@ def summarize_transit_records(records: Iterable[dict[str, Any]]) -> dict[str, An
|
|
|
239
275
|
"expected": len(topic_records),
|
|
240
276
|
"delivered": delivered,
|
|
241
277
|
"lost": lost,
|
|
278
|
+
# >1 means the publishing peer restarted inside this instance. Kept
|
|
279
|
+
# in the summary because it changes how every rate-like number
|
|
280
|
+
# below should be read, and because it is otherwise invisible.
|
|
281
|
+
"epochs": len({record_epoch(record) for record in topic_records}),
|
|
242
282
|
"loss_pct": round(100.0 * lost / len(topic_records), 3) if topic_records else 0.0,
|
|
243
283
|
"reordered": reordered,
|
|
244
284
|
"ota_hop_ms": _distribution_ms(ota),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rosotacom-dev
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.dev36
|
|
4
4
|
Summary: Host CLI for ROS OTA communication sessions.
|
|
5
5
|
Author: develNor
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -311,6 +311,25 @@ rosotacom start 1_heartbeat --identity b \
|
|
|
311
311
|
|
|
312
312
|
`rosotacom` reads the static session input and creates generated files under `session-instances/<date>/<session>_<timestamp>_<id>/config/`, including per-peer plugin/session specs, topic lists, optional QoS, and optional `domain_bridge.yaml`. Catmux pane output is logged under the same instance in `logs/<peer>/catmux/`.
|
|
313
313
|
|
|
314
|
+
What else a run leaves under `logs/<peer>/`, and which file to open first when a
|
|
315
|
+
peer looked healthy but did nothing:
|
|
316
|
+
|
|
317
|
+
| File | Written by | Read it for |
|
|
318
|
+
|---|---|---|
|
|
319
|
+
| `catmux/<NN>-<WINDOW>/<pane>.log` | `catmux_log_setup.sh` (`tmux pipe-pane`) | everything a node printed, including the traceback it died with |
|
|
320
|
+
| `pane_failures.log` | the same script's prompt hook | one line per non-zero pane exit — **the file that answers "is any pane dead?"** |
|
|
321
|
+
| `status/startup_check.json` | `status_overview`, once, after `status_startup_grace_s` and one confirming look a grace later | the verdict: which outbound stages this peer produces and never published, plus the pane failures above |
|
|
322
|
+
| `status/status.{json,txt}` and `status/events.jsonl` | `status_overview`, continuously | live per-stage state and the transit records |
|
|
323
|
+
| `scenario/<component>.log` | `rosotacom start <scenario>` | each scenario application's own output |
|
|
324
|
+
| `launcher.log`, `docker.log` | `rosotacom start` | the exact command, and the container's own stdout where it has one |
|
|
325
|
+
|
|
326
|
+
A pane whose command exits drops back to a shell prompt and keeps looking alive
|
|
327
|
+
— `docker ps` shows a healthy container and the other panes keep working. That
|
|
328
|
+
is how a control centre ran for two hours on 2026-08-13 with no heartbeat
|
|
329
|
+
publisher, because Cyclone had no free participant index left on the local
|
|
330
|
+
domain and `heartbeat_echo` died two seconds after start. The last two rows of
|
|
331
|
+
that table exist so the next one announces itself.
|
|
332
|
+
|
|
314
333
|
### Complete use cases with scenarios
|
|
315
334
|
|
|
316
335
|
A session intentionally describes only the communication contract. When a use
|
|
@@ -577,9 +596,15 @@ The running session writes, under
|
|
|
577
596
|
- `status.json` — machine-readable snapshot (source of truth) for tools/agents,
|
|
578
597
|
refreshed on a short interval and on every state transition,
|
|
579
598
|
- `status.txt` — a human-rendered table, and
|
|
580
|
-
- `events.jsonl` — state transitions plus per-`(topic, seq)` transit
|
|
581
|
-
wrapped topics (delivered/lost/reordered, section latency, size,
|
|
582
|
-
and jitter).
|
|
599
|
+
- `events.jsonl` — state transitions plus per-`(topic, epoch, seq)` transit
|
|
600
|
+
records for wrapped topics (delivered/lost/reordered, section latency, size,
|
|
601
|
+
inter-arrival, and jitter). `epoch` counts the restarts of the sending peer's
|
|
602
|
+
wrapper: sequence numbers start at zero again after one, so it is what keeps
|
|
603
|
+
the numbering unique over an instance that outlived a restart.
|
|
604
|
+
|
|
605
|
+
Which of the bandwidth numbers a session publishes answers which question —
|
|
606
|
+
per-stream payload, whole-interface wire traffic, and the overhead ratio
|
|
607
|
+
between them — is in [link bandwidth](docs/link-bandwidth.md).
|
|
583
608
|
|
|
584
609
|
For fixed-interval network-condition samples alongside the same session, enable
|
|
585
610
|
the [link trace recorder](docs/link-trace.md). It writes
|
|
@@ -6,7 +6,7 @@ rosotacom/benched_set.py,sha256=lBA1Rj04dbr2e-lIvYTWoL55_LdUpCAC-y7eIvJZ8lc,2721
|
|
|
6
6
|
rosotacom/benchmark.py,sha256=pd8GMr_ENRIO4orKBAXDNPFVrgDtSjiqKZHW9icqGeg,76184
|
|
7
7
|
rosotacom/benchmark_delta.py,sha256=qXzriBkbXOeJdlLKM4ziov1F-JfwlseYHE48S9hjFsQ,10005
|
|
8
8
|
rosotacom/bundle_check.py,sha256=l_3BSuYk6juXQmsNU6jkxRbH8ayH2GkiW9ONKMQ1oJY,18270
|
|
9
|
-
rosotacom/cli.py,sha256=
|
|
9
|
+
rosotacom/cli.py,sha256=Z22DGj14_EJ6sgqNRPliOrKTeHyOkeAtHWpSrv2YEOI,399930
|
|
10
10
|
rosotacom/cli_benchmark.py,sha256=vbGAxO7haTQx5RL7p150nlfKMaAFlm8chZ7VN4cQPC4,349881
|
|
11
11
|
rosotacom/deployment.py,sha256=XVhB8JjxhAsglSEEPtiVSeV6fCG0rgGmDRulh14oIYQ,7318
|
|
12
12
|
rosotacom/ffmpeg_packet.py,sha256=zB1BHgY1kexr5sETVMkm4bkVnRytj9k2t0uOYiHUZ6M,4267
|
|
@@ -20,16 +20,16 @@ rosotacom/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
20
20
|
rosotacom/status_eval.py,sha256=V9KVho5DEBYCD_iG1X2Yg6qd6ZEzopdKSY5QJ6K1sP4,23115
|
|
21
21
|
rosotacom/stream_stats.py,sha256=ey09g4B7y0E-NnsOBW8Ng9kqyMbo2xuabNut9op6uM4,23744
|
|
22
22
|
rosotacom/trace_profiles.py,sha256=KNM1RExHDXkppWZdqbSPXTjwJ8f7io8IU48HnKzmHKY,20567
|
|
23
|
-
rosotacom/transit.py,sha256=
|
|
23
|
+
rosotacom/transit.py,sha256=3eM9EC8N9JAHFqEHBRqqubQDR5FOQmrQXREWfDeaNSo,12501
|
|
24
24
|
rosotacom/video_quality.py,sha256=9NqWA3MgHDvjGmJoHHXQCqb17cyIkHYTrkwNxBoVqMA,33845
|
|
25
25
|
rosotacom/resources/benched-set.yaml,sha256=wbWsoPHRQ5ji_pjM-rPe0dOSXv5kfD31FJoTn9YGsO0,14978
|
|
26
|
-
rosotacom/resources/ros2docker.json.example,sha256=
|
|
26
|
+
rosotacom/resources/ros2docker.json.example,sha256=DwJYoqN6M0-Don4nRHtM2h96k1oEmi7UrEfZuEjfrz0,2541
|
|
27
27
|
rosotacom/resources/examples/.gitignore,sha256=9FFIV42CR379XRSDOC9UdXsfJP8cGACLjHxiU9TJG6E,19
|
|
28
28
|
rosotacom/resources/examples/README.md,sha256=RclnxBhlKcl9nRp2PvllVy6akKMZ82cOLv29et-SPyQ,5889
|
|
29
29
|
rosotacom/resources/examples/__init__.py,sha256=IKLiqdO5jeBX3rMkWyb_Uc3SrQZp5vkJtqoTD_NZ90E,52
|
|
30
30
|
rosotacom/resources/examples/deployment.example.yaml,sha256=E3HMJhv-Ft5mA6PC8d7yVZz1uwG3OQhCD_MBnhxtvAg,450
|
|
31
31
|
rosotacom/resources/examples/profiles.yaml,sha256=KhmbIvO7GAWEQEm8QmIr_VCP14eioh_nSohxFf9gouw,3375
|
|
32
|
-
rosotacom/resources/examples/ros2docker.json,sha256=
|
|
32
|
+
rosotacom/resources/examples/ros2docker.json,sha256=Gd_g1P_OA-_7-nM8-KmTTX2cRUyoNCpaPj9MCgxld3M,2499
|
|
33
33
|
rosotacom/resources/examples/rosotacom.yaml,sha256=lpFnRNJv9kjS3Pd6dJAG1PXF-UP-YbSOK8dNcUe68hY,351
|
|
34
34
|
rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml,sha256=esIzUWq2anN89egMyZcHDCs5eIuVyruSU0eZPtfo2-c,445
|
|
35
35
|
rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml,sha256=rxDLMqCyX-WR0Q4moGgmDhxpO4ffvf9pSSihJhXAsNQ,313
|
|
@@ -104,6 +104,7 @@ rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml,s
|
|
|
104
104
|
rosotacom/resources/ws/.vscode/c_cpp_properties.json,sha256=tnO19diLYiK_OoyLXyQqADJkXgfeswy95r4qMCTBeT8,422
|
|
105
105
|
rosotacom/resources/ws/.vscode/settings.json,sha256=xNEQ9qGmUhxkLXYDJj4QVt4l3BtgiwMECuS16qnpdt0,201
|
|
106
106
|
rosotacom/resources/ws/ota_configs/create_zenoh_json5.py,sha256=um2xU1SfNVo_FvlS8iYeAauu67I1N1Z8x-Ckw7KUAAk,2356
|
|
107
|
+
rosotacom/resources/ws/ota_configs/cyclonedds_local_participants.xml.template,sha256=_PAy5sj9_pk2If7xQ_iHF2Oi1tC6HaZe3dDyl8fRSoo,1708
|
|
107
108
|
rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template,sha256=YspSB4xbk2C_CODoowBQUkqsehdGYEd9I0YCjdGN1dc,197
|
|
108
109
|
rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template,sha256=Tb8l9FDEhmfuGp53hTcEqmS6SckdEHjDQ8zQdWPUI6A,1165
|
|
109
110
|
rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template,sha256=j_BF3E9Dub1l-lVreZxmUa0plGRssCWoNkaIy_6jFVQ,1972
|
|
@@ -145,7 +146,7 @@ rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py,sha256=F
|
|
|
145
146
|
rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py,sha256=8nc456sfoL7BdDTikpYJmF3lty0DjV1XdJUTvawTWkc,14382
|
|
146
147
|
rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py,sha256=KtzrNw2rYQ_0TAxO4k73bCnAy7J2WnBHVxgYFxVNELg,2902
|
|
147
148
|
rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py,sha256=jkt8C-voAAqZfI6qNIKvTD8-jzQyEoHmZyrLyR1aqhs,8019
|
|
148
|
-
rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py,sha256=
|
|
149
|
+
rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py,sha256=SB-M4_EIihb6lS6hlaV1b4SuunXje9y6RBxYOpMQB0Q,8985
|
|
149
150
|
rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py,sha256=7zwRYQcbsGwd9jLWozWIQ6u_mI-HeMPRxp6BiPVY6zw,7055
|
|
150
151
|
rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py,sha256=BCuq_njJ_sXgdSGJ1J9FtH6sxqvELTU0UdXAw13XqhA,18724
|
|
151
152
|
rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py,sha256=m6lMb51q5pUYTyHVWiss8vPlsbYRpKMDwHG9gG_fY4E,9088
|
|
@@ -159,10 +160,10 @@ rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py,sha256=S8LjtZs_WBdnvL5oN
|
|
|
159
160
|
rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py,sha256=prpmQf_cXp3KMYw3FlBZfNhq0Rrz56YUjWOunVF6xL8,9259
|
|
160
161
|
rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py,sha256=su_oWQBeDeLHdCy08YzzsASPgYoWID-syqqVkrjSg4g,2768
|
|
161
162
|
rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py,sha256=VXR0EN8EdExxLSb8UbY4mTv94khLZi6weqJpQtRMbd8,2600
|
|
162
|
-
rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=
|
|
163
|
-
rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py,sha256=
|
|
163
|
+
rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=uEG2Rr8Ck7DyaYQn9AtZA3AhQJ3-mAsGQyrc9WzkMKI,22669
|
|
164
|
+
rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py,sha256=i7_U52zML9CPY0oBg6toGHhMETSer3ObqAJThCDzFA8,58877
|
|
164
165
|
rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py,sha256=eWq0D-pVDadj186e2ZC4kZpxTBkXMzi24BfBj3NjGXc,3777
|
|
165
|
-
rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py,sha256=
|
|
166
|
+
rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py,sha256=5iNoHU3SiiDwzsOj7tudh5d801ELVxaHiGKmTCP3JFw,16320
|
|
166
167
|
rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py,sha256=WA34WSuY4jpzqsJ9S3HgETTWP5tZHGsslF7FAqE8oik,7092
|
|
167
168
|
rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py,sha256=gp2qb55dEuF_7LsqDSdnBw0lO-zJb9ZyuhlkGKxxqzs,6788
|
|
168
169
|
rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py,sha256=b_teazIo8ymguKq-f9Xpwe3Q4uRFBhCqb1qB5EC0NP0,25730
|
|
@@ -179,19 +180,19 @@ rosotacom/resources/ws/ros2src/com_py/resource/com_py,sha256=47DEQpj8HBSa-_TImW-
|
|
|
179
180
|
rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py,sha256=6nG7vGJHtcFNF27QnznuJ01WPMmy7DVYKOpFHO_zF1c,962
|
|
180
181
|
rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py,sha256=N6gToFB_DOUaLQtXMS0swHigMHo8qFWA6oAbDS-XVdI,884
|
|
181
182
|
rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py,sha256=6LiNSe4paYextABp-BAZtfoJsaob_umh-q5Fu1z1Pto,803
|
|
182
|
-
rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=
|
|
183
|
+
rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=fcs7G5jlOkTUT3lSOxvoWBkuMokJ3xLr5n15KiYY32U,39717
|
|
183
184
|
rosotacom/resources/ws/session/content/network/up_connectivity.py,sha256=-a6E97Uw159DfRXxYA0MBWRUPA6mQhuAA7pSjASM9qU,13593
|
|
184
185
|
rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py,sha256=UVRtGdbHYayXM0OFkXs2BV2lW0CsY0m_NTlbPO1J9oE,8682
|
|
185
186
|
rosotacom/resources/ws/session/creation/anonymize_bag.py,sha256=jxOTKFjmde-pyoVWE9-9icpSjf4nh97-q2Eqx-BDUl8,6154
|
|
186
|
-
rosotacom/resources/ws/session/creation/catmux_log_setup.sh,sha256=
|
|
187
|
+
rosotacom/resources/ws/session/creation/catmux_log_setup.sh,sha256=VyBAFqmhQcIziSnDCRDNUsT5QpbLzKWU-DqRzaj_WQs,5243
|
|
187
188
|
rosotacom/resources/ws/session/creation/create_session_yaml.py,sha256=8cfzkUxK7IebIBC36Vu7lcWtogM8fG0zveBCUGeiVdU,5727
|
|
188
|
-
rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=
|
|
189
|
+
rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=biJheNo1NnERwYCcuZw0ktGYofBR9l2zeF6BVpiCuMM,142276
|
|
189
190
|
rosotacom/resources/ws/session/creation/run_session.py,sha256=G2hJhrmLp11gm3JZ6_C-zZcqu_reXlE_wmobB7BiCfU,16849
|
|
190
191
|
rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py,sha256=Ub_5f-ttUUc-rAPrZmg_Op34s8Z4C91hTyEn8fgyt-E,2633
|
|
191
192
|
rosotacom/resources/ws/session/creation/strip_ansi.py,sha256=vNjpemcq4xHr0VG4x0bZqvgwEBEfOgN-b4PzbrM5nfM,851
|
|
192
|
-
rosotacom_dev-2.5.
|
|
193
|
-
rosotacom_dev-2.5.
|
|
194
|
-
rosotacom_dev-2.5.
|
|
195
|
-
rosotacom_dev-2.5.
|
|
196
|
-
rosotacom_dev-2.5.
|
|
197
|
-
rosotacom_dev-2.5.
|
|
193
|
+
rosotacom_dev-2.5.dev36.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
|
|
194
|
+
rosotacom_dev-2.5.dev36.dist-info/METADATA,sha256=Yzy1-og2TcINwvCW7w0NSf7QNFtqtqGbto5dkqZjgdA,54130
|
|
195
|
+
rosotacom_dev-2.5.dev36.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
196
|
+
rosotacom_dev-2.5.dev36.dist-info/entry_points.txt,sha256=-Hl9WKAfkLjMHUonDTxvz86EgSfZf6oyatRMxypS5iA,147
|
|
197
|
+
rosotacom_dev-2.5.dev36.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
|
|
198
|
+
rosotacom_dev-2.5.dev36.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{rosotacom_dev-2.5.dev33.dist-info → rosotacom_dev-2.5.dev36.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|
|
File without changes
|