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
MemberDescription
static isSupported()true when navigator.bluetooth exists (SSR-safe).
connect()Show the picker, connect, start streaming. User gesture required.
reconnect()Reconnect to the last paired device without the picker.
disconnect()Disconnect.
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) 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.

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, 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.