W
Warsha Everything Mobile
Documentation / BLE Studio / Developer Guide
Browse documentation

Developer Guides

Included with Warsha BLE Studio

v0.9.9

Warsha BLE Studio — the developer guide

Who this is for. Anyone using BLE Studio to prototype, emulate, test, or learn BLE peripherals. You write JavaScript; you do not need Kotlin, an IDE, or a build step.

How it is organized. Part I gives you six mental models. Part II is a ten-level ladder — one continuous example that grows from four lines of code into a real-time state machine. Part III is reference. Part IV is a debugging playbook.

If you are changing the app itself rather than writing scripts, read the core simulation engine walkthrough instead.


Part 0 — Orientation

0.1 What should I actually build?

Do you want a GATT peripheral that behaves exactly how you specify?

├─ YES, and it's my own invention or a spec I'm implementing
│     → a STANDALONE SCRIPT.  Levels 0–9 of this guide. Full control.

├─ YES, but it's a common standard thing (heart rate, battery, CSC…)
│     → a BUILT-IN PERIPHERAL, or Pro `profiles` inside your own script.
│       Level 6. The app supplies the GATT shape and codecs.

└─ NO — I want a *scenario*: several services whose values must agree
   (treadmill, bike, workout rig)

      ├─ the app's model is close enough  → a COMPOSED DEVICE.        Level 6
      └─ I need to change how it behaves  → a COMPOSED DEVICE
                                            + an ATTACHED DEVICE SCRIPT. Level 6

And two places to run whatever you built:

DestinationUse it to answerIt cannot answer
Local simulation (VIRTUAL)Are my bytes, handlers, error paths, subscription logic, and queue policy right? Does it survive injected faults?Does a radio work? Does this phone’s central behave?
On-air BLE (LIVE)Does discovery, advertising, negotiation, and this specific central work?What the target MCU will do

Neither replaces the other. Local simulation is where you develop; on-air is where you confirm.

0.2 Reader paths

You want to…Go to
Ship something in 20 minutes§1 (models), Levels 0–3, §12.1
Get the bytes rightLevel 1, §11.2
Make it tunable during a testLevel 4
Test without hardwareLevel 5
Not reimplement a standard serviceLevel 6
Use network / storage / state machinesLevel 7
Make it behave like real firmware under loadLevels 8–9, §11.4
Fix something that’s brokenPart IV (§13)
Check it before sharing§14

Part I — Six mental models

Each model has a consequence: the thing it changes about how you write code. If you only skim one section of this guide, skim this one.

MM1 — A peripheral is a contract, not a program

A central never sees your JavaScript. It sees:

advertisement → service → characteristic → operation → bytes → ATT response | notification

That is the entire interface. Your variables, your timers, your clever architecture — invisible.

Consequence: before writing a handler, answer five questions per characteristic and write the answers as a comment next to the UUID:

  1. What does the value mean, in what units?
  2. Which operations are legal — read, write, write-without-response, notify, indicate?
  3. What are the exact bytes: length, endianness, flags, ranges, error statuses?
  4. Is it a cached fact, a computed answer, or a stream?
  5. Does the central need an answer now, or can it receive a notification later?

That comment is usually more valuable than your first implementation, because it survives rewrites.

MM2 — There are four state planes; put each value in exactly one

PlaneHoldsLifetimeOperator-visible?
let / storecounters, parser state, protocol bookkeepingone runno
extVarknobs a human should turn mid-test: rate, mode, enabled, targetone runyes, live
persist (gated)configuration and checkpoints that must survive a reloadacross runsnot directly
device model varsphysical scenario state: speed, incline, heart rate, batteryone device runvia the device dashboard

The metaphor: store is your notebook, extVar is the control panel, persist is the filing cabinet, and the device model is the world your device lives in.

Consequence: a value in the wrong plane creates a specific, predictable bug. A knob in store means “redeploy to change it.” A test value in persist means “yesterday’s experiment silently contaminates today’s.” Choose the plane before you choose the variable name.

MM3 — Every characteristic has two possible paths: the label and the question

The label (cached)The question (scripted)
Howno onRead; seed initialValue, update via setValue()supply onRead: (req) => bytes
On a readanswered natively, never touches your scriptcrosses the native↔JS bridge and back
Cost~freeone async round trip, measured, deadline-enforced
Right whenthe value is already knownthe answer must be computed, authorized, or formatted now

Consequence: the default should be the label. A scripted read is a bridge crossing on the central’s request path — it is measured in the Timing view, it has a deadline, and under a shaped profile that deadline is short. Reach for it deliberately, not reflexively.

MM4 — Handlers are transactions; notifications are publications

An onRead/onWrite handler runs while a central is waiting. It is a transaction: validate, update prepared state, return. Nothing else.

A notification is outbound and nobody is blocked on it. That is where changing state belongs.

Consequence — the single most important rule in this guide:

Never do network, storage, or unbounded work inside a GATT handler. Do it in onStart, on a timer, or in a deferred transition, and have the handler return the already-prepared value.

A slow handler does not merely feel slow — it turns a bounded ATT transaction into an unpredictable one, and under a shaped profile it fails outright.

MM5 — “Connected” is not “subscribed” is not “secure” is not “known MTU”

These are four independent facts. A connected central may not have subscribed. A subscribed central may not be encrypted. An encrypted link may still be at the 23-byte minimum MTU.

Consequence: gate each feature on the fact it actually needs — link.isSubscribed(uuid) before streaming, link.secured before sending anything sensitive, link.mtu before sizing a payload. And on on-air runs, three of those four are unknowable: the platform exposes lifecycle and subscriptions only, so mtu arrives as null and security/paramsProcedure/supervision arrive as the string "unknown". Treat unknown as not established, never as fine.

MM6 — Under a timing profile, notify is a reservation, not a send

