> ## Documentation Index
> Fetch the complete documentation index at: https://developer.neurofit.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API reference (iOS)

> Every public type in the NeurofitVitals Swift module, version 1.0.0.

Module `NeurofitVitals`, iOS 15 or later, Swift 5 language mode. Every public value type is `Sendable` and
`Equatable`. The SDK yields every event on the main thread, in order. A `for await` loop runs on its task's
executor, so iterate `events` from the main actor (for example a `Task` started inside a `@MainActor` type) to
handle events on the main thread.

The binary xcframework ships a textual Swift interface, so it needs **Xcode 27 or newer** (the Xcode the release
was built with). Building the SDK from source needs **Xcode 16 or newer**.

The API is deliberately small: `VitalsSDK` for setup, `VitalsSession` for one reading, eight events and the value
types they carry. There is no configuration. Every session runs the validated 60-second reading with the same
settings the NEUROFIT app uses. The [Android page](/api-reference-android) documents the same API in Kotlin.

## VitalsSDK

The static entry point. There is no instance.

```swift theme={null}
public enum VitalsSDK {
  /// This SDK's version, "1.0.0". Also stamped on every result as VitalsResult.sdkVersion.
  public static let version: String

  /// Verifies the key offline against the public key compiled into the SDK. Call it once, before creating
  /// a session. Throws VitalsError.licenseInvalid(reason:).
  public static func activate(licenseKey: String) throws

  /// Whether this device can run a reading. Does not open the camera.
  public static func deviceSupport() -> DeviceSupport

  /// Whether camera access is granted, without prompting.
  public static var hasCameraPermission: Bool { get }

  /// Prompts for camera access when it has not been asked yet. True when access is granted.
  public static func requestCameraPermission() async -> Bool
}
```

Notes:

* `activate` checks the key's `apps` against `Bundle.main.bundleIdentifier`. When the SDK is built from source in
  a Debug configuration, a key that does not list the bundle identifier is accepted, so you can run under a
  development identifier. The binary xcframework is built Release and always enforces the check. See
  [License keys](/license-keys).
* `requestCameraPermission()` shows the system prompt only the first time. After the user has answered, iOS
  returns the answer at once (`false` when access was declined or a device policy blocks the camera); send the
  user to Settings from there.
* You do not have to request permission yourself: `VitalsSession.start()` prompts when access has not been asked
  yet. The explicit call is for your own flow, such as a rationale screen before the system prompt.

## DeviceSupport

```swift theme={null}
public struct DeviceSupport: Sendable, Equatable {
  public let isSupported: Bool
  /// Why isSupported is false; nil when supported. A short English string for your logs.
  public let reason: String?
}
```

A device is supported when it has a rear wide camera whose capture formats reach a usable frame rate. A torch is
used when present but not required. The `reason` values are listed on [Device support](/device-support).

## VitalsSession

One session runs one reading. Create a new session for each reading screen. The class is `final` and
`@unchecked Sendable`; own it from one place.

```swift theme={null}
public final class VitalsSession: @unchecked Sendable {
  /// Throws .notActivated (activate has not succeeded) or .unsupportedDevice(reason:).
  public convenience init() throws

  /// Every event. The SDK yields every event on the main thread, in order. A `for await` loop runs on its
  /// task's executor, so iterate `events` from the main actor (for example a `Task` started inside a
  /// `@MainActor` type) to handle events on the main thread. One consumer per session. The stream ends
  /// after completed or failed, and on stop() (after the result when stopped while finalizing).
  public let events: AsyncStream<VitalsEvent>

  /// The camera preview for the host to place, if you want one. Available after init.
  public var previewLayer: AVCaptureVideoPreviewLayer? { get }

  /// While false, positioning continues (camera, torch and guidance) but no reading starts. Default true.
  public var isAutoStartEnabled: Bool { get set }

  /// Turns on the camera (and the torch, when the camera has one) and starts positioning.
  public func start() async throws

  /// Cancels the reading in progress with cancelled(.cancelledByApp). No-op unless measuring.
  public func cancelReading()

  /// Turns the camera and torch off and ends the session. Idempotent.
  public func stop()
}
```

`start()` rechecks activation and the device, prompts for camera access when it has not been asked yet, opens
the camera, turns the torch on and starts positioning. It throws:

| Error | When |
| - | - |
| `.notActivated` | Activation has not succeeded. |
| `.cameraPermissionDenied` | Camera access was declined (also at the prompt) or a device policy blocks it. |
| `.unsupportedDevice(reason:)` | `deviceSupport()` reports the device as unsupported. |
| `.cameraUnavailable` | The rear camera could not be configured or started. |
| `.internalError(message: "The session is already running")` | `start()` was already called on this session. |
| `.internalError(message: "The session has finished; create a new session")` | The session completed or failed. |
| `.internalError(message: "The session was stopped; create a new session")` | `stop()` was called. |

A failed `start()` leaves the session as it was, so you can call it again once the cause is fixed (after the user
grants camera access, say). The last two cases need a new session.

Behaviour:

