Adding a custom device
This guide explains how to add support for a custom Bluetooth Low Energy (BLE) device in two scenarios:
- Contributing a device - Add a new device to the monorepo so it is shipped with the library (web, Capacitor, React Native, CLI).
- Using a custom device in your app - Extend the base
Deviceclass in your own project for hardware that is not in the library, and is not mass produced / commercial.
You need your device’s GATT service and characteristic UUIDs, the advertising name or name prefix used during scanning, and an understanding of the protocol (how the device sends force/mass data and any commands).
Contributing a device
Adding a device to the library involves the core package (required) and optionally the Capacitor, React Native, and CLI packages. The core package holds the protocol logic; platform packages wrap it for Web Bluetooth, native BLE, or CLI.
1. Create the device interface
Define a TypeScript interface that extends IDevice and declares any device-specific methods (e.g. battery(), stream(), led()).
File: packages/core/src/interfaces/device/<device-name>.interface.ts
import type { IDevice } from "../device.interface.js"
export interface IMyBoard extends IDevice {
/** Device-specific method example. */
battery(): Promise<string | undefined>
}Use kebab-case for the file name (e.g. my-board.interface.ts). The interface is used for typing the model and for public API exports.
2. Create the device model
Implement the device by extending the abstract Device class and implementing your interface.
File: packages/core/src/models/device/<device-name>.model.ts
- Constructor: Pass
filters,services, and optionallycommandsintosuper(). - Filters:
BluetoothLEScanFilter[]- at least one ofname,namePrefix, orservicesso the Web Bluetooth request can find the device (e.g.[{ namePrefix: "MyBoard" }]). - Services: Array of
Serviceobjects. Each hasname,id(logical id used in code),uuid(BLE service UUID), andcharacteristics: array of{ name, id, uuid }. The characteristic withid: "rx"is the one used for notifications; it will be subscribed to inonConnected. Useid: "tx"for write-only characteristics if the device has a command channel. - Commands: Optional
Commandsobject (e.g.{ START_WEIGHT_MEAS: "e", STOP_WEIGHT_MEAS: "f" }) if the device uses write commands. - handleNotifications: Override
handleNotifications(value: DataView)to parse incoming BLE data. UpdatedownloadPackets,peak,mean,sum,dataPointCount, callactivityCheck(numericData)if appropriate, and invokethis.notifyCallback(this.buildForceMeasurement(current, distribution?))so app code receives real-time data vianotify(). - Device-specific methods: Implement any methods declared in your interface (e.g.
battery(),stream(),stop()), usingthis.read(),this.write(), andthis.commandsas needed.
Example skeleton:
import { Device } from "../device.model.js"
import type { IMyBoard } from "../../interfaces/device/my-board.interface.js"
export class MyBoard extends Device implements IMyBoard {
constructor() {
super({
filters: [{ namePrefix: "MyBoard" }],
services: [
{
name: "Custom Service",
id: "main",
uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
characteristics: [
{ name: "Notify", id: "rx", uuid: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" },
{ name: "Write", id: "tx", uuid: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" },
],
},
],
commands: {
START_WEIGHT_MEAS: "e",
STOP_WEIGHT_MEAS: "f",
},
})
}
override handleNotifications = (value: DataView): void => {
if (!value?.buffer) return
this.updateTimestamp()
// Parse value and update downloadPackets, peak, mean, sum, etc.
// Call this.notifyCallback(this.buildForceMeasurement(current, distribution?))
// Optionally this.activityCheck(numericValue)
}
battery = async (): Promise<string | undefined> => {
// e.g. read or write and use writeCallback
return undefined
}
}Reference existing devices in packages/core/src/models/device/ (e.g. Climbro for notify-only, Progressor for read/write and commands) for full patterns including tare, writeCallback, and response parsing.
3. Export from the core package
- Interfaces: Export the new interface from
packages/core/src/interfaces/index.tsand frompackages/core/src/index.ts(in the type export list). - Models: Export the new class from
packages/core/src/models/index.tsand frompackages/core/src/index.ts(in the value export list).
After this step, the device is available when users install @hangtime/grip-connect.
4. Optional: Capacitor wrapper
To support the device in the Capacitor package, add a wrapper that overrides connection and I/O to use @capacitor-community/bluetooth-le and, if needed, @capacitor/filesystem for download().
- File:
packages/capacitor/src/models/device/<device-name>.model.ts - Extend the core device class (e.g.
import { MyBoard as MyBoardBase } from "@hangtime/grip-connect"), then overrideconnect,disconnect,onConnected,read, andwriteto useBleClient. Overridedownloadif you want mobile-friendly export (e.g. writing toDirectory.Documents). - Export the wrapper from
packages/capacitor/src/models/index.tsandpackages/capacitor/src/index.ts.
Use Climbro’s Capacitor model as a template.
5. Optional: React Native wrapper
Add a similar wrapper under packages/react-native/src/models/device/ that uses the React Native BLE stack (e.g. react-native-ble-plx), and export it from the package index.
6. Optional: CLI
Re-export the core device from packages/cli/src/models/index.ts (and main entry) so the CLI can use it; add a device-specific wrapper only if the CLI needs different behavior.
7. Document the device
- Add a device page under
packages/docs/src/devices/<device-name>.md(see Climbro for structure). - Add a sidebar entry in
packages/docs/src/.vitepress/config.mtsunder the Devices section.
Run the docs dev server (npm run dev:docs) and build (npm run build) to confirm everything compiles and the new device appears in the docs.
Using a custom device in your app
For custom, non-commercial hardware, extend Device in your project. You can simply do the following:
import { Device } from "@hangtime/grip-connect"
class MyCustomBoard extends Device {
constructor() {
super({
filters: [{ namePrefix: "MY-BOARD" }],
services: [
{
name: "Force Service",
id: "force",
uuid: "your-service-uuid",
characteristics: [{ name: "Data", id: "rx", uuid: "your-characteristic-uuid" }],
},
],
})
}
override handleNotifications = (value: DataView): void => {
if (!value?.buffer) return
this.updateTimestamp()
const current = value.getFloat32(0, true) // example
this.peak = Math.max(this.peak, current)
this.sum += current
this.dataPointCount++
this.mean = this.sum / this.dataPointCount
this.notifyCallback(this.buildForceMeasurement(current))
}
}