In lax mode, notify() is fire-and-forget and always “succeeds”. Under a shaped profile it becomes a Promise that resolves when the bytes are admitted to a modeled per-central queue — and rejects when they are not.

lax:     notify() ─────────────────────────────────▶ (assume it went)
shaped:  notify() ──▶ [admitted?] ──▶ resolve       ──▶ … later … ──▶ delivered
                          └──────────▶ reject(QUEUE_FULL | MTU_EXCEEDED | …)

Consequence: the moment you declare a profile, backpressure becomes your responsibility. A producer that ignores rejection is a producer that silently loses data on real hardware. §11.4 gives you the policies.

The ladder principle

Every level below adds a constraint, not a feature. Executing any user-authored JavaScript level requires Pro SCRIPTING; the “You’ll need” column lists additional capabilities/features. Native built-ins are a separate Free path; composed devices are a separate path that needs no JavaScript — the Treadmill is Free, the rest of the catalog is Pro (D61/D88). Level 0 lets you do anything; Level 9 lets you do very little, on purpose. Climb only as far as your problem requires — and do not start at Level 9 because your device is “event driven.” Most peripherals are clearest as a small GATT tree plus a little state.


Part II — The ladder

One example throughout: a bench sensor that reports a temperature and accepts a setpoint. It gains one capability per level. Copy the code at any level and it runs.

LevelYou addYou’ll need
0a GATT shape and a readnothing
1correct bytes and a cached valuenothing
2a validated writenothing
3a notify streamnothing
4live operator knobs + link awarenessnothing
5deterministic testingFree simulation destination in addition to script authorization
6(sideways) borrowed behavior: built-ins, profiles, devicesbuilt-ins/devices Free; profiles and attached hooks Pro
7network, storage, plain state machinesPro + consent
8modeled timing and real backpressurert_tools
9states that own timert_tools

Level 0 — A shape and a read

Goal: get something a scanner can see and read.

/**
 * @name        Bench Sensor
 * @description Lab bench temperature sensor — learning build.
 */

const SVC  = "8f0bb2e0-7c2d-4df0-88c4-000000000001";
const TEMP = "8f0bb2e0-7c2d-4df0-88c4-000000000002";

let tempMilliC = 21_500;  // 21.5 °C, in milli-degrees — see the byte comment at Level 1

peripheral.define({
  name: "BenchSensor",
  services: [{
    uuid: SVC,
    characteristics: [{
      uuid: TEMP,
      name: "Temperature",
      properties: ["read"],
      onRead: () => bytes.ofInt32(tempMilliC),
    }],
  }],
});

Why it’s built this way. The JSDoc header is parsed without running your script, so the app can show what this is and what it asks for before first execution. define() is called once, at load, and declares only static shape — behavior comes from the handlers inside it and from onStart (Level 3).

UUIDs. Use full 128-bit UUIDs for anything private. For adopted Bluetooth SIG services, use the official UUID and its exact wire format. Never ship an “almost standard” characteristic — clients assume the real protocol and will misparse you silently.

Advertising note. The advertisement is 31 bytes and one 128-bit service UUID eats most of it, so your name may not reach scanners (notably on macOS/iOS). Either discover by service UUID, or set advertiseServices: false in define() to drop the UUIDs and make room for the name. Characteristic names are unaffected — they become 0x2901 descriptors, read after connecting.

Graduate when: a scanner sees your service and reads a plausible value.


Level 1 — Correct bytes, and the cached path

Goal: stop thinking in JavaScript numbers and start thinking in packed bytes.

BLE values are bytes. Not JSON, not floats, not strings. This is where most real bugs live.

The helpers that actually exist

NeedCall
hex fixtures and loggingbytes.ofHex("01ff"), bytes.toHex(u8)
textbytes.ofUtf8("hi"), bytes.toUtf8(u8)
signed 32-bitbytes.ofInt32(n, le), bytes.readInt32(u8, offset, le)
join fieldsbytes.concat(a, b, …)
anything elsenew Uint8Array([...]), or a DataView (below)

le defaults to little-endian (pass false for big-endian). There are no ofInt8/ofInt16/view helpers — for other widths, build the array or use a DataView directly:

// A packed record: flags:u8, temp:int16 (LE), battery:u8
function encodeReading(flags, tempCentiC, batteryPct) {
  const u8 = new Uint8Array(4);
  const dv = new DataView(u8.buffer);
  dv.setUint8(0, flags);
  dv.setInt16(1, tempCentiC, true);   // true = little-endian, always be explicit
  dv.setUint8(3, batteryPct);
  return u8;
}

Always confirm endianness against the specification, and write a test vector into a comment. “It worked on my phone” is not endianness verification.

Switch to the cached path

The temperature is a value you always know, so it should be a label, not a question (MM3):

{
  uuid: TEMP,
  name: "Temperature",
  properties: ["read", "notify"],
  initialValue: bytes.ofInt32(21_500),   // seeds the native cache
}

and update it whenever the value changes (after the server is up — Level 3):

peripheral.server.characteristic(TEMP).setValue(bytes.ofInt32(tempMilliC));

Now reads are served natively and never cross the bridge. You have removed a whole class of latency and timeout failures by deleting code.

Graduate when: you can state your byte layout from memory and a read returns exactly those bytes.


Level 2 — A validated write

Goal: accept input from a central, and reject bad input as a protocol error.

const SETPOINT = "8f0bb2e0-7c2d-4df0-88c4-000000000003";

// … inside characteristics: […]
{
  uuid: SETPOINT,
  name: "Setpoint",
  properties: ["write"],
  onWrite: (req) => {
    // wire format: int32 LE, milli-degrees C, 0…100 °C
    if (req.value.length !== 4) return Gatt.error(Gatt.INVALID_ATTRIBUTE_LENGTH);
    const next = bytes.readInt32(req.value);
    if (next < 0 || next > 100_000) return Gatt.error(Gatt.FAILURE);

    setpointMilliC = next;
    console.log("setpoint", next, "from", req.central.id);
    // bare return (or `true`) acknowledges
  },
}