* **Events.** `events` has one consumer. The SDK yields every event on the main thread, in order, never
  synchronously from your own call. A `for await` loop runs on its task's executor, so iterate `events` from the
  main actor (for example a `Task` started inside a `@MainActor` type) to handle events on the main thread.
* **Cancel.** `cancelReading()` applies while a reading is measuring: the session emits
  `.cancelled(.cancelledByApp)` and returns to positioning. While positioning there is nothing to cancel and the
  call does nothing.
* **Auto-start hold.** While `isAutoStartEnabled` is false the session keeps positioning, `guidance` keeps
  flowing and `startProgress` fills, but no reading starts. Setting it back to true starts the next reading as
  soon as the fingertip is steady. A reading already measuring is unaffected. It can be set from any thread, at
  any time, and belongs to that session only.
* **stop().** Final: a stopped session cannot be started again. While positioning or measuring, it ends the
  session without a result and the `events` stream finishes. Once `.measurementComplete` has arrived, `stop()`
  releases the camera and torch at once and the session still delivers `.completed` (or `.failed(.noHeartRate)`)
  before the stream finishes.
* **Releasing the session.** A session that has reached `.measurementComplete` keeps itself alive until it has
  delivered its result, even if you drop your reference without calling `stop()`. Keep your `events` loop
  running if you want that result. Releasing a session at any other moment ends it like `stop()`.
* **Interruptions.** Backgrounding, a phone call, Split View or Slide Over, another app using the camera, or a
  media-services reset never fails the session. A reading in progress is cancelled with `.cancelled(.interrupted)`,
  the session waits, and positioning resumes by itself when the cause clears. A wait the system controls (another
  app holding the camera, a call) has no time limit. If the capture cannot be restored after 10 seconds of
  recovery while the app is in the foreground, the session ends with `.failed(.cameraUnavailable)`. An
  interruption after `.measurementComplete` does not affect the result.
* **Camera health.** When the camera cannot hold its frame rate (Low Power Mode is the usual cause), the SDK
  adjusts the capture in place; a reading in progress is cancelled with `.cancelled(.interrupted)` and starts
  again, and positioning shows `.frameDrop` guidance until the rate recovers. A camera that stops delivering
  frames is restarted once (a reading in progress is cancelled with `.cancelled(.interrupted)`); a second stall
  ends the session with `.failed(.cameraUnavailable)`. A low frame rate never ends a session.
* Lock the reading screen's orientation while a session runs, and keep the screen on
  (`UIApplication.shared.isIdleTimerDisabled`).

## VitalsEvent

```swift theme={null}
public enum VitalsEvent: Sendable, Equatable {
  case guidance(Guidance, startProgress: Double)
  case started
  case preview(LivePreview)
  case heartbeat(ibiMs: Double?)
  case cancelled(CancelReason)
  case measurementComplete
  case completed(VitalsResult)
  case failed(VitalsError)
}
```

| Event | When |
| - | - |
| `.guidance(guidance, startProgress:)` | About once per second while positioning. `startProgress` (0 to 1) fills while the fingertip is steady; the reading starts on its own when it is full. |
| `.started` | A reading started. A `.preview` at progress 0 with no values follows at once. |
| `.preview(preview)` | About once per second while measuring. |
| `.heartbeat(ibiMs:)` | Once per detected beat while measuring. `ibiMs` is the interval since the previous beat, `nil` for the first. |
| `.cancelled(reason)` | The reading was abandoned. The session returns to positioning and guidance flows again. |
| `.measurementComplete` | The full 60 seconds were captured. The result follows within a few seconds. |
| `.completed(result)` | The result. The session has ended. |
| `.failed(error)` | The session ended without a result. |

[Reading lifecycle](/reading-lifecycle) describes the flow in detail, with suggested copy for each moment.

## Guidance

```swift theme={null}
public enum Guidance: String, Sendable, CaseIterable {
  case noContact      // no fingertip on the camera
  case lowQuality     // contact, but the signal is not yet steady
  case weakSignal     // contact, but the pulse is faint
  case compliant      // steady; startProgress is filling
  case frameDrop      // the camera is below its frame rate; the reading waits until it recovers
}
```

The raw values are the case names. The SDK ships no strings; map each case to your own localized copy.

## CancelReason

```swift theme={null}
public enum CancelReason: String, Sendable, CaseIterable {
  case contactLost      // the fingertip left the camera
  case poorSignal       // the signal was too poor to finish a reliable reading
  case interrupted      // the capture was interrupted or restarted; the session resumes on its own
  case cancelledByApp   // your app called cancelReading()
}
```

The raw values are the case names.

## LivePreview and SignalLevel

```swift theme={null}
public struct LivePreview: Sendable, Equatable {
  /// 0...1 of the reading's 60 seconds.
  public let progress: Double
  /// Provisional heart rate, beats per minute.
  public let heartRateBpm: Double?
  /// Provisional HRV (RMSSD), ms. Available from about 20 s into the reading.
  public let rmssdMs: Double?
  /// Ready to display; nil until the reading's first scored preview.
  public let signalLevel: SignalLevel?
}

public enum SignalLevel: String, Sendable, CaseIterable {
  case strong, good, fair, weak
}
```

