App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
The shell (shell.dart): a drawer from the left edge or each page's menu button. Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright, build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals, signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its own navigator, so a run survives switching views. Back goes inside Captures, then to the landing view, then out. The mock banner sits over every page. main.dart is startup only; the old home screen is pages/signals.dart; dialogs and sheets moved to actions/; the protocol client, USB binding and Reading to link/. lib/README.md catalogues it. 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,280 @@
|
||||
// Camera live view: shows the Orin camera's frames as they arrive, with exposure
|
||||
// and gain controls. The server is svc-vision (gitea:yukart/svc-vision,
|
||||
// src/liveview.py), which also defines the protocol. Not the boundary: its own
|
||||
// connection (project-yukart decision 27, proposed).
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../shell_scope.dart';
|
||||
import '../theme/palette.dart';
|
||||
|
||||
const camviewPort = 7710;
|
||||
|
||||
class CamView extends ChangeNotifier {
|
||||
CamView(this.host);
|
||||
final String host;
|
||||
|
||||
Socket? _s;
|
||||
bool _running = false;
|
||||
String status = 'not started';
|
||||
Uint8List? frame;
|
||||
int frames = 0;
|
||||
double fps = 0, kbPerFrame = 0, mbPerS = 0;
|
||||
|
||||
/// Delay beyond the fastest of the last ~300 frames: queueing, independent of the
|
||||
/// tens of ms by which the Orin's and the tablet's clocks disagree.
|
||||
double lagMs = 0;
|
||||
final _delays = <double>[];
|
||||
String lastReply = '';
|
||||
|
||||
/// Whether the Orin has the camera: null until it says, then its last notice.
|
||||
bool? available;
|
||||
|
||||
final _window = <(int, int)>[]; // (arrival µs, bytes) over the last second
|
||||
final _clock = Stopwatch()..start();
|
||||
|
||||
void start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_loop();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_running = false;
|
||||
_s?.destroy();
|
||||
}
|
||||
|
||||
void send(String line) => _s?.write('$line\n');
|
||||
|
||||
Future<void> _loop() async {
|
||||
while (_running) {
|
||||
status = 'connecting to $host:$camviewPort';
|
||||
notifyListeners();
|
||||
try {
|
||||
final s = await Socket.connect(
|
||||
host,
|
||||
camviewPort,
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
_s = s;
|
||||
available = null;
|
||||
status = 'connected';
|
||||
notifyListeners();
|
||||
await _read(s);
|
||||
status = 'closed';
|
||||
} catch (e) {
|
||||
status = '$e'.split('\n').first;
|
||||
}
|
||||
_s = null;
|
||||
frame = null; // a frame from a closed connection is not live
|
||||
notifyListeners();
|
||||
if (_running) await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _read(Socket s) async {
|
||||
var data = Uint8List(0); // unconsumed bytes
|
||||
await for (final chunk in s) {
|
||||
if (data.isEmpty) {
|
||||
data = chunk;
|
||||
} else {
|
||||
data =
|
||||
(BytesBuilder(copy: false)
|
||||
..add(data)
|
||||
..add(chunk))
|
||||
.takeBytes();
|
||||
}
|
||||
var off = 0;
|
||||
while (data.length - off >= 16) {
|
||||
final h = ByteData.sublistView(data, off, off + 16);
|
||||
final len = h.getUint32(0);
|
||||
final sentAt = h.getFloat64(4);
|
||||
final body = len == 0
|
||||
? h.getUint32(12)
|
||||
: len; // length 0: a text reply, its length in the third field
|
||||
if (data.length - off - 16 < body) break;
|
||||
final bytes = Uint8List.sublistView(data, off + 16, off + 16 + body);
|
||||
off += 16 + body;
|
||||
if (len == 0) {
|
||||
_onText(utf8.decode(bytes));
|
||||
} else {
|
||||
_onFrame(Uint8List.fromList(bytes), sentAt);
|
||||
}
|
||||
}
|
||||
data = Uint8List.sublistView(data, off);
|
||||
}
|
||||
}
|
||||
|
||||
void _onText(String text) {
|
||||
if (text == 'camera unavailable' || text == 'camera ok') {
|
||||
available = text == 'camera ok';
|
||||
if (available == false) frame = null; // never show a stale frame as live
|
||||
} else {
|
||||
lastReply = text;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onFrame(Uint8List jpeg, double sentAt) {
|
||||
final now = _clock.elapsedMicroseconds;
|
||||
_window.add((now, jpeg.length));
|
||||
_window.removeWhere((e) => now - e.$1 > 1000000);
|
||||
frames++;
|
||||
fps = _window.length.toDouble();
|
||||
final bytes = _window.fold<int>(0, (a, e) => a + e.$2);
|
||||
mbPerS = bytes / 1e6;
|
||||
kbPerFrame = jpeg.length / 1024;
|
||||
final delay = DateTime.now().microsecondsSinceEpoch / 1000 - sentAt * 1000;
|
||||
_delays.add(delay);
|
||||
if (_delays.length > 300) _delays.removeAt(0);
|
||||
lagMs = delay - _delays.reduce((a, b) => a < b ? a : b);
|
||||
frame = jpeg;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_running = false;
|
||||
_s?.destroy();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class CamViewPage extends StatefulWidget {
|
||||
const CamViewPage({super.key, required this.host});
|
||||
final String host;
|
||||
|
||||
@override
|
||||
State<CamViewPage> createState() => _CamViewPageState();
|
||||
}
|
||||
|
||||
class _CamViewPageState extends State<CamViewPage> {
|
||||
late final cam = CamView(widget.host)..start();
|
||||
bool auto = true;
|
||||
double exposure = 100; // 100 µs units: 10 ms
|
||||
double gain = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cam.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.of(context).background,
|
||||
appBar: AppBar(leading: const MenuButton(), title: const Text('Camera (test)')),
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: cam,
|
||||
builder: (context, _) {
|
||||
final f = cam.frame;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: f == null
|
||||
? Center(
|
||||
child: Text(
|
||||
cam.available == false
|
||||
? 'Camera unavailable on the Orin'
|
||||
: cam.status,
|
||||
style: TextStyle(color: AppTheme.of(context).subdued),
|
||||
),
|
||||
)
|
||||
: Image.memory(
|
||||
f,
|
||||
gaplessPlayback: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(cam.status),
|
||||
Text('${cam.fps.toStringAsFixed(0)} fps'),
|
||||
Text('${cam.kbPerFrame.toStringAsFixed(0)} KB/frame'),
|
||||
Text('${cam.mbPerS.toStringAsFixed(2)} MB/s'),
|
||||
Text('lag +${cam.lagMs.toStringAsFixed(0)} ms'),
|
||||
FilterChip(
|
||||
label: const Text('Auto exposure'),
|
||||
selected: auto,
|
||||
onSelected: (v) {
|
||||
setState(() => auto = v);
|
||||
cam.send(
|
||||
v
|
||||
? 'exposure auto'
|
||||
: 'exposure ${exposure.round()}',
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'exp ${(exposure / 10).toStringAsFixed(1)} ms',
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 1,
|
||||
max: 600,
|
||||
value: exposure,
|
||||
onChanged: auto
|
||||
? null
|
||||
: (v) => setState(() => exposure = v),
|
||||
onChangeEnd: (v) =>
|
||||
cam.send('exposure ${v.round()}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Row(
|
||||
children: [
|
||||
Text('gain ${gain.round()}'),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 240,
|
||||
value: gain,
|
||||
onChanged: (v) => setState(() => gain = v),
|
||||
onChangeEnd: (v) =>
|
||||
cam.send('gain ${v.round()}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (cam.lastReply.isNotEmpty)
|
||||
Text(
|
||||
cam.lastReply,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user