ble-faker
v1.3.5
Published
Scriptable BLE simulation toolchain for React Native development
Maintainers
Readme
ble-faker
Scriptable BLE peripheral simulator for React Native developers. Define your hardware's behavior in JavaScript, connect your app to it — no physical device required.
A complete working example lives in the ble-faker-demo repository.
Why ble-faker?
If you're building a React Native app that talks to BLE hardware, you've probably felt this pain:
- You need the physical device nearby for every change you want to test
- The device has limited availability, limited firmware control, or both
- Automated tests and CI runs can't include live hardware
- Demoing to stakeholders means praying the device cooperates
ble-faker replaces the physical device on your dev machine. Your app code stays unchanged — the same react-native-ble-plx calls that talk to real hardware talk to ble-faker instead. You get a browser dashboard to watch characteristic values change in real time and inject inputs without touching the app.

Your app code stays the same
The key point: ble-faker ships a drop-in replacement for react-native-ble-plx. You configure Metro to redirect the import — that's it. Every connectToDevice, monitorCharacteristicForService, and writeCharacteristicWithResponseForService call in your app works exactly as before, against simulated hardware.
Getting Started
1. Install
npm install --save-dev ble-faker
# or
pnpm add -D ble-faker2. Create a mock folder
mocks/
└── heart-rate-monitors/ ← category folder (one per device type)
├── gatt-profile.json ← GATT structure
└── FF-00-11-22-33-02.js ← device logic, named by MAC addressgatt-profile.json — mirrors the addMockDevice() payload from react-native-ble-plx-mock:
{
"serviceUUIDs": ["180D"],
"isConnectable": true,
"mtu": 247,
"services": [
{
"uuid": "180D",
"characteristics": [
{ "uuid": "2A37", "properties": { "read": true, "notify": true } },
{ "uuid": "2A39", "properties": { "write": true } }
]
}
]
}FF-00-11-22-33-02.js — device logic:
export default function (state, event) {
if (event.kind === "start") {
return [
{ name: "HR Monitor", rssi: -65 },
{ vars: { hr: 72 } },
{ out: [{ name: "bpm", label: "Heart Rate (bpm)" }] },
];
}
if (event.kind === "tick") {
const hr = Number(state.vars.hr);
return [["2A37", utils.packUint16(hr)], { set: { bpm: String(hr) } }];
}
if (event.kind === "notify" && event.uuid === "2A39") {
// App wrote to the control point — reset heart rate
return [{ vars: { hr: 72 } }];
}
return [];
}No code at all? Just
touch FF-00-11-22-33-02.js. ble-faker auto-generates browser controls from the GATT profile — output fields for readable/notifiable characteristics, input fields for writable ones. Good enough to verify your profile is wired up correctly before you write any logic.
Type checking in JavaScript device files — device logic runs in a plain JS sandbox (no TypeScript transpilation). Use JSDoc annotations for full IDE autocompletion and event narrowing with zero runtime cost:
/// <reference types="ble-faker/device" />
/** @type {import('ble-faker/device').DeviceLogicFn} */
export default function (state, event) {
if (event.kind === "tick") {
const hr = Number(state.vars.hr);
return [["2A37", utils.packUint16(hr)]];
}
return [];
}The /// <reference> directive activates utils global completions; the @type annotation narrows event to each specific event kind in if branches. No tsconfig changes needed.
3. Configure Metro
Add one line to your metro.config.js using the withBleFaker helper:
Expo managed workflow:
const { getDefaultConfig } = require("expo/metro-config");
const { withBleFaker } = require("ble-faker/metro");
const config = getDefaultConfig(__dirname);
module.exports = withBleFaker(config, { dir: "./mocks", label: "My App" });Bare React Native:
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const { withBleFaker } = require("ble-faker/metro");
const config = mergeConfig(getDefaultConfig(__dirname), {});
module.exports = withBleFaker(config, { dir: "./mocks", label: "My App" });withBleFaker is a no-op unless BLE_MOCK=true is set in the environment, so it is safe to apply unconditionally.
withBleFaker options:
| Option | Default | Description |
|---|---|---|
| dir | (required) | Path to your mocks directory |
| label | 'ble-faker' | Label shown in the browser dashboard |
| port | from state file | Override the server port (auto-detected from ~/.ble-faker-server.json) |
| env | 'BLE_MOCK' | Environment variable name that activates the mock |
4. Add scripts to your package.json
"scripts": {
"ble:start": "ble-faker --port 58083",
"ble:stop": "ble-faker stop",
"start:mock": "cross-env BLE_MOCK=true expo start",
"dev:mock": "concurrently -k \"npm run ble:start\" \"npm run start:mock\""
}cross-env and concurrently handle Windows env vars and parallel processes — add them with npm install --save-dev cross-env concurrently. dev:mock starts both the ble-faker server and Metro in one command.
5. Start the server
npm run ble:start6. Run your app with the mock enabled
npm run start:mock
# or start both together:
npm run dev:mockRestart Metro (not just reload) whenever you change metro.config.js.
7. Open the browser dashboard
http://localhost:58083 — lists all namespaces. Click through to a device to see its live output fields and interact with it via input controls.
Device Logic Reference
Events
| event.kind | When | Extra fields |
| ------------ | -------------------------------------------------------------------------- | -------------------------- |
| start | Namespace created or device file saved. Use to initialize state.vars. | — |
| connect | App establishes a BLE connection | — |
| disconnect | BLE connection closes. vars updates persist; char updates are discarded. | — |
| tick | 1-second timer — only fires while a connection is open | — |
| advertise | Server building the device list for the app's scan | — |
| notify | App wrote to a characteristic | uuid, payload (base64) |
| input | Browser UI form submitted | id, payload (string) |
Return commands
| Shape | Effect |
| ------------------------------ | ------------------------------------------------------- |
| ['uuid', base64] | Push a characteristic value to the app |
| { name, rssi, … } | Patch the advertising packet (Partial<Device> fields) |
| { vars: { key: value } } | Persist values into state.vars for the next call |
| { in: [{ name, label }] } | Define browser input controls |
| { out: [{ name, label }] } | Define browser output display fields |
| { set: { field: 'value' } } | Push a string to a named browser output field |
| { disconnect: true } | Simulate a device disconnection (see note below) |
| { readError: { uuid } } | Make the app's next characteristic read for uuid fail |
| { clearReadError: { uuid } } | Clear a previously set read error |
{ disconnect: true }note: The mock signals disconnection to the app by injecting a characteristic error on the first monitored characteristic. Apps that only monitor characteristics other than the first one in the GATT profile may not detect the simulated disconnect. If your disconnect handler isn't firing, ensure the app has an active monitor on the first characteristic defined ingatt-profile.json.
State
| Field | Type | Description |
| ------------- | ------------------------- | ---------------------------------------------------- |
| state.dev | Partial<Device> | Device advertising info (includes id as MAC) |
| state.vars | Record<string, unknown> | Your persisted values — read-only, write via vars: |
| state.chars | Record<string, string> | Current characteristic values by UUID (base64) |
| state.ui | { ins, outs } | Current browser UI definition |
Available globals
No imports needed inside device logic files:
| Global | Description |
| ------------------------------ | ----------------------------------------------- |
| Buffer | Node.js Buffer |
| Uint8Array, DataView | Binary views |
| TextEncoder, TextDecoder | Web encoding API |
| utils.toBase64(arr) | Uint8Array → base64 string |
| utils.fromBase64(str) | base64 → Buffer |
| utils.packUint8(n) | uint8 → base64 |
| utils.packInt8(n) | int8 → base64 |
| utils.packUint16(n) | little-endian uint16 → base64 |
| utils.packInt16(n) | little-endian int16 → base64 |
| utils.packUint32(n) | little-endian uint32 → base64 |
| utils.packFloat32(n) | little-endian IEEE 754 float → base64 |
| utils.unpackUint8(b64) | base64 → uint8 |
| utils.unpackInt8(b64) | base64 → int8 |
| utils.unpackUint16(b64) | base64 → little-endian uint16 |
| utils.unpackInt16(b64) | base64 → little-endian int16 |
| utils.unpackUint32(b64) | base64 → little-endian uint32 |
| utils.unpackFloat32(b64) | base64 → little-endian IEEE 754 float |
| console.log/warn/error | forwarded to server stdout, prefixed by deviceId|
All pack/unpack helpers use little-endian byte order, which is standard for BLE SIG characteristic specifications.
Sandbox
Logic files run in an isolated node:vm context with a 50ms CPU budget per call. No access to process, require, the filesystem, or the network.
Testing
ble-faker supports two testing modes depending on how close to the metal you want to test:
- RNTL / Jest — render React Native components against the mock, assert on UI state. No simulator required. Fast.
- Detox / Maestro — drive the full app in a simulator or on a device. The test client controls the device side while Detox/Maestro drives the UI side.
RNTL / Jest
This is the recommended approach for component-level integration tests. The entire test suite runs in Node.js — no simulator, no Metro, no Expo.
Install additional dependencies
npm install --save-dev @testing-library/react-native jest-expo
# or pnpm add -D ...jest.config.js
module.exports = {
preset: 'jest-expo',
globalSetup: './jest.globalSetup.js',
globalTeardown: './jest.globalTeardown.js',
setupFilesAfterEnv: ['./jest.setup.ts'],
moduleNameMapper: {
// Redirect react-native-ble-plx → ble-faker mock (same as Metro redirect)
'^react-native-ble-plx$': '<rootDir>/node_modules/ble-faker/dist/mock.js',
// Allow imports from ble-faker/test in test files
'^ble-faker/test$': '<rootDir>/node_modules/ble-faker/dist/test-client.js',
},
transformIgnorePatterns: [
'node_modules/(?!(jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|ble-faker)',
],
};jest.globalSetup.js
module.exports = async function () {
const { bleMockServer } = await import('ble-faker');
await bleMockServer.start({ port: 58083 });
};jest.globalTeardown.js
module.exports = async function () {
const { bleMockServer } = await import('ble-faker');
bleMockServer.stop();
};Test file
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { BleTestClient, BleNamespace, BleDevice } from 'ble-faker/test';
import { App } from '../src/App';
const MOCKS_DIR = path.join(__dirname, '../mocks');
const DEVICE_ID = 'ff-00-11-22-33-02';
let client: BleTestClient;
let ns: BleNamespace;
let device: BleDevice;
beforeAll(async () => {
client = BleTestClient.connect(); // reads running server from ~/.ble-faker-server.json
ns = await client.mount({
dir: MOCKS_DIR,
label: 'rntl-test',
disableAutoTick: true, // drive the clock explicitly for deterministic tests
});
device = ns.device(DEVICE_ID);
});
afterAll(async () => {
await client.unmount(ns);
});
it('shows updated heart rate after device tick', async () => {
render(<App />);
// Wait for BLE scan to populate the device list
await waitFor(() => screen.getByText('HR Monitor'));
fireEvent.press(screen.getByText('Connect'));
// Advance the simulated clock — one tick fires the device's tick handler
await device.tickN(1);
// Assert the characteristic value was pushed to the app
await device.waitForChar('2a37', /.+/);
// Assert the UI reflects the new value
await waitFor(() => screen.getByText('72 bpm'));
});client.mount() with disableAutoTick: true stops the 1-second automatic tick, giving you full control over the device clock. Use device.tickN(n) to advance it by exactly n steps.
Key points
- The server starts once in
globalSetupand is shared across all test files — fast startup. BleTestClient.mount()writes the namespace URLs directly into the mock's shared module, so the mock skips the Metro discovery step entirely. Noglobal.fetchpatching required.- Each test file mounts its own namespace. Use different
dirpaths for test isolation, or the samedirwith dedicated device IDs.
Detox / Maestro
The React Native app runs with BLE_MOCK=true (driven by Detox, Maestro, or similar), while the test client controls the device side at the same time. For example, Detox taps a button in the app while your test advances the simulated clock:
it("shows updated temperature after device tick", async () => {
const device = ns.device("ff-00-11-22-33-02");
await detox.element(by.id("connectBtn")).tap();
await device.tickN(60); // advance 1 simulated minute
await expect(detox.element(by.id("temp"))).toHaveText("99°F");
});Setup:
import { BleTestClient } from "ble-faker/test";
import { before, after, describe, it } from "node:test";
describe("heart rate monitor", () => {
const client = BleTestClient.connect(); // reads running server from ~/.ble-faker-server.json
let ns: Awaited<ReturnType<typeof client.mount>>;
before(async () => {
ns = await client.mount({ dir: "./mocks", label: "HR test" });
});
after(async () => {
await client.unmount(ns);
});
it("pushes a heart rate characteristic on each tick", async () => {
const device = ns.device("ff-00-11-22-33-02");
await device.tickN(1);
await device.waitForChar("2a37", /.+/);
});
});Controlling the device
| Method | Description |
| ----------------------------- | ----------------------------------------------- |
| device.input(name, payload) | Simulate a browser UI form submission |
| device.tickN(n) | Advance the device clock by n ticks (max 100) |
| device.forceDisconnect() | Trigger a simulated BLE disconnection |
Asserting
Both methods throw an AssertionError (from node:assert) on timeout — no manual assert call needed. pattern can be a string (exact match) or a RegExp. Default timeout is 5000ms.
Both also check the current value immediately — if it already matches when called, they return right away.
| Method | Description |
| ----------------------------------------------- | ----------------------------------------- |
| device.waitForOutput(name, pattern, timeout?) | Wait until a browser output field matches |
| device.waitForChar(uuid, pattern, timeout?) | Wait until a characteristic value matches |
Inspecting last seen values
After any waitFor* call — including ones that timed out and threw — the last observed value is accessible:
try {
await device.waitForOutput("mmode_out", "M", 3000);
} catch (e) {
console.log(device.lastOutput("mmode_out")); // e.g. "B" — what was actually seen
throw e;
}| Method | Description |
| ------------------------- | ------------------------------------------ |
| device.lastOutput(name) | Last value seen for a browser output field |
| device.lastChar(uuid) | Last value seen for a characteristic |
Running the server in CI
# GitHub Actions example
- name: Start ble-faker
run: npx ble-faker --port 58083 &
- name: Run tests
run: npm test
- name: Stop ble-faker
run: npx ble-faker stop
if: always()For RNTL tests with globalSetup/globalTeardown, the server lifecycle is managed automatically by Jest — no manual CI step needed.
The server writes its URL and port to ~/.ble-faker-server.json on startup. BleTestClient.connect() reads from this file. In CI environments where the home directory is not writable or shared across steps, override the path with the BLE_FAKER_STATE environment variable:
- name: Start ble-faker
run: npx ble-faker --port 58083 &
env:
BLE_FAKER_STATE: /tmp/ble-faker-server.json
- name: Run tests
run: npm test
env:
BLE_FAKER_STATE: /tmp/ble-faker-server.jsonProgrammatic API
Embed the server in your own tooling or integration tests:
import { bleMockServer } from "ble-faker";
await bleMockServer.start({ dir: "./mocks", port: 58083 });
const { pid, url, port } = await bleMockServer.get();
await bleMockServer.stop();Development commands
| Command | Description |
| :---------------- | :-------------------------------------- |
| pnpm dev | Watch mode (rebuilds on source changes) |
| pnpm build | Compile to dist/ |
| pnpm test | Run tests (Node.js native test runner) |
| pnpm build:test | Build + test in one step |
| pnpm lint | oxlint |
Contributing
PRs welcome. See docs/ARCHITECTURE.md for the internal design.
License: MIT