req is { central, value: Uint8Array, offset, withoutResponse }.

The error idiom is a return value, not an exception. Return Gatt.error(status) for expected protocol failures. A thrown exception is caught defensively, logged, and NAKed with a generic failure so a bug never kills your session — but that is a safety net, not the API. Validate deliberately.

Useful statuses: Gatt.INVALID_ATTRIBUTE_LENGTH, Gatt.WRITE_NOT_PERMITTED, Gatt.READ_NOT_PERMITTED, Gatt.INVALID_OFFSET, Gatt.REQUEST_NOT_SUPPORTED, Gatt.INSUFFICIENT_AUTHENTICATION, Gatt.FAILURE.

Choosing operations — a decision table

RequirementUseBecause
central needs the latest known valuecached read + setValuefast path, no bridge
the answer depends on the request or authorizationscripted onReadcan compute or return a typed error
central sends a command and needs the result nowwrite + onWritethe reply is part of the ATT transaction
high-rate replaceable inputwriteWithoutResponse — after testingno response on air; your handler still runs
peripheral publishes ordinary changing statenotifyefficient, unconfirmed
peripheral must know it was received at ATT levelindicateconfirmed and serialized — not a stream

Graduate when: a malformed write returns the right GATT error and leaves your state untouched.


Level 3 — Make it live

Goal: publish changes over time.

peripheral.onStart(fn) runs once, after the server is advertising. That is the first moment peripheral.server.characteristic(uuid) is usable, so all imperative setup goes there.

let timer = null;

peripheral.onStart(() => {
  const temp = peripheral.server.characteristic(TEMP);

  function publish() {
    tempMilliC += Math.round((setpointMilliC - tempMilliC) * 0.1);   // simple approach-the-setpoint
    const value = bytes.ofInt32(tempMilliC);
    temp.setValue(value);   // keep the cache current for un-subscribed readers
    temp.notify(value);     // push to subscribers
    timer = setTimeout(publish, 1000);
  }

  timer = setTimeout(publish, 1000);
});

Prefer recursive setTimeout to setInterval. It makes “schedule the next one after this one finished” explicit, which matters the moment publishing becomes asynchronous (Level 8). setInterval is fine for a short fixed experiment, but it happily overlaps slow work and keeps firing when nobody is listening.

Always setValue alongside notify. A central that connects later and reads — without subscribing — gets the cache. Forgetting this produces the classic “the notifications are right but the read is stale” bug.

Timers are watched. The runtime wraps setTimeout/setInterval to measure lateness. A timer that fires more than 2× its period late is counted as late and logs a warning. Two consecutive late fires set the throttled flag; two consecutive on-time fires clear it, so one scheduling hiccup does not flash a false warning. A native reporting timer also flushes a short-lived one-shot within roughly one second even when no later user callback runs. Behavior is unchanged — this is observation only — but it is how you discover that backgrounding the app wrecked your cadence.

peripheral.onEvent((event) => {
  console.log("event", event.type, event.central?.id ?? "");
});

event.type is one of connected, disconnected, subscribed, unsubscribed, serviceAddFailed, and — under a shaped profile — txDrained.

Graduate when: a subscriber receives a stream, and a late-connecting reader gets a current value.


Goal: stop redeploying to change a number.

extVar — the control panel

const cfg = extVar({
  enabled: true,
  periodMs: { value: 1000, min: 250, max: 10_000, step: 250, label: "Publish period (ms)" },
  mode:     { value: "track", options: ["track", "hold"], label: "Mode" },
  hold:     { value: 21_500, min: 0, max: 100_000, step: 100, label: "Held value (m°C)" },
});

extVar.onChange((key, value) => console.log("operator set", key, "=", value));

cfg is a live proxy: cfg.enabled and cfg.periodMs always read the newest accepted host edit. Also available: extVar.get(key), extVar.set(key, value), extVar.onChange(fn).

How edits are validated — know the exact semantics:

Declared typeA host edit is…
boolcoerced ("true"true, anything else truthy-coerced)
numbercoerced, then clamped into [min, max] — out-of-range is not rejected
enum (has options)rejected and logged if not in options
stringcoerced with String(...)

step and label are presentation metadata only — they shape the UI control; they are not enforced. Do not rely on step to keep a value on a grid; round it yourself if your protocol needs it.

What belongs here: things a human tunes during a test. What does not: secrets, credentials, received data, or anything unbounded. Use store for private bookkeeping the operator should not see.

onLinkChange — the four facts (MM5)

const subscribers = new Set();

peripheral.onLinkChange((link) => {
  if (link.isSubscribed(TEMP)) subscribers.add(link.central.id);
  else subscribers.delete(link.central.id);

  console.log("link", link.type, link.central.id,
              "mtu", link.mtu, "secure", link.secured);
});

The snapshot is complete every time — never a delta — and shaped like:

{ type, central: { id }, lifecycle, mtu, security, paramsProcedure,
  subscriptions, supervision, secured, isSubscribed(uuid) }
FieldLocal simulationOn-air
lifecyclefullconnected / disconnected
subscriptionsfullfull
mtunumbernull
security / securedfull"unknown" / false
paramsProcedureidle / pending"unknown"
supervisionhealthy / timedOut"unknown"

Tracking subscribers is your job. There is no server.subscribers(uuid) and no server.centrals in the script API. The controller you get from peripheral.server.characteristic(uuid) exposes exactly setValue, notify, and indicate. Maintain your own Set from onLinkChange (or onEvent’s subscribed/unsubscribed), as above — you will need it anyway at Level 8.

