const express = require('express'); const { Client } = require('node-osc'); const http = require('http'); const { Server } = require('socket.io'); const { exec, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const scriptsDir = path.join(__dirname, '../scripts'); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, '../frontend/dist'))); const si = require('systeminformation'); // System Monitoring API app.get('/system/stats', async (req, res) => { try { const cpu = await si.currentLoad(); const mem = await si.mem(); const temp = await si.cpuTemperature(); res.json({ cpu: cpu.currentLoad.toFixed(1), mem: { total: (mem.total / 1024 / 1024).toFixed(0), used: (mem.used / 1024 / 1024).toFixed(0), free: (mem.free / 1024 / 1024).toFixed(0) }, temp: temp.main }); } catch (err) { res.status(500).json({ error: 'Failed to fetch system stats' }); } }); // Media Directory Configuration const MEDIA_DIR = process.env.MEDIA_DIR || '/home/pi/media'; if (!fs.existsSync(MEDIA_DIR)) { fs.mkdirSync(MEDIA_DIR, { recursive: true }); } // Multer Setup for File Uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, MEDIA_DIR); }, filename: (req, file, cb) => { cb(null, file.originalname); } }); const upload = multer({ storage: storage }); // Mapping Manifest Configuration const MANIFEST_PATH = path.join(__dirname, 'manifest.json'); let manifest = { surfaces: [] }; if (fs.existsSync(MANIFEST_PATH)) { manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH)); } const saveManifest = () => { fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); }; // Media API Endpoints with ffprobe validation app.get('/media', (req, res) => { fs.readdir(MEDIA_DIR, (err, files) => { if (err) return res.status(500).json({ error: 'Failed to read media directory' }); res.json({ files }); }); }); app.post('/media/upload', upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const filename = req.file.filename; const filePath = path.join(MEDIA_DIR, filename); // Validate codec using ffprobe exec(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height -of json "${filePath}"`, (err, stdout) => { if (err) { // If it's an image, ffprobe might return error or something else, but we focus on video H.264 return res.json({ status: 'uploaded', file: filename, warning: 'Could not validate codec' }); } const info = JSON.parse(stdout); const videoStream = info.streams[0]; let warning = null; if (videoStream.codec_name !== 'h264') { warning = `Warning: Codec is ${videoStream.codec_name}. H.264 is recommended for Pi 3B stability.`; } if (videoStream.width > 1920 || videoStream.height > 1080) { warning = (warning ? warning + ' ' : '') + 'Warning: Resolution exceeds 1080p.'; } res.json({ status: 'uploaded', file: filename, warning }); }); }); app.get('/manifest', (req, res) => { res.json(manifest); }); app.post('/media/assign', (req, res) => { const { surfaceIndex, filename, surfaceName } = req.body; // Update manifest let surface = manifest.surfaces.find(s => s.index === surfaceIndex); if (!surface) { surface = { index: surfaceIndex, name: surfaceName || `Surface ${surfaceIndex}`, source: filename }; manifest.surfaces.push(surface); } else { surface.source = filename; if (surfaceName) surface.name = surfaceName; } saveManifest(); oscClient.send('/ofxPiMapper/source/set', surfaceIndex, filename); res.json({ status: 'assigned', surfaceIndex, filename, manifest }); }); // Networking API app.post('/network/ap', (req, res) => { exec(`bash "${path.join(scriptsDir, 'switch-to-ap.sh')}"`, (err, stdout) => { if (err) return res.status(500).json({ error: err.message }); res.json({ status: 'switching to ap', output: stdout }); }); }); app.post('/network/client', (req, res) => { exec(`bash "${path.join(scriptsDir, 'switch-to-client.sh')}"`, (err, stdout) => { if (err) return res.status(500).json({ error: err.message }); res.json({ status: 'switching to client', output: stdout }); }); }); app.get('/network/scan', (req, res) => { exec('iwlist wlan0 scan | grep "ESSID"', (err, stdout) => { if (err) { // Fallback for non-Pi or error return res.json({ networks: [ { ssid: 'Venue-WiFi', strength: 60 }, { ssid: 'Artist-Hotspot', strength: 85 } ] }); } const networks = stdout.split('\n') .filter(line => line.includes('ESSID:')) .map(line => ({ ssid: line.split(':')[1].replace(/"/g, '').trim(), strength: Math.floor(Math.random() * 40) + 50 // Mock strength })); res.json({ networks }); }); }); app.post('/network/ap/update', (req, res) => { const { ssid, password } = req.body; console.log(`Updating AP to SSID: ${ssid}, PASS: ${password}`); // In a real scenario, we would rewrite hostapd.conf and reboot res.json({ status: 'updating', ssid }); setTimeout(() => { exec('sudo reboot'); }, 2000); }); // ofxPiMapper Process Management & Log Throttling let mapperProcess = null; let logBuffer = []; const LOG_THROTTLE_MS = 500; const MAX_LOG_LINES = 10; const streamLogs = (data) => { const lines = data.toString().split('\n').filter(l => l.trim()); logBuffer.push(...lines); }; // Periodic log emitter setInterval(() => { if (logBuffer.length > 0) { const logsToSend = logBuffer.splice(0, MAX_LOG_LINES); io.emit('mapper:logs', { logs: logsToSend }); } }, LOG_THROTTLE_MS); app.post('/mapper/start', (req, res) => { if (mapperProcess) return res.status(400).json({ error: 'Mapper already running' }); mapperProcess = spawn('ofxPiMapper', ['-f']); mapperProcess.stdout.on('data', streamLogs); mapperProcess.stderr.on('data', streamLogs); mapperProcess.on('close', (code) => { mapperProcess = null; io.emit('mapper:status', { status: 'stopped' }); }); io.emit('mapper:status', { status: 'running' }); res.json({ status: 'starting' }); }); app.post('/mapper/stop', (req, res) => { if (mapperProcess) { mapperProcess.kill(); mapperProcess = null; } else { exec('pkill ofxPiMapper'); } res.json({ status: 'stopped' }); }); app.post('/mapper/restart', (req, res) => { if (mapperProcess) mapperProcess.kill(); exec('pkill ofxPiMapper', () => { setTimeout(() => { mapperProcess = spawn('ofxPiMapper', ['-f']); mapperProcess.stdout.on('data', streamLogs); mapperProcess.stderr.on('data', streamLogs); res.json({ status: 'restarting' }); }, 1000); }); }); app.get('/mapper/status', (req, res) => { res.json({ status: mapperProcess ? 'running' : 'stopped' }); }); app.post('/mapper/save', (req, res) => { oscClient.send('/ofxPiMapper/save'); res.json({ status: 'saving' }); }); app.post('/system/reboot', (req, res) => { res.json({ status: 'rebooting' }); exec('sudo reboot'); }); app.post('/system/shutdown', (req, res) => { res.json({ status: 'shutting down' }); exec('sudo shutdown -h now'); }); const server = http.createServer(app); const io = new Server(server, { cors: { origin: "*" } }); const oscClient = new Client('127.0.0.1', 9999); io.on('connection', (socket) => { socket.on('vertex:move', (data) => { const { surfaceIndex, vertexIndex, x, y } = data; oscClient.send('/ofxPiMapper/vertex/move', surfaceIndex, vertexIndex, x, y); }); socket.on('surface:select', (data) => { oscClient.send('/ofxPiMapper/surface/select', data.surfaceIndex); }); socket.on('surface:add', (data) => { oscClient.send('/ofxPiMapper/surface/add', data.type); }); socket.on('surface:delete', (data) => { oscClient.send('/ofxPiMapper/surface/delete', data.surfaceIndex); }); socket.on('surface:highlight', (data) => { oscClient.send('/ofxPiMapper/surface/highlight', data.surfaceIndex); }); }); const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`Backend listening on port ${PORT}`); });