OYMotion Synchroni SDK for iOS / macOS
Synchroni SDK is the software development kit for developers to access OYMotion
Synchroni products on Apple platforms. It ships as a prebuilt static
sensor.xcframework (slices: iOS arm64, iOS Simulator arm64, macOS arm64) with
an Objective-C API that Swift can use directly.
A full SwiftUI demo app (example_swift/, one target for macOS + iPhone/iPad)
is included.
- Add
lib/sensor.xcframeworkto your Xcode target (General -> Frameworks; it is a static framework, so "Do Not Embed" is correct). - Link
CoreBluetooth.frameworkand the C++ runtime (-lc++). - Import the SDK:
import sensor // Swift#import <sensor/sensorobjc.h> // Objective-CAll public types (SensorController / SensorProfile / SensorData /
Sample / DeviceInfo / BLEDevice / BinFileInfo / SensorAlgo) come
from the framework itself.
Add NSBluetoothAlwaysUsageDescription to the app's Info.plist; iOS/macOS
requests Bluetooth permission at first use. To keep streaming in the
background on iOS, also enable the bluetooth-central background mode.
When the app goes to the background, tell the SDK so pending log/capture records are flushed to disk (streaming keeps running; stopping it is the app's decision):
SensorController.getInstance().onSuspend()let controller = SensorController.getInstance()
controller.delegate = self // SensorControllerDelegate
// SensorControllerDelegate:
// onScanResult(_ bleDevices: [BLEDevice]) — device list updates while scanning
// onEnableChanged(_ enabled: Bool) — Bluetooth power state changesUse getVersion() for the SDK version string and capiVersion() to detect a
wrapper/library mismatch at runtime.
let ok = controller.startScan(6.0) // result push period in secondscontroller.stopScan()controller.isScanning
controller.isEnable// Creates and registers a profile (also works for devices not discovered by
// the scanner):
let profile = controller.requireSensor(bleDevice.mac)
// nil when the MAC is unknown:
let profile = controller.getSensor(bleDevice.mac)
let all = controller.getSensors()
let connected = controller.getConnectedSensors() // link Connected or ReadySensorController.terminate()Destroys the shared controller and tears the whole SDK down; every profile handle is invalidated afterwards. Call once at application shutdown; repeated calls are safe.
profile.delegate = self // SensorProfileDelegate
// - onData(_:dataList:) — ALL batches accumulated since the last
// callback, in one call
// - onStateChanged(_:newState:) — BLEState transitions
// - onError(_:err:) — errors (link loss, reconnect budget
// exhausted, ...)
// - onPowerChanged(_:power:) — battery pushes (optional)
// - onDeviceInfoUpdate(_:info:) — DeviceInfo changed in place (optional)
// - onDataTransferStateChange(_:isTransferring:) — stream on/off (optional)
// - onAutoReconnect(_:hasLastSession:answer:) — gate/customize session
// recovery (optional)Callback threading model: delegate calls and completion blocks fire on
internal SDK threads — hop to the main queue for UI work. onAutoReconnect
answers asynchronously: invoke the passed answer block exactly once
(answer(true) = the app takes over recovery, answer(false) = SDK default
recovery), from any thread, at any later time; no answer within ~10 s falls
back to the SDK default recovery.
Both are completion-block async (a nil completion = fire-and-forget; a non-nil completion fires exactly once with the final result):
profile.connect { success, err in ... }
profile.disconnect { success, err in ... } // stops the stream first when one is runningprofile.deviceState // BLEState: Disconnected/Connecting/Connected/Ready/...
profile.isReady // shortcut for deviceState == BLEStateReadySend commands only in the Ready state.
profile.init(32, timeout: 5.0, powerRefreshInterval: 60.0) { success, err in ... }
// packageCount: batch size in samples per channel
// powerRefreshInterval: battery polling period in seconds; 0 disables polling
// (one initial reading is still pushed)
profile.hasInitedprofile.startDataNotification(5.0) { success, err in ... }
profile.stopDataNotification(5.0) { success, err in ... }
profile.isDataTransferinglet info = profile.deviceInfo // cached during init / fetchDeviceInfo, no GATT traffic
profile.fetchDeviceInfo(5.0) { info, err in ... }DeviceInfo carries the device name / model / hardware / firmware versions,
the MTU size, a channel-count / sample-rate pair per modality (EEG, ECG, EMG,
BRTH, ACC, Gyro, PPG, SpO2, Euler, Quat, MagAngle, Impe, IMU), the
device-reported max sample rates (EmgMaxSampleRate / EegMaxSampleRate /
EcgMaxSampleRate, 0 = not reported) and the negotiated BLE link parameters
(ConnectionIntervalMs / PeripheralLatency / SupervisionTimeoutMs,
0 / -1 / 0 = unknown).
profile.getBatteryLevel(5.0) { battery, err in ... }
// battery: 0...100; a fresh GATT query every time (may report failure)profile.setAutoReconnect(true) // default onWhile enabled and the device is streaming, an abnormal disconnect is followed
by automatic reconnect -> init with the previous arguments -> re-applying
the previous session's setParam values -> startDataNotification. Call
answer(true) from onAutoReconnect(_:hasLastSession:answer:) to take over
recovery yourself.
Data types (NotifyDataType):
// NTF_ACC = 1, NTF_GYRO = 2, NTF_EULER = 4, NTF_QUATERNION = 5,
// NTF_GEST = 7, NTF_EMG = 8, NTF_MAG_ANGLE = 13, NTF_EEG = 16,
// NTF_ECG = 17, NTF_IMPEDANCE = 18, NTF_IMU = 19 (aggregated IMU batch:
// acc 0-2 / gyro 3-5 / euler 6-8 / quat 9-12, new-EMG devices only),
// NTF_ADS = 20, NTF_BRTH = 21, NTF_IMPEDANCE_EXT = 22, NTF_SPO2 = 23,
// NTF_PPG = 24Each SensorData is one batch of a single stream:
func onData(_ profile: SensorProfile, dataList: [SensorData]) {
for data in dataList {
guard data.isDataValid() else { continue } // one probe per batch
switch data.dataType {
case NTF_EEG, NTF_ECG:
for ch in 0 ..< Int(data.channelCount) {
guard data.isChannelEnabled(atChannel: ch) else { continue }
for i in 0 ..< Int(data.sampleCount) {
let v = data.getData(atChannel: ch, index: i)
_ = v
}
}
default:
break
}
}
}- Metadata:
deviceMac/deviceName/dataType/lostPackageCount/sampleRate/channelCount/channelMask/sampleCount/startSampleIndex/startTimeStamp/delay/startTimeSec. - Single-point accessors:
getData/getRawData/getImpedance/getSaturation/getSampleIndex/getTimeStampInMs/getAbsTimeStampInSec/isLost(allatChannel:index:) andgetChannelSample(atChannel:index:)materializing aSample. The accessors return zero/nil on out-of-range or stale slots. - A
SensorDatadelivered to the callback borrows SDK memory: the content stays valid while the stream runs (a rewritten slot is caught byisDataValid), but do not use it after the stream stops. Callclone()on any instance you keep — the clone owns its copies.
profile.setParam(5.0, key: "NTF_EEG", value: "ON") { result, err in ... }Available keys (value "ON" / "OFF" unless noted):
// Data stream toggles
"NTF_EEG" "NTF_ECG" "NTF_EMG" "NTF_GEST" "NTF_BRTH" "NTF_IMU"
"NTF_IMPEDANCE" "NTF_MAG_ANGLE" "NTF_PPG" "NTF_PPG_RAW" (alias of NTF_PPG)
"NTF_SPO2"
// NTF_IMU alone switches the legacy ACC/GYRO/EULER/QUAT split streams; on
// legacy EMG devices NTF_GEST and NTF_EMG are mutually exclusive.
// Firmware filters
"FILTER_50HZ" "FILTER_60HZ" "FILTER_HPF" "FILTER_LPF"
// EEG/ECG sample rate (bound together on devices that have both; validated
// against getParam("EEG_SAMPLE_RATE_LIST"))
"EEG_SAMPLE_RATE" // e.g. "500"
// Per-profile debug output (see Logging controls)
"DEBUG_LOG_PATH" // "True" / "False" / absolute path
"DEBUG_BLE_DATA_PATH" // bin capture export: "True" / "False" / absolute path
// Flush this device's pending bin capture and SDK log to disk; value ignored
"FLUSH_BLE_DATA"Changing an NTF_* key while streaming restarts the data notification so the
new setting takes effect immediately; FILTER_* applies on the fly.
profile.getParam(5.0, key: "NTF") { result, err in ... }
// "NTF" -> "NTF_BRTH|ON|NTF_ECG|ON|..." (aggregate of all known keys)
// "FILTER" -> "FILTER_50HZ|ON|FILTER_60HZ|ON|..."
// "EEG_SAMPLE_RATE" -> current rate, e.g. "250"
// "EEG_SAMPLE_RATE_LIST" -> selectable rates, e.g. "250|500"
// plus every setParam key individually.If the key is not supported, the result starts with "Error".
SensorAlgo runs the streaming DSP algorithms on live data (EEG relax via
the theta/beta ratio; PPG heart rate / SpO2). One instance per device;
multiple instances are independent. All operations are completion-block
async like the rest of the wrapper (nil completion = fire-and-forget), and
callbacks fire on the instance's internal worker thread.
let algo = SensorAlgo() // raises when the library is too old
algo.initWithCompletion { ok, err in ... }
algo.hasInit // synchronous probe
algo.pushSamples(dataList, param: "") { ok, err in ... }
// Feed the onData batches directly. Consumed: NTF_EEG (>= 2 channels),
// NTF_PPG (>= 2 channels), aggregate NTF_IMU (>= 6 channels); other types
// are ignored, and IMU is optional (it only drives motion rejection).
// Borrowed batches pass param "" and must stay within their borrow
// lifetime; pass "CLONE" (or set the CLONE param to "True") to have the
// SDK deep-copy every batch at call time.
algo.getResult("NO_HEADER") { result, err in ... }
// "STEP|TIMESTAMP|VALID|RELAX|CONCENTRATE|POSITION" — one snapshot per
// call from a bounded FIFO (falls back to the latest once drained).
// RELAX is the valid-gated EEG theta/beta ratio (-1 while invalid);
// TIMESTAMP is the consumed stream's absolute end time in seconds;
// CONCENTRATE/POSITION are stubs. "EEG" / "PPG" answer NAME|value
// aggregates, "HEADER_ONLY" the field names, a bare field name its value.
algo.setParam("EEG_REFRESH_RATE", value: "1") { result, err in ... }
// Result cadence: 0 (default) = one snapshot per pushed update; R > 0 =
// one snapshot per 1/R second of consumed EEG/PPG samples.
// Other params: "EEG_SAMPLE_RATE" (250/500/1000), "PPG_SAMPLE_RATE" (100),
// "IMU_SAMPLE_RATE" (any positive), the "*_SAMPLE_RATE_LIST" readbacks,
// "CLONE". algo.reset() re-creates the algorithms with fresh state.
algo.algoCapiVersion() // library's algo C API versionThe SDK records the raw BLE packets of every session into a .bin capture
(see DEBUG_BLE_DATA_PATH); bin files can be inspected, replayed through the
normal parsing pipeline, or converted to CSV offline.
let info = controller.getBinFileInfo(path) // BinFileInfo (mac, deviceName,
// durationSec, deviceInfo, valid)
// Replay through the parse pipeline; the returned profile's delegate receives
// the data like a live device:
let replay = controller.replayBinFile(path, deviceMac: info.mac,
realtime: true, timeout: 60)
controller.pauseBinReplay(info.mac) // "OK" or "Error: ..."
controller.resumeBinReplay(info.mac)
controller.stopBinReplay(info.mac) // "OK" or "Error: ..." (non-blocking)
// Offline full-speed conversion to CSV (completion-block async; the result is
// the CSV path or an "Error: ..." string):
controller.parseBin(toCsv: binPath, csvPath: csvPath) { result, err in ... }File logging is driven by the controller-level getParam/setParam pair (completion-block async like every other controller operation; a nil completion = fire-and-forget):
controller.setParam("LOG_PATH", value: dir) { result, err in ... }
controller.setParam("DEBUG_ENABLED", value: "True") { result, err in ... }"LOG_PATH"— log directory (created if missing);"True"selects the default directory,"False"/""turns file output off."DEBUG_ENABLED"— master switch for file output ("True"/"False")."DATA_LOG_ENABLED"— bin data capture switch ("True"/"False").
Set LOG_PATH before DEBUG_ENABLED so the whole session lands in one
directory. Reads go through controller.getParam(key) { result, err in ... }.
The controller log (sensor_controller_log_YYYYMMDD_HHMMSS.txt) holds the
common logs; per-profile logs are enabled via setParam("DEBUG_LOG_PATH", ...).
The default log directory is ~/Documents/sensorsdklog (the app-sandbox
Documents directory on iOS).
Applications can write their own events into the same SDK log timeline
(tagged [App]; level is judged by its first character, case-insensitive
d/i/w/e, default "I"):
controller.log("User clicked start", level: "I")
profile.log("User toggled filter 50Hz") // routed to the profile's logexample_swift/ contains SensorDemoSwift, a full SwiftUI demo (one target
for macOS + iPhone/iPad): multi-device scan/connect/stream, bio waveforms with
live band-pass filter (PPG devices additionally get a RELAX relaxation plot
driven by SensorAlgo), IMU waveforms + FFT spectra + 3D quaternion cube,
NTF/FILTER/sample-rate controls, battery, auto reconnect, bin replay and
parse-to-CSV.
cd example_swift
# macOS (arm64)
xcodebuild -project SensorDemoSwift.xcodeproj -scheme SensorDemoSwift \
-destination 'platform=macOS,arch=arm64' build CODE_SIGNING_ALLOWED=NO
# iOS Simulator
xcodebuild -project SensorDemoSwift.xcodeproj -scheme SensorDemoSwift \
-destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NOOn a real iPhone/iPad, set a Development Team in Xcode (Signing &
Capabilities). The demo expects lib/sensor.xcframework next to it
(../lib/sensor.xcframework); keep the repo layout when moving directories.