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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
@@ -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
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user