Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 137 additions & 9 deletions docs/guide/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ controller.

- **npm:** [`triki-controller`](https://www.npmjs.com/package/triki-controller)
- **Source + README:** [`packages/triki-controller`](https://github.com/Flopsstuff/triki/tree/main/packages/triki-controller#readme)
- Zero runtime dependencies, ESM-only, ships its own `.d.ts`.
- ESM-only, ships its own `.d.ts`. The browser core has **zero runtime dependencies**;
the optional Node transport uses `@abandonware/noble` (see
[Node.js](#nodejs-receive-outside-the-browser)).

The visualizer page and this package share the same parsing and fusion math; the
[BLE protocol](./ble-protocol) and [IMU streaming](./imu-streaming) pages are the
Expand Down Expand Up @@ -120,13 +122,15 @@ interface OrientationEvent {
| `gyroScale` | `14.286` | gyro 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 |
| `transport` | Web Bluetooth | BLE transport; pass a `NobleTransport` (Node) or any `TrikiTransport` |

| Member | Description |
|---|---|
| `static isSupported()` | `true` when `navigator.bluetooth` exists (SSR-safe). |
| `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. |
| `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). |
Expand All @@ -137,9 +141,131 @@ interface OrientationEvent {
| `setGyroBias(v)` / `setAccelBias(v)` | Set the per-axis correction vectors live. |
| `isConnected` / `state` / `rateHz` / `hasLed` / `battery` / `fusion` / `fusionAlgorithm` | Getters. |

`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.
`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`](https://github.com/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`](https://github.com/Flopsstuff/triki/blob/main/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?)`:**

| Option | Default | Meaning |
|---|---|---|
| `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/noble` | a noble-API-compatible module to use instead |
| `scanTimeoutMs` | `15000` | budget 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.

::: warning 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
}
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Standalone primitives

Expand Down Expand Up @@ -180,9 +306,11 @@ Exported alongside the controller:
`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`.
- 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

Expand Down
7 changes: 5 additions & 2 deletions docs/guide/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ tools/ BLE tooling + scripts (tracked; .venv ignored)
into a reusable, dependency-free
[`triki-controller`](https://www.npmjs.com/package/triki-controller)
library (browser BLE client + framework-agnostic fusion math)
- [ ] Build a native controller bridge (joystick / OSC / HID) — the parse + fusion
half above is done and reusable; what's left is a native BLE transport and the
- [x] Receive the token **outside the browser**: `triki-controller` now has a
pluggable transport layer with a Node `NobleTransport`
(see [Node.js](./library#nodejs-receive-outside-the-browser))
- [ ] Build a native controller bridge (joystick / OSC / HID) — the parse, fusion
and Node BLE input halves above are done and reusable; what's left is the
OS-level output, since a browser can't emit a real joystick / HID
(see [Web Bluetooth controller](./controller))
41 changes: 35 additions & 6 deletions packages/triki-controller/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# triki-controller

A dependency-free, strongly-typed **Web Bluetooth** client for the [Żabka **Triki**](https://github.com/Flopsstuff/triki)
A strongly-typed driver for the [Żabka **Triki**](https://github.com/Flopsstuff/triki)
BLE token (an nRF52810 BLE SoC + LSM6DSL accelerometer/gyroscope, shaped like a bottle cap).
It connects 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 you can reuse the token as a motion controller from any web app.
**VQF** — so you can reuse the token as a motion controller. **Web Bluetooth** in the
browser (zero runtime dependencies), or **Node** via the optional
[`@abandonware/noble`](https://github.com/abandonware/noble) transport.

![Triki motion controller demo](https://flopsstuff.github.io/triki/img/controller-demo.gif)

## Browser support

Web Bluetooth is required:
In the browser, Web Bluetooth is required (see [Node.js & custom transports](#nodejs--custom-transports)
for everything else):

- ✅ Desktop **Chrome / Edge / Opera** (Chromium-based).
- ❌ **Safari and Firefox** do not implement Web Bluetooth.
Expand Down Expand Up @@ -71,14 +74,16 @@ standalone (the filters share an `OrientationFilter` interface).
`new TrikiController(options?)` — `options`: `fusion?` (`"madgwick"`/`"vqf"`/`"accel"`/`"none"`,
default `"madgwick"`), `rateHz?` (default `104`), `beta?` (Madgwick gain, default `0.08`),
`tauAcc?` (VQF accel low-pass seconds, default `2.0`), `gyroScale?` (default `14.286`),
`gyroBias?` / `accelBias?` (per-axis correction vectors, default zeros).
`gyroBias?` / `accelBias?` (per-axis correction vectors, default zeros), `transport?`
(a `TrikiTransport`; defaults to the browser's `WebBluetoothTransport`).

| Member | Description |
| --- | --- |
| `static isSupported()` | `true` when `navigator.bluetooth` exists (SSR-safe). |
| `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. |
| `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 sample rate; applied live when streaming. |
| `resetHeading()` | Re-zero yaw (no-op when fusion is off). |
Expand All @@ -92,6 +97,30 @@ default `"madgwick"`), `rateHz?` (default `104`), `beta?` (Madgwick gain, defaul
Events: `frame`, `orientation` (fusion only), `connectionchange` (payload is the state string),
`rate` (measured Hz), `battery` (level in percent, 0–100).

## Node.js & custom transports

The BLE link is pluggable: pass any `TrikiTransport` via the `transport` option. To
receive the token from a plain Node process, use the **`NobleTransport`** from the
`triki-controller/node` entry (backed by the **optional** peer dependency
`@abandonware/noble` — installed separately, imported lazily):

```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));
await triki.connect(); // scans for a "TRIKI" token (15 s budget), connects, streams
```

Options, timeout/cancellation semantics, the full `TrikiTransport` contract, and a
runnable example live in the
[library guide](https://flopsstuff.github.io/triki/guide/library#nodejs-receive-outside-the-browser)
and [`example/node.mjs`](https://github.com/Flopsstuff/triki/blob/main/packages/triki-controller/example/node.mjs).

## Caveats

- **Yaw drift.** Fusion is 6-axis (gyro + accel, no magnetometer), so absolute heading drifts
Expand Down
39 changes: 39 additions & 0 deletions packages/triki-controller/example/node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Receive the Triki IMU stream in Node — no browser required.
*
* Prerequisites (from the package directory):
* npm install @abandonware/noble # optional native dependency
* npm run build # produces dist/node.js
*
* Then run:
* node example/node.mjs
*
* macOS grants Bluetooth access per-app, so the first run prompts the terminal
* (or your IDE) for permission.
*/
import { TrikiController, NobleTransport, ConnectAbortedError } from "../dist/node.js";

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

triki.on("connectionchange", (state) => console.log("[state]", state));
triki.on("rate", (hz) => console.log("[rate]", hz, "Hz"));
triki.on("orientation", ({ euler }) => {
const f = (n) => n.toFixed(1).padStart(7);
console.log(`roll ${f(euler.roll)} pitch ${f(euler.pitch)} yaw ${f(euler.yaw)}`);
});

process.on("SIGINT", () => {
triki.disconnect(); // kicks off the BLE teardown (fire-and-forget inside the transport)
// The 'disconnected' event fires synchronously, BEFORE the HCI disconnect is sent —
// so give the radio a moment instead of exiting from the event.
setTimeout(() => process.exit(0), 300);
});

console.log("Scanning for a TRIKI token… (Ctrl+C to quit)");
try {
await triki.connect(); // 15 s scan budget by default; Ctrl+C mid-scan rejects
} catch (err) {
if (err instanceof ConnectAbortedError) process.exit(0); // user quit during the scan
console.error("[connect]", err instanceof Error ? err.message : err);
process.exit(1);
}
18 changes: 16 additions & 2 deletions packages/triki-controller/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "triki-controller",
"version": "0.2.0",
"description": "Web Bluetooth driver for the Żabka Triki token (nRF52810 + LSM6DSL) — connect, stream the IMU, and read fused orientation (selectable Madgwick or VQF) in the browser to reuse the token as a motion controller.",
"version": "0.3.0",
"description": "Driver for the Żabka Triki token (nRF52810 + LSM6DSL) — connect, stream the IMU, and read fused orientation (selectable Madgwick or VQF) to reuse the token as a motion controller. Web Bluetooth in the browser, or @abandonware/noble in Node.",
"type": "module",
"license": "MIT",
"author": "Flopsstuff",
Expand All @@ -26,6 +26,8 @@
"gyroscope",
"accelerometer",
"motion-controller",
"noble",
"nodejs",
"triki",
"zabka"
],
Expand All @@ -37,6 +39,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
}
},
"files": [
Expand All @@ -61,6 +67,14 @@
"typescript": "^5.7.2",
"vitest": "^4.1.9"
},
"peerDependencies": {
"@abandonware/noble": ">=1.9.2-26 <2.0.0"
},
"peerDependenciesMeta": {
"@abandonware/noble": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
Expand Down
33 changes: 33 additions & 0 deletions packages/triki-controller/src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ describe("battery", () => {
expect(ctrl.battery).toBe(42);
});

test("ignores an empty battery payload instead of throwing", async () => {
const h = install({ batteryLevel: 87 });
const ctrl = make({ fusion: "none" });
await ctrl.connect();
await flush(); // battery read + subscription settle

// A 0-byte ATT value is protocol-legal; getUint8(0) on it would RangeError.
h.battery!.notify(new Uint8Array(0));
expect(ctrl.battery).toBe(87); // the initial read stands, the empty update is dropped
});

test("tolerates a token without the Battery service", async () => {
install({ battery: false });
const ctrl = make({ fusion: "none" });
Expand Down Expand Up @@ -291,6 +302,28 @@ describe("lifecycle", () => {
h.gatt.disconnect(); // simulate the peripheral dropping the link
expect(ctrl.state).toBe("disconnected");
});

test("connect() without Web Bluetooth rejects with zero connectionchange events", async () => {
vi.stubGlobal("navigator", {}); // no navigator.bluetooth
const ctrl = make({ fusion: "none" });
const states: ConnectionState[] = [];
ctrl.on("connectionchange", (s) => states.push(s));

await expect(ctrl.connect()).rejects.toThrow("Web Bluetooth is not available");
expect(states).toEqual([]); // preflight fails before any state change
expect(ctrl.state).toBe("disconnected");
});

test("reconnect() before any connect() rejects with zero connectionchange events", async () => {
install();
const ctrl = make({ fusion: "none" });
const states: ConnectionState[] = [];
ctrl.on("connectionchange", (s) => states.push(s));

await expect(ctrl.reconnect()).rejects.toThrow("No device to reconnect to");
expect(states).toEqual([]);
expect(ctrl.state).toBe("disconnected");
});
});

describe("rate measurement", () => {
Expand Down
Loading
Loading