Developer Guides
Included with Warsha BLE Studio
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:
| Destination | Use it to answer | It 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 right | Level 1, §11.2 |
| Make it tunable during a test | Level 4 |
| Test without hardware | Level 5 |
| Not reimplement a standard service | Level 6 |
| Use network / storage / state machines | Level 7 |
| Make it behave like real firmware under load | Levels 8–9, §11.4 |
| Fix something that’s broken | Part 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:
- What does the value mean, in what units?
- Which operations are legal — read, write, write-without-response, notify, indicate?
- What are the exact bytes: length, endianness, flags, ranges, error statuses?
- Is it a cached fact, a computed answer, or a stream?
- 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
| Plane | Holds | Lifetime | Operator-visible? |
|---|---|---|---|
let / store | counters, parser state, protocol bookkeeping | one run | no |
extVar | knobs a human should turn mid-test: rate, mode, enabled, target | one run | yes, live |
persist (gated) | configuration and checkpoints that must survive a reload | across runs | not directly |
| device model vars | physical scenario state: speed, incline, heart rate, battery | one device run | via 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) | |
|---|---|---|
| How | no onRead; seed initialValue, update via setValue() | supply onRead: (req) => bytes |
| On a read | answered natively, never touches your script | crosses the native↔JS bridge and back |
| Cost | ~free | one async round trip, measured, deadline-enforced |
| Right when | the value is already known | the 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.
| Level | You add | You’ll need |
|---|---|---|
| 0 | a GATT shape and a read | nothing |
| 1 | correct bytes and a cached value | nothing |
| 2 | a validated write | nothing |
| 3 | a notify stream | nothing |
| 4 | live operator knobs + link awareness | nothing |
| 5 | deterministic testing | Free simulation destination in addition to script authorization |
| 6 | (sideways) borrowed behavior: built-ins, profiles, devices | built-ins/devices Free; profiles and attached hooks Pro |
| 7 | network, storage, plain state machines | Pro + consent |
| 8 | modeled timing and real backpressure | rt_tools |
| 9 | states that own time | rt_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
| Need | Call |
|---|---|
| hex fixtures and logging | bytes.ofHex("01ff"), bytes.toHex(u8) |
| text | bytes.ofUtf8("hi"), bytes.toUtf8(u8) |
| signed 32-bit | bytes.ofInt32(n, le), bytes.readInt32(u8, offset, le) |
| join fields | bytes.concat(a, b, …) |
| anything else | new 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
| Requirement | Use | Because |
|---|---|---|
| central needs the latest known value | cached read + setValue | fast path, no bridge |
| the answer depends on the request or authorization | scripted onRead | can compute or return a typed error |
| central sends a command and needs the result now | write + onWrite | the reply is part of the ATT transaction |
| high-rate replaceable input | writeWithoutResponse — after testing | no response on air; your handler still runs |
| peripheral publishes ordinary changing state | notify | efficient, unconfirmed |
| peripheral must know it was received at ATT level | indicate | confirmed 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.
React to link activity
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.
Level 4 — Knobs and link awareness
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 type | A host edit is… |
|---|---|
bool | coerced ("true" → true, anything else truthy-coerced) |
number | coerced, then clamped into [min, max] — out-of-range is not rejected |
enum (has options) | rejected and logged if not in options |
string | coerced 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) }
| Field | Local simulation | On-air |
|---|---|---|
lifecycle | full | connected / disconnected |
subscriptions | full | full |
mtu | number | null |
security / secured | full | "unknown" / false |
paramsProcedure | idle / pending | "unknown" |
supervision | healthy / timedOut | "unknown" |
Tracking subscribers is your job. There is no
server.subscribers(uuid)and noserver.centralsin the script API. The controller you get fromperipheral.server.characteristic(uuid)exposes exactlysetValue,notify, andindicate. Maintain your ownSetfromonLinkChange(oronEvent’ssubscribed/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
- Clear
@name,@description, and only the capabilities you truly need. - Run in Local simulation first. Inspect the GATT tree, cached values, log, and knobs.
- Exercise one operation at a time: read → rejected write → accepted write → subscribe → notify → unsubscribe → disconnect.
- Use byte fixtures from the spec. Test the smallest legal, largest legal, and malformed payloads.
- Then run on-air against a real central: discovery, subscription, MTU behavior, reconnection.
- 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.
| Option | You own | The app owns | Best for |
|---|---|---|---|
| Built-in peripheral | which one to run, test inputs | GATT shape, codecs, runtime behavior | emulating a catalog device fast |
profiles (Pro) | how profiles are combined and configured | the standard service implementations | starting from common services, then adding your own |
| Composed device | device choice, dashboard controls | the shared physical model and cross-service couplings | treadmill/bike scenarios where values must agree |
| Attached device script | scenario rules over the model’s variables | the device’s server and base model | customizing 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
extVarknobs and registers its ownonStartbehavior. Callingprofiles.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 forbidsonStart. 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.getcan reject (e.g.HOST_QUEUE_FULLunder a command flood). Handle it.device.geton an unknown key rejects. Copy key names from the device’s documented variables; never guess. Do not emulate coupling replacement with per-tickset().
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.
| Capability | Unlocks | Request with | Consent? |
|---|---|---|---|
profiles | profiles.* prebuilt services | Pro feature enabled | no |
| (local simulation) | the VIRTUAL destination | select it; simulation is in the Free base | no |
net | net.fetch via the host | @capabilities net + advanced-network feature | yes |
persist | durable per-script key/value | @capabilities persist + persistence feature | yes |
fsm | small state-machine helper | @capabilities fsm + FSM feature | no |
device | device.* on an attached script | attach to a Free device; hook execution also needs scripting | no |
| (timing profile) | shaping, deadlines, fault injection | authorized script + non-lax timingProfile + rt_tools | no |
rt_machine | peripheral.machine() | scripting + @capabilities rt_machine + rt_tools | no |
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,
}
| Field | What it models |
|---|---|
connIntervalMs | the connection-event quantum (7.5–4000 ms, on the 1.25 ms grid) |
mtu | ATT MTU, 23–517; max payload is mtu − 3 |
txQueueDepth | queued notifications per central |
pdusPerEvent | how many drain per connection event, per central |
handlerDeadlineMs | your read/write deadline — often only a few ms |
overrun | "nak" = clean failure; "stall" = a genuinely hung ATT transaction (simulation only) |
jitterMs | extra delay on drain events |
indicateRttMs | modeled confirmation latency |
peripheralLatency | declared 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.code | Means | Do |
|---|---|---|
QUEUE_FULL | a subscriber’s modeled queue is full | pause; resume on txDrained |
MTU_EXCEEDED | payload > mtu − 3 for some target | fix the encoding — retrying cannot help |
HOST_QUEUE_FULL | you flooded the host command lane | slow your producer down |
DELIVERY_FAILED | the underlying delivery failed | log; 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:
| Stream | Policy |
|---|---|
| latest sensor value / gauge | replace and coalesce — send the newest value later, drop the stale one |
| accumulating counter | send the total or a delta, not every intermediate |
| audit / event history | bound a queue, add sequence numbers, drop or report deliberately |
| control-point result | a 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:
- It stops when nobody is subscribed — no wakeups, no wasted power, no phantom traffic.
- It never has two publications in flight (
inFlight), so it cannot self-flood. - It stops producing on
QUEUE_FULLrather than hammering a full queue. - 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
| Signal | Question | If it’s bad |
|---|---|---|
| handler p50 / p95 / max | are centrals waiting on JS? | move work out of handlers; return prepared state |
| budget overrun / deadline miss | did an operation take too long? | simplify it — do not just raise the budget |
| admitted vs delivered vs ceiling | is this stream plausible for this scenario? | reduce payload or rate, coalesce, or pick a different link scenario |
QUEUE_FULL / txDrained | is the producer outrunning subscribers? | pause and resume on capacity, as above |
| timer late / throttled | are timers firing when expected? | tolerate elapsed time, avoid tight polling, verify on target |
| wakeups/sec | doing 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_machineand thert_toolsfeature;- an explicit non-null
timingProfile; - mutually exclusive with
peripheral.onStartandperipheral.onEvent— and therefore withprofiles(Level 6), which registers its ownonStart.
/**
* @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 timers —
every: { periodMs, event, tolerance }andafter: { 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 viaqueueCapacity; - 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.
portableis awarded only to direct function-expression actions whose complete JavaScript syntax tree belongs to the closed D48 allowlist (ctxfield work, arithmetic/comparison/logical expressions, bounded numericfor,bytes.*, andio.notify/io.indicate/io.setValue). Arrow functions, methods, computed or shorthand properties, closures, allocation, and every unknown syntax node arestudio-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
| Global | Surface |
|---|---|
peripheral | define(config), onStart(fn), onEvent(fn), onLinkChange(fn), machine(spec), server |
peripheral.server | characteristic(uuid), disconnect(centralId), disconnectAll() |
…characteristic(uuid) | uuid, setValue(bytes), notify(bytes) → Promise, indicate(bytes) → Promise<bool> |
bytes | ofHex, toHex, ofUtf8, toUtf8, ofInt32(n, le), readInt32(b, off, le), concat(…) |
Gatt | status constants + Gatt.error(status) |
store | get, set, delete, keys — synchronous, session-only |
extVar | callable declaration + get, set, onChange |
console | log, warn, error |
| timers | setTimeout, 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
| Situation | Signal | Correct response |
|---|---|---|
| subscriber queue full | notify rejects QUEUE_FULL | pause producing; resume on txDrained |
| payload too large | notify rejects MTU_EXCEEDED | re-encode — retrying is pointless |
| host command lane saturated | HOST_QUEUE_FULL | reduce your command rate |
| indication already pending | indicate resolves false | serialize; one per central |
| handler too slow | deadline miss / NAK | move work out of the handler |
| timers late | TIMER_LATE, throttled flag | use elapsed time; do not assume cadence |
| nobody subscribed | your own subscriber set is empty | stop producing entirely |
Part IV — Debugging playbook
13.1 By symptom
| Symptom | Check, in this order |
|---|---|
| central can’t see the device | is it running? is the name being dropped for service UUIDs (try advertiseServices: false)? are you scanning by service UUID? |
| read returns stale data | are you calling setValue alongside notify? if the read is scripted, does the handler read the right variable? |
| notification never arrives | is the central actually subscribed (link.isSubscribed)? is the payload ≤ mtu − 3? right characteristic UUID? did it subscribe before you published? |
| notification arrives once, then stops | did your producer set blocked and never see txDrained? did an exception break the loop before rescheduling? |
| write silently does nothing | is 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 write | an exception in your handler is caught and NAKed generically — check the log |
shaped notify rejects immediately | MTU_EXCEEDED means encoding; QUEUE_FULL means you’re outrunning a subscriber; HOST_QUEUE_FULL means you’re outrunning the host |
indicate resolves false | one pending per central; also check subscription, disconnect, and payload size |
| handler times out | you 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 bursts | app 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 fires | device only exists on an attached device run; check the variable key (an unknown key reads 0) |
| machine never reacts to backpressure | you called peripheral.server…notify instead of io.notify |
| machine won’t load | onStart/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 air | re-read §Level 5’s non-claims: advertising, real negotiation, radio timing, this central’s behavior |
| feature unexpectedly locked | every 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
- Remove the profile. Does it work in lax? Then it is a timing/backpressure problem, not a protocol one.
- Remove the stream. Does a plain read/write work? Then it is a publication problem.
- Make the characteristic cached (delete
onRead). Does the value appear? Then it is a handler problem. - Log the actual bytes with
bytes.toHex(...)on both ends. Most “protocol” bugs are byte bugs. - 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
| Document | For |
|---|---|
| Scripting Quick Start | compact copy/paste API introduction |
| JavaScript API | full API, link snapshot, machine and bridge contracts |
The extVar design note | live control variables and their wire protocol |
| Real-time Authoring | deeper timing design and a longer backpressure example |
| The core simulation engine walkthrough | how the runtime works internally, and exactly what local simulation does not emulate |
| Open BLE Protocols Reference | standard BLE protocol layouts used by the catalog and codecs |