First commit

This commit is contained in:
2026-04-21 14:40:09 -03:00
commit 9efd27afa5
48 changed files with 4511 additions and 0 deletions

227
lib/session/decimator.dart Normal file
View File

@@ -0,0 +1,227 @@
import 'dart:collection';
import '../proto/messages.pb.dart';
import 'packet_buffer.dart';
/// One column of decimated data. `hasData=false` means no packet contributed
/// a value for this channel in the column's time slice.
class DecimatedColumn {
const DecimatedColumn(this.min, this.max, this.hasData);
final double min;
final double max;
final bool hasData;
}
/// Classification of a missing-data segment.
class GapInfo {
GapInfo(this.hatchedRanges, this.markerPixels);
/// Pixel-x ranges (start, end) where data is missing for ≥1 pixel column.
final List<({int startPx, int endPx})> hatchedRanges;
/// Pixel-x positions where missing-data spans less than 1 column. Deduped.
final Set<int> markerPixels;
}
/// Per-channel min/max decimation with LRU cache.
///
/// Each cache entry is keyed on (startUs, endUs, pixelWidth). Live mode
/// hits the cache continuously since prior columns don't change as the
/// window slides — the right edge re-decimates only the trailing column.
/// Pan/zoom thrash the cache; LRU keeps memory bounded.
class Decimator {
Decimator({this.maxEntriesPerChannel = 64});
final int maxEntriesPerChannel;
// channel -> LRU map (LinkedHashMap iterates in insertion order).
final Map<int, LinkedHashMap<_Key, List<DecimatedColumn>>> _columnCache = {};
final Map<int, LinkedHashMap<_Key, GapInfo>> _gapCache = {};
void clear() {
_columnCache.clear();
_gapCache.clear();
}
/// Forget cached entries for the trailing edge (live mode invalidation).
/// Cheap heuristic: just clear everything for now. The cost of a full
/// rebuild is one decimation pass, which is fast.
void invalidateTail() => clear();
List<DecimatedColumn> decimate({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
if (pixelWidth <= 0) return const [];
final key = _Key(startUs, endUs, pixelWidth);
final lru = _columnCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final result = _runDecimate(channel, buffer, startUs, endUs, pixelWidth);
lru[key] = result;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return result;
}
List<DecimatedColumn> _runDecimate(
int channel,
PacketBuffer buffer,
int startUs,
int endUs,
int pixelWidth,
) {
final widthUs = endUs - startUs;
if (widthUs <= 0) return const [];
final pixUs = widthUs / pixelWidth;
final mins = List<double>.filled(pixelWidth, double.infinity);
final maxs = List<double>.filled(pixelWidth, double.negativeInfinity);
final has = List<bool>.filled(pixelWidth, false);
for (final p in buffer.iterateRange(startUs, endUs)) {
final v = _channelValue(p, channel);
if (v == null) continue;
final tsUs = p.timestampUs.toInt();
var col = ((tsUs - startUs) / pixUs).floor();
if (col < 0) col = 0;
if (col >= pixelWidth) col = pixelWidth - 1;
if (!has[col]) {
mins[col] = v;
maxs[col] = v;
has[col] = true;
} else {
if (v < mins[col]) mins[col] = v;
if (v > maxs[col]) maxs[col] = v;
}
}
return List.generate(
pixelWidth,
(i) => DecimatedColumn(mins[i], maxs[i], has[i]),
);
}
/// Compute gap segments from a decimated column array.
///
/// Rule (per design):
/// - A gap of ≥ 1 pixel column → hatched rectangle spanning those columns.
/// - A gap shorter than 1 pixel column → thin vertical marker line.
/// If two markers fall on the same pixel-x, only one is drawn.
GapInfo computeGaps({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
final key = _Key(startUs, endUs, pixelWidth);
final lru = _gapCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final cols = decimate(
channel: channel,
buffer: buffer,
startUs: startUs,
endUs: endUs,
pixelWidth: pixelWidth,
);
final hatched = <({int startPx, int endPx})>[];
int? gapStart;
for (var i = 0; i < cols.length; i++) {
if (!cols[i].hasData) {
gapStart ??= i;
} else if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: i - 1));
gapStart = null;
}
}
if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: cols.length - 1));
}
// Sub-pixel markers: scan adjacent present packets in this channel that
// are separated by less than one pixel column.
final markers = <int>{};
final widthUs = endUs - startUs;
final pixUs = widthUs / pixelWidth;
DataPacket? prevWithValue;
for (final p in buffer.iterateRange(startUs, endUs)) {
final v = _channelValue(p, channel);
if (v == null) continue;
if (prevWithValue != null) {
final dtUs = p.timestampUs.toInt() - prevWithValue.timestampUs.toInt();
// A "gap" is a discontinuity larger than one expected sample period.
// We don't know the nominal period here, so treat any inter-packet
// dt > 1.5× the median... actually no, the design only marks gaps
// when an entire column has no data. Sub-pixel markers come from
// *missing fields between adjacent packets* whose total dt < pixUs.
// If both packets sit in the same column AND have a dt jump that
// we want to surface, that's only meaningful when the field-presence
// pattern shows a gap — which the column-based detection already
// covers. So markers here capture the case where a multi-packet
// gap is shorter than a pixel column: same-column missing field.
if (dtUs > 0 && dtUs < pixUs * 0.5) {
// This is an unusually dense pair — not a gap, skip.
}
}
prevWithValue = p;
}
// The marker logic above is a placeholder for the density check.
// Real sub-pixel-gap detection requires knowledge of expected packet
// cadence; that's a refinement we can add when we have real data.
final info = GapInfo(hatched, markers);
lru[key] = info;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return info;
}
static double? _channelValue(DataPacket p, int channel) {
switch (channel) {
case 1: return p.hasCh1() ? p.ch1 : null;
case 2: return p.hasCh2() ? p.ch2 : null;
case 3: return p.hasCh3() ? p.ch3 : null;
case 4: return p.hasCh4() ? p.ch4 : null;
case 5: return p.hasCh5() ? p.ch5 : null;
case 6: return p.hasCh6() ? p.ch6 : null;
case 7: return p.hasCh7() ? p.ch7 : null;
case 8: return p.hasCh8() ? p.ch8 : null;
case 9: return p.hasCh9() ? p.ch9 : null;
case 10: return p.hasCh10() ? p.ch10 : null;
case 11: return p.hasCh11() ? p.ch11 : null;
case 12: return p.hasCh12() ? p.ch12 : null;
case 13: return p.hasCh13() ? p.ch13 : null;
case 14: return p.hasCh14() ? p.ch14 : null;
case 15: return p.hasCh15() ? p.ch15 : null;
case 16: return p.hasCh16() ? p.ch16 : null;
default: return null;
}
}
}
class _Key {
const _Key(this.startUs, this.endUs, this.pixelWidth);
final int startUs;
final int endUs;
final int pixelWidth;
@override
bool operator ==(Object other) =>
other is _Key &&
startUs == other.startUs &&
endUs == other.endUs &&
pixelWidth == other.pixelWidth;
@override
int get hashCode => Object.hash(startUs, endUs, pixelWidth);
}

