Skip to content

triki-controller (npm package) ​

npm

triki-controller is the reusable core of the Web Bluetooth controller shipped as a dependency-free, strongly-typed TypeScript package. It connects to the Triki token over the Nordic UART Service, starts the IMU stream, parses the 14-byte motion frames, and (optionally) fuses orientation with a selectable 6-axis filter — Madgwick or VQF — so any web app can reuse the token as a motion controller.

The visualizer page and this package share the same parsing and fusion math; the BLE protocol and IMU streaming pages are the authoritative spec.

Install ​

sh
npm install triki-controller

Browser support ​

TrikiController uses the Web Bluetooth API, so the same constraints as the live controller apply:

  • Desktop Chrome / Edge / Opera (Chromium). Safari and Firefox do not implement Web Bluetooth.
  • A secure context is required: https:// or http://localhost, never file://.
  • connect() must run inside a user gesture (e.g. a click handler).
  • Only one BLE central holds the token at a time.

TrikiController.isSupported() returns false (rather than throwing) wherever Web Bluetooth is missing, so it is safe to call during SSR.

Quick start ​

ts
import { TrikiController } from "triki-controller";

if (!TrikiController.isSupported()) {
  throw new Error("This browser has no Web Bluetooth.");
}

const triki = new TrikiController({ fusion: true, rateHz: 104 });

triki.on("connectionchange", (state) => {
  // "disconnected" | "pairing" | "streaming"
  console.log("state:", state);
});

triki.on("frame", (f) => {
  // f.gyro = { x, y, z } in deg/s, f.accel = { x, y, z } in g, f.t = ms
});

triki.on("orientation", (o) => {
  // o.quaternion = [w, x, y, z] (right-handed), o.euler = { roll, pitch, yaw } in degrees
});

triki.on("rate", (hz) => console.log("streaming", hz, "Hz"));

// Must be inside a click/tap handler:
document.querySelector("#connect")!.addEventListener("click", async () => {
  await triki.connect();      // shows the device picker, starts streaming
  await triki.setLed(true);   // green LED on
  await triki.setRate(208);   // 26 / 52 / 104 / 208 / 416 Hz
  triki.resetHeading();       // re-zero yaw whenever you like
});

Pick the filter with the fusion option — "madgwick" (default), "vqf", "accel" (accelerometer-only tilt: instant but jittery, no yaw), or "none" (true ≡ "madgwick", false ≡ "none"). For raw frames only, use { fusion: "none" } (or false) and listen to "frame"; the orientation event is then never emitted. You can also switch at runtime with setFusion(...).

Events ​

TrikiController is a typed emitter. on(type, cb) and once(type, cb) return an unsubscribe function; off(type, cb) removes a listener.

EventPayloadWhen
frameFrameEventevery decoded motion frame
orientationOrientationEventevery frame, fusion only
connectionchangeConnectionState (string)state transitions
ratenumber (Hz)~once per second, measured throughput
batterynumber (0–100)on connect, then on each battery update
ts
interface FrameEvent {
  raw:   { gx: number; gy: number; gz: number; ax: number; ay: number; az: number }; // int16 counts
  gyro:  { x: number; y: number; z: number };  // deg/s
  accel: { x: number; y: number; z: number };  // g
  t: number;                                    // performance.now() ms
}

interface OrientationEvent {
  quaternion: readonly [w: number, x: number, y: number, z: number]; // right-handed
  euler: { roll: number; pitch: number; yaw: number };               // degrees
  algorithm: "madgwick" | "vqf" | "accel" | "none";                  // active filter
  t: number;
}

API ​

new TrikiController(options?):

OptionDefaultMeaning
fusiontruefilter: "madgwick"/"vqf"/"accel"/"none" (true≡madgwick, false≡none)
rateHz104initial IMU sample rate
beta0.08Madgwick filter gain
tauAcc2.0VQF accel low-pass time constant (seconds)
gyroScale14.286gyro scale in LSB per deg/s (LSM6DSL ±2000 dps)
gyroBias{0,0,0}per-axis gyro correction (deg/s) subtracted from every sample
accelBias{0,0,0}per-axis accel correction (g) subtracted from every sample
transportWeb BluetoothBLE transport; pass a NobleTransport (Node) or any TrikiTransport
MemberDescription
static isSupported()true when navigator.bluetooth exists (SSR-safe). Only about the default Web Bluetooth transport — in Node with NobleTransport just call connect().
connect()Show the picker, connect, start streaming. User gesture required.
reconnect()Reconnect to the last paired device without the picker.
disconnect()Disconnect. Idempotent; safe in any state — cancels a pending connect().
dispose()Disconnect, release the transport (another controller may then attach), drop all listeners.
setLed(on)Green LED on/off (throws if the token has no LED characteristic).
setRate(hz)Set the sample rate; applied live when streaming.
resetHeading()Re-zero yaw (no-op when fusion is off).
setFusion(algo)Switch filter at runtime ("madgwick"/"vqf"/"accel"/"none"); re-zeros heading.
setBeta(v)Set Madgwick gain; applied live when Madgwick is active.
setTauAcc(v)Set VQF accel low-pass (seconds); applied live when VQF is active.
setGyroScale(scale)Runtime gyro calibration.
setGyroBias(v) / setAccelBias(v)Set the per-axis correction vectors live.
isConnected / state / rateHz / hasLed / battery / fusion / fusionAlgorithmGetters.

