213 lines
8.4 KiB
Markdown
213 lines
8.4 KiB
Markdown
# 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). |