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,280 @@
|
||||
// Camera live view: shows the Orin camera's frames as they arrive, with exposure
|
||||
// and gain controls. The server is svc-vision (gitea:yukart/svc-vision,
|
||||
// src/liveview.py), which also defines the protocol. Not the boundary: its own
|
||||
// connection (project-yukart decision 27, proposed).
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../shell_scope.dart';
|
||||
import '../theme/palette.dart';
|
||||
|
||||
const camviewPort = 7710;
|
||||
|
||||
class CamView extends ChangeNotifier {
|
||||
CamView(this.host);
|
||||
final String host;
|
||||
|
||||
Socket? _s;
|
||||
bool _running = false;
|
||||
String status = 'not started';
|
||||
Uint8List? frame;
|
||||
int frames = 0;
|
||||
double fps = 0, kbPerFrame = 0, mbPerS = 0;
|
||||
|
||||
/// Delay beyond the fastest of the last ~300 frames: queueing, independent of the
|
||||
/// tens of ms by which the Orin's and the tablet's clocks disagree.
|
||||
double lagMs = 0;
|
||||
final _delays = <double>[];
|
||||
String lastReply = '';
|
||||
|
||||
/// Whether the Orin has the camera: null until it says, then its last notice.
|
||||
bool? available;
|
||||
|
||||
final _window = <(int, int)>[]; // (arrival µs, bytes) over the last second
|
||||
final _clock = Stopwatch()..start();
|
||||
|
||||
void start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_loop();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_running = false;
|
||||
_s?.destroy();
|
||||
}
|
||||
|
||||
void send(String line) => _s?.write('$line\n');
|
||||
|
||||
Future<void> _loop() async {
|
||||
while (_running) {
|
||||
status = 'connecting to $host:$camviewPort';
|
||||
notifyListeners();
|
||||
try {
|
||||
final s = await Socket.connect(
|
||||
host,
|
||||
camviewPort,
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
_s = s;
|
||||
available = null;
|
||||
status = 'connected';
|
||||
notifyListeners();
|
||||
await _read(s);
|
||||
status = 'closed';
|
||||
} catch (e) {
|
||||
status = '$e'.split('\n').first;
|
||||
}
|
||||
_s = null;
|
||||
frame = null; // a frame from a closed connection is not live
|
||||
notifyListeners();
|
||||
if (_running) await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _read(Socket s) async {
|
||||
var data = Uint8List(0); // unconsumed bytes
|
||||
await for (final chunk in s) {
|
||||
if (data.isEmpty) {
|
||||
data = chunk;
|
||||
} else {
|
||||
data =
|
||||
(BytesBuilder(copy: false)
|
||||
..add(data)
|
||||
..add(chunk))
|
||||
.takeBytes();
|
||||
}
|
||||
var off = 0;
|
||||
while (data.length - off >= 16) {
|
||||
final h = ByteData.sublistView(data, off, off + 16);
|
||||
final len = h.getUint32(0);
|
||||
final sentAt = h.getFloat64(4);
|
||||
final body = len == 0
|
||||
? h.getUint32(12)
|
||||
: len; // length 0: a text reply, its length in the third field
|
||||
if (data.length - off - 16 < body) break;
|
||||
final bytes = Uint8List.sublistView(data, off + 16, off + 16 + body);
|
||||
off += 16 + body;
|
||||
if (len == 0) {
|
||||
_onText(utf8.decode(bytes));
|
||||
} else {
|
||||
_onFrame(Uint8List.fromList(bytes), sentAt);
|
||||
}
|
||||
}
|
||||
data = Uint8List.sublistView(data, off);
|
||||
}
|
||||
}
|
||||
|
||||
void _onText(String text) {
|
||||
if (text == 'camera unavailable' || text == 'camera ok') {
|
||||
available = text == 'camera ok';
|
||||
if (available == false) frame = null; // never show a stale frame as live
|
||||
} else {
|
||||
lastReply = text;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onFrame(Uint8List jpeg, double sentAt) {
|
||||
final now = _clock.elapsedMicroseconds;
|
||||
_window.add((now, jpeg.length));
|
||||
_window.removeWhere((e) => now - e.$1 > 1000000);
|
||||
frames++;
|
||||
fps = _window.length.toDouble();
|
||||
final bytes = _window.fold<int>(0, (a, e) => a + e.$2);
|
||||
mbPerS = bytes / 1e6;
|
||||
kbPerFrame = jpeg.length / 1024;
|
||||
final delay = DateTime.now().microsecondsSinceEpoch / 1000 - sentAt * 1000;
|
||||
_delays.add(delay);
|
||||
if (_delays.length > 300) _delays.removeAt(0);
|
||||
lagMs = delay - _delays.reduce((a, b) => a < b ? a : b);
|
||||
frame = jpeg;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_running = false;
|
||||
_s?.destroy();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class CamViewPage extends StatefulWidget {
|
||||
const CamViewPage({super.key, required this.host});
|
||||
final String host;
|
||||
|
||||
@override
|
||||
State<CamViewPage> createState() => _CamViewPageState();
|
||||
}
|
||||
|
||||
class _CamViewPageState extends State<CamViewPage> {
|
||||
late final cam = CamView(widget.host)..start();
|
||||
bool auto = true;
|
||||
double exposure = 100; // 100 µs units: 10 ms
|
||||
double gain = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cam.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.of(context).background,
|
||||
appBar: AppBar(leading: const MenuButton(), title: const Text('Camera (test)')),
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: cam,
|
||||
builder: (context, _) {
|
||||
final f = cam.frame;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: f == null
|
||||
? Center(
|
||||
child: Text(
|
||||
cam.available == false
|
||||
? 'Camera unavailable on the Orin'
|
||||
: cam.status,
|
||||
style: TextStyle(color: AppTheme.of(context).subdued),
|
||||
),
|
||||
)
|
||||
: Image.memory(
|
||||
f,
|
||||
gaplessPlayback: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(cam.status),
|
||||
Text('${cam.fps.toStringAsFixed(0)} fps'),
|
||||
Text('${cam.kbPerFrame.toStringAsFixed(0)} KB/frame'),
|
||||
Text('${cam.mbPerS.toStringAsFixed(2)} MB/s'),
|
||||
Text('lag +${cam.lagMs.toStringAsFixed(0)} ms'),
|
||||
FilterChip(
|
||||
label: const Text('Auto exposure'),
|
||||
selected: auto,
|
||||
onSelected: (v) {
|
||||
setState(() => auto = v);
|
||||
cam.send(
|
||||
v
|
||||
? 'exposure auto'
|
||||
: 'exposure ${exposure.round()}',
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'exp ${(exposure / 10).toStringAsFixed(1)} ms',
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 1,
|
||||
max: 600,
|
||||
value: exposure,
|
||||
onChanged: auto
|
||||
? null
|
||||
: (v) => setState(() => exposure = v),
|
||||
onChangeEnd: (v) =>
|
||||
cam.send('exposure ${v.round()}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Row(
|
||||
children: [
|
||||
Text('gain ${gain.round()}'),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 240,
|
||||
value: gain,
|
||||
onChanged: (v) => setState(() => gain = v),
|
||||
onChangeEnd: (v) =>
|
||||
cam.send('gain ${v.round()}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (cam.lastReply.isNotEmpty)
|
||||
Text(
|
||||
cam.lastReply,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Capture checklists: step through a recording plan with one button per action.
|
||||
//
|
||||
// The steps are data, from contracts `checklists/` (bundled as an asset under
|
||||
// assets/contracts/, an exact copy; tool/sync-contracts.sh refreshes it). Nothing here
|
||||
// knows what a step is about: it only turns presses into marks.
|
||||
//
|
||||
// Mark text, as the analysis reads it back from events.ndjson:
|
||||
// <step> <action>[ #<n>] start | end a span (the one button, pressed twice)
|
||||
// <step> <action>[ #<n>] a point
|
||||
// <step> <action>[ #<n>] abort a span left open (skip, redo, another step)
|
||||
// <step> skip | <step> redo the step was skipped / is being done again
|
||||
// checklist <id> v<version> once, before this page's first mark
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../link/boundary.dart';
|
||||
import '../shell_scope.dart';
|
||||
import '../theme/palette.dart';
|
||||
|
||||
const checklistAssets = 'assets/contracts/checklists/';
|
||||
|
||||
class ChecklistAction {
|
||||
ChecklistAction(this.label, {this.holdS, this.repeat = 1, this.point = false});
|
||||
final String label;
|
||||
|
||||
/// Suggested length of the span, shown as a target while it runs.
|
||||
final int? holdS;
|
||||
final int repeat;
|
||||
final bool point;
|
||||
|
||||
factory ChecklistAction.fromJson(Map<String, dynamic> j) => ChecklistAction(
|
||||
j['label'] as String,
|
||||
holdS: j['hold_s'] as int?,
|
||||
repeat: j['repeat'] as int? ?? 1,
|
||||
point: j['point'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
class ChecklistStep {
|
||||
ChecklistStep(this.id, this.title, this.doText, this.finds, this.actions, this.section);
|
||||
final String id;
|
||||
final String title;
|
||||
final String doText;
|
||||
final String finds;
|
||||
final List<ChecklistAction> actions;
|
||||
final String section;
|
||||
}
|
||||
|
||||
class Checklist {
|
||||
Checklist(this.id, this.version, this.title, this.steps);
|
||||
final String id;
|
||||
final int version;
|
||||
final String title;
|
||||
final List<ChecklistStep> steps;
|
||||
|
||||
factory Checklist.fromJson(Map<String, dynamic> j) {
|
||||
final steps = <ChecklistStep>[];
|
||||
for (final s in j['sections'] as List) {
|
||||
final section = (s as Map<String, dynamic>)['title'] as String;
|
||||
for (final st in s['steps'] as List) {
|
||||
final m = st as Map<String, dynamic>;
|
||||
steps.add(ChecklistStep(
|
||||
m['id'] as String,
|
||||
m['title'] as String,
|
||||
m['do'] as String? ?? '',
|
||||
m['finds'] as String? ?? '',
|
||||
[for (final a in m['actions'] as List) ChecklistAction.fromJson(a as Map<String, dynamic>)],
|
||||
section,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Checklist(j['id'] as String, j['version'] as int, j['title'] as String, steps);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the one button is in a checklist, and the mark each press sends. No I/O:
|
||||
/// every method returns the marks to send, in order.
|
||||
class ChecklistRun {
|
||||
ChecklistRun(this.checklist);
|
||||
final Checklist checklist;
|
||||
|
||||
int step = 0;
|
||||
int action = 0;
|
||||
int rep = 0;
|
||||
DateTime? spanStarted;
|
||||
bool _announced = false;
|
||||
|
||||
ChecklistStep get current => checklist.steps[step];
|
||||
bool get inSpan => spanStarted != null;
|
||||
bool get stepDone => action >= current.actions.length;
|
||||
bool get finished => step >= checklist.steps.length - 1 && stepDone;
|
||||
ChecklistAction? get currentAction => stepDone ? null : current.actions[action];
|
||||
|
||||
/// "brake #2" when the action repeats, "brake" when it does not.
|
||||
String get actionName {
|
||||
final a = currentAction!;
|
||||
return a.repeat > 1 ? '${a.label} #${rep + 1}' : a.label;
|
||||
}
|
||||
|
||||
/// What the button will do next, for its label.
|
||||
String get nextLabel {
|
||||
if (stepDone) return finished ? 'All done' : 'Next step';
|
||||
final a = currentAction!;
|
||||
if (a.point) return 'Mark $actionName';
|
||||
return inSpan ? 'End $actionName' : 'Start $actionName';
|
||||
}
|
||||
|
||||
List<String> _announce() {
|
||||
if (_announced) return const [];
|
||||
_announced = true;
|
||||
return ['checklist ${checklist.id} v${checklist.version}'];
|
||||
}
|
||||
|
||||
List<String> press([DateTime? now]) {
|
||||
if (stepDone) {
|
||||
if (!finished) _goto(step + 1);
|
||||
return const [];
|
||||
}
|
||||
final out = _announce();
|
||||
final a = currentAction!;
|
||||
final name = '${current.id} $actionName';
|
||||
if (a.point) {
|
||||
_advance();
|
||||
return [...out, name];
|
||||
}
|
||||
if (!inSpan) {
|
||||
spanStarted = now ?? DateTime.now();
|
||||
return [...out, '$name start'];
|
||||
}
|
||||
spanStarted = null;
|
||||
_advance();
|
||||
return [...out, '$name end'];
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (rep + 1 < currentAction!.repeat) {
|
||||
rep++;
|
||||
} else {
|
||||
rep = 0;
|
||||
action++;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _leave() => inSpan ? [..._announce(), '${current.id} $actionName abort'] : const [];
|
||||
|
||||
List<String> skip() {
|
||||
final out = [..._leave(), ..._announce(), '${current.id} skip'];
|
||||
if (step < checklist.steps.length - 1) {
|
||||
_goto(step + 1);
|
||||
} else {
|
||||
_goto(step);
|
||||
action = current.actions.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<String> redo() {
|
||||
final out = [..._leave(), ..._announce(), '${current.id} redo'];
|
||||
_goto(step);
|
||||
return out;
|
||||
}
|
||||
|
||||
List<String> select(int i) {
|
||||
if (i == step) return const [];
|
||||
final out = _leave();
|
||||
_goto(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
void _goto(int i) {
|
||||
step = i;
|
||||
action = 0;
|
||||
rep = 0;
|
||||
spanStarted = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Checklist>> loadChecklists() async {
|
||||
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
final paths = manifest.listAssets().where((p) => p.startsWith(checklistAssets) && p.endsWith('.json')).toList()..sort();
|
||||
return [
|
||||
for (final p in paths) Checklist.fromJson(jsonDecode(await rootBundle.loadString(p)) as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
|
||||
/// Lists the bundled checklists; opens the only one directly.
|
||||
class ChecklistsPage extends StatelessWidget {
|
||||
const ChecklistsPage({super.key, required this.boundary});
|
||||
final Boundary boundary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Checklist>>(
|
||||
future: loadChecklists(),
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) {
|
||||
return Scaffold(appBar: AppBar(leading: const MenuButton(), title: const Text('Captures')), body: Center(child: Text('${snap.error}')));
|
||||
}
|
||||
final lists = snap.data;
|
||||
if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single);
|
||||
return Scaffold(
|
||||
appBar: AppBar(leading: const MenuButton(), title: const Text('Captures')),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')),
|
||||
for (final c in lists)
|
||||
ListTile(
|
||||
title: Text(c.title),
|
||||
subtitle: Text('${c.id} v${c.version} · ${c.steps.length} steps'),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => ChecklistPage(boundary: boundary, checklist: c)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Sent {
|
||||
_Sent(this.id, this.text);
|
||||
final String id;
|
||||
final String text;
|
||||
}
|
||||
|
||||
class ChecklistPage extends StatefulWidget {
|
||||
const ChecklistPage({super.key, required this.boundary, required this.checklist});
|
||||
final Boundary boundary;
|
||||
final Checklist checklist;
|
||||
|
||||
@override
|
||||
State<ChecklistPage> createState() => _ChecklistPageState();
|
||||
}
|
||||
|
||||
class _ChecklistPageState extends State<ChecklistPage> {
|
||||
late final run = ChecklistRun(widget.checklist);
|
||||
final sent = <_Sent>[];
|
||||
Timer? _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.boundary.addListener(_changed);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick?.cancel();
|
||||
widget.boundary.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() => setState(() {});
|
||||
|
||||
void _send(List<String> marks) {
|
||||
for (final m in marks) {
|
||||
sent.insert(0, _Sent(widget.boundary.command('mark', {'text': m}), m));
|
||||
}
|
||||
if (sent.length > 20) sent.removeRange(20, sent.length);
|
||||
// The span timer only needs to tick while a span is open.
|
||||
_tick?.cancel();
|
||||
if (run.inSpan) _tick = Timer.periodic(const Duration(milliseconds: 100), (_) => setState(() {}));
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = StepList(run: run, onSelect: (i) => _send(run.select(i)));
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.checklist.title),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (context) => MediaQuery.of(context).size.width > 800
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.list),
|
||||
tooltip: 'Steps',
|
||||
onPressed: () => Scaffold.of(context).openEndDrawer(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
endDrawer: MediaQuery.of(context).size.width > 800 ? null : Drawer(child: SafeArea(child: steps)),
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final panel = _panel(context);
|
||||
if (c.maxWidth <= 800) return panel;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: 300, child: steps),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: panel),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panel(BuildContext context) {
|
||||
final s = run.current;
|
||||
final theme = Theme.of(context);
|
||||
final p = AppTheme.of(context);
|
||||
final a = run.currentAction;
|
||||
// The big button by what pressing it does: start (good), stop a running span
|
||||
// (caution: something is in progress), a point mark (accent), skip on (subdued).
|
||||
final (Color color, IconData icon) = run.stepDone
|
||||
? (p.subdued, Icons.skip_next)
|
||||
: a!.point
|
||||
? (p.accent, Icons.flag)
|
||||
: run.inSpan
|
||||
? (p.caution, Icons.stop)
|
||||
: (p.good, Icons.play_arrow);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(s.section, style: theme.textTheme.labelLarge?.copyWith(color: theme.colorScheme.outline)),
|
||||
Text('${s.id}. ${s.title}', style: theme.textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(s.doText, style: theme.textTheme.titleLarge),
|
||||
if (s.finds.isNotEmpty)
|
||||
Text('Finds: ${s.finds}', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.outline)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (var i = 0; i < s.actions.length; i++) _actionChip(s, i)],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: p.onFill(color),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
onPressed: run.finished ? null : () => _send(run.press()),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 72),
|
||||
Text(run.nextLabel, textAlign: TextAlign.center, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.w700)),
|
||||
if (run.inSpan) _spanTimer(a!),
|
||||
if (!run.inSpan && a != null && a.holdS != null && !a.point)
|
||||
Text('hold about ${a.holdS} s', style: const TextStyle(fontSize: 22)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(onPressed: () => _send(run.redo()), icon: const Icon(Icons.replay), label: const Text('Redo step')),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: run.finished ? null : () => _send(run.skip()),
|
||||
icon: const Icon(Icons.redo),
|
||||
label: const Text('Skip step'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _lastMark(context)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionChip(ChecklistStep s, int i) {
|
||||
final a = s.actions[i];
|
||||
final done = i < run.action;
|
||||
final now = i == run.action;
|
||||
final reps = a.repeat > 1 ? ' ×${a.repeat}' : '';
|
||||
return Chip(
|
||||
avatar: Icon(done ? Icons.check : (a.point ? Icons.flag_outlined : Icons.timelapse), size: 18),
|
||||
label: Text('${a.label}$reps'),
|
||||
backgroundColor: now ? Theme.of(context).colorScheme.primaryContainer : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _spanTimer(ChecklistAction a) {
|
||||
final secs = DateTime.now().difference(run.spanStarted!).inMilliseconds / 1000;
|
||||
final reached = a.holdS != null && secs >= a.holdS!;
|
||||
return Text(
|
||||
a.holdS == null ? '${secs.toStringAsFixed(1)} s' : '${secs.toStringAsFixed(1)} s / ${a.holdS} s',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: reached ? AppTheme.of(context).good : AppTheme.of(context).foreground,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The latest mark and whether the recorder took it: an unseen ✗ means marks are going nowhere.
|
||||
Widget _lastMark(BuildContext context) {
|
||||
if (sent.isEmpty) {
|
||||
return Text('No marks sent yet', textAlign: TextAlign.end, style: TextStyle(color: Theme.of(context).colorScheme.outline));
|
||||
}
|
||||
final b = widget.boundary;
|
||||
final last = sent.first;
|
||||
final pending = b.pending.length;
|
||||
final ack = b.acks.where((x) => x.id == last.id).firstOrNull;
|
||||
final (String status, Color? color) = ack != null
|
||||
? (ack.ok ? ('✓', AppTheme.of(context).good) : ('✗ ${ack.detail}', AppTheme.of(context).alarm))
|
||||
: b.pending.any((p) => p.id == last.id)
|
||||
? ('… unacked', AppTheme.of(context).caution)
|
||||
: ('?', null);
|
||||
return Text(
|
||||
'"${last.text}" $status${pending > 1 ? ' · $pending waiting' : ''}',
|
||||
textAlign: TextAlign.end,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: TextStyle(color: color, fontSize: 16),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StepList extends StatelessWidget {
|
||||
const StepList({super.key, required this.run, required this.onSelect});
|
||||
final ChecklistRun run;
|
||||
final void Function(int) onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = run.checklist.steps;
|
||||
return ListView.builder(
|
||||
itemCount: steps.length,
|
||||
itemBuilder: (context, i) {
|
||||
final s = steps[i];
|
||||
final header = i == 0 || steps[i - 1].section != s.section;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (header)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(s.section, style: Theme.of(context).textTheme.labelLarge),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
selected: i == run.step,
|
||||
leading: Text(s.id, style: const TextStyle(fontSize: 18)),
|
||||
title: Text(s.title),
|
||||
onTap: () {
|
||||
onSelect(i);
|
||||
if (Scaffold.maybeOf(context)?.isEndDrawerOpen == true) Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// The software dash: smart-serow's HUD layout and widgets, fed from the boundary.
|
||||
// A grid of boxes (lib/widgets/stat_box.dart): the instruments and the big numbers
|
||||
// in the main row, smaller readouts and telltales below. Every value names its
|
||||
// signal from contracts' signals.json and nothing else (app guideline 1).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../link/boundary.dart';
|
||||
import '../shell_scope.dart';
|
||||
import '../link/reading.dart';
|
||||
import '../theme/palette.dart';
|
||||
import '../widgets/accel_graph.dart';
|
||||
import '../widgets/gps_compass.dart';
|
||||
import '../widgets/stat_box.dart';
|
||||
import '../widgets/telltale.dart';
|
||||
import '../widgets/whiskey_mark.dart';
|
||||
|
||||
class DashPage extends StatelessWidget {
|
||||
const DashPage({super.key, required this.boundary, required this.themes});
|
||||
final Boundary boundary;
|
||||
final Themes themes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([boundary, themes]),
|
||||
builder: (context, _) {
|
||||
final p = AppTheme.of(context);
|
||||
final b = boundary;
|
||||
Reading r(String n) => Reading.of(b, n);
|
||||
final rpm = r('engine_rpm');
|
||||
final speed = r('gnss_speed_kph');
|
||||
final pedal = r('accel_pedal_pct');
|
||||
final coolant = r('obd_coolant_temp_c');
|
||||
final lat = r('imu_accel_lat_g');
|
||||
final lon = r('imu_accel_lon_g');
|
||||
// The compass's states, from gnss_fix: none → acquiring, 2d/3d → fix, missing → none known.
|
||||
final fix = r('gnss_fix');
|
||||
final gpsState = fix.value == null
|
||||
? null
|
||||
: fix.value == 'none'
|
||||
? 'acquiring'
|
||||
: 'fix';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.background,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DashBar(boundary: b, themes: themes),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main area: instruments and the big numbers.
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: WhiskeyMark(
|
||||
roll: r('imu_roll_deg').number,
|
||||
pitch: r('imu_pitch_deg').number,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AccelGraph(
|
||||
ax: lat.number,
|
||||
ay: lon.number,
|
||||
maxG: 0.8,
|
||||
ghostTrackPeriod: const Duration(seconds: 4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
StatBoxMain(
|
||||
value: rpm.fixed(),
|
||||
label: 'RPM',
|
||||
dim: rpm.dim,
|
||||
isWarning: () => (rpm.number ?? 0) > 6000,
|
||||
),
|
||||
StatBoxMain(
|
||||
value: speed.fixed(),
|
||||
unit: 'km/h',
|
||||
label: 'GNSS SPEED',
|
||||
dim: speed.dim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom row: smaller readouts and telltales.
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
children: [
|
||||
StatBox(
|
||||
value: pedal.fixed(),
|
||||
unit: '%',
|
||||
label: 'PEDAL',
|
||||
dim: pedal.dim,
|
||||
),
|
||||
StatBox(
|
||||
value: coolant.fixed(),
|
||||
unit: '°C',
|
||||
label: 'COOLANT',
|
||||
dim: coolant.dim,
|
||||
isWarning: () => (coolant.number ?? 0) > 105,
|
||||
),
|
||||
Expanded(
|
||||
child: GpsCompass(
|
||||
heading: r('gnss_heading_deg').number,
|
||||
gpsState: gpsState,
|
||||
),
|
||||
),
|
||||
Telltale(
|
||||
label: 'LEFT',
|
||||
icon: Icons.arrow_left,
|
||||
reading: r('indicator_left'),
|
||||
),
|
||||
Telltale(
|
||||
label: 'RIGHT',
|
||||
icon: Icons.arrow_right,
|
||||
reading: r('indicator_right'),
|
||||
),
|
||||
Telltale(
|
||||
label: 'BRAKE',
|
||||
reading: r('brake_pedal'),
|
||||
),
|
||||
Telltale(
|
||||
label: 'PARK',
|
||||
reading: r('handbrake'),
|
||||
warn: true,
|
||||
),
|
||||
Telltale(
|
||||
label: 'LIGHTS',
|
||||
reading: r('side_lights'),
|
||||
),
|
||||
Telltale(
|
||||
label: 'DOOR',
|
||||
reading: r('door_open'),
|
||||
warn: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dash's own thin top line: the menu, link state, and the dark/bright switch.
|
||||
class _DashBar extends StatelessWidget {
|
||||
const _DashBar({required this.boundary, required this.themes});
|
||||
final Boundary boundary;
|
||||
final Themes themes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = AppTheme.of(context);
|
||||
final (text, color) = switch (boundary.state) {
|
||||
LinkState.up => ('LINK', p.subdued),
|
||||
LinkState.connecting => ('CONNECTING', p.caution),
|
||||
LinkState.down => ('NO LINK', p.alarm),
|
||||
};
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
MenuButton(color: p.subdued),
|
||||
Text(text, style: TextStyle(color: color, letterSpacing: 2)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: themes.isDark ? 'Bright' : 'Dark',
|
||||
onPressed: themes.toggle,
|
||||
icon: Icon(
|
||||
themes.isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
color: p.subdued,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Settings: the few things that are chosen, gathered in one place. Build-time choices
|
||||
// are shown so it is clear where they come from, not edited here.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../actions/server_url.dart';
|
||||
import '../link/boundary.dart';
|
||||
import '../link/net.dart';
|
||||
import '../shell_scope.dart';
|
||||
import '../theme/palette.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({
|
||||
super.key,
|
||||
required this.boundary,
|
||||
required this.usb,
|
||||
required this.themes,
|
||||
});
|
||||
final Boundary boundary;
|
||||
final UsbLink usb;
|
||||
final Themes themes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = AppTheme.of(context);
|
||||
Widget section(String t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
child: Text(
|
||||
t,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: p.subdued),
|
||||
),
|
||||
);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([boundary, themes]),
|
||||
builder: (context, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const MenuButton(),
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
section('Connection'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lan),
|
||||
title: const Text('Gateway address'),
|
||||
subtitle: Text(
|
||||
boundary.url,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
trailing: const Icon(Icons.edit),
|
||||
onTap: () => editUrl(context, boundary, usb),
|
||||
),
|
||||
section('Display'),
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
themes.isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
),
|
||||
title: const Text('Dark variant'),
|
||||
subtitle: const Text('Bright for daylight, dark for night'),
|
||||
value: themes.isDark,
|
||||
onChanged: (v) => themes.isDark = v,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
title: const Text('Theme'),
|
||||
subtitle: Text(
|
||||
'${themes.name} · set at build time: --dart-define=YUKART_THEME=<name>',
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.home),
|
||||
title: const Text('Landing view'),
|
||||
subtitle: Text(
|
||||
'${landingView.name} · set at build time: --dart-define=YUKART_HOME=dash|signals',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Signals: every value the gateway sends, as a list, beside the raw traffic. The
|
||||
// development landing view (lib/shell_scope.dart).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../actions/marks.dart';
|
||||
import '../actions/server_url.dart';
|
||||
import '../link/boundary.dart';
|
||||
import '../link/net.dart';
|
||||
import '../shell_scope.dart';
|
||||
import '../theme/palette.dart';
|
||||
import '../widgets/link_status.dart';
|
||||
|
||||
class SignalsPage extends StatelessWidget {
|
||||
const SignalsPage({super.key, required this.boundary, required this.usb});
|
||||
final Boundary boundary;
|
||||
final UsbLink usb;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final b = boundary;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([boundary, usb]),
|
||||
builder: (context, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const MenuButton(),
|
||||
title: const Text('Signals'),
|
||||
actions: [
|
||||
LinkChip(boundary: b),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Material(
|
||||
color: AppTheme.of(context).surface,
|
||||
child: InkWell(
|
||||
onTap: () => editUrl(context, b, usb),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: LinkDetails(boundary: b, usb: usb),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final wide = c.maxWidth > 800;
|
||||
final signals = SignalList(boundary: b);
|
||||
final raw = RawLog(boundary: b);
|
||||
return wide
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 3, child: signals),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(flex: 2, child: raw),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(child: signals),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: raw),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => showMarkSheet(context, b),
|
||||
icon: const Icon(Icons.flag),
|
||||
label: Text(
|
||||
b.pending.isEmpty ? 'Mark' : 'Mark (${b.pending.length} pending)',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SignalList extends StatelessWidget {
|
||||
const SignalList({super.key, required this.boundary});
|
||||
final Boundary boundary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final b = boundary;
|
||||
if (b.state != LinkState.up) {
|
||||
return const Center(child: Text('No link to the Orin'));
|
||||
}
|
||||
if (b.seen.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Rows stay put: a signal that drops out is shown as missing, not removed.
|
||||
final names = b.seen.keys.toList()..sort();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
for (final n in names)
|
||||
b.signals[n] is Map
|
||||
? SignalTile(
|
||||
name: n,
|
||||
envelope: Map<String, dynamic>.from(b.signals[n] as Map),
|
||||
)
|
||||
: SignalTile(name: n, envelope: b.seen[n]!, missing: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing, known-without-value, stale and fresh each look different: all four are normal in a car.
|
||||
class SignalTile extends StatelessWidget {
|
||||
const SignalTile({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.envelope,
|
||||
this.missing = false,
|
||||
});
|
||||
final String name;
|
||||
|
||||
/// The current envelope, or the last one seen when [missing] (only its unit is used then).
|
||||
final Map<String, dynamic> envelope;
|
||||
final bool missing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = AppTheme.of(context);
|
||||
if (missing) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
name,
|
||||
style: TextStyle(fontFamily: 'monospace', color: p.subdued),
|
||||
),
|
||||
subtitle: Text(
|
||||
'MISSING · not in the latest state',
|
||||
style: TextStyle(color: p.alarm),
|
||||
),
|
||||
trailing: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '?',
|
||||
style: TextStyle(fontSize: 34, color: p.subdued),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${envelope['unit'] ?? ''}',
|
||||
style: TextStyle(fontSize: 16, color: p.subdued),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final v = envelope['value'];
|
||||
final quality = envelope['quality'] as String? ?? 'ok';
|
||||
final stale = quality != 'ok';
|
||||
final text = v == null
|
||||
? '—'
|
||||
: (v is double && v == v.roundToDouble() ? v.toInt().toString() : '$v');
|
||||
final style = TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: stale || v == null ? p.subdued : p.foreground,
|
||||
fontStyle: stale ? FontStyle.italic : null,
|
||||
);
|
||||
return ListTile(
|
||||
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
|
||||
subtitle: Text(
|
||||
style: stale ? TextStyle(color: p.caution) : null,
|
||||
[
|
||||
if (stale) quality.toUpperCase(),
|
||||
if (v == null) 'no value',
|
||||
if (envelope['mock'] == true) 'mock',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: text, style: style),
|
||||
TextSpan(
|
||||
text: ' ${envelope['unit'] ?? ''}',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RawLog extends StatelessWidget {
|
||||
const RawLog({super.key, required this.boundary});
|
||||
final Boundary boundary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final b = boundary;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
if (b.pending.isNotEmpty || b.acks.isNotEmpty) ...[
|
||||
Text('Commands', style: Theme.of(context).textTheme.titleSmall),
|
||||
for (final p in b.pending)
|
||||
Text(
|
||||
'… ${p.cmd} ${p.args['text'] ?? ''} (unacked)',
|
||||
style: TextStyle(color: AppTheme.of(context).caution),
|
||||
),
|
||||
for (final a in b.acks)
|
||||
Text(
|
||||
'${a.ok ? '✓' : '✗'} ${a.detail}',
|
||||
style: TextStyle(
|
||||
color: a.ok
|
||||
? AppTheme.of(context).good
|
||||
: AppTheme.of(context).alarm,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
Text(
|
||||
'Raw messages, newest first',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
for (final r in b.recent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
r,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user