@smartspectra/node-sdk 3.5.0-rc.2 → 3.5.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE CHANGED
@@ -15,9 +15,10 @@ The following Apache-licensed components are included in this shipped artifact:
15
15
  - HighwayHash
16
16
  - KleidiAI
17
17
  - Kompute
18
+ - LiteRT (TensorFlow Lite runtime)
18
19
  - MediaPipe
19
20
  - ml_dtypes
20
21
  - OpenCV
21
22
  - protoc-gen-validate
22
23
  - ruy
23
- - TensorFlow (TensorFlow Lite)
24
+ - TensorFlow
package/README.md CHANGED
@@ -14,6 +14,7 @@ no native addon, no `binding.gyp`, no `electron-rebuild`, no `node-gyp`.
14
14
 
15
15
  - Electron sample: [electron-quickstart](https://github.com/Presage-Security/SmartSpectra/tree/main/nodejs/samples/electron-quickstart)
16
16
  - API reference: <https://smartspectra.presagetech.com/docs/nodejs/api-reference>
17
+ - Device Readiness: [check readiness and handle results](docs/device-readiness.md)
17
18
  - Metrics guide: <https://smartspectra.presagetech.com/docs/nodejs/metrics>
18
19
 
19
20
  ## Architecture
@@ -19,12 +19,13 @@ Apache-2.0
19
19
  - HighwayHash
20
20
  - KleidiAI
21
21
  - Kompute
22
+ - LiteRT (TensorFlow Lite runtime)
22
23
  - MediaPipe
23
24
  - ml_dtypes
24
25
  - OpenCV
25
26
  - protoc-gen-validate
26
27
  - ruy
27
- - TensorFlow (TensorFlow Lite)
28
+ - TensorFlow
28
29
 
29
30
  BSD-2-Clause
30
31
  - cpuinfo
@@ -294,7 +295,7 @@ Full license texts included in this file
294
295
  See the License for the specific language governing permissions and
295
296
  limitations under the License.
296
297
 
297
- Applies to: Abseil, CNCF xDS API, Envoy data-plane-api, FlatBuffers, gemmlowp, Google APIs, gRPC, HighwayHash, KleidiAI, Kompute, MediaPipe, ml_dtypes, OpenCV, protoc-gen-validate, ruy, TensorFlow (TensorFlow Lite)
298
+ Applies to: Abseil, CNCF xDS API, Envoy data-plane-api, FlatBuffers, gemmlowp, Google APIs, gRPC, HighwayHash, KleidiAI, Kompute, LiteRT (TensorFlow Lite runtime), MediaPipe, ml_dtypes, OpenCV, protoc-gen-validate, ruy, TensorFlow
298
299
 
299
300
  ===== BSD-2-Clause =====
300
301
  Copyright (c) <year> <owner>. All rights reserved.
@@ -0,0 +1,61 @@
1
+ ---
2
+ title: Node.js Device Readiness
3
+ description: Check device readiness for the configured metrics and camera, and handle the result.
4
+ ---
5
+
6
+ # Node.js Device Readiness
7
+
8
+ > **Important:** SDK metrics are offered for general wellness and informational purposes only. SDK metrics have not been cleared by the FDA and may not be used for medical diagnosis or treatment.
9
+
10
+ Configure the SDK's authentication, metrics, and camera, then check readiness
11
+ before starting a measurement:
12
+
13
+ ```javascript
14
+ const result = await sdk.checkDeviceReadiness();
15
+ ```
16
+
17
+ Use `result.verdict` for application decisions and `result.reason` for the
18
+ explanation and recommended next step. Keep the returned result if you need it later.
19
+
20
+ Compare the verdict explicitly, for example `result.verdict === 'ready'`.
21
+ Every verdict string is truthy, including `'blocked'`.
22
+
23
+ See [Device Readiness](../../docs/device-readiness.md) for
24
+ verdict meanings, supported inputs, and result handling.
25
+
26
+ ## Telemetry
27
+
28
+ Telemetry is on by default. When it is enabled, each check sends its full
29
+ diagnostic report to Presage: device and SDK details, requested metrics and camera
30
+ settings, performance measurements, and free-text diagnostic messages. The report
31
+ never includes video frames or vital signs.
32
+
33
+ To turn it off, pass `enableTelemetry: false` when creating the SDK:
34
+
35
+ ```javascript
36
+ const sdk = new SmartSpectraSDK({
37
+ // ...authentication and metrics...
38
+ enableTelemetry: false,
39
+ });
40
+ ```
41
+
42
+ See [SDK Telemetry & Privacy](../../docs/telemetry-and-privacy.md) for the full
43
+ report contents, storage, and retention.
44
+
45
+ ## When a measurement stops for low frame rate
46
+
47
+ When the frame rate stays too low to measure reliably during a measurement, the
48
+ session ends and the `'error'` event receives
49
+ `SmartSpectraErrorCode.kSustainedLowFrameRate` (14) instead of
50
+ `SmartSpectraErrorCode.kProcessingFailed`. The message is "SmartSpectra stopped
51
+ because the frame rate stayed too low to measure reliably." A frame submitted as
52
+ the session ends is rejected with the same code. `retryable` is `false` when the
53
+ SDK plays a video file, because the same file stops the same way; it is `true` for
54
+ cameras and custom input:
55
+ the host may offer a user-initiated fresh start; do not restart automatically.
56
+ To check whether a device can sustain measurement before starting, call
57
+ `checkDeviceReadiness()`.
58
+
59
+ The SDK measures frame rate from frame timestamps: the timestamp file when one is
60
+ given, otherwise the video's playback position. The interframe delay
61
+ (`interframeDelayMs`) changes playback speed, not the measured frame rate.
package/js/constants.js CHANGED
@@ -80,6 +80,8 @@ const SmartSpectraErrorCode = Object.freeze({
80
80
  kFrameConversionFailed: 9,
81
81
  kNonMonotonicTimestamp: 10,
82
82
  kTimestampGap: 11,
83
+ kMetricNotAuthorized: 13,
84
+ kSustainedLowFrameRate: 14,
83
85
  });
84
86
 
85
87
  // Spatial transform applied to each pushed frame before it enters the
@@ -101,16 +103,11 @@ const MetricType = Object.freeze(Object.fromEntries(
101
103
 
102
104
  // MetricType integer codes grouped by measurement type. Spread into
103
105
  // `requestedMetrics`; omitting `requestedMetrics` is equivalent to passing
104
- // `breathingMetrics`.
106
+ // `breathingMetrics` (chest breathing, abdomen breathing, breathing rate).
105
107
  const breathingMetrics = Object.freeze([
106
108
  0, // CHEST_BREATHING
107
109
  1, // ABDOMEN_BREATHING
108
110
  2, // BREATHING_RATE
109
- 3, // BREATHING_AMPLITUDE
110
- 4, // APNEA
111
- 5, // RESPIRATORY_LINE_LENGTH
112
- 6, // BASELINE
113
- 7, // INHALE_EXHALE_RATIO
114
111
  ]);
115
112
 
116
113
  const cardioMetrics = Object.freeze([
@@ -135,8 +132,8 @@ const edaMetrics = Object.freeze([
135
132
  10, // EDA_TRACE
136
133
  ]);
137
134
 
138
- // The default metric bundle used when `requestedMetrics` is omitted; currently
139
- // equals the breathing bundle.
135
+ // The default metric bundle used when `requestedMetrics` is omitted; equal to
136
+ // `breathingMetrics`.
140
137
  const defaultSupportedMetrics = breathingMetrics;
141
138
 
142
139
  module.exports = {
@@ -0,0 +1,430 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+ const koffi = require('koffi');
5
+ const { readinessLibrary, ensurePreconfigured } = require('./ffi');
6
+ let native;
7
+ let typesRegistered = false;
8
+ function unsupported() {
9
+ const error = new Error('This native SDK does not support device readiness ABI v9.');
10
+ error.code = 'unsupported_capability';
11
+ return error;
12
+ }
13
+ function getNative() {
14
+ if (native) return native;
15
+ const lib = readinessLibrary();
16
+ try {
17
+ if (lib.func('int smartspectra_readiness_abi_version(void)')() !== 9) throw unsupported();
18
+ } catch { throw unsupported(); }
19
+ if (!typesRegistered) {
20
+ koffi.struct('smartspectra_readiness_requirement_t', {
21
+ metric_id: 'int',
22
+ minimum_samples: 'uint64_t',
23
+ minimum_output_hz: 'double',
24
+ output_required: 'int',
25
+ });
26
+ koffi.struct('smartspectra_readiness_policy_t', {
27
+ version: 'const char*',
28
+ rationale: 'const char*',
29
+ input_mode: 'const char*',
30
+ frame_width: 'int',
31
+ frame_height: 'int',
32
+ target_fps: 'double',
33
+ minimum_throughput_fps: 'double',
34
+ minimum_duration_us: 'uint64_t',
35
+ metrics: 'const smartspectra_readiness_requirement_t*',
36
+ metrics_len: 'size_t',
37
+ early_window_us: 'uint64_t',
38
+ early_stable_windows: 'uint32_t',
39
+ early_minimum_headroom: 'double',
40
+ uncertainty_fraction: 'double',
41
+ });
42
+ koffi.struct('smartspectra_readiness_request_t', {
43
+ struct_size: 'uint32_t',
44
+ reserved_opt_in: 'int',
45
+ authentication_prepared: 'int',
46
+ scope: 'int',
47
+ camera_permission: 'int',
48
+ camera_index: 'int',
49
+ camera_width: 'int',
50
+ camera_height: 'int',
51
+ camera_fps: 'int',
52
+ frame_width: 'int',
53
+ frame_height: 'int',
54
+ target_fps: 'double',
55
+ duration_us: 'uint64_t',
56
+ maximum_wait_us: 'uint64_t',
57
+ cleanup_reserve_us: 'uint64_t',
58
+ reference_bgr_frame: 'const uint8_t*',
59
+ reference_bgr_frame_len: 'size_t',
60
+ policy: 'smartspectra_readiness_policy_t',
61
+ camera_selection_kind: 'int', camera_selection_id: 'const char*',
62
+ });
63
+ koffi.struct('smartspectra_readiness_progress_t', {
64
+ phase: 'int',
65
+ timing_available: 'int',
66
+ elapsed_us: 'uint64_t',
67
+ has_deadline: 'int',
68
+ remaining_us: 'uint64_t',
69
+ });
70
+ koffi.struct('smartspectra_readiness_result_t', {
71
+ verdict: 'int', reason: 'const char*', diagnostics: 'const char*',
72
+ });
73
+ typesRegistered = true;
74
+ }
75
+ native = {
76
+ referencePolicy: lib.func('int smartspectra_readiness_reference_policy(int workload, int scope, _Out_ smartspectra_readiness_policy_t *out)'),
77
+ fallbackPolicy: lib.func('int smartspectra_readiness_fallback_policy(const smartspectra_readiness_policy_t *policy, int verdict, int reason, _Out_ smartspectra_readiness_policy_t *out)'),
78
+ policy: lib.func('int smartspectra_readiness_chest_policy(_Out_ smartspectra_readiness_policy_t *out)'),
79
+ create: lib.func('void *smartspectra_readiness_create(void)'),
80
+ reserve: lib.func('int smartspectra_readiness_reserve(void *service)'),
81
+ destroy: lib.func('void smartspectra_readiness_destroy(void *service)'),
82
+ finish: lib.func('int smartspectra_readiness_finish_check(void *service)'),
83
+ start: lib.func('void *smartspectra_readiness_start(void *service, const smartspectra_config_t *config, const smartspectra_readiness_request_t *request)'),
84
+ cancel: lib.func('void smartspectra_readiness_cancel(void *check)'),
85
+ background: lib.func('void smartspectra_readiness_background(void *check)'),
86
+ progress: lib.func('int smartspectra_readiness_progress(void *check, _Out_ smartspectra_readiness_progress_t *out)'),
87
+ poll: lib.func('int smartspectra_readiness_poll(void *check, _Out_ smartspectra_readiness_result_t *out)'),
88
+ free: lib.func('void smartspectra_readiness_check_free(void *check)'),
89
+ };
90
+ return native;
91
+ }
92
+ const verdicts = ['ready', 'limited', 'blocked', 'inconclusive'];
93
+ const reasons = ['qualified', 'performance_below_policy', 'invalid_configuration',
94
+ 'authentication_required', 'authorization_denied', 'input_unavailable', 'setup_failed',
95
+ 'run_incomplete', 'insufficient_evidence', 'policy_unavailable', 'backend_unknown',
96
+ 'configuration_changed', 'cancelled', 'deadline_exceeded', 'application_backgrounded',
97
+ 'busy', 'camera_interrupted', 'permission_required', 'permission_denied',
98
+ 'borderline_performance', 'experimental_opt_in_required', 'unsupported_capability',
99
+ 'network_unavailable'];
100
+ const actions = ['proceed', 'retry', 'authenticate', 'obtain_authorization', 'correct_configuration',
101
+ 'supply_policy', 'reduce_workload', 'collect_evidence', 'inspect_backend', 'release_session',
102
+ 'restore_input', 'grant_permission', 'return_to_foreground', 'resolve_setup',
103
+ 'enable_experimental', 'choose_supported_workload'];
104
+ const phases = ['preparing', 'running', 'cleaning_up', 'finalizing'];
105
+ const scopes = ['reference_frame', 'camera_paced_reference_frame'];
106
+ const permissions = ['unknown', 'granted', 'denied'];
107
+ const booleanFields = new Set(['output_required', 'retryable', 'message_truncated', 'available', 'has_deadline', 'phases_available', 'timing_available',
108
+ 'authorization_complete', 'backend_coverage_complete', 'input_validated',
109
+ 'camera_geometry_changed', 'workload_completed', 'early_completion_requested']);
110
+ function fields(object) {
111
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => [
112
+ key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
113
+ booleanFields.has(key) ? !!value : typeof value === 'bigint' ? Number(value) : value,
114
+ ]));
115
+ }
116
+ function array(pointer, type, length) {
117
+ return Number(length) ? Array.from(koffi.decode(pointer, koffi.array(type, Number(length)))) : [];
118
+ }
119
+ function policyFromNative(p) {
120
+ const { metrics, metrics_len, early_window_us, early_stable_windows,
121
+ early_minimum_headroom, ...rest } = p;
122
+ return { ...fields(rest),
123
+ metrics: array(metrics, 'smartspectra_readiness_requirement_t', metrics_len).map(fields),
124
+ earlyCompletion: { windowUs: Number(early_window_us), stableWindows: early_stable_windows,
125
+ minimumHeadroom: early_minimum_headroom } };
126
+ }
127
+ function freeze(value) {
128
+ if (value && typeof value === 'object') {
129
+ Object.values(value).forEach(freeze);
130
+ Object.freeze(value);
131
+ }
132
+ return value;
133
+ }
134
+ function resultFromNative(r) {
135
+ return freeze({ verdict: verdicts[r.verdict], reason: r.reason, diagnostics: r.diagnostics });
136
+ }
137
+
138
+ function number(value, fallback, name, integer = false, min = -Infinity, max = Infinity) {
139
+ const result = value === undefined ? fallback : value;
140
+ if (typeof result !== 'number' || !Number.isFinite(result) ||
141
+ (integer && !Number.isSafeInteger(result)) || result < min || result > max)
142
+ throw new TypeError(`${name} must be a finite ${integer ? 'integer' : 'number'} in range.`);
143
+ return result;
144
+ }
145
+ const int = (v, d, n) => number(v, d, n, true, -2147483648, 2147483647);
146
+ const us = (v, d, n) => number(v, d, n, true, 0, Number.MAX_SAFE_INTEGER);
147
+ function string(value, fallback, name) {
148
+ const result = value === undefined ? fallback : value;
149
+ if (typeof result !== 'string' || result.includes('\0')) throw new TypeError(`${name} must be a string without NUL.`);
150
+ return result;
151
+ }
152
+ function flag(value, fallback, name) {
153
+ if (value === undefined) return +fallback;
154
+ if (typeof value !== 'boolean') throw new TypeError(`${name} must be boolean.`);
155
+ return +value;
156
+ }
157
+ function choice(value, fallback, choices, name) {
158
+ const index = choices.indexOf(value ?? fallback);
159
+ if (index < 0) throw new TypeError(`Unsupported ${name}.`);
160
+ return index;
161
+ }
162
+ function policyToNative(p = {}) {
163
+ const early = p.earlyCompletion ?? {};
164
+ if (!Array.isArray(p.metrics ?? [])) throw new TypeError('policy.metrics must be an array.');
165
+ return {
166
+ version: string(p.version, '', 'policy.version'), rationale: string(p.rationale, '', 'policy.rationale'),
167
+ input_mode: string(p.inputMode, '', 'policy.inputMode'),
168
+ frame_width: int(p.frameWidth, 0, 'policy.frameWidth'), frame_height: int(p.frameHeight, 0, 'policy.frameHeight'),
169
+ target_fps: number(p.targetFps, 0, 'policy.targetFps'),
170
+ minimum_throughput_fps: number(p.minimumThroughputFps, 0, 'policy.minimumThroughputFps'),
171
+ minimum_duration_us: us(p.minimumDurationUs, 0, 'policy.minimumDurationUs'),
172
+ metrics: (p.metrics ?? []).map(m => ({ metric_id: int(m.metricId, 0, 'metricId'),
173
+ minimum_samples: us(m.minimumSamples, 2, 'minimumSamples'),
174
+ minimum_output_hz: number(m.minimumOutputHz, 0, 'minimumOutputHz'),
175
+ output_required: flag(m.outputRequired, true, 'outputRequired') })),
176
+ metrics_len: (p.metrics ?? []).length,
177
+ early_window_us: us(early.windowUs, 0, 'earlyCompletion.windowUs'),
178
+ early_stable_windows: number(early.stableWindows, 0, 'earlyCompletion.stableWindows', true, 0, 4294967295),
179
+ early_minimum_headroom: number(early.minimumHeadroom, 0, 'earlyCompletion.minimumHeadroom'),
180
+ uncertainty_fraction: number(p.uncertaintyFraction, 0, 'policy.uncertaintyFraction'),
181
+ };
182
+ }
183
+ function requestToNative(request) {
184
+ const frame = request.referenceBgrFrame ?? Buffer.alloc(0);
185
+ if (!(frame instanceof Uint8Array)) throw new TypeError('referenceBgrFrame must be a Buffer or Uint8Array.');
186
+ const camera = request.camera ?? {};
187
+ return {
188
+ struct_size: koffi.sizeof('smartspectra_readiness_request_t'),
189
+ reserved_opt_in: 0,
190
+ authentication_prepared: flag(request.authenticationPrepared, false, 'authenticationPrepared'),
191
+ scope: choice(request.scope, 'camera_paced_reference_frame', scopes, 'scope'),
192
+ camera_permission: choice(request.cameraPermission, 'unknown', permissions, 'cameraPermission'),
193
+ camera_index: int(camera.deviceIndex, 0, 'camera.deviceIndex'),
194
+ camera_width: int(camera.preferredWidth, 1280, 'camera.preferredWidth'),
195
+ camera_height: int(camera.preferredHeight, 720, 'camera.preferredHeight'),
196
+ camera_fps: int(camera.preferredFps, 30, 'camera.preferredFps'),
197
+ frame_width: int(request.frameWidth, 1280, 'frameWidth'),
198
+ frame_height: int(request.frameHeight, 720, 'frameHeight'),
199
+ target_fps: number(request.targetFps, 30, 'targetFps'),
200
+ duration_us: us(request.durationUs, 30000000, 'durationUs'),
201
+ maximum_wait_us: us(request.maximumWaitUs, 40000000, 'maximumWaitUs'),
202
+ cleanup_reserve_us: us(request.cleanupReserveUs, 5000000, 'cleanupReserveUs'),
203
+ reference_bgr_frame: frame, reference_bgr_frame_len: frame.byteLength,
204
+ policy: policyToNative(request.policy),
205
+ camera_selection_kind: request.cameraSelection ? choice(request.cameraSelection.kind, '', ['default', 'front', 'back', 'byId'], 'cameraSelection.kind') : -1,
206
+ camera_selection_id: request.cameraSelection?.id ?? null,
207
+ };
208
+ }
209
+ function configurationToNative(config) {
210
+ const metrics = config.requestedMetrics ?? [];
211
+ if (!Array.isArray(metrics)) throw new TypeError('requestedMetrics must be an array.');
212
+ const requested = Int32Array.from(metrics.map(m => int(m, 0, 'requested metric')));
213
+ return { api_key: string(config.apiKey, '', 'apiKey'), requested_metrics: requested,
214
+ requested_metrics_len: requested.length,
215
+ enable_accumulated_output: flag(config.enableAccumulatedOutput, false, 'enableAccumulatedOutput'),
216
+ log_level: config.logLevel === undefined ? 0 : number(config.logLevel, 0, 'logLevel', true, 0, 4) + 1,
217
+ struct_size: koffi.sizeof('smartspectra_config_t'),
218
+ disable_telemetry: 1 - flag(config.enableTelemetry, true, 'enableTelemetry'), reserved0: 0 };
219
+ }
220
+ function progressFromNative(n, handle) {
221
+ const progress = {};
222
+ if (n.progress(handle, progress) !== 0) throw new Error('Unable to read native readiness progress.');
223
+ return freeze({ ...fields(progress), phase: phases[progress.phase] });
224
+ }
225
+ class DeviceReadinessCheck {
226
+ #handle; #native; #progress; #value; #promise;
227
+ constructor(n, handle, signal, finished) {
228
+ this.#native = n;
229
+ this.#handle = handle;
230
+ this.#progress = freeze({ phase: 'preparing', timingAvailable: false, elapsedUs: 0,
231
+ hasDeadline: false, remainingUs: 0 });
232
+ const abort = () => this.cancel();
233
+ let timer;
234
+ this.#promise = new Promise((resolve, reject) => {
235
+ const tick = () => {
236
+ try {
237
+ this.#progress = progressFromNative(n, handle);
238
+ const result = {};
239
+ const status = n.poll(handle, result);
240
+ if (status === 0) return;
241
+ if (status !== 1) throw new Error('Unable to read native readiness result.');
242
+ this.#value = resultFromNative(result);
243
+ resolve(this.#value);
244
+ } catch (error) { reject(error); }
245
+ clearInterval(timer);
246
+ // A user-overridden listener method must not prevent native
247
+ // handle cleanup or leave close() waiting on an abandoned check.
248
+ try { signal?.removeEventListener('abort', abort); } catch {}
249
+ try { n.free(handle); }
250
+ finally { this.#handle = null; finished(this); }
251
+ };
252
+ // Referenced timer retains the event loop until native cleanup and
253
+ // result copying finish, even if the caller only awaits result.
254
+ timer = setInterval(tick, 25);
255
+ });
256
+ // Keep ignored result promises from becoming unhandled rejections.
257
+ this.#promise.catch(() => {});
258
+ try {
259
+ signal?.addEventListener('abort', abort, { once: true });
260
+ if (signal?.aborted) this.cancel();
261
+ } catch (error) {
262
+ clearInterval(timer);
263
+ try { signal?.removeEventListener('abort', abort); } catch {}
264
+ this.#handle = null;
265
+ throw error; // start() still owns the native handle on this path.
266
+ }
267
+ }
268
+ get result() { return this.#promise; }
269
+ poll() { return this.#value; }
270
+ progress() {
271
+ if (this.#handle) this.#progress = progressFromNative(this.#native, this.#handle);
272
+ return this.#progress;
273
+ }
274
+ cancel() { if (this.#handle) this.#native.cancel(this.#handle); }
275
+ applicationBackgrounded() { if (this.#handle) this.#native.background(this.#handle); }
276
+ }
277
+ // Native threads do not keep Node's event loop alive. Drain surviving owners
278
+ // before normal process exit, while native singleton dependencies still exist.
279
+ // This does not promise delivery after process.exit(), SIGKILL, or a crash.
280
+ const liveServices = new Set();
281
+ function destroyNative(state) {
282
+ if (state.closing) return state.closing;
283
+ state.closing = new Promise((resolve, reject) => {
284
+ state.n.destroy.async(state.handle, error => {
285
+ if (error) reject(error);
286
+ else { state.destroyed = true; resolve(); }
287
+ });
288
+ }).finally(() => liveServices.delete(state));
289
+ return state.closing;
290
+ }
291
+ let exitDrainInstalled = false;
292
+ function installExitDrain() {
293
+ if (exitDrainInstalled) return;
294
+ exitDrainInstalled = true;
295
+ process.on('beforeExit', () => {
296
+ for (const state of liveServices) void destroyNative(state).catch(() => {});
297
+ });
298
+ }
299
+ const services = new FinalizationRegistry(state => { void destroyNative(state).catch(() => {}); });
300
+ class DeviceReadinessService {
301
+ static async run(configuration = {}) {
302
+ const service = new DeviceReadinessService();
303
+ try { return await service.check(configuration).result; }
304
+ finally { await service.close(); }
305
+ }
306
+ #native; #handle; #checks = new Set(); #closing; #lifetime; #finishing;
307
+ constructor() {
308
+ this.#native = getNative();
309
+ ensurePreconfigured();
310
+ this.#handle = this.#native.create();
311
+ if (!this.#handle) throw new Error('Unable to create native readiness service.');
312
+ this.#lifetime = { n: this.#native, handle: this.#handle };
313
+ liveServices.add(this.#lifetime);
314
+ installExitDrain();
315
+ services.register(this, this.#lifetime, this);
316
+ }
317
+ get closed() { return !!this.#lifetime.closing; }
318
+ reserve() {
319
+ if (!this.#handle || this.#closing || this.closed || this.#finishing) throw new Error('Readiness service is closed.');
320
+ const result = this.#native.reserve(this.#handle);
321
+ if (result < 0) throw new Error('Unable to reserve SDK state.');
322
+ return result === 1;
323
+ }
324
+ check(configuration, referenceBgrFrame = undefined, workload = 'full', scope = 'camera_paced_reference_frame') {
325
+ const policy = DeviceReadinessPolicies.reference720p30(workload, scope);
326
+ return this.start({ ...configuration, requestedMetrics: policy.metrics.map(m => m.metricId) }, {
327
+ referenceBgrFrame, policy, scope, authenticationPrepared: !!configuration?.apiKey,
328
+ });
329
+ }
330
+ start(configuration = {}, request = {}) {
331
+ if (!this.#handle || this.#closing || this.closed || this.#finishing) throw new Error('Readiness service is closed.');
332
+ const signal = request.signal;
333
+ if (signal !== undefined && !(signal instanceof AbortSignal))
334
+ throw new TypeError('signal must be an AbortSignal.');
335
+ if (signal?.aborted) throw new DOMException('Readiness was aborted before submission.', 'AbortError');
336
+ const config = configurationToNative(configuration);
337
+ const input = requestToNative(request);
338
+ // Configuration/request getters can call close() during conversion.
339
+ if (!this.#handle || this.#closing || this.closed || this.#finishing) throw new Error('Readiness service is closed.');
340
+ const handle = this.#native.start(this.#handle, config, input);
341
+ if (!handle) throw new Error('Unable to submit native readiness request.');
342
+ let check;
343
+ try {
344
+ check = new DeviceReadinessCheck(this.#native, handle, signal, value => this.#checks.delete(value));
345
+ } catch (error) {
346
+ try { this.#native.cancel(handle); }
347
+ finally { this.#native.free(handle); }
348
+ throw error;
349
+ }
350
+ this.#checks.add(check);
351
+ return check;
352
+ }
353
+ finishCheck() {
354
+ if (this.closed) return this.#lifetime.closing;
355
+ if (this.#closing) return this.#closing;
356
+ if (this.#finishing) return this.#finishing;
357
+ const checks = [...this.#checks];
358
+ checks.forEach(check => check.cancel());
359
+ this.#finishing = Promise.allSettled(checks.map(check => check.result)).then(() => new Promise((resolve, reject) => {
360
+ this.#native.finish.async(this.#handle, (error, status) => {
361
+ if (error) reject(error);
362
+ else if (status !== 0) reject(new Error('Unable to finish readiness check.'));
363
+ else resolve();
364
+ });
365
+ })).finally(() => { this.#finishing = null; });
366
+ return this.#finishing;
367
+ }
368
+ // SDK reset is an existing synchronous teardown API. Do not overlap an
369
+ // asynchronous finish/close; ordinary check returns have already joined it.
370
+ closeSync() {
371
+ // beforeExit may have finished draining this owner before another
372
+ // listener resumes application work. Its native handle is already gone.
373
+ if (this.#lifetime.destroyed) {
374
+ this.#handle = null;
375
+ services.unregister(this);
376
+ return;
377
+ }
378
+ if (this.#closing || this.#finishing || this.closed)
379
+ throw new Error('Readiness teardown is already in progress; await it before reset.');
380
+ this.#native.destroy(this.#handle);
381
+ this.#lifetime.destroyed = true;
382
+ this.#handle = null;
383
+ this.#lifetime.closing = Promise.resolve();
384
+ liveServices.delete(this.#lifetime);
385
+ services.unregister(this);
386
+ }
387
+ close() {
388
+ if (this.closed) return this.#lifetime.closing;
389
+ if (this.#closing) return this.#closing;
390
+ const checks = [...this.#checks];
391
+ checks.forEach(check => check.cancel());
392
+ this.#closing = Promise.allSettled([...checks.map(check => check.result), this.#finishing]).then(() => {
393
+ this.#handle = null;
394
+ services.unregister(this);
395
+ return destroyNative(this.#lifetime);
396
+ });
397
+ return this.#closing;
398
+ }
399
+ }
400
+ const DeviceReadinessPolicies = Object.freeze({
401
+ reference720p30(workload = 'full', scope = 'camera_paced_reference_frame') {
402
+ const tier = choice(workload, 'full', ['breathing_pulse_eda', 'breathing_pulse', 'breathing', 'full'], 'workload');
403
+ const policy = {};
404
+ if (getNative().referencePolicy(tier, choice(scope, 'camera_paced_reference_frame', scopes, 'scope'), policy) !== 0) throw new Error('Unable to read native readiness policy.');
405
+ return freeze(policyFromNative(policy));
406
+ },
407
+ fallback(result) {
408
+ const policy = {};
409
+ const status = getNative().fallbackPolicy(policyToNative(result.policy),
410
+ choice(result.qualification.verdict, '', verdicts, 'verdict'),
411
+ choice(result.qualification.reason, '', reasons, 'reason'), policy);
412
+ if (status === 1) return undefined;
413
+ if (status !== 0) throw new Error('Unable to read native fallback policy.');
414
+ return freeze(policyFromNative(policy));
415
+ },
416
+ chestTraceReference720p30() {
417
+ const policy = {};
418
+ if (getNative().policy(policy) !== 0) throw new Error('Unable to read native readiness policy.');
419
+ return freeze(policyFromNative(policy));
420
+ },
421
+ });
422
+ function deviceReadinessAvailable() { try { getNative(); return true; } catch { return false; } }
423
+ module.exports = { DeviceReadinessService, DeviceReadinessPolicies, deviceReadinessAvailable, busyResult };
424
+
425
+ // A rejected busy check has no measurement evidence. Keep the full result shape.
426
+ function busyResult() {
427
+ return freeze({ verdict: 'inconclusive',
428
+ reason: 'The SDK is busy. Wait for the current SDK operation to finish.',
429
+ diagnostics: JSON.stringify({ reason: 'busy', workloadCompleted: false }) });
430
+ }
package/js/ffi.js CHANGED
@@ -17,9 +17,9 @@ const fs = require('fs');
17
17
  const os = require('os');
18
18
  const path = require('path');
19
19
  const koffi = require('koffi');
20
- const { resolveNativeLibrary } = require('./resolve-native');
20
+ const { loadNativeLibrary } = require('./resolve-native');
21
21
 
22
- const lib = koffi.load(resolveNativeLibrary());
22
+ const lib = loadNativeLibrary(koffi);
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Type declarations — mirror smartspectra_capi.h
@@ -648,4 +648,5 @@ module.exports = {
648
648
  availableCameras,
649
649
  preconfigure,
650
650
  ensurePreconfigured,
651
+ readinessLibrary: () => lib,
651
652
  };
package/js/index.d.ts CHANGED
@@ -76,6 +76,10 @@ export declare const SmartSpectraErrorCode: {
76
76
  readonly kFrameConversionFailed: 9;
77
77
  readonly kNonMonotonicTimestamp: 10;
78
78
  readonly kTimestampGap: 11;
79
+ /** Requested metric is not authorized for this API key. */
80
+ readonly kMetricNotAuthorized: 13;
81
+ /** The frame rate stayed too low for a reliable measurement, so the session ended. */
82
+ readonly kSustainedLowFrameRate: 14;
79
83
  };
80
84
  export type SmartSpectraErrorCodeValue = typeof SmartSpectraErrorCode[keyof typeof SmartSpectraErrorCode];
81
85
 
@@ -121,7 +125,11 @@ export interface SmartSpectraOptions {
121
125
  apiKey?: string;
122
126
 
123
127
  /**
124
- * MetricType integer codes. Defaults to `breathingMetrics` when omitted.
128
+ * MetricType integer codes. Defaults to `breathingMetrics` (`MetricType.CHEST_BREATHING`,
129
+ * `MetricType.ABDOMEN_BREATHING`, `MetricType.BREATHING_RATE`) when omitted. Requesting an
130
+ * Unavailable metric (`MetricType.BREATHING_AMPLITUDE`, `MetricType.APNEA`,
131
+ * `MetricType.RESPIRATORY_LINE_LENGTH`, `MetricType.BASELINE`,
132
+ * `MetricType.INHALE_EXHALE_RATIO`) makes `start()` throw `kConfigurationFailed`.
125
133
  */
126
134
  requestedMetrics?: number[];
127
135
 
@@ -134,7 +142,10 @@ export interface SmartSpectraOptions {
134
142
  */
135
143
  logLevel?: SmartSpectraLogLevelValue;
136
144
 
137
- /** Aggregate SDK telemetry. Defaults to `true`; set `false` to opt out. */
145
+ /** Session summaries and full readiness reports. Defaults to `true`; set `false`
146
+ * to opt out. Reports can contain free text, paths and platform context, and
147
+ * authenticated requests can correlate them with an account/device. No raw frames
148
+ * or measured vitals are sent. See SDK Telemetry & Privacy. */
138
149
  enableTelemetry?: boolean;
139
150
  }
140
151
 
@@ -142,7 +153,12 @@ export interface SmartSpectraOptions {
142
153
  export interface VideoFileOptions {
143
154
  /** Path to a per-frame timestamps file (one timestamp per line). */
144
155
  timestampsPath?: string | null;
145
- /** Throttle between frames in ms; omit/0 = as fast as possible. */
156
+ /**
157
+ * Throttle between frames in ms; omit/0 = as fast as possible. The SDK measures
158
+ * frame rate from frame timestamps: the timestamp file when one is given, otherwise
159
+ * the video's playback position. The interframe delay changes playback speed, not
160
+ * the measured frame rate.
161
+ */
146
162
  interframeDelayMs?: number;
147
163
  /** Seek this far into the file before playback (ms); omit/0 = start. */
148
164
  startOffsetMs?: number;
@@ -212,6 +228,8 @@ export interface CameraOptions {
212
228
  * ```
213
229
  */
214
230
  export declare class SmartSpectraSDK {
231
+ /** Device readiness uses this SDK's authentication. */
232
+ checkDeviceReadiness(): Promise<DeviceReadinessResult>;
215
233
  /** SDK package version. */
216
234
  static readonly version: string;
217
235
 
@@ -344,14 +362,14 @@ export declare class SmartSpectraSDK {
344
362
  /**
345
363
  * MetricType integer codes grouped by measurement type. Spread into
346
364
  * `requestedMetrics`; omitting it is equivalent to passing
347
- * `breathingMetrics`.
365
+ * `breathingMetrics` (chest breathing, abdomen breathing, breathing rate).
348
366
  */
349
367
  export declare const breathingMetrics: readonly number[];
350
368
  export declare const cardioMetrics: readonly number[];
351
369
  export declare const faceMetrics: readonly number[];
352
370
  export declare const micromotionMetrics: readonly number[];
353
371
  export declare const edaMetrics: readonly number[];
354
- /** Default metric bundle used when `requestedMetrics` is omitted. */
372
+ /** Default metric bundle used when `requestedMetrics` is omitted; equal to `breathingMetrics`. */
355
373
  export declare const defaultSupportedMetrics: readonly number[];
356
374
 
357
375
  /**
@@ -367,3 +385,11 @@ export declare function setMetricsClass(cls: unknown): void;
367
385
  * been registered via `setMetricsClass`, otherwise the raw Buffer.
368
386
  */
369
387
  export declare function decodeMetrics(buf: Buffer): unknown;
388
+
389
+ /** Device readiness. Pin the JS and native SDK versions together. */
390
+ /** Outcome for the configured SDK workload. */
391
+ export type DeviceReadinessVerdict = 'ready' | 'limited' | 'blocked' | 'inconclusive';
392
+ export interface DeviceReadinessResult {
393
+ readonly verdict: DeviceReadinessVerdict;
394
+ readonly reason: string;
395
+ }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { BrowserWindow } from 'electron';
7
7
 
8
- export { SmartSpectraSDK, ProcessingStatus } from '../../js/index';
8
+ export { SmartSpectraSDK, ProcessingStatus } from '../index';
9
9
 
10
10
  /** Options for `bindSmartSpectraIpc`. */
11
11
  export interface BindSmartSpectraIpcOptions {
@@ -725,6 +725,9 @@ export namespace presage {
725
725
 
726
726
  /** End of the completed measurement window, in microseconds on the session input timeline */
727
727
  timestamp?: (number|Long|null);
728
+
729
+ /** True when the service returned a valid strict value; false means retry the measurement. */
730
+ stable?: (boolean|null);
728
731
  }
729
732
 
730
733
  /**
@@ -745,6 +748,9 @@ export namespace presage {
745
748
  /** End of the completed measurement window, in microseconds on the session input timeline */
746
749
  public timestamp: (number|Long);
747
750
 
751
+ /** True when the service returned a valid strict value; false means retry the measurement. */
752
+ public stable: boolean;
753
+
748
754
  /**
749
755
  * Creates a new Strict instance using the specified properties.
750
756
  * @param [properties] Properties to set
@@ -973,19 +979,19 @@ export namespace presage {
973
979
  */
974
980
  lowerTrace?: (presage.smartspectra.IMeasurement[]|null);
975
981
 
976
- /** Breathing amplitude measurements */
982
+ /** Unavailable: breathing amplitude. This field stays empty; requesting BREATHING_AMPLITUDE makes start fail with ConfigurationFailed. */
977
983
  amplitude?: (presage.smartspectra.IMeasurement[]|null);
978
984
 
979
- /** Apnea (breathing cessation) detection status */
985
+ /** Unavailable: apnea (breathing cessation) detection. This field stays empty; requesting APNEA makes start fail with ConfigurationFailed. */
980
986
  apnea?: (presage.smartspectra.IDetectionStatus[]|null);
981
987
 
982
- /** Respiratory line length measurements for breathing pattern analysis */
988
+ /** Unavailable: respiratory line length. This field stays empty; requesting RESPIRATORY_LINE_LENGTH makes start fail with ConfigurationFailed. */
983
989
  respiratoryLineLength?: (presage.smartspectra.IMeasurement[]|null);
984
990
 
985
- /** Baseline breathing measurements */
991
+ /** Unavailable: breathing baseline. This field stays empty; requesting BASELINE makes start fail with ConfigurationFailed. */
986
992
  baseline?: (presage.smartspectra.IMeasurement[]|null);
987
993
 
988
- /** Inhale to exhale duration ratio measurements */
994
+ /** Unavailable: inhale/exhale duration ratio. This field stays empty; requesting INHALE_EXHALE_RATIO makes start fail with ConfigurationFailed. */
989
995
  inhaleExhaleRatio?: (presage.smartspectra.IMeasurement[]|null);
990
996
 
991
997
  /**
@@ -1031,19 +1037,19 @@ export namespace presage {
1031
1037
  */
1032
1038
  public lowerTrace: presage.smartspectra.IMeasurement[];
1033
1039
 
1034
- /** Breathing amplitude measurements */
1040
+ /** Unavailable: breathing amplitude. This field stays empty; requesting BREATHING_AMPLITUDE makes start fail with ConfigurationFailed. */
1035
1041
  public amplitude: presage.smartspectra.IMeasurement[];
1036
1042
 
1037
- /** Apnea (breathing cessation) detection status */
1043
+ /** Unavailable: apnea (breathing cessation) detection. This field stays empty; requesting APNEA makes start fail with ConfigurationFailed. */
1038
1044
  public apnea: presage.smartspectra.IDetectionStatus[];
1039
1045
 
1040
- /** Respiratory line length measurements for breathing pattern analysis */
1046
+ /** Unavailable: respiratory line length. This field stays empty; requesting RESPIRATORY_LINE_LENGTH makes start fail with ConfigurationFailed. */
1041
1047
  public respiratoryLineLength: presage.smartspectra.IMeasurement[];
1042
1048
 
1043
- /** Baseline breathing measurements */
1049
+ /** Unavailable: breathing baseline. This field stays empty; requesting BASELINE makes start fail with ConfigurationFailed. */
1044
1050
  public baseline: presage.smartspectra.IMeasurement[];
1045
1051
 
1046
- /** Inhale to exhale duration ratio measurements */
1052
+ /** Unavailable: inhale/exhale duration ratio. This field stays empty; requesting INHALE_EXHALE_RATIO makes start fail with ConfigurationFailed. */
1047
1053
  public inhaleExhaleRatio: presage.smartspectra.IMeasurement[];
1048
1054
 
1049
1055
  /**
@@ -2053,6 +2053,7 @@ $root.presage = (function() {
2053
2053
  * @interface IStrict
2054
2054
  * @property {number|null} [value] The strict value requiring high precision
2055
2055
  * @property {number|Long|null} [timestamp] End of the completed measurement window, in microseconds on the session input timeline
2056
+ * @property {boolean|null} [stable] True when the service returned a valid strict value; false means retry the measurement.
2056
2057
  */
2057
2058
 
2058
2059
  /**
@@ -2087,6 +2088,14 @@ $root.presage = (function() {
2087
2088
  */
2088
2089
  Strict.prototype.timestamp = $util.Long ? $util.Long.fromBits(0,0,false) : 0;
2089
2090
 
2091
+ /**
2092
+ * True when the service returned a valid strict value; false means retry the measurement.
2093
+ * @member {boolean} stable
2094
+ * @memberof presage.smartspectra.Strict
2095
+ * @instance
2096
+ */
2097
+ Strict.prototype.stable = false;
2098
+
2090
2099
  /**
2091
2100
  * Creates a new Strict instance using the specified properties.
2092
2101
  * @function create
@@ -2119,6 +2128,8 @@ $root.presage = (function() {
2119
2128
  writer.uint32(/* id 1, wireType 5 =*/13).float(message.value);
2120
2129
  if (message.timestamp != null && Object.hasOwnProperty.call(message, "timestamp"))
2121
2130
  writer.uint32(/* id 2, wireType 0 =*/16).int64(message.timestamp);
2131
+ if (message.stable != null && Object.hasOwnProperty.call(message, "stable"))
2132
+ writer.uint32(/* id 3, wireType 0 =*/24).bool(message.stable);
2122
2133
  return writer;
2123
2134
  };
2124
2135
 
@@ -2177,6 +2188,10 @@ $root.presage = (function() {
2177
2188
  message.timestamp = reader.int64();
2178
2189
  break;
2179
2190
  }
2191
+ case 3: {
2192
+ message.stable = reader.bool();
2193
+ break;
2194
+ }
2180
2195
  default:
2181
2196
  reader.skipType(tag & 7, long);
2182
2197
  break;
@@ -2227,6 +2242,9 @@ $root.presage = (function() {
2227
2242
  if (message.timestamp != null && Object.hasOwnProperty.call(message, "timestamp"))
2228
2243
  if (!$util.isInteger(message.timestamp) && !(message.timestamp && $util.isInteger(message.timestamp.low) && $util.isInteger(message.timestamp.high)))
2229
2244
  return "timestamp: integer|Long expected";
2245
+ if (message.stable != null && Object.hasOwnProperty.call(message, "stable"))
2246
+ if (typeof message.stable !== "boolean")
2247
+ return "stable: boolean expected";
2230
2248
  return null;
2231
2249
  };
2232
2250
 
@@ -2259,6 +2277,8 @@ $root.presage = (function() {
2259
2277
  message.timestamp = object.timestamp;
2260
2278
  else if (typeof object.timestamp === "object")
2261
2279
  message.timestamp = new $util.LongBits(object.timestamp.low >>> 0, object.timestamp.high >>> 0).toNumber();
2280
+ if (object.stable != null)
2281
+ message.stable = Boolean(object.stable);
2262
2282
  return message;
2263
2283
  };
2264
2284
 
@@ -2286,6 +2306,7 @@ $root.presage = (function() {
2286
2306
  object.timestamp = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long;
2287
2307
  } else
2288
2308
  object.timestamp = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0;
2309
+ object.stable = false;
2289
2310
  }
2290
2311
  if (message.value != null && Object.hasOwnProperty.call(message, "value"))
2291
2312
  object.value = options.json && !isFinite(message.value) ? String(message.value) : message.value;
@@ -2296,6 +2317,8 @@ $root.presage = (function() {
2296
2317
  object.timestamp = options.longs === String ? String(message.timestamp) : message.timestamp;
2297
2318
  else
2298
2319
  object.timestamp = options.longs === String ? $util.Long.prototype.toString.call(message.timestamp) : options.longs === Number ? new $util.LongBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0).toNumber() : message.timestamp;
2320
+ if (message.stable != null && Object.hasOwnProperty.call(message, "stable"))
2321
+ object.stable = message.stable;
2299
2322
  return object;
2300
2323
  };
2301
2324
 
@@ -2729,11 +2752,11 @@ $root.presage = (function() {
2729
2752
  * @property {Array.<presage.smartspectra.IMeasurement>|null} [lowerTrace] Abdominal breathing movement trace measurements. The trace has no confidence
2730
2753
  * of its own; `stable` is inherited from the breathing rate verdict (>= 45); see
2731
2754
  * the [breathing model card](https://vandv.presagetech.com/breathing_model_card.html).
2732
- * @property {Array.<presage.smartspectra.IMeasurement>|null} [amplitude] Breathing amplitude measurements
2733
- * @property {Array.<presage.smartspectra.IDetectionStatus>|null} [apnea] Apnea (breathing cessation) detection status
2734
- * @property {Array.<presage.smartspectra.IMeasurement>|null} [respiratoryLineLength] Respiratory line length measurements for breathing pattern analysis
2735
- * @property {Array.<presage.smartspectra.IMeasurement>|null} [baseline] Baseline breathing measurements
2736
- * @property {Array.<presage.smartspectra.IMeasurement>|null} [inhaleExhaleRatio] Inhale to exhale duration ratio measurements
2755
+ * @property {Array.<presage.smartspectra.IMeasurement>|null} [amplitude] Unavailable: breathing amplitude. This field stays empty; requesting BREATHING_AMPLITUDE makes start fail with ConfigurationFailed.
2756
+ * @property {Array.<presage.smartspectra.IDetectionStatus>|null} [apnea] Unavailable: apnea (breathing cessation) detection. This field stays empty; requesting APNEA makes start fail with ConfigurationFailed.
2757
+ * @property {Array.<presage.smartspectra.IMeasurement>|null} [respiratoryLineLength] Unavailable: respiratory line length. This field stays empty; requesting RESPIRATORY_LINE_LENGTH makes start fail with ConfigurationFailed.
2758
+ * @property {Array.<presage.smartspectra.IMeasurement>|null} [baseline] Unavailable: breathing baseline. This field stays empty; requesting BASELINE makes start fail with ConfigurationFailed.
2759
+ * @property {Array.<presage.smartspectra.IMeasurement>|null} [inhaleExhaleRatio] Unavailable: inhale/exhale duration ratio. This field stays empty; requesting INHALE_EXHALE_RATIO makes start fail with ConfigurationFailed.
2737
2760
  * @property {presage.smartspectra.IStrict|null} [breathingRateStrict] Strict/high-precision breathing measurements over a fixed time interval.
2738
2761
  * Populated only when BREATHING_RATE_STRICT is requested and a value is
2739
2762
  * available.
@@ -2796,7 +2819,7 @@ $root.presage = (function() {
2796
2819
  Breathing.prototype.lowerTrace = $util.emptyArray;
2797
2820
 
2798
2821
  /**
2799
- * Breathing amplitude measurements
2822
+ * Unavailable: breathing amplitude. This field stays empty; requesting BREATHING_AMPLITUDE makes start fail with ConfigurationFailed.
2800
2823
  * @member {Array.<presage.smartspectra.IMeasurement>} amplitude
2801
2824
  * @memberof presage.smartspectra.Breathing
2802
2825
  * @instance
@@ -2804,7 +2827,7 @@ $root.presage = (function() {
2804
2827
  Breathing.prototype.amplitude = $util.emptyArray;
2805
2828
 
2806
2829
  /**
2807
- * Apnea (breathing cessation) detection status
2830
+ * Unavailable: apnea (breathing cessation) detection. This field stays empty; requesting APNEA makes start fail with ConfigurationFailed.
2808
2831
  * @member {Array.<presage.smartspectra.IDetectionStatus>} apnea
2809
2832
  * @memberof presage.smartspectra.Breathing
2810
2833
  * @instance
@@ -2812,7 +2835,7 @@ $root.presage = (function() {
2812
2835
  Breathing.prototype.apnea = $util.emptyArray;
2813
2836
 
2814
2837
  /**
2815
- * Respiratory line length measurements for breathing pattern analysis
2838
+ * Unavailable: respiratory line length. This field stays empty; requesting RESPIRATORY_LINE_LENGTH makes start fail with ConfigurationFailed.
2816
2839
  * @member {Array.<presage.smartspectra.IMeasurement>} respiratoryLineLength
2817
2840
  * @memberof presage.smartspectra.Breathing
2818
2841
  * @instance
@@ -2820,7 +2843,7 @@ $root.presage = (function() {
2820
2843
  Breathing.prototype.respiratoryLineLength = $util.emptyArray;
2821
2844
 
2822
2845
  /**
2823
- * Baseline breathing measurements
2846
+ * Unavailable: breathing baseline. This field stays empty; requesting BASELINE makes start fail with ConfigurationFailed.
2824
2847
  * @member {Array.<presage.smartspectra.IMeasurement>} baseline
2825
2848
  * @memberof presage.smartspectra.Breathing
2826
2849
  * @instance
@@ -2828,7 +2851,7 @@ $root.presage = (function() {
2828
2851
  Breathing.prototype.baseline = $util.emptyArray;
2829
2852
 
2830
2853
  /**
2831
- * Inhale to exhale duration ratio measurements
2854
+ * Unavailable: inhale/exhale duration ratio. This field stays empty; requesting INHALE_EXHALE_RATIO makes start fail with ConfigurationFailed.
2832
2855
  * @member {Array.<presage.smartspectra.IMeasurement>} inhaleExhaleRatio
2833
2856
  * @memberof presage.smartspectra.Breathing
2834
2857
  * @instance
@@ -9,7 +9,7 @@ import type {
9
9
  ValidationCodeValue,
10
10
  SmartSpectraErrorCodeValue,
11
11
  SmartSpectraOptions,
12
- } from '../../js/index';
12
+ } from '../index';
13
13
 
14
14
  // Re-export the public value constants and matching type aliases so the
15
15
  // renderer-side consumer can import everything from one entry point.
@@ -26,7 +26,7 @@ export {
26
26
  micromotionMetrics,
27
27
  edaMetrics,
28
28
  defaultSupportedMetrics,
29
- } from '../../js/index';
29
+ } from '../index';
30
30
  export type {
31
31
  ProcessingStatusValue,
32
32
  PixelFormatValue,
@@ -35,7 +35,7 @@ export type {
35
35
  FrameTransformValue,
36
36
  MetricTypeValue,
37
37
  SmartSpectraOptions,
38
- } from '../../js/index';
38
+ } from '../index';
39
39
 
40
40
  /**
41
41
  * Renderer constructor options: the shared SDK options plus a renderer-only
@@ -113,7 +113,37 @@ function resolveNativeLibrary() {
113
113
  return libPath;
114
114
  }
115
115
 
116
+ // Koffi tries LoadLibraryW before its adjacent-DLL fallback. On Windows that
117
+ // can successfully bind a packaged shim to an unrelated SDK DLL on PATH.
118
+ // Preload the package with a scoped dependency search; do not change the
119
+ // process-wide DLL directory or the explicit development override's behavior.
120
+ function loadNativeLibrary(koffi) {
121
+ const filename = resolveNativeLibrary();
122
+ if (process.platform !== 'win32' || process.env.SMARTSPECTRA_CAPI_PATH) {
123
+ return koffi.load(filename);
124
+ }
125
+ const kernel32 = koffi.load('kernel32.dll');
126
+ try {
127
+ const load = kernel32.func('__stdcall', 'LoadLibraryExW', 'void *', ['str16', 'void *', 'uint32_t']);
128
+ const free = kernel32.func('__stdcall', 'FreeLibrary', 'int', ['void *']);
129
+ // LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
130
+ const handle = load(path.resolve(filename), null, 0x00000100 | 0x00001000);
131
+ if (!handle) {
132
+ throw new Error(`@smartspectra/node-sdk: could not load packaged runtime ${filename} and its adjacent DLLs`);
133
+ }
134
+ try {
135
+ // Koffi takes its own module reference and owns its lifetime from here.
136
+ return koffi.load(filename);
137
+ } finally {
138
+ free(handle);
139
+ }
140
+ } finally {
141
+ kernel32.unload();
142
+ }
143
+ }
144
+
116
145
  module.exports = {
146
+ loadNativeLibrary,
117
147
  resolveNativeLibrary,
118
148
  // Exposed for diagnostics and tests.
119
149
  LIB_BASENAME,
@@ -39,6 +39,8 @@ const SUPPORTED_EVENTS = new Set([
39
39
  // FrameTransform object declared later.
40
40
  const DEFAULT_FRAME_TRANSFORM = 0;
41
41
 
42
+ let readinessInFlight = false;
43
+
42
44
  class SmartSpectraSDK {
43
45
  /** SDK package version (matches `package.json#version`). */
44
46
  static get version() { return PACKAGE_VERSION; }
@@ -71,6 +73,8 @@ class SmartSpectraSDK {
71
73
  this._source = null;
72
74
  this._explicitCameraSelection = false;
73
75
  this._session = null; // lazy: created on start()
76
+ this._readinessInFlight = false;
77
+
74
78
  this._destroyPromise = null;
75
79
  }
76
80
 
@@ -99,6 +103,7 @@ class SmartSpectraSDK {
99
103
  * Spatial transform applied to every pushed frame.
100
104
  */
101
105
  useCustomInput(frameTransform = DEFAULT_FRAME_TRANSFORM) {
106
+ this._requireReadinessIdle();
102
107
  if (this._explicitCameraSelection) this._requireSourceSelectionAllowed();
103
108
  this._source = { kind: 'custom', frameTransform: frameTransform | 0 };
104
109
  return this;
@@ -119,6 +124,7 @@ class SmartSpectraSDK {
119
124
  * getUserMedia — use the renderer SDK's useMediaStream() there instead.
120
125
  */
121
126
  useCamera(selection = null, options = {}) {
127
+ this._requireReadinessIdle();
122
128
  const isSelection = selection !== null && typeof selection === 'object' && 'kind' in selection;
123
129
  if (!isSelection && arguments.length < 2) {
124
130
  if (this._explicitCameraSelection) this._requireSourceSelectionAllowed();
@@ -145,6 +151,7 @@ class SmartSpectraSDK {
145
151
  * { timestampsPath?, interframeDelayMs?, startOffsetMs?, maxDurationMs?, frameTransform? }
146
152
  */
147
153
  useFile(videoPath, options = null) {
154
+ this._requireReadinessIdle();
148
155
  if (this._explicitCameraSelection) this._requireSourceSelectionAllowed();
149
156
  if (typeof videoPath !== 'string' || !videoPath) {
150
157
  throw new TypeError('useFile: videoPath must be a non-empty string');
@@ -160,6 +167,7 @@ class SmartSpectraSDK {
160
167
  * useCustomInput(), useCamera(), or useFile() first.
161
168
  */
162
169
  start() {
170
+ this._requireReadinessIdle();
163
171
  if (this._explicitCameraSelection) this._requireSourceSelectionAllowed();
164
172
  const source = this._source;
165
173
  if (!source) {
@@ -207,6 +215,11 @@ class SmartSpectraSDK {
207
215
  * Typed selection requires any pending stopAsync() to settle before reset.
208
216
  */
209
217
  reset() {
218
+ this._requireReadinessIdle();
219
+ if (this._readinessService) {
220
+ this._readinessService.closeSync();
221
+ this._readinessService = null;
222
+ }
210
223
  if (!this._session) return;
211
224
  this._session.reset(this._explicitCameraSelection);
212
225
  if (!this._explicitCameraSelection) this._source = null;
@@ -264,18 +277,96 @@ class SmartSpectraSDK {
264
277
  * replacement session, since SDK state is process-global.
265
278
  */
266
279
  destroy() {
267
- if (this._destroyPromise) return this._destroyPromise;
268
- if (!this._session) return Promise.resolve();
269
- const teardown = this._session.destroy();
270
- this._session = null;
271
- this._source = null;
272
- this._destroyPromise = Promise.resolve(teardown).finally(() => { this._destroyPromise = null; });
273
- return this._destroyPromise;
280
+ try {
281
+ if (this._destroyPromise) return this._destroyPromise;
282
+ // An unrelated, unused SDK owns no native state to tear down.
283
+ if (!this._session && !this._readinessService && !this._readinessInFlight) return Promise.resolve();
284
+ this._requireReadinessIdle();
285
+ const service = this._readinessService;
286
+ this._readinessService = null;
287
+ const session = this._session;
288
+ // Destroy the session synchronously unless a readiness service must drain first.
289
+ const teardown = service ? service.close().then(() => session?.destroy()) : session?.destroy();
290
+ this._session = null;
291
+ this._source = null;
292
+ this._destroyPromise = Promise.resolve(teardown).finally(() => { this._destroyPromise = null; });
293
+ return this._destroyPromise;
294
+ } catch (error) {
295
+ return Promise.reject(error);
296
+ }
297
+ }
298
+
299
+ async checkDeviceReadiness() {
300
+ if (this._readinessResultPending) return Object.freeze({ verdict: 'inconclusive',
301
+ reason: 'The SDK is busy. Wait for the current SDK operation to finish.' });
302
+ this._readinessResultPending = true;
303
+ try {
304
+ const result = await this._runDeviceReadiness();
305
+ return Object.freeze({ verdict: result.verdict, reason: result.reason });
306
+ } finally { this._readinessResultPending = false; }
307
+ }
308
+
309
+ async _runDeviceReadiness() {
310
+ const { DeviceReadinessService, DeviceReadinessPolicies } = require('./device-readiness');
311
+ const selected = { ...this._options,
312
+ requestedMetrics: [...(this._options.requestedMetrics?.length
313
+ ? this._options.requestedMetrics : defaultSupportedMetrics)] };
314
+ const source = this._source;
315
+ const preflight = (code, reason) => Object.freeze({ verdict: 'inconclusive', reason,
316
+ diagnostics: JSON.stringify({ sdkVersion: PACKAGE_VERSION, schemaVersion: 1, reason: code,
317
+ reasonName: code, verdictName: 'inconclusive',
318
+ requestedMetrics: selected.requestedMetrics, input: source?.kind ?? 'unconfigured',
319
+ workloadCompleted: false, observationUs: 0, framesAttempted: 0,
320
+ performanceChecks: [], metricOutputs: [], timing: { available: false } }) });
321
+ const busy = () => preflight('busy', 'The SDK is busy. Wait for the current SDK operation to finish.');
322
+ if (readinessInFlight || this._readinessInFlight || this._destroyPromise)
323
+ return busy();
324
+ const status = this.processingStatus;
325
+ if (status === ProcessingStatus.kError) return preflight('reset_required',
326
+ 'The SDK is in an error state. Resolve the previous error, reset the SDK, and retry the readiness check.');
327
+ if (![ProcessingStatus.kUninitialized, ProcessingStatus.kIdle].includes(status)) return busy();
328
+ if (source?.kind !== 'camera') return preflight('unsupported_input',
329
+ 'Select a camera before checking readiness. File and custom-frame inputs are not yet supported; no device capacity verdict was established.');
330
+ this._readinessInFlight = true;
331
+ readinessInFlight = true;
332
+ let service;
333
+ try {
334
+ const capture = source.options ?? {};
335
+ const request = { authenticationPrepared: !!selected.apiKey,
336
+ cameraSelection: source.selection && { ...source.selection },
337
+ camera: { deviceIndex: capture.deviceIndex ?? 0,
338
+ preferredWidth: capture.width || 1280, preferredHeight: capture.height || 720,
339
+ preferredFps: capture.fps || 30 },
340
+ frameWidth: capture.width || 1280, frameHeight: capture.height || 720, targetFps: capture.fps || 30 };
341
+ const policy = DeviceReadinessPolicies.reference720p30('full');
342
+ const metrics = new Set(selected.requestedMetrics);
343
+ request.policy = { ...policy, frameWidth: request.frameWidth, frameHeight: request.frameHeight,
344
+ targetFps: request.targetFps, metrics: policy.metrics.filter(metric => metrics.has(metric.metricId)) };
345
+ if (this._session) {
346
+ await this._session.destroy();
347
+ this._session = null;
348
+ }
349
+ if (!this._readinessService || this._readinessService.closed)
350
+ this._readinessService = new DeviceReadinessService();
351
+ service = this._readinessService;
352
+ if (!service.reserve()) return busy();
353
+ return await service.start(selected, request).result;
354
+ } finally {
355
+ try { if (service) await service.finishCheck(); }
356
+ finally { this._readinessInFlight = false; readinessInFlight = false; }
357
+ }
358
+ }
359
+
360
+ _requireReadinessIdle() {
361
+ if (readinessInFlight || this._readinessInFlight) throw Object.assign(new Error('Device readiness is running'), {
362
+ code: SmartSpectraErrorCode.kInvalidState, retryable: true,
363
+ });
274
364
  }
275
365
 
276
366
  // ---------- internal ----------------------------------------------------
277
367
 
278
368
  _requireSourceSelectionAllowed() {
369
+ this._requireReadinessIdle();
279
370
  if (this._destroyPromise) {
280
371
  throw Object.assign(new Error('SmartSpectra is shutting down'), {
281
372
  code: SmartSpectraErrorCode.kInvalidState, retryable: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartspectra/node-sdk",
3
- "version": "3.5.0-rc.2",
3
+ "version": "3.5.0-rc.5",
4
4
  "description": "Node.js (Electron) FFI binding for SmartSpectra vitals measurement",
5
5
  "author": "Presage Technologies, Inc.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -35,13 +35,15 @@
35
35
  "THIRD_PARTY_FFMPEG.md",
36
36
  "THIRD_PARTY_NOTICES.txt",
37
37
  "NOTICE",
38
+ "docs/device-readiness.md",
39
+ "samples/device_readiness/*.cjs",
38
40
  "README.md"
39
41
  ],
40
42
  "scripts": {
41
43
  "prepare": "node scripts/gen-proto.cjs",
42
44
  "build:proto": "node scripts/gen-proto.cjs",
43
- "test": "npm run test:types && node --test test/log-level-pin.js test/renderer-timestamp.js test/renderer-frame-transform.js test/camera-selection.js test/reset-lifecycle.js test/smoke.js",
44
- "test:types": "tsc --noEmit --strict --skipLibCheck --target es2022 --module nodenext test/camera-selection-types.ts test/renderer-types.ts",
45
+ "test": "npm run test:types && node --test test/strict-results.js test/log-level-pin.js test/renderer-timestamp.js test/renderer-frame-transform.js test/camera-selection.js test/reset-lifecycle.js test/resolve-native.js test/device-readiness.js test/device-readiness-presentation.js test/smoke.js",
46
+ "test:types": "tsc --noEmit --strict --skipLibCheck --target es2022 --module nodenext test/camera-selection-types.ts test/renderer-types.ts test/device-readiness-types.ts",
45
47
  "test:models-allowlist": "node test/models-allowlist.js"
46
48
  },
47
49
  "keywords": [
@@ -71,10 +73,10 @@
71
73
  "dependencies": {
72
74
  "koffi": "^2.10.0",
73
75
  "protobufjs": "^7.6.5",
74
- "@smartspectra/node-sdk-linux-x64": "3.5.0-rc.2",
75
- "@smartspectra/node-sdk-linux-arm64": "3.5.0-rc.2",
76
- "@smartspectra/node-sdk-darwin-arm64": "3.5.0-rc.2",
77
- "@smartspectra/node-sdk-win32-x64": "3.5.0-rc.2"
76
+ "@smartspectra/node-sdk-linux-x64": "3.5.0-rc.5",
77
+ "@smartspectra/node-sdk-linux-arm64": "3.5.0-rc.5",
78
+ "@smartspectra/node-sdk-darwin-arm64": "3.5.0-rc.5",
79
+ "@smartspectra/node-sdk-win32-x64": "3.5.0-rc.5"
78
80
  },
79
81
  "devDependencies": {
80
82
  "@types/node": "^24.0.0",
@@ -0,0 +1,13 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+ const { SmartSpectraSDK } = require('@smartspectra/node-sdk');
5
+ (async () => {
6
+ const sdk = new SmartSpectraSDK({ apiKey: process.env.PHYSIOLOGY_API_KEY });
7
+ sdk.useCamera();
8
+ try {
9
+ const result = await sdk.checkDeviceReadiness();
10
+ console.log(result.verdict, result.reason);
11
+ process.exitCode = result.verdict === 'ready' ? 0 : 2;
12
+ } finally { await sdk.destroy(); }
13
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,8 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+ function present(result) {
5
+ return { title: result.verdict, reason: result.reason, action: result.reason,
6
+ canProceed: result.verdict === 'ready' };
7
+ }
8
+ module.exports = { present };
@@ -0,0 +1,14 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+ const { SmartSpectraSDK } = require('@smartspectra/node-sdk');
5
+ async function main() {
6
+ const sdk = new SmartSpectraSDK({ apiKey: process.env.PHYSIOLOGY_API_KEY });
7
+ sdk.useCamera();
8
+ try {
9
+ const result = await sdk.checkDeviceReadiness();
10
+ console.log(result.verdict);
11
+ process.exitCode = result.verdict === 'ready' ? 0 : 2;
12
+ } finally { await sdk.destroy(); }
13
+ }
14
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });