Skip to main content

Custom Devices

Register a Bluetooth device the SDK does not already support by declaring what it needs — the GATT service and characteristic UUIDs, how a complete frame is recognised, and how each byte range becomes a number. No SDK release is involved.

The devices the SDK ships with are not declared this way by consumers — they are part of the SDK. See Supported Devices for those, and check there first: a product from a family the SDK already speaks is a few lines rather than a byte-level derivation.

What a declaration looks like​

import {
defineCustomDevice,
IntegratedDevices,
} from "@ovok/native/bt-management";
import { MeasurementTypeKey } from "@ovok/core";

export const acmeScale = defineCustomDevice({
id: "acme-scale",
nameMatch: /^ACME Scale \d{4}$/,
stuckDataTimeoutMs: 30_000,
services: [
{
uuid: "181d", // 16-bit, 32-bit and 128-bit UUIDs are all accepted
monitor: [
{
uuid: "2a9d",
frame: { minLengthBytes: 3 },
caseSelectorBytes: [0], // the flags byte identifies the frame
cases: [
{
selector: "00",
kind: "result",
measurementTypeKey: MeasurementTypeKey.bodyWeight,
fields: [
{
name: "bodyWeight",
byteIndexes: [1, 2],
byteOrder: "little",
encoding: "uint",
unit: "kg",
scale: 0.005, // the device counts in 5 g steps
decimals: 3,
},
],
},
],
},
],
},
],
});

Pass it to BTProvider next to the built-in devices. A session may hold any mix of the two.

<BTProvider
bleManager={bleManager}
acceptedDevices={[IntegratedDevices.BP2, acmeScale] as const}
onResult={({ deviceData, data }) => {
if (deviceData.name === acmeScale.id) {
// data is { measurementTypeKey: "body-weight", bodyWeight: 72.5 }
}
}}
/>

deviceData.name is the declared id, so a mixed session stays distinguishable, and every error names the device it came from.

Frames and cases​

frame decides when the accumulated notification buffer is a complete package.

FieldMeaning
headerRequired leading bytes. The buffer must start with them; the SDK never scans forward, because every declared byte offset is absolute.
minLengthBytesThe shortest buffer that may be parsed. Every byte offset the declaration reads must be inside it — this is checked at registration.
lengthAn in-frame length field (index, widthBytes, byteOrder, overheadBytes) for devices that announce their payload size.

caseSelectorBytes names the byte offsets whose concatenated hex picks the case, and each case's selector is that value. A characteristic with a single frame shape may omit caseSelectorBytes and use an empty selector.

kind is one of:

  • result — a completed reading. Its fields are decoded, bounds-checked and reported through onResult. It works whether or not the device streamed first.
  • stream — the device is measuring. Moves it to DeviceStatus.MEASURING.
  • idle — anything else the device emits. Moves it back to DeviceStatus.CONNECTED.

A profile whose later offsets depend on an earlier flags byte — standard GATT Blood Pressure, for example — is expressed as one case per flags value, each with its own fixed offsets.

Decoding a field​