Graduate when: an operator can retune your device mid-run, and you only stream to actual subscribers.


Level 5 — Test it properly

Goal: find bugs without a radio, deterministically, including failure paths.

The development loop

  1. Clear @name, @description, and only the capabilities you truly need.
  2. Run in Local simulation first. Inspect the GATT tree, cached values, log, and knobs.
  3. Exercise one operation at a time: read → rejected write → accepted write → subscribe → notify → unsubscribe → disconnect.
  4. Use byte fixtures from the spec. Test the smallest legal, largest legal, and malformed payloads.
  5. Then run on-air against a real central: discovery, subscription, MTU behavior, reconnection.
  6. Only then add a timing profile.

What Local simulation actually proves

It runs the same server configuration, the same handlers, the same cached-value semantics, the same subscription tracking and notification routing as the on-air path. It also enforces the conservative default: 23-byte ATT MTU ⇒ 20-byte maximum value payload, and an oversized payload fails loudly rather than silently truncating.

Excellent for: codec correctness, GATT error paths, “does a write change the next read”, subscription ordering, conservative-MTU fit, and — with rt_tools — injected MTU / pairing / parameter-update / supervision-timeout faults.

It does not prove: advertising and scanning, real pairing and bonding, radio loss, platform-reserved services, genuine MTU negotiation, actual scheduling, or radio throughput. Finish important work on-air, and on the target device if you are heading for firmware.

The Inspector is an oscilloscope

Use it for questions with one observable answer: is the server running? who is connected? who subscribed to this characteristic? what is the cached value right now? which extVar values are live? what happens if I poke a value, force a notification, or force a disconnect?

Inspector pokes are instrumentation, not design. If your device only works when someone pokes a characteristic, that behavior belongs in the script.

Graduate when: every operation and every rejection path has been exercised in simulation, and the same script has connected to a real central at least once.


Level 6 — Sideways: borrow behavior instead of writing it

This level is not “more advanced” — it is a different axis. Use it when someone else has already implemented what you need.

OptionYou ownThe app ownsBest for
Built-in peripheralwhich one to run, test inputsGATT shape, codecs, runtime behavioremulating a catalog device fast
profiles (Pro)how profiles are combined and configuredthe standard service implementationsstarting from common services, then adding your own
Composed devicedevice choice, dashboard controlsthe shared physical model and cross-service couplingstreadmill/bike scenarios where values must agree
Attached device scriptscenario rules over the model’s variablesthe device’s server and base modelcustomizing a modeled device without reimplementing its protocol

Pro profiles

const heart   = profiles.heartRate();
const battery = profiles.battery();

peripheral.define({
  name: "Workout Demo",
  services: [].concat(heart.services, battery.services),
});

Available: profiles.heartRate(), profiles.battery(), profiles.deviceInformation(), profiles.cyclingSpeedCadence().

Two things to know before you use them:

  • Each profile declares its own extVar knobs and registers its own onStart behavior. Calling profiles.heartRate() is not a pure factory — it wires itself up. Its knobs appear in your running session alongside yours.
  • Consequently, profiles cannot be combined with peripheral.machine() (Level 9), which forbids onStart. Choose one authoring model.

Composed devices

A composed device has one shared physical model, not a pile of unrelated timers. A speed or incline control moves the same model that drives the FTMS measurement, heart-rate response, distance, and battery. That coherence is the entire reason to choose it over separate services.

Attached device scripts — customize, do not recreate

An attached script gets a device global only when attached to a composed-device run. It never calls peripheral.define — the device already owns the server.

device.onTick((state, dtMs) => {
  // dtMs is the ELAPSED time. Never assume a fixed interval — ticks coalesce under load.
  if (state.sessionState > 0 && state.speedKmh > 12) {
    console.log("fast segment", state.speedKmh, "km/h");
  }
});

device.onWrite(CONTROL_POINT_UUID, (centralId, uuid, writeType, value, outcome) => {
  console.log("control write", centralId, writeType, bytes.toHex(value), outcome.status);
});

device.set("inclinePct", 4);          // only writable model variables accept this

const speed = await device.get("speedKmh");   // Promise — may reject under host backpressure

The full surface is device.get(key) → Promise, device.set(key, value, { mode }), device.drive(key, fn), device.onTick(fn), and device.onWrite(uuid, fn). Write handlers receive central ID, UUID, write type, bytes, and the final { success, status } outcome; they observe the native/default write and cannot alter its ATT response.

Three sharp edges:

  • device.get can reject (e.g. HOST_QUEUE_FULL under a command flood). Handle it.
  • device.get on an unknown key rejects. Copy key names from the device’s documented variables; never guess. Do not emulate coupling replacement with per-tick set().

There is currently no JavaScript API for building an arbitrary simulation model from scratch. Write a standalone script for custom GATT; use a composed device when you want an app-supplied shared model.

Graduate when: you know which parts of your scenario you own and which the app owns.


Level 7 — Gated capabilities: ask narrowly, explain why

Goal: reach outside the sandbox — carefully.

Two distinct concepts:

  • A feature is about the user’s entitlement and remote configuration: may this product area run at all?
  • A capability is about this one script: which extra globals does it receive?
  • For sensitive capabilities the user also consents to that exact script source. Editing the script re-prompts, because changed code is a new trust decision.

The safe-by-default globals — peripheral, bytes, Gatt, console, timers, store, extVar — are always present. The DOM, filesystem, imports, fetch, and browser network APIs never are.

CapabilityUnlocksRequest withConsent?
profilesprofiles.* prebuilt servicesPro feature enabledno
(local simulation)the VIRTUAL destinationselect it; simulation is in the Free baseno
netnet.fetch via the host@capabilities net + advanced-network featureyes
persistdurable per-script key/value@capabilities persist + persistence featureyes
fsmsmall state-machine helper@capabilities fsm + FSM featureno
devicedevice.* on an attached scriptattach to a Free device; hook execution also needs scriptingno
(timing profile)shaping, deadlines, fault injectionauthorized script + non-lax timingProfile + rt_toolsno
rt_machineperipheral.machine()scripting + @capabilities rt_machine + rt_toolsno