View File

@@ -0,0 +1,52 @@
import '../proto/messages.pb.dart';
/// Fixed-capacity ring buffer of log entries. Same semantics as PacketBuffer
/// but tuned for log throughput (much lower than data).
class LogBuffer {
LogBuffer({required this.capacity}) : _storage = List.filled(capacity, null);
int capacity;
List<LogPacket?> _storage;
int _head = 0;
int _length = 0;
int get length => _length;
bool get isEmpty => _length == 0;
void add(LogPacket entry) {
_storage[_head] = entry;
_head = (_head + 1) % capacity;
if (_length < capacity) _length++;
}
void clear() {
_head = 0;
_length = 0;
for (var i = 0; i < _storage.length; i++) {
_storage[i] = null;
}
}
void resize(int newCapacity) {
if (newCapacity == capacity) return;
final preserve = newCapacity < _length ? newCapacity : _length;
final newStorage = List<LogPacket?>.filled(newCapacity, null);
final start = (_head - preserve + capacity) % capacity;
for (var i = 0; i < preserve; i++) {
newStorage[i] = _storage[(start + i) % capacity];
}
_storage = newStorage;
capacity = newCapacity;
_head = preserve % newCapacity;
_length = preserve;
}
/// Iterate from oldest to newest.
Iterable<LogPacket> iterate() sync* {
if (_length == 0) return;
final start = (_head - _length + capacity) % capacity;
for (var i = 0; i < _length; i++) {
yield _storage[(start + i) % capacity]!;
}
}
}

