robotics
Version:
Robotics.dev P2P ROS2 robot controller CLI with ROS telemetry and video streaming
209 lines (178 loc) • 6.54 kB
JavaScript
import {createRequire } from "module";
const require = createRequire(import.meta.url);
import Configstore from 'configstore';
const config = new Configstore('robotics');
var robotId = config.get('ROBOT_ID');
const { exec, spawn } = require('child_process');
const rclnodejs = require('rclnodejs');
const fs = require('fs').promises;
const StreamSplitter = require('stream-split'); // Add this import
const ffmpeg = require('ffmpeg-static');
const JPEG_START = Buffer.from([0xff, 0xd8]);
const JPEG_END = Buffer.from([0xff, 0xd9]);
// Move cameraPub to top level scope
let cameraPub;
let webcam;
// Checks for --device and if it has a value
const deviceIndex = process.argv.indexOf('--device');
let deviceValue;
if (deviceIndex > -1) {
deviceValue = process.argv[deviceIndex + 1];
}
let device = (deviceValue || '/dev/video0');
console.log('Device:', `${device}`);
// Checks for --resolution and if it has a value
const scaleIndex = process.argv.indexOf('--resolution');
let scaleValue;
if (scaleIndex > -1) {
scaleValue = process.argv[scaleIndex + 1];
}
const resolution = (scaleValue || '672x672');
console.log('Resolution:', `${resolution}`);
// Checks for --fps and if it has a value
const fpsIndex = process.argv.indexOf('--fps');
let fpsValue;
if (fpsIndex > -1) {
fpsValue = process.argv[fpsIndex + 1];
}
const fps = (fpsValue || 15);
console.log('FPS:', `${fps}`);
// Helper function to list available video devices
function listVideoDevices() {
return new Promise((resolve, reject) => {
exec('ls /dev/video*', (error, stdout, stderr) => {
if (error) {
console.warn('No video devices found in /dev/video*');
resolve([]);
return;
}
const devices = stdout.trim().split('\n');
resolve(devices);
});
});
}
// Validate and initialize webcam
async function initializeWebcam() {
const availableDevices = await listVideoDevices();
console.log('Available video devices:', availableDevices);
if (!availableDevices.includes(device)) {
console.error(`Device ${device} not found. Available devices: ${availableDevices.join(', ')}`);
if (availableDevices.length > 0) {
console.log(`Falling back to ${availableDevices[0]}`);
device = availableDevices[0];
} else {
throw new Error('No video devices available');
}
}
// Validate ffmpeg availability
if (!ffmpeg) {
throw new Error('ffmpeg binary not found. Please install ffmpeg-static package.');
}
console.log('Using ffmpeg from:', ffmpeg);
// Instead of creating NodeWebcam instance, we'll return the validated device
return device;
}
// Replace the ROS initialization block with this version
async function initializeROS() {
await rclnodejs.init();
const node = new rclnodejs.Node('robotics_dev_camera_node');
cameraPub = node.createPublisher('std_msgs/msg/String', `robot${robotId.replace(/-/g, "")}/camera2d`);
node.spin();
}
// Main execution
async function main() {
try {
await initializeROS();
webcam = await initializeWebcam();
await startStreaming(webcam);
} catch (error) {
console.error('Failed to initialize:', error);
process.exit(1);
}
}
// Start the application
main();
async function createVideoStream(device) {
const args = [
'-f', 'video4linux2',
'-framerate', fps.toString(),
'-video_size', resolution,
'-i', device,
'-f', 'mjpeg', // Changed from image2pipe to mjpeg
'-vf', 'format=yuvj420p', // Ensure correct pixel format
'-q:v', '2', // Adjusted quality (1-31, lower is better)
'-' // Output to stdout
];
try {
const ffmpegProcess = spawn(ffmpeg, args);
// Add immediate error handler for spawn issues
ffmpegProcess.on('error', (error) => {
console.error('Failed to start ffmpeg:', error);
throw error;
});
// Add startup validation
const startupPromise = new Promise((resolve, reject) => {
let errorOutput = '';
ffmpegProcess.stderr.once('data', (data) => {
errorOutput += data.toString();
if (errorOutput.includes('Server returned 404')) {
reject(new Error(`Device ${device} not accessible`));
} else if (errorOutput.includes('Input/output error')) {
reject(new Error(`Cannot access ${device}. Check permissions.`));
} else {
resolve(ffmpegProcess);
}
});
// Timeout if no response
setTimeout(() => reject(new Error('FFmpeg startup timeout')), 5000);
});
const splitter = new StreamSplitter(JPEG_END);
let currentFrame = Buffer.from([]);
ffmpegProcess.stderr.on('data', (data) => {
console.log('ffmpeg:', data.toString());
});
splitter.on('data', (chunk) => {
// Combine with JPEG_END that was removed by splitter
const frame = Buffer.concat([chunk, JPEG_END]);
if (frame[0] === JPEG_START[0] && frame[1] === JPEG_START[1]) {
try {
const base64Data = frame.toString('base64');
cameraPub.publish(base64Data);
} catch (error) {
console.error('Error publishing frame:', error);
}
}
});
ffmpegProcess.stdout.pipe(splitter);
await startupPromise;
return ffmpegProcess;
} catch (error) {
console.error('Video stream initialization failed:', error);
throw error;
}
}
async function startStreaming(device) {
try {
const stream = await createVideoStream(device);
// Handle stream closure
stream.on('close', (code) => {
if (code !== 0) {
console.error(`ffmpeg exited with code ${code}`);
process.exit(1);
}
});
// Handle stream errors
stream.on('error', (error) => {
console.error('Stream error:', error);
process.exit(1);
});
} catch (error) {
console.error('Failed to start video stream:', error);
process.exit(1);
}
}
// Enhanced shutdown handler
process.on('SIGINT', () => {
console.log('\nGracefully shutting down...');
process.exit(0);
});