diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42a7f05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +#Folders +/node_modules/ + +#Files +/config.json \ No newline at end of file diff --git a/arduino_thread.js b/arduino_thread.js new file mode 100644 index 0000000..b406ed6 --- /dev/null +++ b/arduino_thread.js @@ -0,0 +1,170 @@ +const { SerialPort } = require('serialport'); +const fs = require('fs'); +const EventEmitter = require('events'); +// add parser import +const { ReadlineParser } = require('@serialport/parser-readline'); + +class ArduinoThread extends EventEmitter { + constructor(comPort, baudRate = 9600, sliderCount = 3, yamlPath = 'config.json', verbose = false) { + super(); + + if (typeof comPort !== 'string') throw new Error('comPort must be a string'); + if (typeof baudRate !== 'number') throw new Error('baudRate must be a number'); + if (typeof sliderCount !== 'number') throw new Error('sliderCount must be a number'); + + this.port = new SerialPort({ path: comPort, baudRate, autoOpen: true }); + this.sliderCount = sliderCount; + this.yamlPath = yamlPath; // now points to config.json by default + this.verbose = !!verbose; + + this.sendBuffer = []; + this.updateList = Array(sliderCount).fill(0); + this.isRunning = true; + this.pendingMessage = null; + + // pipe through a newline-based parser so we always get full lines + try { + this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\n' })); + this.parser.on('data', (line) => this._handleData(line)); + } catch (err) { + // fallback to raw data event if parser unavailable + this.port.on('data', (data) => this._handleData(data)); + } + + // keep error/close handlers on the port + this.port.on('error', (err) => { + this.emit('error', err); + if (this.verbose) console.error('Serial error:', err); + }); + this.port.on('close', () => { + this.isRunning = false; + if (this.verbose) console.log('Serial port closed'); + }); + } + + _handleData(data) { + // accept string or Buffer; trim newline/whitespace + const input = (typeof data === 'string') ? data.trim() : data.toString().trim(); + + // acknowledgement handling + if (input === 'OK' && this.pendingMessage) { + if (this.verbose) console.log(`Message confirmed: ${this.pendingMessage}`); + this.pendingMessage = null; + // drain buffer if present + if (this.sendBuffer.length > 0) { + const next = this.sendBuffer.shift(); + this._writeMessage(next); + } + return; + } + + const numbers = input.split('|'); + if (numbers.length === this.sliderCount) { + try { + const values = numbers.map((n) => parseInt(n, 10)); + values.forEach((val, i) => { + if (!Number.isFinite(val)) throw new Error('parse error'); + if (val !== this.updateList[i]) { + this._setVolume(val, i); + } + }); + this.updateList = values; + this.emit('update', values); // notify listeners (GUI) + if (this.verbose) console.log('Slider update ->', values); + } catch (err) { + this.emit('warn', { msg: 'Invalid slider data', raw: input }); + if (this.verbose) console.warn('Invalid slider data:', input); + } + } else { + // Not an update; emit for consumers + this.emit('raw', input); + if (this.verbose) console.warn('Unexpected input:', input); + } + } + + send(message) { + if (!message || typeof message !== 'string') return; + if (this.pendingMessage) { + // queue if another message is awaiting ACK + this.sendBuffer.push(message); + return; + } + this._writeMessage(message); + } + + _writeMessage(message) { + if (!this.port || !this.port.writable) { + const err = new Error('Serial port not writable'); + this.emit('error', err); + if (this.verbose) console.error(err); + return; + } + + this.pendingMessage = message; + this.port.write(message + '\n', (err) => { + if (err) { + this.emit('error', err); + if (this.verbose) console.error('Error writing:', err.message); + this.pendingMessage = null; + } else if (this.verbose) { + console.log('Wrote message:', message); + } + }); + + // fallback timeout if device never replies OK + setTimeout(() => { + if (this.pendingMessage) { + this.emit('warn', { msg: "Did not receive OK from device", message: this.pendingMessage }); + if (this.verbose) console.warn('Did not receive OK from device for', this.pendingMessage); + this.pendingMessage = null; + // try to drain next queued message if any + if (this.sendBuffer.length > 0) { + const next = this.sendBuffer.shift(); + this._writeMessage(next); + } + } + }, 100); // adjustable timeout + } + + _setVolume(value, sliderNumber) { + const normalized = value / 1023; + + if (!fs.existsSync(this.yamlPath)) { + this.emit('warn', { msg: 'Config file missing', path: this.yamlPath }); + if (this.verbose) console.warn('Config file missing:', this.yamlPath); + return; + } + + try { + const file = fs.readFileSync(this.yamlPath, 'utf8'); + const config = JSON.parse(file || '{}'); // parse JSON config.json + const processes = config[sliderNumber] || []; + + processes.forEach((proc) => { + // Emit an event so the application can perform the platform-specific change. + this.emit('set-volume', { process: proc, value: normalized, slider: sliderNumber }); + if (this.verbose) { + console.log(`Would set volume for "${proc}" to ${normalized.toFixed(2)} (slider ${sliderNumber})`); + } + }); + } catch (err) { + this.emit('error', err); + if (this.verbose) console.error('Config JSON error:', err); + } + } + + stop() { + this.isRunning = false; + try { + if (this.port && this.port.isOpen) this.port.close(); + } catch (e) { + if (this.verbose) console.warn('Error closing port', e); + } + } + + getValues() { + return this.updateList.slice(); + } +} + +module.exports = ArduinoThread; diff --git a/index.html b/index.html new file mode 100644 index 0000000..89ec91a --- /dev/null +++ b/index.html @@ -0,0 +1,543 @@ + + + + + + Dreckshub + + + + + +
+ + +
+
+ +
+

