// Camera live view, for testing: shows the Orin camera's MJPEG frames as they // arrive, with exposure and gain controls. Pairs with tool/camview_server.py. // 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'; 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 = []; String lastReply = ''; final _window = <(int, int)>[]; // (arrival µs, bytes) over the last second final _clock = Stopwatch()..start(); void start() { if (_running) return; _running = true; _loop(); } Future stop() async { _running = false; _s?.destroy(); } void send(String line) => _s?.write('$line\n'); Future _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; status = 'connected'; notifyListeners(); await _read(s); status = 'closed'; } catch (e) { status = '$e'.split('\n').first; } _s = null; notifyListeners(); if (_running) await Future.delayed(const Duration(seconds: 1)); } } Future _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) { lastReply = utf8.decode(bytes); } else { _onFrame(Uint8List.fromList(bytes), sentAt); } } data = Uint8List.sublistView(data, off); } } 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(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 createState() => _CamViewPageState(); } class _CamViewPageState extends State { 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: Colors.black, appBar: AppBar(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.status, style: const TextStyle(color: Colors.white70), ), ) : 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), ), ], ), ), ], ); }, ), ), ); } }