File size: 13,982 Bytes
bf54bd7
 
2bf3b2f
bf54bd7
 
 
 
 
 
96456d0
 
bf54bd7
 
 
 
 
 
96456d0
 
 
 
2bf3b2f
96456d0
 
 
 
2bf3b2f
96456d0
0fafac8
2bf3b2f
 
 
 
 
 
 
 
e239a9e
96456d0
2bf3b2f
 
e239a9e
 
 
96456d0
 
 
 
 
 
 
 
 
 
 
0fafac8
bf54bd7
 
 
e239a9e
 
 
2bf3b2f
e239a9e
 
 
96456d0
bf54bd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bf3b2f
bf54bd7
 
 
 
 
 
 
 
 
 
 
 
 
2bf3b2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf54bd7
2bf3b2f
 
 
bf54bd7
 
 
 
 
 
 
 
2bf3b2f
96456d0
 
 
2bf3b2f
bf54bd7
 
 
96456d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc3c2ba
 
 
 
 
 
 
 
 
96456d0
 
 
 
bc3c2ba
96456d0
 
 
bc3c2ba
96456d0
 
bc3c2ba
96456d0
 
 
 
 
 
 
01a7e44
96456d0
 
 
 
 
 
 
 
 
bf54bd7
 
 
2bf3b2f
 
bf54bd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bf3b2f
 
bf54bd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bf3b2f
bf54bd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01a7e44
 
bf54bd7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
const express = require('express');
const http = require('http');
const net = require('net');
const socketIO = require('socket.io');
const { spawn, execSync } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');

const HOME = os.homedir();

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
  cors: { origin: '*' }
});

// ── Display config ────────────────────────────────────────────────────────────
const VIZ_DISPLAY = ':20';       // Xvfb virtual display for RViz
const VNC_PORT    = 5920;        // x11vnc listens here
const NOVNC_PORT  = 6080;        // websockify WebSocket β†’ VNC bridge

// ── WebSocket proxy: /vnc WebSocket β†’ websockify on :6080 ────────────────────
server.on('upgrade', (req, socket, head) => {
  if (req.url.startsWith('/vnc')) {
    const target = net.createConnection(NOVNC_PORT, '127.0.0.1');
    target.on('connect', () => {
      const rewrittenUrl = req.url.replace(/^\/vnc/, '') || '/';
      let rawRequest = `${req.method} ${rewrittenUrl} HTTP/${req.httpVersion}\r\n`;
      for (const [k, v] of Object.entries(req.headers)) {
        rawRequest += `${k}: ${v}\r\n`;
      }
      rawRequest += `\r\n`;
      target.write(rawRequest);
      if (head && head.length > 0) target.write(head);
      socket.pipe(target);
      target.pipe(socket);
    });
    target.on('error', (e) => { console.error('[VNC-PROXY] error:', e.message); socket.destroy(); });
    socket.on('error', () => target.destroy());
    socket.on('close', () => target.destroy());
  }
});

// ── Serve noVNC static assets (system install OR npm fallback) ───────────────
const NOVNC_SYSTEM = '/usr/share/novnc';
const NOVNC_NPM    = path.join(__dirname, 'node_modules/@novnc/novnc');
const NOVNC_PATH   = fs.existsSync(NOVNC_SYSTEM) ? NOVNC_SYSTEM : NOVNC_NPM;

if (fs.existsSync(NOVNC_PATH)) {
  app.use('/novnc', express.static(NOVNC_PATH));
  console.log('[VNC] Serving noVNC from', NOVNC_PATH);
} else {
  console.warn('[VNC] noVNC not found β€” install: sudo apt install novnc  OR  npm install');
}

app.use(express.static('public'));
app.use(express.json());

// ── API: expose environment config to frontend ────────────────────────────────
app.get('/api/env', (req, res) => {
  res.json({
    rvizVnc: true
  });
});

const ROS_ROOT = __dirname;