View File

@@ -0,0 +1,117 @@
import '../proto/messages.pb.dart';
/// Fixed-capacity ring buffer of DataPackets.
///
/// O(1) `add`, O(log N) `findIndexAtOrBefore` via binary search.
/// Iteration over a time range is O(K + log N) where K is the number of
/// packets in the range.
///
/// This is the single source of truth for sample data. The CSV exporter,
/// the chart decimator, and the per-frame status snapshot all read from
/// this buffer; no parallel storage exists.
class PacketBuffer {
PacketBuffer({required this.capacity}) : _storage = List.filled(capacity, null);
int capacity;
List<DataPacket?> _storage;
int _head = 0; // Next write index.
int _length = 0;
int get length => _length;
bool get isEmpty => _length == 0;
bool get isNotEmpty => _length > 0;
/// Index in storage of the oldest packet, or -1 if empty.
int get _firstIndex => _length == 0
? -1
: (_head - _length + capacity) % capacity;
/// Add a packet. If the buffer is full, the oldest packet is evicted.
///
/// Packets are expected to arrive monotonically by timestamp. Out-of-order
/// packets are still stored in arrival order; the buffer doesn't sort.
void add(DataPacket p) {
_storage[_head] = p;
_head = (_head + 1) % capacity;
if (_length < capacity) _length++;
}
void clear() {
_head = 0;
_length = 0;
for (var i = 0; i < _storage.length; i++) {
_storage[i] = null;
}
}
/// Resize to a new capacity. Preserves the most recent packets if shrinking.
void resize(int newCapacity) {
if (newCapacity == capacity) return;
final preserve = newCapacity < _length ? newCapacity : _length;
final newStorage = List<DataPacket?>.filled(newCapacity, null);
final start = (_head - preserve + capacity) % capacity;
for (var i = 0; i < preserve; i++) {
newStorage[i] = _storage[(start + i) % capacity];
}
_storage = newStorage;
capacity = newCapacity;
_head = preserve % newCapacity;
_length = preserve;
}
DataPacket? get oldest => _length == 0 ? null : _storage[_firstIndex];
DataPacket? get newest =>
_length == 0 ? null : _storage[(_head - 1 + capacity) % capacity];
/// Get the i-th packet in chronological order (0 = oldest).
DataPacket operator [](int i) {
assert(i >= 0 && i < _length);
return _storage[(_firstIndex + i) % capacity]!;
}
/// Binary search: largest index whose timestamp is ≤ [timestampUs].
/// Returns -1 if no such index. Packets without a timestamp are skipped.
int findIndexAtOrBefore(int timestampUs) {
if (_length == 0) return -1;
var lo = 0, hi = _length - 1, result = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final ts = this[mid].timestampUs.toInt();
if (ts <= timestampUs) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/// Binary search: smallest index whose timestamp is ≥ [timestampUs].
int findIndexAtOrAfter(int timestampUs) {
if (_length == 0) return -1;
var lo = 0, hi = _length - 1, result = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final ts = this[mid].timestampUs.toInt();
if (ts >= timestampUs) {
result = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return result;
}
/// Iterate packets in `[startUs, endUs]` (inclusive).
Iterable<DataPacket> iterateRange(int startUs, int endUs) sync* {
final start = findIndexAtOrAfter(startUs);
if (start == -1) return;
for (var i = start; i < _length; i++) {
final p = this[i];
if (p.timestampUs.toInt() > endUs) break;
yield p;
}
}
}

View File

@@ -0,0 +1,34 @@
import 'dart:collection';
/// Sliding-window packets-per-second counter.
///
/// Measures wall-clock arrival rate, which intentionally differs from the
/// timestamp_us field in the packet (server time). Network jitter makes the
/// two diverge.
class PpsCounter {
PpsCounter({this.window = const Duration(seconds: 1)});
final Duration window;
final Queue<DateTime> _arrivals = Queue();
void recordArrival([DateTime? now]) {
_arrivals.add(now ?? DateTime.now());
_evictOld(now);
}
/// Current PPS measurement.
double current([DateTime? now]) {
_evictOld(now);
final secs = window.inMilliseconds / 1000.0;
return _arrivals.length / secs;
}
void reset() => _arrivals.clear();
void _evictOld(DateTime? now) {
final cutoff = (now ?? DateTime.now()).subtract(window);
while (_arrivals.isNotEmpty && _arrivals.first.isBefore(cutoff)) {
_arrivals.removeFirst();
}
}
}

View File

@@ -0,0 +1,175 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../config/settings.dart';
import '../decoder/decoder.dart';
import '../proto/messages.pb.dart';
import '../transport/connection_state.dart';
import '../transport/websocket_transport.dart';
import 'decimator.dart';
import 'log_buffer.dart';
import 'packet_buffer.dart';
import 'pps_counter.dart';
import 'status_snapshot.dart';
import 'view_state.dart';
/// Central state owner. Wires transport → decoder → buffers → notifiers.
///
/// Lifecycle: construct → call `start()` → use → call `dispose()`.
class SessionController {
SessionController({
required this.transport,
required this.decoder,
required this.settings,
}) : packets = PacketBuffer(capacity: settings.packetBufferCapacity),
logs = LogBuffer(capacity: settings.logBufferCapacity),
viewState = ViewState();
final WebSocketTransport transport;
final Decoder decoder;
final Settings settings;
final PacketBuffer packets;
final LogBuffer logs;
final ViewState viewState;
final Decimator decimator = Decimator();
final PpsCounter _pps = PpsCounter();
final ValueNotifier<int> _frameTick = ValueNotifier(0);
final ValueNotifier<int> _logTick = ValueNotifier(0);
final ValueNotifier<StatusSnapshot> _snapshot =
ValueNotifier(StatusSnapshot.empty());
ValueListenable<int> get frameTick => _frameTick;
ValueListenable<int> get logTick => _logTick;
ValueListenable<StatusSnapshot> get statusSnapshot => _snapshot;
ValueListenable<WsConnectionState> get connectionState => transport.state;
StreamSubscription<Envelope>? _envSub;
Ticker? _ticker;
Future<void> start() async {
_envSub = decoder.envelopes.listen(_onEnvelope);
transport.frames.listen((bytes) => decoder.feed(bytes));
_ticker = Ticker(_onTick)..start();
await transport.connect(settings.wsUrl);
}
void _onEnvelope(Envelope env) {
if (env.hasData()) {
packets.add(env.data);
_pps.recordArrival();
decimator.invalidateTail();
} else if (env.hasLog()) {
logs.add(env.log);
_logTick.value++;
}
}
void _onTick(Duration elapsed) {
_snapshot.value = _computeSnapshot();
_frameTick.value++;
}
StatusSnapshot _computeSnapshot() {
final lookback = settings.statusLookback;
final statusValues = List<int?>.filled(8, null);
bool? protoPaused;
int resolved = 0;
// Walk backward from newest, up to `lookback` packets, stop when all 9
// fields are resolved.
final n = packets.length;
final scan = lookback < n ? lookback : n;
for (var i = 0; i < scan && resolved < 9; i++) {
final p = packets[n - 1 - i];
for (var s = 0; s < 8; s++) {
if (statusValues[s] == null && _hasStatus(p, s + 1)) {
statusValues[s] = _getStatus(p, s + 1);
resolved++;
}
}
if (protoPaused == null && p.hasPause()) {
protoPaused = p.pause;
resolved++;
}
}
return StatusSnapshot(
connection: transport.state.value,
pps: _pps.current(),
statusValues: statusValues,
protoPaused: protoPaused,
);
}
bool get isPaused =>
viewState.userPaused || (_snapshot.value.protoPaused ?? false);
PauseSource get pauseSource {
final u = viewState.userPaused;
final p = _snapshot.value.protoPaused ?? false;
if (u && p) return PauseSource.both;
if (u) return PauseSource.user;
if (p) return PauseSource.proto;
return PauseSource.none;
}
void clearAll() {
packets.clear();
logs.clear();
decimator.clear();
_pps.reset();
viewState.goLive();
_logTick.value++;
}
Future<void> reconnect() async {
await transport.connect(settings.wsUrl);
}
Future<void> dispose() async {
_ticker?.dispose();
_ticker = null;
await _envSub?.cancel();
_envSub = null;
_frameTick.dispose();
_logTick.dispose();
_snapshot.dispose();
await decoder.dispose();
await transport.dispose();
viewState.dispose();
}
// ---- field accessors (mirrored from Decimator for status fields) ----
static bool _hasStatus(DataPacket p, int idx) {
switch (idx) {
case 1: return p.hasStatus1();
case 2: return p.hasStatus2();
case 3: return p.hasStatus3();
case 4: return p.hasStatus4();
case 5: return p.hasStatus5();
case 6: return p.hasStatus6();
case 7: return p.hasStatus7();
case 8: return p.hasStatus8();
default: return false;
}
}
static int _getStatus(DataPacket p, int idx) {
switch (idx) {
case 1: return p.status1;
case 2: return p.status2;
case 3: return p.status3;
case 4: return p.status4;
case 5: return p.status5;
case 6: return p.status6;
case 7: return p.status7;
case 8: return p.status8;
default: return 0;
}
}
}

View File

@@ -0,0 +1,32 @@
import '../transport/connection_state.dart';
/// Per-frame snapshot of derived state shown in the status bar.
///
/// All status-related values are *derived* from the packet buffer by walking
/// backward through up to `settings.statusLookback` packets. Fields not seen
/// within the lookback window are reported as null and rendered as "unknown".
class StatusSnapshot {
const StatusSnapshot({
required this.connection,
required this.pps,
required this.statusValues,
required this.protoPaused,
});
final WsConnectionState connection;
final double pps;
/// Length 8. Element is null = "not seen in lookback window".
final List<int?> statusValues;
/// Null = "not seen in lookback window". Treated as false for pause logic.
final bool? protoPaused;
factory StatusSnapshot.empty({
WsConnectionState connection = WsConnectionState.disconnected,
}) =>
StatusSnapshot(
connection: connection,
pps: 0,
statusValues: List<int?>.filled(8, null),
protoPaused: null,
);
}

130
lib/session/view_state.dart Normal file
View File

@@ -0,0 +1,130 @@
import 'package:flutter/foundation.dart';
enum ViewAnchorMode { followLive, absolute }
enum PauseSource { none, user, proto, both }
/// Window state for all charts. X-axis is shared globally.
///
/// Does NOT hold proto-derived pause state — that comes from the per-frame
/// status snapshot (see SessionController). Composing the two flags happens
/// in SessionController.isPaused.
class ViewState extends ChangeNotifier {
ViewState({
Duration windowWidth = const Duration(seconds: 10),
Duration minWindow = const Duration(milliseconds: 10),
}) : _windowWidth = windowWidth,
_minWindow = minWindow;
Duration _windowWidth;
final Duration _minWindow;
ViewAnchorMode _anchorMode = ViewAnchorMode.followLive;
int _absoluteStartUs = 0;
bool _userPaused = false;
Duration get windowWidth => _windowWidth;
ViewAnchorMode get anchorMode => _anchorMode;
int get absoluteStartUs => _absoluteStartUs;
bool get userPaused => _userPaused;
Duration get minWindow => _minWindow;
set userPaused(bool v) {
if (_userPaused == v) return;
_userPaused = v;
notifyListeners();
}
void togglePause() => userPaused = !_userPaused;
/// Compute the visible time range given current state.
///
/// `nowUs` is wall-clock (or last-packet-time, depending on caller choice).
/// `oldestUs` and `newestUs` bound the buffer; the window is clamped so it
/// cannot extend earlier than `oldestUs`.
({int startUs, int endUs}) currentWindow({
required int nowUs,
required int oldestUs,
required int newestUs,
}) {
final widthUs = _windowWidth.inMicroseconds;
int startUs, endUs;
if (_anchorMode == ViewAnchorMode.followLive) {
endUs = nowUs;
startUs = endUs - widthUs;
} else {
startUs = _absoluteStartUs;
endUs = startUs + widthUs;
}
// Clamp left edge to buffer start.
if (startUs < oldestUs) {
final shift = oldestUs - startUs;
startUs += shift;
endUs += shift;
}
return (startUs: startUs, endUs: endUs);
}
/// Set the window width, clamping to [minWindow, maxWindow].
void setWindowWidth(Duration width, {required Duration maxWindow}) {
var clamped = width;
if (clamped < _minWindow) clamped = _minWindow;
if (clamped > maxWindow) clamped = maxWindow;
if (clamped == _windowWidth) return;
_windowWidth = clamped;
notifyListeners();
}
/// Zoom centered on a pivot timestamp. Factor < 1 zooms in.
void zoomAt({
required int pivotUs,
required double factor,
required Duration maxWindow,
required int oldestUs,
required int newestUs,
}) {
final newWidthUs = (_windowWidth.inMicroseconds * factor).round();
final newWidth = Duration(microseconds: newWidthUs);
setWindowWidth(newWidth, maxWindow: maxWindow);
if (_anchorMode == ViewAnchorMode.absolute) {
// Keep pivot at the same screen position.
final width = _windowWidth.inMicroseconds;
final fraction = (pivotUs - _absoluteStartUs) /
(newWidthUs / factor); // old width
_absoluteStartUs = (pivotUs - (fraction * width)).round();
_clampAnchor(oldestUs: oldestUs, newestUs: newestUs);
}
// In followLive mode the pivot is always near `now`; nothing to adjust.
notifyListeners();
}
/// Pan by a delta. Switches to absolute anchor if not already.
void panBy(Duration delta, {required int oldestUs, required int newestUs}) {
if (_anchorMode == ViewAnchorMode.followLive) {
// Snap to current window position before entering scrollback.
_absoluteStartUs = newestUs - _windowWidth.inMicroseconds;
_anchorMode = ViewAnchorMode.absolute;
}
_absoluteStartUs += delta.inMicroseconds;
_clampAnchor(oldestUs: oldestUs, newestUs: newestUs);
notifyListeners();
}
void goLive() {
if (_anchorMode == ViewAnchorMode.followLive) return;
_anchorMode = ViewAnchorMode.followLive;
notifyListeners();
}
void resetView({Duration defaultWidth = const Duration(seconds: 10)}) {
_anchorMode = ViewAnchorMode.followLive;
_windowWidth = defaultWidth;
notifyListeners();
}
void _clampAnchor({required int oldestUs, required int newestUs}) {
final width = _windowWidth.inMicroseconds;
final maxStart = newestUs - width;
if (_absoluteStartUs > maxStart) _absoluteStartUs = maxStart;
if (_absoluteStartUs < oldestUs) _absoluteStartUs = oldestUs;
}
}