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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-09 20:23:00 +09:00
parent 62c6a79f89
commit 50081d5417
10 changed files with 770 additions and 132 deletions
+206
View File
@@ -0,0 +1,206 @@
// Client side of the display boundary, protocol v0 (contracts docs/BOUNDARY.md).
//
// This side always connects and owns reconnection. The link drops routinely —
// ~3 s at every power change, and an asleep peer looks connected — so silence is
// treated as a dead link, and commands are retried under the same id until acked.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
enum LinkState { connecting, up, down }
class PendingCommand {
PendingCommand(this.id, this.cmd, this.args) : queuedAt = DateTime.now();
final String id;
final String cmd;
final Map<String, dynamic> args;
final DateTime queuedAt;
}
class Ack {
Ack(this.id, this.ok, this.detail, this.at);
final String id;
final bool ok;
final String detail;
final DateTime at;
}
class Boundary extends ChangeNotifier {
Boundary(this.url, {this.connectTimeout = const Duration(seconds: 3)});
String url;
final Duration connectTimeout;
LinkState state = LinkState.down;
String detail = 'not started';
Map<String, dynamic>? hello;
Map<String, dynamic> signals = {};
DateTime? lastState;
DateTime? lastMessage;
/// Orin clock minus tablet clock, from heartbeats. Large values mean one side is unsynced.
Duration? clockOffset;
final List<String> recent = [];
final List<PendingCommand> pending = [];
final List<Ack> acks = [];
WebSocket? _ws;
Timer? _hb;
Timer? _watchdog;
bool _running = false;
int _attempt = 0;
final _rng = Random();
/// True when any message seen on this connection said mock.
bool get mock => hello?['mock'] == true || signals.values.any((e) => e is Map && e['mock'] == true);
Duration get _hbInterval => Duration(milliseconds: (hello?['hb_ms'] as int?) ?? 1000);
Duration get _timeout => Duration(milliseconds: (hello?['timeout_ms'] as int?) ?? 5000);
void start() {
if (_running) return;
_running = true;
_loop();
}
Future<void> stop() async {
_running = false;
await _ws?.close();
}
/// Change the target; the current connection is dropped and the loop reconnects.
void retarget(String newUrl) {
url = newUrl;
_ws?.close();
}
/// Queue a command; it is sent now if connected and resent after every reconnect until acked.
String command(String cmd, [Map<String, dynamic> args = const {}]) {
final id = '${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_rng.nextInt(1 << 32).toRadixString(36)}';
final c = PendingCommand(id, cmd, args);
pending.add(c);
_send(c);
notifyListeners();
return id;
}
Future<void> _loop() async {
while (_running) {
_set(LinkState.connecting, 'connecting to $url');
try {
final ws = await WebSocket.connect(url).timeout(connectTimeout);
_ws = ws;
_attempt = 0;
lastMessage = DateTime.now();
_armWatchdog();
await for (final raw in ws) {
_onMessage(raw as String);
}
_set(LinkState.down, 'closed${ws.closeReason != null && ws.closeReason!.isNotEmpty ? ': ${ws.closeReason}' : ''}');
} catch (e) {
_set(LinkState.down, '$e'.split('\n').first);
} finally {
_hb?.cancel();
_watchdog?.cancel();
_ws = null;
hello = null;
}
if (!_running) break;
// 0.5 s, 1 s, 2 s, then every 3 s: a replugged hub is back in about 3 s.
final wait = Duration(milliseconds: min(3000, 500 * (1 << min(_attempt, 3))));
_attempt++;
await Future.delayed(wait);
}
}
void _onMessage(String raw) {
lastMessage = DateTime.now();
recent.insert(0, raw.length > 400 ? '${raw.substring(0, 400)}…' : raw);
if (recent.length > 30) recent.removeLast();
final Map<String, dynamic> m;
try {
m = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return;
}
switch (m['t']) {
case 'hello':
hello = m;
_set(LinkState.up, '${m['server']} on ${m['host']}');
_hb?.cancel();
_hb = Timer.periodic(_hbInterval, (_) => _add('{"t":"hb"}'));
_armWatchdog();
for (final c in pending) {
_send(c);
}
case 'hb':
final ts = m['ts'];
if (ts is int) {
clockOffset = DateTime.fromMillisecondsSinceEpoch(ts).difference(DateTime.now());
}
case 'state':
signals = Map<String, dynamic>.from(m['signals'] as Map);
lastState = DateTime.now();
case 'ack':
final id = m['id'] as String?;
pending.removeWhere((c) => c.id == id);
acks.insert(0, Ack(id ?? '?', m['ok'] == true, '${m['detail'] ?? ''}', DateTime.now()));
if (acks.length > 10) acks.removeLast();
}
notifyListeners();
}
/// Silence for timeout_ms means the link is dead, whatever the socket says.
void _armWatchdog() {
_watchdog?.cancel();
_watchdog = Timer.periodic(const Duration(milliseconds: 500), (_) {
final last = lastMessage;
if (last != null && DateTime.now().difference(last) > _timeout) {
detail = 'no message for ${_timeout.inMilliseconds} ms';
_hb?.cancel();
_ws?.close();
}
notifyListeners();
});
}
void _send(PendingCommand c) {
if (_ws == null || hello == null) return;
final args = Map<String, dynamic>.from(c.args);
if (c.cmd == 'mark') {
args['delay_ms'] = DateTime.now().difference(c.queuedAt).inMilliseconds;
}
_add(jsonEncode({'t': 'cmd', 'id': c.id, 'cmd': c.cmd, 'args': args}));
}
/// A send on a link that just died is a no-op: the reconnect resends what matters.
void _add(String msg) {
final ws = _ws;
if (ws == null || ws.closeCode != null) return;
try {
ws.add(msg);
} on StateError {
// closed between the check and the add
}
}
void _set(LinkState s, String d) {
state = s;
detail = d;
if (s != LinkState.up) signals = {};
notifyListeners();
}
@override
void dispose() {
_running = false;
_hb?.cancel();
_watchdog?.cancel();
_ws?.close();
super.dispose();
}
}