|
Novarum DX Ltd
|
Document ID: IFU049
|
Overview
The NDX Imaging Web SDK (@novarumdx/ndx-imaging-web) enables web applications to capture and analyse lateral flow tests using the device camera. It handles camera setup, image capture, and analysis result delivery using a WebAssembly imaging engine running in a background web worker.
This guide explains how to integrate the NovarumDX Reader SDK WASM package into plain HTML and JavaScript applications without a framework. It combines quickstart, installation, scan flow, results handling, diagnostics, and configuration reference into a single practical guide.
Deployment benefit
The SDK does not require SharedArrayBuffer and therefore does not require COOP or COEP headers. This makes deployment simpler on standard web hosting platforms.
Content Security Policy (CSP)
The SDK runs its imaging engine in a same-origin Web Worker and compiles a WebAssembly module. If your application (or a proxy in front of it) sets a Content-Security-Policy, include these directives so the Worker, WASM, captured images, and same-origin fetches are allowed:
|
Directive |
Value |
Why it is needed |
|---|---|---|
|
|
|
The SDK creates a same-origin Web Worker ( |
|
|
|
The Worker |
|
|
|
The SDK fetches the unified cassette JSON and the |
|
|
|
Captured frames and PMF-story images are shown via |
A minimal policy that works with the SDK:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
worker-src 'self';
connect-src 'self';
img-src 'self' blob:;
-
'wasm-unsafe-eval'is the modern directive for WebAssembly compilation; very old browsers may instead require'unsafe-eval'inscript-src. -
The live camera feed uses a
MediaStream(viasrcObject), which CSP does not gate, so nomedia-srcdirective is needed for it. -
The SDK does not use
SharedArrayBuffer, so COOP / COEP headers are not required.
Before You Start
|
Requirement |
Detail |
|---|---|
|
Node.js |
18 or later |
|
npm |
8 or later |
|
Browser |
Chrome 80+, Safari 15+, Firefox 79+, Edge 80+ |
|
HTTPS |
Required for camera access in deployed environments; localhost counts during local development |
|
AWS CLI |
Required to fetch a registry auth token |
|
Registry credentials |
AWS Access Key ID and Secret Access Key provided by NovarumDX |
|
Cassette configuration file |
Your assay-specific |
Important
Browser camera access is usually only available in secure contexts. Use HTTPS in deployed environments, or localhost during local development. Opening pages with file:// will not work.
If you want the fastest path to a working scanner, start with the quickstart below. If you are setting up package access, CI, or troubleshooting installation issues, use the installation section first.
Vanilla JS Quickstart
Get from zero to a working scanner in a plain HTML and JavaScript project with no framework and no build step.
Assumptions
This quickstart assumes you already have Node 18+, your .npmrc file from NovarumDX, and your cassette .unified.json file.
Temporary note - SDK v1.0.0 (removed in the next release)
The self-contained browser bundle ndx-imaging-web.browser.mjs is not yet included in the published 1.0.0 package, so it will not appear in your project root after installing. This is fixed in the next release. Until you upgrade, make two small adjustments while following this guide:
-
After
npm install, copy the SDK's bundle folder next to your app:cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk -
Wherever this guide imports from
./ndx-imaging-web.browser.mjs, import from./sdk/index.mjsinstead - for exampleimport { createNDXReader } from './sdk/index.mjs';
Once you move to the release that includes the bundle, undo both adjustments and follow the guide exactly as written.
Step 1: Create a project folder
mkdir my-scanner
cd my-scanner
npm init -y
Step 2: Configure registry access
If you haven't already, install the AWS CLI and configure a profile with the credentials from your NovarumDX onboarding pack (use region eu-west-2 and output json):
If you haven't already, install the AWS CLI and configure a profile with the credentials from your Novarum DX onboarding pack (use region eu-west-2 and output json):
aws configure --profile novarumdx-sdk
Then fetch an auth token:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx --domain-owner 945969778369 --region eu-west-2 \
--query authorizationToken --output text --profile novarumdx-sdk)
If npm install later fails with 401 Unauthorized, refresh the token and try again. The token expires after 12 hours.
Step 3: Install the SDK
NDX_WASM_PUBLIC_DIR=. npm install @novarumdx/ndx-imaging-web
The SDK's install step copies four runtime files. By default it copies them into a public/ folder; the NDX_WASM_PUBLIC_DIR=. above tells it to copy them into your project root instead, so they sit next to index.html. After installation, these files should appear in your project root:
-
imagingWorker.js -
ndx_imaging_wasm.js -
ndx_imaging_wasm.wasm -
ndx-imaging-web.browser.mjs
Do not delete or move these generated files. They are required at runtime and must be served alongside your application.
Temporary note — SDK v1.0.0
On 1.0.0, ndx-imaging-web.browser.mjs will not appear in the list above. Do adjustment 1 from the note at the top of this section now — copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk
Step 4: Add your cassette file
Create a cassettes folder and copy your cassette file into it:
my-scanner/
cassettes/
my-cassette/
my-cassette.unified.json
Step 5: Create index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scanner</title>
<style>
body { margin: 0; font-family: sans-serif; }
#scanner-root { width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
#results { padding: 2rem; display: none; }
button { padding: 1rem 2rem; font-size: 1rem; margin-top: 1rem; display: block; }
</style>
</head>
<body>
<div id="scanner-root"></div>
<div id="results">
<h2>Results</h2>
<p id="result-text"></p>
<button id="btn-again">Scan again</button>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
Step 6: Create app.js
import { createNDXReader } from './ndx-imaging-web.browser.mjs';
const scannerRoot = document.getElementById('scanner-root');
const resultsDiv = document.getElementById('results');
const resultText = document.getElementById('result-text');
const btnAgain = document.getElementById('btn-again');
const scanner = createNDXReader({
config: 'cassettes/my-cassette/my-cassette.unified.json',
onReady: () => {
scanner.start();
},
onSuccess: (strips) => {
scanner.stop();
scannerRoot.style.display = 'none';
console.log('Scan result (StripAnalysis[]):', strips);
const strip = strips[0];
resultText.textContent =
`Control line: ${strip.cHeight.toFixed(4)} | T/C ratio: ${strip.tcRatio[0].toFixed(4)}`;
resultsDiv.style.display = 'block';
},
onError: (error) => {
console.error('Scanner error:', error.message);
},
});
scanner.mount(scannerRoot);
btnAgain.addEventListener('click', () => {
resultsDiv.style.display = 'none';
scannerRoot.style.display = 'block';
scanner.start();
});
Temporary note — SDK v1.0.0
On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs - that is, import { createNDXReader } from './sdk/index.mjs'; (adjustment 2 from the note at the top of this section).
Step 7: Serve and open
Use a local HTTP server:
npx serve .
Open http://localhost:3000, allow camera access, and hold a cassette in front of the camera.
Testing on a phone
The scanner is built for phone cameras, so you'll usually want to test on a real device.
Camera access needs a secure context
localhost counts, but a plain http://<your-IP> address on a phone does not - the page loads but the camera won't start (an insecure-context error). So the phone has to reach the app over HTTPS.
Easiest: a tunnel (no certificates). A tunnel gives you a public, already-trusted HTTPS URL, so there's nothing to generate or install on the phone. Using cloudflared (no account required):
-
Install cloudflared once - see the cloudflared downloads page (for example
brew install cloudflaredon macOS). -
You'll use two terminals. In the first, serve the app over plain HTTP:
Bashnpx serve . -l 3000 -
In the second, start the tunnel:
Bashcloudflared tunnel --url http://localhost:3000 -
cloudflared prints many log lines - near the top, in a boxed message, is your URL:
+-----------------------------------------------------------+ Your quick Tunnel has been created! Visit it at ...: | https://<random-words>.trycloudflare.com | +-----------------------------------------------------------+ -
Open that
https://<random-words>.trycloudflare.comaddress on your phone, allow the camera, and scan. It works from any network. The URL is temporary and changes each run; close the tunnel when done.
(ngrok works the same way — ngrok http 3000 — but needs a free account and authtoken.)
Alternative: local certificates with mkcert (offline, more setup). If you can't use a tunnel, serve over HTTPS with a locally-trusted certificate using mkcert. Install it for your OS (see the mkcert installation guide), then:
mkcert -install # one-time: create and trust a local CA
mkcert localhost YOUR_COMPUTER_IP # cert for localhost + your current LAN IP
npx serve . --ssl-cert localhost+1.pem --ssl-key localhost+1-key.pem
-
Use your current LAN IP (find it with
ipconfig getifaddr en0on macOS,ipconfigon Windows, orhostname -Ion Linux), and regenerate the cert if it changes. -
mkcert names the files after the hosts you pass:
mkcert localhost YOUR_COMPUTER_IPproduceslocalhost+1.pem/localhost+1-key.pem. If you pass different hosts, use the matching filenames, and run mkcert in the folder you serve from. -
On the phone you must install AND trust the mkcert root CA or the camera stays blocked (on iOS, tapping past the warning is not enough): transfer
rootCA.pemfrom$(mkcert -CAROOT)to the phone, install it, then enable it under Settings → General → About → Certificate Trust Settings.
Then open https://YOUR_COMPUTER_IP:3000 on your phone (same Wi-Fi).
What you just built
-
createNDXReadersets up the engine and manages the camera and scan loop -
scanner.mount(el)injects the camera feed and overlay into your container -
scanner.start()opens the camera and begins scanning -
onSuccess(strips)receives the final results
Installation
Use this section when you need the full setup flow, including AWS CLI configuration, registry authentication, generated runtime files, CI/CD setup, and installation troubleshooting.
Temporary note - SDK v1.0.0 (removed in the next release)
The self-contained browser bundle ndx-imaging-web.browser.mjs is not yet included in the published 1.0.0 package, so it will not appear in your project root after installing. This is fixed in the next release. Until you upgrade, make two small adjustments while following this guide:
-
After
npm install, copy the SDK's bundle folder next to your app:cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk -
Wherever this guide imports from
./ndx-imaging-web.browser.mjs, import from./sdk/index.mjsinstead - for exampleimport { createNDXReader } from './sdk/index.mjs';
Once you move to the release that includes the bundle, undo both adjustments and follow the guide exactly as written.
Prerequisites
|
Requirement |
Version |
Check |
|---|---|---|
|
Node.js |
18 or later |
|
|
npm |
8 or later |
|
|
AWS CLI |
Any recent version |
|
|
AWS credentials |
Provided by NovarumDX |
Contact NovarumDX |
Step 1: Configure the AWS CLI
Run this once per machine:
aws configure --profile novarumdx-sdk
The profile name is your choice - we use novarumdx-sdk throughout these examples, so if you pick a different name, use it consistently in every command.
Credential safety
The AWS Access Key ID and Secret Access Key are issued to your organisation by NovarumDX and grant read-only access to the Novarum SDK package registry only. Store them in your secret manager, do not commit them to source control, and do not share them. If they are ever exposed, contact NovarumDX so we can rotate them.
Enter the following values when prompted:
-
AWS Access Key ID: value provided by NovarumDX
-
AWS Secret Access Key: value provided by NovarumDX
-
Default region name:
eu-west-2 -
Default output format:
json
Security note
Keep your credentials safe. Do not commit them to source control or share them in chat or email. If you think they have been exposed, contact NovarumDX immediately so they can be rotated.
Step 2: Configure npm to use the registry
Create a file named .npmrc in your project root alongside package.json. NovarumDX will provide this file.
@novarumdx:registry=https://novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:always-auth=true
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:_authToken=${CODEARTIFACT_AUTH_TOKEN}
The ${CODEARTIFACT_AUTH_TOKEN} value is read from the environment variable you set before install.
Best practice
The .npmrc file above is safe to commit because it contains an environment variable reference rather than a real token.
Step 3: Get an auth token
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text \
--profile novarumdx-sdk)
The token expires after 12 hours. Fetch a new token before installs when needed.
You can save time by creating a shell alias:
alias ndx-login='export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text \
--profile novarumdx-sdk)'
Step 4: Install the SDK
NDX_WASM_PUBLIC_DIR=. npm install @novarumdx/ndx-imaging-web
The SDK runs a postinstall script that copies its compiled runtime files. By default they go into a public/ folder; setting NDX_WASM_PUBLIC_DIR=. copies them into your project root instead, alongside index.html, which is what the examples in this guide expect.
Generated runtime files
|
File |
What it is |
|---|---|
|
|
ES module browser bundle. Import |
|
|
Web Worker script for the imaging pipeline. |
|
|
WebAssembly loader used by the worker. |
|
|
The compiled imaging engine. |
Required at runtime
These files must be served alongside your application over HTTP or HTTPS. Do not move them unless you are also updating your hosting setup accordingly.
Temporary note — SDK v1.0.0
On v1.0.0, ndx-imaging-web.browser.mjs is not published yet, so it will not be among the copied files. Do adjustment 1 from the note at the top of this section — copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk
Add generated files to .gitignore
ndx-imaging-web.browser.mjs
imagingWorker.js
ndx_imaging_wasm.js
ndx_imaging_wasm.wasm
Verify the installation
The SDK bundle is built for the browser, so verify it from a page rather than from Node. With your files served (see 'Serve and open'), open the browser devtools console on your page and run:
const { createNDXReader } = await import('./ndx-imaging-web.browser.mjs');
console.log('createNDXReader loaded:', typeof createNDXReader);
Temporary note — SDK v1.0.0
On v1.0.0, import from ./sdk/index.mjs instead: const { createNDXReader } = await import('./sdk/index.mjs'); (adjustment 2 from the note at the top of this section).
Expected output:
createNDXReader loaded: function
Serving the SDK files
The browser bundle and WASM files must be served over HTTP. They will not work if opened with file://.
npx serve .
To test on a phone (the camera needs HTTPS), see 'Testing on a phone' in the Quickstart section.
In production, you can deploy to a static host such as Netlify, S3, or Nginx. The WASM file is large, about 10 MB, so ensure your host accepts it.
Using a build tool
If you use a bundler such as Vite, Webpack, or Rollup, you can import directly from the npm package:
import { createNDXReader } from '@novarumdx/ndx-imaging-web';
You will need bundler configuration that copies the WASM files to your output directory.
CI/CD environments
In CI, fetch a token before running npm install:
- name: Login to CodeArtifact
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVARUM_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVARUM_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-west-2
run: |
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text)
echo "CODEARTIFACT_AUTH_TOKEN=$CODEARTIFACT_AUTH_TOKEN" >> $GITHUB_ENV
- name: Install dependencies
run: npm install
-
The example above is GitHub Actions syntax. For other CI systems (GitLab CI, Bitbucket Pipelines, Jenkins, etc.) use the equivalent secret/env-var mechanism — the steps are the same: provide the AWS Access Key ID/Secret as secrets, run
aws codeartifact get-authorization-tokento setCODEARTIFACT_AUTH_TOKEN, then install. -
Your
.npmrcmust be committed to the repo (safe — it references${CODEARTIFACT_AUTH_TOKEN}, not a real token) so the install uses the Novarum registry. -
A fresh token is minted on every build, so the 12-hour expiry is never an issue in CI.
-
If your pipeline also builds/deploys the app (not just installs dependencies), apply the same steps from the Installation section —
NDX_WASM_PUBLIC_DIR=.(or your public folder) so the runtime files land correctly, and, on v1.0.0, thecp -r node_modules/@novarumdx/ndx-imaging-web/dist sdkworkaround.
Vanilla JS - Your First Scan
Use this section when you want a fuller, production-style scan flow with clear app states before, during, and after scanning.
Lifecycle summary
createNDXReader(config) creates the scanner and starts loading the engine immediately. scanner.mount(element) injects the camera view and overlay. scanner.start() opens the camera and begins scanning. scanner.stop() closes the camera. scanner.unmount() removes the scanner view from the DOM.
Create the scanner once and reuse it across scan sessions.
Complete example
This example uses three views: pre-scan, scanning, and results.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scanner</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: sans-serif; background: #f9f9f9; }
.view { width: 100vw; min-height: 100vh; display: none; }
.view.active { display: flex; flex-direction: column; align-items: center; justify-content: center; }
#view-scan { position: fixed; inset: 0; background: #000; }
#scan-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; padding-bottom: 6rem; }
#btn-back { position: fixed; top: 1rem; left: 1rem; z-index: 10; background: rgba(0,0,0,0.5); color:#fff; border:none; padding:.5rem 1rem; }
#view-pre, #view-post { padding: 2rem; }
button.primary { padding: 1rem 2rem; font-size: 1rem; cursor: pointer; }
table { border-collapse: collapse; margin-bottom: 1.5rem; }
td { padding: .5rem 1rem; border: 1px solid #ddd; }
td:first-child { font-weight: bold; }
.error { color: red; margin-top: 1rem; }
/* Scan-page overlay (hint + fit + progress). The SDK does NOT draw these — your app owns them. */
#scan-hud { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; padding: 1rem; text-align: center; color: #fff; background: linear-gradient(transparent, rgba(0,0,0,0.65)); }
#scan-hint { margin: 0 0 .5rem; }
#fit-chip { display: inline-block; padding: .25rem .75rem; border-radius: 999px; font-size: .85rem; margin-bottom: .5rem; }
#fit-chip.fit-0 { background: #b00020; }
#fit-chip.fit-1 { background: #b8860b; }
#fit-chip.fit-2 { background: #1b7f3b; }
#progress-bar { width: 80%; max-width: 320px; height: 12px; display: block; margin: 0 auto .25rem; }
#progress-label { font-size: .85rem; }
#raw-json { margin: 0 auto 1.5rem; max-width: 640px; text-align: left; }
#raw-json pre { max-height: 240px; overflow: auto; background: #f4f4f4; padding: .75rem; font-size: .8rem; }
</style>
</head>
<body>
<div id="view-pre" class="view active">
<h1>Ready to scan</h1>
<p id="loading-msg">Loading imaging engine...</p>
<button id="btn-start" class="primary" disabled>Begin Scan</button>
<p id="error-pre" class="error" style="display:none;"></p>
</div>
<div id="view-scan" class="view">
<div id="scan-container"></div>
<button id="btn-back">Back</button>
<div id="scan-hud">
<p id="scan-hint">Point camera at the cassette and hold steady</p>
<div id="fit-chip" class="fit-0">Fit: No Fit</div>
<progress id="progress-bar" value="0" max="1"></progress>
<div id="progress-label">0 / 0 frames</div>
</div>
</div>
<div id="view-post" class="view">
<h1>Results</h1>
<table id="results-table"></table>
<p>To turn these numbers into a positive/negative result, see "Working with Results" — the threshold is assay-specific and provided by NovarumDX.</p>
<details id="raw-json" open><summary>Raw result (JSON)</summary><pre id="raw-json-pre"></pre></details>
<button id="btn-again" class="primary">Scan again</button>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
app.js
import { createNDXReader } from './ndx-imaging-web.browser.mjs';
const views = document.querySelectorAll('.view');
const viewPre = document.getElementById('view-pre');
const viewScan = document.getElementById('view-scan');
const viewPost = document.getElementById('view-post');
const scanContainer = document.getElementById('scan-container');
const btnStart = document.getElementById('btn-start');
const btnBack = document.getElementById('btn-back');
const btnAgain = document.getElementById('btn-again');
const loadingMsg = document.getElementById('loading-msg');
const resultsTable = document.getElementById('results-table');
const errorPre = document.getElementById('error-pre');
const fitChip = document.getElementById('fit-chip');
const progressBar = document.getElementById('progress-bar');
const progressLabel = document.getElementById('progress-label');
const scanHint = document.getElementById('scan-hint');
function showView(el) {
views.forEach(v => v.classList.remove('active'));
el.classList.add('active');
}
// The SDK does not draw a progress bar or fit indicator — the app owns them,
// driven by the onReady / onFitStatus / onProgress callbacks below.
function resetScanHud() {
const max = scanner.maxProgress || 0;
progressBar.max = max;
progressBar.value = 0;
progressLabel.textContent = `0 / ${max} frames`;
scanHint.textContent = 'Point camera at the cassette and hold steady';
scanHint.style.display = '';
fitChip.textContent = 'Fit: No Fit';
fitChip.className = 'fit-0';
}
const scanner = createNDXReader({
config: 'cassettes/my-cassette/my-cassette.unified.json',
onReady: () => {
loadingMsg.style.display = 'none';
btnStart.disabled = false;
btnStart.textContent = 'Begin Scan';
progressBar.max = scanner.maxProgress;
progressLabel.textContent = `0 / ${scanner.maxProgress} frames`;
},
onFitStatus: (status) => {
// 0 = no fit, 1 = marginal, 2 = aligned
fitChip.textContent = 'Fit: ' + (['No Fit', 'Marginal', 'Fit'][status] ?? 'No Fit');
fitChip.className = 'fit-' + status;
if (progressBar.value === 0) {
scanHint.textContent = status === 0
? 'Point camera at the cassette and hold steady'
: 'Hold still — locking on…';
}
},
onProgress: (value, max, strip) => {
progressBar.max = max;
progressBar.value = value;
progressLabel.textContent = `${value} / ${max} frames`;
if (value > 0) scanHint.style.display = 'none';
},
onWarnings: (warnings) => {},
onSuccess: (strips, pmfStory, diagnostics) => {
scanner.stop();
renderResults(strips);
showView(viewPost);
},
onError: (error) => {
scanner.stop();
showView(viewPre);
errorPre.textContent = error.message;
errorPre.style.display = 'block';
},
onAbort: (pmfStory, diagnostics) => {},
});
scanner.mount(scanContainer);
btnStart.addEventListener('click', () => {
errorPre.style.display = 'none';
resetScanHud();
showView(viewScan);
scanner.start();
});
btnBack.addEventListener('click', () => {
scanner.stop();
showView(viewPre);
});
btnAgain.addEventListener('click', () => {
resultsTable.innerHTML = '';
resetScanHud();
showView(viewScan);
scanner.start();
});
function renderResults(strips) {
console.log('Scan result (StripAnalysis[]):', strips);
const num = (v) => (typeof v === 'number' ? v.toFixed(4) : String(v));
const row = (label, val) => `<tr><td>${label}</td><td>${num(val)}</td></tr>`;
let html = '';
strips.forEach((strip, stripIndex) => {
if (strips.length > 1) {
html += `<tr><td colspan="2"><strong>Strip ${stripIndex + 1}</strong></td></tr>`;
}
html += row('Control line height (cHeight)', strip.cHeight);
html += row('Control line position (cPos)', strip.cPos);
html += row('Control area (cArea)', strip.cArea);
strip.tcRatio.forEach((ratio, lineIndex) => {
html += `<tr><td colspan="2"><em>Test line ${lineIndex + 1}</em></td></tr>`;
html += row('T/C ratio', ratio);
html += row('Test height (tHeight)', strip.tHeight[lineIndex]);
html += row('Test position (tPos)', strip.tPos[lineIndex]);
html += row('Test area (tArea)', strip.tArea[lineIndex]);
});
html += row('Baseline fit MSE (baselineMse)', strip.baselineMse);
});
resultsTable.innerHTML = html;
document.getElementById('raw-json-pre').textContent = JSON.stringify(strips, null, 2);
}
Temporary note — SDK v1.0.0
On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs — that is, import { createNDXReader } from './sdk/index.mjs';. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.
Why this pattern works
The SDK owns the camera, overlay, and scan pipeline. Your application owns which screen is visible, how errors are shown, and what happens with final results.
Scanner methods
|
Method |
What it does |
|---|---|
|
|
Injects the camera feed and overlay into the element. Call this before |
|
|
Opens the camera and begins the scan loop after the engine is ready. |
|
|
Closes the camera and stops the scan loop. |
|
|
Removes the scanner view from the DOM. |
Order matters
Always call mount() before start(). Mount once and reuse the scanner across scans: when a scan completes call stop(), and call start() again to scan once more — you do not need to unmount and re-mount between scans. Only call unmount() when you are finished with the scanner, or before creating a new reader for a different cassette.
Common patterns
Disable start until the engine is ready
btnStart.disabled = true;
const scanner = createNDXReader({
config: '...',
onReady: () => {
btnStart.disabled = false;
},
});
Clean up when the user leaves the page
window.addEventListener('beforeunload', () => {
scanner.stop();
scanner.unmount();
});
Time out a scan that is taking too long
let scanTimeout;
btnStart.addEventListener('click', () => {
scanner.start();
scanTimeout = setTimeout(() => {
scanner.stop();
showView(viewPre);
errorPre.textContent = 'Scan timed out. Please try again.';
errorPre.style.display = 'block';
}, 60000);
});
const scanner = createNDXReader({
config: '...',
onSuccess: (strips) => {
clearTimeout(scanTimeout);
},
onError: (error) => {
clearTimeout(scanTimeout);
},
});
Working with Results
When a scan completes, onSuccess is called with the analysis output. For most applications, the main value you need is strips.
The onSuccess callback
const scanner = createNDXReader({
config: 'cassettes/my-cassette/my-cassette.unified.json',
onSuccess: (strips, pmfStory, diagnostics) => {
// strips - array of StripAnalysis
// pmfStory - per-frame diagnostic log
// diagnostics - image capture diagnostics
console.log(strips);
},
});
What is in StripAnalysis
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
T/C ratio for each test line. This is usually the primary result value. |
|
|
|
Height of the control line peak above the baseline. |
|
|
|
Height of each test line peak above the baseline. |
|
|
|
Pixel position of the control line along the strip profile. |
|
|
|
Pixel positions of each test line. |
|
|
|
Integrated area under the control line peak. |
|
|
|
Integrated area under each test line peak. |
|
|
|
Full intensity profile across the strip width. |
|
|
|
Fitted polynomial baseline for the strip profile. |
|
|
|
Mean squared error of the baseline fit. Lower is better. |
|
|
|
Index ranges ( |
The primary result: T/C ratio
tcRatio is the ratio of each test line height to the control line height. Your application decides what the number means.
onSuccess: (strips) => {
const strip = strips[0];
const ratio = strip.tcRatio[0];
console.log('T/C ratio:', ratio);
},
NovarumDX will provide the correct threshold for your assay. Do not hardcode production thresholds without assay-specific confirmation.
const THRESHOLD = 0.05; // example only - confirm with Novarum DX
onSuccess: (strips) => {
const ratio = strips[0].tcRatio[0];
const result = ratio > THRESHOLD ? 'Positive' : 'Negative';
showResult(result, ratio);
},
Checking the control line
Before trusting a T/C ratio, validate the control line:
const C_HEIGHT_THRESHOLD = 8.0; // example only - confirm with Novarum DX
onSuccess: (strips) => {
const strip = strips[0];
if (strip.cHeight < C_HEIGHT_THRESHOLD) {
showError('Scan quality too low - please try again.');
return;
}
const ratio = strip.tcRatio[0];
showResult(ratio > THRESHOLD ? 'Positive' : 'Negative', ratio);
},
Important
Always validate the control line before using the test result. A weak control line can make the reported ratio unreliable.
Deciding the result: positive / negative / inconclusive
Putting the two checks together, most assays resolve to three outcomes:
-
Inconclusive / invalid — the control line is too weak (
cHeightbelow the control threshold). The scan can't be trusted; ask the user to re-scan. -
Positive or Negative — with a valid control line, compare
tcRatio[0]against your assay threshold.
Both thresholds are assay-specific and supplied by Novarum DX — never hardcode production values without confirmation.
onSuccess: (strips) => {
const strip = strips[0];
// 1. Is the control line valid?
if (strip.cHeight < C_HEIGHT_THRESHOLD) { // threshold from Novarum DX
showResult('Inconclusive — please re-scan');
return;
}
// 2. Positive or negative?
const positive = strip.tcRatio[0] > TC_THRESHOLD; // threshold from Novarum DX
showResult(positive ? 'Positive' : 'Negative');
},
Multi-strip cassettes
If a cassette has more than one strip, the strips array will have one entry per strip. Some strips may also have more than one test line.
onSuccess: (strips) => {
strips.forEach((strip, index) => {
console.log(`Strip ${index + 1}: T/C ratio = ${strip.tcRatio[0].toFixed(4)}`);
});
},
Rendering a results table
function renderResults(strips) {
const table = document.getElementById('results-table');
let html = '';
strips.forEach((strip, stripIndex) => {
if (strips.length > 1) {
html += `<tr><td colspan="2"><strong>Strip ${stripIndex + 1}</strong></td></tr>`;
}
html += `<tr>
<td>Control line</td>
<td>${strip.cHeight.toFixed(4)}</td>
</tr>`;
strip.tcRatio.forEach((ratio, lineIndex) => {
html += `<tr>
<td>T/C ratio (line ${lineIndex + 1})</td>
<td>${ratio.toFixed(4)}</td>
</tr>`;
});
});
table.innerHTML = html;
}
Plotting the strip profile
Optional — a visualisation aid for QA/debugging; not needed to decide a result.
You can chart the strip's intensity profile with a library like Chart.js. It uses profile (the intensity trace), baseline (the fitted baseline), profileRegions (shaded baseline/control/test bands) and cPos/tPos (control/test line markers). Add Chart.js from a CDN and a canvas:
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
<div id="chart-wrap" style="max-width:640px;height:260px;"><canvas id="profile-chart"></canvas></div>
Then render the first strip. Create the chart only once its container is visible, or Chart.js sizes the canvas to 0:
let profileChart = null;
function renderChart(strip) {
const canvas = document.getElementById('profile-chart');
if (profileChart) { profileChart.destroy(); profileChart = null; }
const n = strip.profile.length;
const labels = Array.from({ length: n }, (_, i) => i);
const regions = strip.profileRegions ?? { baseline: [], control: [], test: [] };
const Y_MAX = 285; // headroom above the 0–255 range so labels sit clear of the data
const LABEL_Y = 278;
const toPx = (chart, v) => {
const { left, right } = chart.chartArea;
return left + (v / (n - 1)) * (right - left);
};
const bandPlugin = {
id: 'bands',
beforeDraw(chart) {
const { ctx, chartArea: { top, bottom }, scales: { y } } = chart;
const bands = [
...regions.baseline.map(([s, e]) => ({ s, e, color: 'rgba(25,118,210,0.15)', text: 'Baseline', textColor: '#1565c0' })),
...regions.control.map(([s, e]) => ({ s, e, color: 'rgba(46,125,50,0.15)', text: 'Control', textColor: '#1b5e20' })),
...regions.test.map(([s, e]) => ({ s, e, color: 'rgba(211,47,47,0.15)', text: 'Test', textColor: '#b71c1c' })),
];
const labelPx = y.getPixelForValue(LABEL_Y);
ctx.save();
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (const b of bands) {
const x1 = toPx(chart, b.s), x2 = toPx(chart, b.e);
ctx.fillStyle = b.color; ctx.fillRect(x1, top, x2 - x1, bottom - top);
ctx.fillStyle = b.textColor; ctx.fillText(b.text, (x1 + x2) / 2, labelPx);
}
ctx.restore();
},
};
const markerPlugin = {
id: 'markers',
afterDraw(chart) {
const { ctx, chartArea: { top, bottom } } = chart;
ctx.save();
const mark = (pos, label, color) => {
const px = toPx(chart, pos);
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(label, px, top + 12);
};
mark(strip.cPos, 'C', '#2e7d32');
strip.tPos.forEach((pos, i) => mark(pos, `T${i + 1}`, '#b71c1c'));
ctx.restore();
},
};
profileChart = new Chart(canvas, {
type: 'line',
plugins: [bandPlugin, markerPlugin],
data: {
labels,
datasets: [
{ label: 'Profile', data: strip.profile, borderColor: '#1976d2', borderWidth: 2, pointRadius: 0, tension: 0 },
{ label: 'Baseline', data: strip.baseline, borderColor: '#9e9e9e', borderWidth: 1.5, borderDash: [4, 3], pointRadius: 0, tension: 0 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { position: 'top' }, tooltip: { enabled: false } },
scales: { y: { min: 0, max: Y_MAX }, x: { ticks: { maxTicksLimit: 6 } } },
},
});
}
// call renderChart(strips[0]) in onSuccess, after your results view is shown
This charts the first strip. Most cassettes are single-strip; for a multi-strip cassette, render one chart per strip (or add a strip selector) — the fields are the same on every StripAnalysis. Chart.js loads from a CDN, so there's no npm dependency.
The SDK also includes serializeAnalysisResult for packaging results into the JSON format expected by the Novarum DX server API. Contact Novarum DX for the server API specification and integration guidance.
Sending the result to Novarum
serializeAnalysisResult packages the scan into the server-format JSON that the Novarum DX server API expects. It needs engine-configuration context, so contact Novarum DX for the server API specification and integration guidance — they'll help you wire it up as part of your integration. (A future SDK release will make this a one-liner.)
Downloading or uploading the result
You can save the results to a file or POST them to your own server. This helper downloads any JSON string:
import { serializePmfStory } from './ndx-imaging-web.browser.mjs';
function downloadJSON(filename, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// In onSuccess you have strips + pmfStory — wire them to buttons:
btnDownloadResult.addEventListener('click', () => {
downloadJSON('result-strips.json', JSON.stringify(strips, null, 2));
});
btnDownloadPmf.addEventListener('click', () => {
downloadJSON('pmf-story.json', serializePmfStory(pmfStory));
});
// Or POST the results to your own server:
await fetch('https://your-api.example.com/results', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(strips),
});
Temporary note — SDK v1.0.0
On v1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs.
Optional: bundle everything as a ZIP
To send one file for support — the envelope, the PMF story, and all the images together — add JSZip from a CDN and zip them:
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
async function downloadBundle(strips, pmfStory, frames) {
const zip = new JSZip();
zip.file('result-strips.json', JSON.stringify(strips, null, 2));
zip.file('pmf-story.json', serializePmfStory(pmfStory));
let f = 0;
for (const frame of frames) {
if (frame.imageObjectUrl) {
zip.file(`images/frame-${f}.png`, await (await fetch(frame.imageObjectUrl)).blob());
}
const stripUrls = frame.stripImageObjectUrls ?? [];
for (let s = 0; s < stripUrls.length; s++) {
if (stripUrls[s]) {
zip.file(`images/frame-${f}-strip-${s}.png`, await (await fetch(stripUrls[s])).blob());
}
}
f++;
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'scan-bundle.zip';
a.click();
URL.revokeObjectURL(url);
}
frames is the resolved array from resolveStoryImages(pmfStory) (see PMF Story). Because the envelope references images by key rather than embedding them, the ZIP is the simplest way to send results and images together.
PMF Story and Diagnostics
Advanced / optional. This section covers per-frame diagnostics and captured images — useful for QA, debugging and support. You don't need any of it to scan a test and read a result.
The PMF story is a per-frame diagnostic log captured during a scan. It records alignment quality, intensity profiles, and optionally images. It is mainly useful for debugging, quality assurance, and server-side support workflows.
Most applications do not need to display PMF story data to end users. A common pattern is to capture it and send it to the server alongside the final result.
Enabling PMF story capture
PMF story capture is enabled by default and controlled with pmfStoryConfig:
const scanner = createNDXReader({
config: 'cassettes/my-cassette/my-cassette.unified.json',
pmfStoryConfig: {
enabled: true,
captureImages: true,
captureErrorFrameImages: true,
},
onSuccess: (strips, pmfStory, diagnostics) => {
console.log(`Captured ${pmfStory.length} frames`);
},
});
|
Option |
Default |
What it does |
|---|---|---|
|
|
|
Captures the per-frame log. Set to |
|
|
|
Saves a PNG of each well-aligned frame to IndexedDB. |
|
|
|
Saves PNGs for frames where baseline or exposure errors occur. |
Image capture requires OffscreenCanvas. It is supported in Chrome 69+, Firefox 105+, and Safari 16.4+. On unsupported browsers, image capture is ignored silently.
The pmfStory array
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
Unix timestamp in milliseconds when the frame was processed. |
|
|
|
Alignment quality: 0 no fit, 1 marginal, 2 well-aligned. |
|
|
|
Estimated ambient light level in lux. |
|
|
|
Status of each strip such as |
|
|
|
Intensity profile for each strip for that frame. |
|
|
|
IndexedDB key for the full-frame image, or |
|
|
|
IndexedDB keys for cropped strip images. |
Displaying captured images
Captured images are stored in IndexedDB. Resolve the stored keys into Blob URLs before rendering them:
import {
createNDXReader,
resolveStoryImages,
revokeStoryImageUrls,
} from './ndx-imaging-web.browser.mjs';
let resolvedFrames = [];
const scanner = createNDXReader({
config: 'cassettes/my-cassette/my-cassette.unified.json',
pmfStoryConfig: { enabled: true, captureImages: true },
onSuccess: async (strips, pmfStory) => {
resolvedFrames = await resolveStoryImages(pmfStory);
renderImages(resolvedFrames);
scanner.stop();
showResultsView();
},
});
Temporary note — SDK v1.0.0
On 1.0.0, every import in this section should read from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs — for example import { createNDXReader, resolveStoryImages, revokeStoryImageUrls } from './sdk/index.mjs';. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.
Each resolved frame includes:
-
imageObjectUrl: Blob URL for the full-frame image, ornull -
stripImageObjectUrls: Blob URLs for cropped strip images, ornullper strip
function renderImages(frames) {
const frameImages = document.getElementById('frame-images');
const stripImages = document.getElementById('strip-images');
frameImages.innerHTML = '';
stripImages.innerHTML = '';
const downloadableImage = (url, filename, alt) => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.title = 'Click to download';
const img = document.createElement('img');
img.src = url;
if (alt) img.alt = alt;
a.appendChild(img);
return a;
};
frames
.filter((f) => f.imageObjectUrl)
.forEach((frame, i) => {
frameImages.appendChild(downloadableImage(frame.imageObjectUrl, `frame-${i}.png`, `Frame ${i}`));
});
frames
.filter((f) => (f.stripImageObjectUrls ?? []).some(Boolean))
.forEach((frame, i) => {
const group = document.createElement('div');
group.className = 'strip-group';
const label = document.createElement('span');
label.className = 'strip-label';
label.textContent = `Frame ${i} — fit`;
group.appendChild(label);
(frame.stripImageObjectUrls ?? []).forEach((url, si) => {
if (!url) return;
group.appendChild(downloadableImage(url, `frame-${i}-strip-${si}.png`));
});
stripImages.appendChild(group);
});
}
It renders into two containers on your page — <div id="frame-images"></div> and <div id="strip-images"></div> — with a little CSS (frame stills as 4:3 thumbnails; strip crops on black, grouped per frame):
#frame-images { display: flex; flex-wrap: wrap; gap: .4rem; }
#frame-images img { width: 120px; height: 90px; object-fit: cover; background: #000; }
#frame-images a, #strip-images a { text-decoration: none; line-height: 0; }
#strip-images { display: flex; flex-direction: column; gap: .75rem; }
.strip-group { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.strip-label { font-family: monospace; font-size: .75rem; color: #888; min-width: 90px; }
.strip-group img { height: 90px; width: auto; max-width: 120px; object-fit: contain; background: #000; }
Each image is wrapped in a download link, so a click saves the PNG. (On iOS Safari the download attribute may open the image in a new tab rather than saving it.)
Memory note
Blob URLs keep memory alive until they are revoked. Always call revokeStoryImageUrls when the images are no longer needed.
document.getElementById('btn-again').addEventListener('click', () => {
if (resolvedFrames.length > 0) {
revokeStoryImageUrls(resolvedFrames);
resolvedFrames = [];
}
});
Identifying error frames
You can distinguish error frames from normal frames using the metadata:
-
Normal captured frame:
imageUrlis notnullandstripImageUrlsincludes crop keys -
Error frame:
imageUrlis notnulland allstripImageUrlsarenull
const errorFrames = pmfStory.filter(frame =>
frame.imageUrl !== null &&
frame.stripImageUrls.every(url => url === null)
);
console.log(`${errorFrames.length} error frames`);
Cleaning up between sessions
The SDK clears PMF story images automatically at the start of each scan, but old images may remain if the page closes mid-session. Clean up on load if needed:
import {
createNDXReader,
clearAllPmfStoryImages,
} from './ndx-imaging-web.browser.mjs';
clearAllPmfStoryImages().catch(() => {
// best effort - ignore errors
});
const scanner = createNDXReader({ ... });
The SDK includes serializeAnalysisResult to package strips, PMF story, and diagnostics into the format expected by the Novarum DX server API.
Configuration Reference
Use relative paths for Vanilla JS projects, and make sure all SDK files and cassette files are served over HTTP rather than opened with file://.
createNDXReader options
const scanner = createNDXReader({
config,
hScale,
nFrames,
collectionMode,
cameraDeviceId,
torchEnabled,
testName,
pmfStoryConfig,
onReady,
onFitStatus,
onProgress,
onWarnings,
onSuccess,
onError,
onAbort,
});
Option reference
config
Type: string
Required: yes
Path to the unified JSON cassette configuration file, relative to your index.html.
config: 'cassettes/my-cassette/my-cassette.unified.json'
hScale
Type: number
Default: value from the cassette config file
Homography scale factor override. Only set this if Novarum DX has asked you to override it.
nFrames
Type: number
Default: value from the cassette config file
Number of frames to collect per strip before running analysis. Higher values give more accurate results but take longer.
collectionMode
Type: boolean
Default: false
When true, the SDK collects frames and images but skips final strip analysis. onSuccess still fires, but strips will be empty.
cameraDeviceId
Type: string
Default: default rear camera
The device ID of the camera to use. Leave empty to use the default rear-facing camera in most applications.
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === 'videoinput');
// cameras[i].deviceId
torchEnabled
Type: boolean
Default: false
Requests the device torch while scanning. This mainly works on Android Chrome and is ignored on unsupported platforms.
testName
Type: string
Default: 'unknown'
Name for the scan, used to generate the session ID included in the PMF story. Example: 'covid-antigen'.
pmfStoryConfig
Type: object
Default: { enabled: true, captureImages: false }
pmfStoryConfig: {
enabled: true,
captureImages: true,
captureErrorFrameImages: true,
maxFrames: 1000,
}
onReady
Type: () => void
Called when the cassette model is loaded and the engine is ready to scan. Enable your Begin Scan button here.
onReady: () => {
btnStart.disabled = false;
btnStart.textContent = 'Begin Scan';
},
onFitStatus
Type: (fitStatus: 0 | 1 | 2) => void
Called on every frame with the current alignment quality. The built-in overlay changes colour automatically.
onProgress
Type: (value: number, max: number, strip: number) => void
Called each time a frame is stored, with the number stored so far, the total required, and the strip index. Use it to drive your own progress UI (see 'Your First Scan').
onProgress: (value, max, strip) => {
document.getElementById('progress').textContent = `${value} / ${max}`;
},
onWarnings
Type: (warnings: Array<{ strip: number, warnCode: number }>) => void
Called when the set of active quality warnings changes.
|
warnCode |
Meaning |
|---|---|
|
|
Baseline quality too low. Check for shadows or uneven lighting. |
|
|
Exposure out of range. The image is too bright or too dark. |
|
|
Both baseline and exposure problems are present. |
onSuccess
Type: (strips, pmfStory, diagnostics) => void
Called when the scan completes successfully.
onError
Type: (error: { code: string, message: string }) => void
Called when the camera or engine encounters an error it cannot recover from.
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
Error code identifier (see below). |
|
|
|
Human-readable description that is safe to show to the user. |
Common error codes:
|
code |
Cause |
|---|---|
|
|
User denied camera access. |
|
|
No camera is available on the device. |
|
|
The camera is being used by another app or browser tab. |
|
|
The page is not a secure context (needs HTTPS or localhost). |
|
|
The imaging engine failed to initialise. |
onAbort
Type: (pmfStory, diagnostics) => void
Called if scanner.stop() is called before the scan completes. It provides the partial PMF story collected up to that point.
Imports from the browser bundle
import {
createNDXReader,
resolveStoryImages,
revokeStoryImageUrls,
clearAllPmfStoryImages,
serializeAnalysisResult,
serializePmfStory,
} from './ndx-imaging-web.browser.mjs';
Temporary note — SDK v1.0.0
On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.
Best practice
Start with a minimal setup: config, onReady, onSuccess, and onError. Add advanced options such as cameraDeviceId, torchEnabled, or pmfStoryConfig only when your application needs them.
Support and Troubleshooting
Common installation, camera, and runtime issues - for both React and Vanilla - are collected on the shared Troubleshooting page: Troubleshooting. Check there first.
For access credentials, configuration files, or integration assistance, please contact your Novarum support representative or technical account manager.
For streamlined support and rapid troubleshooting, users can access the SDK Service Desk. This portal provides a direct channel for submitting integration queries, reporting issues, and tracking the status of your requests. In addition, the service desk is equipped with an intelligent agent capable of answering common technical questions and guiding you through standard troubleshooting steps.
We recommend using this resource for the fastest resolution of SDK-related issues or to obtain up-to-date technical guidance.
Document History
| Revision | Summary | Date |
|---|---|---|
| 01 | Initial revision | Jul 20, 2026 |
| 02 | Correction to Support section and add document history | Jul 20, 2026 |