2026-08-25 17:33:48 +02:00
|
|
|
import type { ButtplugClient, ButtplugClientDevice, InputType } from "buttplug";
|
2026-08-25 07:51:38 +02:00
|
|
|
import { deriveActuators, type ButtplugRuntime } from "./commands";
|
|
|
|
|
import { useButtplugStore } from "./store";
|
|
|
|
|
import type { ConnectedDeviceInfo } from "./types";
|
|
|
|
|
|
|
|
|
|
export function isWebBluetoothSupported(): boolean {
|
|
|
|
|
return typeof navigator !== "undefined" && "bluetooth" in navigator;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ClientHandle {
|
|
|
|
|
client: ButtplugClient;
|
|
|
|
|
runtime: ButtplugRuntime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let clientPromise: Promise<ClientHandle> | null = null;
|
|
|
|
|
|
|
|
|
|
function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo {
|
|
|
|
|
return {
|
|
|
|
|
index: device.index,
|
|
|
|
|
name: device.name,
|
|
|
|
|
displayName: device.displayName,
|
|
|
|
|
actuators: deriveActuators(device),
|
2026-08-25 17:33:48 +02:00
|
|
|
// "Battery" is a string enum value - passing the literal avoids needing
|
|
|
|
|
// InputType as a runtime import here (see commands.ts for the same trick).
|
|
|
|
|
hasBattery: device.hasInput("Battery" as InputType),
|
2026-08-25 07:51:38 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function initClient(): Promise<ClientHandle> {
|
|
|
|
|
const [{ ButtplugClient, DeviceOutput, OutputType }, { ButtplugWasmClientConnector }] = await Promise.all([
|
|
|
|
|
import("buttplug"),
|
|
|
|
|
import("buttplug-wasm"),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const client = new ButtplugClient("sexy");
|
|
|
|
|
|
|
|
|
|
client.on("deviceadded", (device: ButtplugClientDevice) => {
|
|
|
|
|
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
|
|
|
|
|
});
|
|
|
|
|
client.on("deviceremoved", (device: ButtplugClientDevice) => {
|
|
|
|
|
useButtplugStore.getState().removeDevice(device.index);
|
|
|
|
|
});
|
|
|
|
|
client.on("scanningfinished", () => {
|
|
|
|
|
useButtplugStore.getState().setScanning(false);
|
|
|
|
|
});
|
|
|
|
|
client.on("disconnect", () => {
|
|
|
|
|
useButtplugStore.getState().reset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const connector = new ButtplugWasmClientConnector();
|
2026-08-25 17:33:48 +02:00
|
|
|
// EXPERIMENTAL: buttplug-wasm@3.0.0's embedded server was built against
|
|
|
|
|
// buttplug@^4.0.2's wire format; buttplug@5's OutputCmd message shape
|
|
|
|
|
// changed (`Value` went from number[] to a bare number), which is a real
|
|
|
|
|
// protocol break, not just a type mismatch - the cast below silences that
|
|
|
|
|
// signal rather than fixing it. Scanning/pairing may still work since
|
|
|
|
|
// DeviceAdded's shape didn't change; device *commands* may be silently
|
|
|
|
|
// rejected or mis-parsed server-side. Revert to buttplug@^4.0.2 if so.
|
|
|
|
|
await client.connect(connector as unknown as Parameters<typeof client.connect>[0]);
|
2026-08-25 07:51:38 +02:00
|
|
|
|
|
|
|
|
useButtplugStore.getState().setConnected(true);
|
|
|
|
|
for (const device of client.devices.values()) {
|
|
|
|
|
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { client, runtime: { DeviceOutput, OutputType } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Lazily creates and connects the singleton Buttplug client. Browser-only. */
|
|
|
|
|
export function getButtplugClientHandle(): Promise<ClientHandle> {
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
return Promise.reject(new Error("Buttplug client is browser-only"));
|
|
|
|
|
}
|
|
|
|
|
if (!clientPromise) {
|
|
|
|
|
clientPromise = initClient().catch((err) => {
|
|
|
|
|
clientPromise = null;
|
|
|
|
|
useButtplugStore.getState().setError(err instanceof Error ? err.message : String(err));
|
|
|
|
|
throw err;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return clientPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function startScanning(): Promise<void> {
|
|
|
|
|
const { client } = await getButtplugClientHandle();
|
|
|
|
|
useButtplugStore.getState().setScanning(true);
|
|
|
|
|
await client.startScanning();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function stopScanning(): Promise<void> {
|
|
|
|
|
const { client } = await getButtplugClientHandle();
|
|
|
|
|
await client.stopScanning();
|
|
|
|
|
useButtplugStore.getState().setScanning(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function disconnectAll(): Promise<void> {
|
|
|
|
|
if (!clientPromise) return;
|
|
|
|
|
const { client } = await clientPromise;
|
|
|
|
|
clientPromise = null;
|
|
|
|
|
await client.disconnect();
|
|
|
|
|
useButtplugStore.getState().reset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getDevice(deviceIndex: number): Promise<ButtplugClientDevice | undefined> {
|
|
|
|
|
const { client } = await getButtplugClientHandle();
|
|
|
|
|
return client.devices.get(deviceIndex);
|
|
|
|
|
}
|
2026-08-25 17:33:48 +02:00
|
|
|
|
|
|
|
|
/** Reads the current battery level (0-1), or null if unsupported/unreadable. */
|
|
|
|
|
export async function getBatteryLevel(deviceIndex: number): Promise<number | null> {
|
|
|
|
|
const device = await getDevice(deviceIndex);
|
|
|
|
|
if (!device || !device.hasInput("Battery" as InputType)) return null;
|
|
|
|
|
try {
|
|
|
|
|
return await device.battery();
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|