Developer Guides
Included with Warsha BLE Studio
Scripting quick start
A practical, copy-paste guide to writing the JavaScript that defines your peripheral in BLE Studio.
For the complete API surface and the bridge internals see the JavaScript API reference;
for live control knobs see the extVar design note. Once your script works, read
Real-time Authoring — it’s how you keep it behaving the same on a real
embedded peripheral instead of only in the studio.
Every script runs in a small sandbox with a fixed set of globals — peripheral, bytes, console,
store, extVar, Gatt, and the standard timers (setTimeout / setInterval / clearTimeout /
clearInterval). There is no fetch, filesystem, DOM, or import/export: scripts are classic
scripts, so everything hangs off those globals. Module state (top-level var/let) is fresh on
every (re)launch.
1. The smallest script
A script always calls peripheral.define(...) exactly once to register its GATT tree and advertising.
The JSDoc header is read without running the script, so the app can show what a script does before
you run it — always fill in @name.
/**
* @name New Peripheral
* @description Describe what this peripheral does.
*/
peripheral.define({
name: "MyPeripheral", // advertised local name
services: []
});
2. A readable characteristic
Give a characteristic an onRead handler to compute its value on each read. Return bytes — a
Uint8Array, a number[], or anything from bytes.*.
const SVC = "a1b2c3d4-0000-4000-8000-000000000001";
const TEMP = "a1b2c3d4-0000-4000-8000-000000000002";
let celsius = 21;
peripheral.define({
name: "Thermo",
services: [{
uuid: SVC,
characteristics: [
{ uuid: TEMP, name: "Temperature", properties: ["read"],
onRead: () => bytes.ofInt32(celsius) },
],
}],
});
name: becomes a 0x2901 User-Description descriptor that scanners show after connecting.
3. A writable characteristic (with validation)
onWrite receives req = { central, value, offset, withoutResponse } where value is a
Uint8Array. Return nothing (or true) to ack; return Gatt.error(...) to NAK with a GATT
status. You never need try/catch — an uncaught throw is caught for you, logged, and NAKed.
{ uuid: WRITABLE, name: "Sink", properties: ["write", "writeWithoutResponse"],
onWrite: (req) => {
console.log("write from", req.central.id, "=", bytes.toHex(req.value));
if (req.value.length > 20) return Gatt.error(Gatt.INVALID_ATTRIBUTE_LENGTH);
// ...apply the value...
} }
Common Gatt statuses: SUCCESS, READ_NOT_PERMITTED, WRITE_NOT_PERMITTED,
INVALID_ATTRIBUTE_LENGTH, INVALID_OFFSET, REQUEST_NOT_SUPPORTED,
INSUFFICIENT_AUTHENTICATION, FAILURE.
A characteristic with no onRead/onWrite is served natively from a cached value — give it an
initialValue to seed that cache and skip the bridge entirely (fast path):
{ uuid: NOTIFY, name: "Ticker", properties: ["read", "notify"], initialValue: bytes.ofInt32(0) }
4. Notifying subscribers over time
Set up timers in peripheral.onStart(...), which runs once after the server is advertising. Push
values with the live controller peripheral.server.characteristic(uuid):
let counter = 0;
peripheral.onStart(() => {
setInterval(() => {
counter++;
peripheral.server.characteristic(NOTIFY).notify(bytes.ofInt32(counter));
}, 1000);
});
The controller exposes:
.setValue(bytes)— update the cached value (served on the next native read)..notify(bytes)— fire-and-forget inlax; with a shaped timing profile, a Promise that resolves on queue admission (not later delivery) and rejects with a typed.codesuch asQUEUE_FULLorMTU_EXCEEDED..indicate(bytes)—async; resolvestrueafter all modeled acknowledgements, orfalseon rejection/failure.
peripheral.server also has .disconnect(centralId) and .disconnectAll().
5. Reacting to events
peripheral.onEvent((e) => console.log(e.type, e.central?.id ?? ""));
console.log / .warn / .error go to the in-app event log and the LAN editor console.
For orthogonal link facts, use peripheral.onLinkChange(snapshot). It reports field-scoped changes
such as MTU_CHANGED and SUBSCRIPTION_CHANGED without losing the snapshot’s security, MTU, other
subscriptions, or procedure state.
6. Opt into realistic timing
Add a preset to make queue depth, MTU, connection-event cadence, indication RTT, and handler deadlines explicit:
peripheral.define({
name: "Thermo",
timingProfile: "nrf52-default",
services: [ /* ... */ ]
});
The presets are informed starting points, not hardware-certified values yet. No profile means lax
and preserves existing behavior. Shaping requires Pro’s real-time tools; Timing telemetry remains
visible for lax runs. See Real-time Authoring for backpressure patterns.
7. RT-aware machines (Pro)
For stateful streaming and recovery, request @capabilities rt_machine and use
peripheral.machine({ machineVersion: 1, ... }). It provides declared every/after timers,
bounded priority queues, named synchronous actions, explicit read/write reply specs, and events such
as TX_QUEUE_FULL, TX_DRAINED, TIMER_LATE, and DEADLINE_MISS. It cannot be mixed with
onStart/onEvent; use io.defer(...) to bring Promise results back as named events. The complete
contract and example are in JavaScript API.
/** @name RT Counter
* @capabilities rt_machine */
const SVC = "12340000-0000-4000-8000-000000000001";
const COUNT = "12340000-0000-4000-8000-000000000002";
peripheral.machine({
machineVersion: 1, timingProfile: "nrf52-default",
context: { count: 0 },
services: [{ uuid: SVC, characteristics: [{ uuid: COUNT, properties: ["read"] }] }],
actions: {
tick: function (ctx) { ctx.count++; },
readCount: function (ctx) { return bytes.ofInt32(ctx.count); }
},
initial: "running",
states: { running: {
every: { periodMs: 1000, event: "TICK", tolerance: 100 },
on: {
TICK: { actions: ["tick"], budgetMs: 2 },
["READ:" + COUNT]: { reply: { kind: "read", value: "readCount" } }
}
} }
});
A context var can be a descriptor { type, init, persist: true } — a persisted var survives a
pokeReset/reload (add @capabilities persist, which prompts for consent). Persisted writes are
serialized per key; reset waits for the latest durable write and aborts rather than discard an
unconfirmed change. Plain values are session-only. All script execution requires Pro SCRIPTING;
machine authoring additionally requires RT_TOOLS and never degrades to an unshaped run.
8. bytes — the binary helpers
BLE is packed bytes. Available helpers (all return / accept Uint8Array):
| Helper | Use |
|---|---|
bytes.ofHex("01ff") / bytes.toHex(u8) | hex ⇄ bytes |
bytes.ofUtf8("hi") / bytes.toUtf8(u8) | text ⇄ bytes |
bytes.ofInt32(n[, le]) / bytes.readInt32(u8[, off, le]) | 32-bit int ⇄ bytes (little-endian by default) |
bytes.concat(a, b, …) | join byte chunks |
You can also build raw Uint8Arrays by hand, e.g. new Uint8Array([0x00, bpm & 0xff]).
9. State: store vs extVar
-
store— session-only scratch key/value (store.get/set/delete/keys), cleared each launch. Use it for private bookkeeping the host doesn’t need to see. -
extVar— typed control knobs the LAN editor can read and change while the script runs, no redeploy. Great for flags, rates, and modes:const cfg = extVar({ enabled: true, rateMs: { value: 1000, min: 250, max: 5000, step: 250, label: "Rate (ms)" }, }); // read the live value anytime: if (cfg.enabled) { /* ... */ }Full reference and the wire protocol: the
extVardesign note.
10. Pro: prebuilt standard profiles
The Pro PROFILES_LIB entitlement adds a profiles global — a library of ready-made standard GATT services (Heart Rate,
Battery, Device Information, Cycling Speed & Cadence). Each profiles.<name>() builds the service(s),
declares its own extVar knobs, and self-registers its onStart behavior (notifications, draining,
etc.). You just spread its .services into peripheral.define:
var p = profiles.battery();
peripheral.define({ name: "Battery", services: p.services });
Profiles are not mutually exclusive — a peripheral can expose several services at once. Combine
them by concatenating their services into a single define:
var hr = profiles.heartRate();
var bat = profiles.battery();
peripheral.define({
name: "Heart Rate + Battery",
services: [].concat(hr.services, bat.services),
});
The Profiles button (mobile) and the Profiles picker (LAN editor) generate exactly these
snippets — tick one or more profiles and the app inserts the combined define for you to edit and
save. The knobs each profile declares (e.g. Heart Rate’s bpm / interval, Battery’s drain rate) then
show up as live extVar controls while the script runs.
The profile implementations ship in every artifact but are available to a running script only when runtime entitlements grant
PROFILES_LIB. The snippet calls theprofilescapability; you edit usage, not the internals.
11. Pro: advanced capabilities (net / persist / fsm)
Advanced capabilities reopen the sandbox, so a script must ask for them in its header
(@capabilities). Their modules ship in every artifact but runtime Pro entitlements gate them; the
side-effecting ones prompt for consent the first time you run them (re-prompted if you edit the
script):
/**
* @name Cloud Thermostat
* @capabilities net, persist, fsm
*/
peripheral.define({ services: [{ uuid: "1809", characteristics: [
{ uuid: "2a1c", name: "Temperature", properties: ["read"], onRead: function () { return bytes.ofInt32(reading); } }
]}]});
var reading = 0;
var mode = fsm.create({ initial: "idle", states: { idle: { on: { tick: "polling" } }, polling: { on: { done: "idle" } } } });
peripheral.onStart(async function () {
reading = Number(await persist.getText("last")) || 0; // survives across runs
mode.send("tick");
var r = await net.fetch("https://example.com/temp"); // outbound HTTP (consented)
if (r.ok) { reading = JSON.parse(r.text()).celsius; await persist.set("last", String(reading)); }
mode.send("done");
});
net.fetch(url, { method, headers, body })→{ ok, status, bytes(), text(), json() }(a Promise).persist.set/get/getText/remove/keys— per-script storage that survives across runs (unlikestore, which clears each launch).fsm.create({ initial, states })→{ state, can(e), send(e), onChange(fn) }— a small state machine.
You can also Import from URL (mobile button / LAN editor) to pull a script off the network; it’s saved for review and never auto-runs (you activate it yourself).
12. Splitting a script across files
A script starts as one file and most stay that way. When one grows past the point where a single scroll is useful — a machine with several states plus a byte codec, say — you can split it.
Split into files lives in the script’s row in the library list, and on the desktop app also as Code ▸ Split into Files…. It is an explicit action, and it changes the script’s shape permanently: the script becomes a set of files with one designated entry file that runs first. Afterwards a strip above the editor lists the files, and you switch, add, remove and re-point the entry there — LAN Editor walks through it.
Splitting is done in the browser or desktop editor. On a phone or tablet a split script opens read-only: you can pick any file and read all of it, which is what you want before running something you imported, but changing it needs the larger editor. Splitting also appears only once it is enabled for your build.
// codec.js
module.exports = {
encode: function (bpm) { return bytes.u8(bpm); },
};
// main.js — the entry file
var codec = require("codec.js");
peripheral.define({
name: "HR",
services: [{ uuid: "180D", characteristics: [/* … */] }],
});
What holds:
require("name.js")takes a plain, exact file name. No./prefix, no folders, no search path, and nothing computed —require(someVariable)andrequire("a" + "b")are rejected before the script runs. That is what lets the editor show you the dependency graph and trust it.- Each file has its own scope. Two files can both declare
const configwithout colliding, which is the main thing one file cannot give you. module.exports = valueis how a file exports. Assigning toexportsdirectly (exports = x) rebinds a local name and exports nothing; the editor flags it.- The header comment is read from the entry file only.
@name,@capabilities,@budgetand the rest are ignored — and reported as an error — anywhere else, so you can never believe you declared a capability that was never asked for. - Consent covers the whole set. Adding, removing or renaming a file, or changing which file is the entry, is a change to the script, so an advanced-capability grant has to be given again.
- No
import/export, no npm, no dynamic loading. The sandbox has no network at all, which is what makes it a sandbox;requireis a lookup in the files you wrote, not a fetch.
Before the first split the editor tells you what would change meaning — mainly that a top-level var
or function stops being visible to the rest of the world, and that the names module, exports
and require become the loader’s. this and arguments behave exactly as they do today.
Splitting is not reversible by deleting files: a script that has been split stays split even when it is back down to one file, because its source was written for that shape.
Running always runs what is stored. Deploy saves first, and if that save does not land the run is cancelled rather than starting the previous version behind a screen showing newer code. When a stack trace comes back it names the file and line you wrote, not a position in the combined output.
Next steps
- JavaScript API — full API surface, handler contracts, and the bridge/round-trip details.
- The
extVardesign note — live control knobs in depth.