// Link test: measures the tablet ⇄ Orin link from the app, whose sockets are the // only ones on the tablet bound to the USB network (`adb shell` traffic goes over // WiFi). Pairs with tool/linktest_server.py on the Orin, which also logs every // result, so a run is readable over SSH whatever happens to the screen. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'net.dart'; const _down = 7791, _up = 7792, _echo = 7793, _results = 7794; class LinkTest extends ChangeNotifier { LinkTest(this.usb); final UsbLink usb; bool running = false; String status = 'idle'; final List lines = []; /// UDP echo at each (size, rate) for [udpSecs], an app-like soak, then TCP each way. Future run(String host, String label, {int udpSecs = 3, int soakSecs = 30, int tcpSecs = 5}) async { if (running) return; running = true; lines.clear(); final id = '$label-${DateTime.now().toUtc().toIso8601String().substring(0, 19)}'; _line('run $id → $host'); try { await usb.bindFor(Uri(scheme: 'ws', host: host)); await _report(host, {'id': id, 'kind': 'start', 'label': label}); for (final size in [64, 1400]) { for (final rate in [10, 50, 100, 200, 500]) { _status('udp $size B × $rate/s'); final r = await udpEcho(host, size: size, rate: rate, secs: udpSecs); _line('udp ${size}B $rate/s: ${r['loss_pct']}% loss, rtt p50 ${r['rtt_p50_ms']} p99 ${r['rtt_p99_ms']} max ${r['rtt_max_ms']} ms'); await _report(host, {'id': id, 'kind': 'udp', ...r}); } } _status('soak: 64 B × 10/s for $soakSecs s'); final soak = await udpEcho(host, size: 64, rate: 10, secs: soakSecs); _line('soak ${soakSecs}s: ${soak['loss_pct']}% loss, max gap ${soak['max_gap_ms']} ms, rtt max ${soak['rtt_max_ms']} ms'); await _report(host, {'id': id, 'kind': 'soak', ...soak}); _status('tcp down $tcpSecs s'); final d = await tcpDown(host, id, tcpSecs); _line('tcp down: ${d['mb_s']} MB/s'); await _report(host, {'id': id, 'kind': 'tcp_down', ...d}); _status('tcp up $tcpSecs s'); final u = await tcpUp(host, id, tcpSecs); _line('tcp up: ${u['mb_s']} MB/s'); await _report(host, {'id': id, 'kind': 'tcp_up', ...u}); await _report(host, {'id': id, 'kind': 'end'}); _status('done'); } catch (e) { _line('error: $e'); _status('failed'); try { await _report(host, {'id': id, 'kind': 'error', 'error': '$e'}); } catch (_) {} } finally { running = false; notifyListeners(); } } /// Sends [rate] packets/s of [size] bytes for [secs], each carrying its sequence /// number and send time; loss, RTT and the longest silence come from the echoes. static Future> udpEcho(String host, {required int size, required int rate, required int secs}) async { final addr = InternetAddress(host); final sock = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); final clock = Stopwatch()..start(); final rtts = []; final seen = {}; var dups = 0, sendFails = 0, sent = 0; int? lastRx; var maxGap = 0; sock.listen((ev) { if (ev != RawSocketEvent.read) return; Datagram? d; while ((d = sock.receive()) != null) { if (d!.data.length < 12) continue; final b = ByteData.sublistView(d.data); final seq = b.getUint32(0); final now = clock.elapsedMicroseconds; if (!seen.add(seq)) { dups++; continue; } rtts.add(now - b.getInt64(4)); if (lastRx != null) maxGap = max(maxGap, now - lastRx!); lastRx = now; } }); final total = rate * secs; final buf = Uint8List(size); final done = Completer(); Timer.periodic(const Duration(milliseconds: 2), (t) { final due = min(total, clock.elapsedMicroseconds * rate ~/ 1000000); while (sent < due) { final b = ByteData.sublistView(buf); b.setUint32(0, sent); b.setInt64(4, clock.elapsedMicroseconds); if (sock.send(buf, addr, _echo) == 0) sendFails++; sent++; } if (sent >= total) { t.cancel(); done.complete(); } }); await done.future; await Future.delayed(const Duration(seconds: 1)); // stragglers sock.close(); rtts.sort(); double ms(int us) => (us / 100).round() / 10; int pct(double p) => rtts.isEmpty ? 0 : rtts[min(rtts.length - 1, (rtts.length * p).floor())]; return { 'size': size, 'rate': rate, 'secs': secs, 'sent': sent, 'received': seen.length, 'loss_pct': sent == 0 ? 0 : ((sent - seen.length) * 1000 / sent).round() / 10, 'dups': dups, 'send_fails': sendFails, 'rtt_p50_ms': ms(pct(0.5)), 'rtt_p99_ms': ms(pct(0.99)), 'rtt_max_ms': rtts.isEmpty ? null : ms(rtts.last), 'max_gap_ms': ms(maxGap), }; } static Future> tcpDown(String host, String id, int secs) async { final s = await Socket.connect(host, _down, timeout: const Duration(seconds: 5)); s.write('$id $secs\n'); final clock = Stopwatch()..start(); var bytes = 0; await s.timeout(Duration(seconds: secs + 30)).forEach((b) => bytes += b.length); s.destroy(); final t = clock.elapsedMicroseconds / 1e6; return {'bytes': bytes, 'secs': (t * 1000).round() / 1000, 'mb_s': (bytes / t / 1e4).round() / 100}; } static Future> tcpUp(String host, String id, int secs) async { final s = await Socket.connect(host, _up, timeout: const Duration(seconds: 5)); final reply = utf8.decoder.bind(s).join(); s.write('$id\n'); final chunk = Uint8List(65536); final clock = Stopwatch()..start(); var bytes = 0; while (clock.elapsed < Duration(seconds: secs)) { s.add(chunk); await s.flush(); bytes += chunk.length; } await s.close(); final got = int.tryParse((await reply.timeout(const Duration(seconds: 30))).trim()) ?? -1; final t = clock.elapsedMicroseconds / 1e6; s.destroy(); return {'bytes': bytes, 'orin_bytes': got, 'secs': (t * 1000).round() / 1000, 'mb_s': (got / t / 1e4).round() / 100}; } static Future _report(String host, Map rec) async { final s = await Socket.connect(host, _results, timeout: const Duration(seconds: 5)); s.write('${jsonEncode({...rec, 'tablet_ts': DateTime.now().millisecondsSinceEpoch / 1000})}\n'); await s.close(); s.destroy(); } void _status(String s) { status = s; notifyListeners(); } void _line(String s) { lines.add(s); notifyListeners(); } } class LinkTestPage extends StatefulWidget { const LinkTestPage({super.key, required this.test, required this.host, this.restoreBinding}); final LinkTest test; final String host; /// Called after a run, to return the binding to what the gateway link needs. final Future Function()? restoreBinding; @override State createState() => _LinkTestPageState(); } class _LinkTestPageState extends State { late final _host = TextEditingController(text: widget.host); final _label = TextEditingController(text: 'setup'); Future _run() async { await widget.test.run(_host.text.trim(), _label.text.trim().replaceAll(' ', '_')); await widget.restoreBinding?.call(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Link test')), body: ListenableBuilder( listenable: widget.test, builder: (context, _) { final t = widget.test; return ListView(padding: const EdgeInsets.all(16), children: [ Row(children: [ Expanded(child: TextField(controller: _host, decoration: const InputDecoration(labelText: 'Orin address'))), const SizedBox(width: 16), Expanded(child: TextField(controller: _label, decoration: const InputDecoration(labelText: 'Setup label'))), const SizedBox(width: 16), FilledButton(onPressed: t.running ? null : _run, child: Text(t.running ? 'Running…' : 'Run')), ]), const SizedBox(height: 12), Text('Status: ${t.status}', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), for (final l in t.lines) Text(l, style: const TextStyle(fontFamily: 'monospace')), ]); }, ), ); } }