robotics-dev
Version:
Robotics.dev P2P SDK client for robot control
295 lines (255 loc) • 9.75 kB
JavaScript
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;