const projectConfigs = {
  ros_2: {
    path: path.join(ROS_ROOT, 'ros_2'),
    name: 'ROS 2 - Robot Proximity Communication',
    color: '#3498db'
  },
  ros_3: {
    path: path.join(ROS_ROOT, 'ros_3'),
    name: 'ROS 3 - 3 Robots Simulation',
    color: '#e74c3c'
  },
  ros_4: {
    path: path.join(ROS_ROOT, 'ros_4'),
    name: 'ROS 4 - Quadcopter Multi-Agent',
    color: '#2ecc71'
  }
};

// Only ONE active process at a time across the entire server
let activeProcess = null;   // { process, projectId, pgid }
let activeSocket = null;

// ── Kill the currently running process (entire process group) ──────────────────
function killActiveProcess(notifySocket, reason) {
  if (!activeProcess) return;

  const { process: proc, projectId, pgid } = activeProcess;
  console.log(`[KILL] Stopping ${projectId} (pgid=${pgid}), reason: ${reason}`);

  try {
    // Kill the whole process group so ros2 children die too
    process.kill(-pgid, 'SIGKILL');
  } catch (e) {
    console.warn(`[KILL] process.kill failed (${e.message}), trying proc.kill()`);
    try { proc.kill('SIGKILL'); } catch (_) {}
  }

  // Also nuke any lingering ros2 daemon / nodes
  cleanupLingeringProcesses();

  activeProcess = null;

  if (notifySocket) {
    notifySocket.emit('output', {
      type: 'info',
      message: `⏹ Process stopped (${reason}).`,
      projectId
    });
    notifySocket.emit('process-stopped', { projectId });
  }
}

// ── Robust cleanup helper ─────────────────────────────────────────────────────
function cleanupLingeringProcesses() {
  const commands = [
    'pkill -9 -f "ros2 run" 2>/dev/null || true',
    'pkill -9 -f "ros2 launch" 2>/dev/null || true',
    'pkill -9 -f "robot_node" 2>/dev/null || true',
    'pkill -9 -f "proximity_monitor" 2>/dev/null || true',
    'pkill -9 -f "graph_visualizer" 2>/dev/null || true',
    'pkill -9 -f "interactive_runner" 2>/dev/null || true',
    'pkill -9 -f "rviz2" 2>/dev/null || true',
    'pkill -9 -f "foxglove_bridge" 2>/dev/null || true',
    'source /opt/ros/humble/setup.bash && ros2 daemon stop 2>/dev/null || true'
  ];
  for (const cmd of commands) {
    try {
      execSync(cmd, { shell: '/bin/bash', stdio: 'ignore' });
    } catch (_) {}
  }
}

