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.
- rosotacom/resources/ws/ota_configs/cyclonedds_scoped.xml.template +87 -0
- rosotacom/resources/ws/ota_configs/get_ota_xml.py +75 -30
- rosotacom/resources/ws/ros2src/com_py/com_py/ota_pcap.py +402 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +93 -0
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +16 -4
- rosotacom/resources/ws/session/creation/generate_session_files.py +45 -0
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/METADATA +8 -1
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/RECORD +12 -10
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/WHEEL +0 -0
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/entry_points.txt +0 -0
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/licenses/LICENSE.txt +0 -0
- {rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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,
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
207
|
-
rosotacom_dev-2.5.
|
|
208
|
-
rosotacom_dev-2.5.
|
|
209
|
-
rosotacom_dev-2.5.
|
|
210
|
-
rosotacom_dev-2.5.
|
|
211
|
-
rosotacom_dev-2.5.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
{rosotacom_dev-2.5.dev65.dist-info → rosotacom_dev-2.5.dev67.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|
|
File without changes
|