connect() rejects (after cleaning up, transport included) if pairing or the handshake fails, so wrap it in try/catch to surface picker errors. reconnect() reuses the cached device and throws if connect() was never called.

Event guarantees: precondition failures (no Web Bluetooth, nothing to reconnect to) reject before any state change — zero connectionchange events. Once a real attempt starts you get pairing, and on failure a single disconnected before the rejection. Calling disconnect() while connect() is pending cancels it (the pending promise rejects).

Node.js (receive outside the browser) ​

Web Bluetooth only exists in the browser. To receive the token from a plain Node process, pass a NobleTransport — a transport backed by @abandonware/noble — from the triki-controller/node entry point. Everything else (events, fusion, setLed, setRate, resetHeading) behaves exactly as in the browser.

sh
npm install triki-controller @abandonware/noble
ts
import { TrikiController, NobleTransport } from "triki-controller/node";

const triki = new TrikiController({ transport: new NobleTransport() });

triki.on("orientation", (o) => console.log(o.euler)); // { roll, pitch, yaw }
await triki.connect(); // scans for a "TRIKI" token, connects, starts streaming

A runnable demo lives at packages/triki-controller/example/node.mjs.

@abandonware/noble is an optional peer dependency: install it alongside triki-controller (as above). It is imported lazily, only when connect() runs, so triki-controller never pulls the native module on its own. On macOS the first run prompts the terminal (or your IDE) for Bluetooth access.

new NobleTransport(options?):

OptionDefaultMeaning
namePrefix"TRIKI"match devices whose advertised name starts with this prefix (case-insensitive)
address—match a specific BLE address or noble peripheral id instead of by name (macOS hides MAC addresses — use the id there)
noble@abandonware/noblea noble-API-compatible module to use instead
scanTimeoutMs15000budget for adapter power-on + scan + link setup (writes share it too); Infinity to wait forever
scanServiceUuids[]advertised-service filter for the scan (e.g. [NUS_SERVICE]); empty scans everything

The whole connect attempt shares the scanTimeoutMs budget and rejects with a ScanTimeoutError when it runs out; disconnect() during a pending connect() rejects it with a ConnectAbortedError (both exported from the main entry and triki-controller/node). reconnect() re-links to the exact peripheral matched by the previous connect() scan — no new scan, so with several tokens in range it can never silently attach to a different one.

Native build

@abandonware/noble compiles a native addon, and its bundled node-gyp is old enough to fail on the newest Node releases. Use a current Node LTS (which has prebuilt binaries), or pass a drop-in fork through the noble option:

ts
import noble from "@stoprocent/noble";
const triki = new TrikiController({ transport: new NobleTransport({ noble }) });

Custom transports ​

NobleTransport and the browser WebBluetoothTransport both implement the TrikiTransport interface. Implement it yourself to drive the controller over any link — a WebSocket bridge, a serial dongle, or a replay of recorded frames — while reusing all of the parsing and fusion. The transport is a dumb NUS pipe; the controller owns the Triki protocol, so the protocol logic lives in exactly one place.