Availability is evaluated at runtime and can be revoked remotely. Where possible, write scripts with a useful safe path, and describe plainly why the advanced behavior is needed.

net — move I/O out of handlers (MM4)

/**
 * @name Cloud-fed sensor
 * @capabilities net, persist
 */
let calibration = 1.0;

peripheral.onStart(async () => {
  const saved = await persist.getText("calibration");
  if (saved !== null) calibration = Number(saved);

  try {
    const res = await net.fetch("https://example.com/calibration");
    calibration = Number(res.json().factor);
    await persist.set("calibration", String(calibration));
  } catch (err) {
    console.warn("using last saved calibration", err);   // fetch REJECTS on failure
  }
});

net.fetch(url, { method, headers, body }) resolves to { ok, status, bytes(), text(), json() } and rejects on transport failure or a non-OK response — so try/catch (or .catch) is mandatory. body may be a string (sent UTF-8) or a Uint8Array.

Then let the read handler return the already-prepared value. One slow request must never become an unpredictable radio transaction.

persist — state that deserves to survive

await persist.set("mode", "field");         // string or Uint8Array
const raw  = await persist.get("mode");     // Uint8Array | null
const text = await persist.getText("mode"); // string | null
await persist.remove("mode");
const keys = await persist.keys();

Everything is async and namespaced to your script.

Do not persist high-rate samples one at a time. Keep the latest meaningful state in memory and write at a deliberate boundary: a configuration change, a state transition, or a controlled interval. If the value only matters for one test run, it belongs in store.

fsm — legibility, not real-time

/** @capabilities fsm */
const mode = fsm.create({
  initial: "idle",
  states: {
    idle:      { on: { start: "streaming" }, onEnter: (e) => console.log("idle") },
    streaming: { on: { stop: "idle" } },
  },
});

mode.send("start");            // → true if the transition exists, false otherwise
mode.state;                    // "streaming"
mode.can("stop");              // true
mode.onChange((to, from, event, payload) => console.log(from, "→", to, "on", event));

Pure in-sandbox compute — no bridge, no side effects, hence no consent. Use it to make application logic readable. It does not make timer callbacks real-time; that is Level 9.

Graduate when: your script asks for the minimum capability set and behaves sensibly when a capability is unavailable or a request fails.


Level 8 — Real time: make delivery constraints visible

Goal: stop pretending a desktop is an embedded peripheral.

Start lax, then choose a scenario

With no timingProfile you are in lax mode: unbounded fire-and-forget notify, no payload cap, a generous 2-second handler deadline. The Timing view still measures bridge latency, timer health, notify rate, and wakeups — measurement is free.

When the protocol is correct, declare a scenario:

peripheral.define({
  name: "BenchSensor",
  timingProfile: "nrf52-default",
  services: [ /* … */ ],
});

Presets: nrf52-default, iphone-central, throughput. Or declare one inline:

timingProfile: {
  connIntervalMs: 30, peripheralLatency: 0, mtu: 23,
  txQueueDepth: 6, pdusPerEvent: 4,
  handlerDeadlineMs: 5, overrun: "nak",
  jitterMs: 2, indicateRttMs: 50,
}
FieldWhat it models
connIntervalMsthe connection-event quantum (7.5–4000 ms, on the 1.25 ms grid)
mtuATT MTU, 23–517; max payload is mtu − 3
txQueueDepthqueued notifications per central
pdusPerEventhow many drain per connection event, per central
handlerDeadlineMsyour read/write deadline — often only a few ms
overrun"nak" = clean failure; "stall" = a genuinely hung ATT transaction (simulation only)
jitterMsextra delay on drain events
indicateRttMsmodeled confirmation latency
peripheralLatencydeclared for scenario context; nothing enforces it today

Treat preset numbers as informed starting scenarios, not certified properties of any device with a similar name. Validate against your actual target.

Two honest caveats worth internalizing:

  • overrun: "stall" only works in local simulation. On-air it is coerced to "nak" with a warning, because deliberately wedging a real radio transaction is not acceptable behavior.
  • On-air, the profile MTU is the declared number, not a negotiated one — the platform exposes no negotiated MTU. Only local simulation tracks a real per-link MTU.

The Timing view follows that distinction. In local simulation, each connected central gets its own ceiling computed from its current negotiated MTU, and the aggregate ceiling is the sum of those link ceilings. On-air, the view can only use the declared profile ceiling. Neither number is a radio throughput guarantee.

The consequence: notify becomes a Promise

try {
  await peripheral.server.characteristic(TEMP).notify(value);
  // admitted — queued for delivery, NOT delivered
} catch (err) {
  // err.code ∈ QUEUE_FULL | MTU_EXCEEDED | HOST_QUEUE_FULL | DELIVERY_FAILED
}
err.codeMeansDo
QUEUE_FULLa subscriber’s modeled queue is fullpause; resume on txDrained
MTU_EXCEEDEDpayload > mtu − 3 for some targetfix the encoding — retrying cannot help
HOST_QUEUE_FULLyou flooded the host command laneslow your producer down
DELIVERY_FAILEDthe underlying delivery failedlog; treat as lost

Admission is all-or-nothing across subscribers: if one central cannot take it, the whole publication rejects. That is deliberate — a partial broadcast, where some peers silently miss data, is far worse than a failure you can see and handle.

In lax mode notify() still returns a Promise, but an already-resolved one. Write await notify(...) from the start and your code works unchanged in both modes.

Write an explicit backpressure policy

