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
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
// 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')),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user