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

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/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 compatibility: [configured checks and diagnostic results](docs/device-compatibility.md)
17
18
  - Metrics guide: <https://smartspectra.presagetech.com/docs/nodejs/metrics>
18
19
 
19
20
  ## Architecture
@@ -0,0 +1,31 @@
1
+ ---
2
+ title: Node.js Device Compatibility
3
+ description: Check the configured SDK and return a verdict, actionable reason, and diagnostic JSON.
4
+ ---
5
+
6
+ # Node.js Device Compatibility
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
+ After configuring the SDK's authentication, metrics, and camera, call:
11
+
12
+ ```javascript
13
+ const verdict = await sdk.isDeviceCompatible();
14
+ ```
15
+
16
+ Compare the returned verdict explicitly, for example `verdict === 'ready'`.
17
+ Every verdict string is truthy, including `'blocked'`; do not use it as a Boolean. Call `sdk.getDeviceCompatibilityResult()`
18
+ for the saved explanation and diagnostic JSON. The getter is empty before or during
19
+ a check and after an exception; starting a new check clears the previous result. There is no separate configuration input
20
+ or public policy/timing override. The check uses the SDK's configured workload,
21
+ keeps its settings unchanged, and rejects competing measurement operations.
22
+
23
+ The selected camera supplies capture pacing; bundled reference imagery replaces
24
+ captured pixels. Stable checks may finish after 20 seconds of observation;
25
+ otherwise observation continues to approximately 30 seconds. Setup and cleanup
26
+ add time. File/custom-frame inputs are currently Inconclusive with an explanation.
27
+
28
+ Await the result; the check does not block the JavaScript event loop.
29
+
30
+ See [Device Compatibility Results and Recovery](../../docs/device-compatibility-experience.md)
31
+ for complete setup, verdict meanings, execution safety, and JSON export examples.
package/js/constants.js CHANGED
@@ -80,6 +80,7 @@ const SmartSpectraErrorCode = Object.freeze({
80
80
  kFrameConversionFailed: 9,
81
81
  kNonMonotonicTimestamp: 10,
82
82
  kTimestampGap: 11,
83
+ kMetricNotAuthorized: 13,
83
84
  });
84
85
 
85
86
  // Spatial transform applied to each pushed frame before it enters the
