buttplug-wasm@3.0.0 still declares buttplug@^4.0.2 as its dependency,
and v5 changed the OutputCmd wire shape (Value: number[] -> number) -
a real protocol difference, not just a type mismatch. Confirmed by
testing against real Lovense hardware that device control still works
in practice, so the bump stands; the risk is documented at the
client.connect() cast in case a future device/build doesn't fare as
well.
Also fixes a Map vs ReadonlyMap mismatch in the version-agnostic
feature-type extraction (v4 returns Map, v5 returns ReadonlyMap) that
surfaced while making this change, and adds a battery indicator to each
connected device's card - reads once on connect, refreshes every 60s,
using device.hasInput("Battery") to detect support.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
95 lines
3.8 KiB
TypeScript
95 lines
3.8 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. 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);
|
|
}
|
|
}
|