nsd-ble
v0.4.2
Published
A TypeScript library for communicating with NotSoDoom Bluetooth standees via the Web Bluetooth API. Connect to one standee and communicate with the entire mesh network.
Downloads
90
Readme
nsd-ble
A TypeScript library for communicating with NotSoDoom Bluetooth standees via the Web Bluetooth API. Connect to one standee and communicate with the entire mesh network.
Installation
npm install nsd-bleQuick Start
import { useMesh, useStandees } from "nsd-ble";
const mesh = useMesh();
const { standees, onChange } = useStandees();
// connect() must be called from a user gesture (e.g. button click)
document.getElementById("connect").addEventListener("click", async () => {
await mesh.connect();
});
// React to standees joining the mesh
onChange.attach((list) => {
console.log(
"Standees:",
list.map((s) => s.name()),
);
});Requirements
- A browser that supports the Web Bluetooth API (Chrome, Edge, Opera)
connect()must be called from a user-initiated event (click, tap, etc.) due to Web Bluetooth security requirements
Imports
The package exposes four entry points:
// Core composables
import {
useMesh,
useStandees,
useGraphics,
useLoader,
useDisplay,
useStandeeElements,
useActions,
useButtons,
useConverter,
useBinary,
useImage,
useUtils,
useDebug,
useMockBluetooth,
} from "nsd-ble";
// Display elements
import { Rectangle, Image, Text, DisplayElement } from "nsd-ble/elements";
// Object types
import { Standee, ButtonResult, Buffer } from "nsd-ble/objects";
// Enums
import {
Font,
Button,
ActionType,
ElementData,
ActionCondition,
ActionDataType,
HandshakeType,
DataType,
FloodTypes,
Data,
} from "nsd-ble/enums";Core Concepts
Mesh Network
Standees form a Bluetooth mesh network. You only need to connect to a single standee — messages are automatically flooded to all reachable nodes.
Composable Pattern
All functionality is accessed through useX() composable functions. Some composables are global (useMesh, useDisplay) while others are scoped to a specific standee (useGraphics(standee), useLoader(standee)).
Buffer System
Most operations return a Buffer object. Call .send() on it to transmit the data over BLE:
const buffer = useDisplay().draw(standee);
await buffer.send();Buffers can also be chained with .then():
useDisplay()
.clear(standee)
.then(() => {
console.log("Screen cleared");
})
.send();API Reference
useMesh()
Main entry point. Manages the BLE connection and mesh-level events. Auto-reconnect with exponential backoff is enabled automatically after the first successful connection.
const {
connect, // () => Promise<void> — opens the BLE device picker
reconnect, // () => Promise<void> — reconnects to the last device
onConnect, // SyncEvent<void> — fires when connected
onDisconnect, // SyncEvent<void> — fires when disconnected
onReconnecting, // SyncEvent<ReconnectStatus> — auto-reconnect attempt in progress
onReconnectFailed, // SyncEvent<void> — all auto-reconnect retries exhausted
onNodes, // SyncEvent<Map<number, number[]>> — all discovered nodes
onNode, // SyncEvent<Map<number, number[]>> — new node joined
onNodeLost, // SyncEvent<number> — node left the mesh
onPlusButton, // SyncEvent<ButtonResult>
onMinButton, // SyncEvent<ButtonResult>
onEitherButton, // SyncEvent<ButtonResult>
onMessageDone, // SyncEvent<number> — message transmission complete
} = useMesh();Auto-reconnect uses exponential backoff starting at 2 seconds, doubling each attempt up to 30 seconds, with a maximum of 5 retries. Subscribe to onReconnecting to track reconnect attempts:
mesh.onReconnecting.attach((status) => {
// status.attempt — current attempt number
// status.maxRetries — maximum retries (5)
// status.delay — delay before this attempt in ms
console.log(`Reconnecting: attempt ${status.attempt}/${status.maxRetries}`);
});
mesh.onReconnectFailed.attach(() => {
console.log("All reconnect attempts failed");
});useStandees()
Tracks discovered standees as a reactive list.
const {
standees, // Standee[] — current list of standees
onChange, // SyncEvent<Standee[]> — fires when the list changes
} = useStandees();useGraphics(standee)
Upload images to a standee's memory. Images are stored by numeric ID (0-255) and can later be referenced by display elements.
const { upload, clear } = useGraphics(standee);
// Convert an image to bytes and upload it as ID 0
const bytes = await useConverter().imageToBytes("/images/icon.png");
await upload(0, bytes).send();
// Clear all uploaded graphics
await clear().send();useLoader(standee)
Sends multiple buffers in sequence with progress tracking. Useful for uploading a batch of images.
const loader = useLoader(standee).load(buffers);
loader.onProgress.attach((progress) => {
// progress.current — buffers completed so far
// progress.total — total buffers
// progress.percentage — 0 to 1
console.log(`${Math.round(progress.percentage * 100)}%`);
});
loader.onError.attach((error) => {
// error.buffer — index of the buffer that failed
// error.error — the Error object
console.error(`Buffer ${error.buffer} failed:`, error.error);
});
await loader.send();useStandeeElements(standee)
Add and remove display elements on a standee. Elements are auto-assigned an ID.
const { add, remove } = useStandeeElements(standee);
const rect = add(new Rectangle(0, 0, 128, 64));
const text = add(new Text(10, 30, "Hello", Font.u8g2_font_5x7_mf));
const img = add(new Image(80, 10, 32, 32, 0)); // references uploaded image ID 0
remove(rect.id);useDisplay()
Draw elements to the standee's screen or clear it.
const { draw, clear } = useDisplay();
// Draw all dirty elements
await draw(standee).send();
// Clear the screen
await clear(standee).send();Only elements marked as dirty are redrawn. Modifying any element property (e.g. rect.x = 50) automatically marks it dirty.
useActions() and useButtons(standee)
Define interactive behavior triggered by the standee's physical buttons.
const actions = useActions();
const { setButton } = useButtons(standee);
// Create a target: which element property to affect
const target = actions.target(textElement.id, ElementData.X);
// Assign actions to the plus button
await setButton(Button.PLUS, [actions.increment(target)]).send();Available actions:
| Action | Description |
| ------------------------------------------------------ | ---------------------------------- |
| increment(target) | Increment a property value |
| decrement(target) | Decrement a property value |
| show(elementId) | Show an element |
| hide(elementId) | Hide an element |
| set(target, value) | Set a property to a specific value |
| broadcast(target?) | Broadcast a value across the mesh |
| stop() | Break the action chain |
| timer(deciseconds, resetable, onComplete) | Run actions after a delay (1/10s) |
| condition(left, op, right, actions) | Conditional action execution |
| map(value, fromLow, fromHigh, toLow, toHigh, target) | Map a value between ranges |
The condition action supports comparing targets, numbers, and strings. The op parameter uses ActionCondition values:
// Show an element when HP equals 0
const hp = actions.target(label.id, ElementData.TEXT);
actions.condition(hp, ActionCondition.EQUAL, 0, [actions.show(gameOverElement.id)]);Receiving Broadcast Data
The broadcast() action sends data from the standee back to your app through the mesh. When a physical button press triggers a broadcast(), the standee reads the targeted element property and floods it as a BUTTON_PRESS message. The library delivers this via the button events on useMesh().
const { onPlusButton, onMinButton, onEitherButton } = useMesh();
onPlusButton.attach((result) => {
console.log(`Standee ${result.id} broadcast: ${result.data}`);
});The ButtonResult contains:
id— the standee ID that sent the broadcastdata— the element property value as a string
Which event fires depends on which physical button was pressed. Actions on Button.PLUS trigger onPlusButton, Button.MINUS triggers onMinButton, and Button.EITHER triggers onEitherButton.
Broadcast with a target sends the current value of that element property. For a Text element targeted at ElementData.DATA, result.data contains the text content (e.g. "42"):
const { target, increment, broadcast } = useActions();
const { setButton } = useButtons(standee);
const hp = target(counter.id, ElementData.DATA);
await setButton(Button.PLUS, [increment(hp), broadcast(hp)]).send();
onPlusButton.attach((result) => {
const value = parseInt(result.data, 10);
console.log(`HP is now: ${value}`); // "1", "2", "3", ...
});Broadcast without a target (broadcast() with no arguments) still triggers the button event, but result.data will be an empty string. This is useful for simple "button was pressed" notifications:
await setButton(Button.PLUS, [increment(hp), broadcast()]).send();
onPlusButton.attach((result) => {
// result.data === ""
console.log(`Standee ${result.id} plus pressed`);
});useConverter()
Converts images to the monochrome byte format the standees expect.
const { imageToBytes } = useConverter();
const bytes = await imageToBytes("/path/to/image.png");useBinary()
Low-level binary utilities for pixel data conversion.
const { pixelsToBytes, bitswap, flattenUint8Arrays } = useBinary();
// Convert pixel array to monochrome byte format
const bytes = pixelsToBytes(pixels, width);
// Concatenate multiple Uint8Arrays into one
const combined = flattenUint8Arrays([array1, array2]);useImage()
Low-level image processing utilities used internally by useConverter().
const { createImage, getImageData, imageDataToPixels } = useImage();
// Load an image from URL
const image = await createImage("/path/to/image.png");
// Get raw ImageData from an HTMLImageElement
const imageData = getImageData(image);
// Convert ImageData to monochrome pixel array
const pixels = imageDataToPixels(imageData);
// { width, height, pixels: number[] } — pixels are 0 (black) or 1 (white)useUtils()
General-purpose utility functions.
const { map } = useUtils();
// Map a value from one range to another
const mapped = map(value, fromLow, fromHigh, toLow, toHigh);useDebug()
Message inspector and debug mode. Logs decoded packet contents, retransmission events, and message timing in a structured format. Emits events for building debug UIs.
const debug = useDebug();
// Enable debug logging (no overhead when disabled)
debug.enable();
// Check if debug mode is active
debug.isEnabled(); // true
// Subscribe to all debug events in real-time
debug.onDebugEvent.attach((entry) => {
console.log(`[${entry.type}]`, entry.data);
});
// Track message latency
debug.onTimingUpdate.attach((timing) => {
if (timing.latencyMs !== null) {
console.log(`Cache ${timing.cacheId}: ${timing.latencyMs}ms`);
}
});
// Read the full event log
const log = debug.getLog();
// Get timing records for all messages
const timings = debug.getTimings();
// Get timing for a specific cache ID
const timing = debug.getTimingForCache(cacheId);
// Decode a raw BLE packet into a structured object
const decoded = debug.decodePacket(rawPacket);
// { cacheId, targetId, ttl, position, dataSize, typeName, typeValue, payloadBytes, raw }
// Control
debug.disable(); // Pause logging
debug.clear(); // Clear log and timing data
debug.setMaxLogSize(1000); // Adjust log buffer size (default: 500)Event types logged: message_queued, packet_sent, retransmit, timeout, message_complete, message_done, received_packets, node_discovered, node_lost, connected, disconnected.
useMockBluetooth()
Offline simulator that replaces the real BLE layer with an in-memory mock. Enables development without physical standees and makes integration testing possible.
const mock = useMockBluetooth();
// Activate the mock before connecting
mock.activate({
standees: [
{ id: 1, neighbors: [2, 3] },
{ id: 2, neighbors: [1] },
{ id: 3, neighbors: [1] },
],
responseDelay: 10, // ms before MESSAGE_DONE response (default: 10)
dropRate: 0, // 0-1 packet drop probability (default: 0)
partialDelivery: false, // simulate DATA_FEEDBACK for incomplete messages (default: false)
});
// Now connect normally — all BLE calls go through the mock
await useMesh().connect();
// onNodes fires with the configured standee topology
// Simulate button presses from virtual standees
mock.pressButton(1, "plus"); // triggers onPlusButton
mock.pressButton(2, "minus", "data"); // triggers onMinButton with text data
mock.pressButton(3, "either"); // triggers onEitherButton
// Simulate mesh topology changes
mock.simulateNodeJoin({ id: 4, neighbors: [1] }); // triggers onNode
mock.simulateNodeLost(3); // triggers onNodeLost
// Simulate disconnection
mock.disconnect(); // triggers onDisconnect
// Control reconnect behavior
mock.reconnectable(false); // makes reconnect() reject
mock.reconnectable(true); // restore (default)
// Update config at runtime
mock.updateConfig({ responseDelay: 50, dropRate: 0.2 });
// Check state
mock.isActive(); // true
mock.getConfig(); // current config
// Restore real BLE
mock.deactivate();When writeCharacteristic is called (via Buffer.send()), the mock tracks incoming packets and automatically fires MESSAGE_DONE through the flood characteristic once all packets for a message are received. With partialDelivery: true, it sends DATA_FEEDBACK after a batch of writes, triggering the retransmission flow.
Display Elements
All elements extend DisplayElement and share common properties:
| Property | Type | Description |
| ------------ | ------- | ----------------------------------------------------- |
| id | number | Auto-assigned by useStandeeElements().add() |
| x | number | X position |
| y | number | Y position |
| show | boolean | Visibility (default: true) |
| colorBlack | boolean | Draw in black when true, white when false |
| dirty | boolean | Automatically set to true when any property changes |
Setting any property automatically marks the element as dirty, so it will be included in the next draw() call.
Rectangle
new Rectangle(x, y, width, height, radius?, fill?, show?, colorBlack?)| Property | Type | Default | Description |
| -------- | ------- | ------- | ------------------------------- |
| width | number | | Rectangle width |
| height | number | | Rectangle height |
| radius | number | 0 | Corner radius for rounded edges |
| fill | boolean | false | Fill the rectangle when true |
Text
new Text(x, y, text, font, center?, show?, colorBlack?)| Property | Type | Default | Description |
| -------- | ------- | ------- | ---------------------------------------- |
| text | string | | The text content |
| font | number | | Font from the Font enum |
| center | boolean | false | Center the text at the given coordinates |
Available fonts (from Font enum):
u8g2_font_5x7_mf— smallu8g2_font_crox2c_tr— mediumu8g2_font_10x20_tn— numericu8g2_font_inr33_t_cyrillic— large
Image
new Image(x, y, width, height, data, show?, colorBlack?)| Property | Type | Description |
| -------- | ------ | ------------------------------------------------- |
| width | number | Image width |
| height | number | Image height |
| data | number | Image ID (0-255) matching a previously uploaded graphic |
Objects
Standee
Represents a discovered standee in the mesh network.
| Property | Type | Description |
| ------------ | ----------------- | ---------------------------------------- |
| id | number | Unique standee identifier |
| neighbors | number[] | IDs of neighboring standees in the mesh |
| elements | DisplayElement[] | Display elements assigned to this standee|
| onPlus | SyncEvent<void> | Fires when the plus button is pressed |
| onMin | SyncEvent<void> | Fires when the minus button is pressed |
| Method | Returns | Description |
| -------- | ------- | -------------------------------------------- |
| name() | string | Hex-formatted ID (e.g. "0a" for id 10) |
ButtonResult
Returned by button press events (onPlusButton, onMinButton, onEitherButton).
| Property | Type | Description |
| -------- | ------ | ------------------------------- |
| id | number | ID of the standee that was pressed |
| data | string | Optional data sent with the press |
Buffer
Wraps BLE message data for transmission. Created internally by composable methods.
| Property | Type | Description |
| --------- | ------------ | --------------------------------------- |
| cache | number | Auto-generated cache ID for tracking |
| type | DataType | The message type |
| id | number | Target standee ID |
| packets | Uint8Array[] | The message split into BLE-sized packets|
| Method | Returns | Description |
| ------------------------- | -------------- | ------------------------------------ |
| send() | Promise<void>| Transmit the buffer over BLE |
| then(callback) | Buffer | Chain a callback for after send completes |
Enums
Core Enums
Font
| Value | ID | Description |
| ----------------------------- | -- | ----------- |
| u8g2_font_5x7_mf | 0 | Small |
| u8g2_font_inr33_t_cyrillic | 1 | Large |
| u8g2_font_10x20_tn | 2 | Numeric |
| u8g2_font_crox2c_tr | 3 | Medium |
Button
| Value | ID | Description |
| ------- | -- | ------------- |
| MINUS | 0 | Minus button |
| PLUS | 1 | Plus button |
| EITHER| 2 | Either button |
ActionType
| Value | ID | Description |
| ----------- | -- | -------------------- |
| INCREMENT | 0 | Increment a value |
| DECREMENT | 1 | Decrement a value |
| SHOW | 2 | Show an element |
| HIDE | 3 | Hide an element |
| SET | 4 | Set a value |
| BROADCAST | 5 | Broadcast to mesh |
| TIMER | 6 | Delayed action |
| CONDITION | 7 | Conditional action |
| BREAK | 8 | Break action chain |
| MAP | 9 | Map value to range |
ElementData
Target properties for actions.
| Value | ID | Description |
| -------- | -- | ---------------- |
| TYPE | 0 | Element type |
| ID | 1 | Element ID |
| SHOW | 2 | Visibility |
| X | 3 | X position |
| Y | 4 | Y position |
| WIDTH | 5 | Width |
| HEIGHT | 6 | Height |
| RADIUS | 7 | Corner radius |
| FILL | 8 | Fill mode |
| DATA | 9 | Data/image ID |
| FONT | 10 | Font |
ActionCondition
Comparison operators for condition() actions.
| Value | ID | Description |
| --------------- | -- | ---------------- |
| EQUAL | 0 | Equal to |
| GREATER | 1 | Greater than |
| LESS | 2 | Less than |
| GREATER_EQUAL | 3 | Greater or equal |
| LESS_EQUAL | 4 | Less or equal |
ActionDataType
Value types used in set() and condition() actions.
| Value | ID | Description |
| --------- | -- | --------------------- |
| STRING | 0 | String value |
| NUMBER | 1 | Numeric value |
| ELEMENT | 2 | Element property ref |
Protocol Enums
These enums are used internally by the BLE protocol layer but are exported for advanced use cases and debugging.
DataType
Message types sent over BLE.
| Value | ID | Description |
| ---------------- | -- | ------------------------ |
| UPLOAD | 0 | Image upload |
| DRAW_IMAGE | 1 | Draw an image element |
| DRAW_RECTANGLE | 2 | Draw a rectangle element |
| BUTTON_PRESS | 3 | Button press event |
| DRAW_TEXT | 4 | Draw a text element |
| ALL | 5 | Draw all elements |
| CLEAR_GRAPHICS | 6 | Clear uploaded graphics |
| ACTIONS | 7 | Button action assignment |
| CLEAR_SCREEN | 8 | Clear the screen |
| LOADER | 10 | Loader header |
FloodTypes
Flood message types for mesh-level communication.
| Value | ID | Description |
| ---------------- | -- | ---------------------------------- |
| NODES | 0 | Node discovery |
| LOST_NODE | 1 | Node left the mesh |
| MESSAGE_DONE | 2 | Message fully received by target |
| DATA_FEEDBACK | 3 | Partial delivery feedback |
HandshakeType
Handshake protocol types for initial connection.
| Value | ID | Description |
| ------------ | -- | --------------------- |
| FLOOD_NODES| 2 | Flood node discovery |
| STEP_ONE | 4 | Handshake step one |
| STEP_TWO | 5 | Handshake step two |
Data
Byte positions within a BLE packet. Useful for manual packet inspection with useDebug().decodePacket().
| Value | Byte Position | Description |
| ----------- | ------------- | --------------------------------- |
| ID | 0 | Target standee ID |
| CACHE | 1 | Cache ID for message tracking |
| TTL | 2 | Time to live (mesh hops) |
| POSITION | 3-4 | Packet position (uint16 LE) |
| DATA_SIZE | 5-6 | Total data size (uint16 LE) |
| TYPE | 7 | Message type (DataType) |
| DATA | 8+ | Payload data |
TypeScript Types
The library exports the following type definitions for use in your application:
Connection & Reconnect
interface ReconnectStatus {
attempt: number; // Current attempt number
maxRetries: number; // Maximum retries allowed
delay: number; // Delay before this attempt in ms
}Debug Types
interface DebugLogEntry {
type: DebugEventType;
timestamp: number;
data: Record<string, unknown>;
}
type DebugEventType =
| "packet_sent" | "message_queued" | "retransmit" | "timeout"
| "message_complete" | "message_done" | "received_packets"
| "node_discovered" | "node_lost" | "connected" | "disconnected";
interface DecodedPacket {
cacheId: number;
targetId: number;
ttl: number;
position: number;
dataSize: number;
typeName: string;
typeValue: number;
payloadBytes: number;
raw: number[];
}
interface MessageTimingRecord {
cacheId: number;
queuedAt: number;
firstPacketAt: number | null;
completedAt: number | null;
latencyMs: number | null;
retransmitCount: number;
timeoutCount: number;
packetsSent: number;
totalPackets: number;
}MeshWriter Events
interface PacketSentEvent {
cacheId: number;
position: number;
dataSize: number;
packetBytes: number;
batchIndex: number;
timestamp: number;
}
interface MessageQueuedEvent {
cacheId: number;
type: number;
targetId: number;
packetCount: number;
totalBytes: number;
timestamp: number;
}
interface RetransmitEvent {
cacheId: number;
packetCount: number;
reason: "feedback" | "timeout";
timestamp: number;
}
interface TimeoutEvent {
cacheId: number;
retryCount: number;
maxRetries: number;
willRetry: boolean;
timestamp: number;
}
interface MessageCompleteEvent {
cacheId: number;
timestamp: number;
}Loader Types
interface LoaderProgress {
current: number; // Buffers completed so far
total: number; // Total buffers
percentage: number; // 0 to 1
}
interface LoaderError {
buffer: number; // Index of the buffer that failed
error: Error; // The Error object
}Mock Types
interface VirtualStandee {
id: number;
neighbors: number[];
}
interface MockMeshConfig {
standees: VirtualStandee[];
responseDelay?: number; // Default: 10
dropRate?: number; // Default: 0
partialDelivery?: boolean; // Default: false
}Image Types
type ImagePixels = {
width: number;
height: number;
pixels: number[]; // 0 (black) or 1 (white)
}Configuration
The BLE protocol uses these internal constants (via useBluetoothSettings()):
| Constant | Value | Description |
| ----------------------- | ------ | ------------------------------------------------- |
| PACKET_SIZE | 13 | Max payload bytes per BLE packet |
| BATCH_SIZE | 20 | Packets sent per batch before waiting for ack |
| ACK_TIMEOUT | 5000 | Milliseconds to wait for acknowledgment |
| MAX_RETRIES | 3 | Retransmission attempts before giving up |
| TTL | 10 | Time to live (max mesh hops) |
| RECONNECT_DELAY | 2000 | Base delay for auto-reconnect (doubles each try) |
| RECONNECT_MAX_RETRIES | 5 | Maximum auto-reconnect attempts |
Events
The library uses ts-events for event handling:
// Subscribe
mesh.onConnect.attach(() => console.log("Connected"));
// Unsubscribe
const handler = () => console.log("Connected");
mesh.onConnect.attach(handler);
mesh.onConnect.detach(handler);Full Example
A complete example that connects to the mesh, uploads images, creates a UI, and configures button interaction:
import {
useMesh,
useStandees,
useStandeeElements,
useDisplay,
useGraphics,
useLoader,
useConverter,
useActions,
useButtons,
} from "nsd-ble";
import { Rectangle, Text, Image } from "nsd-ble/elements";
import { Font, Button, ElementData } from "nsd-ble/enums";
const mesh = useMesh();
const { standees, onChange } = useStandees();
const { draw, clear } = useDisplay();
// Connect on button click
document.getElementById("connect").addEventListener("click", () => {
mesh.connect();
});
onChange.attach(async (list) => {
const standee = list[0];
// 1. Upload images
const images = ["/img/icon-a.png", "/img/icon-b.png"];
const buffers = await Promise.all(
images.map(async (path, i) => {
const bytes = await useConverter().imageToBytes(path);
return useGraphics(standee).upload(i, bytes);
}),
);
const loader = useLoader(standee).load(buffers);
loader.onProgress.attach((p) => {
console.log(`Uploading: ${Math.round(p.percentage * 100)}%`);
});
await loader.send();
// 2. Build the display
const { add } = useStandeeElements(standee);
const bg = add(new Rectangle(0, 0, 128, 64, 0, true));
const icon = add(new Image(10, 16, 32, 32, 0));
const label = add(new Text(64, 40, "HP: 10", Font.u8g2_font_5x7_mf, true));
await draw(standee).send();
// 3. Wire up buttons
const actions = useActions();
const hpTarget = actions.target(label.id, ElementData.TEXT);
await useButtons(standee)
.setButton(Button.PLUS, [actions.increment(hpTarget)])
.send();
await useButtons(standee)
.setButton(Button.MINUS, [actions.decrement(hpTarget)])
.send();
});
// Listen for button presses from the app side
mesh.onPlusButton.attach((result) => {
console.log(`Plus pressed on standee ${result.id}`);
});