Blood Pressure measurement
Only available for 287-2 bracelet
The Corsano bracelet computes a cuffless (NIBP) blood-pressure estimate. Before those readings are trustworthy, the bracelet's NIBP model has to be calibrated against a reference cuff — the user takes two (sometimes three) measurements with a real BP cuff, and the SDK feeds those readings to the bracelet so the firmware can fit its model.
The whole calibration workflow (instructions → measure → record → retry → success/failure) is bundled as a self-contained SwiftUI view, BPInitializationView, exactly like WearingOptimizationView. Host apps present the view, hand it a source of cuff readings, and don't touch the bracelet's NIBP plan directly.
Integration: BPInitializationView
The SDK owns the bracelet side of calibration: switching the bracelet into its NIBP-calibration plan, feeding each cuff reading, reading the firmware's accept/reject verdict, and restoring the normal monitoring plan when the flow ends. What the SDK does not own is the BP cuff itself — the cuff's BLE link belongs to the host app. You supply readings through a cuffProvider closure and (optionally) report pairing state through cuffIsPaired.
See the sample app for the live integration (the Settings screen presents the sheet and handles the result).
Quickstart
The minimal integration is a button that toggles a sheet presenting BPInitializationView, plus a cuffProvider that returns readings from your cuff integration:
import SwiftUI
import CorsanoSDK
struct ContentView: View {
@State private var showBPInit = false
@State private var lastResult: BPInitializationResult?
var body: some View {
VStack {
Button("Calibrate NIBP") { showBPInit = true }
if let lastResult { Text(describe(lastResult)) }
}
.sheet(isPresented: $showBPInit) {
BPInitializationView(
cuffProvider: {
// Take one measurement with the host-owned BP cuff.
// Return `nil` if no reading could be obtained.
await myCuffController.takeMeasurement()
},
onCompleted: { result in
lastResult = result
showBPInit = false
}
)
}
}
private func describe(_ result: BPInitializationResult) -> String {
switch result {
case .success(let n): return "Calibrated (\(n) measurements)"
case .cancelledByUser: return "Cancelled"
case .braceletDisconnected: return "Bracelet disconnected"
case .cuffError(let e): return "Cuff error: \(e)"
case .failedAfterRetries(let n): return "Not calibrated after \(n) attempt(s)"
}
}
}
That's the entire host-side wiring. Every screen, the bracelet driving (plan switch, feeding readings, reading the verdict, plan restore), the two/three-measurement rule, the retry budget, and the preparation substeps all live inside the SDK.
Public initializer
public init(
config: BPInitializationConfig = .default,
delegate: BPInitializationDelegate? = nil,
cuffProvider: @escaping @MainActor () async -> CuffReading?,
cuffIsPaired: @escaping @MainActor () -> Bool = { true },
onCuffPairingRequested: (@MainActor () -> Void)? = nil,
onCompleted: @escaping (BPInitializationResult) -> Void
)
config— branding (colors / logo), the deviation threshold, retry budget, and the preparation toggle. Defaults to a neutral blue accent,deviationThresholdMmHg = 10,maxAttempts = 3,includePreparation = true.delegate— optional telemetry hooks (start, per-step funnel, per-measurement, per-attempt, terminal). All methods have no-op default implementations, so you implement only the ones you care about.cuffProvider— required. The host-supplied source of cuff readings. Called once per measurement; it's anasyncclosure so you can drive a real cuff BLE exchange. Returnnilwhen no reading could be obtained — the flow then ends in.cuffError.cuffIsPaired— host check for whether a BP cuff is paired. Defaults to{ true }. When it returnsfalse, the flow opens on the needs-cuff-pairing screen instead of the instructions.onCuffPairingRequested— optional hook fired when the user taps "Pair a cuff" on the needs-cuff-pairing screen (present your own cuff-pairing UI here).onCompleted— required terminal callback. Carries the same value the delegate'sbpInitializationDidFinish(_:)receives.
The view hands control back to the host once the terminal screen is acknowledged — your onCompleted closure is the right place to dismiss the presenting sheet.
Configuration
public struct BPInitializationConfig: Sendable {
public var branding: Branding
public var deviationThresholdMmHg: Int // default 10
public var maxAttempts: Int // default 3
public var includePreparation: Bool // default true
public var maxReadingAge: TimeInterval? // default 900 (15 min); nil disables
public struct Branding: Sendable {
public var accentColor: Color // buttons, links, progress
public var successColor: Color // success seal + success CTA (default green)
public var errorColor: Color // warning icon + error CTA (default orange)
public var logo: Image? // optional mark above the title
}
}
Branding example:
let config = BPInitializationConfig(
branding: .init(
accentColor: Color("BrandBlue"),
logo: Image("BrandLogo")
),
deviationThresholdMmHg: 10,
maxAttempts: 3,
includePreparation: true
)
BPInitializationView(config: config, cuffProvider: { … }) { result in … }
Notes:
- The two-measurement rule. A calibration attempt always takes 2 cuff measurements. A 3rd is taken only when the first two readings differ by more than
deviationThresholdMmHgon systolic or diastolic. The default10matches the Corsano patient app. maxAttempts = 1disables the retry-prompt UX entirely — a first unaccepted session is terminal.includePreparation = falseskips the preparation checklist (cuff fit, arm position, sit-and-rest) and opens directly on the instructions screen. Useful when the host already gathers that guidance elsewhere.maxReadingAgerejects a cuff reading whosemeasuredAtis further from now than this, in either direction — see Replayed and stale readings. Only applies when you populateCuffReading.measuredAt; a reading with no stamp is always accepted.- Strings are not host-overridable, but the SDK now ships its own translations — see Localization.
Localization
The flow ships translated into the 17 CardioMood locales: ar, da, de, el, en, es, fi, fr, he, it, ka, nb, nl, nl-BE, pl, pt-PT, sv.
:::warning Your app must advertise the languages
iOS picks a process language from the languages the app advertises, not from what its embedded frameworks contain. An app that advertises only English runs entirely in English, and the SDK's fr.lproj, ar.lproj … are never consulted — even on a device set to that language.
Add CFBundleLocalizations to your app's Info.plist:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>da</string>
<string>de</string>
<string>el</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>it</string>
<string>ka</string>
<string>nb</string>
<string>nl</string>
<string>nl-BE</string>
<string>pl</string>
<string>pt-PT</string>
<string>sv</string>
</array>
List only the languages your app supports — the SDK falls back to English for anything it doesn't have. Without this key the flow renders in English regardless of device language, which is easily mistaken for an SDK bug. :::
Strings live in the SDK bundle under <lang>.lproj/BPInitialization.strings and are resolved against the SDK's own bundle, so they cannot collide with your app's Localizable.strings.
Result handling
public enum BPInitializationResult {
case success(measurementCount: Int)
case cancelledByUser
case braceletDisconnected
case cuffError(CuffError)
case failedAfterRetries(attemptCount: Int)
}
.success(measurementCount:)— the firmware accepted the calibration.measurementCountis how many cuff measurements were taken in the successful session (2 or 3)..cancelledByUser— the user dismissed the flow before it concluded: the Cancel button, swipe-to-dismiss, or closing the summary while retry attempts remained..braceletDisconnected— the BLE link to the bracelet dropped mid-flow. Terminal, and distinct from a per-attempt failure..cuffError(CuffError)— the hostcuffProviderreturnednil, or the reading it returned was rejected as stale. See Replayed and stale readings..failedAfterRetries(attemptCount:)— every attempt in themaxAttemptsbudget ran and none was accepted by the firmware.
:::info Distinguishing "gave up" from "could not calibrate"
.failedAfterRetries means the device could not be calibrated within its attempt budget — a clinical signal a host may act on. A user who closes the summary with attempts still available reports .cancelledByUser instead. Earlier builds reported .failedAfterRetries for both, so a single abandoned attempt looked like a device that had exhausted three.
:::
The cuff reading you supply
cuffProvider returns a CuffReading (or nil):
public struct CuffReading: Sendable {
public var systolic: Int
public var diastolic: Int
public var pulse: Int
/// Cuff-reported MAP. When nil, the SDK derives it as
/// `diastolic + (systolic - diastolic) / 3`.
public var meanArterialPressure: Int?
/// When the cuff took this reading, in REAL wall-clock time. Optional.
public var measuredAt: Date?
/// true when a person typed these numbers in rather than the SDK
/// receiving them from a cuff over BLE. Never age-checked.
public var isManualEntry: Bool
public init(systolic: Int, diastolic: Int, pulse: Int,
meanArterialPressure: Int? = nil,
measuredAt: Date? = nil,
isManualEntry: Bool = false)
/// How long ago this reading was taken, or nil when `measuredAt` is absent.
public func age(asOf now: Date = Date()) -> TimeInterval?
}
public enum CuffError {
case noReading
case cuffDisconnected
case staleReading(age: TimeInterval) // negative age = dated in the future
case unknown(String) // forward-compatibility escape hatch
}
The SDK owns neither the cuff's BLE link nor its readings — it only feeds the values you return into the bracelet and consumes the firmware's quality verdict. The unknown(String) case lets hosts compiled against an older SDK header keep working when new categories are added.
Replayed and stale readings
BP cuffs replay records they have already handed over. When a cuff connects it dumps its stored history before (or alongside) the measurement just taken, so a single cuffProvider call can see several readings in one burst — most of them old.
Calibrating a bracelet against a replayed record is worse than not calibrating: the reading may belong to an earlier session, or to a different person.
What the SDK does
If you populate measuredAt, the SDK refuses a reading whose timestamp is further from now than maxReadingAge, in either direction, and ends the flow with .cuffError(.staleReading(age:)).
age is negative when the record is dated in the future. That is not hypothetical — a cuff was observed replaying a record stamped three years ahead, because its clock was corrupt. A one-sided age > max check lets those straight through.
Hand-entered readings (isManualEntry: true) are never age-checked; there is no cuff clock behind them.
What your app must do
The SDK can only reject what you hand it. Filtering replays is the host's responsibility — it owns the cuff link and is the only side that knows which reading belongs to which request. Three rules, learned from the field:
1. Pass real wall-clock time in measuredAt, never the raw cuff stamp.
Cuff clocks drift badly — one was a full year out, stamping records 2025-01-01 in 2026. Passing that verbatim makes maxReadingAge reject every reading. If your cuff SDK reports the device clock on connect (Transtek exposes deviceClockRead(_:phoneDate:uuid:)), store the pair and convert:
// captured once per connection
let anchor = (device: deviceDate, phone: phoneDate)
// per record
let realTime = anchor.phone
.addingTimeInterval(record.date.timeIntervalSince(anchor.device))
This is correct whatever year the cuff believes it is in, because the record and the anchor come from the same clock. If you instead set the cuff's clock at connect (Bluetooth SIG Current Time Service, 0x1805 / 0x2A2B), the raw stamp is already real time and can be used directly.
2. Ignore readings measured before you asked for this one.
Record when you requested the measurement and drop anything stamped materially earlier — otherwise the cuff's stored history satisfies the request before the user has even pressed START.
Allow at least 90 s of backdating: cuff timestamps typically have minute resolution, so a measurement finishing at 12:15:39 is legitimately stamped 12:15:00. Drop the reading and keep waiting rather than resolving the request, so the live measurement can still arrive.
3. Never return the same record twice.
Remember each reading you hand back, keyed on timestamp AND all three values, and refuse a repeat. This is the only rule that separates measurement 1 being replayed during measurement 2 from a genuine second measurement: with minute-resolution stamps, two readings a minute apart carry the same timestamp, so no time-based rule can tell them apart.
Key on values as well as time, never time alone — two genuinely different measurements can share one minute, and discarding the second is worse than the bug it fixes.
:::tip Resume scanning if the cuff drops The cuff typically connects, dumps its history, and disconnects — all before the user presses START. If your provider stops scanning at that point, the real measurement has nowhere to arrive and the request sits until it times out. Restart scanning on disconnect while a request is still pending.
Size your provider's timeout for the whole human sequence — reading the screen, fitting the cuff, pressing START, plus ~40 s of measurement. Returning nil ends the entire calibration with .cuffError(.noReading); it does not skip one measurement.
:::
Optional telemetry — BPInitializationDelegate
If you want funnel attribution, implement this protocol on a class-typed observer and pass it as the delegate argument:
@MainActor
public protocol BPInitializationDelegate: AnyObject {
func bpInitializationDidStart()
func bpInitialization(didEnterStep step: BPInitializationStep)
func bpInitialization(didFinishMeasurement index: Int, braceletAccepted: Bool)
func bpInitialization(didFinishAttempt attemptIndex: Int, succeeded: Bool)
func bpInitialization(didCollectReadings readings: [CuffReading])
func bpInitializationDidFinish(_ result: BPInitializationResult)
}
didCollectReadings hands you the cuff readings the flow actually used, in order, with their measuredAt stamps and isManualEntry flags — so you can record what the bracelet was calibrated against. It fires immediately before bpInitializationDidFinish, on every terminal outcome, not only success: a failed calibration is exactly when knowing what was fed to the bracelet matters. Empty when the flow ended before any reading was taken.
All six hooks have default no-op implementations — implement just the ones you need. index (per measurement, within a session) and attemptIndex (per calibration session) are both 1-based.
Step values:
public enum BPInitializationStep {
case needsCuffPairing // no cuff paired (entry only; never returned to)
case instructions // how to position the cuff, what to expect
case measuring // requesting cuff readings + feeding the bracelet
case measurementRecorded // per-measurement sys/dia/pulse, auto-advances
case retryPrompt // session not accepted; retry CTA (budget remaining)
case finalSuccess // terminal success screen
case finalFailure // terminal failure screen (budget exhausted)
}
The terminal bpInitializationDidFinish(_:) callback receives the same BPInitializationResult value as the onCompleted closure — listen on whichever channel fits your code best.
Flow reference
The bundled flow walks through the same screens regardless of host:
- Needs cuff pairing (only if
cuffIsPaired()returnsfalse) — prompts the user to pair a cuff; "Pair a cuff" firesonCuffPairingRequested. Entry step only; the flow never returns here. - Instructions — how to fit the cuff and what to expect. With
includePreparation == truethis includes the preparation checklist (cuff fit, arm position, sit and rest). The user taps Start to begin the first calibration session. - Measuring — "Press START on the BP cuff monitor". For each measurement the SDK calls your
cuffProvider, feeds the returned reading to the bracelet, and reads the firmware verdict. Two measurements per session; a third only if the first two deviate by more thandeviationThresholdMmHg. - Measurement recorded — shows the just-captured sys/dia/pulse and auto-advances to the next measurement (or the terminal screen). A firmware-rejected reading is shown here exactly like an accepted one — no mid-sequence warning, matching the patient app.
- Retry prompt — shown when a session was not accepted and the attempt budget is not yet exhausted.
- Calibration complete — terminal success screen; the bracelet's NIBP model is calibrated and its normal monitoring plan restored. Lists each measurement with a per-measurement quality dot.
- Calibration summary — terminal screen after the retry budget is exhausted; shows the same summary (never an alarming warning) and simply ends the flow.
Requirements
- iOS 15+.
- Bracelet must be paired and connected before presenting the view — standard SDK pairing applies (see the pairing section in the overview).
- The user profile should be populated (height / weight / wrist / gender / metric unit / birthdate) and the bracelet plan set before calibration; the SDK switches to the NIBP-calibration plan for the duration of the flow and restores the previous plan when it ends.
- A BP cuff integration on the host side. The SDK does not connect to the cuff — you provide readings through
cuffProvider, and (optionally) surface pairing state throughcuffIsPaired/onCuffPairingRequested.
Listening for the terminal result via the delegate
final class BPInitTelemetry: NSObject, BPInitializationDelegate {
func bpInitializationDidStart() {
Analytics.track("bpinit_start")
}
func bpInitialization(didEnterStep step: BPInitializationStep) {
Analytics.track("bpinit_step", properties: ["step": "\(step)"])
}
func bpInitialization(didFinishMeasurement index: Int, braceletAccepted: Bool) {
Analytics.track("bpinit_measurement",
properties: ["index": index, "accepted": braceletAccepted])
}
func bpInitialization(didFinishAttempt attemptIndex: Int, succeeded: Bool) {
Analytics.track("bpinit_attempt",
properties: ["n": attemptIndex, "succeeded": succeeded])
}
func bpInitializationDidFinish(_ result: BPInitializationResult) {
Analytics.track("bpinit_finish", properties: ["result": "\(result)"])
}
}
// Hold the observer for the lifetime of the screen:
@StateObject private var telemetry = BPInitTelemetry()
BPInitializationView(config: .default,
delegate: telemetry,
cuffProvider: { … }) { result in … }