From 50081d5417035cc50fd6b82e13867b927bf46cf1 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Fri, 9 Oct 2026 20:23:00 +0900 Subject: [PATCH] Starter display: boundary v0 client, readout, mark button, CI 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 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- .gitea/workflows/ci.yaml | 63 ++++ README.md | 5 + android/app/src/main/AndroidManifest.xml | 6 +- .../kotlin/dev/yukart/display/MainActivity.kt | 80 ++++- docs/impl/plans/INITIAL.md | 48 ++- lib/boundary.dart | 206 +++++++++++ lib/main.dart | 336 +++++++++++++----- lib/net.dart | 34 ++ test/boundary_test.dart | 94 +++++ test/widget_test.dart | 30 -- 10 files changed, 770 insertions(+), 132 deletions(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 lib/boundary.dart create mode 100644 lib/net.dart create mode 100644 test/boundary_test.dart delete mode 100644 test/widget_test.dart diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..c1198b1 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/README.md b/README.md index e999fce..5e99a18 100644 --- a/README.md +++ b/README.md @@ -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 [svc-gw](gitea:yukart/svc-gw), never to hardware. +```bash +flutter test +flutter run --dart-define=YUKART_GW=ws://:7700/ # default: the car Orin, ws://192.168.55.1:7700/ +``` + - For implementation practice guidelines, refer to the [guidelines](docs/GUIDELINES.md); - For implementation plans and history, refer to [them](docs/impl/); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8da7b8c..6c64c7d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + + 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() + } +} diff --git a/docs/impl/plans/INITIAL.md b/docs/impl/plans/INITIAL.md index 65891ae..523a808 100644 --- a/docs/impl/plans/INITIAL.md +++ b/docs/impl/plans/INITIAL.md @@ -7,8 +7,10 @@ updated: 2026-10-09 ## Order -1. MQTT client, subscribing to the retained vehicle topics. Text readout only — - prove the contract before drawing anything. +1. Text readout of the boundary stream — prove the contract before drawing + 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. 3. Moving map: MapLibre against the PMTiles basemap served by the Orin, with the 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 -- **Transport.** Whether the app reaches the broker directly or goes through - `svc-gw` is decided in that repo — see its initial plan. The physical link is - settled (below). +- **Transport.** The app talks to `svc-gw` over one WebSocket (boundary v0). + Whether high-rate topics later bypass it to the broker is decided in `svc-gw`. - **CJK labels.** MapLibre cannot ship Japanese glyph sets as SDF ranges. Local font rendering must be configured, or labels will be slow or absent. - **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. - The microphone stays on the Orin. This app never captures audio; ASR input never 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://: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. diff --git a/lib/boundary.dart b/lib/boundary.dart new file mode 100644 index 0000000..7056338 --- /dev/null +++ b/lib/boundary.dart @@ -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 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? hello; + Map signals = {}; + DateTime? lastState; + DateTime? lastMessage; + + /// Orin clock minus tablet clock, from heartbeats. Large values mean one side is unsynced. + Duration? clockOffset; + final List recent = []; + final List pending = []; + final List 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 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 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 _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 m; + try { + m = jsonDecode(raw) as Map; + } 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.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.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(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..a6886a9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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.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 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 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 editUrl(BuildContext context, Boundary b, UsbLink usb) async { + final c = TextEditingController(text: b.url); + final url = await showDialog( + 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); +} diff --git a/lib/net.dart b/lib/net.dart new file mode 100644 index 0000000..a95565a --- /dev/null +++ b/lib/net.dart @@ -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.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 bindFor(Uri target) async { + if (!supported) return; + final want = target.host.startsWith('192.168.55.'); + await _ch.invokeMethod('bindEthernet', want); + } +} diff --git a/test/boundary_test.dart b/test/boundary_test.dart new file mode 100644 index 0000000..684e73c --- /dev/null +++ b/test/boundary_test.dart @@ -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 = []; + final received = >[]; + bool ackCommands = true; + + Future 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; + 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 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.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); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index ff03a2c..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -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); - }); -}