First commit

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

213
ARCHITECTURE.md Normal file
View File

@@ -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<WsConnectionState>
│ Stream<Uint8List>
DecoderLayer (isolate on native, inline on Web)
│ Stream<Envelope>
SessionController
├── PacketBuffer (ring, ~10 min @ 1 kHz)
├── LogBuffer (ring, ~5k entries)
├── ViewState (window, anchor, userPaused)
├── PpsCounter (sliding 1s arrival times)
├── Ticker (vsync) ──┬─► frameTick (ValueNotifier<int>)
│ └─► statusSnapshot (recomputed per frame)
└── logTick (ValueNotifier<int>, 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<Envelope>` 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<DateTime>` 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).