First commit
This commit is contained in:
227
lib/session/decimator.dart
Normal file
227
lib/session/decimator.dart
Normal 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);
|
||||
}
|
||||
52
lib/session/log_buffer.dart
Normal file
52
lib/session/log_buffer.dart
Normal 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]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
lib/session/packet_buffer.dart
Normal file
117
lib/session/packet_buffer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/session/pps_counter.dart
Normal file
34
lib/session/pps_counter.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
lib/session/session_controller.dart
Normal file
175
lib/session/session_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/session/status_snapshot.dart
Normal file
32
lib/session/status_snapshot.dart
Normal 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
130
lib/session/view_state.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user