FieldMeaning
byteIndexesByte offsets in the order the device puts them on the wire.
byteOrder"big" (default) keeps that order, "little" reverses it.
encoding"uint" (default), "int" (two's complement), "sfloat" (IEEE-11073 16-bit), "float" (IEEE-11073 32-bit), "ascii", or "decimal-pair".
scale / offset / decimalsvalue = raw * scale + offset, rounded to decimals.
unitMust be the canonical unit of the property. Convert with scale rather than declaring another unit.
min / maxOptional narrower bounds. They must sit inside the physiological envelope for the property.

name and unit are checked against measurementFieldCatalog, which lists the measurement types a declaration may produce, the properties each accepts and their canonical units.

A result case must declare every property the catalog marks required — systolic and diastolic for a blood pressure, bloodOxygen and pulseRate for a pulse oximeter. Every property is optional in @ovok/core, so a declaration naming only systolic would otherwise compile and then post a blood pressure with no diastolic: a clinical record of something the device never reported.

What fails, and when​

A malformed declaration fails at registration: defineCustomDevice throws, naming the exact position in the declaration. Nothing is scanned for until it compiles. Registration rejects, among others: an id that collides with a built-in device, a property the measurement type does not have, a unit that is not the canonical one, a byte offset the frame does not guarantee is present, an integer wider than a JavaScript number holds exactly, an sfloat that is not two bytes, bounds outside the physiological envelope, two cases with the same selector, a result case that omits a required property, and a nameMatch regular expression carrying the global or sticky flag.

The compiled device keeps its own frozen copy of every frame, so mutating the declaration after defineCustomDevice returns cannot move a byte offset that registration bounds-checked.

At read time a reading is reported only when every declared property decoded to a finite number inside its accepted range. Anything else — a special "no value" code, a frame shorter than the declaration expects, a value outside the envelope — is reported through onError and no measurement is emitted.

A frame whose shape decides its length​

A selector byte often decides how long the frame is. The Bluetooth blood-pressure profile puts a timestamp and a pulse rate behind flag bits, so one shape is seven bytes and another is sixteen — and bounding every case by the shortest of them makes the later fields undeclarable.

A case may therefore state its own minimum, which must be at least the characteristic's:

{
selector: "06", // timestamp present, pulse present
kind: "result",
minLengthBytes: 16, // this shape only
fields: [..., { name: "heartRate", byteIndexes: [14, 15], encoding: "sfloat", unit: "bpm" }],
}

Byte offsets are still bounds-checked, against the case's own length rather than the characteristic's. A frame that arrives shorter than the case it selected decodes nothing and is reported through onError — never guessed at.

A frame that carries several readings​

A multi-parameter monitor sends a heart rate, an oxygen saturation, a respiration rate and a temperature in one frame. Those are four measurements taken by four instruments, not one measurement with nine properties, so each is declared with its own type and its own fields:

{
selector: "03",
kind: "result",
measurementTypeKey: MeasurementTypeKey.heartRate,
fields: [{ name: "heartRate", byteIndexes: [31, 32], byteOrder: "little", unit: "bpm" }],
additionalMeasurements: [
{
measurementTypeKey: MeasurementTypeKey.temperature,
fields: [{ name: "bodyTemp", byteIndexes: [44, 45], byteOrder: "little",
unit: "Cel", scale: 0.01, decimals: 2 }],
},
],
}

Each is reported separately, and each is checked separately: an instrument that is not attached does not withhold the readings beside it. A temperature probe that is unplugged reports a value outside the physiological range, so that one reading is discarded through onError while the heart rate in the same frame is delivered.

Property names must be unique across the whole frame — the decoded frame is one flat object, so two readings claiming heartRate would share whichever byte range compiled last. Registration refuses it.

Frame integrity​

Declare frame.checksum wherever the device has one. Without it a declaration accepts whatever the radio delivered: a corrupted notification whose header and length still look right is decoded, and the wrong number is reported as a reading.

frame: {
header: ["aa", "01"],
minLengthBytes: 8,
checksum: { algorithm: "xor", index: 7, from: 1 },
}
FieldMeaning
algorithmcrc8 (polynomial 0x07), xor or sum.
indexByte offset holding the checksum.
fromFirst covered byte. Defaults to 0.
byteCountCovered byte count. Defaults to every byte between from and index.

The covered range is stated rather than assumed. A trailing checksum over everything before it is the common shape and is the default, but devices that cover only the payload exist, and a convention hidden in the validator would silently mis-verify them.

What still needs a device class​

A declaration is data, so it cannot express behaviour. These devices are rejected at registration or should not be declared:

  • Stateful protocols the SDK has no sequencer for. A write.commandHex is a single static command, sent after connect and after each processed frame. A device needing a handshake or a challenge response needs a device class — unless it is one of the families the SDK already speaks, in which case see Supported Devices.
  • Readings assembled across notifications. ECG waveforms and urine-analysis panels are built up over many frames; MeasurementTypeKey.ecg and the other absent types are refused by the catalog.
  • Frames whose meaning depends on earlier frames. If "weight 0 means nobody is standing on the scale" needs remembering, that is code.
  • Misaligned buffers. A frame is only recognised where it starts. Declare a header where the device has one; without it, a buffer that arrives misaligned is left to the stuck-data timeout rather than decoded at a shifted offset. Each characteristic buffers separately, so a truncated frame on one characteristic can never shift the parse of the next frame on another.

Reusing the Lepu/Viatom protocol​

The family protocol is also available as a custom declaration. Use the lepu option when the device is a Lepu/Viatom-family model that is not yet in the catalog; this reuses the SDK's framed transport, model gate, command builder, and optional ECG file transfer instead of reimplementing the byte protocol:

const familyDevice = defineCustomDevice({
id: "my-viatom-model",
nameMatch: /^MyViatomModel$/,
stuckDataTimeoutMs: 30_000,
services: [{
uuid: "0000fff0-0000-1000-8000-00805f9b34fb",
monitor: [{
uuid: "0000fff1-0000-1000-8000-00805f9b34fb",
frame: { minLengthBytes: 8 },
cases: [{ selector: "", kind: "idle" }],
}],
}],
lepu: {
protocol: "v2",
startBytes: ["a5"],
write: {
serviceUuid: "0000fff0-0000-1000-8000-00805f9b34fb",
characteristicUuid: "0000fff2-0000-1000-8000-00805f9b34fb",
},
expectedDeviceTypes: [1234],
pollCommand: 8,
},
});

The UUIDs, model numbers, start byte, and command are hardware-specific placeholders in this example. Do not copy them without a captured advertisement and protocol trace. pollCommand is optional; if omitted, the declaration can identify the model without sending a model-specific poll. Set fileTransfer: "ecg" only for a device whose stored ECG file protocol has been verified.