commit 9efd27afa5980f1d74ba37123b9f0a17dab8e62a Author: Gabriel Lima Date: Tue Apr 21 14:40:09 2026 -0300 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a12c0c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Flutter / Dart +.dart_tool/ +.packages +.pub-cache/ +.pub/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +.metadata + +# IDE +.idea/ +.vscode/ +*.iml + +# Generated protobuf +lib/proto/*.pb.dart +lib/proto/*.pbenum.dart +lib/proto/*.pbjson.dart +lib/proto/*.pbserver.dart + +# Platform build output +android/.gradle/ +android/app/build/ +android/local.properties +linux/build/ +web/build/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..95096f2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,213 @@ +# Architecture + +This document describes the design of the Telemetry Monitor application. +Read it before making non-trivial changes. + +## Goals + +- Smoothly render up to 16 rolling charts at 60 Hz from a 1 kHz packet stream. +- Run identically on Linux, Android, and Web from one codebase. +- Provide CSV export, scrollback, zoom, and pause without coupling these + features to each other. +- Stay maintainable: small modules, clear data flow, single sources of truth. + +## High-level data flow + + WebSocket + │ Uint8List frames + ▼ + TransportLayer ──── ValueListenable + │ Stream + ▼ + DecoderLayer (isolate on native, inline on Web) + │ Stream + ▼ + SessionController + ├── PacketBuffer (ring, ~10 min @ 1 kHz) + ├── LogBuffer (ring, ~5k entries) + ├── ViewState (window, anchor, userPaused) + ├── PpsCounter (sliding 1s arrival times) + ├── Ticker (vsync) ──┬─► frameTick (ValueNotifier) + │ └─► statusSnapshot (recomputed per frame) + └── logTick (ValueNotifier, fires per new log) + + UI layer subscribes to the notifiers above: + - ChartGrid → ChartWidget (CustomPainter, repaint: frameTick) + - StatusBar → statusSnapshot, connectionState + - ErrorLogPanel / FullLogTab → logTick + - Toolbar → ViewState, dialogs + +## Layers + +### Transport (`lib/transport/`) + +Abstraction over `web_socket_channel` so the rest of the app doesn't know +about platform-specific channels. + +- `WebSocketTransport` (abstract): `connect`, `disconnect`, `frames`, `state`. +- `websocket_transport_io.dart`: native, uses `IOWebSocketChannel`. +- `websocket_transport_web.dart`: Web, uses `HtmlWebSocketChannel`. +- Conditional import via `websocket_transport.dart`. + +Owns reconnection with exponential backoff. Reconnect parameters configurable +in Settings. + +### Decoder (`lib/decoder/`) + +Turns raw frames into `Envelope` messages. + +- `Decoder` (abstract): `feed(Uint8List)`, `envelopes` stream. +- `decoder_isolate.dart`: native. Spawns an isolate that decodes and + accumulates envelopes, sending them back as a `List` every + ~8 ms (configurable). Reduces SendPort overhead at 1 kHz. +- `decoder_inline.dart`: Web. Decodes synchronously in `feed()`. +- Conditional import via `decoder.dart`. + +The 8 ms batch interval is chosen to be half a frame at 60 Hz, ensuring no +batch ever crosses a frame boundary unobserved. + +### Session (`lib/session/`) + +Central state owner. + +- `PacketBuffer`: fixed-capacity ring of `DataPacket`. Supports binary search + by timestamp and iteration over a time range. +- `LogBuffer`: fixed-capacity ring of `LogPacket`. +- `ViewState`: window width (Duration), anchor (followLive | absolute(t)), + userPaused. Window-clamping logic lives here. Does *not* hold proto-pause. +- `PpsCounter`: `Queue` of arrival times, popped to a 1-second + window. PPS = queue length. +- `Decimator`: per-channel min/max decimation with LRU cache keyed on + `(viewStart, viewEnd, pixelWidth)`. Also computes gap segments classified + as "hatched" (≥ pixel width) or "marker" (< pixel width). Marker pixel-x + positions are deduplicated. +- `SessionController`: owns the above, subscribes to the decoder stream, + drives the Ticker, exposes notifiers. + +#### Status snapshot computation (per frame) + +`StatusSnapshot { connection, pps, statusValues[8], protoPaused }` is +recomputed each frame by walking backward through the packet buffer up to +`settings.statusLookback` packets, collecting the most recent value for each +of the 8 status fields and the pause flag. Fields not seen within the +lookback window are reported as `null` (rendered as "unknown" in the UI). + +This guarantees single-source-of-truth: the buffer is the only store. The +walk is bounded and stops early once all 9 fields are resolved. + +#### Pause composition + +`isPaused = userPaused || (statusSnapshot.protoPaused ?? false)` + +The status bar attributes the pause source by inspecting both flags. + +#### Clear-all + +`clearAll()` empties both buffers, resets the PPS counter, and forces the +view back to live mode. The status snapshot becomes all-null on the next +frame (since the buffer is empty), which is correct. + +### Layout (`lib/layout/`) + +Display configuration. Persisted to `shared_preferences` under `layout.*`. + +- `ChartConfig`: per-chart settings — channel ID, enabled flag, name, + Y mode (auto / fixed / userZoomed), yMin, yMax. +- `GridConfig`: rows, cols, cell-to-channel mapping. +- `LayoutController` (`ChangeNotifier`): owns the above plus the 8 status + indicator names. Provides `loadFromPrefs` / `saveToPrefs`. + +### Export (`lib/export/`) + +CSV writers behind a platform-split interface. + +- `CsvExporter` (abstract): `exportData`, `exportLog`. +- `csv_exporter_io.dart`: native. Uses `path_provider` for save location. +- `csv_exporter_web.dart`: Web. Builds a Blob and triggers a download via + an anchor element. +- Both yield to the event loop between chunks (chunk size ~1000 rows) and + report progress via callback. + +#### Data CSV format + +Columns: `timestamp_us, ch1, ch2, ..., ch16, pause, status1, ..., status8`. +Disabled channels are *omitted from the column set*. Missing values within +included columns appear as blank cells. Pause and status values appear as +integers (or blank if absent in that packet). + +#### Log CSV format + +Columns: `timestamp_us, severity, error_number, description`. The description +field is taken verbatim from the proto. Severity is the enum name. + +### UI (`lib/ui/`) + +- `app.dart`: MaterialApp, providers (via plain `InheritedNotifier`). +- `toolbar.dart`: pause, go-live, reset-view, layout, settings, export-data, + clear-all, zoom readout. +- `tab_scaffold.dart`: tabs (Dashboard, Full log). +- `chart_grid.dart`: builds `ChartWidget` per cell from `LayoutController`. +- `chart_widget.dart`: `RepaintBoundary` + `CustomPaint` (painter listens to + `frameTick`). +- `chart_painter.dart`: per-frame draw — decimation, gaps, line, axes, labels. +- `status_bar.dart`: connection pill, PPS, pause indicator with attribution, + 8 status pills (subscribes to `statusSnapshot`). +- `mini_log_panel.dart`: filtered log view (severity from settings). +- `full_log_tab.dart`: chip-filterable log view + log export button. +- `settings_dialog.dart`, `layout_dialog.dart`: configuration UIs. + +## Persistence keys + +Both stored in `shared_preferences`: + +| Prefix | Owner | Contents | +| ------------ | ------------------- | --------------------------------- | +| `settings.*` | `Settings` | WS URL, buffer caps, decoder/reconnect params, mini-log severity set, status lookback | +| `layout.*` | `LayoutController` | Grid shape, per-chart config, channel names, status names | + +## Frame budget at 1 kHz / 60 Hz + +Per frame (16.6 ms): + +- Decoder isolate has already produced ~16 packets, batched to one SendPort message. +- Main isolate writes those packets to `PacketBuffer` (16 × O(1) writes). +- Ticker fires. +- `statusSnapshot` walks back ≤ 1000 packets × 9 fields, stops early when + all resolved. Typically completes in 1 step. +- 16 charts repaint: + - Each looks up its decimation cache. + - On cache hit: just draws ~400 line segments. + - On cache miss (zoom/pan): re-decimates (one pass over visible packets per channel). +- Status bar reads `statusSnapshot` and renders. + +Total CPU per frame is dominated by chart drawing, which Skia/Impeller handle +well. Cache hit rate is ~100% in live-follow mode (the tail extends, prior +columns are unchanged) and drops only during zoom/pan. + +## Threading model + +- Native: 2 isolates. Main runs Flutter and everything except protobuf + decoding. The decoder isolate parses bytes and emits envelope batches. +- Web: 1 isolate. Decoder runs inline on the main isolate. At 1 kHz with + small protobuf messages this is acceptable; if it becomes a bottleneck, + consider Web Workers via JS interop (significant work). + +## Conditional imports pattern + +Each platform-split module follows: + + // foo.dart (the public import) + export 'foo_io.dart' if (dart.library.html) 'foo_web.dart'; + + // foo_io.dart — native impl + // foo_web.dart — web impl + +This means UI code just `import 'foo.dart'` and gets the right one. + +## Non-goals + +- Multiple simultaneous WebSocket connections. +- Server-side filtering or downsampling. +- Recording to disk continuously (export is on-demand only). +- Per-chart x-axis (X is global). +- Internationalization (English only for now). \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a1a486 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Telemetry Monitor + +A Flutter application for visualizing real-time telemetry streams over WebSocket. +Targets: Linux desktop, Android, and Web browsers (Flutter 3.41+). + +## What it does + +Connects to a WebSocket server, decodes Protocol Buffer packets carrying up to +16 channels of `double` data plus 8 status integers and a pause flag, and renders +them as rolling time-series charts at 60 Hz. Also receives log packets with +severity, error number, timestamp, and description. + +Key features: + +- 4–16 simultaneous charts in a configurable grid. +- 1 kHz packet rate with batched per-frame rendering. +- Min/max decimation for smooth display at any zoom level. +- Hatched regions for missing-data gaps; thin markers for sub-pixel gaps. +- Scrollback through the configured packet buffer (default 10 minutes). +- Shared X-zoom across charts; per-chart Y-zoom. +- Two-tab UI: dashboard with errors-only mini log, plus a full filterable log. +- CSV export for both packet data and log entries. +- Pause from UI button or from a proto field. +- 8 named status indicators with 4-state color coding (gray / red / yellow / green). + +## Quick start + +### Prerequisites + +- Flutter 3.41 or later. +- Protocol Buffers compiler (`protoc`) version 3.x. +- The Dart `protoc_plugin` package (installed automatically via `pubspec.yaml`). + +### Generating Protobuf bindings + +The `messages.proto` file lives in `proto/`. After cloning, run: + + mkdir -p lib/proto + protoc --dart_out=lib/proto/ -Iproto/ proto/messages.proto + +This produces `lib/proto/messages.pb.dart` and friends. The generated files are +gitignored; regenerate after any schema change. + +### Running + + flutter pub get + flutter run -d linux # Linux desktop + flutter run -d # Android + flutter run -d chrome # Web (CanvasKit recommended) + +For Web, prefer the CanvasKit renderer for chart performance: + + flutter run -d chrome --web-renderer canvaskit + +### First-run configuration + +The app starts with a default WebSocket URL of `ws://localhost:9000`. Open +**Settings** to change it. Open **Layout** to assign channels to grid cells and +name them. + +## Architecture overview + +See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full design. Brief summary: + +- **Transport** owns the WebSocket and reconnection. +- **Decoder** turns raw frames into `Envelope` messages (isolate on native, + inline on Web). +- **Session** owns ring buffers for packets and logs, the view-state machine, + and the per-frame Ticker that drives all rendering. +- **Layout** owns the grid configuration, channel assignments, channel names, + and status indicator names. Persisted via `shared_preferences`. +- **Export** writes CSV files (path_provider on native, Blob download on Web). +- **UI** is a `CustomPainter`-based chart grid plus the toolbar, status bar, + tabs, and dialogs. + +## Key invariants + +These are the design rules that keep the app correct. Don't violate them +without re-reading `ARCHITECTURE.md`. + +1. **Single source of truth.** Sample data lives only in the packet ring + buffer. Log entries live only in the log ring buffer. Status snapshots + are *derived* from the packet buffer each frame, not latched. + +2. **Producer/consumer decoupling.** WebSocket packets write into ring buffers + eagerly. Charts repaint only on the per-frame Ticker. The two are decoupled + by design — never call repaint from a packet handler. + +3. **Disabled channels still record.** Toggling a channel "off" hides it on + the dashboard but does not stop recording or change the CSV output. + +4. **Buffer is authoritative for export.** CSV always reads from the buffer, + never from cached UI snapshots. + +5. **Window state is shared, Y state is per-chart.** All charts share one + X-axis window (zoom + pan + anchor). Each chart owns its own Y-mode and + Y-range. + +## Project layout + + lib/ + main.dart # Entry point + app.dart # MaterialApp, top-level wiring + proto/ # Generated from messages.proto + config/ # AppConfig, Settings (persisted) + transport/ # WebSocket abstraction + decoder/ # Protobuf decoding + session/ # Ring buffers, view state, controller + layout/ # Grid + chart config + export/ # CSV exporter + ui/ # Widgets, dialogs, painters + proto/ + messages.proto # Protocol Buffer schema + +## Build targets + +| Target | Notes | +| ------- | -------------------------------------------------------- | +| Linux | Primary development target. Full feature set. | +| Android | Same code as Linux. Uses `IOWebSocketChannel`. | +| Web | Decoder runs inline (no isolates). CanvasKit recommended. CSV export uses Blob downloads. | + +## License + +(Add as appropriate.) \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..92f1f3f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,19 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + # Treat these as errors, not warnings. + missing_required_param: error + missing_return: error + exclude: + - lib/proto/**.pb.dart + - lib/proto/**.pbenum.dart + - lib/proto/**.pbjson.dart + +linter: + rules: + # Encourage good practice. + prefer_const_constructors: true + prefer_final_locals: true + avoid_print: true + require_trailing_commas: true \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..983973a --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'config/settings.dart'; +import 'export/csv_exporter.dart'; +import 'layout/layout_controller.dart'; +import 'session/session_controller.dart'; +import 'ui/app_scope.dart'; +import 'ui/tab_scaffold.dart'; + +class TelemetryApp extends StatelessWidget { + const TelemetryApp({ + super.key, + required this.session, + required this.layout, + required this.settings, + required this.exporter, + }); + + final SessionController session; + final LayoutController layout; + final Settings settings; + final CsvExporter exporter; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Telemetry Monitor', + theme: ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.blueGrey, + ), + home: AppScope( + session: session, + layout: layout, + settings: settings, + exporter: exporter, + child: const TabScaffold(), + ), + ); + } +} \ No newline at end of file diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..52775d7 --- /dev/null +++ b/lib/config/app_config.dart @@ -0,0 +1,72 @@ +/// Compile-time constants and defaults. +/// +/// Anything here that the user can override at runtime appears as a default +/// value in `Settings` or `LayoutController`. Anything that's truly fixed +/// (channel count, status count, severity list) lives only here. +class AppConfig { + AppConfig._(); + + /// Total channels supported by the proto schema. Don't change without + /// updating messages.proto in lockstep. + static const int channelCount = 16; + + /// Total status indicators supported by the proto schema. + static const int statusCount = 8; + + /// Default per-channel display names. + static const List defaultChannelNames = [ + 'CH1', 'CH2', 'CH3', 'CH4', + 'CH5', 'CH6', 'CH7', 'CH8', + 'CH9', 'CH10', 'CH11', 'CH12', + 'CH13', 'CH14', 'CH15', 'CH16', + ]; + + /// Default per-status-field display names. + static const List defaultStatusNames = [ + 'status1', 'status2', 'status3', 'status4', + 'status5', 'status6', 'status7', 'status8', + ]; + + /// Default Y range when a channel is fixed-mode and the user hasn't set one. + static const double defaultYMin = 0.0; + static const double defaultYMax = 1.0; + + /// Default grid shape on first launch. + static const int defaultGridRows = 2; + static const int defaultGridCols = 2; + + /// Default packet buffer capacity (≈ 10 minutes at 1 kHz on native). + static const int defaultPacketBufferCapacityNative = 600000; + + /// Reduced default for Web (browser heap pressure). + static const int defaultPacketBufferCapacityWeb = 120000; + + /// Default log buffer capacity. + static const int defaultLogBufferCapacity = 5000; + + /// Default WebSocket URL on first launch. + static const String defaultWsUrl = 'ws://localhost:9000'; + + /// Default lookback for the per-frame status snapshot. + static const int defaultStatusLookback = 1000; + + /// Default decoder batch interval (native only). + static const Duration defaultDecoderBatchInterval = + Duration(milliseconds: 8); + + /// Default reconnect parameters. + static const Duration defaultReconnectInitialDelay = + Duration(milliseconds: 500); + static const Duration defaultReconnectMaxDelay = Duration(seconds: 30); + static const double defaultReconnectBackoffFactor = 2.0; + + /// Default mini-log severity filter (Dashboard tab). ERROR + FATAL. + static const Set defaultMiniLogSeverities = {4, 5}; + + /// Default chart x-window on launch and after Reset View. + static const Duration defaultWindowWidth = Duration(seconds: 10); + + /// Hard floor on the x-window — below this, charts become a handful of + /// samples and the UI gets confusing. + static const Duration minWindowWidth = Duration(milliseconds: 10); +} \ No newline at end of file diff --git a/lib/config/settings.dart b/lib/config/settings.dart new file mode 100644 index 0000000..93467cd --- /dev/null +++ b/lib/config/settings.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app_config.dart'; + +/// Runtime-tunable parameters that affect the data pipeline. +/// +/// Persistence: all keys under the `settings.` prefix in shared_preferences. +/// Display labels (channel names, status names) live in LayoutController, not +/// here, because they're conceptually about how the dashboard looks. +class Settings extends ChangeNotifier { + Settings._({ + required this.wsUrl, + required this.packetBufferCapacity, + required this.logBufferCapacity, + required this.decoderBatchInterval, + required this.reconnectInitialDelay, + required this.reconnectMaxDelay, + required this.reconnectBackoffFactor, + required this.miniLogSeverities, + required this.statusLookback, + }); + + String wsUrl; + int packetBufferCapacity; + int logBufferCapacity; + Duration decoderBatchInterval; + Duration reconnectInitialDelay; + Duration reconnectMaxDelay; + double reconnectBackoffFactor; + Set miniLogSeverities; + int statusLookback; + + /// Load from shared_preferences with defaults for any missing keys. + static Future load({bool isWeb = false}) async { + final p = await SharedPreferences.getInstance(); + return Settings._( + wsUrl: p.getString('settings.wsUrl') ?? AppConfig.defaultWsUrl, + packetBufferCapacity: p.getInt('settings.packetBufferCapacity') ?? + (isWeb + ? AppConfig.defaultPacketBufferCapacityWeb + : AppConfig.defaultPacketBufferCapacityNative), + logBufferCapacity: p.getInt('settings.logBufferCapacity') ?? + AppConfig.defaultLogBufferCapacity, + decoderBatchInterval: Duration( + milliseconds: p.getInt('settings.decoderBatchIntervalMs') ?? + AppConfig.defaultDecoderBatchInterval.inMilliseconds, + ), + reconnectInitialDelay: Duration( + milliseconds: p.getInt('settings.reconnectInitialDelayMs') ?? + AppConfig.defaultReconnectInitialDelay.inMilliseconds, + ), + reconnectMaxDelay: Duration( + milliseconds: p.getInt('settings.reconnectMaxDelayMs') ?? + AppConfig.defaultReconnectMaxDelay.inMilliseconds, + ), + reconnectBackoffFactor: p.getDouble('settings.reconnectBackoffFactor') ?? + AppConfig.defaultReconnectBackoffFactor, + miniLogSeverities: (p.getStringList('settings.miniLogSeverities') ?? + AppConfig.defaultMiniLogSeverities.map((s) => s.toString()).toList()) + .map(int.parse) + .toSet(), + statusLookback: p.getInt('settings.statusLookback') ?? + AppConfig.defaultStatusLookback, + ); + } + + Future save() async { + final p = await SharedPreferences.getInstance(); + await p.setString('settings.wsUrl', wsUrl); + await p.setInt('settings.packetBufferCapacity', packetBufferCapacity); + await p.setInt('settings.logBufferCapacity', logBufferCapacity); + await p.setInt('settings.decoderBatchIntervalMs', + decoderBatchInterval.inMilliseconds); + await p.setInt('settings.reconnectInitialDelayMs', + reconnectInitialDelay.inMilliseconds); + await p.setInt('settings.reconnectMaxDelayMs', + reconnectMaxDelay.inMilliseconds); + await p.setDouble( + 'settings.reconnectBackoffFactor', reconnectBackoffFactor); + await p.setStringList('settings.miniLogSeverities', + miniLogSeverities.map((s) => s.toString()).toList()); + await p.setInt('settings.statusLookback', statusLookback); + notifyListeners(); + } + + /// Reset every field to its compile-time default. Does not persist; call + /// [save] afterward if you want it persisted. + void restoreDefaults({bool isWeb = false}) { + wsUrl = AppConfig.defaultWsUrl; + packetBufferCapacity = isWeb + ? AppConfig.defaultPacketBufferCapacityWeb + : AppConfig.defaultPacketBufferCapacityNative; + logBufferCapacity = AppConfig.defaultLogBufferCapacity; + decoderBatchInterval = AppConfig.defaultDecoderBatchInterval; + reconnectInitialDelay = AppConfig.defaultReconnectInitialDelay; + reconnectMaxDelay = AppConfig.defaultReconnectMaxDelay; + reconnectBackoffFactor = AppConfig.defaultReconnectBackoffFactor; + miniLogSeverities = Set.of(AppConfig.defaultMiniLogSeverities); + statusLookback = AppConfig.defaultStatusLookback; + notifyListeners(); + } + + /// Take values from [other] (used to apply edits from the Settings dialog). + void copyFrom(Settings other) { + wsUrl = other.wsUrl; + packetBufferCapacity = other.packetBufferCapacity; + logBufferCapacity = other.logBufferCapacity; + decoderBatchInterval = other.decoderBatchInterval; + reconnectInitialDelay = other.reconnectInitialDelay; + reconnectMaxDelay = other.reconnectMaxDelay; + reconnectBackoffFactor = other.reconnectBackoffFactor; + miniLogSeverities = Set.of(other.miniLogSeverities); + statusLookback = other.statusLookback; + notifyListeners(); + } + + Settings clone() => Settings._( + wsUrl: wsUrl, + packetBufferCapacity: packetBufferCapacity, + logBufferCapacity: logBufferCapacity, + decoderBatchInterval: decoderBatchInterval, + reconnectInitialDelay: reconnectInitialDelay, + reconnectMaxDelay: reconnectMaxDelay, + reconnectBackoffFactor: reconnectBackoffFactor, + miniLogSeverities: Set.of(miniLogSeverities), + statusLookback: statusLookback, + ); +} \ No newline at end of file diff --git a/lib/decoder/decoder.dart b/lib/decoder/decoder.dart new file mode 100644 index 0000000..471f3b7 --- /dev/null +++ b/lib/decoder/decoder.dart @@ -0,0 +1,2 @@ +// Public import for the platform-split decoder. +export 'decoder_isolate.dart' if (dart.library.html) 'decoder_inline.dart'; \ No newline at end of file diff --git a/lib/decoder/decoder_base.dart b/lib/decoder/decoder_base.dart new file mode 100644 index 0000000..af91ea4 --- /dev/null +++ b/lib/decoder/decoder_base.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../proto/messages.pb.dart'; + +/// Abstract decoder interface. Feeds raw frames in, emits envelopes out. +/// +/// On native, the implementation runs in an isolate and batches envelopes. +/// On Web, the implementation decodes synchronously in [feed]. +abstract class Decoder { + /// Stream of decoded envelopes (or batches thereof, flattened to per-envelope). + Stream get envelopes; + + /// Push a raw frame for decoding. Returns immediately. + void feed(Uint8List frame); + + /// Tear down. Stops the isolate (on native) and closes the stream. + Future dispose(); +} \ No newline at end of file diff --git a/lib/decoder/decoder_inline.dart b/lib/decoder/decoder_inline.dart new file mode 100644 index 0000000..79b0491 --- /dev/null +++ b/lib/decoder/decoder_inline.dart @@ -0,0 +1,37 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../proto/messages.pb.dart'; +import 'decoder_base.dart'; + +export 'decoder_base.dart'; + +/// Web decoder. Isolates aren't available the same way on Web, so decoding +/// runs synchronously on the main isolate. At 1 kHz with small protobuf +/// messages this is acceptable. +class DecoderInline implements Decoder { + final StreamController _out = StreamController.broadcast(); + + @override + Stream get envelopes => _out.stream; + + @override + void feed(Uint8List frame) { + try { + final env = Envelope.fromBuffer(frame); + _out.add(env); + } catch (_) { + // Malformed packet — silently dropped. + } + } + + @override + Future dispose() async { + await _out.close(); + } +} + +class DecoderImpl extends DecoderInline { + // The inline decoder takes no batchInterval; accept and ignore for API parity. + DecoderImpl({Duration batchInterval = const Duration(milliseconds: 8)}); +} \ No newline at end of file diff --git a/lib/decoder/decoder_isolate.dart b/lib/decoder/decoder_isolate.dart new file mode 100644 index 0000000..ff82601 --- /dev/null +++ b/lib/decoder/decoder_isolate.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import '../proto/messages.pb.dart'; +import 'decoder_base.dart'; + +export 'decoder_base.dart'; + +/// Native decoder. Runs protobuf decoding in a worker isolate and batches +/// decoded envelopes back to the main isolate every [batchInterval] to +/// minimize SendPort overhead at 1 kHz. +class DecoderIsolate implements Decoder { + DecoderIsolate({this.batchInterval = const Duration(milliseconds: 8)}); + + final Duration batchInterval; + + final StreamController _out = StreamController.broadcast(); + Isolate? _isolate; + SendPort? _toIsolate; + ReceivePort? _fromIsolate; + bool _ready = false; + final List _pending = []; + + @override + Stream get envelopes => _out.stream; + + Future start() async { + if (_isolate != null) return; + _fromIsolate = ReceivePort(); + final completer = Completer(); + _fromIsolate!.listen((dynamic message) { + if (message is SendPort) { + completer.complete(message); + } else if (message is List) { + // Batch of encoded envelopes (List) sent back from the + // isolate. We could also send already-decoded objects, but Envelope + // is not a transferable type without copy, and re-decoding on the + // main isolate would defeat the purpose. So the isolate sends raw + // bytes of pre-validated envelopes — we trust them and decode here. + // A simpler model: send decoded envelopes via SendPort; protobuf + // objects survive the copy. We use that. + for (final item in message) { + if (item is Envelope) _out.add(item); + } + } + }); + _isolate = await Isolate.spawn( + _isolateEntry, + _IsolateInit( + sendPort: _fromIsolate!.sendPort, + batchIntervalMs: batchInterval.inMilliseconds, + ), + ); + _toIsolate = await completer.future; + _ready = true; + // Drain anything queued before the isolate was ready. + for (final f in _pending) { + _toIsolate!.send(f); + } + _pending.clear(); + } + + @override + void feed(Uint8List frame) { + if (!_ready) { + _pending.add(frame); + return; + } + // SendPort copies the bytes. At 1 kHz this is ~150 KB/s, negligible. + _toIsolate!.send(frame); + } + + @override + Future dispose() async { + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + _fromIsolate?.close(); + _fromIsolate = null; + await _out.close(); + } +} + +/// Public name for `DecoderIsolate` so consumers can write +/// `Decoder d = DecoderImpl(...)` regardless of platform. +class DecoderImpl extends DecoderIsolate { + DecoderImpl({super.batchInterval}); +} + +class _IsolateInit { + _IsolateInit({required this.sendPort, required this.batchIntervalMs}); + final SendPort sendPort; + final int batchIntervalMs; +} + +void _isolateEntry(_IsolateInit init) { + final inbox = ReceivePort(); + init.sendPort.send(inbox.sendPort); + + final List batch = []; + Timer.periodic(Duration(milliseconds: init.batchIntervalMs), (_) { + if (batch.isEmpty) return; + init.sendPort.send(List.from(batch)); + batch.clear(); + }); + + inbox.listen((dynamic message) { + if (message is Uint8List) { + try { + final env = Envelope.fromBuffer(message); + batch.add(env); + } catch (_) { + // Malformed packet — silently dropped. The session layer can + // observe this via gaps/PPS mismatch if needed. + } + } + }); +} \ No newline at end of file diff --git a/lib/export/csv_exporter.dart b/lib/export/csv_exporter.dart new file mode 100644 index 0000000..a162787 --- /dev/null +++ b/lib/export/csv_exporter.dart @@ -0,0 +1,2 @@ +// Public import for the platform-split CSV exporter. +export 'csv_exporter_io.dart' if (dart.library.html) 'csv_exporter_web.dart'; \ No newline at end of file diff --git a/lib/export/csv_exporter_base.dart b/lib/export/csv_exporter_base.dart new file mode 100644 index 0000000..df073c0 --- /dev/null +++ b/lib/export/csv_exporter_base.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import '../layout/layout_controller.dart'; +import '../session/log_buffer.dart'; +import '../session/packet_buffer.dart'; + +typedef ProgressCallback = void Function(double progress); + +/// Returned by export operations so the UI can report what happened. +class ExportResult { + ExportResult({required this.path, required this.bytesWritten}); + /// On native: filesystem path. On Web: filename. + final String path; + final int bytesWritten; +} + +/// Builds CSV row strings. Shared by both platform implementations so the +/// row layout is defined exactly once. +class CsvRowBuilder { + CsvRowBuilder(this.layout); + final LayoutController layout; + + /// Header row for data CSV. Disabled channels are omitted; pause and the + /// 8 status fields always appear. + String dataHeader() { + final cols = ['timestamp_us']; + for (var ch = 1; ch <= 16; ch++) { + if (layout.configFor(ch).enabled) { + cols.add('ch${ch}_${_safe(layout.configFor(ch).name)}'); + } + } + cols.add('pause'); + for (var s = 0; s < 8; s++) { + cols.add('status${s + 1}_${_safe(layout.statusNames[s])}'); + } + return cols.join(','); + } + + /// Row for one packet. Missing values are blank cells. + String dataRow(packet) { + final cells = []; + cells.add(packet.hasTimestampUs() ? packet.timestampUs.toString() : ''); + for (var ch = 1; ch <= 16; ch++) { + if (!layout.configFor(ch).enabled) continue; + cells.add(_channelCell(packet, ch)); + } + cells.add(packet.hasPause() ? (packet.pause ? '1' : '0') : ''); + for (var s = 1; s <= 8; s++) { + cells.add(_statusCell(packet, s)); + } + return cells.join(','); + } + + String logHeader() => 'timestamp_us,severity,error_number,description'; + + String logRow(entry) { + final ts = entry.hasTimestampUs() ? entry.timestampUs.toString() : ''; + final sev = entry.hasSeverity() ? entry.severity.name : ''; + final err = entry.hasErrorNumber() ? entry.errorNumber.toString() : ''; + final desc = entry.hasDescription() ? _escape(entry.description) : ''; + return '$ts,$sev,$err,$desc'; + } + + static String _channelCell(packet, int ch) { + switch (ch) { + case 1: return packet.hasCh1() ? packet.ch1.toString() : ''; + case 2: return packet.hasCh2() ? packet.ch2.toString() : ''; + case 3: return packet.hasCh3() ? packet.ch3.toString() : ''; + case 4: return packet.hasCh4() ? packet.ch4.toString() : ''; + case 5: return packet.hasCh5() ? packet.ch5.toString() : ''; + case 6: return packet.hasCh6() ? packet.ch6.toString() : ''; + case 7: return packet.hasCh7() ? packet.ch7.toString() : ''; + case 8: return packet.hasCh8() ? packet.ch8.toString() : ''; + case 9: return packet.hasCh9() ? packet.ch9.toString() : ''; + case 10: return packet.hasCh10() ? packet.ch10.toString() : ''; + case 11: return packet.hasCh11() ? packet.ch11.toString() : ''; + case 12: return packet.hasCh12() ? packet.ch12.toString() : ''; + case 13: return packet.hasCh13() ? packet.ch13.toString() : ''; + case 14: return packet.hasCh14() ? packet.ch14.toString() : ''; + case 15: return packet.hasCh15() ? packet.ch15.toString() : ''; + case 16: return packet.hasCh16() ? packet.ch16.toString() : ''; + default: return ''; + } + } + + static String _statusCell(packet, int s) { + switch (s) { + case 1: return packet.hasStatus1() ? packet.status1.toString() : ''; + case 2: return packet.hasStatus2() ? packet.status2.toString() : ''; + case 3: return packet.hasStatus3() ? packet.status3.toString() : ''; + case 4: return packet.hasStatus4() ? packet.status4.toString() : ''; + case 5: return packet.hasStatus5() ? packet.status5.toString() : ''; + case 6: return packet.hasStatus6() ? packet.status6.toString() : ''; + case 7: return packet.hasStatus7() ? packet.status7.toString() : ''; + case 8: return packet.hasStatus8() ? packet.status8.toString() : ''; + default: return ''; + } + } + + /// Strip characters that would break CSV column names. + static String _safe(String s) => s.replaceAll(RegExp(r'[,\s]+'), '_'); + + /// Escape a free-form field for CSV. Wraps in quotes if needed. + static String _escape(String s) { + if (s.contains(',') || s.contains('"') || s.contains('\n')) { + return '"${s.replaceAll('"', '""')}"'; + } + return s; + } +} + +/// Common interface implemented by both platform exporters. +abstract class CsvExporter { + /// Export the entire packet buffer as data CSV. + Future exportData({ + required PacketBuffer buffer, + required LayoutController layout, + ProgressCallback? onProgress, + }); + + /// Export the entire log buffer as log CSV. + Future exportLog({ + required LogBuffer buffer, + ProgressCallback? onProgress, + }); +} \ No newline at end of file diff --git a/lib/export/csv_exporter_io.dart b/lib/export/csv_exporter_io.dart new file mode 100644 index 0000000..b56541e --- /dev/null +++ b/lib/export/csv_exporter_io.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../layout/layout_controller.dart'; +import '../session/log_buffer.dart'; +import '../session/packet_buffer.dart'; +import 'csv_exporter_base.dart'; + +export 'csv_exporter_base.dart'; + +/// Native CSV exporter: writes to a file in the documents directory. +class CsvExporterImpl implements CsvExporter { + static const int _chunkRows = 1000; + + @override + Future exportData({ + required PacketBuffer buffer, + required LayoutController layout, + ProgressCallback? onProgress, + }) async { + final builder = CsvRowBuilder(layout); + final dir = await getApplicationDocumentsDirectory(); + final ts = DateTime.now().toIso8601String().replaceAll(':', '-'); + final file = File('${dir.path}/telemetry_data_$ts.csv'); + final sink = file.openWrite(); + sink.writeln(builder.dataHeader()); + final total = buffer.length; + var written = 0; + for (var i = 0; i < total; i++) { + sink.writeln(builder.dataRow(buffer[i])); + written++; + if (written % _chunkRows == 0) { + onProgress?.call(written / total); + // Yield to the event loop so the UI stays responsive. + await Future.delayed(Duration.zero); + } + } + await sink.flush(); + await sink.close(); + onProgress?.call(1.0); + return ExportResult( + path: file.path, + bytesWritten: await file.length(), + ); + } + + @override + Future exportLog({ + required LogBuffer buffer, + ProgressCallback? onProgress, + }) async { + // Logs don't need a layout to format (no column projection). + final dir = await getApplicationDocumentsDirectory(); + final ts = DateTime.now().toIso8601String().replaceAll(':', '-'); + final file = File('${dir.path}/telemetry_log_$ts.csv'); + final sink = file.openWrite(); + final builder = CsvRowBuilder(_NoLayoutPlaceholder()); + sink.writeln(builder.logHeader()); + final total = buffer.length; + var written = 0; + for (final entry in buffer.iterate()) { + sink.writeln(builder.logRow(entry)); + written++; + if (written % _chunkRows == 0) { + onProgress?.call(written / total); + await Future.delayed(Duration.zero); + } + } + await sink.flush(); + await sink.close(); + onProgress?.call(1.0); + return ExportResult( + path: file.path, + bytesWritten: await file.length(), + ); + } +} + +/// CsvRowBuilder.logRow doesn't actually need a layout. We pass a placeholder +/// so we don't have to plumb a real one through for log-only exports. +class _NoLayoutPlaceholder implements LayoutController { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} \ No newline at end of file diff --git a/lib/export/csv_exporter_web.dart b/lib/export/csv_exporter_web.dart new file mode 100644 index 0000000..df2a15d --- /dev/null +++ b/lib/export/csv_exporter_web.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:typed_data'; + +import '../layout/layout_controller.dart'; +import '../session/log_buffer.dart'; +import '../session/packet_buffer.dart'; +import 'csv_exporter_base.dart'; + +export 'csv_exporter_base.dart'; + +/// Web CSV exporter: builds a Blob in memory and triggers a download. +/// +/// Yields between chunks so the main thread doesn't lock during large exports. +class CsvExporterImpl implements CsvExporter { + static const int _chunkRows = 1000; + + @override + Future exportData({ + required PacketBuffer buffer, + required LayoutController layout, + ProgressCallback? onProgress, + }) async { + final builder = CsvRowBuilder(layout); + final chunks = []; + chunks.add('${builder.dataHeader()}\n'); + final total = buffer.length; + final sb = StringBuffer(); + var written = 0; + for (var i = 0; i < total; i++) { + sb.writeln(builder.dataRow(buffer[i])); + written++; + if (written % _chunkRows == 0) { + chunks.add(sb.toString()); + sb.clear(); + onProgress?.call(written / total); + await Future.delayed(Duration.zero); + } + } + if (sb.isNotEmpty) chunks.add(sb.toString()); + + final ts = DateTime.now().toIso8601String().replaceAll(':', '-'); + final filename = 'telemetry_data_$ts.csv'; + final bytes = _toBytes(chunks); + _triggerDownload(filename, bytes); + onProgress?.call(1.0); + return ExportResult(path: filename, bytesWritten: bytes.length); + } + + @override + Future exportLog({ + required LogBuffer buffer, + ProgressCallback? onProgress, + }) async { + final builder = CsvRowBuilder(_NoLayoutPlaceholder()); + final chunks = []; + chunks.add('${builder.logHeader()}\n'); + final total = buffer.length; + final sb = StringBuffer(); + var written = 0; + for (final entry in buffer.iterate()) { + sb.writeln(builder.logRow(entry)); + written++; + if (written % _chunkRows == 0) { + chunks.add(sb.toString()); + sb.clear(); + onProgress?.call(written / total); + await Future.delayed(Duration.zero); + } + } + if (sb.isNotEmpty) chunks.add(sb.toString()); + + final ts = DateTime.now().toIso8601String().replaceAll(':', '-'); + final filename = 'telemetry_log_$ts.csv'; + final bytes = _toBytes(chunks); + _triggerDownload(filename, bytes); + onProgress?.call(1.0); + return ExportResult(path: filename, bytesWritten: bytes.length); + } + + Uint8List _toBytes(List chunks) { + final encoder = utf8.encoder; + final encoded = chunks.map(encoder.convert).toList(); + final total = encoded.fold(0, (s, c) => s + c.length); + final out = Uint8List(total); + var off = 0; + for (final c in encoded) { + out.setRange(off, off + c.length, c); + off += c.length; + } + return out; + } + + void _triggerDownload(String filename, Uint8List bytes) { + final blob = html.Blob([bytes], 'text/csv'); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..download = filename + ..style.display = 'none'; + html.document.body!.append(anchor); + anchor.click(); + anchor.remove(); + html.Url.revokeObjectUrl(url); + } +} + +class _NoLayoutPlaceholder implements LayoutController { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} \ No newline at end of file diff --git a/lib/layout/app_config_safe.dart b/lib/layout/app_config_safe.dart new file mode 100644 index 0000000..f017c74 --- /dev/null +++ b/lib/layout/app_config_safe.dart @@ -0,0 +1,10 @@ +// Re-export of AppConfig defaults under a name that doesn't pull in +// pkg-relative cycles when ChartConfig is constructed in tests. +export '../config/app_config.dart' show AppConfig; +import '../config/app_config.dart'; + +class AppConfigSafe { + AppConfigSafe._(); + static const double defaultYMin = AppConfig.defaultYMin; + static const double defaultYMax = AppConfig.defaultYMax; +} \ No newline at end of file diff --git a/lib/layout/chart_config.dart b/lib/layout/chart_config.dart new file mode 100644 index 0000000..c4f9728 --- /dev/null +++ b/lib/layout/chart_config.dart @@ -0,0 +1,60 @@ +import 'app_config_safe.dart'; + +enum YMode { auto, fixed, userZoomed } + +YMode yModeFromString(String s) { + switch (s) { + case 'fixed': return YMode.fixed; + case 'userZoomed': return YMode.userZoomed; + case 'auto': + default: return YMode.auto; + } +} + +String yModeToString(YMode m) { + switch (m) { + case YMode.auto: return 'auto'; + case YMode.fixed: return 'fixed'; + case YMode.userZoomed: return 'userZoomed'; + } +} + +/// Per-channel display configuration. +/// +/// `channel` is the proto channel index (1..16). `enabled` toggles UI +/// visibility — disabled channels still record to the buffer and to CSV. +class ChartConfig { + ChartConfig({ + required this.channel, + required this.name, + this.enabled = true, + this.yMode = YMode.auto, + this.yMin = AppConfigSafe.defaultYMin, + this.yMax = AppConfigSafe.defaultYMax, + }); + + final int channel; + String name; + bool enabled; + YMode yMode; + double yMin; + double yMax; + + Map toJson() => { + 'channel': channel, + 'name': name, + 'enabled': enabled, + 'yMode': yModeToString(yMode), + 'yMin': yMin, + 'yMax': yMax, + }; + + static ChartConfig fromJson(Map j) => ChartConfig( + channel: j['channel'] as int, + name: j['name'] as String, + enabled: j['enabled'] as bool? ?? true, + yMode: yModeFromString(j['yMode'] as String? ?? 'auto'), + yMin: (j['yMin'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMin, + yMax: (j['yMax'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMax, + ); +} \ No newline at end of file diff --git a/lib/layout/grid_config.dart b/lib/layout/grid_config.dart new file mode 100644 index 0000000..f79f993 --- /dev/null +++ b/lib/layout/grid_config.dart @@ -0,0 +1,77 @@ +/// Grid shape and per-cell channel assignments. +/// +/// Cell numbering is 1..N in row-major order. A cell may be unassigned +/// (channel = null), in which case it renders as empty. +class GridConfig { + GridConfig({ + required this.rows, + required this.cols, + List? cellChannels, + }) : cellChannels = + cellChannels ?? List.filled(rows * cols, null); + + int rows; + int cols; + /// Length = rows * cols. Element is the proto channel index (1..16) or null. + List cellChannels; + + int get cellCount => rows * cols; + + /// 1-based cell number → channel, or null if unassigned. + int? channelForCell(int cellNumber) { + if (cellNumber < 1 || cellNumber > cellCount) return null; + return cellChannels[cellNumber - 1]; + } + + /// Set the channel assigned to a 1-based cell. If [channel] is already + /// assigned to a different cell, that cell is cleared (auto-swap). + void assign(int cellNumber, int? channel) { + if (cellNumber < 1 || cellNumber > cellCount) return; + if (channel != null) { + for (var i = 0; i < cellChannels.length; i++) { + if (cellChannels[i] == channel) cellChannels[i] = null; + } + } + cellChannels[cellNumber - 1] = channel; + } + + /// Resize the grid. Channel assignments to cells beyond the new range + /// are dropped. + void resize(int newRows, int newCols) { + final newCells = List.filled(newRows * newCols, null); + final keep = newCells.length < cellChannels.length + ? newCells.length + : cellChannels.length; + for (var i = 0; i < keep; i++) { + newCells[i] = cellChannels[i]; + } + rows = newRows; + cols = newCols; + cellChannels = newCells; + } + + /// Auto-assign: fill cells with the lowest-numbered enabled channels. + void autoAssign(List enabledChannels) { + cellChannels = List.filled(cellCount, null); + final n = enabledChannels.length < cellCount + ? enabledChannels.length + : cellCount; + for (var i = 0; i < n; i++) { + cellChannels[i] = enabledChannels[i]; + } + } + + Map toJson() => { + 'rows': rows, + 'cols': cols, + 'cellChannels': cellChannels, + }; + + static GridConfig fromJson(Map j) => GridConfig( + rows: j['rows'] as int, + cols: j['cols'] as int, + cellChannels: (j['cellChannels'] as List) + .map((e) => e as int?) + .toList(), + ); +} \ No newline at end of file diff --git a/lib/layout/layout_controller.dart b/lib/layout/layout_controller.dart new file mode 100644 index 0000000..7788d22 --- /dev/null +++ b/lib/layout/layout_controller.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/app_config.dart'; +import 'chart_config.dart'; +import 'grid_config.dart'; + +/// Owns the dashboard's display configuration. +/// +/// - Grid shape and channel-to-cell assignment (GridConfig). +/// - Per-channel display config (ChartConfig × 16). +/// - Status indicator display names (List × 8). +/// +/// Persistence: shared_preferences under the `layout.` prefix. +class LayoutController extends ChangeNotifier { + LayoutController._({ + required this.grid, + required this.charts, + required this.statusNames, + }); + + GridConfig grid; + /// Indexed 0..15 corresponding to channels 1..16. + List charts; + /// Indexed 0..7 corresponding to status1..status8. + List statusNames; + + static Future load() async { + final p = await SharedPreferences.getInstance(); + final gridJson = p.getString('layout.grid'); + final chartsJson = p.getString('layout.charts'); + final statusJson = p.getString('layout.statusNames'); + + final grid = gridJson != null + ? GridConfig.fromJson(jsonDecode(gridJson) as Map) + : GridConfig( + rows: AppConfig.defaultGridRows, + cols: AppConfig.defaultGridCols, + ); + + final charts = chartsJson != null + ? (jsonDecode(chartsJson) as List) + .map((e) => ChartConfig.fromJson(e as Map)) + .toList() + : List.generate( + AppConfig.channelCount, + (i) => ChartConfig( + channel: i + 1, + name: AppConfig.defaultChannelNames[i], + enabled: i < AppConfig.defaultGridRows * AppConfig.defaultGridCols, + ), + ); + + final statusNames = statusJson != null + ? (jsonDecode(statusJson) as List).cast() + : List.from(AppConfig.defaultStatusNames); + + final ctrl = LayoutController._( + grid: grid, + charts: charts, + statusNames: statusNames, + ); + + // First-run convenience: auto-assign enabled channels to grid cells if + // the saved grid is empty. + if (gridJson == null) { + ctrl.grid.autoAssign( + ctrl.charts.where((c) => c.enabled).map((c) => c.channel).toList(), + ); + } + return ctrl; + } + + Future save() async { + final p = await SharedPreferences.getInstance(); + await p.setString('layout.grid', jsonEncode(grid.toJson())); + await p.setString( + 'layout.charts', + jsonEncode(charts.map((c) => c.toJson()).toList()), + ); + await p.setString('layout.statusNames', jsonEncode(statusNames)); + notifyListeners(); + } + + /// Channel 1..16 → ChartConfig. + ChartConfig configFor(int channel) => charts[channel - 1]; + + /// Replace state from another LayoutController (used by the Layout dialog + /// when applying edits from a working copy). + void copyFrom(LayoutController other) { + grid = GridConfig( + rows: other.grid.rows, + cols: other.grid.cols, + cellChannels: List.from(other.grid.cellChannels), + ); + charts = other.charts + .map((c) => ChartConfig( + channel: c.channel, + name: c.name, + enabled: c.enabled, + yMode: c.yMode, + yMin: c.yMin, + yMax: c.yMax, + )) + .toList(); + statusNames = List.from(other.statusNames); + notifyListeners(); + } + + LayoutController clone() { + return LayoutController._( + grid: GridConfig( + rows: grid.rows, + cols: grid.cols, + cellChannels: List.from(grid.cellChannels), + ), + charts: charts + .map((c) => ChartConfig( + channel: c.channel, + name: c.name, + enabled: c.enabled, + yMode: c.yMode, + yMin: c.yMin, + yMax: c.yMax, + )) + .toList(), + statusNames: List.from(statusNames), + ); + } + + /// Mutators that notify on change. + + void setChannelEnabled(int channel, bool enabled) { + final c = configFor(channel); + if (c.enabled == enabled) return; + c.enabled = enabled; + if (!enabled) { + // Also clear from any grid cell. + for (var i = 0; i < grid.cellChannels.length; i++) { + if (grid.cellChannels[i] == channel) grid.cellChannels[i] = null; + } + } + notifyListeners(); + } + + void setChannelName(int channel, String name) { + final c = configFor(channel); + if (c.name == name) return; + c.name = name; + notifyListeners(); + } + + void setYMode(int channel, YMode mode) { + final c = configFor(channel); + if (c.yMode == mode) return; + c.yMode = mode; + notifyListeners(); + } + + void setYRange(int channel, double yMin, double yMax) { + final c = configFor(channel); + if (c.yMin == yMin && c.yMax == yMax) return; + c.yMin = yMin; + c.yMax = yMax; + notifyListeners(); + } + + /// Mouse-wheel Y zoom on a chart. Switches mode to userZoomed and updates + /// the range, keeping the pivot screen-Y fixed. + void zoomY({ + required int channel, + required double pivotValue, + required double factor, + }) { + final c = configFor(channel); + final span = c.yMax - c.yMin; + final newSpan = span * factor; + final fraction = (pivotValue - c.yMin) / span; + c.yMin = pivotValue - newSpan * fraction; + c.yMax = c.yMin + newSpan; + c.yMode = YMode.userZoomed; + notifyListeners(); + } + + void setStatusName(int statusIdx, String name) { + if (statusNames[statusIdx] == name) return; + statusNames[statusIdx] = name; + notifyListeners(); + } + + void setGridSize(int rows, int cols) { + grid.resize(rows, cols); + notifyListeners(); + } + + void assignCellToChannel(int cellNumber, int? channel) { + grid.assign(cellNumber, channel); + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f5d00d4 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'app.dart'; +import 'config/settings.dart'; +import 'decoder/decoder.dart'; +import 'export/csv_exporter.dart'; +import 'layout/layout_controller.dart'; +import 'session/session_controller.dart'; +import 'transport/websocket_transport.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final settings = await Settings.load(isWeb: kIsWeb); + final layout = await LayoutController.load(); + + final transport = WebSocketTransport( + initialReconnectDelay: settings.reconnectInitialDelay, + maxReconnectDelay: settings.reconnectMaxDelay, + backoffFactor: settings.reconnectBackoffFactor, + ); + final decoder = DecoderImpl(batchInterval: settings.decoderBatchInterval); + + // Native isolate decoders need an explicit start. + if (decoder is DecoderIsolate) { + await decoder.start(); + } + + final session = SessionController( + transport: transport, + decoder: decoder, + settings: settings, + ); + await session.start(); + + final exporter = CsvExporterImpl(); + + runApp(TelemetryApp( + session: session, + layout: layout, + settings: settings, + exporter: exporter, + )); +} \ No newline at end of file diff --git a/lib/session/decimator.dart b/lib/session/decimator.dart new file mode 100644 index 0000000..10ef954 --- /dev/null +++ b/lib/session/decimator.dart @@ -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 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>> _columnCache = {}; + final Map> _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 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 _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.filled(pixelWidth, double.infinity); + final maxs = List.filled(pixelWidth, double.negativeInfinity); + final has = List.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 = {}; + 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); +} \ No newline at end of file diff --git a/lib/session/log_buffer.dart b/lib/session/log_buffer.dart new file mode 100644 index 0000000..36258c6 --- /dev/null +++ b/lib/session/log_buffer.dart @@ -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 _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.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 iterate() sync* { + if (_length == 0) return; + final start = (_head - _length + capacity) % capacity; + for (var i = 0; i < _length; i++) { + yield _storage[(start + i) % capacity]!; + } + } +} \ No newline at end of file diff --git a/lib/session/packet_buffer.dart b/lib/session/packet_buffer.dart new file mode 100644 index 0000000..24cd2e2 --- /dev/null +++ b/lib/session/packet_buffer.dart @@ -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 _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.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 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; + } + } +} \ No newline at end of file diff --git a/lib/session/pps_counter.dart b/lib/session/pps_counter.dart new file mode 100644 index 0000000..5b9866b --- /dev/null +++ b/lib/session/pps_counter.dart @@ -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 _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(); + } + } +} \ No newline at end of file diff --git a/lib/session/session_controller.dart b/lib/session/session_controller.dart new file mode 100644 index 0000000..2faa991 --- /dev/null +++ b/lib/session/session_controller.dart @@ -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 _frameTick = ValueNotifier(0); + final ValueNotifier _logTick = ValueNotifier(0); + final ValueNotifier _snapshot = + ValueNotifier(StatusSnapshot.empty()); + + ValueListenable get frameTick => _frameTick; + ValueListenable get logTick => _logTick; + ValueListenable get statusSnapshot => _snapshot; + ValueListenable get connectionState => transport.state; + + StreamSubscription? _envSub; + Ticker? _ticker; + + Future 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.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 reconnect() async { + await transport.connect(settings.wsUrl); + } + + Future 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; + } + } +} \ No newline at end of file diff --git a/lib/session/status_snapshot.dart b/lib/session/status_snapshot.dart new file mode 100644 index 0000000..7c5725b --- /dev/null +++ b/lib/session/status_snapshot.dart @@ -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 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.filled(8, null), + protoPaused: null, + ); +} \ No newline at end of file diff --git a/lib/session/view_state.dart b/lib/session/view_state.dart new file mode 100644 index 0000000..387dfa1 --- /dev/null +++ b/lib/session/view_state.dart @@ -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; + } +} \ No newline at end of file diff --git a/lib/transport/connection_state.dart b/lib/transport/connection_state.dart new file mode 100644 index 0000000..e7e330c --- /dev/null +++ b/lib/transport/connection_state.dart @@ -0,0 +1,12 @@ +/// State of the WebSocket connection. Reported by `WebSocketTransport`. +/// +/// Transitions: +/// disconnected -> connecting -> connected +/// -> disconnected (immediate failure) +/// connected -> disconnected -> reconnecting -> connecting -> ... +enum WsConnectionState { + disconnected, + connecting, + connected, + reconnecting, +} \ No newline at end of file diff --git a/lib/transport/websocket_transport.dart b/lib/transport/websocket_transport.dart new file mode 100644 index 0000000..69bd49b --- /dev/null +++ b/lib/transport/websocket_transport.dart @@ -0,0 +1,6 @@ +// Public import for the platform-split transport. Consumers do: +// import 'package:telemetry_monitor/transport/websocket_transport.dart'; +// and the right implementation is selected at compile time. + +export 'websocket_transport_io.dart' + if (dart.library.html) 'websocket_transport_web.dart'; \ No newline at end of file diff --git a/lib/transport/websocket_transport_base.dart b/lib/transport/websocket_transport_base.dart new file mode 100644 index 0000000..4b7836e --- /dev/null +++ b/lib/transport/websocket_transport_base.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; + +import 'connection_state.dart'; + +/// Common base for the platform-specific WebSocket transports. +/// +/// Owns reconnection with exponential backoff. Subclasses implement only the +/// platform-specific channel construction. +abstract class WebSocketTransportBase { + WebSocketTransportBase({ + this.initialReconnectDelay = const Duration(milliseconds: 500), + this.maxReconnectDelay = const Duration(seconds: 30), + this.backoffFactor = 2.0, + }); + + Duration initialReconnectDelay; + Duration maxReconnectDelay; + double backoffFactor; + + final ValueNotifier _state = + ValueNotifier(WsConnectionState.disconnected); + ValueListenable get state => _state; + + final StreamController _frames = StreamController.broadcast(); + Stream get frames => _frames.stream; + + String? _url; + String? get currentUrl => _url; + + bool _shouldReconnect = false; + Duration _nextReconnectDelay = const Duration(milliseconds: 500); + Timer? _reconnectTimer; + + /// Connect to [url]. If a connection already exists it is closed first. + Future connect(String url) async { + await disconnect(); + _url = url; + _shouldReconnect = true; + _nextReconnectDelay = initialReconnectDelay; + await _openOnce(); + } + + /// Close the connection and stop reconnecting. + Future disconnect() async { + _shouldReconnect = false; + _reconnectTimer?.cancel(); + _reconnectTimer = null; + await _closePlatform(); + _state.value = WsConnectionState.disconnected; + } + + /// Subclasses call this when bytes arrive. + @protected + void onBytes(Uint8List bytes) => _frames.add(bytes); + + /// Subclasses call this when the underlying channel closes (with or without + /// error). Triggers reconnect if [_shouldReconnect] is true. + @protected + void onChannelClosed() { + if (!_shouldReconnect) { + _state.value = WsConnectionState.disconnected; + return; + } + _state.value = WsConnectionState.reconnecting; + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(_nextReconnectDelay, _openOnce); + final next = _nextReconnectDelay * backoffFactor; + _nextReconnectDelay = next > maxReconnectDelay ? maxReconnectDelay : next; + } + + /// Subclass: open the platform-specific channel using [_url] and call + /// [onBytes]/[onChannelClosed] as appropriate. + @protected + Future openPlatform(String url); + + /// Subclass: close the platform-specific channel if open. + @protected + Future _closePlatform(); + + Future _openOnce() async { + if (_url == null) return; + _state.value = WsConnectionState.connecting; + try { + await openPlatform(_url!); + _state.value = WsConnectionState.connected; + _nextReconnectDelay = initialReconnectDelay; + } catch (_) { + onChannelClosed(); + } + } + + Future dispose() async { + await disconnect(); + await _frames.close(); + _state.dispose(); + } +} \ No newline at end of file diff --git a/lib/transport/websocket_transport_io.dart b/lib/transport/websocket_transport_io.dart new file mode 100644 index 0000000..054a5e5 --- /dev/null +++ b/lib/transport/websocket_transport_io.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'websocket_transport_base.dart'; + +/// Native (Linux + Android) WebSocket transport using IOWebSocketChannel. +class WebSocketTransport extends WebSocketTransportBase { + WebSocketTransport({ + super.initialReconnectDelay, + super.maxReconnectDelay, + super.backoffFactor, + }); + + WebSocketChannel? _channel; + StreamSubscription? _sub; + + @override + Future openPlatform(String url) async { + _channel = IOWebSocketChannel.connect( + Uri.parse(url), + pingInterval: const Duration(seconds: 10), + ); + // Awaiting `ready` surfaces connection failures as exceptions. + await _channel!.ready; + _sub = _channel!.stream.listen( + (dynamic message) { + if (message is Uint8List) { + onBytes(message); + } else if (message is List) { + onBytes(Uint8List.fromList(message)); + } + // String messages are ignored — server should send binary protobuf. + }, + onError: (_) => onChannelClosed(), + onDone: onChannelClosed, + cancelOnError: true, + ); + } + + @override + Future _closePlatform() async { + await _sub?.cancel(); + _sub = null; + await _channel?.sink.close(WebSocketStatus.normalClosure); + _channel = null; + } +} \ No newline at end of file diff --git a/lib/transport/websocket_transport_web.dart b/lib/transport/websocket_transport_web.dart new file mode 100644 index 0000000..b02d90b --- /dev/null +++ b/lib/transport/websocket_transport_web.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:web_socket_channel/html.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'websocket_transport_base.dart'; + +/// Web WebSocket transport using HtmlWebSocketChannel. +class WebSocketTransport extends WebSocketTransportBase { + WebSocketTransport({ + super.initialReconnectDelay, + super.maxReconnectDelay, + super.backoffFactor, + }); + + WebSocketChannel? _channel; + StreamSubscription? _sub; + + @override + Future openPlatform(String url) async { + _channel = HtmlWebSocketChannel.connect(Uri.parse(url)); + await _channel!.ready; + _sub = _channel!.stream.listen( + (dynamic message) { + if (message is Uint8List) { + onBytes(message); + } else if (message is ByteBuffer) { + onBytes(message.asUint8List()); + } else if (message is List) { + onBytes(Uint8List.fromList(message)); + } + }, + onError: (_) => onChannelClosed(), + onDone: onChannelClosed, + cancelOnError: true, + ); + } + + @override + Future _closePlatform() async { + await _sub?.cancel(); + _sub = null; + await _channel?.sink.close(1000); // Normal closure. + _channel = null; + } +} \ No newline at end of file diff --git a/lib/ui/app_scope.dart b/lib/ui/app_scope.dart new file mode 100644 index 0000000..187b634 --- /dev/null +++ b/lib/ui/app_scope.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; + +import '../config/settings.dart'; +import '../export/csv_exporter.dart'; +import '../layout/layout_controller.dart'; +import '../session/session_controller.dart'; + +/// Inherited widget that exposes the app's controllers to descendants. +/// +/// Plain InheritedWidget rather than Provider/Riverpod, per the design +/// decision to avoid a state-management dependency. +class AppScope extends InheritedWidget { + const AppScope({ + super.key, + required this.session, + required this.layout, + required this.settings, + required this.exporter, + required super.child, + }); + + final SessionController session; + final LayoutController layout; + final Settings settings; + final CsvExporter exporter; + + static AppScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'AppScope.of() called with no AppScope ancestor'); + return scope!; + } + + @override + bool updateShouldNotify(AppScope old) => + session != old.session || + layout != old.layout || + settings != old.settings || + exporter != old.exporter; +} \ No newline at end of file diff --git a/lib/ui/chart_grid.dart b/lib/ui/chart_grid.dart new file mode 100644 index 0000000..2cd283e --- /dev/null +++ b/lib/ui/chart_grid.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'app_scope.dart'; +import 'chart_widget.dart'; + +class ChartGrid extends StatelessWidget { + const ChartGrid({super.key}); + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return ListenableBuilder( + listenable: scope.layout, + builder: (_, __) { + final grid = scope.layout.grid; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: grid.cols, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 16 / 9, + ), + itemCount: grid.cellCount, + itemBuilder: (_, i) { + final cellNumber = i + 1; + final ch = grid.channelForCell(cellNumber); + if (ch == null) return const _EmptyCell(); + final cfg = scope.layout.configFor(ch); + if (!cfg.enabled) return const _EmptyCell(); + return RepaintBoundary(child: ChartWidget(channel: ch)); + }, + ); + }, + ); + } +} + +class _EmptyCell extends StatelessWidget { + const _EmptyCell(); + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(6), + ), + child: const Center( + child: Text('empty', style: TextStyle(color: Colors.grey)), + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/chart_painter.dart b/lib/ui/chart_painter.dart new file mode 100644 index 0000000..d9fe694 --- /dev/null +++ b/lib/ui/chart_painter.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import '../layout/chart_config.dart'; +import '../layout/layout_controller.dart'; +import '../session/decimator.dart'; +import '../session/session_controller.dart'; + +/// Stateless per-frame painter. Reads everything fresh from the controllers +/// each `paint()` — no internal state. +class ChartPainter extends CustomPainter { + ChartPainter({ + required this.channel, + required this.session, + required this.layout, + }); + + final int channel; + final SessionController session; + final LayoutController layout; + + @override + void paint(Canvas canvas, Size size) { + final padLeft = 32.0, padRight = 4.0, padTop = 4.0, padBottom = 16.0; + final plotW = size.width - padLeft - padRight; + final plotH = size.height - padTop - padBottom; + if (plotW <= 0 || plotH <= 0) return; + + // Background. + final bgPaint = Paint()..color = const Color(0xFFF7F7F4); + canvas.drawRect( + Rect.fromLTWH(padLeft, padTop, plotW, plotH), + bgPaint, + ); + + // Compute window. + final newest = session.packets.newest; + final oldestUs = session.packets.oldest?.timestampUs.toInt() ?? 0; + final newestUs = newest?.timestampUs.toInt() ?? + DateTime.now().microsecondsSinceEpoch; + final win = session.viewState.currentWindow( + nowUs: newestUs, + oldestUs: oldestUs, + newestUs: newestUs, + ); + + // Decimate. + final cols = session.decimator.decimate( + channel: channel, + buffer: session.packets, + startUs: win.startUs, + endUs: win.endUs, + pixelWidth: plotW.round(), + ); + final gaps = session.decimator.computeGaps( + channel: channel, + buffer: session.packets, + startUs: win.startUs, + endUs: win.endUs, + pixelWidth: plotW.round(), + ); + + // Y range. + final cfg = layout.configFor(channel); + var yMin = cfg.yMin, yMax = cfg.yMax; + if (cfg.yMode == YMode.auto) { + double lo = double.infinity, hi = double.negativeInfinity; + for (final c in cols) { + if (!c.hasData) continue; + if (c.min < lo) lo = c.min; + if (c.max > hi) hi = c.max; + } + if (lo.isFinite && hi.isFinite && hi > lo) { + yMin = lo; + yMax = hi; + } + } + final ySpan = (yMax - yMin).abs() < 1e-12 ? 1.0 : (yMax - yMin); + + double xForCol(int col) => padLeft + col + 0.5; + double yForVal(double v) => + padTop + plotH * (1 - (v - yMin) / ySpan); + + // Hatched gap rectangles. + final hatchPaint = Paint() + ..color = const Color(0x55993C1D) + ..style = PaintingStyle.fill; + for (final g in gaps.hatchedRanges) { + final x0 = xForCol(g.startPx); + final x1 = xForCol(g.endPx + 1); + canvas.drawRect( + Rect.fromLTRB(x0, padTop, x1, padTop + plotH), + hatchPaint, + ); + } + + // Polyline. + final linePaint = Paint() + ..color = const Color(0xFF185FA5) + ..strokeWidth = 1.2 + ..style = PaintingStyle.stroke; + final path = Path(); + bool started = false; + for (var i = 0; i < cols.length; i++) { + final c = cols[i]; + if (!c.hasData) { + started = false; + continue; + } + final x = xForCol(i); + final yMinPx = yForVal(c.max); // Higher value = smaller y. + final yMaxPx = yForVal(c.min); + if (!started) { + path.moveTo(x, (yMinPx + yMaxPx) / 2); + started = true; + } else { + path.lineTo(x, (yMinPx + yMaxPx) / 2); + } + // Min/max vertical span to surface intra-column variation. + if ((yMaxPx - yMinPx).abs() > 0.5) { + canvas.drawLine(Offset(x, yMinPx), Offset(x, yMaxPx), linePaint); + } + } + canvas.drawPath(path, linePaint); + + // Sub-pixel marker lines. + final markerPaint = Paint() + ..color = const Color(0xAA993C1D) + ..strokeWidth = 1; + for (final px in gaps.markerPixels) { + final x = xForCol(px); + canvas.drawLine( + Offset(x, padTop), + Offset(x, padTop + plotH), + markerPaint, + ); + } + + // Axes labels. + _drawText(canvas, '${yMax.toStringAsFixed(2)}', + Offset(padLeft - 4, padTop), align: TextAlign.right); + _drawText(canvas, '${yMin.toStringAsFixed(2)}', + Offset(padLeft - 4, padTop + plotH - 12), align: TextAlign.right); + _drawText(canvas, _fmtTime(win.startUs), + Offset(padLeft, size.height - 14)); + _drawText(canvas, _fmtTime(win.endUs), + Offset(size.width - padRight, size.height - 14), + align: TextAlign.right); + } + + void _drawText(Canvas c, String s, Offset where, + {TextAlign align = TextAlign.left}) { + final tp = TextPainter( + text: TextSpan( + text: s, + style: const TextStyle(fontSize: 9, color: Colors.grey), + ), + textDirection: TextDirection.ltr, + textAlign: align, + )..layout(); + final dx = align == TextAlign.right ? where.dx - tp.width : where.dx; + tp.paint(c, Offset(dx, where.dy)); + } + + String _fmtTime(int us) { + final d = DateTime.fromMicrosecondsSinceEpoch(us); + return '${d.hour.toString().padLeft(2, '0')}:' + '${d.minute.toString().padLeft(2, '0')}:' + '${d.second.toString().padLeft(2, '0')}.' + '${d.millisecond.toString().padLeft(3, '0')}'; + } + + @override + bool shouldRepaint(ChartPainter old) => true; // Per-frame repaint. +} \ No newline at end of file diff --git a/lib/ui/chart_widget.dart b/lib/ui/chart_widget.dart new file mode 100644 index 0000000..fafa7d2 --- /dev/null +++ b/lib/ui/chart_widget.dart @@ -0,0 +1,189 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../layout/chart_config.dart'; +import '../session/view_state.dart'; +import 'app_scope.dart'; +import 'chart_painter.dart'; + +/// One chart cell: header + painted plot area. Listens to frameTick for +/// repaints, and translates pointer events into view-state and y-zoom edits. +class ChartWidget extends StatefulWidget { + const ChartWidget({super.key, required this.channel}); + final int channel; + + @override + State createState() => _ChartWidgetState(); +} + +class _ChartWidgetState extends State { + Offset? _dragStart; + int? _dragStartUs; + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Container( + decoration: BoxDecoration( + border: + Border.all(color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header(channel: widget.channel), + Expanded( + child: LayoutBuilder(builder: (context, constraints) { + return Listener( + onPointerSignal: (ev) => + _onPointerSignal(ev, constraints), + child: GestureDetector( + onDoubleTap: () => _resetY(), + onHorizontalDragStart: (d) => _dragStartHandler(d), + onHorizontalDragUpdate: (d) => + _dragUpdate(d, constraints), + onHorizontalDragEnd: (_) => _dragStart = null, + child: ValueListenableBuilder( + valueListenable: scope.session.frameTick, + builder: (_, __, ___) { + return CustomPaint( + painter: ChartPainter( + channel: widget.channel, + session: scope.session, + layout: scope.layout, + ), + size: Size.infinite, + ); + }, + ), + ), + ); + }), + ), + ], + ), + ); + } + + void _onPointerSignal(PointerSignalEvent ev, BoxConstraints c) { + if (ev is! PointerScrollEvent) return; + final scope = AppScope.of(context); + final ctrl = HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed; + final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1; + if (ctrl) { + // Y-zoom for this chart only. + final cfg = scope.layout.configFor(widget.channel); + final localY = ev.localPosition.dy; + // Map local Y back to data value (top = yMax, bottom = yMin). + final h = c.maxHeight; + final v = + cfg.yMax - (localY / h) * (cfg.yMax - cfg.yMin); + scope.layout.zoomY( + channel: widget.channel, + pivotValue: v, + factor: factor, + ); + } else { + // Shared X-zoom. + final newest = scope.session.packets.newest; + final oldestUs = + scope.session.packets.oldest?.timestampUs.toInt() ?? 0; + final newestUs = newest?.timestampUs.toInt() ?? + DateTime.now().microsecondsSinceEpoch; + final win = scope.session.viewState.currentWindow( + nowUs: newestUs, + oldestUs: oldestUs, + newestUs: newestUs, + ); + final localX = ev.localPosition.dx; + final w = c.maxWidth; + final pivotUs = win.startUs + + ((localX / w) * (win.endUs - win.startUs)).round(); + final maxWindow = Duration( + microseconds: (newestUs - oldestUs).clamp( + const Duration(milliseconds: 10).inMicroseconds, + 1 << 62, + ), + ); + scope.session.viewState.zoomAt( + pivotUs: pivotUs, + factor: factor, + maxWindow: maxWindow, + oldestUs: oldestUs, + newestUs: newestUs, + ); + } + } + + void _dragStartHandler(DragStartDetails d) { + final scope = AppScope.of(context); + _dragStart = d.localPosition; + final newest = scope.session.packets.newest; + final newestUs = newest?.timestampUs.toInt() ?? + DateTime.now().microsecondsSinceEpoch; + _dragStartUs = newestUs; + } + + void _dragUpdate(DragUpdateDetails d, BoxConstraints c) { + final scope = AppScope.of(context); + if (_dragStart == null || _dragStartUs == null) return; + final dxPx = d.localPosition.dx - _dragStart!.dx; + final widthUs = scope.session.viewState.windowWidth.inMicroseconds; + final usPerPx = widthUs / c.maxWidth; + final shiftUs = (-dxPx * usPerPx).round(); + final newest = scope.session.packets.newest; + final oldestUs = + scope.session.packets.oldest?.timestampUs.toInt() ?? 0; + final newestUs = newest?.timestampUs.toInt() ?? _dragStartUs!; + scope.session.viewState.panBy( + Duration(microseconds: shiftUs), + oldestUs: oldestUs, + newestUs: newestUs, + ); + _dragStart = d.localPosition; + } + + void _resetY() { + final scope = AppScope.of(context); + scope.layout.setYMode(widget.channel, YMode.auto); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.channel}); + final int channel; + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return ListenableBuilder( + listenable: scope.layout, + builder: (_, __) { + final cfg = scope.layout.configFor(channel); + final modeTag = switch (cfg.yMode) { + YMode.auto => 'auto', + YMode.fixed => 'fixed', + YMode.userZoomed => 'user', + }; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Row( + children: [ + Text('CH$channel · ${cfg.name}', + style: const TextStyle( + fontWeight: FontWeight.w500, fontSize: 12)), + const Spacer(), + Text( + '$modeTag · y: ${cfg.yMin.toStringAsFixed(2)} → ' + '${cfg.yMax.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/ui/dashboard_tab.dart b/lib/ui/dashboard_tab.dart new file mode 100644 index 0000000..5bda0cf --- /dev/null +++ b/lib/ui/dashboard_tab.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'app_scope.dart'; +import 'chart_grid.dart'; +import 'mini_log_panel.dart'; + +class DashboardTab extends StatelessWidget { + const DashboardTab({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + const Expanded(flex: 4, child: ChartGrid()), + const SizedBox(height: 8), + Expanded( + flex: 1, + child: MiniLogPanel( + severities: AppScope.of(context).settings.miniLogSeverities, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/dialogs/clear_confirm_dialog.dart b/lib/ui/dialogs/clear_confirm_dialog.dart new file mode 100644 index 0000000..878ac83 --- /dev/null +++ b/lib/ui/dialogs/clear_confirm_dialog.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class ClearConfirmDialog extends StatelessWidget { + const ClearConfirmDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Clear all data?'), + content: const Text( + 'This will discard all packets and log entries currently in memory. ' + 'Cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: + FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + child: const Text('Clear all'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/dialogs/layout_dialog.dart b/lib/ui/dialogs/layout_dialog.dart new file mode 100644 index 0000000..e3b9a00 --- /dev/null +++ b/lib/ui/dialogs/layout_dialog.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; + +import '../../config/app_config.dart'; +import '../../layout/chart_config.dart'; +import '../../layout/layout_controller.dart'; +import '../app_scope.dart'; + +class LayoutDialog extends StatefulWidget { + const LayoutDialog({super.key}); + + @override + State createState() => _LayoutDialogState(); +} + +class _LayoutDialogState extends State { + late LayoutController _draft; + static const _gridOptions = [ + (2, 2), (2, 3), (3, 3), (3, 4), (4, 4), + ]; + + @override + void initState() { + super.initState(); + final live = context.findAncestorWidgetOfExactType()!.layout; + _draft = live.clone(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Layout configuration'), + content: SizedBox( + width: 620, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _gridSection(), + const SizedBox(height: 16), + _channelsSection(), + const SizedBox(height: 16), + _statusNamesSection(), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + setState(() { + _draft.grid.autoAssign( + _draft.charts + .where((c) => c.enabled) + .map((c) => c.channel) + .toList(), + ); + }); + }, + child: const Text('Auto-assign'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => _apply(context), + child: const Text('Apply'), + ), + ], + ); + } + + Future _apply(BuildContext context) async { + final scope = AppScope.of(context); + scope.layout.copyFrom(_draft); + await scope.layout.save(); + if (context.mounted) Navigator.of(context).pop(); + } + + Widget _gridSection() { + final enabled = + _draft.charts.where((c) => c.enabled).length; + final empty = _draft.grid.cellCount - + _draft.grid.cellChannels.where((c) => c != null).length; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Grid size', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + DropdownButton<(int, int)>( + value: _gridOptions.firstWhere( + (g) => g.$1 == _draft.grid.rows && g.$2 == _draft.grid.cols, + orElse: () => _gridOptions.first, + ), + items: _gridOptions + .map((g) => DropdownMenuItem( + value: g, + child: Text('${g.$1} × ${g.$2}'), + )) + .toList(), + onChanged: (g) { + if (g == null) return; + setState(() => _draft.grid.resize(g.$1, g.$2)); + }, + ), + const SizedBox(height: 4), + Text( + '${_draft.grid.cellCount} cells · ' + '$enabled enabled · $empty empty', + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded(child: _gridPreview()), + ], + ); + } + + Widget _gridPreview() { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _draft.grid.cols, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 16 / 9, + ), + itemCount: _draft.grid.cellCount, + itemBuilder: (_, i) { + final cellNumber = i + 1; + final ch = _draft.grid.channelForCell(cellNumber); + if (ch == null) { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade400, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('$cellNumber', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.grey)), + const Text('empty', + style: TextStyle( + fontSize: 11, + color: Colors.grey, + fontStyle: FontStyle.italic)), + ], + ), + ), + ); + } + final cfg = _draft.configFor(ch); + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('$cellNumber', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.grey)), + Text('CH$ch · ${cfg.name}', + style: const TextStyle( + fontSize: 11, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _channelsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text('Channels', + style: TextStyle(fontWeight: FontWeight.w500)), + ), + ...List.generate(AppConfig.channelCount, (i) => _channelRow(i + 1)), + ], + ); + } + + Widget _channelRow(int channel) { + final cfg = _draft.configFor(channel); + final assignedCell = () { + for (var i = 0; i < _draft.grid.cellChannels.length; i++) { + if (_draft.grid.cellChannels[i] == channel) return i + 1; + } + return 0; // 0 = unassigned + }(); + return Opacity( + opacity: cfg.enabled ? 1.0 : 0.55, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Checkbox( + value: cfg.enabled, + onChanged: (v) => setState(() { + cfg.enabled = v ?? false; + if (!cfg.enabled) { + for (var i = 0; i < _draft.grid.cellChannels.length; i++) { + if (_draft.grid.cellChannels[i] == channel) { + _draft.grid.cellChannels[i] = null; + } + } + } + }), + ), + SizedBox( + width: 40, + child: Text('CH$channel', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.grey)), + ), + Expanded( + child: TextFormField( + initialValue: cfg.name, + enabled: cfg.enabled, + decoration: const InputDecoration( + isDense: true, contentPadding: EdgeInsets.all(6)), + onChanged: (v) => cfg.name = v, + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 64, + child: DropdownButton( + value: assignedCell, + isExpanded: true, + onChanged: cfg.enabled + ? (v) => setState(() { + _draft.grid.assign(v ?? 0, v == 0 ? null : channel); + }) + : null, + items: [ + const DropdownMenuItem(value: 0, child: Text('—')), + ...List.generate( + _draft.grid.cellCount, + (i) => DropdownMenuItem( + value: i + 1, + child: Text('${i + 1}'), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 78, + child: DropdownButton( + value: + cfg.yMode == YMode.userZoomed ? YMode.fixed : cfg.yMode, + isExpanded: true, + onChanged: cfg.enabled + ? (m) => setState(() => cfg.yMode = m ?? YMode.auto) + : null, + items: const [ + DropdownMenuItem(value: YMode.auto, child: Text('auto')), + DropdownMenuItem(value: YMode.fixed, child: Text('fixed')), + ], + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 70, + child: TextFormField( + initialValue: cfg.yMin.toString(), + enabled: cfg.enabled && cfg.yMode != YMode.auto, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + textAlign: TextAlign.right, + decoration: const InputDecoration( + isDense: true, contentPadding: EdgeInsets.all(6)), + onChanged: (v) => + cfg.yMin = double.tryParse(v) ?? cfg.yMin, + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 70, + child: TextFormField( + initialValue: cfg.yMax.toString(), + enabled: cfg.enabled && cfg.yMode != YMode.auto, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + textAlign: TextAlign.right, + decoration: const InputDecoration( + isDense: true, contentPadding: EdgeInsets.all(6)), + onChanged: (v) => + cfg.yMax = double.tryParse(v) ?? cfg.yMax, + ), + ), + ], + ), + ), + ); + } + + Widget _statusNamesSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text('Status indicator names', + style: TextStyle(fontWeight: FontWeight.w500)), + ), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Names shown on the status bar pills for the 8 status fields.', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + ), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 10, + childAspectRatio: 6, + ), + itemCount: 8, + itemBuilder: (_, i) => Row(children: [ + SizedBox( + width: 56, + child: Text('status${i + 1}', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.grey)), + ), + Expanded( + child: TextFormField( + initialValue: _draft.statusNames[i], + decoration: const InputDecoration( + isDense: true, contentPadding: EdgeInsets.all(6)), + onChanged: (v) => _draft.statusNames[i] = v, + ), + ), + ]), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/dialogs/settings_dialog.dart b/lib/ui/dialogs/settings_dialog.dart new file mode 100644 index 0000000..cb19265 --- /dev/null +++ b/lib/ui/dialogs/settings_dialog.dart @@ -0,0 +1,280 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../config/settings.dart'; +import '../../transport/connection_state.dart'; +import '../app_scope.dart'; + +class SettingsDialog extends StatefulWidget { + const SettingsDialog({super.key}); + + @override + State createState() => _SettingsDialogState(); +} + +class _SettingsDialogState extends State { + late Settings _draft; + late TextEditingController _wsCtrl; + late TextEditingController _pktCtrl; + late TextEditingController _logCtrl; + late TextEditingController _batchCtrl; + late TextEditingController _initCtrl; + late TextEditingController _maxCtrl; + late TextEditingController _backoffCtrl; + late TextEditingController _lookbackCtrl; + + @override + void initState() { + super.initState(); + final live = context.findAncestorWidgetOfExactType()!.settings; + _draft = live.clone(); + _wsCtrl = TextEditingController(text: _draft.wsUrl); + _pktCtrl = TextEditingController(text: _draft.packetBufferCapacity.toString()); + _logCtrl = TextEditingController(text: _draft.logBufferCapacity.toString()); + _batchCtrl = TextEditingController( + text: _draft.decoderBatchInterval.inMilliseconds.toString()); + _initCtrl = TextEditingController( + text: _draft.reconnectInitialDelay.inMilliseconds.toString()); + _maxCtrl = TextEditingController( + text: _draft.reconnectMaxDelay.inMilliseconds.toString()); + _backoffCtrl = + TextEditingController(text: _draft.reconnectBackoffFactor.toString()); + _lookbackCtrl = + TextEditingController(text: _draft.statusLookback.toString()); + } + + @override + void dispose() { + for (final c in [ + _wsCtrl, _pktCtrl, _logCtrl, _batchCtrl, + _initCtrl, _maxCtrl, _backoffCtrl, _lookbackCtrl, + ]) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return AlertDialog( + title: const Text('Settings'), + content: SizedBox( + width: 480, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _section('Connection'), + TextField( + controller: _wsCtrl, + decoration: const InputDecoration(labelText: 'WebSocket URL'), + style: const TextStyle(fontFamily: 'monospace'), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: scope.session.connectionState, + builder: (_, state, __) { + final dotColor = switch (state) { + WsConnectionState.connected => Colors.green, + WsConnectionState.connecting || + WsConnectionState.reconnecting => Colors.orange, + WsConnectionState.disconnected => Colors.grey, + }; + final label = switch (state) { + WsConnectionState.connected => 'Connected to', + WsConnectionState.connecting => 'Connecting to', + WsConnectionState.reconnecting => 'Reconnecting to', + WsConnectionState.disconnected => 'Disconnected', + }; + final url = scope.transport_url(); + final urlChanged = _wsCtrl.text != url; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Row(children: [ + Container( + width: 8, height: 8, + decoration: BoxDecoration( + color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text('$label ', style: const TextStyle(fontSize: 11)), + Text(url, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 11)), + const Spacer(), + if (urlChanged) + const Text('URL changed — will reconnect on Apply', + style: TextStyle( + fontSize: 10, + fontStyle: FontStyle.italic, + color: Colors.grey)), + ]), + ); + }, + ), + _section('Buffers'), + _numberField('Packet buffer', _pktCtrl, hint: '≈ 10 min @ 1 kHz'), + _numberField('Log buffer', _logCtrl, hint: 'entries'), + _section('Decoder'), + _numberField('Batch interval', _batchCtrl, hint: 'ms (native)'), + if (kIsWeb) + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text( + 'Web target decodes inline; this setting is ignored.', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + ), + _section('Reconnect'), + _numberField('Initial delay', _initCtrl, hint: 'ms'), + _numberField('Max delay', _maxCtrl, hint: 'ms'), + _numberField('Backoff factor', _backoffCtrl, hint: '×'), + _section('Status indicators'), + _numberField('Status lookback', _lookbackCtrl, + hint: '≈ 1 s @ 1 kHz'), + _section('Dashboard mini log'), + ..._severityCheckboxes(), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + _draft.restoreDefaults(isWeb: kIsWeb); + setState(() { + _wsCtrl.text = _draft.wsUrl; + _pktCtrl.text = _draft.packetBufferCapacity.toString(); + _logCtrl.text = _draft.logBufferCapacity.toString(); + _batchCtrl.text = + _draft.decoderBatchInterval.inMilliseconds.toString(); + _initCtrl.text = + _draft.reconnectInitialDelay.inMilliseconds.toString(); + _maxCtrl.text = + _draft.reconnectMaxDelay.inMilliseconds.toString(); + _backoffCtrl.text = _draft.reconnectBackoffFactor.toString(); + _lookbackCtrl.text = _draft.statusLookback.toString(); + }); + }, + child: const Text('Restore defaults'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => _apply(context), + child: const Text('Apply'), + ), + ], + ); + } + + Future _apply(BuildContext context) async { + _draft.wsUrl = _wsCtrl.text; + _draft.packetBufferCapacity = int.tryParse(_pktCtrl.text) ?? + _draft.packetBufferCapacity; + _draft.logBufferCapacity = + int.tryParse(_logCtrl.text) ?? _draft.logBufferCapacity; + _draft.decoderBatchInterval = Duration( + milliseconds: int.tryParse(_batchCtrl.text) ?? + _draft.decoderBatchInterval.inMilliseconds, + ); + _draft.reconnectInitialDelay = Duration( + milliseconds: int.tryParse(_initCtrl.text) ?? + _draft.reconnectInitialDelay.inMilliseconds, + ); + _draft.reconnectMaxDelay = Duration( + milliseconds: int.tryParse(_maxCtrl.text) ?? + _draft.reconnectMaxDelay.inMilliseconds, + ); + _draft.reconnectBackoffFactor = + double.tryParse(_backoffCtrl.text) ?? _draft.reconnectBackoffFactor; + _draft.statusLookback = + int.tryParse(_lookbackCtrl.text) ?? _draft.statusLookback; + + final scope = AppScope.of(context); + final urlChanged = scope.settings.wsUrl != _draft.wsUrl; + scope.settings.copyFrom(_draft); + await scope.settings.save(); + // Resize buffers if capacity changed. + if (scope.session.packets.capacity != _draft.packetBufferCapacity) { + scope.session.packets.resize(_draft.packetBufferCapacity); + } + if (scope.session.logs.capacity != _draft.logBufferCapacity) { + scope.session.logs.resize(_draft.logBufferCapacity); + } + if (urlChanged) { + await scope.session.reconnect(); + } + if (context.mounted) Navigator.of(context).pop(); + } + + Widget _section(String label) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 6), + child: Text(label, + style: const TextStyle( + fontWeight: FontWeight.w500, color: Colors.grey)), + ); + + Widget _numberField(String label, TextEditingController c, + {String? hint}) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + SizedBox(width: 130, child: Text(label)), + Expanded( + child: TextField( + controller: c, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + if (hint != null) ...[ + const SizedBox(width: 8), + SizedBox( + width: 100, + child: + Text(hint, style: const TextStyle(color: Colors.grey)), + ), + ], + ], + ), + ); + } + + List _severityCheckboxes() { + const labels = { + 1: 'DEBUG', 2: 'INFO', 3: 'WARN', 4: 'ERROR', 5: 'FATAL', + }; + return labels.entries.map((e) { + return CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(e.value, style: const TextStyle(fontFamily: 'monospace')), + value: _draft.miniLogSeverities.contains(e.key), + onChanged: (v) => setState(() { + if (v == true) { + _draft.miniLogSeverities.add(e.key); + } else { + _draft.miniLogSeverities.remove(e.key); + } + }), + ); + }).toList(); + } +} + +extension _ScopeTransport on AppScope { + // Helper: get the transport's actual current URL. Falls back to the + // settings value if transport is between connections. + String transport_url() => session.transport.currentUrl ?? settings.wsUrl; +} \ No newline at end of file diff --git a/lib/ui/full_log_tab.dart b/lib/ui/full_log_tab.dart new file mode 100644 index 0000000..fc2f056 --- /dev/null +++ b/lib/ui/full_log_tab.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../proto/messages.pb.dart'; +import 'app_scope.dart'; +import 'log_list_view.dart'; + +class FullLogTab extends StatefulWidget { + const FullLogTab({super.key}); + + @override + State createState() => _FullLogTabState(); +} + +class _FullLogTabState extends State { + Set _visible = {1, 2, 3, 4, 5}; + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Padding( + padding: const EdgeInsets.all(8), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _FilterRow( + visible: _visible, + onToggle: (s) => setState(() { + if (_visible.contains(s)) { + _visible.remove(s); + } else { + _visible.add(s); + } + }), + onExport: () => _exportLog(context), + ), + const Divider(height: 1), + Expanded( + child: LogListView( + session: scope.session, + filter: (e) => _visible.contains( + e.hasSeverity() ? e.severity.value : 0, + ), + ), + ), + ], + ), + ), + ); + } + + Future _exportLog(BuildContext context) async { + final scope = AppScope.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + final result = + await scope.exporter.exportLog(buffer: scope.session.logs); + messenger.showSnackBar( + SnackBar(content: Text('Exported to ${result.path}')), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } +} + +class _FilterRow extends StatelessWidget { + const _FilterRow({ + required this.visible, + required this.onToggle, + required this.onExport, + }); + + final Set visible; + final void Function(int) onToggle; + final VoidCallback onExport; + + @override + Widget build(BuildContext context) { + Widget chip(int sev, String label, Color color) { + final on = visible.contains(sev); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: FilterChip( + label: Text(label, style: const TextStyle(fontSize: 11)), + selected: on, + onSelected: (_) => onToggle(sev), + selectedColor: color.withValues(alpha: 0.2), + checkmarkColor: color, + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + const Text('Filter:', style: TextStyle(fontSize: 11)), + const SizedBox(width: 6), + chip(1, 'DEBUG', Colors.grey), + chip(2, 'INFO', Colors.blueGrey), + chip(3, 'WARN', Colors.amber.shade800), + chip(4, 'ERROR', Colors.red.shade800), + chip(5, 'FATAL', Colors.red.shade900), + const Spacer(), + OutlinedButton.icon( + onPressed: onExport, + icon: const Icon(Icons.download, size: 16), + label: const Text('Export log'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/log_list_view.dart b/lib/ui/log_list_view.dart new file mode 100644 index 0000000..d90f41a --- /dev/null +++ b/lib/ui/log_list_view.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +import '../proto/messages.pb.dart'; +import '../session/session_controller.dart'; + +typedef LogFilter = bool Function(LogPacket entry); + +/// Reusable log viewer with auto-scroll-to-newest and disengage-on-manual-scroll. +class LogListView extends StatefulWidget { + const LogListView({ + super.key, + required this.session, + required this.filter, + }); + + final SessionController session; + final LogFilter filter; + + @override + State createState() => _LogListViewState(); +} + +class _LogListViewState extends State { + final ScrollController _ctrl = ScrollController(); + bool _autoScroll = true; + + @override + void initState() { + super.initState(); + _ctrl.addListener(_onScroll); + widget.session.logTick.addListener(_onNewLog); + } + + @override + void dispose() { + widget.session.logTick.removeListener(_onNewLog); + _ctrl.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_ctrl.hasClients) return; + final atBottom = _ctrl.offset >= + _ctrl.position.maxScrollExtent - 4; + if (_autoScroll != atBottom) { + setState(() => _autoScroll = atBottom); + } + } + + void _onNewLog() { + if (!_autoScroll || !_ctrl.hasClients) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_ctrl.hasClients) return; + _ctrl.jumpTo(_ctrl.position.maxScrollExtent); + }); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.session.logTick, + builder: (_, __, ___) { + final entries = widget.session.logs + .iterate() + .where(widget.filter) + .toList(growable: false); + return ListView.builder( + controller: _ctrl, + itemCount: entries.length, + itemBuilder: (_, i) => _LogRow(entry: entries[i]), + ); + }, + ); + } +} + +class _LogRow extends StatelessWidget { + const _LogRow({required this.entry}); + final LogPacket entry; + + @override + Widget build(BuildContext context) { + final ts = entry.hasTimestampUs() + ? _fmtTime(entry.timestampUs.toInt()) + : ''; + final sev = entry.hasSeverity() ? entry.severity.name : ''; + final err = entry.hasErrorNumber() + ? '[${entry.errorNumber.toString().padLeft(4, '0')}]' + : ''; + final desc = entry.hasDescription() ? entry.description : ''; + final sevColor = switch (sev) { + 'WARN' => Colors.amber.shade800, + 'ERROR' || 'FATAL' => Colors.red.shade800, + _ => Colors.grey.shade700, + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1), + child: DefaultTextStyle( + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ts, style: const TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + SizedBox( + width: 50, + child: Text(sev, style: TextStyle(color: sevColor)), + ), + const SizedBox(width: 6), + Text(err, style: const TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + Expanded(child: Text(desc, overflow: TextOverflow.ellipsis)), + ], + ), + ), + ); + } + + String _fmtTime(int us) { + final d = DateTime.fromMicrosecondsSinceEpoch(us); + return '${d.hour.toString().padLeft(2, '0')}:' + '${d.minute.toString().padLeft(2, '0')}:' + '${d.second.toString().padLeft(2, '0')}.' + '${d.millisecond.toString().padLeft(3, '0')}'; + } +} \ No newline at end of file diff --git a/lib/ui/mini_log_panel.dart b/lib/ui/mini_log_panel.dart new file mode 100644 index 0000000..c235c82 --- /dev/null +++ b/lib/ui/mini_log_panel.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../proto/messages.pb.dart'; +import 'app_scope.dart'; +import 'log_list_view.dart'; + +/// Compact log panel on the Dashboard tab. Filters by severity using the +/// set configured in Settings (default ERROR + FATAL). +class MiniLogPanel extends StatelessWidget { + const MiniLogPanel({super.key, required this.severities}); + + final Set severities; + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: const [ + Text('Errors & fatals', + style: TextStyle( + fontWeight: FontWeight.w500, fontSize: 12)), + SizedBox(width: 6), + Text('· filtered view', + style: TextStyle(fontSize: 11, color: Colors.grey)), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: LogListView( + session: scope.session, + filter: (e) => severities.contains( + e.hasSeverity() ? e.severity.value : 0, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/status_bar.dart b/lib/ui/status_bar.dart new file mode 100644 index 0000000..257d701 --- /dev/null +++ b/lib/ui/status_bar.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +import '../session/status_snapshot.dart'; +import '../session/view_state.dart'; +import '../transport/connection_state.dart'; +import 'app_scope.dart'; + +class StatusBar extends StatelessWidget { + const StatusBar({super.key}); + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Material( + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ValueListenableBuilder( + valueListenable: scope.session.statusSnapshot, + builder: (_, snap, __) { + return Wrap( + spacing: 14, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _ConnectionPill(snap.connection, scope.settings.wsUrl), + _Sep(), + _PpsPill(snap.pps), + _Sep(), + _PausePill(scope.session.pauseSource), + _Sep(), + const Text('Status:', + style: TextStyle(fontSize: 11, color: Colors.grey)), + ...List.generate(8, (i) { + return _StatusPill( + name: scope.layout.statusNames[i], + value: snap.statusValues[i], + ); + }), + ], + ); + }, + ), + ), + ); + } +} + +class _Sep extends StatelessWidget { + @override + Widget build(BuildContext context) => + Container(width: 1, height: 14, color: Colors.grey.shade400); +} + +class _ConnectionPill extends StatelessWidget { + const _ConnectionPill(this.state, this.url); + final WsConnectionState state; + final String url; + + @override + Widget build(BuildContext context) { + final (color, label) = switch (state) { + WsConnectionState.connected => (Colors.green, 'WS connected'), + WsConnectionState.connecting => (Colors.orange, 'Connecting'), + WsConnectionState.reconnecting => (Colors.orange, 'Reconnecting'), + WsConnectionState.disconnected => (Colors.grey, 'Disconnected'), + }; + return Row(mainAxisSize: MainAxisSize.min, children: [ + _Dot(color), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 11)), + const SizedBox(width: 6), + Text(url, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 11, color: Colors.grey)), + ]); + } +} + +class _PpsPill extends StatelessWidget { + const _PpsPill(this.pps); + final double pps; + @override + Widget build(BuildContext context) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + const Text('PPS ', + style: TextStyle(fontSize: 11, color: Colors.grey)), + Text(pps.round().toString(), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + fontWeight: FontWeight.w500)), + ]); + } +} + +class _PausePill extends StatelessWidget { + const _PausePill(this.source); + final PauseSource source; + + @override + Widget build(BuildContext context) { + if (source == PauseSource.none) { + return const SizedBox.shrink(); + } + final label = switch (source) { + PauseSource.user => 'Paused (user)', + PauseSource.proto => 'Paused (proto)', + PauseSource.both => 'Paused (user + proto)', + PauseSource.none => '', + }; + return Row(mainAxisSize: MainAxisSize.min, children: [ + _Dot(Colors.orange), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 11)), + ]); + } +} + +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.name, required this.value}); + final String name; + final int? value; + + @override + Widget build(BuildContext context) { + final (bg, fg, dot, text) = switch (value) { + null => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey, + '$name · ?'), + 0 => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey, + '$name · 0'), + 1 => (Colors.red.shade100, Colors.red.shade900, Colors.red, + '$name · 1'), + 2 => (Colors.amber.shade100, Colors.amber.shade900, Colors.amber, + '$name · 2'), + 3 => (Colors.green.shade100, Colors.green.shade900, Colors.green, + '$name · 3'), + _ => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey, + '$name · $value'), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + _Dot(dot, size: 7), + const SizedBox(width: 5), + Text(text, style: TextStyle(fontSize: 10, color: fg)), + ]), + ); + } +} + +class _Dot extends StatelessWidget { + const _Dot(this.color, {this.size = 8}); + final Color color; + final double size; + @override + Widget build(BuildContext context) => Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); +} \ No newline at end of file diff --git a/lib/ui/tab_scaffold.dart b/lib/ui/tab_scaffold.dart new file mode 100644 index 0000000..4e68c93 --- /dev/null +++ b/lib/ui/tab_scaffold.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'app_scope.dart'; +import 'dashboard_tab.dart'; +import 'full_log_tab.dart'; +import 'status_bar.dart'; +import 'toolbar.dart'; + +/// Top-level scaffold: toolbar at top, two tabs (Dashboard / Full log), +/// status bar at bottom. Both tabs share the same controllers; switching +/// is purely a view change. +class TabScaffold extends StatefulWidget { + const TabScaffold({super.key}); + + @override + State createState() => _TabScaffoldState(); +} + +class _TabScaffoldState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabs = TabController(length: 2, vsync: this); + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Scaffold( + body: SafeArea( + child: Column( + children: [ + const Toolbar(), + Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: TabBar( + controller: _tabs, + tabs: [ + const Tab(text: 'Dashboard'), + Tab( + child: ValueListenableBuilder( + valueListenable: scope.session.logTick, + builder: (_, __, ___) => Text( + 'Full log (${scope.session.logs.length})', + ), + ), + ), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabs, + physics: const NeverScrollableScrollPhysics(), + children: const [ + DashboardTab(), + FullLogTab(), + ], + ), + ), + const StatusBar(), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/toolbar.dart b/lib/ui/toolbar.dart new file mode 100644 index 0000000..628b3a8 --- /dev/null +++ b/lib/ui/toolbar.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../session/view_state.dart'; +import 'app_scope.dart'; +import 'dialogs/clear_confirm_dialog.dart'; +import 'dialogs/layout_dialog.dart'; +import 'dialogs/settings_dialog.dart'; + +class Toolbar extends StatelessWidget { + const Toolbar({super.key}); + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return Material( + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + const Text( + 'Telemetry Monitor', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(width: 12), + const _Sep(), + ListenableBuilder( + listenable: scope.session.viewState, + builder: (_, __) => OutlinedButton.icon( + onPressed: scope.session.viewState.togglePause, + icon: Icon(scope.session.viewState.userPaused + ? Icons.play_arrow + : Icons.pause), + label: Text( + scope.session.viewState.userPaused ? 'Resume' : 'Pause'), + ), + ), + const SizedBox(width: 6), + ListenableBuilder( + listenable: scope.session.viewState, + builder: (_, __) => OutlinedButton.icon( + onPressed: scope.session.viewState.anchorMode == + ViewAnchorMode.followLive + ? null + : scope.session.viewState.goLive, + icon: const Icon(Icons.fast_forward), + label: const Text('Go live'), + ), + ), + const SizedBox(width: 6), + OutlinedButton.icon( + onPressed: () => scope.session.viewState.resetView(), + icon: const Icon(Icons.fit_screen), + label: const Text('Reset view'), + ), + const _Sep(), + OutlinedButton.icon( + onPressed: () => showDialog( + context: context, + builder: (_) => const LayoutDialog(), + ), + icon: const Icon(Icons.grid_view), + label: const Text('Layout'), + ), + const SizedBox(width: 6), + OutlinedButton.icon( + onPressed: () => showDialog( + context: context, + builder: (_) => const SettingsDialog(), + ), + icon: const Icon(Icons.settings), + label: const Text('Settings'), + ), + const _Sep(), + OutlinedButton.icon( + onPressed: () => _exportData(context), + icon: const Icon(Icons.download), + label: const Text('Export data'), + ), + const SizedBox(width: 6), + OutlinedButton.icon( + onPressed: () => _confirmClear(context), + icon: const Icon(Icons.delete_outline), + label: const Text('Clear all'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + const Spacer(), + const _ZoomReadout(), + ], + ), + ), + ); + } + + Future _exportData(BuildContext context) async { + final scope = AppScope.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + final result = await scope.exporter.exportData( + buffer: scope.session.packets, + layout: scope.layout, + ); + messenger.showSnackBar( + SnackBar(content: Text('Exported to ${result.path}')), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } + + Future _confirmClear(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (_) => const ClearConfirmDialog(), + ); + if (ok == true && context.mounted) { + AppScope.of(context).session.clearAll(); + } + } +} + +class _Sep extends StatelessWidget { + const _Sep(); + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 22, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Theme.of(context).colorScheme.outlineVariant, + ); +} + +class _ZoomReadout extends StatelessWidget { + const _ZoomReadout(); + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + return ValueListenableBuilder( + valueListenable: scope.session.frameTick, + builder: (_, __, ___) { + final view = scope.session.viewState; + final newest = scope.session.packets.newest; + final nowUs = newest?.timestampUs.toInt() ?? + DateTime.now().microsecondsSinceEpoch; + final win = view.currentWindow( + nowUs: nowUs, + oldestUs: scope.session.packets.oldest?.timestampUs.toInt() ?? nowUs, + newestUs: nowUs, + ); + final widthMs = view.windowWidth.inMilliseconds; + final centerUs = (win.startUs + win.endUs) ~/ 2; + final dt = DateTime.fromMicrosecondsSinceEpoch(centerUs); + final widthStr = widthMs >= 1000 + ? '${(widthMs / 1000).toStringAsFixed(2)} s' + : '$widthMs ms'; + final hh = dt.hour.toString().padLeft(2, '0'); + final mm = dt.minute.toString().padLeft(2, '0'); + final ss = dt.second.toString().padLeft(2, '0'); + final ms = dt.millisecond.toString().padLeft(3, '0'); + return Text( + 'Window: $widthStr · Center: $hh:$mm:$ss.$ms', + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12, color: Colors.grey), + ); + }, + ); + } +} \ No newline at end of file diff --git a/proto/messages.proto b/proto/messages.proto new file mode 100644 index 0000000..c9909e4 --- /dev/null +++ b/proto/messages.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package telemetry; + +// Top-level wrapper. Every message on the WebSocket is an Envelope. +// `oneof` enforces mutual exclusivity at the schema level. +message Envelope { + oneof payload { + DataPacket data = 1; + LogPacket log = 2; + } +} + +// One sample packet covering all channels and all status fields. +// All fields optional: any field absent means "no update for this field +// in this packet" — the UI renders missing channel data as a gap and +// missing status fields walk back to find the most recent value. +message DataPacket { + optional int64 timestamp_us = 1; + + optional double ch1 = 2; + optional double ch2 = 3; + optional double ch3 = 4; + optional double ch4 = 5; + optional double ch5 = 6; + optional double ch6 = 7; + optional double ch7 = 8; + optional double ch8 = 9; + optional double ch9 = 10; + optional double ch10 = 11; + optional double ch11 = 12; + optional double ch12 = 13; + optional double ch13 = 14; + optional double ch14 = 15; + optional double ch15 = 16; + optional double ch16 = 17; + + // True = pause requested by server. UI button OR-composes with this. + optional bool pause = 18; + + // 4-state status indicators. Defined values: 0=gray, 1=red, 2=yellow, 3=green. + // Other values render as gray with a one-time warning logged. + optional uint32 status1 = 19; + optional uint32 status2 = 20; + optional uint32 status3 = 21; + optional uint32 status4 = 22; + optional uint32 status5 = 23; + optional uint32 status6 = 24; + optional uint32 status7 = 25; + optional uint32 status8 = 26; +} + +// One log entry. Description is taken verbatim and is the single source +// of truth for the human-readable text — the app does not maintain a +// separate error_number → description mapping. +message LogPacket { + optional int64 timestamp_us = 1; + optional Severity severity = 2; + optional uint32 error_number = 3; + optional string description = 4; +} + +enum Severity { + SEVERITY_UNKNOWN = 0; + DEBUG = 1; + INFO = 2; + WARN = 3; + ERROR = 4; + FATAL = 5; +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..fa1b9b0 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,42 @@ +name: telemetry_monitor +description: Real-time telemetry visualization over WebSocket. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.41.0' + +dependencies: + flutter: + sdk: flutter + + # WebSocket abstraction with platform splits. + web_socket_channel: ^3.0.1 + + # Protocol Buffers runtime. Generation handled by protoc + dart_out. + protobuf: ^3.1.0 + + # Persistence for Settings and LayoutController. + shared_preferences: ^2.3.2 + + # File path resolution for native CSV export. + path_provider: ^2.1.4 + + # Used by the web CSV exporter for Blob downloads. + # On native, conditional import means web: is not pulled in. + +dev_dependencies: + flutter_test: + sdk: flutter + + # Generates Dart code from .proto. Run via: + # protoc --dart_out=lib/proto/ -Iproto/ proto/messages.proto + # The plugin must be on PATH; install with: + # dart pub global activate protoc_plugin + protoc_plugin: ^21.1.2 + + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true \ No newline at end of file