Initial commit
This commit is contained in:
294
server.js
Normal file
294
server.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user