ive-connect
Version:
A universal haptic device control library for interactive experiences
650 lines (649 loc) • 20.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createHandyApi = exports.HandyApi = void 0;
class HandyApi {
constructor(baseV3Url, baseV2Url, applicationId, connectionKey = '') {
this.serverTimeOffset = 0;
this._eventSource = null;
this.baseV3Url = baseV3Url;
this.baseV2Url = baseV2Url;
this.applicationId = applicationId;
this.connectionKey = connectionKey;
}
/**
* Set the connection key for API requests
*/
setConnectionKey(connectionKey) {
this.connectionKey = connectionKey;
}
/**
* Get the connection key
*/
getConnectionKey() {
return this.connectionKey;
}
/**
* Set the server time offset for synchronization
*/
setServerTimeOffset(offset) {
this.serverTimeOffset = offset;
}
/**
* Get the server time offset
*/
getServerTimeOffset() {
return this.serverTimeOffset;
}
/**
* Estimate the current server time based on local time and offset
*/
estimateServerTime() {
return Math.round(Date.now() + this.serverTimeOffset);
}
/**
* Get headers for API requests
*/
getHeaders() {
return {
'X-Connection-Key': this.connectionKey,
Authorization: `Bearer ${this.applicationId}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
/**
* Make an API request with error handling
*/
async request(endpoint, options = {}, useV2 = false) {
try {
const baseUrl = useV2 ? this.baseV2Url : this.baseV3Url;
const response = await fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...this.getHeaders(),
...options.headers,
},
});
const data = await response.json();
return data;
}
catch (error) {
console.error(`API error (${endpoint}):`, error);
throw error;
}
}
/**
* Check if the device is connected
*/
async isConnected() {
var _a;
try {
const response = await this.request('/connected');
return !!((_a = response.result) === null || _a === void 0 ? void 0 : _a.connected);
}
catch (error) {
console.error('Handy: Error checking connection:', error);
return false;
}
}
/**
* Get device information
*/
async getDeviceInfo() {
try {
const response = await this.request('/info');
return response.result || null;
}
catch (error) {
console.error('Handy: Error getting device info:', error);
return null;
}
}
/**
* Get the current device mode
*/
async getMode() {
var _a, _b;
try {
const response = await this.request('/mode');
return (_b = (_a = response.result) === null || _a === void 0 ? void 0 : _a.mode) !== null && _b !== void 0 ? _b : null;
}
catch (error) {
console.error('Handy: Error getting mode:', error);
return null;
}
}
/**
* Upload a script file to the hosting service
* Returns the URL where the script can be accessed
*/
async uploadScript(scriptFile) {
try {
// Convert Blob to File if needed
const file = scriptFile instanceof File
? scriptFile
: new File([scriptFile], 'script.funscript', {
type: 'application/json',
});
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${this.baseV2Url}/upload`, {
method: 'POST',
body: formData,
mode: 'cors',
credentials: 'omit',
});
const data = (await response.json());
return data.url || null;
}
catch (error) {
console.error('Handy: Error uploading script:', error);
return null;
}
}
// ============================================
// HSSP (Handy Synchronized Script Protocol)
// ============================================
/**
* Setup script for HSSP playback
*/
async setupScript(scriptUrl) {
var _a;
try {
const response = await this.request('/hssp/setup', {
method: 'PUT',
body: JSON.stringify({ url: scriptUrl }),
});
return !!((_a = response.result) === null || _a === void 0 ? void 0 : _a.stream_id);
}
catch (error) {
console.error('Handy: Error setting up script:', error);
return false;
}
}
/**
* Start playback with the HSSP protocol
*/
async play(videoTime, playbackRate = 1.0, loop = false) {
try {
const response = await this.request('/hssp/play', {
method: 'PUT',
body: JSON.stringify({
start_time: Math.round(videoTime),
server_time: this.estimateServerTime(),
playback_rate: playbackRate,
loop,
}),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error starting playback:', error);
return null;
}
}
/**
* Stop HSSP playback
*/
async stop() {
try {
const response = await this.request('/hssp/stop', {
method: 'PUT',
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error stopping playback:', error);
return null;
}
}
/**
* Synchronize the device's time with video time (HSSP)
*/
async syncVideoTime(videoTime, filter = 0.5) {
var _a;
try {
const response = await this.request('/hssp/synctime', {
method: 'PUT',
body: JSON.stringify({
current_time: Math.round(videoTime),
server_time: this.estimateServerTime(),
filter,
}),
});
return !!((_a = response.result) === null || _a === void 0 ? void 0 : _a.stream_id);
}
catch (error) {
console.error('Handy: Error syncing video time:', error);
return false;
}
}
// ============================================
// HSP (Handy Streaming Protocol)
// ============================================
/**
* Setup a new HSP session on the device.
* This clears any existing HSP session state.
* @param streamId Optional session identifier. If not provided, one will be generated.
*/
async hspSetup(streamId) {
try {
const body = streamId ? { stream_id: streamId } : {};
const response = await this.request('/hsp/setup', {
method: 'PUT',
body: JSON.stringify(body),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting up HSP:', error);
return null;
}
}
/**
* Get the current HSP state
*/
async hspGetState() {
try {
const response = await this.request('/hsp/state');
return response.result || null;
}
catch (error) {
console.error('Handy: Error getting HSP state:', error);
return null;
}
}
/**
* Add points to the HSP buffer.
* You can add up to 100 points in a single command.
* @param points Array of points to add (max 100)
* @param tailPointStreamIndex The index of the last point relative to the overall stream
* @param flush If true, clears buffer before adding new points
* @param tailPointThreshold Optional threshold for starving notifications
*/
async hspAddPoints(points, tailPointStreamIndex, flush = false, tailPointThreshold) {
try {
const body = {
points,
tail_point_stream_index: tailPointStreamIndex,
flush,
};
if (tailPointThreshold !== undefined) {
body.tail_point_threshold = tailPointThreshold;
}
const response = await this.request('/hsp/add', {
method: 'PUT',
body: JSON.stringify(body),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error adding HSP points:', error);
return null;
}
}
/**
* Start HSP playback
* @param startTime The start time in milliseconds
* @param options Optional playback options
*/
async hspPlay(startTime, options = {}) {
var _a;
try {
const body = {
start_time: Math.round(startTime),
server_time: (_a = options.serverTime) !== null && _a !== void 0 ? _a : this.estimateServerTime(),
};
if (options.playbackRate !== undefined) {
body.playback_rate = options.playbackRate;
}
if (options.pauseOnStarving !== undefined) {
body.pause_on_starving = options.pauseOnStarving;
}
if (options.loop !== undefined) {
body.loop = options.loop;
}
if (options.addPoints) {
body.add = options.addPoints;
}
const response = await this.request('/hsp/play', {
method: 'PUT',
body: JSON.stringify(body),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error starting HSP playback:', error);
return null;
}
}
/**
* Stop HSP playback
*/
async hspStop() {
try {
const response = await this.request('/hsp/stop', {
method: 'PUT',
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error stopping HSP:', error);
return null;
}
}
/**
* Pause HSP playback
*/
async hspPause() {
try {
const response = await this.request('/hsp/pause', {
method: 'PUT',
});
return response.result || null;
}
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, resumes from paused position.
*/
async hspResume(pickUp = false) {
try {
const response = await this.request('/hsp/resume', {
method: 'PUT',
body: JSON.stringify({ pick_up: pickUp }),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error resuming HSP:', error);
return null;
}
}
/**
* Flush the HSP buffer (remove all points)
*/
async hspFlush() {
try {
const response = await this.request('/hsp/flush', {
method: 'PUT',
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error flushing HSP buffer:', error);
return null;
}
}
/**
* Set the HSP loop flag
*/
async hspSetLoop(loop) {
try {
const response = await this.request('/hsp/loop', {
method: 'PUT',
body: JSON.stringify({ loop }),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting HSP loop:', error);
return null;
}
}
/**
* Set the HSP playback rate
*/
async hspSetPlaybackRate(playbackRate) {
try {
const response = await this.request('/hsp/playbackrate', {
method: 'PUT',
body: JSON.stringify({ playback_rate: playbackRate }),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting HSP playback rate:', error);
return null;
}
}
/**
* Set the HSP tail point stream index threshold
*/
async hspSetThreshold(threshold) {
try {
const response = await this.request('/hsp/threshold', {
method: 'PUT',
body: JSON.stringify({ tail_point_threshold: threshold }),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting HSP threshold:', error);
return null;
}
}
/**
* Set the HSP pause-on-starving flag
*/
async hspSetPauseOnStarving(pauseOnStarving) {
try {
const response = await this.request('/hsp/pause/onstarving', {
method: 'PUT',
body: JSON.stringify({ pause_on_starving: pauseOnStarving }),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting HSP pause on starving:', error);
return null;
}
}
/**
* Sync HSP time with external source
* @param currentTime Current time from external source
* @param filter Filter value for gradual adjustment (0-1)
*/
async hspSyncTime(currentTime, filter = 0.5) {
try {
const response = await this.request('/hsp/synctime', {
method: 'PUT',
body: JSON.stringify({
current_time: Math.round(currentTime),
server_time: this.estimateServerTime(),
filter,
}),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error syncing HSP time:', error);
return null;
}
}
// ============================================
// HSTP (Handy Simple Timing Protocol)
// ============================================
/**
* Get the current time offset
*/
async getOffset() {
var _a;
try {
const response = await this.request('/hstp/offset');
return ((_a = response.result) === null || _a === void 0 ? void 0 : _a.offset) || 0;
}
catch (error) {
console.error('Handy: Error getting offset:', error);
return 0;
}
}
/**
* Set the time offset
*/
async setOffset(offset) {
try {
const response = await this.request('/hstp/offset', {
method: 'PUT',
body: JSON.stringify({ offset }),
});
return response.result === 'ok';
}
catch (error) {
console.error('Handy: Error setting offset:', error);
return false;
}
}
/**
* Get device time info
*/
async getDeviceTimeInfo() {
try {
const response = await this.request('/hstp/info');
return response.result || null;
}
catch (error) {
console.error('Handy: Error getting device time info:', error);
return null;
}
}
/**
* Trigger a server-device clock synchronization
*/
async clockSync(synchronous = true) {
try {
const response = await this.request(`/hstp/clocksync?s=${synchronous}`);
return response.result || null;
}
catch (error) {
console.error('Handy: Error triggering clock sync:', error);
return null;
}
}
// ============================================
// Slider Settings
// ============================================
/**
* Get the current stroke settings
*/
async getStrokeSettings() {
try {
const response = await this.request('/slider/stroke');
return response.result || null;
}
catch (error) {
console.error('Handy: Error getting stroke settings:', error);
return null;
}
}
/**
* Set the stroke settings
*/
async setStrokeSettings(settings) {
try {
const response = await this.request('/slider/stroke', {
method: 'PUT',
body: JSON.stringify(settings),
});
return response.result || null;
}
catch (error) {
console.error('Handy: Error setting stroke settings:', error);
return null;
}
}
// ============================================
// Server Time Sync
// ============================================
/**
* Get the server time for synchronization calculations
*/
async getServerTime() {
try {
const response = await fetch(`${this.baseV3Url}/servertime`);
const data = await response.json();
return data.server_time || null;
}
catch (error) {
console.error('Handy: Error getting server time:', error);
return null;
}
}
/**
* Synchronize time with the server
* Returns calculated server time offset
*/
async syncServerTime(sampleCount = 10) {
try {
const samples = [];
for (let i = 0; i < sampleCount; i++) {
try {
const start = Date.now();
const serverTime = await this.getServerTime();
if (!serverTime)
continue;
const end = Date.now();
const rtd = end - start; // Round trip delay
const serverTimeEst = rtd / 2 + serverTime;
samples.push({
rtd,
offset: serverTimeEst - end,
});
}
catch (error) {
console.warn('Error during time sync sample:', error);
// Continue with other samples
}
}
// Sort samples by RTD (Round Trip Delay) to get the most accurate ones
if (samples.length > 0) {
samples.sort((a, b) => a.rtd - b.rtd);
// Use 80% of the most accurate samples if we have enough
const usableSamples = samples.length > 3
? samples.slice(0, Math.ceil(samples.length * 0.8))
: samples;
const averageOffset = usableSamples.reduce((acc, sample) => acc + sample.offset, 0) /
usableSamples.length;
this.serverTimeOffset = averageOffset;
return averageOffset;
}
return this.serverTimeOffset;
}
catch (error) {
console.error('Error syncing time:', error);
return this.serverTimeOffset;
}
}
// ============================================
// SSE (Server-Sent Events)
// ============================================
/**
* Create an EventSource for server-sent events
*/
createEventSource() {
if (this._eventSource) {
this._eventSource.close();
}
this._eventSource = new EventSource(`${this.baseV3Url}/sse?ck=${this.connectionKey}&apikey=${this.applicationId}`);
return this._eventSource;
}
/**
* Close the EventSource if it exists
*/
closeEventSource() {
if (this._eventSource) {
this._eventSource.close();
this._eventSource = null;
}
}
}
exports.HandyApi = HandyApi;
// Factory function to create HandyApi instances
const createHandyApi = (baseV3Url, baseV2Url, applicationId, connectionKey = '') => {
return new HandyApi(baseV3Url, baseV2Url, applicationId, connectionKey);
};
exports.createHandyApi = createHandyApi;