W
Warsha Everything Mobile
Documentation / BLE Studio / Scripting Quick Start
Browse documentation

Developer Guides

Included with Warsha BLE Studio

v0.9.9

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 in lax; with a shaped timing profile, a Promise that resolves on queue admission (not later delivery) and rejects with a typed .code such as QUEUE_FULL or MTU_EXCEEDED.
  • .indicate(bytes)async; resolves true after all modeled acknowledgements, or false on 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):

HelperUse
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 extVar design 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 the profiles capability; 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 (unlike store, 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) and require("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 config without colliding, which is the main thing one file cannot give you.
  • module.exports = value is how a file exports. Assigning to exports directly (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, @budget and 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; require is 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 extVar design note — live control knobs in depth.