// ── ROS environment for child processes ───────────────────────────────────────
function rosEnv(projectId) {
  // Use unique ROS_DOMAIN_ID for each project to isolate network traffic
  const domainId = projectId === 'ros_2' ? '2' : (projectId === 'ros_3' ? '3' : '4');
  return {
    ...process.env,
    PYTHONUNBUFFERED: '1',
    PATH: `/opt/ros/humble/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
    LD_LIBRARY_PATH: `/opt/ros/humble/lib:${process.env.LD_LIBRARY_PATH || ''}`,
    AMENT_PREFIX_PATH: '/opt/ros/humble',
    ROS_DISTRO: 'humble',
    COLCON_HOME: `${HOME}/.colcon`,
    PYTHONPATH: '',
    DISPLAY: VIZ_DISPLAY,
    WAYLAND_DISPLAY: '',          // force X11 mode for RViz
    XDG_SESSION_TYPE: 'x11',
    ROS_DOMAIN_ID: domainId
  };
}

// ── Virtual display stack: Xvfb β†’ x11vnc β†’ websockify ───────────────────────
let xvfbProc = null;
let x11vncProc = null;
let websockifyProc = null;

function startDisplayStack() {
  console.log(`[VIZ] Starting Xvfb on ${VIZ_DISPLAY}...`);

  // Kill any leftover processes from a previous run
  try { execSync(`pkill -f 'Xvfb ${VIZ_DISPLAY}' 2>/dev/null`); } catch (_) {}
  try { execSync(`pkill -f 'x11vnc.*:${VNC_PORT}' 2>/dev/null`); } catch (_) {}
  try { execSync(`pkill -f 'websockify.*${NOVNC_PORT}' 2>/dev/null`); } catch (_) {}

  // 1. Xvfb virtual framebuffer
  xvfbProc = spawn('Xvfb', [VIZ_DISPLAY, '-screen', '0', '1280x800x24', '-ac'], {
    detached: false, stdio: 'ignore'
  });
  xvfbProc.on('error', e => console.error('[VIZ] Xvfb error:', e.message));

  // 2. x11vnc β€” wait 1.5s for Xvfb to be ready
  setTimeout(() => {
    console.log(`[VIZ] Starting x11vnc on port ${VNC_PORT}...`);

    // x11vnc checks getenv("WAYLAND_DISPLAY") != NULL β€” even an empty string
    // triggers Wayland detection and causes x11vnc to exit immediately.
    // We must DELETE the key entirely from the child process environment.
    const x11vncEnv = { ...process.env };
    delete x11vncEnv.WAYLAND_DISPLAY;
    x11vncEnv.DISPLAY           = VIZ_DISPLAY;
    x11vncEnv.XDG_SESSION_TYPE  = 'x11';

    x11vncProc = spawn('x11vnc', [
      '-display', VIZ_DISPLAY,
      '-nopw', '-localhost',
      '-rfbport', String(VNC_PORT),
      '-shared', '-forever', '-noxdamage', '-loop'
    ], {
      detached: false,
      stdio: 'ignore',
      env: x11vncEnv
    });
    x11vncProc.on('error', e => console.error('[VIZ] x11vnc error:', e.message));
    x11vncProc.on('exit',  c => console.warn(`[VIZ] x11vnc exited code=${c}`));

    // 3. websockify β€” bridge VNC β†’ WebSocket
    setTimeout(() => {
      console.log(`[VIZ] Starting websockify on port ${NOVNC_PORT} β†’ VNC ${VNC_PORT}...`);
      websockifyProc = spawn('websockify', [
        '--web', NOVNC_PATH,
        String(NOVNC_PORT),
        `127.0.0.1:${VNC_PORT}`
      ], { detached: false, stdio: 'ignore' });
      websockifyProc.on('error', e => console.error('[VIZ] websockify error:', e.message));
      console.log('[VIZ] Display stack ready.');
    }, 1500);
  }, 1500);
}

startDisplayStack();

// ── Socket connections ─────────────────────────────────────────────────────────
// ── Global cleanup on server startup ───────────────────────────────────────────
console.log('[SYSTEM] Performing initial cleanup of lingering ROS processes...');
cleanupLingeringProcesses();
console.log('[SYSTEM] Cleanup complete.');

io.on('connection', (socket) => {
  console.log('Client connected:', socket.id);
  activeSocket = socket;

  // ── Start (or restart) a project ──────────────────────────────────────────
  socket.on('build-and-run', (projectId) => {
    const config = projectConfigs[projectId];
    if (!config) {
      socket.emit('output', { type: 'error', message: `Unknown project: ${projectId}` });
      return;
    }

    // Stop whatever is already running first
    if (activeProcess) {
      socket.emit('output', {
        type: 'info',
        message: `⏹ Stopping previous project (${activeProcess.projectId}) before starting ${projectId}...`
      });
      killActiveProcess(socket, 'new project started');
      // Brief pause so OS can clean up ports/resources
      setTimeout(() => launchProject(socket, projectId, config), 1500);
    } else {
      launchProject(socket, projectId, config);
    }
  });

  // ── Send stdin to the running process ─────────────────────────────────────
  socket.on('send-input', ({ projectId, input }) => {
    if (activeProcess && activeProcess.projectId === projectId) {
      console.log(`[INPUT β†’ ${projectId}]: ${input}`);
      activeProcess.process.stdin.write(input + '\n');
      socket.emit('output', { type: 'user-input', message: `> ${input}`, projectId });
    } else {
      socket.emit('output', { type: 'error', message: 'No matching running process to send input to.' });
    }
  });

  // ── Stop the running process ───────────────────────────────────────────────
  socket.on('stop-process', (projectId) => {
    if (activeProcess && activeProcess.projectId === projectId) {
      killActiveProcess(socket, 'user requested stop');
    } else {
      socket.emit('output', { type: 'info', message: 'No running process to stop.' });
    }
  });

  // ── Cleanup on disconnect ─────────────────────────────────────────────────
  socket.on('disconnect', () => {
    console.log('Client disconnected:', socket.id);
    // Do not kill the active process on disconnect so that page reloads or multiple tabs
    // don't abort the running simulation.
  });
});

// ── Launch a ROS project ──────────────────────────────────────────────────────
function launchProject(socket, projectId, config) {
  socket.emit('output', { type: 'info', message: `β–Ά Starting ${config.name}...` });
  socket.emit('output', { type: 'building', message: 'πŸ”¨ Building project...' });

  const buildScript = `#!/bin/bash
set -e
source /opt/ros/humble/setup.bash
echo "[ROS] ROS_DISTRO=$ROS_DISTRO"
cd "${config.path}"
echo "[BUILD] Building robot_proximity..."
colcon build --packages-select robot_proximity 2>&1
echo "[BUILD] Done. Sourcing install..."
source "${config.path}/install/setup.bash"
echo "[RUN] Launching interactive_runner..."
exec ros2 run robot_proximity interactive_runner
`;

  const child = spawn('bash', ['-c', buildScript], {
    cwd: config.path,
    stdio: ['pipe', 'pipe', 'pipe'],
    detached: true,   // <-- creates a new process group so we can kill -pgid
    env: rosEnv(projectId)
  });

  // Store pgid = child.pid (because detached=true makes child the group leader)
  activeProcess = { process: child, projectId, pgid: child.pid };
  console.log(`[LAUNCH] ${projectId} pid=${child.pid} pgid=${child.pid}`);

  // Notify frontend which project is now active
  socket.emit('project-started', { projectId });

  child.stdout.on('data', (data) => {
    const msg = data.toString();
    console.log(`[${projectId}] ${msg.trim()}`);
    socket.emit('output', { type: 'stdout', message: msg, projectId });
  });

  child.stderr.on('data', (data) => {
    const msg = data.toString();
    // ROS2 uses stderr for INFO logs β€” don't colour them red
    const msgLower = msg.toLowerCase();
    const isInfo = msgLower.includes('[info]') || msgLower.includes('[launch]');
    console.log(`[${projectId}][ERR] ${msg.trim()}`);
    socket.emit('output', { type: isInfo ? 'stdout' : 'stderr', message: msg, projectId });
  });

  child.on('close', (code) => {
    console.log(`[${projectId}] exited code=${code}`);
    if (activeProcess && activeProcess.projectId === projectId) {
      activeProcess = null;
    }
    socket.emit('output', {
      type: code === 0 ? 'success' : 'info',
      message: `Process ended (exit code ${code}).`,
      projectId
    });
    socket.emit('process-stopped', { projectId });
  });

  child.on('error', (err) => {
    console.error(`[${projectId}] spawn error:`, err);
    socket.emit('output', { type: 'error', message: `Spawn error: ${err.message}`, projectId });
    activeProcess = null;
    socket.emit('process-stopped', { projectId });
  });
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
  console.log(`ROS Project Runner running on http://0.0.0.0:${PORT}`);
});