import type { ButtplugClientDevice, DeviceOutputCommand, OutputType } from "buttplug"; import type { ActuatorInfo, NormalizedOutputType } from "./types"; // buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself, // so its type is recovered from the `features` map it's stored in. type DeviceFeature = ButtplugClientDevice["features"] extends Map ? F : never; /** * The pieces of the dynamically-imported `buttplug` module namespace this * file needs at runtime. Kept as a parameter (rather than a static import of * `buttplug`'s runtime values) so this module has zero runtime dependency on * the browser-only client library and stays safe to reference from anywhere. * * Pinned to buttplug@4.x's API on purpose: `buttplug-wasm` (the embedded * Web Bluetooth connector) has not been updated for buttplug v5's breaking * OutputCmd wire-format change, so v4 is what's actually wire-compatible * with the WASM embedded server at runtime. */ export interface ButtplugRuntime { OutputType: typeof OutputType; DeviceOutput: { Vibrate: { percent(p: number): DeviceOutputCommand }; Rotate: { percent(p: number): DeviceOutputCommand }; Position: { percent(p: number): DeviceOutputCommand }; HwPositionWithDuration: { percent(p: number, durationMs: number): DeviceOutputCommand }; }; } const NORMALIZED_TO_OUTPUT: Record = { vibrate: ["Vibrate"], rotate: ["Rotate"], // A device may expose plain Position (no duration) or the // duration-bearing HwPositionWithDuration - prefer whichever it reports. linear: ["HwPositionWithDuration", "Position"], }; /** * Builds the actuator list for a device from its reported feature outputs. * buttplug@4's `ButtplugClientDeviceFeature` only exposes `hasOutput`/ * `hasInput`/`runOutput`/`runInput` (no `.index`/`.descriptor` getters, added * later in v5), so featureIndex comes from the `device.features` Map key and * the descriptor is a synthesized label, not the device's own string. */ export function deriveActuators(device: ButtplugClientDevice): ActuatorInfo[] { const actuators: ActuatorInfo[] = []; const countByType: Partial> = {}; for (const [featureIndex, feature] of device.features.entries()) { for (const [normalized, candidates] of Object.entries(NORMALIZED_TO_OUTPUT) as [ NormalizedOutputType, string[], ][]) { const matched = candidates.find((c) => feature.hasOutput(c as OutputType)); if (matched) { const n = (countByType[normalized] ?? 0) + 1; countByType[normalized] = n; actuators.push({ featureIndex, outputType: normalized, requiresDuration: matched === "HwPositionWithDuration", descriptor: `${normalized[0].toUpperCase()}${normalized.slice(1)} ${n}`, }); break; } } } return actuators; } export function findFeature(device: ButtplugClientDevice, featureIndex: number): DeviceFeature | undefined { return device.features.get(featureIndex); } /** Builds the DeviceOutputCommand for a normalized 0-1 command value. */ export function buildOutputCommand( bp: ButtplugRuntime, actuator: ActuatorInfo, value: number, durationMs?: number, ): DeviceOutputCommand { const clamped = Math.min(1, Math.max(0, value)); switch (actuator.outputType) { case "vibrate": return bp.DeviceOutput.Vibrate.percent(clamped); case "rotate": return bp.DeviceOutput.Rotate.percent(clamped); case "linear": return actuator.requiresDuration ? bp.DeviceOutput.HwPositionWithDuration.percent(clamped, durationMs ?? 500) : bp.DeviceOutput.Position.percent(clamped); } }