W
Warsha Everything Mobile
Documentation / BLE Studio / Real-time Authoring
Browse documentation

Developer Guides

Included with Warsha BLE Studio

v0.9.9

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 lax run 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 with QUEUE_FULL or MTU_EXCEEDED.
  • Recovery: TX_DRAINED tells 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 + laxFree-form + timing profileMachine + timing profile
Shapeperipheral.define, handlers and callbacksThe same script style with timingProfileperipheral.machine({...})
Timing behaviorFast and optimisticBounded queues, MTU enforcement and deadlinesThe same enforcement plus declared states, timers and event queues
Best forSketching and GATT-layout experimentsExercising an existing script under realistic transport limitsFirmware-oriented behavior and load-sensitive state machines
GuidanceTiming measurements and warningsMeasurements plus QUEUE_FULL, MTU_EXCEEDED and profile deadlinesProfile 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, and DEADLINE_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

  1. Sketch with lax. Get the GATT tree, byte encoding and basic behavior right.
  2. Exercise several timing scenarios. At minimum, test a conservative MTU/interval, the expected everyday link, and the highest-throughput link you intend to support.
  3. Read the timing signals. Inspect handler p95/max, notify throughput versus the selected ceiling, wakeups per second, queue rejections, and timer-throttling warnings.
  4. 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.
  5. Make important states explicit. Use a machine when streaming, throttling, control procedures, recovery, or persistence have distinct behavior.
  6. 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_LATE when lateness changes the validity of the result.
  • Big or fast notifications. Test the payload against ATT_MTU - 3 and the total stream against the selected timing profile. Handle MTU_EXCEEDED and QUEUE_FULL deliberately.
  • 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 dt supplied 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

SignalWhereMeaning
Handler latency p50/p95/maxTiming viewtotal scripted-operation latency, including the bridge
Throughput versus ceilingTiming viewnotify bytes/s compared with the selected profile’s planning ceiling
Wakeups per secondTiming viewapplication timer fires and handler runs; a power warning, not a full estimate
Timer-throttling warningTiming viewthe JavaScript environment delivered timers substantially late
QUEUE_FULLRejected profiled notify() Promiseone or more target queues cannot admit this publication
MTU_EXCEEDEDRejected profiled notify() Promisethe value does not fit the selected ATT MTU
TX_DRAINEDRuntime eventa previously full transmit queue has room again
TIMER_LATEMachine eventa declared timer missed its tolerance
DEADLINE_MISSMachine eventa completed action exceeded its declared budget
Link-change snapshot/eventRuntime callback or machine eventlifecycle, security, MTU, subscription or supervision information changed
Static-check warningMachine loaddeclared wakeup, throughput, reachability or event-handling issue
portable / studio-onlyEditorexport 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