295 lines
8.4 KiB
JavaScript
295 lines
8.4 KiB
JavaScript
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();
|
|
});
|