// 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 '../link/net.dart'; import '../shell_scope.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}; } /// Orin → tablet only: sustained bulk sampled every 250 ms, TTS-like bursts on a /// warm connection, and camera-like paced streams. Future runDownload(String host, String label, {int repeats = 5, int bulkSecs = 10, int pacedSecs = 10}) async { if (running) return; running = true; lines.clear(); final id = '$label-${DateTime.now().toUtc().toIso8601String().substring(0, 19)}'; _line('download series $id → $host'); try { await usb.bindFor(Uri(scheme: 'ws', host: host)); await _report(host, {'id': id, 'kind': 'start', 'label': label, 'series': 'download'}); for (var i = 0; i < repeats; i++) { _status('bulk ${i + 1}/$repeats, $bulkSecs s'); final r = await tcpBulkSampled(host, id, bulkSecs); _line('bulk ${i + 1}: ${r['mb_s']} MB/s, 250 ms windows min ${r['win_min_mb_s']} p10 ${r['win_p10_mb_s']} max ${r['win_max_mb_s']}, longest stall ${r['max_gap_ms']} ms'); await _report(host, {'id': id, 'kind': 'dl_bulk', 'n': i, ...r}); } _status('bursts'); final b = await tcpBursts(host, id, sizes: const [65536, 262144, 1048576, 4194304], repeats: repeats); for (final e in (b['sizes'] as List)) { _line('burst ${e['bytes'] ~/ 1024} KB: p50 ${e['p50_ms']} ms, max ${e['max_ms']} ms (${e['mb_s_p50']} MB/s at p50)'); } await _report(host, {'id': id, 'kind': 'dl_bursts', ...b}); for (final rate in const [500000, 1000000, 2000000, 4000000]) { _status('paced ${rate / 1e6} MB/s, $pacedSecs s'); final r = await tcpPaced(host, id, rate, pacedSecs); _line('paced ${rate / 1e6} MB/s: got ${r['mb_s']} MB/s, ${r['frames']}/${r['expected']} frames, queueing p50 ${r['q_p50_ms']} p99 ${r['q_p99_ms']} max ${r['q_max_ms']} ms, >100 ms ${r['late_100']}, >500 ms ${r['late_500']}'); await _report(host, {'id': id, 'kind': 'dl_paced', ...r}); } 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(); } } /// Burst latency against the idle gap before each burst, with and without keepalive traffic. Future runBurstIdle(String host, String label) async { if (running) return; running = true; lines.clear(); final id = '$label-${DateTime.now().toUtc().toIso8601String().substring(0, 19)}'; _line('burst/idle series $id → $host'); try { await usb.bindFor(Uri(scheme: 'ws', host: host)); await _report(host, {'id': id, 'kind': 'start', 'label': label, 'series': 'burst-idle'}); for (final (idleMs, ka) in const [(0, 0), (50, 0), (200, 0), (1000, 0), (1000, 50), (0, 0)]) { _status('256 KB bursts, idle $idleMs ms, keepalive $ka/s'); final r = await tcpBursts(host, id, sizes: const [262144], repeats: 8, idle: Duration(milliseconds: idleMs), keepalivePps: ka); final e = (r['sizes'] as List).first; _line('idle $idleMs ms ka $ka/s: p50 ${e['p50_ms']} max ${e['max_ms']} ms ${e['ms']}'); await _report(host, {'id': id, 'kind': 'dl_bursts', ...r}); } 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(); } } static Future> tcpBulkSampled(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(); final windows = {}; var bytes = 0, maxGap = 0; int? first, last; await s.timeout(Duration(seconds: secs + 60)).forEach((b) { final now = clock.elapsedMicroseconds; first ??= now; if (last != null) maxGap = max(maxGap, now - last!); last = now; bytes += b.length; windows.update((now - first!) ~/ 250000, (v) => v + b.length, ifAbsent: () => b.length); }); s.destroy(); final t = ((last ?? 0) - (first ?? 0)) / 1e6; final n = windows.isEmpty ? 0 : windows.keys.reduce(max); // drop the last, partial window final rates = [for (var i = 0; i < n; i++) (windows[i] ?? 0) * 4 / 1e6]..sort(); double r2(num v) => (v * 100).round() / 100; return { 'bytes': bytes, 'secs': r2(t), 'mb_s': t > 0 ? r2(bytes / t / 1e6) : 0, 'win_min_mb_s': rates.isEmpty ? null : r2(rates.first), 'win_p10_mb_s': rates.isEmpty ? null : r2(rates[rates.length ~/ 10]), 'win_p50_mb_s': rates.isEmpty ? null : r2(rates[rates.length ~/ 2]), 'win_max_mb_s': rates.isEmpty ? null : r2(rates.last), 'zero_windows': rates.where((r) => r == 0).length, 'max_gap_ms': (maxGap / 100).round() / 10, }; } /// Each burst is requested on the same connection after 1 s idle, as utterances would be. static Future> tcpBursts(String host, String id, {required List sizes, required int repeats, Duration idle = const Duration(seconds: 1), int keepalivePps = 0}) async { // Optional light UDP traffic during the idle gaps, to tell link power saving from TCP behaviour. RawDatagramSocket? ka; Timer? kaTimer; if (keepalivePps > 0) { ka = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); final addr = InternetAddress(host); kaTimer = Timer.periodic(Duration(microseconds: 1000000 ~/ keepalivePps), (_) => ka!.send(Uint8List(16), addr, _echo)); } final s = await Socket.connect(host, _down, timeout: const Duration(seconds: 5)); s.write('$id burst\n'); var got = 0; Completer? want; var target = 0; final sub = s.listen((b) { got += b.length; final w = want; if (w != null && !w.isCompleted && got >= target) w.complete(); }); final out = >[]; for (final size in sizes) { final ms = []; for (var i = 0; i < repeats; i++) { await Future.delayed(idle); got = 0; target = size; final w = want = Completer(); final clock = Stopwatch()..start(); s.write('$size\n'); await w.future.timeout(const Duration(seconds: 60)); want = null; ms.add(clock.elapsedMicroseconds / 1000); } ms.sort(); final p50 = ms[ms.length ~/ 2]; out.add({ 'bytes': size, 'ms': [for (final m in ms) (m * 10).round() / 10], 'p50_ms': (p50 * 10).round() / 10, 'max_ms': (ms.last * 10).round() / 10, 'mb_s_p50': (size / p50 / 10).round() / 100, }); } await sub.cancel(); s.destroy(); kaTimer?.cancel(); ka?.close(); return {'idle_ms': idle.inMilliseconds, 'keepalive_pps': keepalivePps, 'sizes': out}; } /// Frames carry the Orin's send time. The clocks are not compared absolutely: queueing /// delay is each frame's one-way delay minus the run's smallest. static Future> tcpPaced(String host, String id, int rate, int secs) async { final s = await Socket.connect(host, _down, timeout: const Duration(seconds: 5)); s.write('$id paced $rate $secs\n'); final header = BytesBuilder(); var skip = 0, bytes = 0; final owd = []; double? firstRx, lastRx; await s.timeout(Duration(seconds: secs + 60)).forEach((chunk) { bytes += chunk.length; var i = 0; while (i < chunk.length) { if (skip > 0) { final n = min(skip, chunk.length - i); skip -= n; i += n; continue; } final n = min(12 - header.length, chunk.length - i); header.add(Uint8List.sublistView(chunk, i, i + n)); i += n; if (header.length == 12) { final h = ByteData.sublistView(header.takeBytes()); final size = h.getUint32(0); final sentAt = h.getFloat64(4); skip = size - 12; // Arrival of the header stands for the frame; with skip pending, the rest follows. final now = DateTime.now().microsecondsSinceEpoch / 1e6; firstRx ??= now; lastRx = now; owd.add(now - sentAt); } } }); s.destroy(); final base = owd.isEmpty ? 0.0 : owd.reduce(min); final q = [for (final d in owd) (d - base) * 1000]..sort(); double r1(num v) => (v * 10).round() / 10; final t = (lastRx ?? 0) - (firstRx ?? 0); return { 'rate': rate, 'secs': secs, 'expected': secs * 30, 'frames': owd.length, 'mb_s': t > 0 ? (bytes / t / 1e4).round() / 100 : 0, 'q_p50_ms': q.isEmpty ? null : r1(q[q.length ~/ 2]), 'q_p99_ms': q.isEmpty ? null : r1(q[min(q.length - 1, (q.length * 0.99).floor())]), 'q_max_ms': q.isEmpty ? null : r1(q.last), 'late_100': q.where((v) => v > 100).length, 'late_500': q.where((v) => v > 500).length, }; } 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(leading: const MenuButton(), 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')), ]); }, ), ); } }