Makropad — Main

+

Content for Makropad on the first tab.

+
+ + + +
+ +
+ +
+
+
+
+ + + +
+ +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+ +
+
+ +
+
+
+
+ +
+
+
+ + + +
+ +
+
+ +
+
+
+ +
+
+
+ + + +
+ +
+
+ +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+ + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..4bb34cc --- /dev/null +++ b/main.js @@ -0,0 +1,708 @@ +const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } = require("electron"); +const fs = require("fs"); +const path = require("path"); +const { exec } = require("child_process"); +const { execFile } = require("child_process"); +const { spawn } = require("child_process"); + +// add ArduinoThread module and instance holder +const ArduinoThread = require("./arduino_thread"); +let arduinoInstance = null; + +// NEW: persistent python server helpers (globals) +let pythonServer = null; +let pythonServerStdoutBuffer = ""; +let pythonServerReady = false; + +let mainWindow; +let tray = null; +let isQuiting = false; +const tagsFilePath = path.join(__dirname, "config.json"); + +// load a single app/tray icon to reuse (tray.png expected next to index.html/main.js) +const iconPath = path.join(__dirname, "tray.png"); +let appIcon; +try { + if (fs.existsSync(iconPath)) { + const img = nativeImage.createFromPath(iconPath); + // prefer a multi-size icon where possible; keep as-is if empty + appIcon = img && !img.isEmpty() ? img : undefined; + } +} catch (e) { + appIcon = undefined; +} + +function readConfig() { + try { + if (fs.existsSync(tagsFilePath)) { + return JSON.parse(fs.readFileSync(tagsFilePath, "utf8")); + } + } catch (err) { + console.error("readConfig error:", err); + } + return {}; +} + +function writeConfig(obj) { + try { + fs.writeFileSync(tagsFilePath, JSON.stringify(obj, null, 2)); + } catch (err) { + console.error("writeConfig error:", err); + } +} + +// helper to run a command and return stdout +function execPromise(cmd) { + return new Promise((resolve, reject) => { + exec(cmd, { encoding: "utf8", windowsHide: true }, (err, stdout, stderr) => { + if (err) return reject(err); + resolve(stdout || ""); + }); + }); +} + +// --- NEW: tray helpers --- +function createTray() { + try { + if (tray) return; + // use the shared appIcon if available + tray = new Tray(appIcon || undefined); + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show", + click: () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + } + }, + { + label: "Quit", + click: () => { + // user explicitly wants to quit + isQuiting = true; + app.quit(); + } + } + ]); + tray.setToolTip("Dreckshub"); + tray.setContextMenu(contextMenu); + tray.on("double-click", () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + }); + } catch (err) { + console.error("createTray failed:", err); + } +} + +function destroyTray() { + try { + if (tray) { + tray.destroy(); + tray = null; + } + } catch (err) { + console.error("destroyTray failed:", err); + } +} + +/* Ensure tray state matches config.closeToTray */ +function updateTrayState(enable) { + if (enable) { + createTray(); + } else { + destroyTray(); + } +} + +app.whenReady().then(() => { + // set macOS dock icon early so the app uses tray.png instead of default electron icon + if (process.platform === "darwin" && appIcon) { + try { app.dock && app.dock.setIcon && app.dock.setIcon(appIcon); } catch (err) { /* ignore */ } + } + + // restore saved window bounds if present + const cfg = readConfig(); + const wb = cfg.windowBounds || null; + const winOptions = { + // start with the typical fixed size used previously + width: 1466, + height: 642, + // prevent user resizing + resizable: false, + // set window/taskbar icon (Windows/Linux) + icon: appIcon || undefined, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + autoHideMenuBar: true, + }; + + if (wb && typeof wb.width === "number" && typeof wb.height === "number") { + winOptions.width = wb.width; + winOptions.height = wb.height; + if (typeof wb.x === "number" && typeof wb.y === "number") { + winOptions.x = wb.x; + winOptions.y = wb.y; + } + } + + mainWindow = new BrowserWindow(winOptions); + // window is non-resizable; no minimum size call needed + + mainWindow.loadFile("index.html"); + + // after content loads, apply maximized state if saved + mainWindow.webContents.once("did-finish-load", () => { + try { + if (wb && wb.isMaximized) { + mainWindow.maximize(); + } + } catch (err) { + console.error("apply maximize failed:", err); + } + }); + + // create tray if setting enabled in config + updateTrayState(Boolean(cfg && cfg.closeToTray)); + + // save bounds when window is closing + mainWindow.on("close", (e) => { + try { + // If close-to-tray is enabled and user didn't explicitly choose to quit, hide instead of closing + const cfgNow = readConfig(); + const useTray = Boolean(cfgNow && cfgNow.closeToTray); + if (useTray && !isQuiting) { + e.preventDefault(); + if (mainWindow) mainWindow.hide(); + return; + } + + // allow closing — save bounds as before + // getBounds returns the un-maximized bounds; good for restoring windowed state + const bounds = mainWindow.getBounds(); + const isMax = mainWindow.isMaximized(); + const curCfg = readConfig(); + curCfg.windowBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized: isMax, + }; + writeConfig(curCfg); + } catch (err) { + console.error("save window bounds failed:", err); + } + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +}); + +ipcMain.on("save-tags", (event, data) => { + // merge incoming tag data with existing config so windowBounds (and future fields) are preserved + try { + const cfg = readConfig() || {}; + // If data is an array => legacy behavior for slider0 + if (Array.isArray(data)) { + cfg.slider0 = data; + } else if (data && typeof data === "object") { + // copy all keys provided (slider0, slider1, slider2, sliderAssignments, etc.) + Object.keys(data).forEach((k) => { + cfg[k] = data[k]; + }); + } + writeConfig(cfg); + + // react to closeToTray changes immediately + try { + updateTrayState(Boolean(cfg.closeToTray)); + } catch (err) { + console.error("updateTrayState failed after save-tags:", err); + } + } catch (err) { + console.error("save-tags merge failed:", err); + } +}); + +ipcMain.handle("load-tags", () => { + const cfg = readConfig() || {}; + // ensure defaults for all three sliders for backward compatibility + if (!Array.isArray(cfg.slider0)) cfg.slider0 = []; + if (!Array.isArray(cfg.slider1)) cfg.slider1 = []; + if (!Array.isArray(cfg.slider2)) cfg.slider2 = []; + return cfg; +}); + +ipcMain.handle("list-serial-ports", async () => { + try { + // Windows: prefer .NET SerialPort.GetPortNames() via PowerShell (reliable, returns COM names) + if (process.platform === "win32") { + try { + const psCmd = `powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames() | ForEach-Object { $_ }"`; + const out = await execPromise(psCmd); + const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + if (lines.length > 0) { + // Ensure unique, return objects with label=value + const uniq = Array.from(new Set(lines)); + return uniq.map(p => ({ value: p, label: p })); + } + + // Fallback 1: Get-PnpDevice friendly names containing (COMx) + const psFallback = `powershell -NoProfile -Command "Get-PnpDevice -Status OK | Where-Object { $_.FriendlyName -match '\\(COM\\d+\\)' -or $_.Name -match '\\(COM\\d+\\)' } | ForEach-Object { ($_.FriendlyName -or $_.Name) }"`; + const out2 = await execPromise(psFallback); + const lines2 = out2.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + const ports = []; + const re = /(COM\d+)/i; + for (const l of lines2) { + const m = l.match(re); + if (m) ports.push({ value: m[1], label: l }); + } + if (ports.length > 0) { + // dedupe by value + const seen = new Set(); + return ports.filter(p => { + if (seen.has(p.value)) return false; + seen.add(p.value); + return true; + }); + } + + // Fallback 2: 'mode' command parsing + const out3 = await execPromise("mode"); + const lines3 = out3.split(/\r?\n/); + const reMode = /(COM\d+)/gi; + const portsMode = []; + for (const l of lines3) { + const m = l.match(reMode); + if (m) { + for (const token of m) portsMode.push({ value: token, label: token }); + } + } + return portsMode; + } catch (err) { + console.error("list-serial-ports (win) primary/fallback failed:", err); + return []; + } + } else { + // macOS / Linux: scan common device files + try { + const out = await execPromise("ls /dev/tty.* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null || true"); + const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + return lines.map(p => ({ value: p, label: p })); + } catch (err) { + console.error("list-serial-ports (unix) failed:", err); + return []; + } + } + } catch (err) { + console.error("list-serial-ports failed:", err); + return []; + } +}); + +// --- new IPC handlers to start/stop and forward serial updates --- +// Ensure we don't double-register handlers if the file is reloaded in dev reloads: +if (ipcMain.removeHandler) { + try { ipcMain.removeHandler("connect-com"); } catch (e) { /* ignore */ } +} +ipcMain.handle("connect-com", async (event, { port, baudRate = 9600 }) => { + try { + // if already connected, return info + if (arduinoInstance) { + return { ok: false, message: "Already connected" }; + } + if (!port) return { ok: false, message: "No port specified" }; + + // create and wire instance (verbose disabled to avoid noisy slider logs) + arduinoInstance = new ArduinoThread(port, Number(baudRate) || 9600, 3, "config.json", false); + + // Keep last normalized value per slider so we only act when the slider actually changed. + // Index = slider number (0..2). null means "unknown / not initialized". + let _lastSliderNormalized = [null, null, null]; + const _minSliderDeltaForChange = 0.01; // 1% change required to be considered different + + arduinoInstance.on("update", (values) => { + // forward to renderer + try { + if (mainWindow && mainWindow.webContents) { + mainWindow.webContents.send("serial-update", values); + } + } catch (e) { /* ignore */ } + + // Also ensure the Python helper is invoked for each slider value. + // This is a fallback in case the ArduinoThread did not emit 'set-volume'. + try { + const cfg = readConfig() || {}; + // values expected as integers (0..1023) + if (Array.isArray(values)) { + // ensure we have a session cache and refresher running + startSessionRefresher(); + values.forEach((raw, idx) => { + const num = Number(raw); + if (!Number.isFinite(num)) return; + let normalized = Math.max(0, Math.min(1, num / 1023)); + // snap near-zero to exact 0 to avoid tiny audible residuals + if (normalized < 0.02) normalized = 0; + + // only continue if the slider value changed enough since last time + const prev = _lastSliderNormalized[idx]; + const changed = prev === null || Math.abs(prev - normalized) > _minSliderDeltaForChange; + if (!changed) return; // skip unchanged slider + _lastSliderNormalized[idx] = normalized; + + const key = `slider${idx}`; + const procs = Array.isArray(cfg[key]) ? cfg[key] : []; + if (!procs || procs.length === 0) return; + + // match configured process names against running sessions (case-insensitive) + procs.forEach((proc) => { + if (!proc) return; + const procLow = String(proc).toLowerCase(); + const matched = _runningSessions.some(s => { + return s === procLow || s.includes(procLow) || procLow.includes(s); + }); + if (matched) { + // enqueue only when matched; DO NOT produce per-update verbose logs + runPythonSetVolume(proc, normalized, idx); + } + }); + }); + } + } catch (err) { + console.error("update handler fallback failed:", err); + sendVolumeLog(`update handler fallback failed: ${String(err)}`, true); + } + }); + arduinoInstance.on("raw", (raw) => { + try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-raw", raw); } catch (e) {} + }); + arduinoInstance.on("error", (err) => { + try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-error", String(err)); } catch (e) {} + }); + arduinoInstance.on("warn", (w) => { + try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-warn", w); } catch (e) {} + }); + + // --- new helper to call the python volume setter and emit logs to renderer --- + // send only concise important logs to renderer to avoid flooding DevTools + const _verboseVolumeLogs = false; + function sendVolumeLog(msg, force = false) { + try { + // keep main-process console for troubleshooting but avoid massive logs + if (_verboseVolumeLogs || force || /\b(error|failed|succeed|succeeded)\b/i.test(String(msg))) { + console.log(msg); + try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("volume-log", String(msg)); } catch (e) {} + } + } catch (e) { /* ignore */ } + } + + // cached running audio sessions (lowercase strings) + let _runningSessions = []; + let _sessionRefreshTimer = null; + const _sessionRefreshIntervalMs = 5000; // less frequent to avoid many spawns + + // ask set_volume.py to list sessions (tries several invocation patterns) + async function refreshRunningSessions() { + const script = path.join(__dirname, "set_volume.py"); + // Try invocation patterns; prefer "python