Skip to main content
Each SDK runs a small timer-driven engine: Dart LiveSessionEngine and MockSessionEngine behind SynheartSession, and SynheartSession itself in Kotlin and Swift. None of them exposes a state enum; a session is either running or not, and getStatus() reports which.

Session status

getStatus() returns a SessionStatus while a session is running (active is always true) and null otherwise. Kotlin and Swift return the same shape (SessionStatus(sessionId, active, lastSeq)), and return null after dispose(). lastSeq is the last SessionFrame sequence number. Kotlin and Swift run one session at a time per SynheartSession. The Dart SynheartSession refuses only a second start with the same sessionId.

Event-stream lifecycle

How the stream ends:

Who drives transitions

Window

Kotlin and Swift keep every sample within windowSec of the newest sample. The Dart live engine keeps the last windowSec samples and uses those within windowSec of the current time. hr_mean_bpm is the mean of the window; HRV values come from ingestHsiMetrics().

Phone-watch lifecycle (Flutter)

When the provider is a WatchBiosignalProvider, two sessions run:
  • Watch session: the companion app reads the sensor and sends session events to the phone over WatchConnectivity / Wearable Data Layer.
  • Phone session (Dart LiveSessionEngine): fed by WatchBiosignalProvider, which turns the watch’s events into BiosignalSamples.
The host receives the phone engine’s events only. The watch’s own frames and summary are consumed as HR input and are not passed through, so this re-windowing gives the host a single Stream<SessionEvent> whatever the source. The phone subscribes to the provider inside startSession, before the start_session command is sent, so early watch events are not missed. In Kotlin, WatchSessionRelay works differently: no phone engine is involved, and the watch’s events are decoded and emitted as they arrive. In Swift there is no phone-side relay.

Fallback semantics

There is no automatic fallback to a different provider. When the watch is unreachable but a WatchBiosignalProvider is configured, the phone engine still emits SessionStarted (the provider reports isAvailable = true optimistically), and the start command is not sent. The HR buffer stays empty, so frames fire on schedule with sample_count 0 and hr_mean_bpm 0.0. For a different source, construct another SynheartSession with a different BiosignalProvider (for example MockBiosignalProvider, or your own provider over a BLE HRM). WatchBiosignalProvider has a synthesis path: when the watch sends only SessionFrame events (no raw samples), it derives one BiosignalSample per frame from metrics.hr_mean_bpm, with rrIntervalMs = 60000.0 / bpm. Frames with hr_mean_bpm 0 or missing produce no sample.

Reconnect behaviour

If the watch disconnects mid-session:
  • WatchBiosignalProvider swallows channel errors as non-fatal.
  • The phone session keeps running and emits frames from whatever samples arrive.
  • The session does not auto-stop. Hosts that want strict reachability must poll getWatchStatus() and stop manually.

Frame sequencing

SessionFrame.seq starts at 1. BiosignalFrame.seq is a separate counter, also starting at 1, in Kotlin, Swift, and the Dart mock engine. emittedAtMs is wall-clock epoch ms at the producer. Treat watch and phone timestamps as the same clock, but don’t assume sub-second sync.

Disposal

In Flutter, dispose() does not send stop_session to the watch — only stopSession(id) does. If the host disposes mid-session without stopping, the watch keeps running until its own duration timer ends the session.