The session SDK runs a small, deterministic state machine. The same machine runs on the phone (DartDocumentation Index
Fetch the complete documentation index at: https://docs.synheart.ai/llms.txt
Use this file to discover all available pages before exploring further.
LiveSessionEngine, Kotlin SessionEngine, Swift SessionEngine) and on the watch (watchOS / Wear OS companions). The wire protocol’s SessionState is authoritative for cross-platform tooling.
States
Fromsynheart-session/protos/session.proto:
| State | Meaning | Allowed inputs |
|---|---|---|
IDLE | No active session. | StartSession |
STARTING | Provider warm-up: HealthKit HKWorkoutSession.startActivity, Health Services ExerciseClient.startExercise, BLE GATT subscribe, etc. | StopSession (cancels) |
ACTIVE | Frames flowing. seq increases on every emit. | StopSession, GetStatus |
STOPPING | Wind-down: drain final window, compute summary, persist. | (none) |
TERMINATED | Session ended; SessionSummary or SessionError already delivered. | (next StartSession) |
Phone-side state at the Dart layer
SynheartSession does not expose the proto enum directly. Instead it offers SessionStatus:
active = true corresponds to wire STARTING ∪ ACTIVE. The Dart layer collapses these because the engine emits SessionStarted only after the provider is hot — there is no public STARTING state for stream consumers.
The Kotlin and Swift SDKs match: their SessionStatus carries (sessionId, active, lastSeq).
Event-stream lifecycle
The DartStream<SessionEvent> lifecycle is fixed:
SessionSummary and SessionError close the controller automatically. The mock engine matches this contract.
Who drives transitions
| Transition | Driver |
|---|---|
IDLE → STARTING | SynheartSession.startSession(config) (phone). When WatchBiosignalProvider is in use, SessionChannel.startSession(config) also fires, sending StartSession over WatchConnectivity / Wearable Data Layer. |
STARTING → ACTIVE | Provider’s first sample is admitted. Engine emits SessionStarted then begins the periodic Timer.periodic(emitIntervalSec). |
| Engine timer tick | LiveSessionEngine checks elapsed >= durationSec first; if exceeded, transitions to STOPPING immediately and emits final summary. Otherwise emits SessionFrame. |
ACTIVE → STOPPING | Either SynheartSession.stopSession(id) (host call) or duration elapsed. The watch companion runs the same logic against its local timer. |
STOPPING → TERMINATED | Engine computes metrics, emits SessionSummary, closes controller. |
Any state → TERMINATED (error path) | Engine emits SessionError and closes; provider failures or panic surface here. |
Phone-watch dual lifecycle
WhenWatchBiosignalProvider is configured, two engines run simultaneously:
- Watch engine: hot path. Reads sensors directly (HealthKit / Health Services / Samsung), runs windowing on-device, sends typed
SessionFrame/BiosignalFrameover WatchConnectivity. - Phone engine (Dart
LiveSessionEngine): fed byWatchBiosignalProviderwhich pulls watch events throughSessionChannel.eventsand re-emits them asBiosignalSamples.
LiveSessionEngine re-windows the watch samples and emits its own SessionFrame events to the host. This double-windowing is intentional — it gives the host a single Stream<SessionEvent> regardless of source.
The phone subscribes via provider.startStreaming() before issuing SessionChannel.startSession(config), so the first SessionStarted event from the watch is not missed.
Fallback semantics
When the watch is unreachable but aWatchBiosignalProvider is configured, the SDK does not automatically fall back to a different provider. The phone-side engine still emits SessionStarted (the provider reports isAvailable = true optimistically) but the HR buffer stays empty until samples arrive. Frames will fire on schedule with zero sample_count and hr_mean_bpm = 0.0.
For real fallback, hosts must construct a different SynheartSession instance with a non-watch provider (e.g. MockBiosignalProvider, BLE HRM, HealthConnectBiosignalProvider).
The WatchBiosignalProvider does have a synthesis path: when only SessionFrame is forwarded (no raw samples), it derives a synthetic BiosignalSample from metrics.hr_mean_bpm with rrIntervalMs = 60000.0 / bpm. This keeps the phone engine’s buffer non-empty even when the watch isn’t streaming raw biosignals.
Reconnect behaviour
If the watch disconnects mid-session:- Watch engine continues running locally (it doesn’t observe the connectivity drop synchronously).
- Phone-side
WatchBiosignalProviderswallows channel errors as non-fatal. - Buffered events on the watch may flush when the channel comes back, depending on the OS transport’s queueing semantics.
getWatchStatus and stop manually.
Frame sequencing
seq is monotonic per session, starting at 1 for the first frame after SessionStarted. BiosignalFrame.seq and SessionFrame.seq are independent counters in the wire spec; the Dart SDK uses a single counter today.
emittedAtMs is wall-clock epoch ms at the producer (watch or phone). Hosts should treat watch and phone timestamps as on the same clock — both come from the OS — but cannot assume sub-second sync.
Disposal
SynheartSession.dispose():
- Disposes both mock and live engines.
- Closes all open
StreamControllers. - Sets the session as disposed.
SessionInvalidStateError("SynheartSession has been disposed").
The watch-side companion is not stopped on dispose() — only on explicit stopSession(id). If the host disposes mid-session without stopping, the watch keeps running until its own duration timer fires (worst case: durationSec seconds of orphaned battery use).
Related
- Providers — what feeds the engine.
- Watch Protocol — the proto envelope.
- Errors — the closed-stream paths.