UNPKG

robotics-dev

Version:

Robotics.dev P2P SDK client for robot control

295 lines (255 loc) 9.75 kB
const io = require('socket.io-client'); const LZString = require('lz-string'); const nodeDataChannel = require('node-datachannel'); const config = { iceServers: [ 'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302' ], maxMessageSize: 16384, enableIceTcp: true, portRangeBegin: 5000, portRangeEnd: 6000 }; function formatLog(message) { const timestamp = new Date().toISOString(); return `[${timestamp}] ${message}`; } class RoboticsClient { constructor() { this.socket = null; this.defaultServer = 'wss://robotics.dev'; this.pc = null; this.dataChannel = null; this.isConnected = false; this.hasRemoteDescription = false; this.pendingCandidates = []; this.setupComplete = false; this.setupInProgress = false; this.messages = new Map(); this.clientId = 'remote-' + Math.random().toString(36).substr(2, 9); this.robot = null; this.token = null; } connect(options = {}, callback) { const server = options.server || this.defaultServer; this.robot = options.robot; this.token = options.token; if (!this.robot || !this.token) { throw new Error('Robot ID and token are required'); } this.socket = io(server, { reconnection: true, rejectUnauthorized: false, transports: ["websocket", "polling"], query: { id: this.clientId, room: this.robot, token: this.token }, auth: { id: this.clientId } }); this.socket.on('connect', async () => { console.log(formatLog('Connected to robotics.dev - Socket ID:', this.socket.id)); this.socket.emit('register', { id: this.clientId, room: this.robot, token: this.token }); await this.setupPeerConnection(callback); }); this.setupSignalingHandlers(callback); return this.socket.id; } setupSignalingHandlers(callback) { this.socket.on('signal', async (message) => { try { if (message.type === 'answer' && message.sourcePeer === this.robot) { await this.pc.setRemoteDescription(String(message.sdp), message.type); this.hasRemoteDescription = true; while (this.pendingCandidates.length > 0) { const candidate = this.pendingCandidates.shift(); await this.pc.addRemoteCandidate(candidate.candidate, candidate.mid); } } else if (message.type === 'candidate' && message.sourcePeer === this.robot) { const candidate = { candidate: String(message.candidate), mid: String(message.mid || "0") }; if (!this.hasRemoteDescription) { this.pendingCandidates.push(candidate); } else { await this.pc.addRemoteCandidate(candidate.candidate, candidate.mid); } } } catch (error) { console.error(formatLog(`Signal error: ${error}`)); } }); this.socket.on('room-info', (info) => { if (info.peers.includes(this.robot) && !this.isConnected && !this.setupComplete) { this.setupPeerConnection(callback); } }); } async setupPeerConnection(callback) { if (this.setupInProgress) return; this.setupInProgress = true; try { if (this.pc) { this.pc.close(); this.pc = null; } this.pc = new nodeDataChannel.PeerConnection('client', config); this.pc.onStateChange((state) => { console.log(formatLog(`Connection state: ${state}`)); this.isConnected = state === 'connected'; this.setupComplete = this.isConnected; this.setupInProgress = !this.isConnected; }); this.pc.onLocalDescription((sdp, type) => { this.sendSignal({ type, sdp: String(sdp) }); }); this.pc.onLocalCandidate((candidate, mid) => { if (!candidate) return; this.sendSignal({ type: 'candidate', candidate: String(candidate), mid: String(mid) }); }); this.dataChannel = this.pc.createDataChannel('robotics'); this.setupDataChannel(callback); await this.pc.setLocalDescription(); } catch (error) { console.error(formatLog(`Setup failed: ${error}`)); this.setupInProgress = false; throw error; } } setupDataChannel(callback) { this.dataChannel.onOpen(() => { console.log(formatLog('Data channel opened')); this.isConnected = true; }); this.dataChannel.onMessage((data) => { try { // Handle both string and binary data let message; if (typeof data === 'string') { message = JSON.parse(data); } else { // Convert ArrayBuffer to string if needed const str = new TextDecoder().decode(new Uint8Array(data)); message = JSON.parse(str); } // Check if this is a chunked message if (message.chunk && message.messageId) { // Handle chunked message const { chunk, index, total, messageId } = message; if (!this.messages.has(messageId)) { this.messages.set(messageId, { receivedChunks: [], totalChunks: total, }); } const currentMessage = this.messages.get(messageId); currentMessage.receivedChunks[index] = chunk; if (currentMessage.receivedChunks.filter(Boolean).length === currentMessage.totalChunks) { const fullData = currentMessage.receivedChunks.join(''); try { const originalData = JSON.parse(LZString.decompressFromBase64(fullData)); this.messages.delete(messageId); if (callback) callback(originalData); } catch (e) { console.error(formatLog(`Decompression error: ${e}`)); this.messages.delete(messageId); } } } else { // Handle regular ROS messages directly if (callback) callback(message); } } catch (error) { console.error(formatLog(`Message error: ${error}`)); // Try to handle raw message if (callback && typeof data === 'string') { callback({ data: data }); } } }); this.dataChannel.onError((error) => { console.error(formatLog(`Data channel error: ${error}`)); this.isConnected = false; }); this.dataChannel.onClosed(() => { console.log(formatLog('Data channel closed')); this.isConnected = false; }); } sendSignal(message) { const signalMessage = { ...message, targetPeer: this.robot, sourcePeer: this.clientId, room: this.robot }; this.socket.emit('signal', signalMessage); } twist(robot, twist) { if (!robot || !twist) { throw new Error('Robot and twist payload are required'); } const message = { topic: "twist", robot: robot, twist: twist }; if (this.isConnected && this.dataChannel) { this.dataChannel.sendMessage(JSON.stringify(message)); } else if (this.socket) { this.socket.emit('twist', { robot, token: this.token, twist }); } else { throw new Error('No connection available'); } } speak(robot, text) { if (!robot || !text) { throw new Error('Robot and text payload are required'); } const message = { topic: "speak", robot: robot, text: text }; if (this.isConnected && this.dataChannel) { this.dataChannel.sendMessage(JSON.stringify(message)); } else if (this.socket) { this.socket.emit('speak', { robot, token: this.token, text }); } else { throw new Error('No connection available'); } } disconnect() { if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; } if (this.pc) { this.pc.close(); this.pc = null; } if (this.socket) { this.socket.disconnect(); this.socket = null; } this.isConnected = false; this.setupComplete = false; } } // Create singleton instance const robotics = new RoboticsClient(); module.exports = robotics;