ive-connect
Version:
A universal haptic device control library for interactive experiences
766 lines (765 loc) • 26.9 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");
const types_1 = require("./types");
/**
* 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._scriptPrepared = false;
// HSP state tracking
this._hspState = null;
this._hspStreamIndex = 0;
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 the API instance for direct access
*/
get api() {
return this._api;
}
/**
* Get device connection state
*/
get isConnected() {
return this._connectionState === device_interface_1.ConnectionState.CONNECTED;
}
/**
* Get device playback state
*/
get isPlaying() {
return this._isPlaying;
}
/**
* Get current HSP state
*/
get hspState() {
return this._hspState;
}
/**
* 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;
this._hspState = null;
this._scriptPrepared = false;
// Emit events
this.emit('connectionStateChanged', this._connectionState);
this.emit('disconnected');
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;
}
}
/**
* 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;
}
/**
* Prepare a script for playback (upload to Handy server)
* The funscript is already parsed - we just need to upload it
*
* @param funscript The parsed funscript content
* @param _options Script options (inversion already applied by DeviceManager)
*/
async prepareScript(funscript) {
if (!this.isConnected) {
return { success: false, error: 'Device not connected' };
}
try {
// Convert funscript to blob and upload
const blob = new Blob([JSON.stringify(funscript)], {
type: 'application/json',
});
const uploadedUrl = await this._api.uploadScript(blob);
if (!uploadedUrl) {
return {
success: false,
error: 'Failed to upload script to Handy server',
};
}
// Setup the script on the device
const success = await this._api.setupScript(uploadedUrl);
if (success) {
this._scriptPrepared = true;
this.emit('scriptLoaded', {
url: uploadedUrl,
actions: funscript.actions.length,
});
return { success: true };
}
else {
return { success: false, error: 'Failed to setup script on device' };
}
}
catch (error) {
console.error('Handy: Error preparing script:', error);
return {
success: false,
error: `Script preparation error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Play the loaded script at the specified time (HSSP)
* @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;
}
if (!this._scriptPrepared) {
this.emit('error', 'Cannot play: No script prepared');
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 (works for both HSSP and HSP)
*/
async stop() {
if (!this.isConnected) {
this.emit('error', 'Cannot stop: Device not connected');
return false;
}
try {
// Try HSP stop first, then HSSP stop
let hspState = await this._api.hspStop();
if (!hspState) {
hspState = await this._api.stop();
}
if (hspState) {
this._isPlaying =
hspState.play_state === types_1.HspPlayState.PLAYING ||
hspState.play_state === 1 ||
hspState.play_state === '1';
this._hspState = hspState;
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:', error);
this.emit('error', `Stop 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,
};
}
// ============================================
// HSP (Handy Streaming Protocol) Methods
// ============================================
/**
* Initialize a new HSP session.
* This clears any existing session state and prepares the device for streaming.
* @param streamId Optional custom stream ID
*/
async hspSetup(streamId) {
if (!this.isConnected) {
this.emit('error', 'Cannot setup HSP: Device not connected');
return null;
}
try {
const state = await this._api.hspSetup(streamId);
if (state) {
this._hspState = state;
this._hspStreamIndex = 0;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error setting up HSP:', error);
this.emit('error', `HSP setup error: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Get the current HSP state from the device
*/
async hspGetState() {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspGetState();
if (state) {
this._hspState = state;
}
return state;
}
catch (error) {
console.error('Handy: Error getting HSP state:', error);
return null;
}
}
/**
* Add points to the HSP buffer.
* Points are {t, x} where:
* - t: timestamp in ms relative to start (t=0)
* - x: position 0-100 (0=bottom, 100=top)
*
* @param points Array of points to add (max 100 per call)
* @param flush If true, clear buffer before adding
*/
async hspAddPoints(points, flush = false) {
if (!this.isConnected) {
this.emit('error', 'Cannot add HSP points: Device not connected');
return null;
}
try {
// Update stream index
this._hspStreamIndex += points.length;
const state = await this._api.hspAddPoints(points, this._hspStreamIndex, flush);
if (state) {
this._hspState = state;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error adding HSP points:', error);
this.emit('error', `HSP add points error: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Start HSP playback
* @param startTime Time to start from (in ms, relative to points)
* @param options Playback options
*/
async hspPlay(startTime = 0, options = {}) {
var _a, _b;
if (!this.isConnected) {
this.emit('error', 'Cannot start HSP: Device not connected');
return null;
}
try {
const state = await this._api.hspPlay(startTime, options);
if (state) {
this._hspState = state;
this._isPlaying =
state.play_state === types_1.HspPlayState.PLAYING ||
state.play_state === 1 ||
state.play_state === '1';
this.emit('hspStateChanged', state);
this.emit('playbackStateChanged', {
isPlaying: this._isPlaying,
timeMs: startTime,
playbackRate: (_a = options.playbackRate) !== null && _a !== void 0 ? _a : 1.0,
loop: (_b = options.loop) !== null && _b !== void 0 ? _b : false,
});
}
return state;
}
catch (error) {
console.error('Handy: Error starting HSP playback:', error);
this.emit('error', `HSP play error: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Stop HSP playback
*/
async hspStop() {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspStop();
if (state) {
this._hspState = state;
this._isPlaying = false;
this.emit('hspStateChanged', state);
this.emit('playbackStateChanged', { isPlaying: false });
}
return state;
}
catch (error) {
console.error('Handy: Error stopping HSP:', error);
return null;
}
}
/**
* Pause HSP playback
*/
async hspPause() {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspPause();
if (state) {
this._hspState = state;
this._isPlaying = false;
this.emit('hspStateChanged', state);
this.emit('playbackStateChanged', { isPlaying: false });
}
return state;
}
catch (error) {
console.error('Handy: Error pausing HSP:', error);
return null;
}
}
/**
* Resume HSP playback
* @param pickUp If true, resumes from current live position. If false, from paused position.
*/
async hspResume(pickUp = false) {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspResume(pickUp);
if (state) {
this._hspState = state;
this._isPlaying =
state.play_state === types_1.HspPlayState.PLAYING ||
state.play_state === 1 ||
state.play_state === '1';
this.emit('hspStateChanged', state);
this.emit('playbackStateChanged', { isPlaying: this._isPlaying });
}
return state;
}
catch (error) {
console.error('Handy: Error resuming HSP:', error);
return null;
}
}
/**
* Flush the HSP buffer (remove all points)
*/
async hspFlush() {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspFlush();
if (state) {
this._hspState = state;
this._hspStreamIndex = 0;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error flushing HSP:', error);
return null;
}
}
/**
* Set HSP loop mode
*/
async hspSetLoop(loop) {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspSetLoop(loop);
if (state) {
this._hspState = state;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error setting HSP loop:', error);
return null;
}
}
/**
* Set HSP playback rate
*/
async hspSetPlaybackRate(rate) {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspSetPlaybackRate(rate);
if (state) {
this._hspState = state;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error setting HSP playback rate:', error);
return null;
}
}
/**
* Sync HSP time with external source
*/
async hspSyncTime(currentTime, filter = 0.5) {
if (!this.isConnected || !this._isPlaying) {
return null;
}
try {
const state = await this._api.hspSyncTime(currentTime, filter);
if (state) {
this._hspState = state;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error syncing HSP time:', error);
return null;
}
}
/**
* Set pause-on-starving flag
* When enabled, device clock pauses when buffer runs out and resumes when points are added
*/
async hspSetPauseOnStarving(pause) {
if (!this.isConnected) {
return null;
}
try {
const state = await this._api.hspSetPauseOnStarving(pause);
if (state) {
this._hspState = state;
this.emit('hspStateChanged', state);
}
return state;
}
catch (error) {
console.error('Handy: Error setting pause on starving:', error);
return null;
}
}
/**
* Get the current stream index for tracking
*/
getHspStreamIndex() {
return this._hspStreamIndex;
}
/**
* Reset the stream index (call after hspSetup)
*/
resetHspStreamIndex() {
this._hspStreamIndex = 0;
}
// ============================================
// Private Methods
// ============================================
/**
* 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', () => {
this._connectionState = device_interface_1.ConnectionState.DISCONNECTED;
this.emit('connectionStateChanged', this._connectionState);
this.emit('disconnected');
});
this._eventSource.addEventListener('mode_changed', () => {
this._isPlaying = false;
this.emit('playbackStateChanged', { isPlaying: false });
});
// HSP-specific events
this._eventSource.addEventListener('hsp_state_changed', (event) => {
var _a;
const data = JSON.parse(event.data);
const state = (_a = data.data) === null || _a === void 0 ? void 0 : _a.data;
if (state) {
this._hspState = state;
this._isPlaying =
state.play_state === types_1.HspPlayState.PLAYING ||
state.play_state === 1 ||
state.play_state === '1';
this.emit('hspStateChanged', state);
this.emit('playbackStateChanged', { isPlaying: this._isPlaying });
}
});
this._eventSource.addEventListener('hsp_starving', (event) => {
var _a;
const data = JSON.parse(event.data);
this.emit('hspStarving', (_a = data.data) === null || _a === void 0 ? void 0 : _a.data);
});
this._eventSource.addEventListener('hsp_threshold_reached', (event) => {
var _a;
const data = JSON.parse(event.data);
this.emit('hspThresholdReached', (_a = data.data) === null || _a === void 0 ? void 0 : _a.data);
});
this._eventSource.addEventListener('hsp_looping', (event) => {
var _a;
const data = JSON.parse(event.data);
this.emit('hspLooping', (_a = data.data) === null || _a === void 0 ? void 0 : _a.data);
});
}
/**
* 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;