Required members: attach(handlers), detach(), connect(), writeRx(data, withoutResponse?), writeCtrl(data), hasLed, disconnect(). Optional: reconnect() (omit it when the transport can't remember a device — the controller then rejects reconnect() with a clear error) and preflight(op) (synchronous precondition check, called before any state change).

The controller claims the transport with attach(handlers) in its constructor — one controller per transport (attach throws if already attached). The handlers object carries onFrame(bytes), onDisconnect(), and onBattery(percent); call only the ones you have data for.

The contract, in short:

  • onDisconnect() fires exactly once per successful connect()/reconnect(), and never when no connect succeeded — a failed connect signals through its rejection.
  • disconnect() is idempotent and safe in any state: it tears down an active session (firing onDisconnect once), cancels a pending connect() (which then rejects), and is a no-op otherwise.
  • connect() cleans up its own resources before rejecting.

A minimal replay transport:

ts
import type { TrikiTransport, TrikiTransportHandlers } from "triki-controller";

class ReplayTransport implements TrikiTransport {
  #handlers: TrikiTransportHandlers | null = null;
  #connected = false; // connection state — separate from attach/detach ownership
  readonly hasLed = false;

  attach(handlers: TrikiTransportHandlers): void {
    if (this.#handlers) throw new Error("Already attached.");
    this.#handlers = handlers;
  }
  detach(): void { this.#handlers = null; }
  async connect(): Promise<void> { this.#connected = true; /* open your source */ }
  async writeRx(): Promise<void> { /* swallow START — it's a replay */ }
  async writeCtrl(): Promise<void> { throw new Error("No LED on a replay."); }
  disconnect(): void {
    if (!this.#connected) return; // idempotent; never fires onDisconnect unconnected
    this.#connected = false;
    this.#handlers?.onDisconnect();
  }

  feed(bytes: Uint8Array): void {
    if (this.#connected) this.#handlers?.onFrame(bytes); // frames only while active
  }
}

Standalone primitives ​

The parsing, fusion and protocol code is pure and environment-agnostic (no DOM, no Bluetooth), so it works in Node or a worker. This is the seam for a non-browser transport, for example a native bridge that owns its own BLE stack and only reuses the math.

ts
import { FrameParser, MadgwickAHRS, startCmd, decodeCounts } from "triki-controller";

const parser = new FrameParser();
const madgwick = new MadgwickAHRS({ beta: 0.08 });

// Feed your own BLE notification bytes (from any transport):
for (const f of parser.push(notificationBytes)) {
  const gx = f.gxRaw / 14.286, gy = f.gyRaw / 14.286, gz = f.gzRaw / 14.286; // deg/s
  const ax = f.axRaw / 2048, ay = f.ayRaw / 2048, az = f.azRaw / 2048;       // g
  madgwick.update(gx, gy, gz, ax, ay, az, 1 / 104);
}
console.log(madgwick.euler()); // { roll, pitch, yaw } in degrees

// startCmd(hz) builds the 8-byte START payload to write to RX:
await rxCharacteristic.writeValue(startCmd(208));

Exported alongside the controller:

  • MadgwickAHRS — 6-axis filter: update(gx, gy, gz, ax, ay, az, dt), quaternion(), euler(), reset().
  • VqfAHRS — 6-axis VQF filter (gyro strapdown + low-pass-filtered accel inclination; ported from VQF by Daniel Laidig, MIT). Drop-in for MadgwickAHRS — same update / quaternion / reset shape and [w, x, y, z] convention, plus setTauAcc(tau).
  • AccelAHRS — accelerometer-only tilt (no gyro, no yaw); same drop-in shape and frame.
  • OrientationFilter — the common interface all three filters implement, so you can swap them.
  • FrameParser — stateful framer with header resync: push(chunk) => RawFrame[], reset(). decodeCounts(frame) decodes a single 14-byte frame.
  • startCmd(hz) / ledCmd(on) — command builders.
  • Quaternion helpers quatMul / quatAboutZ / yawRadOf / eulerOf.
  • Constants: NUS_SERVICE / NUS_RX / NUS_TX / NUS_CTRL, BATTERY_SERVICE / BATTERY_LEVEL (canonical 128-bit UUIDs), DEVICE_NAME_PREFIX, START_BASE, FRAME_LEN, SUPPORTED_RATES_HZ ([26, 52, 104, 208, 416]), DEFAULT_RATE_HZ, DEFAULT_GYRO_SCALE, DEFAULT_ACCEL_SCALE, DEFAULT_BETA, DEFAULT_TAU_ACC.

Caveats ​

  • Yaw drift. Fusion is 6-axis (gyro + accel, no magnetometer), so absolute heading drifts over time — for both Madgwick and VQF. Roll/pitch stay levelled by gravity. Call resetHeading() to re-zero yaw.
  • Filter choice. All filters share the same output frame (right-handed [w, x, y, z], gravity-up), so they are interchangeable. Madgwick exposes one gain (beta: higher = trusts the accel more, snappier but noisier); VQF exposes tauAcc (accel low-pass time constant in seconds: higher = smoother, slower tilt); "accel" is gyro-free tilt — instant roll/pitch but noisy, and yaw stays 0.
  • Math vs display frame. The library emits the right-handed math quaternion. The visualizer page negates y and z ([w, x, -y, -z]) only for its on-screen roll/pitch/yaw readout, to match the visual sense of rotation; the 3D model itself is driven from the unmodified quaternion via a per-model mount offset. Apply that negation yourself only if you mirror that readout, and do not double-negate.
  • Throughput. Above ~208 Hz, BLE may not keep up; the rate event reports the actual measured throughput so the app can react to the real frame rate, not the requested one.
  • No native joystick. A browser cannot emit a real joystick / HID gamepad. Turning the orientation stream into OS-level input still needs a native helper or a WebSocket / WebHID side channel (see the roadmap on the Overview).

License ​

MIT.