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
+63
View File
@@ -0,0 +1,63 @@
name: CI
on:
push:
branches: [ master ]
paths:
- 'lib/**'
- 'test/**'
- 'android/**'
- 'pubspec.yaml'
- 'pubspec.lock'
- '.gitea/workflows/**'
pull_request:
branches: [ master ]
workflow_dispatch:
# Same self-hosted runner shape as android-stuff (halogen, host mode, label
# flutter-android): the toolchain on the host is the one that verified the
# release, and nothing installs per run. Host mode is also what keeps Gradle and
# pub caches warm between runs.
jobs:
analyze:
name: Analyze & Test
runs-on: flutter-android
steps:
- uses: actions/checkout@v4
- name: Toolchain
run: |
flutter --version
echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
flutter-android-preflight .
- name: Get dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test
apk:
name: Debug APK
runs-on: flutter-android
needs: [analyze]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Get dependencies
run: flutter pub get
# Debug-signed: this is a side-loaded in-car display, not a store release.
- name: Build APK
run: flutter build apk --debug
- uses: actions/upload-artifact@v4
with:
name: yukart-display-debug-apk
path: build/app/outputs/flutter-apk/app-debug.apk
if-no-files-found: error
+5
View File
@@ -19,5 +19,10 @@ assume it will always be an Android tablet.
It is a bus subscriber like any other display: it talks to the message bus and to It is a bus subscriber like any other display: it talks to the message bus and to
[svc-gw](gitea:yukart/svc-gw), never to hardware. [svc-gw](gitea:yukart/svc-gw), never to hardware.
```bash
flutter test
flutter run --dart-define=YUKART_GW=ws://<host>:7700/ # default: the car Orin, ws://192.168.55.1:7700/
```
- For implementation practice guidelines, refer to the [guidelines](docs/GUIDELINES.md); - For implementation practice guidelines, refer to the [guidelines](docs/GUIDELINES.md);
- For implementation plans and history, refer to [them](docs/impl/); - For implementation plans and history, refer to [them](docs/impl/);
+5 -1
View File
@@ -1,6 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- requestNetwork for the USB (NCM) Ethernet link to the Orin -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<application <application
android:label="display" android:label="Yukart"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -1,5 +1,83 @@
package dev.yukart.display package dev.yukart.display
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Bundle
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() /**
* Two things Dart cannot do on its own:
*
* - Keep the screen on. An idle tablet suspends about a minute after screen-off and
* then answers nothing, while the Orin still sees the USB link up.
* - Route over the USB link. It has no internet, so Android never makes it the
* default network while WiFi or cellular is up, and ordinary sockets go elsewhere.
* When asked, the whole process is bound to the Ethernet (NCM) network while it
* exists, and unbound when it goes.
*/
class MainActivity : FlutterActivity() {
private lateinit var channel: MethodChannel
private lateinit var cm: ConnectivityManager
private var ethernet: Network? = null
private var wantBinding = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
ethernet = network
apply()
}
override fun onLost(network: Network) {
if (ethernet == network) {
ethernet = null
apply()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "yukart/net")
channel.setMethodCallHandler { call, result ->
when (call.method) {
"bindEthernet" -> {
wantBinding = call.arguments as? Boolean ?: false
apply()
result.success(ethernet != null)
}
else -> result.notImplemented()
}
}
// NET_CAPABILITY_INTERNET is in the default request; the USB link lacks it.
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.requestNetwork(request, callback)
}
private fun apply() {
val target = if (wantBinding) ethernet else null
cm.bindProcessToNetwork(target)
val up = ethernet != null
runOnUiThread { channel.invokeMethod("ethernet", mapOf("up" to up, "bound" to (target != null))) }
}
override fun onDestroy() {
runCatching { cm.unregisterNetworkCallback(callback) }
cm.bindProcessToNetwork(null)
super.onDestroy()
}
}
+43 -5
View File
@@ -7,8 +7,10 @@ updated: 2026-10-09
## Order ## Order
1. MQTT client, subscribing to the retained vehicle topics. Text readout only — 1. Text readout of the boundary stream — prove the contract before drawing
prove the contract before drawing anything. anything. **Starter in place:** connects to `svc-gw` (protocol v0, contracts
`docs/BOUNDARY.md`), shows link state, every signal with stale/null/missing
told apart, the raw message log, the mock banner, and a Mark button.
2. Gauges. 2. Gauges.
3. Moving map: MapLibre against the PMTiles basemap served by the Orin, with the 3. Moving map: MapLibre against the PMTiles basemap served by the Orin, with the
position from `yukart/gnss/fix`. No routing involved. position from `yukart/gnss/fix`. No routing involved.
@@ -19,9 +21,8 @@ needs no routing engine at all. Routing is the expensive last fraction.
## Open ## Open
- **Transport.** Whether the app reaches the broker directly or goes through - **Transport.** The app talks to `svc-gw` over one WebSocket (boundary v0).
`svc-gw` is decided in that repo — see its initial plan. The physical link is Whether high-rate topics later bypass it to the broker is decided in `svc-gw`.
settled (below).
- **CJK labels.** MapLibre cannot ship Japanese glyph sets as SDF ranges. Local - **CJK labels.** MapLibre cannot ship Japanese glyph sets as SDF ranges. Local
font rendering must be configured, or labels will be slow or absent. font rendering must be configured, or labels will be slow or absent.
- **Sunlight.** A typical tablet is unreadable through a windscreen in daylight. - **Sunlight.** A typical tablet is unreadable through a windscreen in daylight.
@@ -96,3 +97,40 @@ has to arrive here to be heard.
without either side coordinating. without either side coordinating.
- The microphone stays on the Orin. This app never captures audio; ASR input never - The microphone stays on the Orin. This app never captures audio; ASR input never
crosses the boundary. crosses the boundary.
## Development workflow
| Where | Device link | Gateway |
|---|---|---|
| Desk | tablet USB → halogen, ADB over USB | `cargo run -- --mock` in `svc-gw` on halogen; app built with `--dart-define=YUKART_GW=ws://<halogen LAN IP>:7700/`, or the address changed at runtime by tapping the link bar |
| Car | tablet USB → Orin (tablet is USB host; no ADB over USB) | `yukart-gw.service` on the Orin, `ws://192.168.55.1:7700/`, the default |
The app binds the process to the USB Ethernet network only when the gateway
address is on `192.168.55.0/24`, so a desk build pointed at halogen over WiFi is
not routed into a dead USB link.
### ADB in the car, without a hub
The tablet's only USB port is the Orin link, and in it the tablet is the host —
USB debugging is unavailable there. ADB over TCP is not, and it travels over the
same NCM link:
```bash
adb tcpip 5555 # at the desk, over USB, once per tablet boot
# in the car, from halogen, through the Orin over the tailnet:
ssh -N -L 5555:192.168.55.100:5555 mikkeli-orin-nano &
adb connect localhost:5555
flutter run -d localhost:5555
```
The tablet keeps the RSA authorisation from the USB pairing. `adb usb` turns TCP
mode off again; a reboot does too. Android's *Wireless debugging* (pairing) is
WiFi-only and does not help here. Untested in the car so far.
### CI
`.gitea/workflows/ci.yaml` mirrors the android-stuff apps: host-mode runner on
halogen, label `flutter-android`, analyze + test, then a debug APK artifact on
push. The existing `halogen-android` runner is registered to the android-stuff
org only, so this repo needs a runner registered to the `yukart` org (a second
instance on halogen, the way `gitea-runner-audio` is set up) before jobs run.
+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'; import 'package:flutter/material.dart';
void main() { import 'boundary.dart';
runApp(const MyApp()); 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 { class YukartApp extends StatelessWidget {
const MyApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Flutter Demo', title: 'Yukart',
theme: ThemeData( debugShowCheckedModeBanner: false,
// This is the theme of your application. theme: ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark, useMaterial3: true),
// home: ListenableBuilder(
// TRY THIS: Try running your application with "flutter run". You'll see listenable: Listenable.merge([boundary, usb]),
// the application has a purple toolbar. Then, without quitting the app, builder: (context, _) => Readout(boundary: boundary, usb: usb),
// 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),
), ),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
); );
} }
} }
class MyHomePage extends StatefulWidget { class Readout extends StatelessWidget {
const MyHomePage({super.key, required this.title}); const Readout({super.key, required this.boundary, required this.usb});
final Boundary boundary;
// This widget is the home page of your application. It is stateful, meaning final UsbLink usb;
// 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++;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done final b = boundary;
// 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.
return Scaffold( return Scaffold(
appBar: AppBar( body: SafeArea(
// 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.
child: Column( child: Column(
// Column is also a layout widget. It takes a list of children and crossAxisAlignment: CrossAxisAlignment.stretch,
// 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,
children: [ children: [
const Text('You have pushed the button this many times:'), // Mock data must look like mock data: on the surface, not in a menu.
Text( if (b.mock) const MockBanner(),
'$_counter', LinkBar(boundary: b, usb: usb),
style: Theme.of(context).textTheme.headlineMedium, 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( floatingActionButton: FloatingActionButton.extended(
onPressed: _incrementCounter, onPressed: () => showMarkSheet(context, b),
tooltip: 'Increment', icon: const Icon(Icons.flag),
child: const Icon(Icons.add), 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);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:display/boundary.dart';
import 'package:flutter_test/flutter_test.dart';
/// A minimal stand-in for svc-gw: hello, then whatever the test sends.
class FakeGateway {
late HttpServer server;
final sockets = <WebSocket>[];
final received = <Map<String, dynamic>>[];
bool ackCommands = true;
Future<void> start({bool mock = false}) async {
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.transform(WebSocketTransformer()).listen((ws) {
sockets.add(ws);
ws.add(jsonEncode({
't': 'hello', 'proto': 0, 'server': 'fake', 'host': 'test', 'source': mock ? 'mock' : 'none',
'mock': mock, 'hb_ms': 100, 'timeout_ms': 400,
}));
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
received.add(m);
if (m['t'] == 'cmd' && ackCommands) {
ws.add(jsonEncode({'t': 'ack', 'id': m['id'], 'ok': true, 'detail': 'ok'}));
}
});
});
}
String get url => 'ws://127.0.0.1:${server.port}/';
}
Future<void> until(bool Function() cond, {Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (!cond()) {
if (DateTime.now().isAfter(end)) throw TimeoutException('condition not met');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
void main() {
test('connects, takes state, flags mock, heartbeats', () async {
final gw = FakeGateway();
await gw.start(mock: true);
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
expect(b.mock, isTrue);
gw.sockets.first.add(jsonEncode({
't': 'state',
'seq': 1,
'signals': {
'engine_rpm': {'timestamp': 0, 'monotonic': 0, 'value': 820, 'unit': 'rpm', 'mock': true},
},
}));
await until(() => b.signals.containsKey('engine_rpm'));
await until(() => gw.received.where((m) => m['t'] == 'hb').length >= 2);
await b.stop();
await gw.server.close(force: true);
});
test('silence past timeout_ms drops the link and reconnects', () async {
final gw = FakeGateway();
await gw.start();
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
// The fake never sends heartbeats, so the watchdog must fire after 400 ms.
await until(() => gw.sockets.length >= 2, timeout: const Duration(seconds: 4));
await b.stop();
await gw.server.close(force: true);
});
test('commands are retried under the same id until acked', () async {
final gw = FakeGateway()..ackCommands = false;
await gw.start();
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
final id = b.command('mark', {'text': 'brake'});
await until(() => gw.received.any((m) => m['t'] == 'cmd'));
// Drop the connection unacked; the command must be resent after reconnect.
gw.ackCommands = true;
await gw.sockets.first.close();
await until(() => b.pending.isEmpty);
final sent = gw.received.where((m) => m['t'] == 'cmd').toList();
expect(sent.length, greaterThanOrEqualTo(2));
expect(sent.every((m) => m['id'] == id), isTrue);
expect(sent.last['args']['delay_ms'], greaterThan(0));
expect(b.acks.first.ok, isTrue);
await b.stop();
await gw.server.close(force: true);
});
}
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:display/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}