For every stream, decide up front what happens when delivery cannot keep up:

StreamPolicy
latest sensor value / gaugereplace and coalesce — send the newest value later, drop the stale one
accumulating countersend the total or a delta, not every intermediate
audit / event historybound a queue, add sequence numbers, drop or report deliberately
control-point resulta small response or indication — never a high-rate stream

Here is the complete pattern for a replaceable stream. It is worth reading line by line: it is the reference shape for almost every shaped producer.

const subscribers = new Set();
let timer = null, inFlight = false, blocked = false;

function schedule() {
  if (timer !== null || inFlight || blocked || subscribers.size === 0) return;
  timer = setTimeout(publish, cfg.periodMs);
}

async function publish() {
  timer = null;
  if (blocked || subscribers.size === 0) return;   // nobody listening ⇒ no work, no wakeups

  inFlight = true;
  try {
    const value = encodeLatestReading();            // ≤ mtu − 3 bytes
    const temp = peripheral.server.characteristic(TEMP);
    temp.setValue(value);                           // cache stays current regardless
    await temp.notify(value);
  } catch (err) {
    if (err.code === "QUEUE_FULL" || err.code === "HOST_QUEUE_FULL") {
      blocked = true;                               // stop producing until capacity returns
    } else {
      console.error("stream error", err.code || err);
    }
  } finally {
    inFlight = false;
    schedule();
  }
}

peripheral.onLinkChange((link) => {
  if (link.isSubscribed(TEMP)) subscribers.add(link.central.id);
  else subscribers.delete(link.central.id);
  schedule();
});

peripheral.onEvent((event) => {
  if (event.type === "txDrained") { blocked = false; schedule(); }
});

Four properties make this correct, and all four matter:

  1. It stops when nobody is subscribed — no wakeups, no wasted power, no phantom traffic.
  2. It never has two publications in flight (inFlight), so it cannot self-flood.
  3. It stops producing on QUEUE_FULL rather than hammering a full queue.
  4. It resumes only on txDrained, the event that says capacity actually reopened.

Payload sizing: the limit is ATT_MTU − 3. Design for the conservative 20 bytes until a larger MTU is established. Do not implicitly split a record across notifications and assume every central reassembles it the way you intended — if you need fragmentation, design it explicitly, with a header.

Indicate — only for confirmation

const confirmed = await peripheral.server.characteristic(RESULT).indicate(value);
// true  = every current subscriber confirmed at the ATT layer
// false = rejected or delivery failed  (it resolves; it does not reject)

One pending indication per central. Under a shaped profile it fans out to current subscribers and resolves true only after every target confirms.

Use it for a control-point response or a significant alert — never for continuous telemetry. Confirmation proves ATT-layer receipt only: not that the central’s application processed it, and certainly not that anything was stored. Application-critical flows still need their own IDs, idempotency, and acknowledgements.

Read the Timing view as a diagnosis, not a score

SignalQuestionIf it’s bad
handler p50 / p95 / maxare centrals waiting on JS?move work out of handlers; return prepared state
budget overrun / deadline missdid an operation take too long?simplify it — do not just raise the budget
admitted vs delivered vs ceilingis this stream plausible for this scenario?reduce payload or rate, coalesce, or pick a different link scenario
QUEUE_FULL / txDrainedis the producer outrunning subscribers?pause and resume on capacity, as above
timer late / throttledare timers firing when expected?tolerate elapsed time, avoid tight polling, verify on target
wakeups/secdoing needless work?stop inactive schedules; react to events instead of polling

Read max and p95, not the average. A handler that occasionally misses its deadline is broken no matter how good its mean looks. And passing a shaped simulation means that scenario was handled — it is not a worst-case execution time or a radio-throughput guarantee.

Graduate when: every rejection code has an explicit policy, your producer idles when nobody is subscribed, and your handlers finish well inside the profile deadline.


Level 9 — RT-aware machines: states that own time

Goal: when the meaning of a timer, a backpressure event, a link change, or a reply depends on what state you are in, encode that in the structure instead of in flags.

Use it for: streaming / paused / throttled; a control procedure with recovery; a device that must stop its timers the instant it leaves a state.

Requirements and constraints:

  • @capabilities rt_machine and the rt_tools feature;
  • an explicit non-null timingProfile;
  • mutually exclusive with peripheral.onStart and peripheral.onEvent — and therefore with profiles (Level 6), which registers its own onStart.
/**
 * @name Bench Sensor (RT)
 * @capabilities rt_machine
 */
const SVC  = "8f0bb2e0-7c2d-4df0-88c4-000000000020";
const TEMP = "8f0bb2e0-7c2d-4df0-88c4-000000000021";

peripheral.machine({
  machineVersion: 1,
  timingProfile: "nrf52-default",

  // plain value = session-only; a descriptor may declare a type and persistence
  context: { reading: 0 },

  services: [{
    uuid: SVC,
    characteristics: [{ uuid: TEMP, properties: ["read", "notify"] }],
  }],

  // named, synchronous, run-to-completion. No `await` in here, ever.
  actions: {
    sample:     function (ctx) { ctx.reading += 1; },
    push:       function (ctx) { io.notify(TEMP, bytes.ofInt32(ctx.reading)); },
    replyTemp:  function (ctx) { return bytes.ofInt32(ctx.reading); },
  },

  initial: "streaming",
  states: {
    streaming: {
      every: { periodMs: 1000, event: "TICK", tolerance: 100 },
      on: {
        TICK:                { actions: ["sample", "push"], budgetMs: 2 },
        ["READ:" + TEMP]:    { reply: { kind: "read", value: "replyTemp" } },
        TX_QUEUE_FULL:       { target: "throttled" },
      },
    },
    throttled: {
      entry: ["sample"],
      on: {
        TX_DRAINED:          { target: "streaming" },
        ["READ:" + TEMP]:    { reply: { kind: "read", value: "replyTemp" } },
      },
    },
  },
});

