Files
app-flutter/lib/linktest.dart
T
mikkeli 6b5fef41c4
CI / Analyze & Test (push) Successful in 10s
CI / Debug APK (push) Successful in 25s
Link test: UDP echo sweep, soak and TCP each way, driven by the app
The tablet's app sockets are the only ones bound to the USB link, so the
measurement has to run in the app. tool/linktest_server.py serves the
Orin end and logs every result with its own TCP_INFO. Startable without
touching the screen: am start ... --es linktest <label>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-11 15:55:52 +09:00

232 lines
8.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<String> lines = [];
/// UDP echo at each (size, rate) for [udpSecs], an app-like soak, then TCP each way.
Future<void> 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<Map<String, dynamic>> 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 = <int>[];
final seen = <int>{};
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<void>();
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<void>.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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> _report(String host, Map<String, dynamic> 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<void> Function()? restoreBinding;
@override
State<LinkTestPage> createState() => _LinkTestPageState();
}
class _LinkTestPageState extends State<LinkTestPage> {
late final _host = TextEditingController(text: widget.host);
final _label = TextEditingController(text: 'setup');
Future<void> _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')),
]);
},
),
);
}
}