Starter display: boundary v0 client, readout, mark button, CI
CI / Analyze & Test (push) Successful in 12s
CI / Debug APK (push) Successful in 57s

Connects to svc-gw over one WebSocket, heartbeats, treats timeout_ms of
silence as a dead link, and retries commands under the same id until acked.
Shows link state, signals with stale/null/missing told apart, the raw stream,
a mock banner on the surface, and a Mark button that stamps the recording.
Android: keeps the screen on and binds the process to the USB NCM network
when the gateway is on it. CI mirrors android-stuff's host-mode runner setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-09 20:23:00 +09:00
parent 62c6a79f89
commit 50081d5417
10 changed files with 770 additions and 132 deletions
+206
View File
@@ -0,0 +1,206 @@
// Client side of the display boundary, protocol v0 (contracts docs/BOUNDARY.md).
//
// This side always connects and owns reconnection. The link drops routinely —
// ~3 s at every power change, and an asleep peer looks connected — so silence is
// treated as a dead link, and commands are retried under the same id until acked.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
enum LinkState { connecting, up, down }
class PendingCommand {
PendingCommand(this.id, this.cmd, this.args) : queuedAt = DateTime.now();
final String id;
final String cmd;
final Map<String, dynamic> args;
final DateTime queuedAt;
}
class Ack {
Ack(this.id, this.ok, this.detail, this.at);
final String id;
final bool ok;
final String detail;
final DateTime at;
}
class Boundary extends ChangeNotifier {
Boundary(this.url, {this.connectTimeout = const Duration(seconds: 3)});
String url;
final Duration connectTimeout;
LinkState state = LinkState.down;
String detail = 'not started';
Map<String, dynamic>? hello;
Map<String, dynamic> signals = {};
DateTime? lastState;
DateTime? lastMessage;
/// Orin clock minus tablet clock, from heartbeats. Large values mean one side is unsynced.
Duration? clockOffset;
final List<String> recent = [];
final List<PendingCommand> pending = [];
final List<Ack> acks = [];
WebSocket? _ws;
Timer? _hb;
Timer? _watchdog;
bool _running = false;
int _attempt = 0;
final _rng = Random();
/// True when any message seen on this connection said mock.
bool get mock => hello?['mock'] == true || signals.values.any((e) => e is Map && e['mock'] == true);
Duration get _hbInterval => Duration(milliseconds: (hello?['hb_ms'] as int?) ?? 1000);
Duration get _timeout => Duration(milliseconds: (hello?['timeout_ms'] as int?) ?? 5000);
void start() {
if (_running) return;
_running = true;
_loop();
}
Future<void> stop() async {
_running = false;
await _ws?.close();
}
/// Change the target; the current connection is dropped and the loop reconnects.
void retarget(String newUrl) {
url = newUrl;
_ws?.close();
}
/// Queue a command; it is sent now if connected and resent after every reconnect until acked.
String command(String cmd, [Map<String, dynamic> args = const {}]) {
final id = '${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_rng.nextInt(1 << 32).toRadixString(36)}';
final c = PendingCommand(id, cmd, args);
pending.add(c);
_send(c);
notifyListeners();
return id;
}
Future<void> _loop() async {
while (_running) {
_set(LinkState.connecting, 'connecting to $url');
try {
final ws = await WebSocket.connect(url).timeout(connectTimeout);
_ws = ws;
_attempt = 0;
lastMessage = DateTime.now();
_armWatchdog();
await for (final raw in ws) {
_onMessage(raw as String);
}
_set(LinkState.down, 'closed${ws.closeReason != null && ws.closeReason!.isNotEmpty ? ': ${ws.closeReason}' : ''}');
} catch (e) {
_set(LinkState.down, '$e'.split('\n').first);
} finally {
_hb?.cancel();
_watchdog?.cancel();
_ws = null;
hello = null;
}
if (!_running) break;
// 0.5 s, 1 s, 2 s, then every 3 s: a replugged hub is back in about 3 s.
final wait = Duration(milliseconds: min(3000, 500 * (1 << min(_attempt, 3))));
_attempt++;
await Future.delayed(wait);
}
}
void _onMessage(String raw) {
lastMessage = DateTime.now();
recent.insert(0, raw.length > 400 ? '${raw.substring(0, 400)}…' : raw);
if (recent.length > 30) recent.removeLast();
final Map<String, dynamic> m;
try {
m = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return;
}
switch (m['t']) {
case 'hello':
hello = m;
_set(LinkState.up, '${m['server']} on ${m['host']}');
_hb?.cancel();
_hb = Timer.periodic(_hbInterval, (_) => _add('{"t":"hb"}'));
_armWatchdog();
for (final c in pending) {
_send(c);
}
case 'hb':
final ts = m['ts'];
if (ts is int) {
clockOffset = DateTime.fromMillisecondsSinceEpoch(ts).difference(DateTime.now());
}
case 'state':
signals = Map<String, dynamic>.from(m['signals'] as Map);
lastState = DateTime.now();
case 'ack':
final id = m['id'] as String?;
pending.removeWhere((c) => c.id == id);
acks.insert(0, Ack(id ?? '?', m['ok'] == true, '${m['detail'] ?? ''}', DateTime.now()));
if (acks.length > 10) acks.removeLast();
}
notifyListeners();
}
/// Silence for timeout_ms means the link is dead, whatever the socket says.
void _armWatchdog() {
_watchdog?.cancel();
_watchdog = Timer.periodic(const Duration(milliseconds: 500), (_) {
final last = lastMessage;
if (last != null && DateTime.now().difference(last) > _timeout) {
detail = 'no message for ${_timeout.inMilliseconds} ms';
_hb?.cancel();
_ws?.close();
}
notifyListeners();
});
}
void _send(PendingCommand c) {
if (_ws == null || hello == null) return;
final args = Map<String, dynamic>.from(c.args);
if (c.cmd == 'mark') {
args['delay_ms'] = DateTime.now().difference(c.queuedAt).inMilliseconds;
}
_add(jsonEncode({'t': 'cmd', 'id': c.id, 'cmd': c.cmd, 'args': args}));
}
/// A send on a link that just died is a no-op: the reconnect resends what matters.
void _add(String msg) {
final ws = _ws;
if (ws == null || ws.closeCode != null) return;
try {
ws.add(msg);
} on StateError {
// closed between the check and the add
}
}
void _set(LinkState s, String d) {
state = s;
detail = d;
if (s != LinkState.up) signals = {};
notifyListeners();
}
@override
void dispose() {
_running = false;
_hb?.cancel();
_watchdog?.cancel();
_ws?.close();
super.dispose();
}
}
+241 -95
View File
@@ -1,122 +1,268 @@
// Yukart display — starter. A text readout of whatever svc-gw sends, proving the
// boundary contract before anything is drawn (initial plan, step 1).
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
import 'boundary.dart';
import 'net.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
const defaultUrl = String.fromEnvironment('YUKART_GW', defaultValue: 'ws://192.168.55.1:7700/');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start();
runApp(YukartApp(boundary: boundary, usb: usb));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class YukartApp extends StatelessWidget {
const YukartApp({super.key, required this.boundary, required this.usb});
final Boundary boundary;
final UsbLink usb;
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
title: 'Yukart',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark, useMaterial3: true),
home: ListenableBuilder(
listenable: Listenable.merge([boundary, usb]),
builder: (context, _) => Readout(boundary: boundary, usb: usb),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
class Readout extends StatelessWidget {
const Readout({super.key, required this.boundary, required this.usb});
final Boundary boundary;
final UsbLink usb;
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
final b = boundary;
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
body: SafeArea(
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
// Mock data must look like mock data: on the surface, not in a menu.
if (b.mock) const MockBanner(),
LinkBar(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(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => showMarkSheet(context, b),
icon: const Icon(Icons.flag),
label: Text(b.pending.isEmpty ? 'Mark' : 'Mark (${b.pending.length} pending)'),
),
);
}
}
class MockBanner extends StatelessWidget {
const MockBanner({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.amber.shade700,
padding: const EdgeInsets.symmetric(vertical: 6),
child: const Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black, fontWeight: FontWeight.w900, fontSize: 20, letterSpacing: 2),
),
);
}
}
class LinkBar extends StatelessWidget {
const LinkBar({super.key, required this.boundary, required this.usb});
final Boundary boundary;
final UsbLink usb;
@override
Widget build(BuildContext context) {
final b = boundary;
final (color, label) = switch (b.state) {
LinkState.up => (Colors.green, 'CONNECTED'),
LinkState.connecting => (Colors.orange, 'CONNECTING'),
LinkState.down => (Colors.red, 'DOWN'),
};
final age = b.lastMessage == null ? '—' : '${DateTime.now().difference(b.lastMessage!).inMilliseconds} ms';
final offset = b.clockOffset == null ? '—' : '${(b.clockOffset!.inMilliseconds / 1000).toStringAsFixed(1)} s';
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell(
onTap: () => editUrl(context, b, usb),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Chip(label: Text(label), backgroundColor: color.withValues(alpha: 0.3)),
Text(b.url, style: const TextStyle(fontFamily: 'monospace')),
Text(b.detail),
Text('last msg $age'),
Text('Orin−tablet clock $offset'),
if (usb.supported) Text('USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}'),
]),
),
),
);
}
}
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.signals.isEmpty) {
return Center(
child: Text(
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
textAlign: TextAlign.center,
),
);
}
final names = b.signals.keys.toList()..sort();
return ListView(
padding: const EdgeInsets.all(12),
children: [
for (final n in names) SignalTile(name: n, envelope: Map<String, dynamic>.from(b.signals[n] as Map)),
],
);
}
}
/// Known-without-value, stale and fresh each look different: all three are normal in a car.
class SignalTile extends StatelessWidget {
const SignalTile({super.key, required this.name, required this.envelope});
final String name;
final Map<String, dynamic> envelope;
@override
Widget build(BuildContext context) {
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 ? Colors.grey : null,
fontStyle: stale ? FontStyle.italic : null,
);
return ListTile(
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text([
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)'),
for (final a in b.acks) Text('${a.ok ? '✓' : '✗'} ${a.detail}', style: TextStyle(color: a.ok ? null : Colors.redAccent)),
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)),
),
],
);
}
}
const quickMarks = ['cold start', 'engine start', 'indicator left', 'indicator right', 'brake', 'headlights', 'something odd'];
Future<void> showMarkSheet(BuildContext context, Boundary b) {
final custom = TextEditingController();
return showModalBottomSheet(
context: context,
builder: (ctx) {
void send(String text) {
if (text.trim().isEmpty) return;
b.command('mark', {'text': text.trim()});
Navigator.pop(ctx);
}
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + MediaQuery.of(ctx).viewInsets.bottom),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text('Stamp into the recording', style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: [
for (final q in quickMarks) ActionChip(label: Text(q), onPressed: () => send(q)),
]),
TextField(controller: custom, decoration: const InputDecoration(labelText: 'Other'), onSubmitted: send),
]),
);
},
);
}
Future<void> editUrl(BuildContext context, Boundary b, UsbLink usb) async {
final c = TextEditingController(text: b.url);
final url = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gateway address'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(ctx, c.text.trim()), child: const Text('Connect')),
],
),
);
if (url == null || url.isEmpty) return;
await usb.bindFor(Uri.parse(url));
b.retarget(url);
}
+34
View File
@@ -0,0 +1,34 @@
// Android only: binding the process to the USB (NCM) Ethernet link to the Orin.
// See MainActivity.kt for why Android needs telling.
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class UsbLink extends ChangeNotifier {
static const _ch = MethodChannel('yukart/net');
bool supported = Platform.isAndroid;
bool up = false;
bool bound = false;
UsbLink() {
if (!supported) return;
_ch.setMethodCallHandler((call) async {
if (call.method == 'ethernet') {
final a = Map<String, dynamic>.from(call.arguments as Map);
up = a['up'] == true;
bound = a['bound'] == true;
notifyListeners();
}
});
}
/// Bind only when the target is on the USB link; a workstation over WiFi must stay reachable.
Future<void> bindFor(Uri target) async {
if (!supported) return;
final want = target.host.startsWith('192.168.55.');
await _ch.invokeMethod<bool>('bindEthernet', want);
}
}