Skip to content

Commit c34237b

Browse files
feat(sdk): add kinesis over Bluetooth transport
Routes `neurosity.kinesis(label)` through the BLE transport whenever the active streaming mode is Bluetooth, giving offline / LAN-only clients feature parity with the cloud path. - `BluetoothClient` now multicasts the `kinesis` characteristic and exposes `kinesis(label?)` with cloud-equivalent label filtering. - `Neurosity.kinesis()` uses `_withStreamingModeObservable` so the BLE branch is taken when active, mirroring `focus`/`calm`/`accelerometer`. - Adds unit coverage for the BLE path at both layers (`BluetoothClient.kinesis` + `Neurosity.kinesis`). Note: `@neurosity/ipk@2.13.0` does not yet define a `kinesis` entry in `BLUETOOTH_CHARACTERISTICS`, so the BLE payload will remain inert at runtime until the firmware and IPK ship the characteristic UUID. The SDK-side routing is ready and will start emitting as soon as the IPK upgrade lands — no further SDK changes required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b1b79a commit c34237b

4 files changed

Lines changed: 472 additions & 6 deletions

File tree

src/Neurosity.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,7 +1395,14 @@ export class Neurosity {
13951395
}
13961396

13971397
/**
1398-
* <StreamingModes wifi={true} />
1398+
* <StreamingModes wifi={true} bluetooth={true} />
1399+
*
1400+
* Observes kinesis classification events for the given label.
1401+
*
1402+
* When the active streaming transport is Wi-Fi, events are routed through
1403+
* the cloud (Firebase). When the active transport is Bluetooth, events are
1404+
* streamed directly from the device over the `kinesis` BLE characteristic,
1405+
* so clients can receive classifications without a cloud roundtrip.
13991406
*
14001407
* @param label Name of metric properties to filter by
14011408
* @returns Observable of kinesis metric events
@@ -1413,10 +1420,14 @@ export class Neurosity {
14131420
return throwError(() => OAuthError);
14141421
}
14151422

1416-
return getCloudMetric(this._getCloudMetricDependencies(), {
1417-
metric,
1418-
labels: label ? [label] : [],
1419-
atomic: false
1423+
return this._withStreamingModeObservable({
1424+
wifi: () =>
1425+
getCloudMetric(this._getCloudMetricDependencies(), {
1426+
metric,
1427+
labels: label ? [label] : [],
1428+
atomic: false
1429+
}),
1430+
bluetooth: () => this.bluetoothClient.kinesis(label)
14201431
});
14211432
}
14221433

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { BehaviorSubject, Subject, Subscription, NEVER, of } from "rxjs";
2+
import { take, toArray } from "rxjs/operators";
3+
4+
import { BluetoothClient } from "../api/bluetooth/BluetoothClient";
5+
import {
6+
BLUETOOTH_CONNECTION,
7+
TRANSPORT_TYPE
8+
} from "../api/bluetooth/types";
9+
import { Kinesis } from "../types/kinesis";
10+
import { DeviceInfo } from "../types/deviceInfo";
11+
12+
/**
13+
* Builds a minimal fake BluetoothTransport that satisfies the surface of
14+
* `BluetoothClient` used by `kinesis()`. The real WebBluetoothTransport is
15+
* deliberately not instantiated here because its `_onDisconnected` wiring
16+
* requires a live GATT device.
17+
*/
18+
function createFakeTransport(kinesisFeed$: Subject<Kinesis>) {
19+
const subscribeToCharacteristic = jest
20+
.fn()
21+
.mockImplementation(({ characteristicName }) => {
22+
if (characteristicName === "kinesis") {
23+
return kinesisFeed$.asObservable();
24+
}
25+
return NEVER;
26+
});
27+
28+
return {
29+
type: TRANSPORT_TYPE.WEB,
30+
addLog: jest.fn(),
31+
logs$: new Subject<string>(),
32+
connection$: new BehaviorSubject<BLUETOOTH_CONNECTION>(
33+
BLUETOOTH_CONNECTION.CONNECTED
34+
),
35+
onDisconnected$: NEVER,
36+
connection: () => of(BLUETOOTH_CONNECTION.CONNECTED),
37+
connect: jest.fn(),
38+
disconnect: jest.fn(),
39+
readCharacteristic: jest.fn(),
40+
writeCharacteristic: jest.fn(),
41+
dispatchAction: jest.fn(),
42+
subscribeToCharacteristic,
43+
_autoConnect: () => NEVER,
44+
_autoToggleActionNotifications: () => NEVER,
45+
enableAutoConnect: jest.fn()
46+
} as any;
47+
}
48+
49+
describe("BluetoothClient.kinesis()", () => {
50+
let client: BluetoothClient;
51+
let kinesisFeed$: Subject<Kinesis>;
52+
let transport: any;
53+
54+
beforeEach(() => {
55+
kinesisFeed$ = new Subject<Kinesis>();
56+
transport = createFakeTransport(kinesisFeed$);
57+
58+
const selectedDevice$ = new BehaviorSubject<DeviceInfo | null>({
59+
deviceId: "test-device-id",
60+
deviceNickname: "Test",
61+
channelNames: ["CP3", "C3", "F5", "PO3", "PO4", "F6", "C4", "CP4"],
62+
channels: 8,
63+
samplingRate: 256,
64+
manufacturer: "Neurosity",
65+
model: "Crown",
66+
modelName: "Crown",
67+
modelVersion: "3",
68+
apiVersion: "1.0.0",
69+
osVersion: "16.0.0",
70+
emulator: false
71+
});
72+
const osHasBluetoothSupport$ = new BehaviorSubject<boolean>(true);
73+
74+
client = new BluetoothClient({
75+
transport,
76+
selectedDevice$,
77+
osHasBluetoothSupport$,
78+
createBluetoothToken: undefined as any
79+
});
80+
81+
// The constructor wires auto-authentication only when a
82+
// createBluetoothToken function is provided, so the authenticated state
83+
// must be pushed directly for the metric streams to become active.
84+
client.isAuthenticated$.next(true);
85+
});
86+
87+
it("subscribes to the `kinesis` BLE characteristic on first use", (done) => {
88+
const sub: Subscription = client
89+
.kinesis("leftHandPinch")
90+
.pipe(take(1))
91+
.subscribe({
92+
next: () => {
93+
expect(transport.subscribeToCharacteristic).toHaveBeenCalledWith(
94+
expect.objectContaining({ characteristicName: "kinesis" })
95+
);
96+
sub.unsubscribe();
97+
done();
98+
},
99+
error: done
100+
});
101+
102+
setImmediate(() =>
103+
kinesisFeed$.next({
104+
metric: "kinesis",
105+
label: "leftHandPinch",
106+
probability: 0.7,
107+
timestamp: 1
108+
})
109+
);
110+
});
111+
112+
it("filters events by label when provided", (done) => {
113+
const sub: Subscription = client
114+
.kinesis("leftHandPinch")
115+
.pipe(take(2), toArray())
116+
.subscribe({
117+
next: (received) => {
118+
expect(received.map((k) => k.probability)).toEqual([0.4, 0.9]);
119+
sub.unsubscribe();
120+
done();
121+
},
122+
error: done
123+
});
124+
125+
setImmediate(() => {
126+
kinesisFeed$.next({
127+
metric: "kinesis",
128+
label: "rightHandPinch",
129+
probability: 0.1,
130+
timestamp: 1
131+
});
132+
kinesisFeed$.next({
133+
metric: "kinesis",
134+
label: "leftHandPinch",
135+
probability: 0.4,
136+
timestamp: 2
137+
});
138+
kinesisFeed$.next({
139+
metric: "kinesis",
140+
label: "rightHandPinch",
141+
probability: 0.55,
142+
timestamp: 3
143+
});
144+
kinesisFeed$.next({
145+
metric: "kinesis",
146+
label: "leftHandPinch",
147+
probability: 0.9,
148+
timestamp: 4
149+
});
150+
});
151+
});
152+
153+
it("emits every event when no label is provided", (done) => {
154+
const sub: Subscription = client
155+
.kinesis()
156+
.pipe(take(3), toArray())
157+
.subscribe({
158+
next: (received) => {
159+
expect(received.map((k) => k.label)).toEqual([
160+
"leftHandPinch",
161+
"rightHandPinch",
162+
"tongue"
163+
]);
164+
sub.unsubscribe();
165+
done();
166+
},
167+
error: done
168+
});
169+
170+
setImmediate(() => {
171+
kinesisFeed$.next({
172+
metric: "kinesis",
173+
label: "leftHandPinch",
174+
probability: 0.1,
175+
timestamp: 1
176+
});
177+
kinesisFeed$.next({
178+
metric: "kinesis",
179+
label: "rightHandPinch",
180+
probability: 0.2,
181+
timestamp: 2
182+
});
183+
kinesisFeed$.next({
184+
metric: "kinesis",
185+
label: "tongue",
186+
probability: 0.3,
187+
timestamp: 3
188+
});
189+
});
190+
});
191+
});

0 commit comments

Comments
 (0)