Initial commit

This commit is contained in:
Robert Chery
2026-07-08 19:16:44 -04:00
commit 4b45c031e0
24 changed files with 6680 additions and 0 deletions

294
server.js Normal file
View File

@@ -0,0 +1,294 @@
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);
});
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}`);
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 (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();
});