Skip to content

Commit 8cd5fc4

Browse files
daiverdclaude
andcommitted
Honor server-specified duration for Vibrate haptics commands
GamepadBackend and ButtplugWasmBackend previously ignored `duration` on continuous (non-Position) actuator commands, so actuators ran until the HapticsService auto-stop timer (default 30s) kicked in. Both backends now schedule a per-actuator setTimeout to stop at the given ms; a new actuate replaces the prior timer, and stop/disconnect/emergencyStop clear them. Also makes ButtplugWasmBackend.stop() a no-op when not connected (and for unknown actuator IDs), matching HapticsService's own tolerance. This fixes the `Uncaught (in promise) Error: ButtplugWasmBackend: not connected` that fired every time the auto-stop timer ran against a registered-but-not-connected WASM backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7596290 commit 8cd5fc4

3 files changed

Lines changed: 90 additions & 9 deletions

File tree

src/haptics/ButtplugWasmBackend.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,8 @@ describe("ButtplugWasmBackend", () => {
459459
expect(latestMockClient.stopAllDevices).toHaveBeenCalled();
460460
});
461461

462-
it("throws for an unknown actuator ID", async () => {
463-
await expect(backend.stop(9999)).rejects.toThrow("unknown actuator 9999");
462+
it("silently ignores an unknown actuator ID", async () => {
463+
await expect(backend.stop(9999)).resolves.toBeUndefined();
464464
});
465465
});
466466

src/haptics/ButtplugWasmBackend.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ export class ButtplugWasmBackend
234234
private sensorMap = new Map<number, SensorMapping>();
235235
/** Maps sensor ID -> polling interval handle */
236236
private sensorSubscriptions = new Map<number, ReturnType<typeof setInterval>>();
237+
/** Maps actuator ID -> pending auto-stop timer (for server-specified durations) */
238+
private actuatorStopTimers = new Map<number, ReturnType<typeof setTimeout>>();
237239

238240
constructor(deps: ButtplugWasmDeps) {
239241
super();
@@ -282,6 +284,9 @@ export class ButtplugWasmBackend
282284
async disconnect(): Promise<void> {
283285
if (!this.client) return;
284286

287+
// Cancel any pending auto-stop timers
288+
this.clearAllActuatorStopTimers();
289+
285290
// Stop all sensor subscriptions
286291
for (const [id] of this.sensorSubscriptions) {
287292
this.unsubscribeSensor(id);
@@ -373,6 +378,9 @@ export class ButtplugWasmBackend
373378

374379
const { device, featureIndex, actuatorType } = mapping;
375380

381+
// Any new command cancels a pending duration-driven auto-stop
382+
this.clearActuatorStopTimer(actuatorId);
383+
376384
// Route commands through the appropriate v3 API:
377385
// - Scalar types (Vibrate, Oscillate, Constrict, Inflate) use device.scalar()
378386
// with explicit Index and ActuatorType to target exact features
@@ -401,19 +409,50 @@ export class ButtplugWasmBackend
401409
break;
402410
}
403411
}
412+
413+
// For continuous (non-Position) actuators, honor server-specified duration
414+
// by scheduling a stop after the given ms. Position encodes duration as the
415+
// movement time, so it's excluded here.
416+
if (
417+
type !== "Position" &&
418+
options?.duration !== undefined &&
419+
options.duration > 0 &&
420+
intensity > 0
421+
) {
422+
const handle = setTimeout(() => {
423+
this.actuatorStopTimers.delete(actuatorId);
424+
// Best-effort stop; swallow errors if device is gone
425+
void device.stop().catch(() => {});
426+
}, options.duration);
427+
this.actuatorStopTimers.set(actuatorId, handle);
428+
}
429+
}
430+
431+
private clearActuatorStopTimer(actuatorId: number): void {
432+
const t = this.actuatorStopTimers.get(actuatorId);
433+
if (t !== undefined) {
434+
clearTimeout(t);
435+
this.actuatorStopTimers.delete(actuatorId);
436+
}
437+
}
438+
439+
private clearAllActuatorStopTimers(): void {
440+
for (const t of this.actuatorStopTimers.values()) {
441+
clearTimeout(t);
442+
}
443+
this.actuatorStopTimers.clear();
404444
}
405445

406446
async stop(actuatorId?: number): Promise<void> {
407447
if (actuatorId != null) {
448+
this.clearActuatorStopTimer(actuatorId);
408449
const mapping = this.actuatorMap.get(actuatorId);
409-
if (!mapping) {
410-
throw new Error(`ButtplugWasmBackend: unknown actuator ${actuatorId}`);
411-
}
450+
if (!mapping) return;
412451
await mapping.device.stop();
413452
} else {
414-
if (!this.client) {
415-
throw new Error("ButtplugWasmBackend: not connected");
416-
}
453+
this.clearAllActuatorStopTimers();
454+
// No client yet (lazy-registered backend not connected) → nothing to stop
455+
if (!this.client) return;
417456
await this.client.stopAllDevices();
418457
}
419458
}
@@ -471,6 +510,7 @@ export class ButtplugWasmBackend
471510
// --- Safety ---
472511

