scentience
v2.2.1
Published
Connect to Scentience olfaction instruments via BLE Bluetooth, with a hosted OVL inference client
Maintainers
Readme
scentience
npm-installable package for interacting with Scentience olfaction instruments via BLE Bluetooth, with a client for the hosted Olfaction-Vision-Language (OVL) inference API.
Browser support
Web Bluetooth is required for browser use. It is supported in Chrome, Edge, and other Chromium-based browsers on desktop and Android. It is not supported in Firefox, Safari, or any browser on iOS.
Your page must also be served over HTTPS (or localhost) — Web Bluetooth is blocked on plain HTTP.
| Environment | Status | Notes |
|--------------------|---------|--------------------------------------------|
| Chrome / Edge | ✅ | Desktop and Android |
| Other Chromium | ✅ | Opera, Brave, Samsung Internet, etc. |
| Firefox | ❌ | No Web Bluetooth support |
| Safari / iOS | ❌ | No Web Bluetooth support on any iOS browser|
| Node.js | ✅ | Via webbluetooth peer dependency |
Installation
npm install scentienceFor Node.js environments, also install the optional BLE polyfill:
npm install webbluetoothBrowser and React environments use the native Web Bluetooth API — no extra dependencies needed.
The cloud OVL client needs no extra packages at all: it uses the built-in Fetch API, available in every modern browser and in Node.js 18+.
Quick Start
The Scentience service and characteristic UUIDs are set as defaults — for standard Scentience devices you can call connectBLE() with no UUID arguments.
import { ScentienceDevice } from 'scentience';
const device = new ScentienceDevice('<YOUR_API_KEY>');
// Defaults work for standard Scentience devices.
// Pass serviceUuid / charUuid explicitly if your firmware uses different values.
await device.connectBLE();
// Single reading
const sample = await device.sampleBLE({ async: true });
console.log(sample);
// Continuous stream
device.on('data', (data) => console.log(data));
device.on('error', (err) => console.error(err));
await device.streamBLE({ async: true });React Example
import { useEffect, useState } from 'react';
import { ScentienceDevice } from 'scentience';
export default function App() {
const [reading, setReading] = useState(null);
useEffect(() => {
const device = new ScentienceDevice('<YOUR_API_KEY>');
device
.connectBLE({ serviceUuid: '<YOUR_SERVICE_UUID>', charUuid: '<YOUR_CHAR_UUID>' })
.then(() => {
device.on('data', setReading);
return device.streamBLE({ async: true });
})
.catch(console.error);
return () => device.disconnect();
}, []);
return <pre>{JSON.stringify(reading, null, 2)}</pre>;
}API
new ScentienceDevice(apiKey)
| Parameter | Type | Description |
|-----------|----------|-------------------------|
| apiKey | string | Your Scentience API key |
device.connectBLE(options)
Scan for and connect to one or more Scentience peripherals.
Both serviceUuid and charUuid must be valid 128-bit Bluetooth UUIDs in 8-4-4-4-12 format using only hex characters (0–9, a–f), e.g. 00001234-0000-1000-8000-00805f9b34fb. Obtain these values from your device's firmware or BLE GATT table — they are distinct identifiers and cannot be the same string.
| Option | Type | Default | Description |
|---------------|------------|--------------------------------------------|---------------------------------------------------------------|
| serviceUuid | string | '569a1101-b87f-490c-92cb-11ba5ea5167c' | GATT service UUID (declared in optionalServices) |
| charUuid | string | '569a2000-b87f-490c-92cb-11ba5ea5167c' | GATT characteristic UUID within that service |
| deviceUid | string | null | Single-device mode: filter scan to a specific device name/UID |
| deviceUids | string[] | null | Multi-device mode: connect to multiple devices by UID |
When deviceUids is provided the browser picker is shown once per requested UID. Each selected device is matched against the UID using this priority: (1) exact device.id, (2) exact device name, (3) case-insensitive substring of device name (e.g. "A00022" matches "Scentience A00022"). All matched devices are then connected concurrently and an error is thrown listing any UIDs that were not matched.
Web Bluetooth constraint: The browser's
requestDevice()API requires an explicit user gesture and returns exactly one device per call. This is a hard security requirement of the Web Bluetooth spec — no library can bypass it. Consequently,deviceUidswith N entries will open the browser device picker N times, once per UID. After all selections are confirmed the connections are established in parallel. In Node.js environments using thewebbluetoothpolyfill the picker interaction is replaced by a programmatic scan, so multiple devices can be selected in a single pass.
Returns Promise<ScentienceDevice>.
device.sampleBLE(options)
Request a single sensor reading from the connected device(s).
| Option | Type | Default | Description |
|---------|-----------|---------|--------------------------------------------------|
| async | boolean | false | Return a Promise resolving with the data payload |
Returns Promise<ScienceData> when one device is connected, or Promise<ScienceData[]> when multiple devices are connected (one entry per device, in the same order as deviceUids).
device.streamBLE(options)
Begin continuous streaming from all connected devices. Use .on('data', cb) to receive readings. The UID field inside each packet identifies which device sent it.
| Option | Type | Default | Description |
|---------|-----------|---------|----------------------------------------------------------|
| async | boolean | false | Return a Promise that stays pending until stopStream() |
Returns Promise<void>.
device.on(event, callback) / device.off(event, callback)
Subscribe or unsubscribe from stream events.
| Event | Callback signature |
|---------|-------------------------|
| data | (data: ScienceData) => void |
| error | (err: Error) => void |
device.stopStream()
Stop an active stream and release BLE notification subscriptions on all connected devices.
device.disconnect()
Stop the stream and close all active GATT connections.
device.isConnected
boolean — true when at least one GATT connection is active.
ScentienceDevice.scanDevices(timeoutMs?)
Static method. Scan for visible BLE devices without connecting and return their info.
| Parameter | Type | Default | Description |
|--------------|----------|---------|------------------------------------|
| timeoutMs | number | 10000 | Scan duration in milliseconds |
Returns Promise<DeviceInfo[]> where each entry is { name: string | null, address: string, rssi: number | null }.
Requires the experimental Web Bluetooth Scanning API (requestLEScan). In Chrome, enable it via chrome://flags/#enable-web-bluetooth-new-permissions-backend.
const devices = await ScentienceDevice.scanDevices(5000);
console.log(devices);
// [{ name: 'Scentience A00022', address: '...', rssi: -62 }, ...]Data Payload
Sensor readings are delivered as a JSON object. Chemical compound fields are only present when their detected magnitude is greater than zero.
{
"UID": "device_identifier",
"TIMESTAMP": "2026-03-23T12:00:00.000Z",
"ENV_temperatureC": 22.4,
"ENV_humidity": 45.1,
"ENV_pressureHpa": 1013.2,
"STATUS_opuA": 120,
"BATT_health": 98,
"BATT_v": 3.85,
"BATT_charge": 87,
"BATT_time": 14.2,
"CO2": 412,
"VOC": 0.03
}| Prefix | Description |
|----------|---------------------------|
| ENV_ | Environmental parameters |
| BATT_ | Battery metrics |
| (none) | Chemical compound readings |
Connect to a Specific Device
await device.connectBLE({
serviceUuid: '<YOUR_SERVICE_UUID>',
charUuid: '<YOUR_CHAR_UUID>',
deviceUid: '<DEVICE_UID>',
});Connect to Multiple Devices Simultaneously
Pass deviceUids to connect to several instruments at once. All matched devices are connected concurrently.
Browser limitation: The Web Bluetooth API requires one user gesture per device. Passing
deviceUids: ['A', 'B']will open the browser device picker twice — once for each UID. This is a hard constraint of the Web Bluetooth security model; it cannot be changed by any library. After both devices are confirmed they are connected in parallel. Node.js environments (with thewebbluetoothpolyfill) do not have this restriction.
import { ScentienceDevice } from 'scentience';
const device = new ScentienceDevice('<YOUR_API_KEY>');
// Browser: picker opens twice (once per UID).
// UIDs are matched by exact device name or case-insensitive substring
// (e.g. "A00022" matches "Scentience A00022").
await device.connectBLE({ deviceUids: ['Scentience A00022', 'Scentience B00031'] });
// sampleBLE returns an array when multiple devices are connected.
const [sampleA, sampleB] = await device.sampleBLE({ async: true });
console.log(sampleA.UID, sampleB.UID);
// streamBLE receives packets from all devices.
// The UID field in each packet identifies the source device.
device.on('data', (data) => {
console.log(`[${data.UID}]`, data);
});
await device.streamBLE({ async: true });Scan for Devices
Use ScentienceDevice.scanDevices() to list visible BLE devices before connecting. Requires the experimental Web Bluetooth Scanning API (Chrome flag: enable-web-bluetooth-new-permissions-backend).
import { ScentienceDevice } from 'scentience';
const devices = await ScentienceDevice.scanDevices(5000);
console.log(devices);
// [{ name: 'Scentience A00022', address: '...', rssi: -62 }, ...]Error Handling
All async methods throw on failure. Common failure points and what to expect:
| Situation | Error message |
|-----------|---------------|
| No API key passed to constructor | A Scentience API key is required. |
| Malformed UUID passed to connectBLE | Invalid Bluetooth UUID: "..." |
| Empty deviceUids array | deviceUids must be a non-empty array... |
| UID not matched by any selected device | The following device UIDs were not matched: ... |
| sampleBLE / streamBLE before connectBLE | Not connected. Call connectBLE() first. |
| Web Bluetooth unavailable (wrong browser / HTTP) | Web Bluetooth API is unavailable. |
| scanDevices without the experimental flag | scanDevices() requires the experimental Web Bluetooth Scanning API... |
Wrap connection and streaming logic in try/catch:
try {
await device.connectBLE();
device.on('data', (data) => console.log(data));
device.on('error', (err) => console.error('Stream error:', err));
await device.streamBLE({ async: true });
} catch (err) {
console.error('BLE error:', err.message);
device.disconnect();
}The error event on the device fires for individual packet decode failures during a stream without stopping the stream. The outer try/catch covers connection and setup failures.
Olfaction-Vision-Language Embeddings
ScentienceEmbedder provides access to the four COLIP OVL models — a joint multimodal embedding space for olfaction, vision, and language data.
Install the required peer dependency:
npm install @huggingface/inference| Variant | Description |
|-------------------|--------------------------------------------------------|
| colip-small-base| Fast inference; edge robotics, mobile |
| colip-small-gat | Graph-Attention variant; edge with higher accuracy |
| colip-large-base| Best accuracy for online/server tasks |
| colip-large-gat | Graph-Attention variant; highest accuracy |
Example 1 — Embed a live sensor reading and match against text labels
import { ScentienceDevice, ScentienceEmbedder } from 'scentience';
const device = new ScentienceDevice('<SCENTIENCE_API_KEY>');
const embedder = new ScentienceEmbedder({
model: 'colip-large-base',
apiToken: '<HF_TOKEN>',
});
await device.connectBLE({ serviceUuid: '<SERVICE_UUID>', charUuid: '<CHAR_UUID>' });
const reading = await device.sampleBLE({ async: true });
// Embed the sensor reading and a set of candidate text labels
const [scentVec, ...labelVecs] = await Promise.all([
embedder.embedOlfaction(reading),
embedder.embedText('fresh coffee'),
embedder.embedText('pine forest'),
embedder.embedText('ocean breeze'),
]);
const ranked = ScentienceEmbedder.rankBySimilarity(scentVec, labelVecs);
const labels = ['fresh coffee', 'pine forest', 'ocean breeze'];
console.log('Best match:', labels[ranked[0].index], `(score: ${ranked[0].score.toFixed(3)})`);Example 2 — Load the small-GAT model and embed text + image
import { ScentienceEmbedder } from 'scentience';
const embedder = new ScentienceEmbedder({
model: 'colip-small-gat',
apiToken: '<HF_TOKEN>',
});
const textVec = await embedder.embedText('smoky oak barrel');
const imageVec = await embedder.embedImage('https://example.com/barrel.jpg');
const similarity = ScentienceEmbedder.cosineSimilarity(textVec, imageVec);
console.log('Text–image similarity:', similarity.toFixed(4));ScentienceEmbedder API
| Method / Property | Description |
|-------------------------------------------------|--------------------------------------------------------------------|
| new ScentienceEmbedder({ model, apiToken, modelId? }) | Create a client for the given model variant. |
| embedder.embedText(text) | Embed a text string. Returns Promise<number[]>. |
| embedder.embedImage(blobOrUrl) | Embed an image (Blob, ArrayBuffer, or URL). Returns Promise<number[]>. |
| embedder.embedOlfaction(sensorData) | Embed a ScienceData sensor reading. Returns Promise<number[]>. |
| embedder.modelId | The resolved HF repo ID being used. |
| ScentienceEmbedder.cosineSimilarity(a, b) | Cosine similarity between two vectors. Returns number in [-1, 1].|
| ScentienceEmbedder.rankBySimilarity(q, vecs) | Rank candidates by similarity to query. Returns sorted array. |
Note on model IDs: The COLIP_MODELS map assumes repos at kordelfrance/colip-{size}-{variant}. If the models are published under different IDs, pass modelId directly:
new ScentienceEmbedder({ apiToken: '<HF_TOKEN>', modelId: 'kordelfrance/my-custom-id' })Cloud inference — OVL API
OVLClient calls the hosted Olfaction-Vision-Language model with an API key, so you don't download any weights — it uses only fetch, built into browsers and Node.js 18+. It maps a molecule, a gas-sensor reading, or text/image into one shared embedding space, and can localize which object in an image is emitting a detected aroma (with an optional heatmap overlay).
import { readFile } from 'fs/promises';
import { OVLClient } from 'scentience';
const ovl = new OVLClient('<YOUR_API_KEY>'); // get a key from the Scentience portal
// 1) molecule -> embedding + predicted odor descriptors
const r = await ovl.embedMolecule('CC(=C)C1CCC(C)=CC1'); // limonene
console.log(r.descriptors.map((d) => d.descriptor));
// -> ['citrus', 'terpenic', 'herbal', 'woody']
// 2) "which object in this image emits this smell?" + save the heatmap overlay
const image = await readFile('scene.jpg');
const g = await ovl.ground(image, { smiles: 'CC(=C)C1CCC(C)=CC1', returnHeatmap: true });
console.log(g.regions[0]); // highest-scoring image region
await OVLClient.saveHeatmap(g, 'aroma-heatmap.png');Vision calls are slow, by design. The first call to
ground/embedImage/embedTextwarms up the scale-to-zero service (~1 minute); after that,embedTextandembedImagereturn in a few seconds, butgroundscales withgridand commonly runs past a minute — measured against the hosted deployment at roughly 140 s forgrid: 3and 60 s forgrid: 2. That is why the vision methods use their own, longervisionTimeoutMs(300000) instead of the 120000timeoutMsof the olfaction endpoints. Tune either withnew OVLClient(key, { visionTimeoutMs }), and drop toground(image, { grid: 2 })for faster, coarser heatmaps.
| Method | What it does |
|--------|--------------|
| embedMolecule(smiles, { topK }) | molecule → embedding + odor descriptors |
| embedSensor(readings, { topK }) | e-nose window [T][6] → embedding + substance + descriptors |
| descriptors({ smiles \| readings, topK }) | just the top odor descriptors |
| embedText(text) / embedImage(image) | text / image → shared-space embedding |
| ground(image, { smiles \| readings, grid, returnHeatmap, heatmapAlpha }) | aroma-source regions (+ optional heatmap PNG) |
| info() / health() | endpoint metadata / liveness |
| OVLClient.saveHeatmap(response, filename) | write (Node) or download (browser) the heatmap overlay |
| OVLClient.heatmapBytes(response) | the heatmap PNG as a Uint8Array |
| OVLClient.deviceReadingToOvl(reading) | map one ScienceData reading onto the six OVL gas channels |
embedImage and ground accept a Uint8Array, ArrayBuffer, Buffer, Blob, or an http(s) URL string (the bytes are fetched first).
Constructor options
new OVLClient('<YOUR_API_KEY>', {
baseUrl: 'https://your-private-deployment', // default: the hosted endpoint
timeoutMs: 120000, // fast olfaction endpoints
visionTimeoutMs: 300000, // ground / embedImage / embedText
});
// The options-object form is also accepted:
new OVLClient({ apiKey: '<YOUR_API_KEY>', timeoutMs: 60000 });The API key is sent as an X-API-Key request header and is held in a private field, so it never appears in the URL and is not exposed by JSON.stringify(client) or object spreads. baseUrl must be an absolute http/https URL.
Example — live device reading → cloud inference
import { ScentienceDevice, OVLClient } from 'scentience';
const ovl = new OVLClient('<YOUR_API_KEY>');
const device = new ScentienceDevice('<YOUR_API_KEY>');
await device.connectBLE({ deviceUid: 'A00022' });
// collect a short window of sensor samples, then embed it
const window = [];
for (let i = 0; i < 64; i++) {
const reading = await device.sampleBLE({ async: true });
window.push(OVLClient.deviceReadingToOvl(reading));
}
const result = await ovl.embedSensor(window);
console.log(result.substance, result.descriptors.map((d) => d.descriptor));Note on the sensor path. The OVL sensor encoder was trained on a different gas-sensor array, whose channels don't line up 1:1 with the Scentience device.
deviceReadingToOvlprovides a best-effort mapping, so device-sourced sensor inference is experimental until the encoder is recalibrated to your hardware. The molecule and vision/grounding endpoints are unaffected and production-ready.
Sensor windows must be [T][6] arrays in OVL_SENSOR_CHANNELS order — ['NO2', 'C2H5OH', 'VOC', 'CO', 'Alcohol', 'LPG']. Rows of the wrong width, or containing NaN/Infinity, are rejected before the request is sent.
OVL error handling
Every failure — HTTP error, unreachable host, timeout, or a non-JSON body — is raised as an OVLClientError:
import { OVLClient, OVLClientError } from 'scentience';
try {
const r = await ovl.embedMolecule('CCO');
} catch (err) {
if (err instanceof OVLClientError) {
console.error(err.status, err.message); // e.g. 401 "OVL API 401: {"detail":"invalid key"}"
} else {
console.error('Bad input:', err.message); // validation errors are plain Errors
}
}| Property | Description |
|------------|------------------------------------------------------------------|
| status | HTTP status code, or null for transport failures and timeouts |
| body | Response body (truncated to 500 characters), when readable |
A runnable end-to-end script is in examples/ovl-cloud.mjs.
Logging & Export
ScentienceLogger collects readings and exports them to JSON or CSV.
Stream to CSV (Node.js)
import { ScentienceDevice, ScentienceLogger } from 'scentience';
const device = new ScentienceDevice('<YOUR_API_KEY>');
const logger = new ScentienceLogger();
await device.connectBLE({ serviceUuid: '<YOUR_SERVICE_UUID>', charUuid: '<YOUR_CHAR_UUID>' });
device.on('data', (d) => logger.log(d));
await device.streamBLE({ async: true });
// Later — write readings.csv to disk
await logger.exportCSV('readings.csv');Stream to CSV (React / browser download)
import { useEffect, useRef } from 'react';
import { ScentienceDevice, ScentienceLogger } from 'scentience';
export default function App() {
const loggerRef = useRef(new ScentienceLogger());
const deviceRef = useRef(null);
useEffect(() => {
const device = new ScentienceDevice('<YOUR_API_KEY>');
deviceRef.current = device;
device
.connectBLE({ serviceUuid: '<YOUR_SERVICE_UUID>', charUuid: '<YOUR_CHAR_UUID>' })
.then(() => {
device.on('data', (d) => loggerRef.current.log(d));
return device.streamBLE({ async: true });
})
.catch(console.error);
return () => device.disconnect();
}, []);
const downloadCSV = () => loggerRef.current.exportCSV('readings.csv');
const downloadJSON = () => loggerRef.current.exportJSON('readings.json');
return (
<>
<button onClick={downloadCSV}>Download CSV</button>
<button onClick={downloadJSON}>Download JSON</button>
</>
);
}ScentienceLogger API
| Method / Property | Description |
|---------------------------------|------------------------------------------------------|
| logger.log(data) | Add a reading to the log. Returns this. |
| logger.data | Array of all logged records. |
| logger.size | Number of records in the log. |
| logger.clear() | Remove all records. Returns this. |
| logger.toJSON(indent?) | Serialize records to a JSON string. |
| logger.toCSV() | Serialize records to a CSV string. |
| logger.exportJSON(filename?) | Write/download JSON file. Default: scentience-data.json |
| logger.exportCSV(filename?) | Write/download CSV file. Default: scentience-data.csv |
CSV column handling: the header row is the union of all keys across every record. Chemical compound columns are included whenever at least one reading detected them — missing values in other rows appear as empty cells.
License
Apache-2.0
