Files
app-flutter/lib/pages/link_test.dart
T
mikkeli 4cf7a786d6
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 27s
App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
The shell (shell.dart): a drawer from the left edge or each page's menu button.
Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the
rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright,
build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals,
signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its
own navigator, so a run survives switching views. Back goes inside Captures, then to
the landing view, then out. The mock banner sits over every page.

main.dart is startup only; the old home screen is pages/signals.dart; dialogs and
sheets moved to actions/; the protocol client, USB binding and Reading to link/.
lib/README.md catalogues it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-14 14:08:56 +09:00

446 lines
18 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 '../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<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};
}
/// Orin → tablet only: sustained bulk sampled every 250 ms, TTS-like bursts on a
/// warm connection, and camera-like paced streams.
Future<void> 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<void> 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<Map<String, dynamic>> 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 = <int, int>{};
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<Map<String, dynamic>> tcpBursts(String host, String id,
{required List<int> 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<void>? 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 = <Map<String, dynamic>>[];
for (final size in sizes) {
final ms = <double>[];
for (var i = 0; i < repeats; i++) {
await Future<void>.delayed(idle);
got = 0;
target = size;
final w = want = Completer<void>();
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<Map<String, dynamic>> 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>[];
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<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(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')),
]);
},
),
);
}
}