What the machine gives you

  • state entry/exit actions (entry, exit);
  • state-owned timersevery: { periodMs, event, tolerance } and after: { delayMs, event, tolerance } — which are canceled automatically when the state exits. This alone eliminates a whole family of “the old timer is still firing” bugs;
  • named, synchronous actions and guards, referenced by name, validated at load;
  • explicit GATT replies: { kind: "read", value: "actionName" }, { kind: "success" }, or { kind: "gattError", status: … }. Every read/write the machine answers gets exactly one terminal reply — an unhandled event, a failed guard, or a thrown action all resolve to a typed ATT error rather than hanging;
  • bounded priority queues: P0 control (SDK, link, shaping, DEADLINE_MISS, TIMER_LATE, TX_QUEUE_FULL, deferred results), P1 GATT (READ:<uuid> / WRITE:<uuid>), P2 timer-originated. Defaults are P0 = 8, P1 = 16, P2 = 8, overridable via queueCapacity;
  • static checks at load — unreachable states, unhandled GATT events, wakeup rate, throughput feasibility, budget sanity, persistence audit. These are advisory badges, not blockers.
  • portable/studio-only action guidance in the editor. portable is awarded only to direct function-expression actions whose complete JavaScript syntax tree belongs to the closed D48 allowlist (ctx field work, arithmetic/comparison/logical expressions, bounded numeric for, bytes.*, and io.notify/io.indicate/io.setValue). Arrow functions, methods, computed or shorthand properties, closures, allocation, and every unknown syntax node are studio-only. This is conservative export guidance, not a timing proof and never a load gate.

The three rules people break

1. Use io.*, not peripheral.server, inside a machine.

io.notify(uuid, value);        // ← this is what raises TX_QUEUE_FULL back into the machine
io.setValue(uuid, value);
io.indicate(uuid, value);      // → Promise

io.notify watches the shaped rejection and, on QUEUE_FULL/HOST_QUEUE_FULL, enqueues a TX_QUEUE_FULL event so your throttled state can react. If you call peripheral.server.characteristic(...).notify(...) directly, that recovery path never fires. (Note the deliberate asymmetry: MTU_EXCEEDED does not raise TX_QUEUE_FULL — an oversized payload is a bug, not backpressure, and no amount of throttling fixes it.)

2. Never await in an action. Actions are strictly run-to-completion. The only bridge from async work back into the machine is:

io.defer(promise, "DONE_EVENT", "FAIL_EVENT");

The result arrives as a P0 event with the resolved value (or the error message) as its payload.

3. Event names are uppercase. SDK events map to CONNECTED, DISCONNECTED, SUBSCRIBED, UNSUBSCRIBED, SERVICE_ADD_FAILED, TX_DRAINED. Link changes arrive as their field-scoped types (MTU_CHANGED, SECURITY_CHANGED, SUBSCRIPTION_CHANGED, SUPERVISION_TIMED_OUT, …). Control events are TIMER_LATE, DEADLINE_MISS, TX_QUEUE_FULL, TX_DRAINED.

Persisted context

context: {
  reading: 0,                                     // session-only
  mode: { type: "u8", init: 0, persist: true },   // survives reset and reload
}

A persist: true variable additionally requires @capabilities persist (Pro plus per-script consent). Declaring it without persist is a load error, never a silent downgrade.

It survives a reset only after durable writes succeed — a reset first waits for the latest write and aborts, leaving the run alive, on failure or timeout. Boot with persisted context is briefly async: an early GATT operation during that window is answered ATT-busy, exactly like a real device that has not finished coming up. Inspector/LAN callers receive the typed RESET_FAILED result for an aborted reset; they never receive ok: true for a reset that did not happen.

Use persisted context sparingly, and only where you have actually designed the recovery semantics.

Graduate when: every timer belongs to a state, every event has a handler or a deliberate default, and no action awaits anything.


Part III — Reference

11.1 The global namespace, as actually implemented

GlobalSurface
peripheraldefine(config), onStart(fn), onEvent(fn), onLinkChange(fn), machine(spec), server
peripheral.servercharacteristic(uuid), disconnect(centralId), disconnectAll()
characteristic(uuid)uuid, setValue(bytes), notify(bytes) → Promise, indicate(bytes) → Promise<bool>
bytesofHex, toHex, ofUtf8, toUtf8, ofInt32(n, le), readInt32(b, off, le), concat(…)
Gattstatus constants + Gatt.error(status)
storeget, set, delete, keys — synchronous, session-only
extVarcallable declaration + get, set, onChange
consolelog, warn, error
timerssetTimeout, setInterval, clearTimeout, clearInterval (wrapped for health metrics)
profiles (Pro)heartRate(), battery(), deviceInformation(), cyclingSpeedCadence()
net (Pro + consent)fetch(url, opts) → Promise
persist (Pro + consent)get, getText, set, remove, keys — all Promises
fsm (Pro)create(spec){ state, can, send, onChange }
device (attached scripts)get, set, onTick, onWrite
io (inside a machine)notify, setValue, indicate, defer

Things that do not exist, despite being easy to assume: server.subscribers(uuid), server.centrals, characteristic().value, bytes.view(), bytes.ofInt8/ofInt16, fetch, require, import, the DOM, and the filesystem.

11.2 The define config

{
  name?: string,                 // advertised local name
  advertiseServices?: boolean,   // default true; false ⇒ drop service UUIDs, make room for the name
  timingProfile?: "lax" | "nrf52-default" | "iphone-central" | "throughput" | { … },
  services: [{
    uuid,
    characteristics: [{
      uuid,
      name?,                     // → 0x2901 User Description descriptor
      properties: ["read" | "write" | "writeWithoutResponse" | "notify" | "indicate"],
      permissions?,              // inferred from properties if omitted
      initialValue?,             // seeds the native cached value
      onRead?:  (req) => bytes | Gatt.error(…),          // omit ⇒ native cached read
      onWrite?: (req) => void | true | Gatt.error(…),    // omit ⇒ native store-to-cache
    }],
  }],
}