@@ -0,0 +1,371 @@
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 v8.');
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)')() !== 8) 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
+ start: lib.func('void *smartspectra_readiness_start(void *service, const smartspectra_config_t *config, const smartspectra_readiness_request_t *request)'),
83
+ cancel: lib.func('void smartspectra_readiness_cancel(void *check)'),
84
+ background: lib.func('void smartspectra_readiness_background(void *check)'),
85
+ progress: lib.func('int smartspectra_readiness_progress(void *check, _Out_ smartspectra_readiness_progress_t *out)'),
86
+ poll: lib.func('int smartspectra_readiness_poll(void *check, _Out_ smartspectra_readiness_result_t *out)'),
87
+ free: lib.func('void smartspectra_readiness_check_free(void *check)'),
88
+ };
89
+ return native;
90
+ }
91
+ const verdicts = ['ready', 'limited', 'blocked', 'inconclusive'];
92
+ const reasons = ['qualified', 'performance_below_policy', 'invalid_configuration',
93
+ 'authentication_required', 'authorization_denied', 'input_unavailable', 'setup_failed',
94
+ 'run_incomplete', 'insufficient_evidence', 'policy_unavailable', 'backend_unknown',
95
+ 'configuration_changed', 'cancelled', 'deadline_exceeded', 'application_backgrounded',
96
+ 'busy', 'camera_interrupted', 'permission_required', 'permission_denied',
97
+ 'borderline_performance', 'experimental_opt_in_required', 'unsupported_capability'];
98
+ const actions = ['proceed', 'retry', 'authenticate', 'obtain_authorization', 'correct_configuration',
99
+ 'supply_policy', 'reduce_workload', 'collect_evidence', 'inspect_backend', 'release_session',
100
+ 'restore_input', 'grant_permission', 'return_to_foreground', 'resolve_setup',
101
+ 'enable_experimental', 'choose_supported_workload'];
102
+ const phases = ['preparing', 'running', 'cleaning_up', 'finalizing'];
103
+ const scopes = ['reference_frame', 'camera_paced_reference_frame'];
104
+ const permissions = ['unknown', 'granted', 'denied'];
105
+ const booleanFields = new Set(['output_required', 'retryable', 'message_truncated', 'available', 'has_deadline', 'phases_available', 'timing_available',
106
+ 'authorization_complete', 'backend_coverage_complete', 'input_validated',
107
+ 'camera_geometry_changed', 'workload_completed', 'early_completion_requested']);
108
+ function fields(object) {
109
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => [
110
+ key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
111
+ booleanFields.has(key) ? !!value : typeof value === 'bigint' ? Number(value) : value,
112
+ ]));
113
+ }
114
+ function array(pointer, type, length) {
115
+ return Number(length) ? Array.from(koffi.decode(pointer, koffi.array(type, Number(length)))) : [];
116
+ }
117
+ function policyFromNative(p) {
118
+ const { metrics, metrics_len, early_window_us, early_stable_windows,
119
+ early_minimum_headroom, ...rest } = p;
120
+ return { ...fields(rest),
121
+ metrics: array(metrics, 'smartspectra_readiness_requirement_t', metrics_len).map(fields),
122
+ earlyCompletion: { windowUs: Number(early_window_us), stableWindows: early_stable_windows,
123
+ minimumHeadroom: early_minimum_headroom } };
124
+ }
125
+ function freeze(value) {
126
+ if (value && typeof value === 'object') {
127
+ Object.values(value).forEach(freeze);
128
+ Object.freeze(value);
129
+ }
130
+ return value;
131
+ }
132
+ function resultFromNative(r) {
133
+ return freeze({ verdict: verdicts[r.verdict], reason: r.reason, diagnostics: r.diagnostics });
134
+ }
135
+
136
+ function number(value, fallback, name, integer = false, min = -Infinity, max = Infinity) {
137
+ const result = value === undefined ? fallback : value;
138
+ if (typeof result !== 'number' || !Number.isFinite(result) ||
139
+ (integer && !Number.isSafeInteger(result)) || result < min || result > max)
140
+ throw new TypeError(`${name} must be a finite ${integer ? 'integer' : 'number'} in range.`);
141
+ return result;
142
+ }
143
+ const int = (v, d, n) => number(v, d, n, true, -2147483648, 2147483647);
144
+ const us = (v, d, n) => number(v, d, n, true, 0, Number.MAX_SAFE_INTEGER);
145
+ function string(value, fallback, name) {
146
+ const result = value === undefined ? fallback : value;
147
+ if (typeof result !== 'string' || result.includes('\0')) throw new TypeError(`${name} must be a string without NUL.`);
148
+ return result;
149
+ }
150
+ function flag(value, fallback, name) {
151
+ if (value === undefined) return +fallback;
152
+ if (typeof value !== 'boolean') throw new TypeError(`${name} must be boolean.`);
153
+ return +value;
154
+ }
155
+ function choice(value, fallback, choices, name) {
156
+ const index = choices.indexOf(value ?? fallback);
157
+ if (index < 0) throw new TypeError(`Unsupported ${name}.`);
158
+ return index;
159
+ }
160
+ function policyToNative(p = {}) {
161
+ const early = p.earlyCompletion ?? {};
162
+ if (!Array.isArray(p.metrics ?? [])) throw new TypeError('policy.metrics must be an array.');
163
+ return {
164
+ version: string(p.version, '', 'policy.version'), rationale: string(p.rationale, '', 'policy.rationale'),
165
+ input_mode: string(p.inputMode, '', 'policy.inputMode'),
166
+ frame_width: int(p.frameWidth, 0, 'policy.frameWidth'), frame_height: int(p.frameHeight, 0, 'policy.frameHeight'),
167
+ target_fps: number(p.targetFps, 0, 'policy.targetFps'),
168
+ minimum_throughput_fps: number(p.minimumThroughputFps, 0, 'policy.minimumThroughputFps'),
169
+ minimum_duration_us: us(p.minimumDurationUs, 0, 'policy.minimumDurationUs'),
170
+ metrics: (p.metrics ?? []).map(m => ({ metric_id: int(m.metricId, 0, 'metricId'),
171
+ minimum_samples: us(m.minimumSamples, 2, 'minimumSamples'),
172
+ minimum_output_hz: number(m.minimumOutputHz, 0, 'minimumOutputHz'),
173
+ output_required: flag(m.outputRequired, true, 'outputRequired') })),
174
+ metrics_len: (p.metrics ?? []).length,
175
+ early_window_us: us(early.windowUs, 0, 'earlyCompletion.windowUs'),
176
+ early_stable_windows: number(early.stableWindows, 0, 'earlyCompletion.stableWindows', true, 0, 4294967295),
177
+ early_minimum_headroom: number(early.minimumHeadroom, 0, 'earlyCompletion.minimumHeadroom'),
178
+ uncertainty_fraction: number(p.uncertaintyFraction, 0, 'policy.uncertaintyFraction'),
179
+ };
180
+ }
181
+ function requestToNative(request) {
182
+ const frame = request.referenceBgrFrame ?? Buffer.alloc(0);
183
+ if (!(frame instanceof Uint8Array)) throw new TypeError('referenceBgrFrame must be a Buffer or Uint8Array.');
184
+ const camera = request.camera ?? {};
185
+ return {
186
+ struct_size: koffi.sizeof('smartspectra_readiness_request_t'),
187
+ reserved_opt_in: 0,
188
+ authentication_prepared: flag(request.authenticationPrepared, false, 'authenticationPrepared'),
189
+ scope: choice(request.scope, 'camera_paced_reference_frame', scopes, 'scope'),
190
+ camera_permission: choice(request.cameraPermission, 'unknown', permissions, 'cameraPermission'),
191
+ camera_index: int(camera.deviceIndex, 0, 'camera.deviceIndex'),
192
+ camera_width: int(camera.preferredWidth, 1280, 'camera.preferredWidth'),
193
+ camera_height: int(camera.preferredHeight, 720, 'camera.preferredHeight'),
194
+ camera_fps: int(camera.preferredFps, 30, 'camera.preferredFps'),
195
+ frame_width: int(request.frameWidth, 1280, 'frameWidth'),
196
+ frame_height: int(request.frameHeight, 720, 'frameHeight'),
197
+ target_fps: number(request.targetFps, 30, 'targetFps'),
198
+ duration_us: us(request.durationUs, 30000000, 'durationUs'),
199
+ maximum_wait_us: us(request.maximumWaitUs, 40000000, 'maximumWaitUs'),
200
+ cleanup_reserve_us: us(request.cleanupReserveUs, 5000000, 'cleanupReserveUs'),
201
+ reference_bgr_frame: frame, reference_bgr_frame_len: frame.byteLength,
202
+ policy: policyToNative(request.policy),
203
+ camera_selection_kind: request.cameraSelection ? choice(request.cameraSelection.kind, '', ['default', 'front', 'back', 'byId'], 'cameraSelection.kind') : -1,
204
+ camera_selection_id: request.cameraSelection?.id ?? null,
205
+ };
206
+ }
207
+ function configurationToNative(config) {
208
+ const metrics = config.requestedMetrics ?? [];
209
+ if (!Array.isArray(metrics)) throw new TypeError('requestedMetrics must be an array.');
210
+ const requested = Int32Array.from(metrics.map(m => int(m, 0, 'requested metric')));
211
+ return { api_key: string(config.apiKey, '', 'apiKey'), requested_metrics: requested,
212
+ requested_metrics_len: requested.length,
213
+ enable_accumulated_output: flag(config.enableAccumulatedOutput, false, 'enableAccumulatedOutput'),
214
+ log_level: config.logLevel === undefined ? 0 : number(config.logLevel, 0, 'logLevel', true, 0, 4) + 1,
215
+ struct_size: koffi.sizeof('smartspectra_config_t'),
216
+ disable_telemetry: 1 - flag(config.enableTelemetry, true, 'enableTelemetry'), reserved0: 0 };
217
+ }
218
+ function progressFromNative(n, handle) {
219
+ const progress = {};
220
+ if (n.progress(handle, progress) !== 0) throw new Error('Unable to read native readiness progress.');
221
+ return freeze({ ...fields(progress), phase: phases[progress.phase] });
222
+ }
223
+ class DeviceCompatibilityCheck {
224
+ #handle; #native; #progress; #value; #promise;
225
+ constructor(n, handle, signal, finished) {
226
+ this.#native = n;
227
+ this.#handle = handle;
228
+ this.#progress = freeze({ phase: 'preparing', timingAvailable: false, elapsedUs: 0,
229
+ hasDeadline: false, remainingUs: 0 });
230
+ const abort = () => this.cancel();
231
+ let timer;
232
+ this.#promise = new Promise((resolve, reject) => {
233
+ const tick = () => {
234
+ try {
235
+ this.#progress = progressFromNative(n, handle);
236
+ const result = {};
237
+ const status = n.poll(handle, result);
238
+ if (status === 0) return;
239
+ if (status !== 1) throw new Error('Unable to read native readiness result.');
240
+ this.#value = resultFromNative(result);
241
+ resolve(this.#value);
242
+ } catch (error) { reject(error); }
243
+ clearInterval(timer);
244
+ // A user-overridden listener method must not prevent native
245
+ // handle cleanup or leave close() waiting on an abandoned check.
246
+ try { signal?.removeEventListener('abort', abort); } catch {}
247
+ try { n.free(handle); }
248
+ finally { this.#handle = null; finished(this); }
249
+ };
250
+ // Referenced timer retains the event loop until native cleanup and
251
+ // result copying finish, even if the caller only awaits result.
252
+ timer = setInterval(tick, 25);
253
+ });
254
+ // Keep ignored result promises from becoming unhandled rejections.
255
+ this.#promise.catch(() => {});
256
+ try {
257
+ signal?.addEventListener('abort', abort, { once: true });
258
+ if (signal?.aborted) this.cancel();
259
+ } catch (error) {
260
+ clearInterval(timer);
261
+ try { signal?.removeEventListener('abort', abort); } catch {}
262
+ this.#handle = null;
263
+ throw error; // start() still owns the native handle on this path.
264
+ }
265
+ }
266
+ get result() { return this.#promise; }
267
+ poll() { return this.#value; }
268
+ progress() {
269
+ if (this.#handle) this.#progress = progressFromNative(this.#native, this.#handle);
270
+ return this.#progress;
271
+ }
272
+ cancel() { if (this.#handle) this.#native.cancel(this.#handle); }
273
+ applicationBackgrounded() { if (this.#handle) this.#native.background(this.#handle); }
274
+ }
275
+ const services = new FinalizationRegistry(({ n, handle }) => {
276
+ // Best effort for abandoned services; explicit close() is deterministic.
277
+ n.destroy.async(handle, () => {});
278
+ });
279
+ class DeviceCompatibilityService {
280
+ static async run(configuration = {}) {
281
+ const service = new DeviceCompatibilityService();
282
+ try { return await service.check(configuration).result; }
283
+ finally { await service.close(); }
284
+ }
285
+ #native; #handle; #checks = new Set(); #closing;
286
+ constructor() {
287
+ this.#native = getNative();
288
+ ensurePreconfigured();
289
+ this.#handle = this.#native.create();
290
+ if (!this.#handle) throw new Error('Unable to create native readiness service.');
291
+ services.register(this, { n: this.#native, handle: this.#handle }, this);
292
+ }
293
+ reserve() {
294
+ if (!this.#handle || this.#closing) throw new Error('Compatibility service is closed.');
295
+ const result = this.#native.reserve(this.#handle);
296
+ if (result < 0) throw new Error('Unable to reserve SDK state.');
297
+ return result === 1;
298
+ }
299
+ check(configuration, referenceBgrFrame = undefined, workload = 'full', scope = 'camera_paced_reference_frame') {
300
+ const policy = DeviceCompatibilityPolicies.reference720p30(workload, scope);
301
+ return this.start({ ...configuration, requestedMetrics: policy.metrics.map(m => m.metricId) }, {
302
+ referenceBgrFrame, policy, scope, authenticationPrepared: !!configuration?.apiKey,
303
+ });
304
+ }
305
+ start(configuration = {}, request = {}) {
306
+ if (!this.#handle || this.#closing) throw new Error('Readiness service is closed.');
307
+ const signal = request.signal;
308
+ if (signal !== undefined && !(signal instanceof AbortSignal))
309
+ throw new TypeError('signal must be an AbortSignal.');
310
+ if (signal?.aborted) throw new DOMException('Readiness was aborted before submission.', 'AbortError');
311
+ const config = configurationToNative(configuration);
312
+ const input = requestToNative(request);
313
+ // Configuration/request getters can call close() during conversion.
314
+ if (!this.#handle || this.#closing) throw new Error('Readiness service is closed.');
315
+ const handle = this.#native.start(this.#handle, config, input);
316
+ if (!handle) throw new Error('Unable to submit native readiness request.');
317
+ let check;
318
+ try {
319
+ check = new DeviceCompatibilityCheck(this.#native, handle, signal, value => this.#checks.delete(value));
320
+ } catch (error) {
321
+ try { this.#native.cancel(handle); }
322
+ finally { this.#native.free(handle); }
323
+ throw error;
324
+ }
325
+ this.#checks.add(check);
326
+ return check;
327
+ }
328
+ close() {
329
+ if (this.#closing) return this.#closing;
330
+ const checks = [...this.#checks];
331
+ checks.forEach(check => check.cancel());
332
+ this.#closing = Promise.allSettled(checks.map(check => check.result)).then(() => new Promise((resolve, reject) => {
333
+ const handle = this.#handle;
334
+ this.#handle = null;
335
+ services.unregister(this);
336
+ this.#native.destroy.async(handle, error => error ? reject(error) : resolve());
337
+ }));
338
+ return this.#closing;
339
+ }
340
+ }
341
+ const DeviceCompatibilityPolicies = Object.freeze({
342
+ reference720p30(workload = 'full', scope = 'camera_paced_reference_frame') {
343
+ const tier = choice(workload, 'full', ['breathing_pulse_eda', 'breathing_pulse', 'breathing', 'full'], 'workload');
344
+ const policy = {};
345
+ if (getNative().referencePolicy(tier, choice(scope, 'camera_paced_reference_frame', scopes, 'scope'), policy) !== 0) throw new Error('Unable to read native readiness policy.');
346
+ return freeze(policyFromNative(policy));
347
+ },
348
+ fallback(result) {
349
+ const policy = {};
350
+ const status = getNative().fallbackPolicy(policyToNative(result.policy),
351
+ choice(result.qualification.verdict, '', verdicts, 'verdict'),
352
+ choice(result.qualification.reason, '', reasons, 'reason'), policy);
353
+ if (status === 1) return undefined;
354
+ if (status !== 0) throw new Error('Unable to read native fallback policy.');
355
+ return freeze(policyFromNative(policy));
356
+ },
357
+ chestTraceReference720p30() {
358
+ const policy = {};
359
+ if (getNative().policy(policy) !== 0) throw new Error('Unable to read native readiness policy.');
360
+ return freeze(policyFromNative(policy));
361
+ },
362
+ });
363
+ function deviceCompatibilityAvailable() { try { getNative(); return true; } catch { return false; } }
364
+ module.exports = { DeviceCompatibilityService, DeviceCompatibilityPolicies, deviceCompatibilityAvailable, busyResult };
365
+
366
+ // A rejected busy check has no measurement evidence. Keep the full result shape.
367
+ function busyResult() {
368
+ return freeze({ verdict: 'inconclusive',
369
+ reason: 'The SDK is busy. Wait for the current SDK operation to finish.',
370
+ diagnostics: JSON.stringify({ reason: 'busy', workloadCompleted: false }) });
371
+ }
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,8 @@ 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;
79
81
  };
80
82
  export type SmartSpectraErrorCodeValue = typeof SmartSpectraErrorCode[keyof typeof SmartSpectraErrorCode];
81
83
 
@@ -212,6 +214,10 @@ export interface CameraOptions {
212
214
  * ```
213
215
  */
214
216
  export declare class SmartSpectraSDK {
217
+ /** Device Compatibility uses this SDK's authentication. */
218
+ isDeviceCompatible(): Promise<DeviceCompatibilityVerdict>;
219
+ /** Latest completed result; undefined before/during a check or after an error. */
220
+ getDeviceCompatibilityResult(): DeviceCompatibilityResult | undefined;
215
221
  /** SDK package version. */
216
222
  static readonly version: string;
217
223
 
@@ -367,3 +373,13 @@ export declare function setMetricsClass(cls: unknown): void;
367
373
  * been registered via `setMetricsClass`, otherwise the raw Buffer.
368
374
  */
369
375
  export declare function decodeMetrics(buf: Buffer): unknown;
376
+
377
+ /** Device Compatibility. Pin the JS and native SDK versions together. */
378
+ /** Outcome for the configured SDK workload. */
379
+ export type DeviceCompatibilityVerdict = 'ready' | 'limited' | 'blocked' | 'inconclusive';
380
+ export interface DeviceCompatibilityResult {
381
+ readonly verdict: DeviceCompatibilityVerdict;
382
+ readonly reason: string;
383
+ /** Native JSON support report; detailed fields are not a stable typed API. */
384
+ readonly diagnostics: string;
385
+ }
@@ -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 compatibilityInFlight = 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._compatibilityInFlight = 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._requireCompatibilityIdle();
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._requireCompatibilityIdle();
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._requireCompatibilityIdle();
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._requireCompatibilityIdle();
163
171
  if (this._explicitCameraSelection) this._requireSourceSelectionAllowed();
164
172
  const source = this._source;
165
173
  if (!source) {
@@ -207,6 +215,7 @@ class SmartSpectraSDK {
207
215
  * Typed selection requires any pending stopAsync() to settle before reset.
208
216
  */
209
217
  reset() {
218
+ this._requireCompatibilityIdle();
210
219
  if (!this._session) return;
211
220
  this._session.reset(this._explicitCameraSelection);
212
221
  if (!this._explicitCameraSelection) this._source = null;
@@ -264,18 +273,93 @@ class SmartSpectraSDK {
264
273
  * replacement session, since SDK state is process-global.
265
274
  */
266
275
  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;
276
+ try {
277
+ if (this._destroyPromise) return this._destroyPromise;
278
+ // An unrelated, unused SDK owns no native state to tear down.
279
+ if (!this._session && !this._compatibilityInFlight) return Promise.resolve();
280
+ this._requireCompatibilityIdle();
281
+ const teardown = this._session.destroy();
282
+ this._session = null;
283
+ this._source = null;
284
+ this._destroyPromise = Promise.resolve(teardown).finally(() => { this._destroyPromise = null; });
285
+ return this._destroyPromise;
286
+ } catch (error) {
287
+ return Promise.reject(error);
288
+ }
289
+ }
290
+
291
+ async isDeviceCompatible() {
292
+ if (this._compatibilityResultPending) return 'inconclusive';
293
+ this._compatibilityResult = undefined;
294
+ this._compatibilityResultPending = true;
295
+ try {
296
+ const result = await this._runDeviceCompatibility();
297
+ this._compatibilityResult = Object.freeze({ ...result });
298
+ return result.verdict;
299
+ } finally { this._compatibilityResultPending = false; }
300
+ }
301
+
302
+ getDeviceCompatibilityResult() { return this._compatibilityResult; }
303
+
304
+ async _runDeviceCompatibility() {
305
+ const { DeviceCompatibilityService, DeviceCompatibilityPolicies } = require('./device-compatibility');
306
+ const selected = { ...this._options,
307
+ requestedMetrics: [...(this._options.requestedMetrics?.length
308
+ ? this._options.requestedMetrics : defaultSupportedMetrics)] };
309
+ const source = this._source;
310
+ const preflight = (code, reason) => Object.freeze({ verdict: 'inconclusive', reason,
311
+ diagnostics: JSON.stringify({ sdkVersion: PACKAGE_VERSION, schemaVersion: 1, reason: code,
312
+ reasonName: code, verdictName: 'inconclusive',
313
+ requestedMetrics: selected.requestedMetrics, input: source?.kind ?? 'unconfigured',
314
+ workloadCompleted: false, observationUs: 0, framesAttempted: 0,
315
+ performanceChecks: [], metricOutputs: [], timing: { available: false } }) });
316
+ const busy = () => preflight('busy', 'The SDK is busy. Wait for the current SDK operation to finish.');
317
+ if (compatibilityInFlight || this._compatibilityInFlight || this._destroyPromise)
318
+ return busy();
319
+ const status = this.processingStatus;
320
+ if (status === ProcessingStatus.kError) return preflight('reset_required',
321
+ 'The SDK is in an error state. Resolve the previous error, reset the SDK, and retry the compatibility check.');
322
+ if (![ProcessingStatus.kUninitialized, ProcessingStatus.kIdle].includes(status)) return busy();
323
+ if (source?.kind !== 'camera') return preflight('unsupported_input',
324
+ 'Select a camera before checking compatibility. File and custom-frame inputs are not yet supported; no device capacity verdict was established.');
325
+ this._compatibilityInFlight = true;
326
+ compatibilityInFlight = true;
327
+ let service;
328
+ try {
329
+ const capture = source.options ?? {};
330
+ const request = { authenticationPrepared: !!selected.apiKey,
331
+ cameraSelection: source.selection && { ...source.selection },
332
+ camera: { deviceIndex: capture.deviceIndex ?? 0,
333
+ preferredWidth: capture.width || 1280, preferredHeight: capture.height || 720,
334
+ preferredFps: capture.fps || 30 },
335
+ frameWidth: capture.width || 1280, frameHeight: capture.height || 720, targetFps: capture.fps || 30 };
336
+ const policy = DeviceCompatibilityPolicies.reference720p30('full');
337
+ const metrics = new Set(selected.requestedMetrics);
338
+ request.policy = { ...policy, frameWidth: request.frameWidth, frameHeight: request.frameHeight,
339
+ targetFps: request.targetFps, metrics: policy.metrics.filter(metric => metrics.has(metric.metricId)) };
340
+ if (this._session) {
341
+ await this._session.destroy();
342
+ this._session = null;
343
+ }
344
+ service = new DeviceCompatibilityService();
345
+ if (!service.reserve()) return busy();
346
+ return await service.start(selected, request).result;
347
+ } finally {
348
+ try { if (service) await service.close(); }
349
+ finally { this._compatibilityInFlight = false; compatibilityInFlight = false; }
350
+ }
351
+ }
352
+
353
+ _requireCompatibilityIdle() {
354
+ if (compatibilityInFlight || this._compatibilityInFlight) throw Object.assign(new Error('Device compatibility is running'), {
355
+ code: SmartSpectraErrorCode.kInvalidState, retryable: true,
356
+ });
274
357
  }
275
358
 
276
359
  // ---------- internal ----------------------------------------------------
277
360
 
278
361
  _requireSourceSelectionAllowed() {
362
+ this._requireCompatibilityIdle();
279
363
  if (this._destroyPromise) {
280
364
  throw Object.assign(new Error('SmartSpectra is shutting down'), {
281
365
  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.3",
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-compatibility.md",
39
+ "samples/device_compatibility/*.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/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-compatibility.js test/device-compatibility-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-compatibility-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.3",
77
+ "@smartspectra/node-sdk-linux-arm64": "3.5.0-rc.3",
78
+ "@smartspectra/node-sdk-darwin-arm64": "3.5.0-rc.3",
79
+ "@smartspectra/node-sdk-win32-x64": "3.5.0-rc.3"
78
80
  },
79
81
  "devDependencies": {
80
82
  "@types/node": "^24.0.0",
@@ -0,0 +1,16 @@
1
+ // Copyright (C) 2026 Presage Technologies, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Proprietary
3
+ 'use strict';
4
+ const { SmartSpectraSDK } = require('@smartspectra/node-sdk');
5
+ const { writeFileSync } = require('node:fs');
6
+ (async () => {
7
+ const sdk = new SmartSpectraSDK({ apiKey: process.env.PHYSIOLOGY_API_KEY });
8
+ sdk.useCamera();
9
+ try {
10
+ await sdk.isDeviceCompatible();
11
+ const result = sdk.getDeviceCompatibilityResult();
12
+ console.log(result.verdict, result.reason);
13
+ if (process.argv[2]) writeFileSync(process.argv[2], result.diagnostics, { flag: 'wx', mode: 0o600 });
14
+ process.exitCode = result.verdict === 'ready' ? 0 : 2;
15
+ } finally { await sdk.destroy(); }
16
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,9 @@
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
+ function supportReport(result) { return result.diagnostics; }
9
+ module.exports = { present, supportReport };
@@ -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 verdict = await sdk.isDeviceCompatible();
10
+ console.log(verdict);
11
+ process.exitCode = verdict === 'ready' ? 0 : 2;
12
+ } finally { await sdk.destroy(); }
13
+ }
14
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });