@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 +2 -1
- package/README.md +1 -0
- package/THIRD_PARTY_NOTICES.txt +3 -2
- package/docs/device-readiness.md +61 -0
- package/js/constants.js +5 -8
- package/js/device-readiness.js +430 -0
- package/js/ffi.js +3 -2
- package/js/index.d.ts +31 -5
- package/js/main/index.d.ts +1 -1
- package/js/messages/generated.d.ts +16 -10
- package/js/messages/generated.js +33 -10
- package/js/renderer/index.d.ts +3 -3
- package/js/resolve-native.js +30 -0
- package/js/smartspectra.js +98 -7
- package/package.json +9 -7
- package/samples/device_readiness/main.cjs +13 -0
- package/samples/device_readiness/presentation.cjs +8 -0
- package/samples/device_readiness/simple.cjs +14 -0
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
|
|
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
|
package/THIRD_PARTY_NOTICES.txt
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
|
139
|
-
//
|
|
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 {
|
|
20
|
+
const { loadNativeLibrary } = require('./resolve-native');
|
|
21
21
|
|
|
22
|
-
const lib = koffi
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/js/main/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { BrowserWindow } from 'electron';
|
|
7
7
|
|
|
8
|
-
export { SmartSpectraSDK, ProcessingStatus } from '
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
/**
|
package/js/messages/generated.js
CHANGED
|
@@ -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]
|
|
2733
|
-
* @property {Array.<presage.smartspectra.IDetectionStatus>|null} [apnea]
|
|
2734
|
-
* @property {Array.<presage.smartspectra.IMeasurement>|null} [respiratoryLineLength]
|
|
2735
|
-
* @property {Array.<presage.smartspectra.IMeasurement>|null} [baseline]
|
|
2736
|
-
* @property {Array.<presage.smartspectra.IMeasurement>|null} [inhaleExhaleRatio]
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
package/js/renderer/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
ValidationCodeValue,
|
|
10
10
|
SmartSpectraErrorCodeValue,
|
|
11
11
|
SmartSpectraOptions,
|
|
12
|
-
} from '
|
|
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 '
|
|
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 '
|
|
38
|
+
} from '../index';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Renderer constructor options: the shared SDK options plus a renderer-only
|
package/js/resolve-native.js
CHANGED
|
@@ -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,
|
package/js/smartspectra.js
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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.
|
|
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.
|
|
75
|
-
"@smartspectra/node-sdk-linux-arm64": "3.5.0-rc.
|
|
76
|
-
"@smartspectra/node-sdk-darwin-arm64": "3.5.0-rc.
|
|
77
|
-
"@smartspectra/node-sdk-win32-x64": "3.5.0-rc.
|
|
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; });
|