Embedded HandbookApps
All sections › XXIII. The device's companion app: Dart and Flutter
STM32F103, STM32F407, STM32G431, STM32L431 · level intermediate · 6 min read
Open in the interactive handbook

186. Asynchrony: frame stream, timeouts, reconnection, isolate

A device sends bytes whenever it likes, does not always answer and disconnects at the worst possible moment. The app must still neither hang nor lie. This section is about the four things that make this possible: the stream, the timeout, the retry and the isolate.

Everything is tested by a run: the package _verify/dart186 and seven tests in dart test, plus a parsing benchmark on 200000 frames.

Terms used in the documentation

Term Meaning
Future, async/await a future value; waiting without blocking
Stream, async* , yield a stream of values; a generator function
broadcast stream a stream with several listeners
backpressure holding back: the consumer is slower than the source
timeout the time limit for waiting
exponential backoff a retry with a growing pause
reconnect connecting again
isolate a separate thread with its own memory
event loop the loop that dispatches events

186.1 A byte stream becomes a frame stream

A port, a socket and BLE deliver bytes in chunks of arbitrary length: a frame may arrive in parts, or two may arrive at once. The parser from section 185 already knows how to survive this — all that remains is to wrap it in a stream:

Stream<FrameEvent> frames(Stream<List<int>> bytes) async* {
  final parser = FrameParser();
  await for (final chunk in bytes) {
    for (final b in chunk) {
      final e = parser.feed(b);
      if (e is! FrameNone) yield e;
    }
  }
}

Three lines give exactly what is needed: the state lives between chunks, and only events go out. Tested with both extreme cases: a frame cut into three pieces is assembled into one; two frames in one chunk give two events.

The byte source is not named: it may be a serial port, a socket or a BLE characteristic — more on them later in this part. Parsing does not depend on it — and so it is tested without the device.

Bytes from the port become app events


186.2 An error is an event, not an exception

A broken CRC and garbage on the wire are not an exceptional situation but a normal mode of operation: noise, a disconnect, a device restart. If you throw an exception, the stream closes and the link has to be brought up again. So the parser delivers FrameError as an event, and the stream lives on:

What we fed in What we got
garbage, a frame with a broken CRC and a correct frame one error event and one frame

Exceptions are left for what really is an exception: the port disappeared, a permission was not granted, the device was unplugged.


186.3 Request-response: timeout and retry with a growing pause

A command without a timeout is a hung app. A timeout is set always:

final answer = _events.firstWhere((e) => e is FrameReady).timeout(timeout);
sent++;
_out(encodeFrame(payload));

The subscription is made before sending: otherwise a fast answer manages to arrive before anyone started waiting for it — a classic race that reproduces once in a hundred runs and takes a week to debug.

The second condition is that the answer is recognised by its number. firstWhere((e) => e is FrameReady) takes the first frame that comes along: in the experiment the device answered every request, but after the timeout, and the second attempt counted the answer to the first as its own — 2 sent, the answer from attempt 1. Any telemetry frame will also "answer" any command. So every request carries a counter, the device returns it in the answer, and the app waits for firstWhere((e) => e is FrameReady && seqOf(e) == seq), where seqOf extracts the number from the frame. And the event stream must be a broadcast stream — in Link it is made so by events.asBroadcastStream(): with an ordinary stream the second firstWhere on a retry throws "Bad state: Stream has already been listened to".

Retries go with a growing pause: 1, 2, 4 timeouts. In the test the device answers only on the third attempt:

What we count Value
requests sent 3
retries 2
answer received yes

The growing pause is not about politeness: if the device is rebooting, frequent retries just eat its input buffer and keep it from coming up. In ten seconds of silence an app with a constant pause of 20 ms sends 500 requests, and one that doubles up to a ceiling of 2 s sends 10.

Retry with a growing pause: the pause and the number of requests to a silent device


186.4 Reconnection: link state as part of the protocol

A dropped link is not a program error but an event. A frame stream that survives it looks like this: open the source, deliver frames, on close or failure report offline, wait and open again — the pause doubles up to a limit.

yield LinkState.connecting;
try {
  final source = await open();
  yield LinkState.online;
  wait = pause;                 // связь была — пауза снова короткая
  yield* frames(source);
  yield LinkState.offline;
} catch (_) {
  yield LinkState.offline;
}

In the test the first attempt to open fails and the second succeeds; out goes exactly the sequence of states the screen expects: connecting → offline → connecting → online → offline, with a parsed frame between them.

Resetting the pause after a successful connection is mandatory, otherwise after one bad day the app will wait two seconds at a time even with a live device.


186.5 Isolate: parsing must not touch the screen

Dart is single-threaded: while your code is computing, nothing is drawn. How much this matters shows in a benchmark — 200000 frames, 2200000 bytes:

What we count Value
frames parsed 200000
parsing speed more than 1000000 frames per second
ticks of a 1 ms timer during parsing in the main thread no more than 2
ticks during parsing in an isolate more than 10

Parsing itself is fast — it is not the bottleneck. But even these tens of milliseconds are visible to the eye: while parsing runs in the main thread, a 1 ms timer almost never ticks, which means an animation frame is skipped. The same parsing in an isolate does not touch the main thread:

final int parsed = await Isolate.run(() => parseAll(bytes));

The web has no isolates: Isolate.run does not build there, and compute runs the work in the same thread. There the batch is cut into pieces and control is given back between them: await Future<void>.delayed(Duration.zero).

The rule is simple: what goes into an isolate is work done on a batch — parsing a log, thinning a chart, verifying a firmware update. The stream of live frames from the device is usually left in the main thread: passing data between isolates is not free either.


186.6 Pitfalls

No timeout. The device is silent — the app waits forever, the button does not respond, the user restarts.

Subscribing after sending. A fast answer is lost, and the request goes into a timeout exactly when everything works.

A retry counted someone else's answer. The answer is awaited by frame type, not by request number: a late answer to the first attempt goes to the second one.

Retries without a growing pause. A rebooting device gets a flood of requests and does not manage to come up.

The pause is not reset after success. The link has long been up, but the app still thinks for two seconds.

A parse error breaks the stream. One burst of noise — and the link is "lost" until the app is restarted.

Heavy parsing in the main thread. The screen freezes for tens of milliseconds; on a weak phone, for hundreds.


186.7 Summary

Next: section 187 "A device screen in Flutter: widgets, state, commands, tests".

Open in the interactive handbook