W
Warsha Everything Mobile
Documentation / BLE Studio / JavaScript API
Browse documentation

Developer Guides

Included with Warsha BLE Studio

v0.9.9

JavaScript API & bridge flow

Status: implemented API reference. Examples describe the current scripting runtime. The real-time profiles and RT-machine APIs are advisory models of embedded constraints; preset numbers remain unvalidated until the R8 hardware-validation pass.

The runtime gives each script a curated global namespace (the sandbox — D4): peripheral, bytes, console, and the standard timer functions. No fetch/XHR, no filesystem, no modules — just what a peripheral needs. Scripts are classic scripts (not ES modules), so no import/export — everything hangs off the globals. (Classic-script eval behaves identically on WKWebView and Android WebView; module loading does not, so we avoid it.)

1. A complete script (the reframed TestProfile)

This is the ergonomics test — if this reads cleanly, the shape is right.

/**
 * @name        Test Profile
 * @author      you@example.com
 * @description Full op set — readable counter, writable sink, 1 Hz notify.
 * @advertises  RBTestPeripheral
 */

const SVC      = "a1b2c3d4-0000-4000-8000-000000000001";
const READABLE = "a1b2c3d4-0000-4000-8000-000000000002";
const WRITABLE = "a1b2c3d4-0000-4000-8000-000000000003";
const NOTIFY   = "a1b2c3d4-0000-4000-8000-000000000004";

let counter = 0;                       // module state — fresh each launch (D#8)

peripheral.define({
  name: "RBTestPeripheral",            // advertised local name (see the advertising note below)
  // advertiseServices: false,         // drop service UUIDs from the advert to make room for the name
  services: [{
    uuid: SVC,
    characteristics: [
      { uuid: READABLE, name: "Counter (read)", properties: ["read"],
        onRead: (req) => bytes.ofInt32(counter) },          // scripted → bridged

      { uuid: WRITABLE, name: "Sink (write)", properties: ["write", "writeWithoutResponse"],
        onWrite: (req) => {                                  // scripted → bridged
          console.log("write from", req.central.id, "=", bytes.toHex(req.value));
          if (req.value.length > 20) return Gatt.error(Gatt.INVALID_ATTRIBUTE_LENGTH);
        } },

      { uuid: NOTIFY, name: "Ticker (notify)", properties: ["read", "notify"] },  // no handler → native default (D6)
    ],
  }],
});

peripheral.onStart(() => {                                   // imperative behavior
  setInterval(() => {
    counter++;
    peripheral.server.characteristic(NOTIFY).notify(bytes.ofInt32(counter));
  }, 1000);
});

peripheral.onEvent((e) => console.log(e.type, e.central?.id ?? ""));   // → app log + LAN editor console

Note the two paths sitting side by side: READABLE/WRITABLE are scripted (handlers cross the bridge); NOTIFY has no handler so reads are served natively from a cached value (D6), and the script only touches the bridge when it chooses to push a notify.

2. API surface

peripheral — the root

  • peripheral.define(config) — register the GATT tree + advertising. Call once. Static shape only; behavior comes from the handlers inside it and from onStart.

  • peripheral.onStart(fn)fn() runs once after the server is advertising. Where you set up timers and imperative behavior.

  • peripheral.onEvent(fn)fn(event) for every PeripheralEvent (connect/disconnect, subscribe/ unsubscribe, read/write). For logging and reactive behavior.

  • peripheral.server — the live controller (also valid inside handlers), available after start:

    • .characteristic(uuid){ uuid, setValue(bytes), notify(bytes), indicate(bytes) }
    • .disconnect(centralId) / .disconnectAll()

    There is deliberately no subscribers(uuid) and no centrals list. Who is subscribed is a per-central fact that changes without a GATT operation, so it is delivered by onLinkChange — whose snapshot carries subscriptions and the derived isSubscribed(uuid) — rather than polled from the server. Track it yourself from those snapshots.

  • peripheral.onLinkChange(fn)fn(snapshot) receives a complete per-central link snapshot when one field changes. snapshot.type identifies the field-scoped change (MTU_CHANGED, SECURITY_CHANGED, SUBSCRIPTION_CHANGED, etc.); secured and isSubscribed(uuid) are derived conveniences.

  • peripheral.machine(spec) — the RT-aware, closure-free machine authoring model described below. It is mutually exclusive with onStart/onEvent and requires @capabilities rt_machine.

