Skip to content

Commit aa98867

Browse files
committed
feat: add UDP diagnostic script and enhance livox_scan with network configuration reporting
1 parent 7438fcb commit aa98867

2 files changed

Lines changed: 548 additions & 45 deletions

File tree

src/tools/debug.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""
3+
UDP Diagnostic Script
4+
Analyses UDP reception issues by testing at multiple levels simultaneously.
5+
Usage: sudo python3 udp_diag.py --ip 192.168.1.10 --port <port> [--iface eno1] [--duration 15]
6+
"""
7+
8+
import argparse
9+
import socket
10+
import subprocess
11+
import threading
12+
import time
13+
import sys
14+
import os
15+
import struct
16+
from datetime import datetime
17+
18+
19+
# ──────────────────────────────────────────────
20+
# Helpers
21+
# ──────────────────────────────────────────────
22+
23+
def header(title):
24+
print(f"\n{'='*60}")
25+
print(f" {title}")
26+
print(f"{'='*60}")
27+
28+
def run(cmd):
29+
try:
30+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
31+
return result.stdout.strip(), result.stderr.strip()
32+
except subprocess.TimeoutExpired:
33+
return "", "timeout"
34+
35+
def ts():
36+
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
37+
38+
39+
# ──────────────────────────────────────────────
40+
# 1. Static network diagnostics
41+
# ──────────────────────────────────────────────
42+
43+
def check_network(ip, iface):
44+
header("1. NETWORK CONFIGURATION")
45+
46+
print("\n--- Interface addresses ---")
47+
out, _ = run(f"ip addr show {iface}")
48+
print(out or f" [interface {iface} not found]")
49+
50+
print("\n--- Routing table (relevant entries) ---")
51+
out, _ = run("ip route show")
52+
for line in out.splitlines():
53+
if "192.168.1" in line or "default" in line:
54+
print(f" {line}")
55+
56+
print(f"\n--- Route used to reach device 192.168.1.167 ---")
57+
out, _ = run("ip route get 192.168.1.167")
58+
print(f" {out}")
59+
60+
print(f"\n--- Route used to reach device 192.168.1.184 ---")
61+
out, _ = run("ip route get 192.168.1.184")
62+
print(f" {out}")
63+
64+
print(f"\n--- rp_filter settings ---")
65+
for key in ["all", iface, "wlp1s0"]:
66+
out, _ = run(f"sysctl net.ipv4.conf.{key}.rp_filter")
67+
print(f" {out}")
68+
69+
print(f"\n--- ARP cache for devices ---")
70+
out, _ = run("arp -n")
71+
for line in out.splitlines():
72+
if "192.168.1.16" in line or "192.168.1.18" in line:
73+
print(f" {line}")
74+
if not any(x in out for x in ["192.168.1.16", "192.168.1.18"]):
75+
print(" [no ARP entries found for devices — try pinging them first]")
76+
77+
78+
# ──────────────────────────────────────────────
79+
# 2. Socket state check
80+
# ──────────────────────────────────────────────
81+
82+
def check_sockets(ip, port):
83+
header("2. SOCKET STATE")
84+
85+
print(f"\n--- All UDP sockets bound to {ip} or 0.0.0.0 ---")
86+
out, _ = run("ss -ulnp")
87+
found = False
88+
print(f" {'Local Address':30s} {'Process'}")
89+
for line in out.splitlines():
90+
if ip in line or "0.0.0.0" in line or "*" in line:
91+
if "State" in line or "Recv" in line: # header
92+
continue
93+
print(f" {line}")
94+
found = True
95+
if not found:
96+
print(" [no relevant UDP sockets found]")
97+
98+
if port:
99+
print(f"\n--- Checking specifically for port {port} ---")
100+
out, _ = run(f"ss -ulnp sport = :{port}")
101+
print(out or f" [nothing listening on UDP port {port}]")
102+
103+
104+
# ──────────────────────────────────────────────
105+
# 3. tcpdump capture (background thread)
106+
# ──────────────────────────────────────────────
107+
108+
tcpdump_results = []
109+
110+
def run_tcpdump(iface, port, duration):
111+
port_filter = f"and udp port {port}" if port else "and udp"
112+
cmd = (
113+
f"tcpdump -i {iface} -n -c 50 -tt "
114+
f"'(src 192.168.1.167 or src 192.168.1.184) {port_filter}' "
115+
f"2>&1"
116+
)
117+
try:
118+
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
119+
stderr=subprocess.STDOUT, text=True)
120+
time.sleep(duration)
121+
proc.terminate()
122+
out = proc.stdout.read()
123+
tcpdump_results.extend(out.splitlines())
124+
except Exception as e:
125+
tcpdump_results.append(f"tcpdump error: {e}")
126+
127+
128+
# ──────────────────────────────────────────────
129+
# 4. Raw socket capture (no UDP stack involvement)
130+
# ──────────────────────────────────────────────
131+
132+
raw_results = []
133+
134+
def run_raw_socket(iface, duration):
135+
"""
136+
Captures at raw Ethernet level — bypasses routing, iptables INPUT,
137+
and socket binding entirely. If we see packets here but not on the
138+
UDP socket, the problem is above the NIC driver.
139+
"""
140+
try:
141+
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0800))
142+
s.bind((iface, 0))
143+
s.settimeout(1.0)
144+
deadline = time.time() + duration
145+
count = 0
146+
while time.time() < deadline:
147+
try:
148+
pkt = s.recv(65535)
149+
# Parse IP header (starts at byte 14 after Ethernet header)
150+
if len(pkt) < 34:
151+
continue
152+
proto = pkt[23]
153+
if proto != 17: # UDP only
154+
continue
155+
src_ip = socket.inet_ntoa(pkt[26:30])
156+
dst_ip = socket.inet_ntoa(pkt[30:34])
157+
if src_ip in ("192.168.1.167", "192.168.1.184"):
158+
dst_port = struct.unpack("!H", pkt[36:38])[0]
159+
src_port = struct.unpack("!H", pkt[34:36])[0]
160+
length = struct.unpack("!H", pkt[38:40])[0]
161+
raw_results.append(
162+
f" [{ts()}] UDP {src_ip}:{src_port} -> {dst_ip}:{dst_port} "
163+
f"payload={length-8}B"
164+
)
165+
count += 1
166+
if count >= 20:
167+
break
168+
except socket.timeout:
169+
continue
170+
s.close()
171+
except PermissionError:
172+
raw_results.append(" [raw socket requires root — run with sudo]")
173+
except Exception as e:
174+
raw_results.append(f" [raw socket error: {e}]")
175+
176+
177+
# ──────────────────────────────────────────────
178+
# 5. UDP socket test listener
179+
# ──────────────────────────────────────────────
180+
181+
udp_results = []
182+
183+
def run_udp_listener(ip, port, duration):
184+
if not port:
185+
udp_results.append(" [no port specified — skipping UDP listener test]")
186+
return
187+
try:
188+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
189+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
190+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
191+
s.bind((ip, port))
192+
s.settimeout(1.0)
193+
udp_results.append(f" Socket bound to {ip}:{port} — listening...")
194+
deadline = time.time() + duration
195+
count = 0
196+
while time.time() < deadline:
197+
try:
198+
data, addr = s.recvfrom(65535)
199+
udp_results.append(
200+
f" [{ts()}] Received {len(data)}B from {addr[0]}:{addr[1]}"
201+
)
202+
count += 1
203+
if count >= 20:
204+
break
205+
except socket.timeout:
206+
continue
207+
if count == 0:
208+
udp_results.append(f" [no UDP packets received on {ip}:{port} in {duration}s]")
209+
s.close()
210+
except OSError as e:
211+
udp_results.append(f" [socket error: {e}]")
212+
213+
214+
# ──────────────────────────────────────────────
215+
# Main
216+
# ──────────────────────────────────────────────
217+
218+
def main():
219+
parser = argparse.ArgumentParser(description="UDP reception diagnostics")
220+
parser.add_argument("--ip", default="192.168.1.10", help="Local IP to bind to")
221+
parser.add_argument("--port", type=int, default=None, help="UDP port to test")
222+
parser.add_argument("--iface", default="eno1", help="Ethernet interface name")
223+
parser.add_argument("--duration", type=int, default=15, help="Capture duration in seconds")
224+
args = parser.parse_args()
225+
226+
if os.geteuid() != 0:
227+
print("WARNING: Not running as root. Raw socket and tcpdump tests will fail.")
228+
print(" Re-run with: sudo python3 udp_diag.py ...\n")
229+
230+
print(f"\nUDP Diagnostic Tool")
231+
print(f" Target IP : {args.ip}")
232+
print(f" Port : {args.port or 'not specified'}")
233+
print(f" Interface : {args.iface}")
234+
print(f" Duration : {args.duration}s")
235+
236+
# Static checks first (instant)
237+
check_network(args.ip, args.iface)
238+
check_sockets(args.ip, args.port)
239+
240+
# Launch parallel capture threads
241+
header(f"3. LIVE CAPTURE ({args.duration}s)")
242+
print(f"\n Starting parallel captures — please ensure devices are streaming...\n")
243+
244+
t_tcp = threading.Thread(target=run_tcpdump, args=(args.iface, args.port, args.duration))
245+
t_raw = threading.Thread(target=run_raw_socket, args=(args.iface, args.duration))
246+
t_udp = threading.Thread(target=run_udp_listener, args=(args.ip, args.port, args.duration))
247+
248+
t_tcp.start(); t_raw.start(); t_udp.start()
249+
t_tcp.join(); t_raw.join(); t_udp.join()
250+
251+
print("\n--- tcpdump (NIC via libpcap) ---")
252+
if tcpdump_results:
253+
for line in tcpdump_results:
254+
print(f" {line}")
255+
else:
256+
print(" [no output]")
257+
258+
print("\n--- Raw IP socket (kernel receive path, pre-iptables) ---")
259+
for line in raw_results:
260+
print(line)
261+
262+
print(f"\n--- UDP socket bound to {args.ip}:{args.port or '?'} ---")
263+
for line in udp_results:
264+
print(line)
265+
266+
# ── Interpretation ──
267+
header("4. INTERPRETATION")
268+
269+
saw_tcpdump = any("192.168.1" in l for l in tcpdump_results)
270+
saw_raw = len([l for l in raw_results if "UDP" in l]) > 0
271+
saw_udp = any("Received" in l for l in udp_results)
272+
273+
print()
274+
if not saw_tcpdump and not saw_raw:
275+
print(" ✗ No packets seen at NIC level (tcpdump + raw socket both empty)")
276+
print(" → Devices are not sending, or sending to wrong IP/port")
277+
print(" → Check device configuration and run: sudo tcpdump -i eno1 -n host 192.168.1.167")
278+
elif saw_tcpdump and saw_raw and not saw_udp:
279+
print(" ✓ Packets arriving at NIC")
280+
print(" ✗ Not reaching UDP socket")
281+
print(" → Check iptables INPUT rules, or port mismatch between device and listener")
282+
elif saw_tcpdump and not saw_raw:
283+
print(" ✓ tcpdump sees packets")
284+
print(" ✗ Raw socket does not — unusual, may indicate interface name mismatch")
285+
elif saw_udp:
286+
print(" ✓ Packets received successfully on UDP socket!")
287+
print(" → No reception problem detected during this run")
288+
else:
289+
print(" ✗ No packets seen at any level")
290+
print(" → Verify devices are actively streaming during the test window")
291+
292+
print()
293+
294+
295+
if __name__ == "__main__":
296+
main()

0 commit comments

Comments
 (0)