The live values are provisional and for display only; store the final `VitalsResult`. `signalLevel` is smoothed
over the last few seconds and lowered ahead of a poor-signal cancel, so you can show it as it is. `LivePreview`
has no public initializer.

## VitalsResult, SignalQuality and BreathingRateConfidence

```swift theme={null}
public struct VitalsResult: Sendable, Equatable, Codable {
  public let id: UUID
  public let capturedAt: Date              // when the reading finished
  public let heartRateBpm: Double          // always present on a completed reading
  public let rmssdMs: Double?              // HRV (RMSSD), ms
  public let sdnnMs: Double?               // SDNN, ms
  public let baevskyStressIndex: Double?
  public let sd2sd1: Double?               // Poincaré SD2:SD1 ratio
  public let breathingRateBrpm: Double?    // breaths per minute
  public let breathingRateConfidence: BreathingRateConfidence?   // nil exactly when breathingRateBrpm is nil
  public let quality: SignalQuality
  public let sdkVersion: String            // the SDK that produced the reading

  /// For tests and for results restored from your own storage.
  public init(id: UUID, capturedAt: Date, heartRateBpm: Double, rmssdMs: Double?, sdnnMs: Double?,
              baevskyStressIndex: Double?, sd2sd1: Double?, breathingRateBrpm: Double?,
              breathingRateConfidence: BreathingRateConfidence?, quality: SignalQuality, sdkVersion: String)
}

public enum SignalQuality: Int, Sendable, Codable, CaseIterable {
  case clean = 3
  case usable = 2
  case withheld = 1
}

public enum BreathingRateConfidence: String, Sendable, Codable, CaseIterable {
  case high     // measured from the phone's motion
  case medium   // a strong camera estimate
  case low      // a weaker camera estimate; show it as approximate
}
```

`Codable` encodes exactly these eleven properties, under these names as keys. Absent optional values are left out,
`quality` is encoded as its raw value (3, 2 or 1), `breathingRateConfidence` as its raw value (`"high"`,
`"medium"` or `"low"`) and `capturedAt` follows your encoder's `dateEncodingStrategy`. Every value is finite.
Results the SDK produces have `breathingRateConfidence` exactly when they have `breathingRateBrpm`; the public
initializer stores what you pass. Field meanings, units and the quality and breathing-rate tiers are on
[Metrics](/metrics).

## VitalsError

```swift theme={null}
public enum VitalsError: Error, Sendable, Equatable, LocalizedError {
  case notActivated
  case licenseInvalid(reason: String)
  case cameraPermissionDenied
  case cameraUnavailable
  case unsupportedDevice(reason: String)
  case noHeartRate
  case internalError(message: String)
}
```

| Case | Meaning | Where it comes from |
| - | - | - |
| `.notActivated` | `activate(licenseKey:)` has not succeeded in this process. | `VitalsSession.init`, `start()` |
| `.licenseInvalid(reason:)` | The key was rejected. `reason` names the check that failed: the key is malformed or its signature does not verify, it does not cover this app's bundle identifier, or this SDK build is newer than the key's `updatesUntil`. | `activate(licenseKey:)` |
| `.cameraPermissionDenied` | Camera access was declined or is blocked by a device policy. | `start()` |
| `.cameraUnavailable` | The rear camera could not be opened, or it stopped and could not be restored. | `start()`, `.failed` |
| `.unsupportedDevice(reason:)` | The device cannot run a reading; `reason` says why. | `VitalsSession.init`, `start()` |
| `.noHeartRate` | The 60 seconds were captured but no heart rate could be recovered. | `.failed` |
| `.internalError(message:)` | A misuse, such as starting a session twice or after it ended, or an unexpected condition. | `start()`, `.failed` |

`errorDescription` gives a short English sentence for your logs, for example
`"The license key is not valid: <reason>."` Show your own localized copy to users.

## Threading

* `VitalsSDK` members, `VitalsSession.init`, `isAutoStartEnabled`, `cancelReading()` and `stop()` are safe from
  any thread.
* `start()` is `async` and may be awaited from any context; it suspends through the camera prompt and the
  camera's own start.
* The SDK yields every event on the main thread, in order. A `for await` loop runs on its task's executor, so
  iterate `events` from the main actor (for example a `Task` started inside a `@MainActor` type) to handle events
  on the main thread.
* Frame processing runs on the SDK's own queues. Nothing blocks the main thread.

## Package

Two library products over the same module:

* `NeurofitVitals`, a dynamic library. The binary xcframework is built from it.
* `NeurofitVitalsStatic`, linked statically into your app target. Use it when you build the source package into
  your app (source license).

Link one of them, not both. There are no third-party dependencies: the binary xcframework has the engine built
in, and the source package also needs the bundled `PpgCore` engine package (`ppgcore/ports/swift`) next to it. The
module bundles `PrivacyInfo.xcprivacy` (no tracking, no collected data types) and no other resources.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.