Without a timing profile, notify(bytes) preserves the original fire-and-forget behavior. With a shaped timingProfile, it returns a Promise that resolves after all current subscriber queues admit the publication—not after controller/over-air delivery—or rejects with an Error whose code is QUEUE_FULL, MTU_EXCEEDED, HOST_QUEUE_FULL, or DELIVERY_FAILED. indicate(bytes) is always asynchronous; under a shaped profile it fans out to the current subscribers and resolves true only after every modeled confirmation (false on rejection or delivery failure).

config shape

{ name?: string,              // advertised local name
  advertiseServices?: bool,   // default true; false → omit service UUIDs from the advert (see note)
  timingProfile?: "lax" | "nrf52-default" | "iphone-central" | "throughput" | {
    connIntervalMs, peripheralLatency, mtu, txQueueDepth, pdusPerEvent,
    handlerDeadlineMs, overrun: "nak" | "stall", jitterMs, indicateRttMs
  },
  services: [ { uuid, characteristics: [ {
      uuid,
      name?: string,           // human-readable name → 0x2901 User-Description descriptor (shown by scanners)
      properties: ["read"|"write"|"writeWithoutResponse"|"notify"|"indicate"],
      permissions?: [...],     // inferred from properties if omitted (SDK behavior)
      value?: bytes,           // initial cached value
      onRead?:  (req) => bytes,                 // omit → native default (cached value)
      onWrite?: (req) => void | Gatt.error(status),  // omit → native default (store value)
  } ] } ] }

Advertising the name (note): the BLE advertising packet is 31 bytes; a single 128-bit service UUID consumes most of it, so the local name may not fit and some stacks (notably macOS/iOS CoreBluetooth) won’t surface it to scanners — discover by service UUID instead, or set advertiseServices: false to drop the UUIDs and make room for the name. Characteristic names are unaffected: they’re exposed as 0x2901 descriptors and read after connecting, so name: always shows.

Handler contracts

Status is signaled by the return value (the tolerant, predictable path — you never have to write try/catch). A thrown exception is not the API: the bridge catches it defensively, logs it, and NAKs with a generic failure — so a script bug degrades cleanly instead of killing the session.

  • onRead(req)req = { central, offset }. Return a byte value (Uint8Array | number[] | hex/utf8 via bytes.*) → success. Return Gatt.error(status) → that GATT status. (Uncaught throw → generic failure + log.) May be async/return a Promise.
  • onWrite(req)req = { central, value: Uint8Array, offset, withoutResponse }. Return nothing/ trueack. Return Gatt.error(status)NAK with that status. (Uncaught throw → NAK + log.) For writeWithoutResponse there’s no response on the wire, but the handler still runs (state updates).

bytes — binary ergonomics (BLE is packed bytes)

Built on the WebView’s standard Uint8Array/DataView/TextEncoder. Handlers may also just return a raw Uint8Array.

  • Hex / textbytes.ofHex("01ff") · bytes.toHex(u8) · bytes.ofUtf8("hi") · bytes.toUtf8(u8).
  • Integers — a writer and a reader at every width, signed and unsigned: bytes.ofInt8(n) · bytes.ofInt16(n, le) · bytes.ofInt32(n, le) · bytes.ofUint8(n) · bytes.ofUint16(n, le) · bytes.ofUint32(n, le) · bytes.readInt8(u8, off) · bytes.readInt16(u8, off, le) · bytes.readInt32(u8, off, le) · bytes.readUint8(u8, off) · bytes.readUint16(u8, off, le) · bytes.readUint32(u8, off, le). le is a positional boolean, defaulting to little-endian — the byte order every multi-octet Bluetooth field uses. Pass false for the rare big-endian payload. The 8-bit helpers take no le. Unsigned is usually what a GATT field wants: readUint8 of ff is 255, readInt8 is -1.
  • Floatsbytes.ofFloat32(n, le) · bytes.readFloat32(u8, off, le) (IEEE 754 single).
  • Bits — for the flags octets profiles are full of, counting from bit 0 = the least significant bit of byte 0, exactly as those fields are specified: bytes.ofBits([1, 0, 1])05 · bytes.toBits(u8) (always whole octets, so a 3-bit input returns 8 entries) · bytes.bit(u8, index)0/1.
  • Composition / escape hatchbytes.concat(...) · bytes.view(u8) → a DataView positioned on those bytes (use it for 24-bit fields, BCD, or IEEE-11073 sfloat, which have no helper here).