473512
async emergencyStop(): Promise<void> {
513+
this.clearAllActuatorStopTimers();
474514
if (!this.client) return;
475515
await this.client.stopAllDevices();
476516
}

src/haptics/GamepadBackend.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ interface TrackedGamepad {
2929
intensities: [number, number, number, number];
3030
// The setInterval ID for the vibration loop
3131
loopInterval: ReturnType<typeof setInterval> | null;
32+
// Per-motor auto-stop timers (when server specifies duration)
33+
stopTimers: [
34+
ReturnType<typeof setTimeout> | null,
35+
ReturnType<typeof setTimeout> | null,
36+
ReturnType<typeof setTimeout> | null,
37+
ReturnType<typeof setTimeout> | null,
38+
];
3239
}
3340

3441
export class GamepadBackend
@@ -142,7 +149,7 @@ export class GamepadBackend
142149
actuatorId: number,
143150
type: HapticsActuatorType,
144151
intensity: number,
145-
_options?: { duration?: number; clockwise?: boolean }
152+
options?: { duration?: number; clockwise?: boolean }
146153
): Promise<void> {
147154
if (type !== "Vibrate") return;
148155

@@ -160,13 +167,29 @@ export class GamepadBackend
160167

161168
// Start the vibration loop if not already running and any intensity > 0
162169
this.ensureLoop(tracked);
170+
171+
// A new command cancels any previous duration timer for this motor
172+
this.clearStopTimer(tracked, motorOffset);
173+
174+
// If server specified a duration (ms), schedule this motor to stop
175+
if (options?.duration !== undefined && options.duration > 0) {
176+
tracked.stopTimers[motorOffset] = setTimeout(() => {
177+
tracked.stopTimers[motorOffset] = null;
178+
tracked.intensities[motorOffset] = 0;
179+
if (tracked.intensities.every((v) => v === 0)) {
180+
this.clearLoop(tracked);
181+
void this.resetGamepad(tracked.gamepadIndex);
182+
}
183+
}, options.duration);
184+
}
163185
}
164186

165187
async stop(actuatorId?: number): Promise<void> {
166188
if (actuatorId === undefined) {
167189
// Stop all motors on all gamepads
168190
for (const tracked of this.gamepads.values()) {
169191
tracked.intensities = [0, 0, 0, 0];
192+
this.clearAllStopTimers(tracked);
170193
this.clearLoop(tracked);
171194
await this.resetGamepad(tracked.gamepadIndex);
172195
}
@@ -177,6 +200,7 @@ export class GamepadBackend
177200
if (!tracked) return;
178201

179202
tracked.intensities[motorOffset] = 0;
203+
this.clearStopTimer(tracked, motorOffset);
180204

181205
// If all intensities are zero, stop the loop and reset
182206
if (tracked.intensities.every((v) => v === 0)) {
@@ -202,6 +226,7 @@ export class GamepadBackend
202226
// Immediately clear all intervals and reset all gamepads
203227
for (const tracked of this.gamepads.values()) {
204228
tracked.intensities = [0, 0, 0, 0];
229+
this.clearAllStopTimers(tracked);
205230
this.clearLoop(tracked);
206231
await this.resetGamepad(tracked.gamepadIndex);
207232
}
@@ -244,6 +269,7 @@ export class GamepadBackend
244269
hasTriggerRumble,
245270
intensities: [0, 0, 0, 0],
246271
loopInterval: null,
272+
stopTimers: [null, null, null, null],
247273
});
248274

249275
this.emit("devicechanged");
@@ -255,11 +281,26 @@ export class GamepadBackend
255281
if (!tracked) return;
256282

257283
tracked.intensities = [0, 0, 0, 0];
284+
this.clearAllStopTimers(tracked);
258285
this.clearLoop(tracked);
259286
this.gamepads.delete(gamepadIndex);
260287
this.emit("devicechanged");
261288
}
262289

290+
private clearStopTimer(tracked: TrackedGamepad, motorOffset: number): void {
291+
const t = tracked.stopTimers[motorOffset];
292+
if (t !== null) {
293+
clearTimeout(t);
294+
tracked.stopTimers[motorOffset] = null;
295+
}
296+
}
297+
298+
private clearAllStopTimers(tracked: TrackedGamepad): void {
299+
for (let i = 0; i < 4; i++) {
300+
this.clearStopTimer(tracked, i);
301+
}
302+
}
303+
263304
private ensureLoop(tracked: TrackedGamepad): void {
264305
// If all zero, no need for a loop
265306
if (tracked.intensities.every((v) => v === 0)) {

0 commit comments

Comments
 (0)