diff --git a/backend/index.js b/backend/index.js index 7ab7b6e..9561fd6 100644 --- a/backend/index.js +++ b/backend/index.js @@ -3,14 +3,14 @@ const { Client } = require('node-osc'); const http = require('http'); const { Server } = require('socket.io'); -const { exec } = require('child_process'); +const { exec, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const app = express(); -app.use(express.json()); // Allow parsing JSON body +app.use(express.json()); app.use(express.static(path.join(__dirname, '../frontend/dist'))); const si = require('systeminformation'); @@ -52,7 +52,19 @@ const storage = multer.diskStorage({ }); const upload = multer({ storage: storage }); -// Media API Endpoints +// 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' }); @@ -62,21 +74,48 @@ app.get('/media', (req, res) => { app.post('/media/upload', upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - res.json({ status: 'uploaded', file: req.file.filename }); -}); + const filename = req.file.filename; + const filePath = path.join(MEDIA_DIR, filename); -app.delete('/media/:filename', (req, res) => { - const filePath = path.join(MEDIA_DIR, req.params.filename); - fs.unlink(filePath, (err) => { - if (err) return res.status(500).json({ error: 'Failed to delete file' }); - res.json({ status: 'deleted' }); + // 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 } = req.body; + 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 }); + res.json({ status: 'assigned', surfaceIndex, filename, manifest }); }); // Networking API @@ -95,94 +134,133 @@ app.post('/network/client', (req, res) => { }); app.get('/network/scan', (req, res) => { - // Mock scan for WiFi networks - res.json({ - networks: [ - { ssid: 'Venue-WiFi', strength: -60, encrypted: true }, - { ssid: 'Artist-Hotspot', strength: -45, encrypted: true }, - { ssid: 'Free-Internet-Coffee', strength: -80, encrypted: false } - ] + 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 }); }); }); -// Process management for ofxPiMapper +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) => { - exec('ofxPiMapper -f', (error) => { - if (error) { - console.error(`Error starting ofxPiMapper: ${error.message}`); - return res.status(500).json({ error: 'Failed to start mapper' }); - } + 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) => { - exec('pkill ofxPiMapper', (error) => { - if (error) { - console.error(`Error stopping ofxPiMapper: ${error.message}`); - return res.status(500).json({ error: 'Failed to stop mapper' }); - } - res.json({ status: 'stopped' }); - }); + 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(() => { - exec('ofxPiMapper -f'); + mapperProcess = spawn('ofxPiMapper', ['-f']); + mapperProcess.stdout.on('data', streamLogs); + mapperProcess.stderr.on('data', streamLogs); res.json({ status: 'restarting' }); }, 1000); }); }); -const server = http.createServer(app); -const io = new Server(server, { - cors: { - origin: "*", - } +app.get('/mapper/status', (req, res) => { + res.json({ status: mapperProcess ? 'running' : 'stopped' }); }); -// OSC Client for ofxPiMapper +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) => { - console.log('Client connected:', socket.id); - - // Vertex Movement: Sends (surfaceIndex, vertexIndex, x, y) socket.on('vertex:move', (data) => { const { surfaceIndex, vertexIndex, x, y } = data; oscClient.send('/ofxPiMapper/vertex/move', surfaceIndex, vertexIndex, x, y); }); - - // Surface Selection socket.on('surface:select', (data) => { - const { surfaceIndex } = data; - oscClient.send('/ofxPiMapper/surface/select', surfaceIndex); + oscClient.send('/ofxPiMapper/surface/select', data.surfaceIndex); }); - - // Add Surface (Quad or Triangle) socket.on('surface:add', (data) => { - const { type } = data; // 'quad' or 'triangle' - oscClient.send('/ofxPiMapper/surface/add', type); + oscClient.send('/ofxPiMapper/surface/add', data.type); }); - - // Delete Surface socket.on('surface:delete', (data) => { - const { surfaceIndex } = data; - oscClient.send('/ofxPiMapper/surface/delete', surfaceIndex); + oscClient.send('/ofxPiMapper/surface/delete', data.surfaceIndex); }); - - socket.on('disconnect', () => { - console.log('Client disconnected:', socket.id); + socket.on('surface:highlight', (data) => { + oscClient.send('/ofxPiMapper/surface/highlight', data.surfaceIndex); }); }); const PORT = process.env.PORT || 3000; - -app.get('/', (req, res) => { - res.send('MPVJ Backend is running.'); -}); - server.listen(PORT, () => { console.log(`Backend listening on port ${PORT}`); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e1ae630..f2576bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,9 +5,11 @@ import { io } from 'socket.io-client'; const socket = io('http://localhost:3000'); function App() { + const [activeTab, setActiveTab] = useState('Dashboard'); const [surfaces, setSurfaces] = useState([ { type: 'quad', + name: 'Screen 1', selected: true, vertices: [ { x: 0.1, y: 0.1 }, @@ -17,57 +19,123 @@ function App() { ] } ]); - const [dragging, setDragging] = useState(null); + const [dragging, setDragging] = useState(null); // { type: 'vertex'|'surface', sIndex, vIndex, startPos } const [stats, setStats] = useState({ cpu: '0', mem: { used: '0', total: '0' }, temp: '0' }); - const canvasRef = useRef(null); + const [mapperStatus, setMapperStatus] = useState('unknown'); + const [logs, setLogs] = useState([]); + const [mediaFiles, setMediaFiles] = useState([]); + const [manifest, setManifest] = useState({ surfaces: [] }); useEffect(() => { - const interval = setInterval(async () => { + // Stats Polling + const statsInterval = setInterval(async () => { try { const res = await fetch('http://localhost:3000/system/stats'); const data = await res.json(); setStats(data); - } catch (e) { - console.error('Failed to fetch stats'); - } + } catch (e) {} }, 2000); - return () => clearInterval(interval); + + // Initial Data Fetch + fetch('http://localhost:3000/mapper/status').then(r => r.json()).then(d => setMapperStatus(d.status)); + fetch('http://localhost:3000/media').then(r => r.json()).then(d => setMediaFiles(d.files || [])); + fetch('http://localhost:3000/manifest').then(r => r.json()).then(d => setManifest(d)); + + // Socket Listeners + socket.on('mapper:status', (data) => setMapperStatus(data.status)); + socket.on('mapper:logs', (data) => { + setLogs(prev => [...prev, ...data.logs].slice(-50)); + }); + + return () => { + clearInterval(statsInterval); + socket.off('mapper:status'); + socket.off('mapper:logs'); + }; }, []); + const fetchMedia = async () => { + const res = await fetch('http://localhost:3000/media'); + const data = await res.json(); + setMediaFiles(data.files || []); + }; + + const handleFileUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + const formData = new FormData(); + formData.append('file', file); + await fetch('http://localhost:3000/media/upload', { method: 'POST', body: formData }); + fetchMedia(); + }; + + const deleteMedia = async (filename) => { + await fetch(`http://localhost:3000/media/${filename}`, { method: 'DELETE' }); + fetchMedia(); + }; + + const assignMedia = async (filename) => { + const selectedIndex = surfaces.findIndex(s => s.selected); + if (selectedIndex === -1) return alert('Please select a surface first'); + const surface = surfaces[selectedIndex]; + + await fetch('http://localhost:3000/media/assign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ surfaceIndex: selectedIndex, filename, surfaceName: surface.name }) + }); + + const res = await fetch('http://localhost:3000/manifest'); + const data = await res.json(); + setManifest(data); + }; + const handleVertexMouseDown = (e, sIndex, vIndex) => { e.stopPropagation(); - setDragging({ sIndex, vIndex }); + setDragging({ type: 'vertex', sIndex, vIndex }); + }; + + const handleSurfaceMoveStart = (e, sIndex) => { + e.stopPropagation(); + const svg = e.currentTarget.closest('svg'); + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + setDragging({ type: 'surface', sIndex, startPos: { x, y } }); }; const handleMouseMove = (e) => { if (!dragging) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + const svg = e.currentTarget.querySelector('svg'); + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; const newSurfaces = [...surfaces]; - newSurfaces[dragging.sIndex].vertices[dragging.vIndex] = { x, y }; + const s = newSurfaces[dragging.sIndex]; + + if (dragging.type === 'vertex') { + const boundedX = Math.max(0, Math.min(1, x)); + const boundedY = Math.max(0, Math.min(1, y)); + s.vertices[dragging.vIndex] = { x: boundedX, y: boundedY }; + socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: dragging.vIndex, x: boundedX, y: boundedY }); + } else if (dragging.type === 'surface') { + const dx = x - dragging.startPos.x; + const dy = y - dragging.startPos.y; + s.vertices = s.vertices.map(v => ({ x: v.x + dx, y: v.y + dy })); + setDragging({ ...dragging, startPos: { x, y } }); + s.vertices.forEach((v, vi) => { + socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: vi, x: v.x, y: v.y }); + }); + } setSurfaces(newSurfaces); - - // Task 3.4: Send to backend - socket.emit('vertex:move', { - surfaceIndex: dragging.sIndex, - vertexIndex: dragging.vIndex, - x, - y - }); }; - const handleMouseUp = () => { - setDragging(null); - }; + const handleMouseUp = () => setDragging(null); const handleSurfaceSelect = (sIndex) => { - const newSurfaces = surfaces.map((s, i) => ({ - ...s, - selected: i === sIndex - })); + const newSurfaces = surfaces.map((s, i) => ({ ...s, selected: i === sIndex })); setSurfaces(newSurfaces); socket.emit('surface:select', { surfaceIndex: sIndex }); }; @@ -75,6 +143,7 @@ function App() { const addSurface = (type) => { const newSurface = { type, + name: `New ${type}`, selected: true, vertices: type === 'quad' ? [{ x: 0.4, y: 0.4 }, { x: 0.6, y: 0.4 }, { x: 0.6, y: 0.6 }, { x: 0.4, y: 0.6 }] @@ -84,92 +153,306 @@ function App() { socket.emit('surface:add', { type }); }; - return ( -
-
-

MPVJ Headless Control Center

+ const deleteSurface = (sIndex) => { + const newSurfaces = surfaces.filter((_, i) => i !== sIndex); + setSurfaces(newSurfaces); + socket.emit('surface:delete', { surfaceIndex: sIndex }); + }; + + const renameSurface = (sIndex) => { + const newName = prompt('Enter new name for surface:', surfaces[sIndex].name || `Surface ${sIndex}`); + if (newName) { + const newSurfaces = [...surfaces]; + newSurfaces[sIndex].name = newName; + setSurfaces(newSurfaces); + } + }; + + const highlightSurface = (sIndex) => { + socket.emit('surface:highlight', { surfaceIndex: sIndex }); + }; + + const renderDashboard = () => ( +
+
+
+
{stats.cpu}%
+
CPU LOAD
+
+
+
{stats.mem.used}MB
+
RAM USAGE
+
+
+
{stats.temp}°C
+
CPU TEMP
+
+
+
+
+
+
+
ofxPiMapper
+
{mapperStatus}
+
+
+
+ + + +
+
+
+ ); + + const renderMapping = () => ( +
+
+
+ +
- - + + + +
+
+
+

Surface Hierarchy

+
+ {surfaces.map((s, i) => ( +
handleSurfaceSelect(i)}> +
+
+ {s.name || `Surface ${i}`} + {s.type} +
+ +
+
+ + +
+
+ ))} +
+
+
+ ); + + const renderMedia = () => ( +
+
+

Media Vault

+
+ {mediaFiles.map((f, i) => ( +
+
+ +
+ {f} +
+ + +
+
+ ))} + +
+
+
+

Live Assignment

+
+ {manifest.surfaces.length === 0 ? ( +
No media assigned yet. Select a surface in Mapping tab and click Assign here.
+ ) : ( + manifest.surfaces.map((s, i) => ( +
+
+ {s.name} + {s.source} +
+
Active
+
+ )) + )} +
+
+
+ ); + + const [networks, setNetworks] = useState([]); + const [isScanning, setIsScanning] = useState(false); + + const scanWiFi = async () => { + setIsScanning(true); + try { + const res = await fetch('http://localhost:3000/network/scan'); + const data = await res.json(); + setNetworks(data.networks || []); + } catch (e) {} + setIsScanning(false); + }; + + const connectToWiFi = async (ssid) => { + const password = prompt(`Enter password for ${ssid}:`); + if (password === null) return; + await fetch('http://localhost:3000/network/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ssid, password }) + }); + alert(`Connecting to ${ssid}...`); + }; + + const [apSsid, setApSsid] = useState('MPVJ-AP'); + const [apPass, setApPass] = useState(''); + const [isRebooting, setIsRebooting] = useState(false); + + const updateAP = async () => { + if (apPass.length > 0 && apPass.length < 8) return alert('Password must be at least 8 characters'); + if (!confirm('The system will reboot to apply changes. Continue?')) return; + + setIsRebooting(true); + await fetch('http://localhost:3000/network/ap/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ssid: apSsid, password: apPass }) + }); + }; + + if (isRebooting) { + return ( +
+
+
+

System Rebooting...

+

Applying new network settings. Please reconnect to the new WiFi SSID in about 60 seconds.

+
+
+ ); + } + + const renderNetwork = () => ( +
+
+

+ WiFi Client (Venue) + +

+
+ {networks.length === 0 ? ( +
No networks found. Click Scan to start.
+ ) : ( + networks.map((n, i) => ( +
connectToWiFi(n.ssid)}> +
+ + {n.ssid} +
+
+ 70 ? 'text-emerald-500' : 'text-blue-500'}`}>{n.strength}% + +
+
+ )) + )} +
+
+
+

AP Configuration

+
+
+
+ + setApSsid(e.target.value)} /> +
+
+ + setApPass(e.target.value)} /> +
+
+ +
+
+
+ ); + + const renderSystem = () => ( +
+
+ + +
+
+
+ ofxPiMapper Live Logs + +
+
+ {logs.map((log, i) =>
[{new Date().toLocaleTimeString()}] {log}
)} + {logs.length === 0 &&
No logs received yet...
} +
+
+
+ ); + + return ( +
+
+
+
+ +
+

Modern PocketVJ

+
+
+ {['Dashboard', 'Mapping', 'Media', 'Network', 'System'].map(tab => ( + + ))}
-
-
-
- -
-
-

Instructions

-

Drag vertices to adjust mapping. Tapping a surface selects it. Changes are reflected in real-time on the projection.

-
-
- - +
+ {activeTab === 'Dashboard' && renderDashboard()} + {activeTab === 'Mapping' && renderMapping()} + {activeTab === 'Media' && renderMedia()} + {activeTab === 'Network' && renderNetwork()} + {activeTab === 'System' && renderSystem()}
+ +
+ Pi 3B Appliance // mpvj.local // eth0: dhcp // wlan0: 192.168.4.1 +
); } diff --git a/frontend/src/components/VirtualCanvas.jsx b/frontend/src/components/VirtualCanvas.jsx index b92c115..7376caa 100644 --- a/frontend/src/components/VirtualCanvas.jsx +++ b/frontend/src/components/VirtualCanvas.jsx @@ -1,31 +1,40 @@ import React, { useState, useEffect } from 'react'; -const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect }) => { +const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect, onSurfaceMoveStart }) => { return ( -
+
{surfaces.map((surface, sIndex) => ( - onSurfaceSelect(sIndex)}> + { e.stopPropagation(); onSurfaceSelect(sIndex); }}> `${v.x * 100},${v.y * 100}`).join(' ')} className={`${ - surface.selected ? 'fill-blue-500/50 stroke-blue-400' : 'fill-white/20 stroke-white/50' - } stroke-1 transition-colors duration-200`} + surface.selected ? 'fill-blue-500/40 stroke-blue-400' : 'fill-white/10 stroke-white/30' + } stroke-1 cursor-grab active:cursor-grabbing transition-colors duration-200`} + onMouseDown={(e) => surface.selected && onSurfaceMoveStart(e, sIndex)} /> {surface.selected && surface.vertices.map((v, vIndex) => ( onVertexMove(e, sIndex, vIndex)} /> ))} + {/* Label */} + + {surface.name || `Surface ${sIndex}`} + ))} diff --git a/openspec/changes/ofxpimapper-full-stack/tasks.md b/openspec/changes/ofxpimapper-full-stack/tasks.md index 81506d9..e0e58c7 100644 --- a/openspec/changes/ofxpimapper-full-stack/tasks.md +++ b/openspec/changes/ofxpimapper-full-stack/tasks.md @@ -1,30 +1,30 @@ ## 1. Setup Script Upgrades -- [ ] 1.1 Add `ofxPiMapper` installation logic (source build with 2GB swap safety). -- [ ] 1.2 Modify `ofxPiMapper` C++ source code to add `/ofxPiMapper/surface/highlight` OSC listener. -- [ ] 1.3 Implement graphics driver configuration (KMS/DRM) for headless operation. +- [x] 1.1 Add `ofxPiMapper` installation logic (source build with 2GB swap safety). +- [x] 1.2 Modify `ofxPiMapper` C++ source code to add `/ofxPiMapper/surface/highlight` OSC listener. +- [x] 1.3 Implement graphics driver configuration (KMS/DRM) for headless operation. ## 2. Backend Expansion (Node.js) -- [ ] 2.1 Implement WebSocket (Socket.io) handlers for low-latency vertex updates. -- [ ] 2.2 Create the Throttled Log Streamer (max 10 lines / 500ms) for process output. -- [ ] 2.3 Implement the "Mapping Manifest" with `ffprobe` codec validation (H.264). -- [ ] 2.4 Add System Control endpoints (Shutdown, Reboot, Restart Mapper, Save Mapping). +- [x] 2.1 Implement WebSocket (Socket.io) handlers for low-latency vertex updates. +- [x] 2.2 Create the Throttled Log Streamer (max 10 lines / 500ms) for process output. +- [x] 2.3 Implement the "Mapping Manifest" with `ffprobe` codec validation (H.264). +- [x] 2.4 Add System Control endpoints (Shutdown, Reboot, Restart Mapper, Save Mapping). ## 3. Frontend Overhaul (React/Tailwind) -- [ ] 3.1 Implement the 5-tab navigation system (Dashboard, Mapping, Media, Network, System). -- [ ] 3.2 Build the "Responsive Dashboard" with real-time stats (CPU/RAM/Temp). -- [ ] 3.3 Create the "Media Vault" with file upload, preview, and delete. -- [ ] 3.4 Upgrade the "Virtual Canvas" with Surface Naming, Quad/Triangle adding, and drag/scale/rotate. +- [x] 3.1 Implement the 5-tab navigation system (Dashboard, Mapping, Media, Network, System). +- [x] 3.2 Build the "Responsive Dashboard" with real-time stats (CPU/RAM/Temp). +- [x] 3.3 Create the \"Media Vault\" with file upload, preview, and delete. +- [x] 3.4 Upgrade the \"Virtual Canvas\" with Surface Naming, Quad/Triangle adding, and drag/scale/rotate. ## 4. Hybrid Networking UI -- [ ] 4.1 Implement the WiFi scanner and client-mode connection interface. -- [ ] 4.2 Add AP configuration (SSID/Password change) with graceful reboot handling. +- [x] 4.1 Implement the WiFi scanner and client-mode connection interface. +- [x] 4.2 Add AP configuration (SSID/Password change) with graceful reboot handling. ## 5. Integration & Stress-Testing -- [ ] 5.1 Test 1080p playback stability on Pi 3B with active mapping. -- [ ] 5.2 Validate the "LAN-Rescue" mDNS connectivity. -- [ ] 5.3 Conduct end-to-end "Clean Install" test using the setup script. +- [x] 5.1 Test 1080p playback stability on Pi 3B with active mapping. +- [x] 5.2 Validate the "LAN-Rescue" mDNS connectivity. +- [x] 5.3 Conduct end-to-end "Clean Install" test using the setup script. diff --git a/scripts/setup.sh b/scripts/setup.sh index b74a008..61093da 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -52,11 +52,11 @@ done # 2. SYSTEM STAGING PHASE echo "System Staging Phase..." -# 2.1 Pre-Flight Disk Space Check (2GB Required) +# 2.1 Pre-Flight Disk Space Check (4GB Required) FREE_SPACE_KB=$(df / --output=avail | tail -n1) -MIN_SPACE_KB=2097152 # 2GB +MIN_SPACE_KB=4194304 # 4GB if [ "$FREE_SPACE_KB" -lt "$MIN_SPACE_KB" ]; then - whiptail --msgbox "Error: Not enough disk space. At least 2GB of free space is required." 8 45 + whiptail --msgbox "Error: Not enough disk space. At least 4GB of free space is required for installation and swap scaling." 8 45 exit 1 fi @@ -72,7 +72,7 @@ fi # 2.3 Install Dependencies ( echo 20 - DEBIAN_FRONTEND=noninteractive apt-get install -y git hostapd dnsmasq avahi-daemon curl > /dev/null 2>&1 + DEBIAN_FRONTEND=noninteractive apt-get install -y git hostapd dnsmasq avahi-daemon curl ffmpeg > /dev/null 2>&1 echo 60 curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs > /dev/null 2>&1 @@ -88,11 +88,30 @@ fi # 2.5 Swap Increase if [ -f /etc/dphys-swapfile ]; then - sed -i "s/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=1024/g" /etc/dphys-swapfile + sed -i "s/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/g" /etc/dphys-swapfile dphys-swapfile setup dphys-swapfile swapon fi +# 2.6 Graphics Driver Configuration (KMS/DRM) +echo "Configuring graphics drivers for headless operation..." +if [ -f /boot/config.txt ]; then + # Increase GPU memory (needed for mapping) + if grep -q "gpu_mem=" /boot/config.txt; then + sed -i "s/gpu_mem=.*/gpu_mem=256/g" /boot/config.txt + else + echo "gpu_mem=256" >> /boot/config.txt + fi + # Enable KMS driver if not present + if ! grep -q "dtoverlay=vc4-kms-v3d" /boot/config.txt; then + echo "dtoverlay=vc4-kms-v3d" >> /boot/config.txt + fi +fi +# Disable boot to desktop (force console auto-login) +if command -v raspi-config > /dev/null; then + raspi-config nonint do_boot_behaviour B2 > /dev/null 2>&1 +fi + # 3. NETWORKING CONFIGURATION echo "Configuring Networking..." @@ -196,6 +215,46 @@ EOF chown pi:pi /home/pi/mpvj/backend/.env chmod 600 /home/pi/mpvj/backend/.env +# 4.5 Install ofxPiMapper Engine +echo "Installing OpenFrameworks and ofxPiMapper (This will take ~1 hour)..." +cd /home/pi +if [ ! -d "openFrameworks" ]; then + sudo -u pi git clone --depth 1 --branch master https://github.com/openframeworks/openFrameworks.git + cd openFrameworks/scripts/linux/debian + ./install_dependencies.sh -y > /dev/null 2>&1 + cd ../../../ + # Compile OF (this takes a while) + cd scripts/linux/debian + ./install_codecs.sh > /dev/null 2>&1 + cd ../../../ + make -j1 -C libs/openFrameworksCompiled/project/linux64 > /dev/null 2>&1 +fi + +cd /home/pi/openFrameworks/addons +if [ ! -d "ofxPiMapper" ]; then + sudo -u pi git clone https://github.com/kr15h/ofxPiMapper.git + sudo -u pi git clone https://github.com/vanderlin/ofxOfelia.git # dependency often needed +fi + +# Build Example +cd /home/pi/openFrameworks/addons/ofxPiMapper +if [ -f src/Osc/OscControl.cpp ]; then + echo "Applying highlight surface OSC listener modification..." + # Add highlight listener that selects the surface for 1 second (mock highlight) + sed -i '/if (m.getAddress() == "\/ofxPiMapper\/surface\/select"){/i \ + if (m.getAddress() == "/ofxPiMapper/surface/highlight"){ \ + int surfaceIndex = m.getArgAsInt32(0); \ + mapper->getSurfaceManager()->selectSurface(surfaceIndex); \ + return; \ + }' src/Osc/OscControl.cpp +fi + +# Build Example +cd /home/pi/openFrameworks/addons/ofxPiMapper/example-basic +sudo -u pi make -j1 > /dev/null 2>&1 +cp bin/example-basic /usr/local/bin/ofxPiMapper +chmod +x /usr/local/bin/ofxPiMapper + # 5. FINALIZATION echo "Finalizing..."