Developer Guides
Included with Warsha BLE Studio
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 fromonStart. -
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 everyPeripheralEvent(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 nocentralslist. Who is subscribed is a per-central fact that changes without a GATT operation, so it is delivered byonLinkChange— whose snapshot carriessubscriptionsand the derivedisSubscribed(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.typeidentifies the field-scoped change (MTU_CHANGED,SECURITY_CHANGED,SUBSCRIPTION_CHANGED, etc.);securedandisSubscribed(uuid)are derived conveniences. -
peripheral.machine(spec)— the RT-aware, closure-free machine authoring model described below. It is mutually exclusive withonStart/onEventand 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 viabytes.*) → success. ReturnGatt.error(status)→ that GATT status. (Uncaught throw → generic failure + log.) May beasync/return a Promise.onWrite(req)—req = { central, value: Uint8Array, offset, withoutResponse }. Return nothing/true→ ack. ReturnGatt.error(status)→ NAK with that status. (Uncaught throw → NAK + log.) ForwriteWithoutResponsethere’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 / text —
bytes.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).leis a positional boolean, defaulting to little-endian — the byte order every multi-octet Bluetooth field uses. Passfalsefor the rare big-endian payload. The 8-bit helpers take nole. Unsigned is usually what a GATT field wants:readUint8offfis255,readInt8is-1. - Floats —
bytes.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 hatch —
bytes.concat(...)·bytes.view(u8)→ aDataViewpositioned 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).
Timing profiles and link snapshots (R8)
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:
| dimension | VIRTUAL | LIVE |
|---|---|---|
lifecycle | full | connected / disconnected |
subscriptions | full | full (per-characteristic) |
mtu | number | null (unknown) |
security | full | "unknown" |
paramsProcedure | idle/pending | "unknown" |
supervision | healthy/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). Notifies2A37(flags0x00, uint8 bpm) and answers Body Sensor Location2A38with1(Chest). Knobs:ticking,bpm,stepMs.profiles.battery()— Battery service (0x180F).2A19is 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). Notifies2A5Bwith flags0x03, cumulative wheel revolutions, and 1/1024 s event times;2A5Creports 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 (
extVarkeys into a single map, and a later declaration overwrites an earlier one).heartRate()andbattery()both declarestepMs, so combining them leaves one shared interval driving both tickers;heartRate()andcyclingSpeedCadence()both declareticking. Declare your ownextVarafter 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 }>.modeis'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.statecarries every declared key.device.onWrite(uuid, fn)—fn(centralId, uuid, writeType, bytes, outcome, ref)for a completed central write.outcomeis{ success, status }.ref(additive) is the exact instance written —{ service, characteristic, instance }— and isundefinedwhen the platform could not resolve one;ref.instanceis 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.sensor — temperature(celsius) · humidity(percent) · pressure(pascals) ·
drift(on). Turning drift off stops the model’s random walk, so a scripted value stays put.
device.io — pin(index, on) · analog(value).
device.hid — key(letter) holds a key (a–z; 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.midi — note(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.
Advanced capabilities (Pro + @capabilities + consent) — Phase E
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() }>.bodyis a string (sent UTF-8) or aUint8Array. Reopens the D4-locked-down sandbox, so it is paid + consented.persist— key/value storage that survives across runs (vs. session-onlystore), 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
suspendis 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/didReceiveWriteRequests → bridgeWrite
(suspend) → invokeWrite(charId, {value: base64, …}) → JS onWrite runs → returns ack / Gatt.error
→ WriteResponse → sendResponse (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
- Config shape → object literal (declarative, diff-friendly, most idiomatic for JS).
- Handler failure idiom → return a sentinel (
Gatt.error(code)); bare return = success. Notry/catchrequired. 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). serveraccess → globalperipheral.server(also valid inside handlers).- Scoped KV → expose a session-only
storenow (host-visible, cleared each launch; persistence deferred behind a flag — D12/D#8). notify/indicate→ parallel properties, mirroringwrite/writeWithoutResponse; shaped notify is admission-correlated and indicate is the modeled-confirmation (async) counterpart.- Hot-reload → full teardown and re-run the script on redeploy (matches fresh-runtime; no GATT diffing).