Web Bluetooth and Web Serial SDK for Shimmer sensor devices.
| Device | Class | Radio / port | Links this SDK can drive |
|---|---|---|---|
| Shimmer3R | Shimmer3RClient |
CYW20820 (BLE + SPP) + USB-C | BLE, Classic Bluetooth (SPP) |
| Shimmer3, RN4678 (SR31-6-0 onward) | Shimmer3Client |
RN4678 (dual-mode) | Classic Bluetooth (SPP); BLE possible in principle — see below |
| Shimmer3, RN42 (earlier boards) | Shimmer3Client |
RN42 (classic only) | Classic Bluetooth (SPP) |
| Shimmer3R over USB-C | WiredShimmerClient |
USB-C CDC | wired serial (dock protocol, not LiteProtocol) |
| Shimmer3/3R in a BasicDock or Base | WiredShimmerClient |
Dock FTDI UART | wired serial (injected transport only) |
| SmartDock multi-slot base | SmartDockClient |
Dock FTDI UART | wired serial |
| Verisense (IMU, Pulse+) | VerisenseBleDevice |
nRF52 (BLE) + USB | BLE, wired serial |
Which radio a Shimmer3 has matters. Early boards carry an RN42, which is Classic Bluetooth (BR/EDR) only — there is no BLE radio on them at all, so no browser can reach one except through a paired SPP port. Later boards carry an RN4678, which is dual-mode.
Which of the two a given unit has is not a single revision cutoff. It tracks
the sensor generation, and the revision gates differ per board ID — SR31, SR38,
SR47, SR48 and SR49 each run their own major/minor scheme, and assembly variants
of one PCB are encoded as a higher minor revision, so a bare revision number
means nothing without its board ID. Check
SHIMMER3_BOARD_REVISIONS.md
rather than inferring capability from a model name or revision alone.
At runtime the LogAndStream firmware picks the radio from two EEPROM bits at
start-up (ShimBt_startCommon, log-and-stream-common Comms/shimmer_bt_uart.c),
and a unit with no EEPROM at all is forced to Classic Bluetooth precisely because
that is the RN42 fleet.
BLE on an RN4678 is real but slow — the module carries the LiteProtocol
over its transparent-UART service, and throughput is a small fraction of what
SPP or a Shimmer3R's CYW20820 BLE gives, which is why Classic Bluetooth
stayed the streaming link for the Shimmer3. This SDK has no built-in BLE
transport for a Shimmer3: Shimmer3Client requires an injected transport
whichever radio is in play, so an RN4678 BLE link is a matter of writing a
transport over the module's transparent-UART characteristics and handing it in.
HARDWARE-VERIFY: nothing here has been run against an RN4678 in BLE mode.
The same physical link looks different from a browser's point of view, and the device table above collapses that. Spelled out — this is the distinction the table used to blur:
| Link | Browser API | Transport | Notes |
|---|---|---|---|
| BLE | Web Bluetooth | WebBluetoothTransport (built by connect()) |
Shimmer3R and Verisense. Nordic UART service; notification boundaries preserved. |
| Classic Bluetooth (SPP) | Web Serial | WebSerialTransport + SHIMMER3_SPP_SERIAL_OPTIONS |
Shimmer3 and Shimmer3R, identically: pairing exposes the sensor as a virtual COM port and Web Serial opens it. Byte stream, no framing. |
| wired USB / dock | Web Serial | WebSerialTransport (no Bluetooth options) |
A different protocol — $-header dock packets, not LiteProtocol. No streaming. |
Two consequences worth stating, because both have caused confusion:
- Classic Bluetooth is Web Serial, on every device. A Shimmer3R's classic
link is opened exactly the way a Shimmer3's is: same API, same transport,
same
SHIMMER3_SPP_SERIAL_OPTIONS, same requirement that the host has already paired the sensor. What differs between the two devices is only which other links they also have. - "Web Serial" on a Shimmer3R means one of two unrelated things. Over a
paired Bluetooth port it is the LiteProtocol and
Shimmer3RClient; over the USB-C cable it is the dock protocol andWiredShimmerClient. Same API, same picker, different client and different command set.
Everything here rests on two Chromium-only APIs, and both need a secure
context — HTTPS, or localhost for development.
| Browser | Web Bluetooth (BLE) | Web Serial (Classic BT, dock) | Directory picker (SD download) |
|---|---|---|---|
| Chrome / Edge, desktop | yes | yes (89+) | yes |
| Chrome, Android | yes | 138+, RFCOMM only | no (showSaveFilePicker only) |
| Opera / Brave / other Chromium, desktop | usually — Brave keeps Web Bluetooth behind a flag | usually | usually |
| Firefox, any platform | no | no | no |
| Safari, macOS | no | no | no |
| Any browser on iOS / iPadOS | no — all WebKit | no | no |
| Bluefy / WebBLE (iOS) | yes — bundles its own BLE stack | no | no |
Do not re-derive this table in a page. describePlatformSupport() reports the
two capabilities as booleans that are safe to gate a control on,
transportAvailability() turns them into a three-state answer per link, and
transportAdvice() returns the sentence to show the user. The Android row in
particular is not detectable by feature test, which is the whole reason
that module exists.
Per link, since that is where the differences actually fall:
| Host | BLE | Classic Bluetooth (SPP) | Wired USB / dock |
|---|---|---|---|
| Windows 10 / 11 | yes | yes — pair, then the sensor is a COMx port |
yes |
| macOS | yes | yes — pair, then /dev/cu.*-SPPDev |
yes |
| Linux | yes, with BlueZ | yes — pair and bind an rfcomm node |
yes |
| Android (Chrome 138+) | yes | yes, once paired — see the LE-bond trap below | unlikely — wired serial is still rolling out |
| ChromeOS | yes | yes, once paired | yes |
| iOS / iPadOS | Bluefy / WebBLE only | impossible — no third-party classic access at any layer | no |
Three of those cells need more than a word:
- Android, Classic Bluetooth. The picker lists paired devices by their
cached classic service records, so a sensor has to be paired in system
settings first. A dual-mode sensor advertising both radios invites Android to
create an LE bond instead, which satisfies the user while leaving no
BR/EDR link key and therefore no SPP record — the sensor is then simply
absent from the picker. Confirmed on hardware with
dumpsys bluetooth_manager. The fix: disable the sensor's BLE radio, unpair, pair again, then re-enable BLE. The classic bond survives, so it is once per phone, not once per session.transportAdvice()returns this as prose. - Android, wired.
navigator.serialis present and callable, but Chrome implements it there for Bluetooth RFCOMM port emulation only, so a dock will not appear in the picker. No feature test separates the two cases, which is whytransportAvailability()answers'unlikely'rather than'unavailable'— the control stays enabled so devices that do gain wired support are not locked out. - iOS, Classic Bluetooth. Not "not yet": Core Bluetooth is BLE-only and classic profiles such as SPP need MFi licensing, so no browser release can change it. A classic-only Shimmer3 — the whole RN42 fleet — cannot be reached from iOS by any route.
SD-card download additionally needs showDirectoryPicker, which is
Chromium-desktop only, so the SD browser is unavailable on Android even where
the link itself works perfectly well.
<script type="module">
import { Shimmer3RClient, SensorBitmapShimmer3 } from './dist/shimmer-web-sdk.esm.js';
const client = new Shimmer3RClient({ timestampFmt: 'u24', debug: true });
client.onStatus = (msg) => console.log('[status]', msg);
client.onStreamFrame = (oc) => {
// Every channel carries a raw count and, where a conversion exists, a
// calibrated value with its unit. Ask for the one you want.
const gz = oc.get('GYRO_Z', 'cal'); // { value: 12.4, unit: 'deg/s' }
const batt = oc.get('BATTERY', 'cal'); // { value: 3841.2, unit: 'mV' }
const t = oc.get('Timestamp_Unix', 'cal')?.value; // epoch ms, once anchored
const when = t == null ? '(unanchored)' : new Date(t).toISOString();
console.log(when, gz?.value, gz?.unit, batt?.value);
};
document.getElementById('btnConnect').addEventListener('click', async () => {
await client.connect();
await client.getRtcTime(); // one reading pins the whole session to a wall clock
await client.setSamplingRate(51.2);
await client.setSensors(SensorBitmapShimmer3.SENSOR_GYRO | SensorBitmapShimmer3.SENSOR_A_ACCEL);
await client.startStreaming();
});
</script><script type="module">
import { VerisenseBleDevice } from './dist/shimmer-web-sdk.esm.js';
const v = new VerisenseBleDevice({ hardwareIdentifier: 'VERISENSE_PULSE_PLUS' });
v.on('streamPacket', (pkt) => console.log(pkt.sensorId, pkt.decoded));
document.getElementById('btnConnect').addEventListener('click', async () => {
await v.connect();
await v.startStreaming();
});
</script>Every channel a Shimmer3 or Shimmer3R streams reaches onStreamFrame twice: as
a raw converter count, and — where a conversion exists — as a calibrated value
carrying its unit. oc.get(name, 'raw' | 'cal') picks one; oc.fields walks
them all.
| Family | Unit | Notes |
|---|---|---|
| Inertial (6 triples) | m/(s^2), deg/s, local_flux |
Uses the device's own calibration once applyCalibDump() or readCalibration() has run; the range's factory seed otherwise |
| Battery | mV |
ADC millivolts × 2, the board's divider |
| External / internal ADC, PPG | mV |
raw × 3000 / 4095 — 12-bit at 3.0 V on both generations |
| Bridge amplifier HIGH / LOW | mV |
Shimmer3 with an SR49 board |
| ExG CH1 / CH2, both chips | mV |
From the chip's own reference and PGA gain; 16-bit mode is bits 22:7 of the 24-bit conversion, so its divisor carries a factor of 2 |
| ExG status | no_units |
The lead-off register, relayed untouched |
| GSR | uS |
Plus GSR_RESISTANCE in kOhms and GSR_RANGE, both calibrated-only |
| Pressure / temperature | kPa, Degrees Celsius |
Needs readPressureCalibration() first; a BMP581 self-compensates and needs nothing |
TIMESTAMP |
ticks raw, ms calibrated |
Unwrapped, not zeroed at stream start — the Java driver's convention |
Timestamp_Unix |
ms |
Epoch milliseconds, present once the timeline is anchored |
Three rules worth knowing before reading the numbers:
- A channel with no conversion gets no
'cal'field at all, rather than a copy of the raw count. A raw-only column is honest; one claiming a calibration it does not have is not. - Pressure and temperature are raw-only until the coefficients are fetched.
readPressureCalibration()returnsnullrather than throwing when the firmware predates the command, and says so throughonStatus. - The device's inertial calibration is adopted explicitly.
readCalibDump()hands back a dump for inspection;applyCalibDump(dump)is what makes the stream use it. Which numbers are in force, per group and per range, is whatcalibrationInforeports.
client.timelineState says how absolute time was fixed: rwc-aligned (exact,
Shimmer3R — its packet timestamp is the low 24 bits of the same counter
GET_RWC returns), rwc-estimated (Shimmer3, from the request round trip, with
the uncertainty stated), or host (no device clock; the Consensys method).
oc.timestampValid says whether a frame's timestamp means anything. Firmware
stamps a packet when the sample tick starts it, so a counter field of exactly
0x000000 is a record it published without stamping — LogAndStream
v1.00.x–1.01.003 could do that under SD write back-pressure. Read as a
roll-over, one such packet puts every later sample a clean 512 s late. The
timeline rejects it instead: the frame still arrives, with its real sensor
values and timestampValid false, and its TIMESTAMP fields repeat the
previous frame's. Drop those frames if you need a true time axis. A genuine wrap
onto zero is unaffected — its predecessor is at the top of the range — and the
16-bit counter older firmware uses, whose whole range is 2 s, keeps its existing
behaviour.
devices/calibration/streamChannels.ts is the single table behind all of it, so
the two platforms cannot drift apart on a formula, and
devices/shimmer3/sensorRules.ts answers the configuration-side question of
which sensors can be enabled together and which need the expansion rail.
The device clients are transport-agnostic. Each client talks to its device through
a ShimmerTransport — a raw byte pipe (connect / disconnect / write /
onNotify / onDisconnect) that does no protocol interpretation and preserves
notification chunk boundaries. In a browser nothing changes: Shimmer3RClient
builds a WebBluetoothTransport and VerisenseBleDevice builds a
WebBluetoothTransport (or a WebSerialTransport via connectSerial())
automatically when you call connect().
To run the clients elsewhere (React Native / react-native-ble-plx, Bluetooth
Classic) or in tests, inject your own transport:
import { Shimmer3RClient, LoopbackTransport } from '@shimmerresearch/shimmer-web-sdk';
// Constructor option …
const client = new Shimmer3RClient({ transport: myTransport });
// … or connect() parameter:
await client.connect(myTransport);Implement ShimmerTransport for a new platform: map write to the write
characteristic, deliver each inbound notification verbatim to the onNotify
callback (never merge or re-split chunks), and fire onDisconnect on link loss.
LoopbackTransport is an in-memory implementation used by the test suites.
The classic (pre-3R) Shimmer3 speaks the same LiteProtocol but over an RFCOMM/SPP
byte stream rather than BLE. Web Bluetooth cannot open an RFCOMM socket, so
Shimmer3Client requires an injected transport and connect() throws without
one — but a browser can still reach the device. Pairing a Shimmer over classic
Bluetooth makes the OS expose it as a virtual COM port (Windows COMx,
macOS /dev/cu.*-SPPDev), and Web Serial can open that port, so
WebSerialTransport is a working SPP transport.
Everything in this section applies to a Shimmer3R's classic link as well:
its RN4678 is reached the same way, with the same options, and the client is
then Shimmer3RClient over the same WebSerialTransport rather than over Web
Bluetooth. The one difference is that Shimmer3RClient can also build its own
BLE transport, so unlike Shimmer3Client it does not require one to be
injected.
import {
Shimmer3Client,
WebSerialTransport,
SHIMMER3_SPP_UUID,
} from '@shimmerresearch/shimmer-web-sdk';
const client = new Shimmer3Client({
transport: new WebSerialTransport({
// BOTH are needed, and they do different jobs:
// allowedBluetoothServiceClassIds *permits* Bluetooth ports to be
// surfaced at all — Chrome hides them otherwise;
// filters *narrows* the picker to that service class.
// With the permission alone the picker lists every COM port and every
// paired Bluetooth device.
filters: [{ bluetoothServiceClassId: SHIMMER3_SPP_UUID }],
allowedBluetoothServiceClassIds: [SHIMMER3_SPP_UUID],
kind: 'rfcomm',
}),
});
await client.connect();The sensor must already be paired with the host (the page cannot do that), and
open() is what brings the RFCOMM link up — so it blocks, and eventually fails,
when the sensor is asleep or out of range. WebSerialTransport bounds that with
openTimeoutMs (15 s by default).
On a non-browser platform, anything that can open SPP (e.g. a React Native
Android module calling createRfcommSocketToServiceRecord with
SHIMMER3_SPP_UUID) works equally well. Either way the transport should report
capabilities.framed = false, since RFCOMM has no message boundaries.
import { Shimmer3Client, SensorBitmapShimmer3 } from '@shimmerresearch/shimmer-web-sdk';
const client = new Shimmer3Client({ transport: rfcommTransport }); // required
client.onStatus = (m) => console.log(m);
await client.connect(); // handshake: flush buffer → HW version → FW version
await client.setSamplingRate(51.2);
await client.setSensors(SensorBitmapShimmer3.SENSOR_GYRO);
await client.setGSRRange(2);
await client.startStreaming();Unlike the BLE clients, Shimmer3Client runs a byte-stream parser: inbound
bytes are accumulated and complete LiteProtocol messages are extracted with a
length-aware framer, so ACKs and responses are recovered correctly no matter how
the RFCOMM stream splits or coalesces them.
Verification status: code-complete, pending hardware. The protocol is ported byte-for-byte from the Java driver and covered by unit tests with hand-derived fixtures (the CRC is cross-checked against the Java
ShimmerCrcrun directly), but it has not yet been exercised against a physical dock. See Hardware-verify items below.
A Shimmer sitting in a BasicDock / Base is reachable over the dock's FTDI
UART (host↔device). This is a completely different protocol from the
Bluetooth LiteProtocol above — $-header packets addressed by component +
property, with a length byte, payload and a Shimmer-specific CRC (seed
0xB0CA). WiredShimmerClient is phase D1 of dock support: identify,
status, and property-level config for a single docked device. It does not
cover mass-storage/SD, firmware flashing, or the multi-slot Base state machine
(later phases), and the dock protocol has no streaming.
Transport injection is required (a docked Shimmer is only reachable over the
wired link, so there is no browser default and connect() throws without one).
Supply a serial ShimmerTransport reporting capabilities.framed = false;
configure the port at UART_DOCK_BAUD_RATE (115200, 8N1, no flow control).
import { WiredShimmerClient, UART_PROP } from '@shimmerresearch/shimmer-web-sdk';
const client = new WiredShimmerClient({ transport: dockSerialTransport }); // required
client.onStatus = (m) => console.log(m);
await client.connect();
const id = await client.identify(); // { mac, hardwareVersion, firmwareVersion, expansionBoard }
const status = await client.getStatus(); // { voltage, percentage, chargingStatus, adcValue }
// Property-level config (READ / WRITE a single component+property):
const range = await client.getConfig(UART_PROP.GSR.RANGE);
await client.setConfig(UART_PROP.GSR.RANGE, new Uint8Array([2]));
// Low-level InfoMem escape hatch (raw bytes; layout not interpreted in D1):
const infomem = await client.readInfoMem(0, 128);Like Shimmer3Client, the dock link is an unframed byte stream, so the
client accumulates inbound bytes and extracts complete packets with a
length-aware parser (wiredPacketLength), robust to packets split, dribbled or
coalesced arbitrarily. A packet whose CRC fails triggers a single-byte resync,
and device error responses (BAD_CMD / BAD_ARG / BAD_CRC) reject with their
reason.
Config surface. The wired protocol exposes discrete config commands via the
Java mListOfUartCommandsConfig list (surfaced as UART_CONFIG_COMMANDS,
same order). These are GQ-oriented enable/rate/range/divider properties; for
a Shimmer3/3R the app's real configuration model (enabled sensors, sampling
rate, sensor ranges) lives in InfoMem, not in these per-property commands.
D1 therefore exposes both: the property-level getConfig/setConfig/
getConfigAll for the discrete commands the firmware implements, and a raw
readInfoMem/writeInfoMem escape hatch for the InfoMem-backed config — but it
does not port the InfoMem layout (that maps InfoMem bytes ↔ the app config
model and is a later phase).
Hardware-verify items (need a real dock to confirm):
- Init/timing. The 500 ms per-request response timeout (Java
SERIAL_PORT_TIMEOUT) and the 2× MAC-read retry are ported as-is; real dock latency may warrant tuning. Whether the FTDI port needs DTR/RTS asserted or a settle delay after open is transport-level and not yet exercised. - VER payload width. The parser accepts both the 7-byte (1-byte HW version) and 8-byte (2-byte HW version) layouts; which a given docked firmware returns needs confirming on hardware.
- Battery semantics. Voltage (ADC → V via the shared U12 calibration, then
the divider) and the 4th-order charge-% polynomial are ported exactly, but the
charging-status byte values (
0xC0/0x40/0x80/0x00/0xFF) and the percentage curve should be sanity-checked against a docked device across charge states. Note this path keeps Java's ×1.988 divider, where the streamedBATTERYchannel uses the firmware's own ×2 — 0.6% apart. The charge-% polynomial was fitted against the 1.988 figure, so changing it here would move the reported percentage; the two are deliberately not unified until a docked device has been measured. - Expansion-board / MAC byte order. MAC is emitted in device byte order
(first 6 payload bytes, no reversal, per the Java); the daughter-card ID is
read as
[boardId, boardRev, specialRev]. Confirm against known hardware.
Verification status: code-complete, pending hardware. The SmartDock base protocol is ported from the Java driver (
SmartDockUart/SmartDockUartListener) and covered by unit tests driving a scripted multi-slot base overLoopbackTransport, but it has not yet been exercised against a physical Base-6 / Base-15. See Hardware-verify items below.
Phase D2 adds SmartDock multi-slot bases (Base-6 = 6 slots, Base-15 = 15 slots) on top of D1. A SmartDock has two channels over (two) FTDI serial ports:
- a base control channel speaking short ASCII
SDx$commands (\r\n-terminated replies) — read version, query occupancy, switch the active slot; and - a per-Shimmer UART channel onto which the base routes the active slot,
spoken with the D1 binary
$-header protocol.
Multi-slot support is therefore select a slot on the base channel, then talk to
the docked Shimmer on the per-Shimmer channel. SmartDockClient composes
(does not duplicate) WiredShimmerClient for the per-Shimmer half. Scope is
READ-ONLY: dock info, occupancy, slot select, per-slot identify/status. No
config writes, no SD/mass-storage (the SDC with-SD-access connect exists in the
oracle but is not driven), no bootloader/flashing.
import { SmartDockClient } from '@shimmerresearch/shimmer-web-sdk';
// baseSerial and shimmerSerial are two distinct serial ShimmerTransports
// (capabilities.framed = false), one per FTDI port the base exposes.
const dock = new SmartDockClient({ transport: baseSerial, shimmerTransport: shimmerSerial });
await dock.connect();
const info = await dock.getDockInfo(); // { hardwareType: 'base15', firmwareVersion, slotCount: 15 }
const slots = await dock.getSlotOccupancy(); // [{ slot: 1, occupied: true }, { slot: 2, occupied: false }, ...]
// Select a slot then reuse the D1 per-Shimmer protocol against it:
const id = await dock.identifyDockedShimmer(1); // { mac, hardwareVersion, firmwareVersion, expansionBoard }
const st = await dock.getDockedShimmerStatus(1); // { voltage, percentage, chargingStatus, adcValue }Slot selection issues SDP,NN$, awaits the P,NN confirmation with the ported
~10 s slot-change timeout (SMARTDOCK_RESPONSE_TIMEOUT_SLOT_CHANGE), verifies
the returned slot matches, then waits the ported 1500 ms without-SD settle
delay (SLOT_CHANGEOVER_DELAY_WITHOUT_SD_CARD) before the per-Shimmer UART is
usable. Normal base-command reads use the ported 1000 ms timeout
(SMARTDOCK_RESPONSE_TIMEOUT). Like D1, the base UART is an unframed byte
stream, so the client accumulates bytes and extracts \r\n-terminated lines,
ignoring unrelated / partial lines (resync); an E line rejects with an error.
Hardware-verify items (need a real Base-6 / Base-15 to confirm):
- Slot-change timing. The 10 s slot-change timeout and 1500 ms without-SD settle delay are ported as-is; real base latency (especially how long the per-Shimmer UART takes to become usable after routing) needs measuring.
- Occupancy semantics. Occupancy is decoded from the
SDQ$→Q,<bitmap>reply (one ASCII0/1per slot, index 0 → slot 1). The auto-notifyS,<map>push and the prototype-board slot remap (remapSlotsSmartDockToUi, only BASE15U firmware ≤ 1.0.0.5) are deliberately not implemented — confirm no production base needs the remap. - Base6-vs-15 detection.
getDockInfoderives family + slot count from the version reply's hardware-version field (BASE_HARDWARE_IDS: 1 → base15, 2 → base6). In the Java driver the slot count actually comes from the USB device descriptor, not the version byte — verify the version byte alone is sufficient, or fall back to the occupancy-bitmap length. - Two-port assumption. Real hardware presents the base control UART and the
per-Shimmer UART as two separate serial ports (
SmartDock.java:226-229); confirm the port enumeration / which is which on the target platform.
runFactoryTest asks the sensor to run the suite its firmware runs on the
production line, and returns the report it prints. It is available on
Shimmer3RClient (BLE and Classic Bluetooth) and on WiredShimmerClient
(dock UART, and a Shimmer3R's USB-C port).
import {
SHIMMER3_FACTORY_TEST_TYPE,
SHIMMER3_FACTORY_TEST_TYPES,
parseShimmerFactoryTestReport,
} from '@shimmerresearch/shimmer-web-sdk';
// SHIMMER3_FACTORY_TEST_TYPES describes all four suites — label, expected
// duration, default timeout, and whether that suite prints a verdict at all.
const report = await client.runFactoryTest(SHIMMER3_FACTORY_TEST_TYPE.MAIN, {
onLine: (line) => console.log(line), // shown as it prints, not at the end
});
const parsed = parseShimmerFactoryTestReport(report);
console.log(parsed.overall.result, parsed.overall.failedTestNames);Three things make this command unlike the others in this SDK, and a host has to be built for them:
- The report is raw text on the same link. The firmware acknowledges the command and then prints, with no opcode, no length and no CRC. The client diverts those bytes ahead of its framer for the duration; nothing else needs to know.
- Nothing else may be sent meanwhile. The firmware's main loop is blocked
for the whole suite — up to about seventy seconds for the LED-state
walk-through — so every other command rejects with a
FactoryTestErrorwhosereasonisbusyuntil the run ends. - There is no abort command.
opts.signalstops the client listening; the sensor keeps printing to its own end. The run therefore enters adrainingstate, and the link is only free oncewhenFactoryTestIdle()resolves:
const ctl = new AbortController();
const run = client.runFactoryTest(SHIMMER3_FACTORY_TEST_TYPE.LED_STATES, {
signal: ctl.signal,
});
ctl.abort();
await run.catch(() => {}); // rejects at once…
await client.whenFactoryTestIdle(); // …but the sensor is still printingfactoryTestState and onFactoryTestStateChange report idle / running /
draining, which is what a user interface needs to keep its own gating honest.
The firmware refuses the command outright while the sensor is streaming or
recording (reason: 'nack').
parseShimmerFactoryTestReport reads both families: Shimmer3R reports carry
S3R_TEST_00NN ids, Shimmer3 reports carry none at all and are named from
their line content. parseVerisenseFactoryTestReport shares the same core and
returns the same shape.
toggleLed() flips the firmware's red-LED override — the "which sensor is
this one" aid, which holds the lower LED solid red above the sensor's own
indications. The firmware never clears the flag, so it survives a disconnect
until it is toggled again or the sensor loses power. Because the command is a
toggle with no "set", setRedLed(on) reads status bit 7 either side of it,
writes nothing when the LED is already as asked, and throws rather than
reporting success if the sensor's flag does not follow. Bluetooth only: the
dock protocol has no LED command.
npm install
npm run build # produces dist/shimmer-web-sdk.esm.js, .umd.js, .d.tsConsumer repos can call this SDK-owned helper to keep build logic centralized:
powershell -ExecutionPolicy Bypass -File .\build-local-sdk.ps1First run only (installs dependencies):
powershell -ExecutionPolicy Bypass -File .\build-local-sdk.ps1 -InstallDepsBuild from a specific tag version (for example v0.1.7) without switching your current checkout:
powershell -ExecutionPolicy Bypass -File .\build-local-sdk.ps1 -Version 0.1.7Build from the latest v* tag:
powershell -ExecutionPolicy Bypass -File .\build-local-sdk.ps1 -Latestnpm test # Vitest — runs without a browserThis package is configured for GitHub Packages (@shimmerresearch scope).
Release, tag, and package publishing are automated by .github/workflows/cut-release.yml.
To cut a new release using npm-standard versioning:
- Run
.github/workflows/cut-release.yml. - Choose
major,minor, orpatch. - The workflow bumps
package.json/package-lock.json, publishes to GitHub Packages, pushes thevX.Y.Ztag, and creates a GitHub Release.
Formatting is automated on each push by .github/workflows/format-on-commit.yml using Prettier.
- Run locally:
npm run format - Check formatting only:
npm run format:check
src/
index.ts ← barrel: re-exports all public API
core/
types.ts ← shared interfaces (IShimmerClient, SensorField…)
ObjectCluster.ts ← sensor data frame container
BaseShimmerClient.ts ← abstract base class
units.ts ← CHANNEL_UNITS — the Java driver's unit strings
StreamTimeline.ts ← counter unwrap + wall-clock anchoring
transport/ ← pluggable byte-pipe layer
types.ts ← ShimmerTransport interface + capabilities
WebBluetoothTransport.ts ← Web Bluetooth GATT transport (default web)
WebSerialTransport.ts ← Web Serial (USB) transport
LoopbackTransport.ts ← in-memory transport for tests
devices/
calibration/
streamChannels.ts ← the one per-channel conversion table
gsr.ts ← GSR µS / kΩ / range, shared by both clients
sensorIds.ts ← SC_SENSOR ids, dump → per-group calibration
pressure/ ← Bosch BMP180 / 280 / 390 / 581 compensation
response.ts ← the 0xA7 reply, coefficient parsers
compensate.ts ← raw registers → kPa and °C
exg/
registers.ts ← ADS1292R register codec
calibration.ts ← reference voltage and PGA gain → mV factor
shimmer3/
Shimmer3Client.ts ← classic Bluetooth / unframed byte stream
sensorRules.ts ← conflicts, expansion power, board gating
protocol.ts ← control-message framing, firmware gates
shimmer3r/
Shimmer3RClient.ts ← main BLE client class
constants.ts ← opcodes, UUIDs, defaults
channelFormats.ts ← channel ID → format map
SensorBitmap.ts ← sensor enable bitmasks
calibration.ts ← GSR / ExG / ADC calibration math
protocol.ts ← byte-level helpers (u16le, sign24…)
verisense/
VerisenseClient.ts ← main BLE + Serial client class
constants.ts ← NUS UUIDs, opcodes, OP_IDX offsets
protocol.ts ← CRC-16, packet framing, config helpers
sensors/
SensorBase.ts ← timestamp unwrap + extrapolation
SensorADC.ts ← ADC/GSR + battery decoder (id=1)
SensorLIS2DW12.ts ← LIS2DW12 accelerometer (id=2)
SensorLSM6DS3.ts ← LSM6DS3 gyro+accel (id=3)
SensorPPG.ts ← PPG decoder (id=4)
tests/
shimmer3r/
calibration.test.ts
protocol.test.ts
verisense/
crc.test.ts
sensors.test.ts
BSD-3-Clause (see LICENSE).