const { SerialPort } = require('serialport'); const { ReadlineParser } = require('@serialport/parser-readline'); const { keyboard, Key } = require('@nut-tree-fork/nut-js'); const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const si = require('systeminformation'); const app = express(); app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'ui', 'dist'))); const CONFIG_FILE = path.join(__dirname, 'config.json'); const sseClients = []; // Default configuration structure let config = { portPath: "auto", buttons: {} }; // Initialize 16 default buttons if empty for(let i=0; i<16; i++) { config.buttons[i] = { actionType: "none", // "key", "cmd", "none" key: "", cmd: "", color: { r: 0, g: 0, b: 50 }, // Dim blue pressedColor: { r: 255, g: 0, b: 0 } // Red }; } if (fs.existsSync(CONFIG_FILE)) { try { const savedConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); config = { ...config, ...savedConfig }; } catch (e) { console.error("Failed to parse config.json, resetting to default config.", e); } } function saveConfig() { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } let serialPort = null; let parser = null; let reconnectTimeout = null; let isConnecting = false; function normalizeKeyName(name) { const mapping = { 'ctrl': 'LeftControl', 'control': 'LeftControl', 'leftcontrol': 'LeftControl', 'rightcontrol': 'RightControl', 'shift': 'LeftShift', 'leftshift': 'LeftShift', 'rightshift': 'RightShift', 'alt': 'LeftAlt', 'leftalt': 'LeftAlt', 'rightalt': 'RightAlt', 'win': 'LeftSuper', 'super': 'LeftSuper', 'cmd': 'LeftSuper', 'command': 'LeftSuper', 'leftsuper': 'LeftSuper', 'rightsuper': 'RightSuper', 'enter': 'Enter', 'escape': 'Escape', 'space': 'Space', 'tab': 'Tab', 'backspace': 'Backspace', 'delete': 'Delete', 'up': 'Up', 'down': 'Down', 'left': 'Left', 'right': 'Right' }; const norm = name.trim().toLowerCase(); if (mapping[norm]) return mapping[norm]; if (norm.length === 1) return norm.toUpperCase(); if (/^f\d+$/.test(norm)) { return 'F' + norm.substring(1); } return name.trim().charAt(0).toUpperCase() + name.trim().slice(1); } async function connectToPico() { if (isConnecting) return; isConnecting = true; if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } let portPath = config.portPath; if (portPath === "auto") { try { const ports = await SerialPort.list(); // The Pico's Vendor ID is 2E8A. The Arduino core PID for USB Serial is 0005 (or similar depending on stack) const picoPort = ports.find(p => p.vendorId && p.vendorId.toUpperCase() === '2E8A'); if (picoPort) { portPath = picoPort.path; console.log(`Found Raspberry Pi Pico on ${portPath}`); } else { console.log("Could not auto-detect Raspberry Pi Pico. Retrying in 5 seconds..."); isConnecting = false; scheduleReconnect(); return; } } catch(e) { console.error(e); isConnecting = false; scheduleReconnect(); return; } } if (serialPort && serialPort.isOpen) { serialPort.removeAllListeners('close'); serialPort.close(); } serialPort = new SerialPort({ path: portPath, baudRate: 115200 }, (err) => { isConnecting = false; if (err) { console.log('Error opening port: ', err.message); scheduleReconnect(); return; } console.log("Connected to Pico!"); // Sync all LEDs to their idle states setTimeout(() => { for(let i=0; i<16; i++) { setLedColor(i, config.buttons[i].color.r, config.buttons[i].color.g, config.buttons[i].color.b); } }, 1000); }); serialPort.on('error', (err) => { console.error('Serial port error: ', err.message); }); parser = serialPort.pipe(new ReadlineParser({ delimiter: '\n' })); parser.on('data', async (line) => { line = line.trim(); if (line.startsWith("P,")) { const id = parseInt(line.split(',')[1]); console.log(`[EVENT] Button ${id} Pressed`); sseClients.forEach(c => c.write(`data: ${JSON.stringify({ type: 'press', id })}\n\n`)); setLedColor(id, config.buttons[id].pressedColor.r, config.buttons[id].pressedColor.g, config.buttons[id].pressedColor.b); // Execute Macro const btn = config.buttons[id]; if (btn.actionType === "key" && btn.key) { try { const parts = btn.key.split('+').map(p => p.trim()); const keysToPress = []; let isCombo = true; for (const p of parts) { const normalized = normalizeKeyName(p); if (Key[normalized] !== undefined) { keysToPress.push(Key[normalized]); } else { isCombo = false; break; } } if (isCombo && keysToPress.length > 0) { await keyboard.type(...keysToPress); } else { // If it's not a recognizable combo of Key properties, type it out as a raw string await keyboard.type(btn.key); } } catch(e) { console.error("Key Error", e); } } else if (btn.actionType === "cmd" && btn.cmd) { console.log(`Executing: ${btn.cmd}`); exec(btn.cmd, (error, stdout, stderr) => { if (error) { console.error(`exec error: ${error}`); return; } }); } else if (btn.actionType === "mute_app" && btn.cmd) { console.log(`Toggling mute for: ${btn.cmd}`); if (process.platform === 'win32') { const svvPath = path.join(__dirname, 'SoundVolumeView.exe'); exec(`"${svvPath}" /Switch "${btn.cmd}"`, (error, stdout, stderr) => { if (error) console.error(`Mute error: ${error}\n${stderr}`); }); } else if (process.platform === 'linux') { const cleanAppName = btn.cmd.replace(/\.exe$/i, '').toLowerCase(); exec('pactl list sink-inputs', (error, stdout, stderr) => { if (error) { console.error(`pactl error: ${error}\n${stderr}`); return; } const blocks = stdout.split(/Sink Input #/i); let found = false; for (const block of blocks) { if (!block.trim()) continue; const lines = block.split('\n'); const index = parseInt(lines[0].trim()); if (isNaN(index)) continue; const blockLower = block.toLowerCase(); const hasAppName = blockLower.includes(`application.name = "${cleanAppName}"`) || blockLower.includes(`application.name = '${cleanAppName}'`) || blockLower.includes(`application.process.binary = "${cleanAppName}"`) || blockLower.includes(`application.process.binary = '${cleanAppName}'`) || blockLower.includes(`media.name = "${cleanAppName}"`); if (hasAppName) { found = true; console.log(`Found stream index ${index} for ${cleanAppName}, toggling mute...`); exec(`pactl set-sink-input-mute ${index} toggle`, (err) => { if (err) console.error(`Failed to mute sink input ${index}: ${err}`); }); } } if (!found) { console.log(`Application '${cleanAppName}' not found in active audio streams.`); } }); } else { console.warn(`mute_app is not supported on platform: ${process.platform}`); } } } else if (line.startsWith("R,")) { const id = parseInt(line.split(',')[1]); console.log(`[EVENT] Button ${id} Released`); sseClients.forEach(c => c.write(`data: ${JSON.stringify({ type: 'release', id })}\n\n`)); if (config.buttons[id].actionType !== "cpu_monitor") { setLedColor(id, config.buttons[id].color.r, config.buttons[id].color.g, config.buttons[id].color.b); } } }); serialPort.on('close', () => { console.log("Port closed, reconnecting in 5s..."); scheduleReconnect(); }); } function scheduleReconnect() { if (!reconnectTimeout) { reconnectTimeout = setTimeout(() => { isConnecting = false; connectToPico(); }, 5000); } } function setLedColor(id, r, g, b) { if (serialPort && serialPort.isOpen) { serialPort.write(`C,${id},${r},${g},${b}\n`); } } // REST API for Frontend Configuration UI app.get('/api/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); sseClients.push(res); req.on('close', () => { const index = sseClients.indexOf(res); if (index !== -1) sseClients.splice(index, 1); }); }); app.get('/api/config', (req, res) => { res.json(config); }); app.post('/api/config', (req, res) => { config = req.body; saveConfig(); res.json({ success: true }); // Update colors immediately on the device for(let i=0; i<16; i++) { if (config.buttons[i].actionType !== "cpu_monitor") { setLedColor(i, config.buttons[i].color.r, config.buttons[i].color.g, config.buttons[i].color.b); } } }); // CPU Polling Loop setInterval(async () => { try { const hasCpuMonitor = Object.values(config.buttons).some(b => b.actionType === "cpu_monitor"); if (!hasCpuMonitor) return; const load = await si.currentLoad(); const cpu = load.currentLoad; // 0 to 100 let r=0, g=0, b=0; if (cpu < 30) { g = 150; } // Green else if (cpu < 60) { r = 150; g = 150; } // Yellow else if (cpu < 85) { r = 200; g = 50; } // Orange else { r = 255; } // Red for(let i=0; i<16; i++) { if (config.buttons[i].actionType === "cpu_monitor") { setLedColor(i, r, g, b); } } } catch (e) { console.error("CPU Poll Error", e); } }, 1000); app.listen(3001, () => { console.log("-----------------------------------------"); console.log("Macropad Server listening on port 3001"); console.log("-----------------------------------------"); connectToPico(); });