Link test: UDP echo sweep, soak and TCP each way, driven by the app
CI / Analyze & Test (push) Successful in 10s
CI / Debug APK (push) Successful in 25s

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
This commit is contained in:
2026-10-11 15:55:52 +09:00
parent f14202047f
commit 6b5fef41c4
5 changed files with 416 additions and 6 deletions
@@ -1,6 +1,7 @@
package dev.yukart.display package dev.yukart.display
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
@@ -20,12 +21,16 @@ import io.flutter.plugin.common.MethodChannel
* default network while WiFi or cellular is up, and ordinary sockets go elsewhere. * default network while WiFi or cellular is up, and ordinary sockets go elsewhere.
* When asked, the whole process is bound to the Ethernet (NCM) network while it * When asked, the whole process is bound to the Ethernet (NCM) network while it
* exists, and unbound when it goes. * exists, and unbound when it goes.
*
* Also a development hook: `am start -n dev.yukart.display/.MainActivity --es linktest <label>`
* runs the link test (lib/linktest.dart) without touching the screen.
*/ */
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private lateinit var channel: MethodChannel private lateinit var channel: MethodChannel
private lateinit var cm: ConnectivityManager private lateinit var cm: ConnectivityManager
private var ethernet: Network? = null private var ethernet: Network? = null
private var wantBinding = false private var wantBinding = false
private var pendingLinkTest: String? = null
private val callback = object : ConnectivityManager.NetworkCallback() { private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
@@ -44,6 +49,12 @@ class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
pendingLinkTest = intent?.getStringExtra("linktest")
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.getStringExtra("linktest")?.let { channel.invokeMethod("linktest", it) }
} }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -57,6 +68,10 @@ class MainActivity : FlutterActivity() {
apply() apply()
result.success(ethernet != null) result.success(ethernet != null)
} }
"takeLinkTest" -> {
result.success(pendingLinkTest)
pendingLinkTest = null
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
+231
View File
@@ -0,0 +1,231 @@
// 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')),
]);
},
),
);
}
}
+39 -6
View File
@@ -6,6 +6,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'boundary.dart'; import 'boundary.dart';
import 'linktest.dart';
import 'net.dart'; import 'net.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`. /// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
@@ -16,32 +17,56 @@ Future<void> main() async {
final usb = UsbLink(); final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl)); await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start(); final boundary = Boundary(defaultUrl)..start();
runApp(YukartApp(boundary: boundary, usb: usb)); final linkTest = LinkTest(usb);
// Development hook: `am start ... --es linktest <label>` runs the test on the gateway's host.
Future<void> runLinkTest(String label) async {
navigatorKey.currentState?.push(MaterialPageRoute<void>(builder: (_) => linkTestPage(boundary, linkTest, usb)));
await linkTest.run(Uri.parse(boundary.url).host, label);
await usb.bindFor(Uri.parse(boundary.url));
}
usb.onLinkTest = runLinkTest;
runApp(YukartApp(boundary: boundary, usb: usb, linkTest: linkTest));
final pending = await usb.takeLinkTest();
if (pending != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => runLinkTest(pending));
}
} }
final navigatorKey = GlobalKey<NavigatorState>();
Widget linkTestPage(Boundary b, LinkTest t, UsbLink usb) => LinkTestPage(
test: t,
host: Uri.parse(b.url).host,
restoreBinding: () => usb.bindFor(Uri.parse(b.url)),
);
class YukartApp extends StatelessWidget { class YukartApp extends StatelessWidget {
const YukartApp({super.key, required this.boundary, required this.usb}); const YukartApp({super.key, required this.boundary, required this.usb, required this.linkTest});
final Boundary boundary; final Boundary boundary;
final UsbLink usb; final UsbLink usb;
final LinkTest linkTest;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Yukart', title: 'Yukart',
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark, useMaterial3: true), theme: ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark, useMaterial3: true),
home: ListenableBuilder( home: ListenableBuilder(
listenable: Listenable.merge([boundary, usb]), listenable: Listenable.merge([boundary, usb]),
builder: (context, _) => Readout(boundary: boundary, usb: usb), builder: (context, _) => Readout(boundary: boundary, usb: usb, linkTest: linkTest),
), ),
); );
} }
} }
class Readout extends StatelessWidget { class Readout extends StatelessWidget {
const Readout({super.key, required this.boundary, required this.usb}); const Readout({super.key, required this.boundary, required this.usb, required this.linkTest});
final Boundary boundary; final Boundary boundary;
final UsbLink usb; final UsbLink usb;
final LinkTest linkTest;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -53,7 +78,7 @@ class Readout extends StatelessWidget {
children: [ children: [
// Mock data must look like mock data: on the surface, not in a menu. // Mock data must look like mock data: on the surface, not in a menu.
if (b.mock) const MockBanner(), if (b.mock) const MockBanner(),
LinkBar(boundary: b, usb: usb), LinkBar(boundary: b, usb: usb, linkTest: linkTest),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, c) { builder: (context, c) {
@@ -100,9 +125,10 @@ class MockBanner extends StatelessWidget {
} }
class LinkBar extends StatelessWidget { class LinkBar extends StatelessWidget {
const LinkBar({super.key, required this.boundary, required this.usb}); const LinkBar({super.key, required this.boundary, required this.usb, required this.linkTest});
final Boundary boundary; final Boundary boundary;
final UsbLink usb; final UsbLink usb;
final LinkTest linkTest;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -127,6 +153,13 @@ class LinkBar extends StatelessWidget {
Text('last msg $age'), Text('last msg $age'),
Text('Orin−tablet clock $offset'), Text('Orin−tablet clock $offset'),
if (usb.supported) Text('USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}'), if (usb.supported) Text('USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}'),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => linkTestPage(b, linkTest, usb)),
),
icon: const Icon(Icons.speed),
label: const Text('Link test'),
),
]), ]),
), ),
), ),
+8
View File
@@ -13,6 +13,9 @@ class UsbLink extends ChangeNotifier {
bool up = false; bool up = false;
bool bound = false; bool bound = false;
/// Development hook: a link test requested by launch intent (see MainActivity.kt).
void Function(String label)? onLinkTest;
UsbLink() { UsbLink() {
if (!supported) return; if (!supported) return;
_ch.setMethodCallHandler((call) async { _ch.setMethodCallHandler((call) async {
@@ -21,10 +24,15 @@ class UsbLink extends ChangeNotifier {
up = a['up'] == true; up = a['up'] == true;
bound = a['bound'] == true; bound = a['bound'] == true;
notifyListeners(); notifyListeners();
} else if (call.method == 'linktest') {
onLinkTest?.call(call.arguments as String);
} }
}); });
} }
/// A link test requested by the intent that launched the app, once.
Future<String?> takeLinkTest() async => supported ? _ch.invokeMethod<String>('takeLinkTest') : null;
/// Bind only when the target is on the USB link; a workstation over WiFi must stay reachable. /// Bind only when the target is on the USB link; a workstation over WiFi must stay reachable.
Future<void> bindFor(Uri target) async { Future<void> bindFor(Uri target) async {
if (!supported) return; if (!supported) return;
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Orin side of the app's link test (lib/linktest.dart). Development tool, not a service.
The tablet's own sockets are the only ones bound to the USB link (`adb shell`
traffic goes over WiFi), so the app drives the test and this end only serves:
7791/tcp down: client sends "<id> <secs>\\n", gets zeros for <secs>
7792/tcp up: client sends "<id>\\n" then data until EOF; gets "<bytes>\\n" back
7793/udp echo
7794/tcp results: one JSON line per connection, appended to the results file
Every TCP test also logs this end's TCP_INFO (retransmits, RTT), which the app
cannot read. Run: python3 linktest_server.py [results.jsonl]
"""
import json
import os
import socket
import struct
import sys
import threading
import time
RESULTS = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/linktest/results.jsonl")
CHUNK = b"\0" * 65536
lock = threading.Lock()
def log(rec):
rec = {"orin_ts": round(time.time(), 3), **rec}
with lock, open(RESULTS, "a") as f:
f.write(json.dumps(rec) + "\n")
print(json.dumps(rec), flush=True)
def tcp_info(sock):
# struct tcp_info: 8 bytes of u8 fields, then u32s; rtt is the 16th, total_retrans the 24th.
raw = sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_INFO, 104)
u = struct.unpack_from("8B24I", raw)
return {"retrans": u[8 + 23], "rtt_ms": u[8 + 15] / 1000, "rto_ms": u[8] / 1000}
def readline(conn):
buf = b""
while not buf.endswith(b"\n") and len(buf) < 256:
b = conn.recv(1)
if not b:
break
buf += b
return buf.decode().strip()
def down(conn, peer):
test_id, secs = readline(conn).split()
end = time.monotonic() + float(secs)
sent = 0
try:
while time.monotonic() < end:
sent += conn.send(CHUNK)
except OSError as e:
log({"id": test_id, "kind": "tcp_down_orin", "error": str(e)})
log({"id": test_id, "kind": "tcp_down_orin", "peer": peer, "bytes": sent, **tcp_info(conn)})
def up(conn, peer):
test_id = readline(conn)
got = 0
t0 = time.monotonic()
while True:
b = conn.recv(65536)
if not b:
break
got += len(b)
secs = time.monotonic() - t0
conn.sendall(f"{got}\n".encode())
log({"id": test_id, "kind": "tcp_up_orin", "peer": peer, "bytes": got, "secs": round(secs, 3),
**tcp_info(conn)})
def results(conn, peer):
data = b""
while True:
b = conn.recv(65536)
if not b:
break
data += b
for line in data.decode().splitlines():
if line.strip():
log({"peer": peer, **json.loads(line)})
def serve_tcp(port, handler):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port))
s.listen(4)
while True:
conn, addr = s.accept()
def run(conn=conn, peer=addr[0]):
with conn:
try:
handler(conn, peer)
except Exception as e: # a broken test must not stop the server
print(f"{handler.__name__} {peer}: {e}", flush=True)
threading.Thread(target=run, daemon=True).start()
def serve_echo(port):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", port))
while True:
data, addr = s.recvfrom(2048)
s.sendto(data, addr)
if __name__ == "__main__":
os.makedirs(os.path.dirname(RESULTS), exist_ok=True)
for port, h in ((7791, down), (7792, up), (7794, results)):
threading.Thread(target=serve_tcp, args=(port, h), daemon=True).start()
print(f"linktest server: tcp 7791 down, 7792 up, 7794 results; udp 7793 echo; -> {RESULTS}", flush=True)
serve_echo(7793)