Developer Guides
Included with Warsha BLE Studio
Thinking in real time — authoring peripherals that survive contact with hardware
BLE Studio lets you write a peripheral in JavaScript and run it in seconds. That speed is the point, but it can hide an important constraint: a script that behaves perfectly in the studio can still encode a design that is slow, jittery, or physically impossible on an embedded peripheral.
This guide provides the mental model that keeps that gap small. Think of your script as a peripheral operating on a schedule, not merely as a program that runs.
Read Scripting Quick Start first if you have not written a script yet.
The one-sentence shift
A peripheral must answer on the link’s schedule, within a tight budget, over a narrow pipe, without blocking — and it never gets to say “hold on.”
Everything below follows from that sentence.
Five rules to hold in your head
1. You do not own the clock
The central establishes and schedules the connection. Your peripheral may request different connection parameters, but it cannot assume the central will accept them. The resulting connection interval is a heartbeat, commonly between 7.5 ms and 4 seconds, and radio traffic is exchanged during connection events.
A notify() does not mean “transmit at this exact instant.” It means “admit these bytes for delivery
when the link can carry them.” Between connection events, the radio is usually asleep.
- Why: BLE is designed to spend most of its time with the radio off. Devices wake, exchange a bounded amount of data, and sleep again.
- What the studio models: with a timing profile, notifications and indications are scheduled
against the profile’s connection interval and queue limits. A
laxrun deliberately skips this enforcement for fast iteration. - What you do: design cadence-sensitive behavior to tolerate a slower interval than you hoped for. Treat a chosen interval as a test scenario, not a promise from every central.
2. Every handler is on a deadline, and the bridge is not free
When a central reads or writes a scripted characteristic, it waits for your onRead or onWrite
answer. Embedded handlers are normally kept extremely short so they do not hold stack work or miss
connection opportunities. In the studio, a scripted handler also makes a round trip through the
JavaScript bridge, adding measurable latency.
- The trap: a clean error in an emulator is kinder than a blocked firmware handler. On hardware, a handler that never returns can stall the ATT transaction until it times out or the connection is lost.
- What you do: treat handlers as small decision points. Return a value you already computed. Do not perform a network call, a large loop, or a persistence write while the central is waiting.
- How you check: declare handler budgets and inspect p95/max latency. A budget is a target backed by measurement, not proof that the same JavaScript duration equals execution time on an MCU.
3. Notifications are airtime, not messages
A notify() feels like “send this array.” It is really “try to admit these bytes into a small,
bounded transmit queue.” That queue drains by a limited number of packets per connection event, and
each notification is capped by the negotiated ATT MTU.
Push faster than the pipe drains and the transport reports that it has no room. Your code must pause, drop or coalesce data according to the product’s needs, then resume when capacity returns.
Approximate notification throughput with:
(ATT_MTU - 3) × packets-per-event ÷ connection-interval
This is a planning ceiling, not a guarantee. The PHY, data-length negotiation, central scheduling, radio conditions, and other traffic can lower real throughput.
- MTU: before a larger MTU is negotiated, design for the 23-byte default: a 20-byte notification value.
- Backpressure: under a timing profile,
notify()is asynchronous and can reject withQUEUE_FULLorMTU_EXCEEDED. - Recovery:
TX_DRAINEDtells you that a previously full queue has room again. - What you do: send deltas instead of full snapshots, choose an explicit loss policy, and never assume every sample should wait in memory until it can be sent.
4. indicate is not “reliable notify”
Notifications do not request an ATT acknowledgment. Indications do: the central returns an ATT-layer confirmation that it received the indication. Only one indication may be outstanding per client on an ATT bearer, so another indication must wait for that confirmation.
Other ATT traffic can still continue. In particular, an outstanding indication does not prevent notifications or client requests from using the bearer.
An indication confirms ATT-layer receipt; it does not prove that the central application acted on the value, stored it durably, or processed it exactly once.
- What you do: use indications when a profile or procedure calls for ATT-level receipt confirmation, such as a control-point result or important alert.
- What you still do: for application-critical procedures, use transaction identifiers, idempotency, and an application-level response where appropriate.
- What you avoid: do not use indications as a high-throughput data stream.
5. Run to completion, never block, and count wakeups
All script callbacks run on one JavaScript thread, one callback at a time. A long callback delays other handlers and timers in the studio. Treat every action as run-to-completion: make a small state change, request bounded I/O if needed, and return.
This is a useful conservative authoring discipline, but it is not an exact model of an embedded scheduler. Real stacks may preempt application work, separate links can progress independently, and an RTOS may use several tasks. Conversely, a small MCU may be much more constrained than the JavaScript engine.
- The trap — polling:
setInterval(() => checkSomething(), 20)creates 50 wakeups per second even when nothing changes. - What you do: react to writes, subscription changes, completed I/O, and real sensor events. Periodic sampling is valid when the product actually needs it, but start the schedule only while the relevant state is active and stop it when it is not.
- How you check: inspect wakeups per second and distinguish application wakeups from radio activity. Wakeup count is a useful power warning, not a complete battery estimate.
Three levels of authoring
Free-form + lax | Free-form + timing profile | Machine + timing profile | |
|---|---|---|---|
| Shape | peripheral.define, handlers and callbacks | The same script style with timingProfile | peripheral.machine({...}) |
| Timing behavior | Fast and optimistic | Bounded queues, MTU enforcement and deadlines | The same enforcement plus declared states, timers and event queues |
| Best for | Sketching and GATT-layout experiments | Exercising an existing script under realistic transport limits | Firmware-oriented behavior and load-sensitive state machines |
| Guidance | Timing measurements and warnings | Measurements plus QUEUE_FULL, MTU_EXCEEDED and profile deadlines | Profile enforcement plus TIMER_LATE, DEADLINE_MISS and static checks |
Use lax while exploring. Add a timing profile when the GATT shape is stable enough to pressure-test.
Move to a machine when explicit states, declared timers, and first-class backpressure events make the
design easier to reason about.
What machine authoring buys you
peripheral.machine({...}) reshapes the script into declared states, a bounded event queue, and short
run-to-completion actions with declared budgets. In exchange:
- backpressure and link changes arrive as events such as
TX_QUEUE_FULL,TX_DRAINED,SUBSCRIPTION_CHANGED,TIMER_LATE, andDEADLINE_MISS; - timers belong to states, so leaving a state cancels work that should no longer run;
- timer lateness becomes visible instead of silently changing the device’s behavior;
- load-time checks can report declared wakeup rate, throughput feasibility, unreachable states, and unhandled GATT events;
- actions receive portable or studio-only guidance, showing which logic depends on studio-only behavior.
These properties are advisory and measured, not a mathematical proof. JavaScript cannot be preempted in the middle of an action that loops forever. A passing budget means “this completed within the declared budget in this run,” not “this is guaranteed to take the same time on every MCU.”
A practical real-time workflow
- Sketch with
lax. Get the GATT tree, byte encoding and basic behavior right. - Exercise several timing scenarios. At minimum, test a conservative MTU/interval, the expected everyday link, and the highest-throughput link you intend to support.
- Read the timing signals. Inspect handler p95/max, notify throughput versus the selected ceiling, wakeups per second, queue rejections, and timer-throttling warnings.
- Fix the design rather than silencing the warning. Move work out of handlers, reduce payloads, send less often, coalesce replaceable samples, and stop inactive timers.
- Make important states explicit. Use a machine when streaming, throttling, control procedures, recovery, or persistence have distinct behavior.
- Validate at three layers:
- VIRTUAL runs under several timing profiles test deterministic constraints and failure paths.
- LIVE runs test interoperability with the host platform’s BLE stack and real centrals.
- Target firmware on the intended MCU tests its scheduler, memory, flash, power, and radio behavior.
The studio narrows the gap; target hardware closes it.
Watch-out checklist
- Background throttling. The studio’s timers live inside an app-hosted JavaScript engine. A
backgrounded or suspended app can make a timer late. Watch the timer-health warning; in machine
mode, handle
TIMER_LATEwhen lateness changes the validity of the result. - Big or fast notifications. Test the payload against
ATT_MTU - 3and the total stream against the selected timing profile. HandleMTU_EXCEEDEDandQUEUE_FULLdeliberately. - Work in handlers. Do not perform network, persistence, or unbounded computation while a read or write request is waiting. Watch handler-latency p95/max and deadline-overrun warnings.
- Indications used as a stream. Confirmation makes indications slower and serializes subsequent indications; it does not create application-level exactly-once delivery.
- Polling loops. A fast interval “just to check” still wakes the system when nothing changed.
- Blocking the script thread. A synchronous long loop delays every other script callback.
- Per-event allocation. Repeatedly constructing arrays and closures creates garbage in the studio and usually maps poorly to a memory-constrained firmware implementation.
- Assuming sim time is nominal time. React to the
dtsupplied with a tick rather than assuming every tick arrived at the requested interval. - Assuming connect means ready. Security, MTU and subscriptions are independent facts. Gate an operation on the specific link properties it requires.
- Treating one profile as universal. Centrals negotiate differently. A design that only works at the most favorable interval or MTU is fragile.
Reading the studio’s signals
| Signal | Where | Meaning |
|---|---|---|
| Handler latency p50/p95/max | Timing view | total scripted-operation latency, including the bridge |
| Throughput versus ceiling | Timing view | notify bytes/s compared with the selected profile’s planning ceiling |
| Wakeups per second | Timing view | application timer fires and handler runs; a power warning, not a full estimate |
| Timer-throttling warning | Timing view | the JavaScript environment delivered timers substantially late |
QUEUE_FULL | Rejected profiled notify() Promise | one or more target queues cannot admit this publication |
MTU_EXCEEDED | Rejected profiled notify() Promise | the value does not fit the selected ATT MTU |
TX_DRAINED | Runtime event | a previously full transmit queue has room again |
TIMER_LATE | Machine event | a declared timer missed its tolerance |
DEADLINE_MISS | Machine event | a completed action exceeded its declared budget |
| Link-change snapshot/event | Runtime callback or machine event | lifecycle, security, MTU, subscription or supervision information changed |
| Static-check warning | Machine load | declared wakeup, throughput, reachability or event-handling issue |
| portable / studio-only | Editor | export guidance for an action, not a timing proof |
A lax run still reports useful measurements, but it does not enforce the profile-dependent queue,
MTU, or cadence constraints.
Worked example
Optimistic: fast polling with no backpressure
peripheral.onStart(() => {
setInterval(() => {
const full = bytes.concat(header(), readAllSensors()); // 200 bytes, fresh allocation
peripheral.server.characteristic(STREAM).notify(full); // result ignored, 100 Hz
}, 10);
});
This asks for about 20 kB/s, allocates a fresh payload 100 times per second, and keeps running when nobody is subscribed. Under a conservative link with ATT MTU 23, a 50 ms connection interval and four notification packets per event, each 200-byte publication exceeds the 20-byte value limit and the requested 20 kB/s is far above the approximate 1.6 kB/s planning ceiling.
Real-time-honest: active only while needed, one send in flight, explicit backpressure
const PROFILE = {
connIntervalMs: 50,
peripheralLatency: 0,
mtu: 23,
txQueueDepth: 6,
pdusPerEvent: 4,
handlerDeadlineMs: 5,
overrun: "nak",
jitterMs: 0,
indicateRttMs: 50,
};
peripheral.define({
name: "Sensor",
timingProfile: PROFILE,
services: [ /* ... */ ],
});
const stream = peripheral.server.characteristic(STREAM);
const buf = new Uint8Array(20);
const subscribedCentrals = new Set();
let timer = null;
let sending = false;
let backpressured = false;
let stoppedByError = false;
function scheduleNextSample() {
if (timer !== null || sending || backpressured || stoppedByError || subscribedCentrals.size === 0) return;
timer = setTimeout(publishSample, 100); // an intentional 10 Hz sample schedule, not a poll
}
function stopSamplingIfIdle() {
if (subscribedCentrals.size !== 0 || timer === null) return;
clearTimeout(timer);
timer = null;
}
async function publishSample() {
timer = null;
if (subscribedCentrals.size === 0 || backpressured) return;
sending = true;
encodeDeltaInto(buf);
try {
await stream.notify(buf);
} catch (e) {
if (e && e.code === "QUEUE_FULL") {
backpressured = true; // resume only when TX_DRAINED arrives
} else {
stoppedByError = true; // configuration/permanent errors must not become a retry loop
console.error("stream notify failed", e);
}
} finally {
sending = false;
scheduleNextSample();
}
}
peripheral.onLinkChange((link) => {
const id = link.central.id;
if (link.isSubscribed(STREAM)) subscribedCentrals.add(id);
else subscribedCentrals.delete(id);
stopSamplingIfIdle();
scheduleNextSample();
});
peripheral.onEvent((event) => {
// Free-form callback event names are lower camel case. Machine events use `TX_DRAINED`.
if (event.type !== "txDrained") return;
backpressured = false;
scheduleNextSample();
});
This version makes the constraints part of the behavior:
- the timing scenario is explicit;
- no sampling timer runs without a subscriber;
- only one profiled
notify()is awaiting admission at a time; - the payload fits
ATT_MTU - 3; - queue saturation pauses production until
TX_DRAINED; - replaceable samples do not accumulate in an unbounded application queue.
The same behavior maps naturally to machine states such as idle, streaming, and throttled, with
the sample schedule owned by streaming and TX_DRAINED returning throttled to streaming.
Where to go next
- Scripting Quick Start — the API basics and a first peripheral.
- JavaScript API — the complete scripting, timing-profile, link-snapshot and machine API.