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.
| Field | Meaning |
|---|---|
header | Required leading bytes. The buffer must start with them; the SDK never scans forward, because every declared byte offset is absolute. |
minLengthBytes | The shortest buffer that may be parsed. Every byte offset the declaration reads must be inside it — this is checked at registration. |
length | An 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 throughonResult. It works whether or not the device streamed first.stream— the device is measuring. Moves it toDeviceStatus.MEASURING.idle— anything else the device emits. Moves it back toDeviceStatus.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
| Field | Meaning |
|---|---|
byteIndexes | Byte 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 / decimals | value = raw * scale + offset, rounded to decimals. |
unit | Must be the canonical unit of the property. Convert with scale rather than declaring another unit. |
min / max | Optional 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 },
}
| Field | Meaning |
|---|---|
algorithm | crc8 (polynomial 0x07), xor or sum. |
index | Byte offset holding the checksum. |
from | First covered byte. Defaults to 0. |
byteCount | Covered 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.commandHexis 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.ecgand 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
headerwhere 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.