93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
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<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);
|
||
|
|
}
|
||
|
|
}
|