rosotacom-dev 2.5.dev65__py3-none-any.whl → 2.5.dev67__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.
@@ -0,0 +1,87 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ One CycloneDDS configuration for both of a peer's domains, each section scoped
4
+ to the domain it belongs to.
5
+
6
+ Set it as `shared.rmw.ota.cyclone.config` and leave the local one unset: this
7
+ file already carries the local domain's section, so nothing has to overwrite
8
+ anything.
9
+
10
+ Why it exists. The two-file arrangement renders a local profile and an OTA
11
+ profile and exports CYCLONEDDS_URI twice, so the second replaces the first and
12
+ every process started after it views the *local* domain through the *OTA*
13
+ profile. On CycloneDDS 0.10.5 that was survivable: participants configured
14
+ differently on one host and one domain still exchanged data. On 11.0.1 they do
15
+ not — they discover each other, announce their endpoints, match, and deliver
16
+ nothing. Measured on one host, one domain, a publisher and a subscriber:
17
+
18
+ publisher / subscriber 0.10.5 11.0.1
19
+ local / local 12 B 12 B
20
+ local / ota 12 B 0
21
+ ota / local 12 B 0
22
+ ota / ota 12 B 12 B
23
+
24
+ A session whose relay publishes under one profile and whose bridge subscribes
25
+ under the other therefore hands nothing from the local domain into the OTA
26
+ domain, and the link carries nothing while every stage looks healthy
27
+ (docs/findings/one-domain-two-cyclone-configs-stop-interoperating.md).
28
+
29
+ Scoping fixes the cause rather than the symptom: the OTA settings apply to the
30
+ OTA domain, the local domain keeps default discovery with room for many
31
+ participants, and one file can be shared by every process on the host.
32
+
33
+ The two-file arrangement stays exactly as it was — this is an additional
34
+ template, not a replacement, and a session that does not name it is unchanged.
35
+ -->
36
+ <CycloneDDS xmlns="https://cdds.io/config">
37
+ <!--
38
+ The OTA domain: interface-pinned, unicast-only, with the datagram caps the
39
+ provisioned link is sized for. Identical to cyclonedds_tuned.xml except that
40
+ it says which domain it is for.
41
+ -->
42
+ <Domain Id="#ota_domain">
43
+ <General>
44
+ <AllowMulticast>false</AllowMulticast>
45
+ <Interfaces>
46
+ <NetworkInterface address="#host_ip"/>
47
+ </Interfaces>
48
+ <MulticastRecvNetworkInterfaceAddresses>none</MulticastRecvNetworkInterfaceAddresses>
49
+ <!-- Strictly below MaxMessageSize; see cyclonedds_tuned.xml for what
50
+ making them equal cost. -->
51
+ <FragmentSize>1024B</FragmentSize>
52
+ <MaxMessageSize>1200B</MaxMessageSize>
53
+ <MaxRexmitMessageSize>1200B</MaxRexmitMessageSize>
54
+ </General>
55
+
56
+ <Discovery>
57
+ <Peers>
58
+ <!--peer-block--><Peer Address="#peer"/><!--/peer-block-->
59
+ </Peers>
60
+ <EnableTopicDiscoveryEndpoints>false</EnableTopicDiscoveryEndpoints>
61
+ <LeaseDuration>60s</LeaseDuration>
62
+ <SPDPInterval>#spdp_interval</SPDPInterval>
63
+ </Discovery>
64
+
65
+ <Internal>
66
+ <SquashParticipants>true</SquashParticipants>
67
+ </Internal>
68
+
69
+ <Tracing>
70
+ <Verbosity>config</Verbosity>
71
+ <OutputFile>/tmp/cyclonedds.log</OutputFile>
72
+ </Tracing>
73
+ </Domain>
74
+
75
+ <!--
76
+ The local domain: default discovery, more participants. It pins no interface,
77
+ disables no multicast and sets no peer list, because intra-host discovery has
78
+ to keep working — the same reasoning as cyclonedds_local_participants.xml,
79
+ including why 119 and not higher.
80
+ -->
81
+ <Domain Id="#local_domain">
82
+ <Discovery>
83
+ <ParticipantIndex>auto</ParticipantIndex>
84
+ <MaxAutoParticipantIndex>119</MaxAutoParticipantIndex>
85
+ </Discovery>
86
+ </Domain>
87
+ </CycloneDDS>
@@ -15,8 +15,20 @@ import re
15
15
  import sys
16
16
  import tempfile
17
17
 
18
-
19
- KNOWN_PLACEHOLDERS = {"#host_ip", "#peer", "#easy_mode_ip", "#spdp_interval"}
18
+ KNOWN_PLACEHOLDERS = {
19
+ "#host_ip",
20
+ "#peer",
21
+ "#easy_mode_ip",
22
+ "#spdp_interval",
23
+ #: A template that scopes its settings to one ROS domain resolves these.
24
+ #: Cyclone applies a <Domain> section to the domain its Id names, so a
25
+ #: single configuration can hold the OTA settings for the OTA domain and
26
+ #: leave the local domain alone — which is what lets every process on a
27
+ #: host share one configuration instead of half of them carrying the OTA
28
+ #: profile and half the local one.
29
+ "#local_domain",
30
+ "#ota_domain",
31
+ }
20
32
  PLACEHOLDER_PATTERN = re.compile(r"#[A-Za-z_][A-Za-z0-9_]*")
21
33
  DEFAULT_SPDP_INTERVAL = "30s"
22
34
 
@@ -48,6 +60,19 @@ def _resolved_address(value: str, label: str) -> str:
48
60
  return address
49
61
 
50
62
 
63
+ def _resolved_domain(value: str, label: str) -> str:
64
+ """A ROS domain id, validated rather than pasted into the XML.
65
+
66
+ An unusable Id is worse than a missing one: Cyclone keeps a <Domain>
67
+ section it cannot match to any domain, so the settings vanish without a
68
+ word and the link comes up on defaults.
69
+ """
70
+ domain = str(value).strip()
71
+ if not domain.isdigit() or not 0 <= int(domain) <= 232:
72
+ raise ValueError(f"{label} must be a ROS domain id between 0 and 232, got {value!r}.")
73
+ return str(int(domain))
74
+
75
+
51
76
  def _resolved_spdp_interval(value: str) -> str:
52
77
  interval = value.strip().lower()
53
78
  if not interval:
@@ -99,9 +124,7 @@ def _expand_peer_block(content: str, peer_ips: list) -> str:
99
124
 
100
125
  has_markers = PEER_BLOCK_OPEN in content
101
126
  if has_markers and (PEER_BLOCK_CLOSE not in content):
102
- raise RuntimeError(
103
- f"Template has '{PEER_BLOCK_OPEN}' without matching '{PEER_BLOCK_CLOSE}'."
104
- )
127
+ raise RuntimeError(f"Template has '{PEER_BLOCK_OPEN}' without matching '{PEER_BLOCK_CLOSE}'.")
105
128
 
106
129
  if len(peer_ips) == 1 and not has_markers:
107
130
  return content.replace("#peer", peer_ips[0])
@@ -116,9 +139,7 @@ def _expand_peer_block(content: str, peer_ips: list) -> str:
116
139
 
117
140
  matches = list(PEER_BLOCK_PATTERN.finditer(content))
118
141
  if len(matches) != 1:
119
- raise RuntimeError(
120
- f"Template must contain exactly one peer-block region; found {len(matches)}."
121
- )
142
+ raise RuntimeError(f"Template must contain exactly one peer-block region; found {len(matches)}.")
122
143
  m = matches[0]
123
144
  block = m.group(1)
124
145
  if "#peer" not in block:
@@ -128,11 +149,11 @@ def _expand_peer_block(content: str, peer_ips: list) -> str:
128
149
 
