First commit
This commit is contained in:
213
ARCHITECTURE.md
Normal file
213
ARCHITECTURE.md
Normal 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).
|
||||
Reference in New Issue
Block a user