ive-connect
Version:
A universal haptic device control library for interactive experiences
502 lines (501 loc) • 20.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HandyDevice = void 0;
/**
* Handy Device Implementation
*/
const device_interface_1 = require("../../core/device-interface");
const events_1 = require("../../core/events");
const handy_api_1 = require("./handy-api");
/**
* Default Handy configuration
*/
const DEFAULT_CONFIG = {
id: "handy",
name: "Handy",
connectionKey: "",
enabled: true,
offset: 0,
stroke: {
min: 0,
max: 1,
},
};
/**
* Handy device implementation
*/
class HandyDevice extends events_1.EventEmitter {
/**
* Create a new Handy device instance
* @param config Optional configuration
*/
constructor(config) {
super();
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this._deviceInfo = null;
this._isPlaying = false;
this._eventSource = null;
this.id = "handy";
this.name = "Handy";
this.type = "handy";
this.capabilities = [
device_interface_1.DeviceCapability.LINEAR,
device_interface_1.DeviceCapability.STROKE,
];
this._config = { ...DEFAULT_CONFIG };
// Set up configuration
if (config === null || config === void 0 ? void 0 : config.connectionKey) {
this._config.connectionKey = config.connectionKey;
}
// Create the API client
this._api = (0, handy_api_1.createHandyApi)((config === null || config === void 0 ? void 0 : config.baseV3Url) || "https://www.handyfeeling.com/api/handy-rest/v3", (config === null || config === void 0 ? void 0 : config.baseV2Url) || "https://www.handyfeeling.com/api/hosting/v2", (config === null || config === void 0 ? void 0 : config.applicationId) || "12345", this._config.connectionKey);
}
/**
* Get device connection state
*/
get isConnected() {
return this._connectionState === device_interface_1.ConnectionState.CONNECTED;
}
/**
* Get device playback state
*/
get isPlaying() {
return this._isPlaying;
}
/**
* Connect to the device
* @param config Optional configuration override
*/
async connect(config) {
try {
// Update config if provided
if (config) {
this.updateConfig(config);
}
// Validate connection key
if (!this._config.connectionKey ||
this._config.connectionKey.length < 5) {
this.emit("error", "Connection key must be at least 5 characters");
return false;
}
// Update connection state
this._connectionState = device_interface_1.ConnectionState.CONNECTING;
this.emit("connectionStateChanged", this._connectionState);
// Synchronize time
await this._api.syncServerTime();
// Create event source for server-sent events
this._eventSource = this._api.createEventSource();
// Set up event handlers
this._setupEventHandlers();
// Get initial device info
const isConnected = await this._api.isConnected();
if (isConnected) {
this._deviceInfo = await this._api.getDeviceInfo();
this._connectionState = device_interface_1.ConnectionState.CONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("connected", this._deviceInfo);
// Get device settings after connection
await this._loadDeviceSettings();
return true;
}
else {
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("error", "Failed to connect to device");
return false;
}
}
catch (error) {
console.error("Handy: Error connecting to device:", error);
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("error", `Connection error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
/**
* Disconnect from the device
*/
async disconnect() {
try {
// Stop playback if active
if (this._isPlaying) {
await this.stop();
}
// Close event source
if (this._eventSource) {
this._eventSource.close();
this._eventSource = null;
}
// Update state immediately
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this._deviceInfo = null;
this._isPlaying = false;
// Emit events
this.emit("connectionStateChanged", this._connectionState);
this.emit("disconnected");
// Return true regardless of what happens with the API
return true;
}
catch (error) {
console.error("Handy: Error disconnecting device:", error);
// Set disconnected state even in case of error
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this._deviceInfo = null;
this._isPlaying = false;
// Emit events
this.emit("connectionStateChanged", this._connectionState);
this.emit("disconnected");
return true; // Return true anyway for better UX
}
}
/**
* Get current device configuration
*/
getConfig() {
return { ...this._config };
}
/**
* Update device configuration
* @param config Partial configuration to update
*/
async updateConfig(config) {
// Update local config
if (config.connectionKey !== undefined) {
this._config.connectionKey = config.connectionKey;
this._api.setConnectionKey(config.connectionKey);
}
// Update offset if connected
if (config.offset !== undefined && this.isConnected) {
this._config.offset = config.offset;
await this._api.setOffset(config.offset);
}
else if (config.offset !== undefined) {
this._config.offset = config.offset;
}
// Update stroke settings if connected
if (config.stroke !== undefined && this.isConnected) {
this._config.stroke = { ...this._config.stroke, ...config.stroke };
await this._api.setStrokeSettings(this._config.stroke);
}
else if (config.stroke !== undefined) {
this._config.stroke = { ...this._config.stroke, ...config.stroke };
}
// Update other fields if present
if (config.name !== undefined) {
this._config.name = config.name;
}
if (config.enabled !== undefined) {
this._config.enabled = config.enabled;
}
// Emit configuration changed event
this.emit("configChanged", this._config);
return true;
}
/**
* Load a script for playback
* @param scriptData Script data to load
*/
async loadScript(scriptData, options = { invertScript: false }) {
if (!this.isConnected) {
this.emit("error", "Cannot load script: Device not connected");
return { success: false };
}
try {
let scriptContent;
let scriptUrl;
if (scriptData.url) {
if (scriptData.url.toLowerCase().endsWith(".funscript") ||
scriptData.type === "funscript") {
try {
const response = await fetch(scriptData.url);
if (!response.ok) {
throw new Error(`Failed to fetch funscript: ${response.status}`);
}
scriptContent = await response.json();
if (options.invertScript && scriptContent.actions) {
scriptContent.actions = scriptContent.actions.map((action) => ({
...action,
pos: 100 - action.pos,
}));
}
const blob = new Blob([JSON.stringify(scriptContent)], {
type: "application/json",
});
const uploadedUrl = await this._api.uploadScript(blob);
if (!uploadedUrl) {
throw new Error("Failed to upload funscript");
}
scriptUrl = uploadedUrl;
}
catch (error) {
console.error("Error processing funscript URL:", error);
scriptUrl = scriptData.url;
}
}
else {
try {
const response = await fetch(scriptData.url);
if (!response.ok) {
throw new Error(`Failed to fetch script: ${response.status}`);
}
const fileExtension = scriptData.url.toLowerCase().split(".").pop();
if (fileExtension === "csv") {
let csvText = await response.text();
// Apply inversion directly to CSV data
if (options.invertScript) {
const lines = csvText.split("\n");
const hasHeader = isNaN(parseFloat(lines[0].split(",")[0]));
const startIndex = hasHeader ? 1 : 0;
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line)
continue;
const columns = line.split(",");
if (columns.length >= 2) {
const pos = parseFloat(columns[1].trim());
if (!isNaN(pos)) {
columns[1] = (100 - pos).toString();
lines[i] = columns.join(",");
}
}
}
csvText = lines.join("\n");
}
const blob = new Blob([csvText], {
type: "text/csv",
});
const uploadedUrl = await this._api.uploadScript(blob);
if (!uploadedUrl) {
throw new Error("Failed to upload CSV script");
}
scriptUrl = uploadedUrl;
}
else {
// For other file types, use URL directly
scriptUrl = scriptData.url;
}
}
catch (error) {
console.error("Error processing script URL:", error);
scriptUrl = scriptData.url;
}
}
}
else if (scriptData.content) {
scriptContent = { ...scriptData.content };
if (options.invertScript && scriptContent.actions) {
scriptContent.actions = scriptContent.actions.map((action) => ({
...action,
pos: 100 - action.pos,
}));
}
const blob = new Blob([JSON.stringify(scriptContent)], {
type: "application/json",
});
const uploadedUrl = await this._api.uploadScript(blob);
if (!uploadedUrl) {
this.emit("error", "Failed to upload script");
return { success: false };
}
scriptUrl = uploadedUrl;
}
else {
this.emit("error", "Invalid script data: Either URL or content must be provided");
return { success: false };
}
const success = await this._api.setupScript(scriptUrl);
if (success) {
this.emit("scriptLoaded", {
url: scriptUrl,
options,
});
return { success: true };
}
else {
this.emit("error", "Failed to set up script with device");
return { success: false };
}
}
catch (error) {
console.error("Handy: Error loading script:", error);
this.emit("error", `Script loading error: ${error instanceof Error ? error.message : String(error)}`);
return { success: false };
}
}
/**
* Play the loaded script at the specified time
* @param timeMs Current time in milliseconds
* @param playbackRate Playback rate (1.0 = normal speed)
* @param loop Whether to loop the script
*/
async play(timeMs, playbackRate = 1.0, loop = false) {
if (!this.isConnected) {
this.emit("error", "Cannot play: Device not connected");
return false;
}
try {
const hspState = await this._api.play(timeMs, playbackRate, loop);
if (hspState) {
this._isPlaying =
hspState.play_state === 1 || hspState.play_state === "1";
this.emit("playbackStateChanged", {
isPlaying: this._isPlaying,
timeMs,
playbackRate,
loop,
});
return true;
}
else {
this.emit("error", "Failed to start playback");
return false;
}
}
catch (error) {
console.error("Handy: Error playing script:", error);
this.emit("error", `Playback error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
/**
* Stop playback
*/
async stop() {
if (!this.isConnected) {
this.emit("error", "Cannot stop: Device not connected");
return false;
}
try {
const hspState = await this._api.stop();
if (hspState) {
this._isPlaying =
hspState.play_state === 1 || hspState.play_state === "1";
this.emit("playbackStateChanged", {
isPlaying: this._isPlaying,
});
return true;
}
else {
this.emit("error", "Failed to stop playback");
return false;
}
}
catch (error) {
console.error("Handy: Error stopping script:", error);
this.emit("error", `Stop playback error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
/**
* Synchronize device time with provided time
* @param timeMs Current time in milliseconds
*/
async syncTime(timeMs, filter = 0.5) {
if (!this.isConnected || !this._isPlaying) {
return false;
}
try {
return await this._api.syncVideoTime(timeMs, filter);
}
catch (error) {
console.error("Handy: Error syncing time:", error);
return false;
}
}
/**
* Get device-specific information
*/
getDeviceInfo() {
if (!this._deviceInfo)
return null;
return {
id: this.id,
name: this.name,
type: this.type,
firmware: this._deviceInfo.fw_version,
hardware: this._deviceInfo.hw_model_name,
sessionId: this._deviceInfo.session_id,
...this._deviceInfo,
};
}
/**
* Set up event handlers for the device
*/
_setupEventHandlers() {
if (!this._eventSource)
return;
this._eventSource.onerror = (error) => {
console.error("EventSource error:", error);
this.emit("error", "Connection to device lost");
};
this._eventSource.addEventListener("device_status", (event) => {
const data = JSON.parse(event.data);
this._deviceInfo = data.data.info;
const connected = data.data.connected;
if (connected && this._connectionState !== device_interface_1.ConnectionState.CONNECTED) {
this._connectionState = device_interface_1.ConnectionState.CONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("connected", this._deviceInfo);
}
else if (!connected &&
this._connectionState === device_interface_1.ConnectionState.CONNECTED) {
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("disconnected");
}
});
this._eventSource.addEventListener("device_connected", (event) => {
const data = JSON.parse(event.data);
this._deviceInfo = data.data.info;
this._connectionState = device_interface_1.ConnectionState.CONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("connected", this._deviceInfo);
});
this._eventSource.addEventListener("device_disconnected", (event) => {
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this.emit("connectionStateChanged", this._connectionState);
this.emit("disconnected");
});
this._eventSource.addEventListener("mode_changed", (event) => {
this._isPlaying = false;
this.emit("playbackStateChanged", { isPlaying: false });
});
this._eventSource.addEventListener("hsp_state_changed", (event) => {
var _a, _b;
const data = JSON.parse(event.data);
// Set isPlaying based on play_state
this._isPlaying =
((_a = data.data.data) === null || _a === void 0 ? void 0 : _a.play_state) === 1 || ((_b = data.data.data) === null || _b === void 0 ? void 0 : _b.play_state) === "1";
this.emit("playbackStateChanged", { isPlaying: this._isPlaying });
});
}
/**
* Load device settings after connection
*/
async _loadDeviceSettings() {
if (!this.isConnected)
return;
try {
// Get device offset
const offset = await this._api.getOffset();
if (offset !== undefined) {
this._config.offset = offset;
}
// Get stroke settings
const strokeSettings = await this._api.getStrokeSettings();
if (strokeSettings) {
this._config.stroke = {
min: strokeSettings.min,
max: strokeSettings.max,
};
}
// Emit config updated event
this.emit("configChanged", this._config);
}
catch (error) {
console.error("Handy: Error loading device settings:", error);
}
}
}
exports.HandyDevice = HandyDevice;