129
150
  # Preserve the indentation of the marker line so duplicated blocks align.
130
151
  line_start = content.rfind("\n", 0, m.start()) + 1
131
- indent = content[line_start:m.start()]
152
+ indent = content[line_start : m.start()]
132
153
  sep = ("\n" + indent) if not indent.strip() else ""
133
154
 
134
155
  rendered = sep.join(block.replace("#peer", ip) for ip in peer_ips)
135
- return content[:m.start()] + rendered + content[m.end():]
156
+ return content[: m.start()] + rendered + content[m.end() :]
136
157
 
137
158
 
138
159
  def main(
@@ -141,12 +162,14 @@ def main(
141
162
  peer: str = None,
142
163
  easy_mode_ip: str = None,
143
164
  spdp_interval: str = DEFAULT_SPDP_INTERVAL,
165
+ local_domain: str = None,
166
+ ota_domain: str = None,
144
167
  ) -> str:
145
168
  path = _template_path(config)
146
169
  if not os.path.exists(path):
147
170
  raise FileNotFoundError(f"Template not found: {path}")
148
171
 
149
- with open(path, "r", encoding="utf-8") as f:
172
+ with open(path, encoding="utf-8") as f:
150
173
  content = f.read()
151
174
 
152
175
  found = set(PLACEHOLDER_PATTERN.findall(content))
@@ -159,9 +182,7 @@ def main(
159
182
 
160
183
  if "#host_ip" in found:
161
184
  if not host_ip:
162
- raise RuntimeError(
163
- f"Template '{os.path.basename(path)}' uses #host_ip but --host-ip was not provided."
164
- )
185
+ raise RuntimeError(f"Template '{os.path.basename(path)}' uses #host_ip but --host-ip was not provided.")
165
186
  content = content.replace("#host_ip", _resolved_address(host_ip, "Host IP"))
166
187
 
167
188
  if "#easy_mode_ip" in found:
@@ -169,45 +190,60 @@ def main(
169
190
  raise RuntimeError(
170
191
  f"Template '{os.path.basename(path)}' uses #easy_mode_ip but --easy-mode-ip was not provided."
171
192
  )
172
- content = content.replace(
173
- "#easy_mode_ip", _resolved_address(easy_mode_ip, "Easy Mode IP")
174
- )
193
+ content = content.replace("#easy_mode_ip", _resolved_address(easy_mode_ip, "Easy Mode IP"))
175
194
 
176
195
  if "#spdp_interval" in found:
177
196
  content = content.replace("#spdp_interval", _resolved_spdp_interval(spdp_interval))
178
197
 
198
+ for placeholder, value, option in (
199
+ ("#local_domain", local_domain, "--local-domain"),
200
+ ("#ota_domain", ota_domain, "--ota-domain"),
201
+ ):
202
+ if placeholder in found:
203
+ if value is None or not str(value).strip():
204
+ raise RuntimeError(
205
+ f"Template '{os.path.basename(path)}' uses {placeholder} but {option} was not provided."
206
+ )
207
+ content = content.replace(placeholder, _resolved_domain(value, placeholder))
208
+
179
209
  if "#peer" in found:
180
210
  if not peer:
181
- raise RuntimeError(
182
- f"Template '{os.path.basename(path)}' uses #peer but --peer was not provided."
183
- )
211
+ raise RuntimeError(f"Template '{os.path.basename(path)}' uses #peer but --peer was not provided.")
184
212
  content = _expand_peer_block(content, _resolve_peer_ips(peer))
185
213
 
186
214
  return content
187
215
 
188
216
 
189
217
  if __name__ == "__main__":
190
- parser = argparse.ArgumentParser(
191
- description="Resolve placeholders in an OTA DDS XML template."
192
- )
218
+ parser = argparse.ArgumentParser(description="Resolve placeholders in an OTA DDS XML template.")
193
219
  parser.add_argument(
194
- "-c", "--config", required=True,
220
+ "-c",
221
+ "--config",
222
+ required=True,
195
223
  help="Template name (e.g. fastdds_v1.xml, cyclonedds.xml, fastdds_easy_mode.xml).",
196
224
  )
197
225
  parser.add_argument(
198
- "-i", "--host-ip", dest="host_ip",
226
+ "-i",
227
+ "--host-ip",
228
+ dest="host_ip",
199
229
  help="Resolved local host address (#host_ip).",
200
230
  )
201
231
  parser.add_argument(
202
- "-p", "--peer", dest="peer",
232
+ "-p",
233
+ "--peer",
234
+ dest="peer",
203
235
  help="Resolved peer address, or comma-separated addresses (#peer).",
204
236
  )
205
237
  parser.add_argument(
206
- "-e", "--easy-mode-ip", dest="easy_mode_ip",
238
+ "-e",
239
+ "--easy-mode-ip",
240
+ dest="easy_mode_ip",
207
241
  help="Resolved Fast DDS Easy Mode address (#easy_mode_ip).",
208
242
  )
209
243
  parser.add_argument(
210
- "-o", "--output", dest="output",
244
+ "-o",
245
+ "--output",
246
+ dest="output",
211
247
  help=(
212
248
  "Write the rendered XML to this path, atomically. Prefer this over a shell "
213
249
  "redirect: `> file` truncates the target before this script runs, so a "
@@ -216,6 +252,16 @@ if __name__ == "__main__":
216
252
  "comes up carrying nothing."
217
253
  ),
218
254
  )
255
+ parser.add_argument(
256
+ "--local-domain",
257
+ dest="local_domain",
258
+ help="Local ROS domain id, for a template that scopes a section to it (#local_domain).",
259
+ )
260
+ parser.add_argument(
261
+ "--ota-domain",
262
+ dest="ota_domain",
263
+ help="OTA ROS domain id, for a template that scopes a section to it (#ota_domain).",
264
+ )
219
265
  parser.add_argument(
220
266
  "--spdp-interval",
221
267
  dest="spdp_interval",
@@ -224,8 +270,7 @@ if __name__ == "__main__":
224
270
  )
225
271
  args = parser.parse_args()
226
272
  output = args.output
227
- rendered = main(**{k: v for k, v in vars(args).items()
228
- if v is not None and k != "output"})
273
+ rendered = main(**{k: v for k, v in vars(args).items() if v is not None and k != "output"})
229
274
  if not output:
230
275
  print(rendered)
231
276
  raise SystemExit(0)
@@ -0,0 +1,402 @@
1
+ """Header-only packet capture of the OTA link, beside the link trace.
2
+
3
+ Runs inside the communication container for the lifetime of one peer, started
4
+ by `status_overview` when a session definition asks for it:
5
+
6
+ shared:
7
+ use_status_overview: true
8
+ ota_pcap:
9
+ enabled: true
10
+
11
+ python3 -m com_py.ota_pcap --iface tun0 --out .../ota.pcap [--peer 10.254.0.37]
12
+
13
+ WHAT IT ANSWERS THAT `link_trace.jsonl` CANNOT
14
+ ----------------------------------------------
15
+ The link trace counts `/proc/net/dev` bytes per direction once a second, which
16
+ localises loss to a *direction* and no further. It cannot say which fragment of
17
+ which sample was lost, whether a retransmit followed, or how one sample's
18
+ sub-messages were spaced on the wire. Those are the questions a header-only
19
+ capture answers, at roughly 1 % of payload volume.
20
+
21
+ It is passive and local: nothing is transmitted, so it costs no link capacity,
22
+ and the interface is never put into promiscuous mode — everything of interest
23
+ is addressed to or from this host, and promiscuous mode on a control-centre LAN
24
+ port would collect other people's traffic. `CAP_NET_ADMIN`, which the
25
+ originating issue assumed was needed, buys only that.
26
+
27
+ WHY IT LIVES HERE AND NOT NEXT TO A RECORDER
28
+ --------------------------------------------
29
+ The first implementation of this sat beside the recording tool in the
30
+ downstream project, and had to rebuild what this container already knows:
31
+ which interface carries the link, and to which peer. `link_bytes` answers both
32
+ from the peer's own OTA address — including the refusal that matters most, a
33
+ loopback interface for a non-loopback address, which once read 28 Gbit/s next
34
+ to a 6 Mbit/s link and survived a field session (#267). A capture that has to
35
+ re-derive link knowledge is in the wrong place; this one asks the same
36
+ resolution the status snapshot's own link block uses.
37
+
38
+ It also needs no privilege here. This container already runs as root with
39
+ `CAP_NET_RAW` in the default docker set, so the option adds no capability, no
40
+ image change and no container.
41
+
42
+ THE FILE
43
+ --------
44
+ `SOCK_DGRAM` rather than `SOCK_RAW`, which is what makes one code path work on
45
+ both link types the fleet uses: a VPN `tun` device is layer 3 and has no
46
+ link-layer header at all, while a LAN port is Ethernet. The kernel hands a
47
+ `SOCK_DGRAM` packet socket the network-layer packet either way, plus the
48
+ `sockaddr_ll` that says what the link layer was.
49
+
50
+ That is exactly the input `LINKTYPE_LINUX_SLL` (113) is defined over, so the
51
+ 16-byte SLL pseudo-header is synthesized from the address tuple and the file
52
+ reads in Wireshark, tshark and scapy without a hint. It carries the field the
53
+ analysis is actually for: `pkttype` distinguishes PACKET_OUTGOING from
54
+ PACKET_HOST, so **every packet in the file is labelled uplink or downlink** —
55
+ which is the direction question the per-second counters answer only coarsely,
56
+ and the one a direction-blind `LINKTYPE_RAW` capture would have thrown away.
57
+
58
+ Timestamps come from the kernel (`SO_TIMESTAMPNS`), not from the moment python
59
+ got round to the packet, and are written at microsecond resolution — the
60
+ classic `0xa1b2c3d4` magic, because every reader takes it and a millisecond
61
+ phenomenon does not need nanoseconds.
62
+
63
+ HONESTY ABOUT WHAT IT MISSED
64
+ ----------------------------
65
+ A capture that quietly drops packets is worse than none: the analysis would
66
+ read the gap as radio loss. So `PACKET_STATISTICS` is read from the kernel and
67
+ the sidecar `.json` reports `kernel_drops` next to `packets`. A non-zero drop
68
+ count means this process fell behind, not that the link did.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import argparse
74
+ import json
75
+ import signal
76
+ import socket
77
+ import struct
78
+ import sys
79
+ import time
80
+ from pathlib import Path
81
+
82
+ #: Capture every ethertype; the protocol filter is applied below, on the
83
+ #: network-layer header the kernel hands a SOCK_DGRAM packet socket.
84
+ ETH_P_ALL = 0x0003
85
+
86
+ ETH_P_IP = 0x0800
87
+ ETH_P_IPV6 = 0x86DD
88
+ IPPROTO_UDP = 17
89
+
90
+ #: linux/if_packet.h. Only the outgoing one is load-bearing — it is what makes
91
+ #: a packet uplink rather than downlink — but all five are written through so a
92
+ #: reader sees what the kernel saw.
93
+ PACKET_OUTGOING = 4
94
+
95
+ SOL_PACKET = 263
96
+ PACKET_STATISTICS = 6
97
+
98
+ #: CPython does not export these on every build — 3.12 in the recorder image
99
+ #: has neither, which cost the first capture its kernel timestamps and left it
100
+ #: stamping packets when python got round to them. The numbers are the
101
+ #: asm-generic ones every architecture the fleet runs uses, and `SCM_` shares
102
+ #: the `SO_` value by definition (linux/socket.h).
103
+ SO_TIMESTAMPNS = getattr(socket, "SO_TIMESTAMPNS", 35)
104
+ SCM_TIMESTAMPNS = getattr(socket, "SCM_TIMESTAMPNS", 35)
105
+
106
+ #: pcap, classic microsecond magic (LE). Every reader takes it.
107
+ PCAP_MAGIC = 0xA1B2C3D4
108
+ LINKTYPE_LINUX_SLL = 113
109
+
110
+ #: `-s 96` in the issue: enough for the link, network, UDP and RTPS sub-message
111
+ #: headers, and short of any payload.
112
+ DEFAULT_SNAPLEN = 96
113
+
114
+ #: Not part of the recorder's own disk budget, and deliberately far above what
115
+ #: a drive produces: header-only at 500 packets/s is ~6 MB per minute, so a
116
+ #: two-hour drive is well under a gigabyte. It exists to bound a mistake — a
117
+ #: capture pointed at a busy control-centre LAN port — not to shape a drive.
118
+ DEFAULT_MAX_MB = 2000
119
+
120
+ #: 4 MiB of kernel socket buffer. The default is a few hundred kilobytes, which
121
+ #: is where `kernel_drops` comes from on a burst.
122
+ RCVBUF_BYTES = 4 << 20
123
+
124
+
125
+ def log(message: str) -> None:
126
+ print(f"[ota_pcap] {message}", flush=True)
127
+
128
+
129
+ # ------------------------------------------------------------------ the file
130
+
131
+
132
+ def pcap_header(snaplen: int) -> bytes:
133
+ return struct.pack(
134
+ "<IHHiIII",
135
+ PCAP_MAGIC,
136
+ 2, # version_major
137
+ 4, # version_minor
138
+ 0, # thiszone: timestamps are UTC
139
+ 0, # sigfigs, as every writer leaves it
140
+ snaplen,
141
+ LINKTYPE_LINUX_SLL,
142
+ )
143
+
144
+
145
+ def sll_header(pkttype: int, hatype: int, addr: bytes, protocol: int) -> bytes:
146
+ """The 16-byte pseudo-header LINKTYPE_LINUX_SLL is defined over.
147
+
148
+ Big-endian, unlike the pcap headers around it — that is the format, not an
149
+ oversight. `addr` is padded to the fixed 8 bytes and its true length is
150
+ written next to it, which is how a reader tells a 6-byte MAC from the empty
151
+ address a layer-3 tunnel has.
152
+ """
153
+ address = (addr or b"")[:8]
154
+ return struct.pack(
155
+ "!HHH8sH",
156
+ pkttype,
157
+ hatype,
158
+ len(address),
159
+ address.ljust(8, b"\0"),
160
+ protocol,
161
+ )
162
+
163
+
164
+ def record_header(seconds: int, micros: int, incl_len: int, orig_len: int) -> bytes:
165
+ return struct.pack("<IIII", seconds, micros, incl_len, orig_len)
166
+
167
+
168
+ # --------------------------------------------------------------- the filter
169
+
170
+
171
+ def is_udp(protocol: int, packet: bytes) -> bool:
172
+ """UDP, judged from the network-layer header the packet socket handed us.
173
+
174
+ Both branches read a fixed offset of the header the kernel guarantees is
175
+ there, so a truncated or malformed packet is answered `False` rather than
176
+ raising inside the capture loop.
177
+
178
+ IPv4 fragments after the first carry no UDP header, and are still matched:
179
+ the protocol field is in the IP header of every fragment, which is what
180
+ keeps a fragmented sample whole in the file.
181
+ """
182
+ if protocol == ETH_P_IP:
183
+ return len(packet) > 9 and packet[9] == IPPROTO_UDP
184
+ if protocol == ETH_P_IPV6:
185
+ # Next Header. An extension-header chain would hide the UDP behind it;
186
+ # nothing on this link uses one, and guessing past it would be the kind
187
+ # of cleverness that silently drops traffic.
188
+ return len(packet) > 6 and packet[6] == IPPROTO_UDP
189
+ return False
190
+
191
+
192
+ def addresses_of(protocol: int, packet: bytes) -> tuple[str, str] | None:
193
+ """Source and destination, for the optional peer filter."""
194
+ try:
195
+ if protocol == ETH_P_IP and len(packet) >= 20:
196
+ return (
197
+ socket.inet_ntop(socket.AF_INET, packet[12:16]),
198
+ socket.inet_ntop(socket.AF_INET, packet[16:20]),
199
+ )
200
+ if protocol == ETH_P_IPV6 and len(packet) >= 40:
201
+ return (
202
+ socket.inet_ntop(socket.AF_INET6, packet[8:24]),
203
+ socket.inet_ntop(socket.AF_INET6, packet[24:40]),
204
+ )
205
+ except (ValueError, OSError):
206
+ return None
207
+ return None
208
+
209
+
210
+ def wanted(protocol: int, packet: bytes, peer: str | None) -> bool:
211
+ if not is_udp(protocol, packet):
212
+ return False
213
+ if peer is None:
214
+ return True
215
+ pair = addresses_of(protocol, packet)
216
+ return bool(pair) and peer in pair
217
+
218
+
219
+ # ---------------------------------------------------------------- capturing
220
+
221
+
222
+ def open_socket(iface: str) -> socket.socket:
223
+ """A non-promiscuous packet socket, bound to one interface.
224
+
225
+ `SOCK_DGRAM` normalises the two link types the fleet has — see the module
226
+ header. No `PACKET_ADD_MEMBERSHIP`, so the interface is never put into
227
+ promiscuous mode and only this host's own traffic is seen.
228
+ """
229
+ sock = socket.socket(socket.AF_PACKET, socket.SOCK_DGRAM, socket.htons(ETH_P_ALL))
230
+ try:
231
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, RCVBUF_BYTES)
232
+ except OSError: # a smaller buffer is a worse capture, not a broken one
233
+ pass
234
+ try:
235
+ sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1)
236
+ except OSError:
237
+ log("WARN the kernel refused SO_TIMESTAMPNS — timestamps are taken in "
238
+ "userspace, so they carry this process's scheduling delay")
239
+ sock.bind((iface, ETH_P_ALL))
240
+ return sock
241
+
242
+
243
+ def kernel_timestamp(ancdata) -> tuple[int, int] | None:
244
+ """(seconds, microseconds) out of SCM_TIMESTAMPNS, if the kernel sent it."""
245
+ for level, kind, data in ancdata:
246
+ if level == socket.SOL_SOCKET and kind == SCM_TIMESTAMPNS:
247
+ if len(data) >= 16:
248
+ seconds, nanos = struct.unpack("qq", data[:16])
249
+ return int(seconds), int(nanos) // 1000
250
+ return None
251
+
252
+
253
+ def packet_statistics(sock: socket.socket) -> tuple[int, int]:
254
+ """(seen, dropped) since the last call — reading resets the counters."""
255
+ try:
256
+ raw = sock.getsockopt(SOL_PACKET, PACKET_STATISTICS, 8)
257
+ except OSError:
258
+ return 0, 0
259
+ seen, dropped = struct.unpack("II", raw)
260
+ return int(seen), int(dropped)
261
+
262
+
263
+ class Capture:
264
+ """One pcap file, and the counts needed to trust it."""
265
+
266
+ def __init__(self, iface: str, out: Path, *, snaplen: int = DEFAULT_SNAPLEN,
267
+ peer: str | None = None, max_bytes: int = DEFAULT_MAX_MB * 1000 * 1000):
268
+ self.iface = iface
269
+ self.out = out
270
+ self.snaplen = snaplen
271
+ self.peer = peer
272
+ self.max_bytes = max_bytes
273
+ self.packets = 0
274
+ self.bytes_written = 0
275
+ self.kernel_drops = 0
276
+ self.kernel_seen = 0
277
+ self.first_ts: float | None = None
278
+ self.last_ts: float | None = None
279
+ self.stopped_because = "signal"
280
+
281
+ def sidecar(self) -> dict:
282
+ """What the file is, and what it is missing. Written next to the pcap."""
283
+ return {
284
+ "interface": self.iface,
285
+ "snaplen": self.snaplen,
286
+ "linktype": "LINKTYPE_LINUX_SLL",
287
+ "filter": "udp" + (f" and host {self.peer}" if self.peer else ""),
288
+ "promiscuous": False,
289
+ "packets": self.packets,
290
+ "bytes": self.bytes_written,
291
+ # Packets the kernel dropped because this process fell behind. A
292
+ # non-zero value here is a gap in the FILE, not in the link, and
293
+ # any loss analysis has to subtract it rather than report it.
294
+ "kernel_drops": self.kernel_drops,
295
+ "kernel_seen": self.kernel_seen,
296
+ "first_timestamp": self.first_ts,
297
+ "last_timestamp": self.last_ts,
298
+ "stopped_because": self.stopped_because,
299
+ }
300
+
301
+ def run(self, stop: list) -> None:
302
+ sock = open_socket(self.iface)
303
+ packet_statistics(sock) # zero the counters; the run owns them from here
304
+ ancillary = socket.CMSG_SPACE(16)
305
+ self.out.parent.mkdir(parents=True, exist_ok=True)
306
+ with open(self.out, "wb", buffering=1 << 20) as handle:
307
+ handle.write(pcap_header(self.snaplen))
308
+ self.bytes_written = 24
309
+ sock.settimeout(0.5)
310
+ while not stop:
311
+ try:
312
+ packet, ancdata, _flags, address = sock.recvmsg(65535, ancillary)
313
+ except socket.timeout:
314
+ continue
315
+ except OSError as error:
316
+ log(f"WARN capture socket: {error}")
317
+ self.stopped_because = f"socket error: {error}"
318
+ break
319
+ # (ifname, protocol, pkttype, hatype, addr) — everything the
320
+ # SLL header needs, which is why SOCK_DGRAM is enough.
321
+ _name, protocol, pkttype, hatype, addr = address
322
+ if not wanted(protocol, packet, self.peer):
323
+ continue
324
+ stamp = kernel_timestamp(ancdata)
325
+ if stamp is None:
326
+ now = time.time()
327
+ stamp = (int(now), int((now % 1) * 1_000_000))
328
+ body = sll_header(pkttype, hatype, addr, protocol) + packet
329
+ orig_len = len(body)
330
+ clipped = body[: self.snaplen]
331
+ handle.write(record_header(stamp[0], stamp[1], len(clipped), orig_len))
332
+ handle.write(clipped)
333
+ self.packets += 1
334
+ self.bytes_written += 16 + len(clipped)
335
+ seconds = stamp[0] + stamp[1] / 1_000_000
336
+ if self.first_ts is None:
337
+ self.first_ts = seconds
338
+ self.last_ts = seconds
339
+ if self.bytes_written >= self.max_bytes:
340
+ log(f"stopping at the {self.max_bytes // 1000 // 1000} MB ceiling "
341
+ f"after {self.packets} packets — the recording is untouched")
342
+ self.stopped_because = "size ceiling"
343
+ break
344
+ if self.packets % 20000 == 0:
345
+ self._collect(sock)
346
+ self._collect(sock)
347
+ sock.close()
348
+
349
+ def _collect(self, sock: socket.socket) -> None:
350
+ seen, dropped = packet_statistics(sock)
351
+ self.kernel_seen += seen
352
+ self.kernel_drops += dropped
353
+
354
+
355
+ # --------------------------------------------------------------------- main
356
+
357
+
358
+ def main(argv: list[str] | None = None) -> int:
359
+ parser = argparse.ArgumentParser(
360
+ description="Header-only UDP capture on one interface, written as pcap.")
361
+ parser.add_argument("--iface", required=True, help="interface to capture on")
362
+ parser.add_argument("--out", required=True, type=Path, help="pcap file to write")
363
+ parser.add_argument("--snaplen", type=int, default=DEFAULT_SNAPLEN,
364
+ help=f"bytes kept per packet (default {DEFAULT_SNAPLEN})")
365
+ parser.add_argument("--peer", help="keep only packets to or from this address")
366
+ parser.add_argument("--max-mb", type=int, default=DEFAULT_MAX_MB,
367
+ help=f"stop after this many MB (default {DEFAULT_MAX_MB})")
368
+ args = parser.parse_args(argv)
369
+
370
+ capture = Capture(args.iface, args.out, snaplen=args.snaplen, peer=args.peer,
371
+ max_bytes=args.max_mb * 1000 * 1000)
372
+
373
+ stop: list = []
374
+ for name in (signal.SIGINT, signal.SIGTERM):
375
+ signal.signal(name, lambda *_: stop.append(True))
376
+
377
+ log(f"{args.iface} -> {args.out} (-s {args.snaplen}, udp"
378
+ f"{f' and host {args.peer}' if args.peer else ''})")
379
+ try:
380
+ capture.run(stop)
381
+ except PermissionError:
382
+ log("FAIL no permission to open a packet socket. Inside the recorder "
383
+ "container this should not happen — CAP_NET_RAW is in docker's "
384
+ "default set. Outside it, run as root.")
385
+ return 3
386
+ except OSError as error:
387
+ log(f"FAIL {error}")
388
+ return 3
389
+
390
+ sidecar = capture.sidecar()
391
+ args.out.with_suffix(".json").write_text(
392
+ json.dumps(sidecar, indent=2) + "\n", encoding="utf-8")
393
+ log(f"{sidecar['packets']} packets, {sidecar['bytes'] / 1e6:.1f} MB, "
394
+ f"{sidecar['kernel_drops']} dropped by the kernel")
395
+ if sidecar["kernel_drops"]:
396
+ log("WARN the kernel dropped packets: that is a gap in THIS FILE, not "
397
+ "on the link. Subtract it before reading any loss out of the pcap.")
398
+ return 0
399
+
400
+
401
+ if __name__ == "__main__":
402
+ sys.exit(main())
@@ -54,6 +54,9 @@
54
54
  from __future__ import annotations
55
55
 
56
56
  import os
57
+ import signal
58
+ import subprocess
59
+ import sys
57
60
  import threading
58
61
  from typing import Dict, Optional
59
62
 
@@ -375,6 +378,14 @@ class StatusOverview(Node):
375
378
  # (the snapshot's `link` block is null).
376
379
  self.declare_parameter("ota_interface", "")
377
380
  self.declare_parameter("ota_local_ip", "")
381
+ # Header-only capture of the OTA link, beside the link trace. Off
382
+ # unless a session definition asks for it; it needs the same interface
383
+ # resolution as the link sampler above and nothing else.
384
+ self.declare_parameter("ota_pcap", False)
385
+ self.declare_parameter("ota_pcap_snaplen", 96)
386
+ self.declare_parameter("ota_pcap_max_mb", 2000)
387
+ self.declare_parameter("ota_pcap_peer_filter", True)
388
+ self.declare_parameter("ota_pcap_peer_ip", "")
378
389
  self.declare_parameter("link_trace", False)
379
390
  self.declare_parameter("link_trace_interval_s", 1.0)
380
391
  self.declare_parameter("link_trace_modem_command", "")
@@ -475,6 +486,10 @@ class StatusOverview(Node):
475
486
  f"'{ota_interface}' ({provenance})"
476
487
  )
477
488
 
489
+ self._pcap: Optional[subprocess.Popen] = None
490
+ if bool(self.get_parameter("ota_pcap").value):
491
+ self._start_ota_pcap(output_dir, ota_interface if link_sampler else "")
492
+
478
493
  link_trace_recorder = None
479
494
  if bool(self.get_parameter("link_trace").value):
480
495
  link_trace_path = os.path.join(output_dir, "link_trace.jsonl")
@@ -518,10 +533,88 @@ class StatusOverview(Node):
518
533
  ):
519
534
  executor.add_node(self._ota_observer)
520
535
 
536
+ def _start_ota_pcap(self, output_dir: str, interface: str) -> None:
537
+ """Begin the header-only capture, or say why there is none.
538
+
539
+ A separate process, not a thread: this node writes the status snapshot
540
+ every interval and is the thing an operator watches, so a capture that
541
+ misbehaves must not be able to reach it. It is still the same container
542
+ — no capability, no image and no `docker ps` line is added by the
543
+ option — which is the whole reason the capture lives here rather than
544
+ beside a recorder.
545
+
546
+ Never raises. The capture is a sidecar to the link, not a precondition
547
+ for it, and a session that cannot capture must still run.
548
+ """
549
+ if not interface:
550
+ # `ota_local_ip`/`ota_interface` are what resolve it, and the
551
+ # resolution above already said why it failed — including the
552
+ # loopback refusal, which is the failure worth repeating (#267).
553
+ self.get_logger().warning(
554
+ "status_overview: ota_pcap is enabled but no OTA interface was "
555
+ "resolved; capturing nothing. Set shared.ota_pcap only where "
556
+ "ota_local_ip resolves, and read the warning above for why."
557
+ )
558
+ return
559
+
560
+ path = os.path.join(output_dir, "ota.pcap")
561
+ argv = [
562
+ sys.executable, "-m", "com_py.ota_pcap",
563
+ "--iface", interface,
564
+ "--out", path,
565
+ "--snaplen", str(int(self.get_parameter("ota_pcap_snaplen").value)),
566
+ "--max-mb", str(int(self.get_parameter("ota_pcap_max_mb").value)),
567
+ ]
568
+ # Narrowed to the remote peer by default. On a VPN tunnel the whole
569
+ # device is the link and the filter changes nothing; on a LAN port it
570
+ # is the difference between the link and every other conversation the
571
+ # machine is having.
572
+ peer_ip = str(self.get_parameter("ota_pcap_peer_ip").value or "").strip()
573
+ if bool(self.get_parameter("ota_pcap_peer_filter").value) and peer_ip:
574
+ argv += ["--peer", peer_ip]
575
+
576
+ try:
577
+ self._pcap = subprocess.Popen(argv, start_new_session=True)
578
+ except OSError as exc: # noqa: BLE001 - a sidecar may not stop a session
579
+ self.get_logger().warning(
580
+ f"status_overview: ota_pcap could not start ({exc}); "
581
+ "the session runs without a capture"
582
+ )
583
+ self._pcap = None
584
+ return
585
+ self.get_logger().info(
586
+ f"status_overview: ota_pcap capturing {interface} -> {path}"
587
+ + (f" (to/from {peer_ip})" if peer_ip and
588
+ bool(self.get_parameter("ota_pcap_peer_filter").value) else "")
589
+ )
590
+
591
+ def _stop_ota_pcap(self) -> None:
592
+ """SIGINT and wait, so the sidecar JSON and the drop counts are written.
593
+
594
+ Killed instead, the pcap on disk is still readable — it is written as
595
+ it goes — but `kernel_drops` never lands, and that is the one number
596
+ that separates a gap in the file from a gap on the link.
597
+ """
598
+ child, self._pcap = self._pcap, None
599
+ if child is None or child.poll() is not None:
600
+ return
601
+ try:
602
+ child.send_signal(signal.SIGINT)
603
+ child.wait(timeout=20.0)
604
+ except subprocess.TimeoutExpired:
605
+ child.kill()
606
+ try:
607
+ child.wait(timeout=5.0)
608
+ except subprocess.TimeoutExpired:
609
+ pass
610
+ except OSError:
611
+ pass
612
+
521
613
  def _on_write(self) -> None:
522
614
  self.aggregator.write()
523
615
 
524
616
  def shutdown(self) -> None:
617
+ self._stop_ota_pcap()
525
618
  if self._ota_executor is not None:
526
619
  try:
527
620
  self._ota_executor.shutdown()
@@ -71,6 +71,13 @@ parameters:
71
71
  link_trace_interval_s: 1.0
72
72
  link_trace_modem_command: ""
73
73
  link_trace_modem_timeout_s: 2.0
74
+ # Header-only capture of the OTA link (docs/ota-pcap.md). Off here, so a
75
+ # session that does not ask for it renders the same command line it always
76
+ # did; `shared.ota_pcap.enabled` is what turns it on.
77
+ ota_pcap: false
78
+ ota_pcap_snaplen: 96
79
+ ota_pcap_max_mb: 2000
80
+ ota_pcap_peer_filter: true
74
81
  metric_stage_bag: false
75
82
  metric_stage_topics:
76
83
 
@@ -292,7 +299,7 @@ common:
292
299
  before_commands:
293
300
  - if [ '${local_domain_id}' != "None" ] && [ -n '${local_domain_id}' ]; then export ROS_DOMAIN_ID='${local_domain_id}'; fi
294
301
  - if [ '${rmw_local}' != "None" ]; then case ${rmw_local} in cyclone) export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp;; fastdds) export RMW_IMPLEMENTATION=rmw_fastrtps_cpp; export FASTDDS_BUILTIN_TRANSPORTS=${FASTDDS_BUILTIN_TRANSPORTS:-UDPv4};; zenoh) export RMW_IMPLEMENTATION=rmw_zenoh_cpp;; *) export RMW_IMPLEMENTATION='${rmw_local}';; esac; fi
295
- - if [ '${local_config_template}' != "None" ] && [ -n '${local_config_template}' ]; then local_args=(--config "${local_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${local_easy_mode_ip}' != "None" ] && [ -n '${local_easy_mode_ip}' ] && local_args+=(--easy-mode-ip "${local_easy_mode_ip}"); [ '${local_spdp_interval}' != "None" ] && [ -n '${local_spdp_interval}' ] && local_args+=(--spdp-interval "${local_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${local_args[@]}" --output "${local_config_file}" || { echo "FATAL - could not render the local DDS profile ${local_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_local} in cyclone) export CYCLONEDDS_URI="file://${local_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${local_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
302
+ - if [ '${local_config_template}' != "None" ] && [ -n '${local_config_template}' ]; then local_args=(--config "${local_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${local_domain_id}' != "None" ] && [ -n '${local_domain_id}' ] && local_args+=(--local-domain "${local_domain_id}"); [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ] && local_args+=(--ota-domain "${ota_domain_id}"); [ '${local_easy_mode_ip}' != "None" ] && [ -n '${local_easy_mode_ip}' ] && local_args+=(--easy-mode-ip "${local_easy_mode_ip}"); [ '${local_spdp_interval}' != "None" ] && [ -n '${local_spdp_interval}' ] && local_args+=(--spdp-interval "${local_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${local_args[@]}" --output "${local_config_file}" || { echo "FATAL - could not render the local DDS profile ${local_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_local} in cyclone) export CYCLONEDDS_URI="file://${local_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${local_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
296
303
  - if [ '${before_command}' != "None" ]; then ${before_command}; fi
297
304
 
298
305
  windows:
@@ -338,7 +345,7 @@ windows:
338
345
  if: use_domain_bridge
339
346
  splits:
340
347
  - commands:
341
- - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
348
+ - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${local_domain_id}' != "None" ] && [ -n '${local_domain_id}' ] && ota_args+=(--local-domain "${local_domain_id}"); [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ] && ota_args+=(--ota-domain "${ota_domain_id}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
342
349
  - ros2 run domain_bridge domain_bridge --wait-for-publisher false ${domain_bridge_config_file}
343
350
  - name: IN
344
351
  if: in
@@ -346,7 +353,7 @@ windows:
346
353
  - commands:
347
354
  - if [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ]; then export ROS_DOMAIN_ID='${ota_domain_id}'; fi
348
355
  - case ${rmw_ota} in cyclone) export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp;; fastdds) export RMW_IMPLEMENTATION=rmw_fastrtps_cpp; export FASTDDS_BUILTIN_TRANSPORTS=${FASTDDS_BUILTIN_TRANSPORTS:-UDPv4};; zenoh) export RMW_IMPLEMENTATION=rmw_zenoh_cpp;; esac
349
- - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
356
+ - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${local_domain_id}' != "None" ] && [ -n '${local_domain_id}' ] && ota_args+=(--local-domain "${local_domain_id}"); [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ] && ota_args+=(--ota-domain "${ota_domain_id}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
350
357
  # For zenoh_ros2dds the OTA domain is loopback-only (zenoh is the inter-host
351
358
  # transport), so confine discovery to localhost instead of statically peering
352
359
  # the remote host — otherwise the OTA-domain DDS double-delivers alongside zenoh.
@@ -373,7 +380,7 @@ windows:
373
380
  - commands:
374
381
  - if [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ]; then export ROS_DOMAIN_ID='${ota_domain_id}'; fi
375
382
  - case ${rmw_ota} in cyclone) export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp;; fastdds) export RMW_IMPLEMENTATION=rmw_fastrtps_cpp; export FASTDDS_BUILTIN_TRANSPORTS=${FASTDDS_BUILTIN_TRANSPORTS:-UDPv4};; zenoh) export RMW_IMPLEMENTATION=rmw_zenoh_cpp;; esac
376
- - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
383
+ - if [ '${ota_config_template}' != "None" ] && [ -n '${ota_config_template}' ]; then ota_args=(--config "${ota_config_template}" --host-ip "${ip_local}" --peer "${ip_remote}"); [ '${local_domain_id}' != "None" ] && [ -n '${local_domain_id}' ] && ota_args+=(--local-domain "${local_domain_id}"); [ '${ota_domain_id}' != "None" ] && [ -n '${ota_domain_id}' ] && ota_args+=(--ota-domain "${ota_domain_id}"); [ '${ota_easy_mode_ip}' != "None" ] && [ -n '${ota_easy_mode_ip}' ] && ota_args+=(--easy-mode-ip "${ota_easy_mode_ip}"); [ '${ota_spdp_interval}' != "None" ] && [ -n '${ota_spdp_interval}' ] && ota_args+=(--spdp-interval "${ota_spdp_interval}"); python3 /ws/ota_configs/get_ota_xml.py "${ota_args[@]}" --output "${ota_config_file}" || { echo "FATAL - could not render the OTA DDS profile ${ota_config_template}; refusing to start on defaults." >&2; exit 1; }; case ${rmw_ota} in cyclone) export CYCLONEDDS_URI="file://${ota_config_file}";; fastdds) export FASTDDS_DEFAULT_PROFILES_FILE="${ota_config_file}"; export RMW_FASTRTPS_USE_QOS_FROM_XML=1;; esac; fi
377
384
  # For zenoh_ros2dds the OTA domain is loopback-only (zenoh is the inter-host
378
385
  # transport), so confine discovery to localhost instead of statically peering
379
386
  # the remote host — otherwise the OTA-domain DDS double-delivers alongside zenoh.
@@ -427,6 +434,11 @@ windows:
427
434
  -p link_trace_interval_s:=${link_trace_interval_s}
428
435
  -p "link_trace_modem_command:='${link_trace_modem_command}'"
429
436
  -p link_trace_modem_timeout_s:=${link_trace_modem_timeout_s}
437
+ -p ota_pcap:=${ota_pcap}
438
+ -p ota_pcap_snaplen:=${ota_pcap_snaplen}
439
+ -p ota_pcap_max_mb:=${ota_pcap_max_mb}
440
+ -p ota_pcap_peer_filter:=${ota_pcap_peer_filter}
441
+ -p ota_pcap_peer_ip:=${ip_remote}
430
442
  - commands:
431
443
  - status_out_dir="$(dirname "${ROSOTACOM_CATMUX_LOG_DIR:-/tmp/rosotacom_logs/catmux}")/status"
432
444
  - status_txt="$status_out_dir/status.txt"
@@ -1063,6 +1063,7 @@ def _validate_session_template_cfg(cfg: Dict[str, Any]) -> None:
1063
1063
  "heartbeat",
1064
1064
  "metric_backbone",
1065
1065
  "link_trace",
1066
+ "ota_pcap",
1066
1067
  "use_in",
1067
1068
  "use_out",
1068
1069
  "rmw",
@@ -1135,6 +1136,27 @@ def _validate_session_template_cfg(cfg: Dict[str, Any]) -> None:
1135
1136
  "shared.link_trace.modem_metrics_timeout_s must be a positive number if provided."
1136
1137
  )
1137
1138
 
1139
+ if "ota_pcap" in shared and shared["ota_pcap"] is not None:
1140
+ ota_pcap = _assert_mapping(shared.get("ota_pcap"), "shared.ota_pcap")
1141
+ _assert_allowed_keys(
1142
+ "shared.ota_pcap",
1143
+ ota_pcap,
1144
+ {"enabled", "snaplen", "max_mb", "peer_filter"},
1145
+ )
1146
+ if "enabled" in ota_pcap and not isinstance(ota_pcap["enabled"], bool):
1147
+ raise RuntimeError("shared.ota_pcap.enabled must be boolean if provided.")
1148
+ if "peer_filter" in ota_pcap and not isinstance(ota_pcap["peer_filter"], bool):
1149
+ raise RuntimeError("shared.ota_pcap.peer_filter must be boolean if provided.")
1150
+ for key, low, high in (("snaplen", 40, 65535), ("max_mb", 1, 1_000_000)):
1151
+ if key in ota_pcap and ota_pcap[key] is not None:
1152
+ value = ota_pcap[key]
1153
+ if not isinstance(value, int) or isinstance(value, bool) \
1154
+ or not (low <= value <= high):
1155
+ raise RuntimeError(
1156
+ f"shared.ota_pcap.{key} must be an integer between "
1157
+ f"{low} and {high} if provided."
1158
+ )
1159
+
1138
1160
  # peer_settings is optional
1139
1161
  if "peer_settings" in cfg and cfg["peer_settings"] is not None:
1140
1162
  peer_settings = _assert_mapping(cfg.get("peer_settings"), "peer_settings")
@@ -2135,6 +2157,19 @@ def func(
2135
2157
  link_trace_modem_timeout_s = float(link_trace.get("modem_metrics_timeout_s", 2.0))
2136
2158
  if link_trace_enabled and not use_status_overview:
2137
2159
  raise RuntimeError("shared.link_trace.enabled requires shared.use_status_overview=true.")
2160
+ ota_pcap = shared.get("ota_pcap", {}) or {}
2161
+ if not isinstance(ota_pcap, dict):
2162
+ raise RuntimeError("shared.ota_pcap must be a mapping if provided.")
2163
+ ota_pcap_enabled = bool(ota_pcap.get("enabled", False))
2164
+ ota_pcap_snaplen = int(ota_pcap.get("snaplen", 96))
2165
+ ota_pcap_max_mb = int(ota_pcap.get("max_mb", 2000))
2166
+ ota_pcap_peer_filter = bool(ota_pcap.get("peer_filter", True))
2167
+ if ota_pcap_enabled and not use_status_overview:
2168
+ # The capture is started by the status_overview node, which is also
2169
+ # what resolves the OTA interface from the peer's own address — the
2170
+ # resolution that refuses loopback (#267). Without it there is nothing
2171
+ # to hang the capture on and no safe way to name an interface.
2172
+ raise RuntimeError("shared.ota_pcap.enabled requires shared.use_status_overview=true.")
2138
2173
  shared_use_in = shared.get("use_in", None)
2139
2174
  shared_use_out = shared.get("use_out", None)
2140
2175
 
@@ -3168,6 +3203,16 @@ def func(
3168
3203
  ]
3169
3204
  if link_trace_enabled
3170
3205
  else []
3206
+ )
3207
+ + (
3208
+ [
3209
+ ("ota_pcap", True),
3210
+ ("ota_pcap_snaplen", ota_pcap_snaplen),
3211
+ ("ota_pcap_max_mb", ota_pcap_max_mb),
3212
+ ("ota_pcap_peer_filter", ota_pcap_peer_filter),
3213
+ ]
3214
+ if ota_pcap_enabled
3215
+ else []
3171
3216
  ),
3172
3217
  )
3173
3218
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rosotacom-dev
3
- Version: 2.5.dev65
3
+ Version: 2.5.dev67
4
4
  Summary: Host CLI for ROS OTA communication sessions.
5
5
  Author: develNor
6
6
  License-Expression: BSD-3-Clause
@@ -684,6 +684,13 @@ the [link trace recorder](docs/link-trace.md). It writes
684
684
  `link_trace.jsonl` under the same status directory with `/proc/net/dev` counter
685
685
  deltas, echo-heartbeat RTT/loss provenance, and an optional modem-metrics hook.
686
686
 
687
+ Those counters localise loss to a *direction* and no further. When the question
688
+ is which fragment was lost, whether a retransmit followed, or how one sample's
689
+ sub-messages were spaced, enable the [header-only OTA capture](docs/ota-pcap.md)
690
+ next to them — `shared.ota_pcap.enabled`, off by default, roughly 1 % of payload
691
+ volume, and every packet labelled uplink or downlink. It needs no capability,
692
+ no image change and no extra container.
693
+
687
694
  Convert a recorded link trace into a profiles-file entry when you want to replay
688
695
  the same drive conditions in a repeatable benchmark:
689
696
 
@@ -113,12 +113,13 @@ rosotacom/resources/ws/.vscode/settings.json,sha256=xNEQ9qGmUhxkLXYDJj4QVt4l3Btg
113
113
  rosotacom/resources/ws/ota_configs/create_zenoh_json5.py,sha256=um2xU1SfNVo_FvlS8iYeAauu67I1N1Z8x-Ckw7KUAAk,2356
114
114
  rosotacom/resources/ws/ota_configs/cyclonedds_local_participants.xml.template,sha256=_PAy5sj9_pk2If7xQ_iHF2Oi1tC6HaZe3dDyl8fRSoo,1708
115
115
  rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template,sha256=YspSB4xbk2C_CODoowBQUkqsehdGYEd9I0YCjdGN1dc,197
116
+ rosotacom/resources/ws/ota_configs/cyclonedds_scoped.xml.template,sha256=tjHTn2SSaCgPT_3JRTuievvu7rV8nJoYGChPo-kUtOM,3532
116
117
  rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template,sha256=e6XmnP3WpqxCc_las62SIB9yUuCYZQ4Zxc3KMk6g0a8,2027
117
118
  rosotacom/resources/ws/ota_configs/fastdds_easy_mode.xml.template,sha256=NsQfwrf2JoqKaD0j_cEp2LCLNsRcdjKoReuQ_ow9Txk,2037
118
119
  rosotacom/resources/ws/ota_configs/fastdds_local.xml.template,sha256=KhLMd_vYe8o_sb5WqBNDNvdsFDJ1BmHwmpLpJ9WginY,3326
119
120
  rosotacom/resources/ws/ota_configs/fastdds_tuned.xml.template,sha256=pl8dskuk4cBRU4t9fhyA4ZyeQ-KpcWRhHTsm2Gn4TSc,6504
120
121
  rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template,sha256=NZGt5mP9IWBk0drx0FOLCuySTamvQ9s8B5gPjHJbtG4,3838
121
- rosotacom/resources/ws/ota_configs/get_ota_xml.py,sha256=IjZNuIt1opHlGcURWuKAaFFDqDTghqyutbCSBUwQP-k,8772
122
+ rosotacom/resources/ws/ota_configs/get_ota_xml.py,sha256=jzVchlCFah2wkia5nTtYDYOM4slt9XzpaPW3-IaSWyA,10625
122
123
  rosotacom/resources/ws/ota_configs/utils.py,sha256=HS-n4AUGFEVd1YmIjFUjD0TMI22M6PUMKaTac30qfd4,3109
123
124
  rosotacom/resources/ws/ota_configs/zenoh.json5.template,sha256=XDsugd411skY6lepBq5DoNIWcw8cpY922ZgfR3DOLvA,798
124
125
  rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template,sha256=UNpcu-JTIvNBR5AfE4ILwXCdWVdwtF22zsttjUdg4eY,1350
@@ -162,6 +163,7 @@ rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py,sha256=elVggfIe4YXCnq
162
163
  rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py,sha256=BCuq_njJ_sXgdSGJ1J9FtH6sxqvELTU0UdXAw13XqhA,18724
163
164
  rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py,sha256=m6lMb51q5pUYTyHVWiss8vPlsbYRpKMDwHG9gG_fY4E,9088
164
165
  rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py,sha256=gdSYrvMUKuBOG5UePm6EGxkHz-QNfZ00PIR9IYEr8ao,9629
166
+ rosotacom/resources/ws/ros2src/com_py/com_py/ota_pcap.py,sha256=2UrUI9X-sIOezpXW-SwJ2BhTULNeCpPrJF0HhFJHnC8,16536
165
167
  rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py,sha256=DfNNsX09GEKwIFolXFKj0blYQTNhoHQTj09Cat3zKZ8,3765
166
168
  rosotacom/resources/ws/ros2src/com_py/com_py/playout_pacer.py,sha256=Kr5T_pBPjJP4DgEaDSINv5TkaiBrs6HF-J6GhlxzEYs,4813
167
169
  rosotacom/resources/ws/ros2src/com_py/com_py/playout_pacer_core.py,sha256=d9omGcz9a7rT1smA2ROIsOiL0rqXHZTAA0NRj81V9UA,4675
@@ -173,7 +175,7 @@ rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py,sha256=S8LjtZs_WBdnvL5oN
173
175
  rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py,sha256=prpmQf_cXp3KMYw3FlBZfNhq0Rrz56YUjWOunVF6xL8,9259
174
176
  rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py,sha256=su_oWQBeDeLHdCy08YzzsASPgYoWID-syqqVkrjSg4g,2768
175
177
  rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py,sha256=VXR0EN8EdExxLSb8UbY4mTv94khLZi6weqJpQtRMbd8,2600
176
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=cF1AbEWJao5lz4rP4bdyngBGi1wN9e0oIXbDcZXevAc,24963
178
+ rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=GhoKic8WBwJDqw0kn5Zfc41SdpR7nXBpbUESugDrTvI,29268
177
179
  rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py,sha256=1g_wULJGfcBL9lIkkxWVfYDGnGuiuTVnWEo3ZdvnZ_Q,64146
178
180
  rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py,sha256=eWq0D-pVDadj186e2ZC4kZpxTBkXMzi24BfBj3NjGXc,3777
179
181
  rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py,sha256=5iNoHU3SiiDwzsOj7tudh5d801ELVxaHiGKmTCP3JFw,16320
@@ -193,19 +195,19 @@ rosotacom/resources/ws/ros2src/com_py/resource/com_py,sha256=47DEQpj8HBSa-_TImW-
193
195
  rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py,sha256=6nG7vGJHtcFNF27QnznuJ01WPMmy7DVYKOpFHO_zF1c,962
194
196
  rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py,sha256=N6gToFB_DOUaLQtXMS0swHigMHo8qFWA6oAbDS-XVdI,884
195
197
  rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py,sha256=6LiNSe4paYextABp-BAZtfoJsaob_umh-q5Fu1z1Pto,803
196
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=dDX5xswpiy54cLwjDIbsmokuHBqO1PSpzZaVbzkLvwc,49635
198
+ rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=uCO6m1cPW9LLxzrnTFmOsLIGijqT24OCQiHx6qjqpUU,51101
197
199
  rosotacom/resources/ws/session/content/network/up_connectivity.py,sha256=-a6E97Uw159DfRXxYA0MBWRUPA6mQhuAA7pSjASM9qU,13593
198
200
  rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py,sha256=UVRtGdbHYayXM0OFkXs2BV2lW0CsY0m_NTlbPO1J9oE,8682
199
201
  rosotacom/resources/ws/session/creation/anonymize_bag.py,sha256=jxOTKFjmde-pyoVWE9-9icpSjf4nh97-q2Eqx-BDUl8,6154
200
202
  rosotacom/resources/ws/session/creation/catmux_log_setup.sh,sha256=VyBAFqmhQcIziSnDCRDNUsT5QpbLzKWU-DqRzaj_WQs,5243
201
203
  rosotacom/resources/ws/session/creation/create_session_yaml.py,sha256=8cfzkUxK7IebIBC36Vu7lcWtogM8fG0zveBCUGeiVdU,5727
202
- rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=ryp8trvjjFpmpHMgsDaeGvLitroaiVJfAZI4gmYO10I,158711
204
+ rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=eosnUYSwVzkaP_Bra5CStJ6qt9sduqhRNmUudXwlFLo,161258
203
205
  rosotacom/resources/ws/session/creation/run_session.py,sha256=G2hJhrmLp11gm3JZ6_C-zZcqu_reXlE_wmobB7BiCfU,16849
204
206
  rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py,sha256=Ub_5f-ttUUc-rAPrZmg_Op34s8Z4C91hTyEn8fgyt-E,2633
205
207
  rosotacom/resources/ws/session/creation/strip_ansi.py,sha256=vNjpemcq4xHr0VG4x0bZqvgwEBEfOgN-b4PzbrM5nfM,851
206
- rosotacom_dev-2.5.dev65.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
207
- rosotacom_dev-2.5.dev65.dist-info/METADATA,sha256=o6cZxZ1dKwy1UWWYV6NSuSNnxXGHIhBNncxUiJzdmro,58350
208
- rosotacom_dev-2.5.dev65.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
209
- rosotacom_dev-2.5.dev65.dist-info/entry_points.txt,sha256=im11ZWqBbgjQUBz2Ik2RNfMu54wlMwgiVpsBKeI7dCI,49
210
- rosotacom_dev-2.5.dev65.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
211
- rosotacom_dev-2.5.dev65.dist-info/RECORD,,
208
+ rosotacom_dev-2.5.dev67.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
209
+ rosotacom_dev-2.5.dev67.dist-info/METADATA,sha256=RsQ3qETER7_LuJ0JB46k0IG5EZmMgoIQxbeejhYlnCc,58792
210
+ rosotacom_dev-2.5.dev67.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
211
+ rosotacom_dev-2.5.dev67.dist-info/entry_points.txt,sha256=im11ZWqBbgjQUBz2Ik2RNfMu54wlMwgiVpsBKeI7dCI,49
212
+ rosotacom_dev-2.5.dev67.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
213
+ rosotacom_dev-2.5.dev67.dist-info/RECORD,,