Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.synheart.ai/llms.txt

Use this file to discover all available pages before exploring further.

The session SDK runs a small, deterministic state machine. The same machine runs on the phone (Dart 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

From synheart-session/protos/session.proto:
SESSION_STATE_IDLE       → SESSION_STATE_STARTING
SESSION_STATE_STARTING   → SESSION_STATE_ACTIVE       (or → TERMINATED on error)
SESSION_STATE_ACTIVE     → SESSION_STATE_STOPPING
SESSION_STATE_STOPPING   → SESSION_STATE_TERMINATED
SESSION_STATE_TERMINATED → SESSION_STATE_IDLE          (next session)
StateMeaningAllowed inputs
IDLENo active session.StartSession
STARTINGProvider warm-up: HealthKit HKWorkoutSession.startActivity, Health Services ExerciseClient.startExercise, BLE GATT subscribe, etc.StopSession (cancels)
ACTIVEFrames flowing. seq increases on every emit.StopSession, GetStatus
STOPPINGWind-down: drain final window, compute summary, persist.(none)
TERMINATEDSession 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:
class SessionStatus {
  final String sessionId;
  final bool active;     // true between SessionStarted and SessionSummary/Error
  final int lastSeq;
}
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 Dart Stream<SessionEvent> lifecycle is fixed:
SessionStarted
  → SessionFrame (seq=1) → SessionFrame (seq=2) → ...
    [optionally interleaved BiosignalFrame events when includeRawSamples]
  → SessionSummary           (closes the stream)
        OR
  → SessionError             (closes the stream)
SessionSummary and SessionError close the controller automatically. The mock engine matches this contract.

Who drives transitions

TransitionDriver
IDLE → STARTINGSynheartSession.startSession(config) (phone). When WatchBiosignalProvider is in use, SessionChannel.startSession(config) also fires, sending StartSession over WatchConnectivity / Wearable Data Layer.
STARTING → ACTIVEProvider’s first sample is admitted. Engine emits SessionStarted then begins the periodic Timer.periodic(emitIntervalSec).
Engine timer tickLiveSessionEngine checks elapsed >= durationSec first; if exceeded, transitions to STOPPING immediately and emits final summary. Otherwise emits SessionFrame.
ACTIVE → STOPPINGEither SynheartSession.stopSession(id) (host call) or duration elapsed. The watch companion runs the same logic against its local timer.
STOPPING → TERMINATEDEngine 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

When WatchBiosignalProvider is configured, two engines run simultaneously:
  • Watch engine: hot path. Reads sensors directly (HealthKit / Health Services / Samsung), runs windowing on-device, sends typed SessionFrame / BiosignalFrame over WatchConnectivity.
  • Phone engine (Dart LiveSessionEngine): fed by WatchBiosignalProvider which pulls watch events through SessionChannel.events and re-emits them as BiosignalSamples.
The phone-side 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 a WatchBiosignalProvider 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 WatchBiosignalProvider swallows 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.
The session does not auto-stop on disconnect. Hosts that want strict reachability behavior must call 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():
  1. Disposes both mock and live engines.
  2. Closes all open StreamControllers.
  3. Sets the session as disposed.
After dispose, every public method throws 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).