Files
sexy/lib/buttplug/commands.ts
T

95 lines
3.8 KiB
TypeScript
Raw Permalink Normal View History

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. Matched
// against ReadonlyMap (not Map) so this works whether `.features` returns a
// mutable Map (v4) or a ReadonlyMap (v5) - Map structurally extends ReadonlyMap.
type DeviceFeature = ButtplugClientDevice["features"] extends ReadonlyMap<number, infer F> ? 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<NormalizedOutputType, string[]> = {
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<Record<NormalizedOutputType, number>> = {};
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);
}
}