Gatt — status constants

The complete set, mirroring the SDK’s GattStatus one-for-one (values already match BluetoothGatt.GATT_*): Gatt.SUCCESS · Gatt.READ_NOT_PERMITTED · Gatt.WRITE_NOT_PERMITTED · Gatt.INSUFFICIENT_AUTHENTICATION · Gatt.REQUEST_NOT_SUPPORTED · Gatt.INVALID_OFFSET · Gatt.INVALID_ATTRIBUTE_LENGTH · Gatt.INSUFFICIENT_RESOURCES · Gatt.FAILURE. Gatt.error(status) builds the failure sentinel a handler returns.

There is deliberately nothing else. ScriptRuntime.statusOf resolves a status by code and falls back to Failure, so a constant the SDK has no enum entry for would not reach the wire — it would silently become 0x101. Adding a status here means adding it to the SDK’s GattStatus first.

store — session key-value

store.get(key), store.set(key, value), store.delete(key), store.keys(). JSON-serializable values; in-memory and cleared each launch (D#8). Private script scratch — not host-visible. For state you want the laptop to see and edit live, declare it as an extVar instead (below).

extVar — live, host-visible control knobs

extVar({ key: default, … }) declares typed control variables the LAN editor can read and edit while the script runs, without a redeploy — flags, rates, modes. Returns a Proxy whose reads are always the current value (cfg.enabled); also extVar.get/set and extVar.onChange(fn). Reset to declared defaults on every (re)launch, like everything else. Full reference + the wire protocol: the extVar design note.

Standard, provided

console.log/warn/error (→ app event log + LAN editor console), setTimeout/setInterval/clearTimeout/ clearInterval (the WebView event loop — D2).

No profile means lax: existing scripts retain unbounded, fire-and-forget notification behavior. Declaring a non-lax profile opts into a per-central bounded TX queue, ATT_MTU - 3 payload checks, connection-event pacing, modeled indication RTT, and the profile’s handler deadline. VIRTUAL uses each central’s negotiated MTU (initially the profile value); LIVE keeps the declared profile MTU because the SDK cannot observe negotiation. overrun: "stall" is VIRTUAL-only: the request remains pending until a handler reply, link teardown/supervision timeout, or run stop. It is coerced to "nak" on LIVE. These tools require Pro’s rt_tools feature; telemetry and the Timing view remain available without shaping.

onLinkChange snapshots have this orthogonal shape:

{
  type, central: { id },
  lifecycle, mtu, security, paramsProcedure, subscriptions, supervision,
  secured, isSubscribed(uuid)
}

Both transports emit the same snapshot shape. VIRTUAL supplies every orthogonal dimension, including procedure-level changes (MTU exchange, security, params-update, supervision). LIVE emits snapshots derived from what the platform can actually observe — lifecycle (from connect/disconnect) and subscriptions (from subscribe/unsubscribe) — and reports every dimension the platform has no seam for as the explicit-unknown wire value rather than fabricating a concrete state:

dimensionVIRTUALLIVE
lifecyclefullconnected / disconnected
subscriptionsfullfull (per-characteristic)
mtunumbernull (unknown)
securityfull"unknown"
paramsProcedureidle/pending"unknown"
supervisionhealthy/timedOut"unknown"

So ev.link.secured is false on LIVE (we can’t prove encryption) and mtu is null — scripts that gate on those should treat unknown as “not established”, never as a definite state.

peripheral.machine(spec) (R8 Stage A)

The machine uses named actions, strict run-to-completion, bounded P0/P1/P2 event queues, declared every/after timers, and explicit GATT replies. Actions are ordinary synchronous JavaScript in Stage A; use io.defer(promise, doneEvent, failEvent) to return asynchronous work to the queue. P1’s reject-as-busy capacity policy is reserved for a concurrent Stage-B executor: Stage A drains P1 synchronously, so the only current overflow vector is synthetic test coverage.

peripheral.machine() is a Pro authoring model — all script execution needs SCRIPTING, and a machine also needs @capabilities rt_machine (Feature.RT_TOOLS), the same tier as the shipping fsm helper. There is no entitlement-based degrade-to-lax path: if a declared shaped profile is not authorized, the run returns the typed RT_TOOLS feature error.

/** @capabilities rt_machine persist */
peripheral.machine({
  machineVersion: 1,
  timingProfile: "nrf52-default",
  // context: a plain value is session-only; a descriptor { type, init, persist } declares an export
  // type hint and (with persist:true) survives pokeReset / reload via the gated PERSIST store.
  context: {
    count: 0,
    mode:  { type: "u8", init: 0, persist: true }
  },
  services: [{ uuid: SVC, characteristics: [
    { uuid: COUNT, properties: ["read"] }
  ] }],
  actions: {
    replyCount: function (ctx) { return bytes.ofInt32(ctx.count); },
    increment: function (ctx) { ctx.count++; }
  },
  initial: "streaming",
  states: {
    streaming: {
      every: { periodMs: 1000, event: "TICK", tolerance: 100 },
      on: {
        TICK: { actions: ["increment"], budgetMs: 2 },
        ["READ:" + COUNT]: { reply: { kind: "read", value: "replyCount" } },
        TX_QUEUE_FULL: { target: "throttled" }
      }
    },
    throttled: { on: { TX_DRAINED: { target: "streaming" } } }
  }
});

Reply specs are {kind:"read", value: actionName}, {kind:"success"}, or {kind:"gattError", status: GattStatusOrName}. Link changes arrive as their field-scoped uppercase types; control events include TIMER_LATE, DEADLINE_MISS, TX_QUEUE_FULL, and TX_DRAINED. actionKind: "javascript" is the only Stage A action language.

Typed / persisted context (D54). Each context entry is either a plain initial value (session-only) or a descriptor { type?, init?, persist? }. A persist: true var is written back through the per-script PERSIST store (namespaced __mctx:<name>) whenever it changes and reloaded at start, so it survives pokeReset and a reload — the RT11 story. Per-key writes are ordered; a reset first waits for the latest durable write and aborts (leaving the current run alive) on failure or timeout. Because persistence rides the PERSIST capability, a machine with any persist:true var must also declare @capabilities persist (Pro + per-script consent); declaring it without persist is a load error, never a silent downgrade to session-only. Session-only vars reset on reload/reset, as before.

profiles — prebuilt standard services (Pro)

profiles.<name>() returns { services: [...] }, ready to spread into peripheral.define:

peripheral.define({ name: "HRM", services: [].concat(profiles.heartRate().services) });
  • profiles.heartRate() — Heart Rate service (0x180D). Notifies 2A37 (flags 0x00, uint8 bpm) and answers Body Sensor Location 2A38 with 1 (Chest). Knobs: ticking, bpm, stepMs.
  • profiles.battery() — Battery service (0x180F). 2A19 is readable and notifying; the level walks 100 → 0 and wraps back to 100. Knobs: draining, stepMs.
  • profiles.deviceInformation() — Device Information (0x180A): manufacturer, model, serial and firmware strings. The only inert profile — no knobs, no ticker.
  • profiles.cyclingSpeedCadence() — CSC (0x1816). Notifies 2A5B with flags 0x03, cumulative wheel revolutions, and 1/1024 s event times; 2A5C reports wheel + crank support. Knobs: ticking, wheelRpm, crankRpm.

Calling one is not just building a value. Each profile also declares its own extVar knobs and registers its own peripheral.onStart ticker, so the call has side effects and the order it appears in matters. Two consequences worth knowing before combining profiles:

  • Knob names are one flat namespace (extVar keys into a single map, and a later declaration overwrites an earlier one). heartRate() and battery() both declare stepMs, so combining them leaves one shared interval driving both tickers; heartRate() and cyclingSpeedCadence() both declare ticking. Declare your own extVar after the profiles if you need to pin a value.
  • Each profile starts its own ticker, so combining four means four independent timers.

device — hooks into a running composed device (Pro, attached hooks only)

Granted only to a script a composed device runs as its attached hook — declare @kind attached-hook in the manifest. A standalone peripheral never has this global. Tier-gated by the devices entitlement; no consent, since it is pure compute over the device model the host already owns.

Every function returns a Promise, and the target-specific helpers can reject: they resolve a concept to whichever variable the running model actually declares (the catalog spells the same idea differently per target — sessionState vs rowerSessionState), by waiting for the first engine tick and matching against the keys that tick carries. A target that declares none of the candidates rejects with this target declares none of: …, and no call is ever sent for a key the target lacks.

Primitives

  • device.get(key)Promise<number> — the current value of one simulation variable.
  • device.set(key, value, { mode })Promise<{ key, value, mode }>. mode is 'nudge' (default) or 'hold'. A nudge is a one-shot poke the model’s own couplings may undo on the next tick; a hold pins the value, which is what the dashboard’s Start/Pause/Stop use.
  • device.drive(key, fn){ ready: Promise, remove(): Promise }. fn(state, dtMs) runs every tick and must return a finite number; a throw or non-finite return is logged and skipped, not fatal.
  • device.onTick(fn)fn(state, dtMs) on every engine tick. state carries every declared key.
  • device.onWrite(uuid, fn)fn(centralId, uuid, writeType, bytes, outcome, ref) for a completed central write. outcome is { success, status }. ref (additive) is the exact instance written — { service, characteristic, instance } — and is undefined when the platform could not resolve one; ref.instance is null unless the spec declares that UUID twice. A hook written against the five-argument signature keeps working.

device.fitness (FTMS treadmill / indoor bike / rower) — start() · pause() · stop() (all held, since a nudge would be undone next tick) · session()Promise<'idle'|'running'|'paused'> · onSession(fn) (fires on change, not every tick) · targetPower(watts) · resistance(percent) · speed(kmh) · incline(percent) · cadence(rpm) · strokeRate(spm).

device.sensortemperature(celsius) · humidity(percent) · pressure(pascals) · drift(on). Turning drift off stops the model’s random walk, so a scripted value stays put.

device.iopin(index, on) · analog(value).

device.hidkey(letter) holds a key (az; pass null to release) · tap(letter) presses then releases · modifiers({ shift, ctrl, alt, gui }) holds modifiers for every later key · move(direction) with 'rest'|'up'|'down'|'left'|'right', streaming until rested · button(name, down) with 'left'|'right'|'middle' · wheel(direction) with 'still'|'up'|'down' (report protocol only — the boot report has no wheel byte). An unknown name throws RangeError.

device.midinote(note, { velocity, channel }) holds a note, channel being 1–16 as a DAW shows it · release() · noteName(note) → e.g. "C4" · onMessage(fn)fn(message, centralId) · parse(packet)[{ timestampMs, bytes, status, channel, type, note, velocity }]. Use these rather than a raw onWrite: BLE-MIDI Running Status means a chord’s later notes carry no status byte, so raw bytes cannot be interpreted without tracking packet state yourself.

The namespace is assembled from flag-gated capabilities (D12)

The globals above are the safe-by-default set for an authorized script run. The runtime builds each session’s namespace from a capability registry (script/CapabilityRegistry.kt), so the sandbox can be widened deliberately — behind advanced/paid flags with explicit consent. The advanced capabilities below are off by default; their modules ship in every artifact but runtime entitlements gate them, and they are requested per-script via @capabilities. The DOM is never exposed.

Two Pro globals are not requested this way, because they are gated by entitlement rather than declared per script: profiles (present for every script — scripting is itself a Pro feature) and device (present only for a script a composed device runs as its attached hook, @kind attached-hook). Only the four tokens below are @capabilities tokens.

Declare what a script needs in its header: @capabilities net, persist, fsm. When the corresponding runtime Pro entitlement is present, the user is asked to consent the first time a net/persist script runs (bound to the exact source — an edit re-prompts). fsm is pure compute, so it needs no consent. Without the entitlement these globals are absent and the run is rejected.

  • net — outbound HTTP. net.fetch(url, { method, headers, body })Promise<{ ok, status, bytes(), text(), json() }>. body is a string (sent UTF-8) or a Uint8Array. Reopens the D4-locked-down sandbox, so it is paid + consented.
  • persist — key/value storage that survives across runs (vs. session-only store), namespaced per script: persist.set(key, value) · persist.get(key)Promise<Uint8Array|null> · persist.getText(key) · persist.remove(key) · persist.keys(). All return Promises.
  • fsm — a bundled finite-state-machine helper: fsm.create({ initial, states: { s: { on: { event: 'next' }, onEnter(e) {} } } }){ state, can(event), send(event, payload), onChange(fn) }.

Manifest

The @name/@author/@description/@advertises/@capabilities JSDoc header is parsed statically, without running the script, so the app can show “what this does / who wrote it” — and what it asks for — for provenance gating before first run (D5). Running the script is never required to read its metadata. Scripts can also be imported from a URL (host “Import from URL”); fetched scripts are recorded IMPORTED and never auto-run (D5).

3. GATT read through the bridge (scripted characteristic)

Central                SDK (platform GATT server)      Binding (common)        JsRuntime (actual)        JS (WebView)
  │  ATT Read Req  ───▶ onCharacteristicReadRequest                                                     
  │                     / didReceiveReadRequest                                                          
  │                          │ invokes registered ReadHandler (suspend)                                  
  │                          │ ───────────────────────────▶ bridgeRead(charId, req)                      
  │                          │                                    │ suspends, calls ───▶ invokeRead(charId, reqJson)
  │                          │                                    │                          │  callAsyncJavaScript      
  │                          │                                    │                          │  (iOS) / evaluateJavascript (Android)
  │                          │                                    │                          │ ──▶ __invokeRead(charId, req)
  │                          │                                    │                          │       look up onRead, run it
  │                          │                                    │                          │       (await if Promise), bytes→base64
  │                          │                                    │                          │ ◀── returns base64 (async)
  │                          │                                    │ ◀─ resume: base64→bytes  │                            
  │                          │ ◀─ ReadResponse.success(bytes)     │                                                       
  │  ◀── ATT Read Rsp ──     │  sendResponse                                                                              
  • One async JS round trip, on the WebView’s JS thread; sub-ms for local JS. The SDK handler being suspend is what makes the async hop invisible to the GATT layer.
  • Self-imposed timeout: the binding races the JS call against a short deadline (≪ ATT’s ~30 s). On timeout/uncaught error → ReadResponse.Failure(GattStatus.Failure) + a log entry, so a hung script NAKs cleanly instead of stalling the link.
  • Byte payloads cross as base64 strings (JSON-safe), decoded at the seam. Script author only ever sees Uint8Array.

4. Write and notify

Write is the mirror of read: onCharacteristicWriteRequest/didReceiveWriteRequestsbridgeWrite (suspend) → invokeWrite(charId, {value: base64, …}) → JS onWrite runs → returns ack / Gatt.errorWriteResponsesendResponse (skipped for writeWithoutResponse). Same timeout guard.

Notify is the only JS→native hot path (imperative, no central request to answer):

JS: server.characteristic(uuid).notify(bytes)
      └─▶ __post({t:"notify", uuid, valueB64[, callId]})
            └─▶ Binding decodes ─▶ lax controller, or per-central TrafficShaper queues
                  └─▶ SDK CharacteristicController.notify(bytes, targetSubscribers)

Fast path (D6): at define() time the binding registers a bridging handler only for characteristics that supplied onRead/onWrite. Characteristics without handlers keep the SDK’s native default (cached read / store-on-write) and never cross the bridge on the hot path — so a high-rate writeWithoutResponse throughput test against an un-scripted characteristic runs at native speed. setValue/notify remain available on any characteristic via the controller (JS→native only when the script chooses).

5. Resolved decisions

  1. Config shapeobject literal (declarative, diff-friendly, most idiomatic for JS).
  2. Handler failure idiomreturn a sentinel (Gatt.error(code)); bare return = success. No try/catch required. A thrown exception is caught defensively → generic failure + log (so a script bug never kills the session). This keeps errors out of the author’s way (they may not be JS experts).
  3. server accessglobal peripheral.server (also valid inside handlers).
  4. Scoped KVexpose a session-only store now (host-visible, cleared each launch; persistence deferred behind a flag — D12/D#8).
  5. notify / indicateparallel properties, mirroring write/writeWithoutResponse; shaped notify is admission-correlated and indicate is the modeled-confirmation (async) counterpart.
  6. Hot-reloadfull teardown and re-run the script on redeploy (matches fresh-runtime; no GATT diffing).