Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user