11.3 Manifest tags

/**
 * @name         Bench Sensor
 * @author       you@example.com
 * @description  What it does, in one line.
 * @advertises   BenchSensor
 * @capabilities net, persist
 * @budget       read=3ms write=3ms tick=5ms
 */

Parsed statically, without running the script, so the app can show provenance and requested capabilities before first execution.

@budget values are measured against the full bridge round trip and logged when exceeded — a target you can observe, not an enforced limit. The ms suffix is required: read=3 is silently ignored, and so is any budget key that isn’t an operation kind (read, write, tick). @capabilities accepts comma- or space-separated tokens; unknown tokens are ignored rather than rejected, so a typo means the capability is simply never requested.

11.4 Backpressure policies at a glance

SituationSignalCorrect response
subscriber queue fullnotify rejects QUEUE_FULLpause producing; resume on txDrained
payload too largenotify rejects MTU_EXCEEDEDre-encode — retrying is pointless
host command lane saturatedHOST_QUEUE_FULLreduce your command rate
indication already pendingindicate resolves falseserialize; one per central
handler too slowdeadline miss / NAKmove work out of the handler
timers lateTIMER_LATE, throttled flaguse elapsed time; do not assume cadence
nobody subscribedyour own subscriber set is emptystop producing entirely

Part IV — Debugging playbook

13.1 By symptom

SymptomCheck, in this order
central can’t see the deviceis it running? is the name being dropped for service UUIDs (try advertiseServices: false)? are you scanning by service UUID?
read returns stale dataare you calling setValue alongside notify? if the read is scripted, does the handler read the right variable?
notification never arrivesis the central actually subscribed (link.isSubscribed)? is the payload ≤ mtu − 3? right characteristic UUID? did it subscribe before you published?
notification arrives once, then stopsdid your producer set blocked and never see txDrained? did an exception break the loop before rescheduling?
write silently does nothingis onWrite on the right characteristic? does properties include write? are you returning a Gatt.error you didn’t intend?
write returns an error you didn’t writean exception in your handler is caught and NAKed generically — check the log
shaped notify rejects immediatelyMTU_EXCEEDED means encoding; QUEUE_FULL means you’re outrunning a subscriber; HOST_QUEUE_FULL means you’re outrunning the host
indicate resolves falseone pending per central; also check subscription, disconnect, and payload size
handler times outyou did I/O in a handler (MM4), or the profile deadline is only a few ms — prepare the value elsewhere
timer drifts or fires in burstsapp backgrounded; check the throttled flag; use elapsed time rather than assuming cadence
extVar edit “doesn’t stick”numbers are clamped to min/max; enums are rejected if not in options — check the log
device script hook never firesdevice only exists on an attached device run; check the variable key (an unknown key reads 0)
machine never reacts to backpressureyou called peripheral.server…notify instead of io.notify
machine won’t loadonStart/onEvent/profiles combined with machine(); an action name that doesn’t exist; a persist: true context var without @capabilities persist
works in simulation, fails on airre-read §Level 5’s non-claims: advertising, real negotiation, radio timing, this central’s behavior
feature unexpectedly lockedevery script run needs scripting; shaped profiles/rt_machine also need rt_tools; net/persist need their feature plus consent for this exact source

13.2 Bisecting a misbehaving script

  1. Remove the profile. Does it work in lax? Then it is a timing/backpressure problem, not a protocol one.
  2. Remove the stream. Does a plain read/write work? Then it is a publication problem.
  3. Make the characteristic cached (delete onRead). Does the value appear? Then it is a handler problem.
  4. Log the actual bytes with bytes.toHex(...) on both ends. Most “protocol” bugs are byte bugs.
  5. Run in local simulation with one central, then two. Multi-central failures are almost always a shared-state assumption (a single “the subscriber” variable instead of a set).

14. Release checklist

Before you share a script or depend on it in a demo:

Contract

  • Every service and characteristic UUID is intentional and documented.
  • Byte layouts are verified against a specification or a real capture, with a test vector in a comment.
  • Every read and write has a deliberate success and failure result.
  • Cached values stay current for characteristics with a native read path.

Behavior

  • No network, storage, or unbounded work inside a GATT handler.
  • Operator-facing values are extVars with sensible ranges, not magic numbers.
  • The stream stops when nobody is subscribed.
  • The script behaves correctly with two connected centrals, not just one.

Real time

  • Every notify rejection code has an explicit policy.
  • Payloads fit the 20-byte conservative MTU, or a larger MTU is a stated requirement.
  • Handler timings sit well inside the profile deadline at p95 and max.
  • Timer lateness and wakeup rate have been looked at at least once.
  • In local simulation, per-central ceilings were checked after exercising at least two MTUs.

Capabilities

  • The requested capability set is minimal.
  • Every gated feature has a useful failure/offline path.
  • Persisted values are ones whose recovery semantics you actually designed.

Coverage

  • Tested in local simulation for logic and failure paths.
  • Tested on air against at least one real central.
  • If targeting firmware, validated on the target device.

15. Where to go next

DocumentFor
Scripting Quick Startcompact copy/paste API introduction
JavaScript APIfull API, link snapshot, machine and bridge contracts
The extVar design notelive control variables and their wire protocol
Real-time Authoringdeeper timing design and a longer backpressure example
The core simulation engine walkthroughhow the runtime works internally, and exactly what local simulation does not emulate
Open BLE Protocols Referencestandard BLE protocol layouts used by the catalog and codecs