diff --git a/apps/electron-app/package.json b/apps/electron-app/package.json index f79edb3e..55421b9b 100644 --- a/apps/electron-app/package.json +++ b/apps/electron-app/package.json @@ -25,7 +25,8 @@ "publish:arm64": "electron-forge publish --arch arm64", "lint": "echo \"No linting configured\"", "test:boards": "npx tsx ./test-board.ts", - "test": "npx jest" + "test": "npx jest", + "clean:vite": "rm -rf node_modules/.vite .vite" }, "keywords": [], "devDependencies": { diff --git a/apps/electron-app/src/common/nodes.ts b/apps/electron-app/src/common/nodes.ts index b1866f1d..284d10c1 100644 --- a/apps/electron-app/src/common/nodes.ts +++ b/apps/electron-app/src/common/nodes.ts @@ -10,7 +10,7 @@ import { Motion } from '../render/components/react-flow/nodes/Motion'; import { Mqtt } from '../render/components/react-flow/nodes/Mqtt'; import { Note } from '../render/components/react-flow/nodes/Note'; import { Oscillator } from '../render/components/react-flow/nodes/Oscillator'; -import { Pixel } from '../render/components/react-flow/nodes/Pixel'; +import { Pixel } from '../render/components/react-flow/nodes/pixel/Pixel'; import { Piezo } from '../render/components/react-flow/nodes/piezo/Piezo'; import { RangeMap } from '../render/components/react-flow/nodes/RangeMap'; import { Rgb } from '../render/components/react-flow/nodes/RGB'; diff --git a/apps/electron-app/src/main/board-connection.ts b/apps/electron-app/src/main/board-connection.ts new file mode 100644 index 00000000..d230aa74 --- /dev/null +++ b/apps/electron-app/src/main/board-connection.ts @@ -0,0 +1,301 @@ +import { + BOARDS, + Flasher, + getConnectedPorts, + UnableToOpenSerialConnection, + type BoardName, + type PortInfo, +} from '@microflow/flasher'; +import type { Edge, Node } from '@xyflow/react'; +import { fork, ChildProcess } from 'child_process'; +import { sendMessageToRenderer } from './window'; +import { Board, IpcResponse, UploadedCodeMessage } from '../common/types'; +import { getRandomMessage } from '../common/messages'; +import log from 'electron-log/node'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { + PortDisconnectedError, + getConnectedPort, + setConnectedPort, + getKnownBoardsWithPorts, +} from './port-manager'; +import { Timer } from './utils'; + +const ipRegex = new RegExp( + /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/ +); + +let runnerProcess: ChildProcess | undefined; +let lastUsedPinsHash: string | null = null; + +/** + * Gets the current runner process + */ +export function getRunnerProcess(): ChildProcess | undefined { + return runnerProcess; +} + +/** + * Kills the runner process and clears the connected port + */ +export async function killRunnerProcess() { + runnerProcess?.kill('SIGKILL'); + runnerProcess = undefined; + setConnectedPort(undefined); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for the process to die +} + +async function checkPortError(error: unknown, portPath: string, context: string = 'operation') { + if (error instanceof PortDisconnectedError) { + throw error; + } + + // Check if it's a port-related error + const isPortError = + error instanceof UnableToOpenSerialConnection || + (error instanceof Error && + (error.message.includes('No such file or directory') || + error.message.includes('cannot open'))); + + if (isPortError) { + const ports = await getConnectedPorts(); + const portStillExists = ports.find(p => p.path === portPath); + + if (!portStillExists) { + throw new PortDisconnectedError(portPath, `Port ${portPath} disconnected during ${context}`); + } + } + + // If port still exists or it's not a port error, let the original error propagate +} + +/** + * Checks if pins have changed between flow executions + */ +async function didPinsChange(nodes: Node[]) { + const pins = nodes + .map(node => { + if ('pins' in node.data) return Object.values(node.data.pins as Record); + if ('pin' in node.data) return [node.data.pin]; + }) + .flat(); + + // TODO: this can be a bit more efficient + // E.g., If we add new pins, it is okay. + const pinsHash = pins.sort().join(','); + if (!lastUsedPinsHash || pinsHash === lastUsedPinsHash) return false; + lastUsedPinsHash = pinsHash; + return true; +} + +export async function ensureRunnerProcess(nodes: Node[], edges: Edge[], ip?: string) { + if (!runnerProcess) return startRunnerProcess(ip); + + if (await didPinsChange(nodes)) { + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'info', message: 'Reconfiguring microcontroller...' }, + }); + await killRunnerProcess(); + await startRunnerProcess(ip); + } +} + +export async function startRunnerProcess(ip?: string) { + const timer = new Timer(); + + const boardOverIp: Awaited> = [ + ['BOARD_OVER_IP' as BoardName, [{ path: ip ?? '' } as PortInfo]], + ]; + + const boardsAndPorts = ip ? boardOverIp : await getKnownBoardsWithPorts(); + + if (!boardsAndPorts.length) { + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'close', message: 'No boards found' }, + }); + return; + } + + checkBoard: for (const [board, ports] of boardsAndPorts) { + for (const port of ports) { + log.debug('[CHECK] ', board, port.path, timer.duration); + + try { + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'info', port: port.path, message: `Connecting to ${port.path}` }, + }); + + await checkBoardOnPort(port, board); + setConnectedPort(port); + log.debug(`[CHECK] ${port.path}`, timer.duration); + break checkBoard; + } catch (error) { + await killRunnerProcess(); + + // If port was disconnected, skip it and continue checking other ports + if (error instanceof PortDisconnectedError) { + log.warn('[CHECK] ', board, port.path, error.message); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'info', message: `${port.path} disconnected, checking other boards...` }, + }); + continue; // Continue to next port + } + + log.warn('[CHECK] ', board, port.path, error); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'info', message: (error as any).message ?? getRandomMessage('wait') }, + }); + } + } + } + + if (!getConnectedPort()) { + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'warn', message: 'Unable to connect to board' }, + }); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'close', message: 'No board found' }, + }); + return; + } +} + +async function checkBoardOnPort(port: Pick, board: BoardName) { + await killRunnerProcess(); + + const timer = new Timer(); + const filePath = join(__dirname, 'workers', 'runner.js'); + + return new Promise((resolve, reject) => { + log.debug('[RUNNER] ', filePath, timer.duration); + runnerProcess = fork(filePath, [port.path], { + // serviceName: 'Microflow studio - microcontroller validator', + stdio: 'pipe', + }); + + runnerProcess.on('spawn', () => { + log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration); + }); + + runnerProcess.stderr?.on('data', async data => { + log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration, data.toString()); + sendMessageToRenderer('ipc-board', { + success: false, + error: data.toString(), + }); + }); + + runnerProcess.stdout?.on('data', async data => { + log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration, data.toString()); + }); + + async function handleMessage(data: Board | UploadedCodeMessage) { + // log.debug('[RUNNER] ', runnerProcess?.pid, data.type, timer.duration); + try { + switch (data.type) { + case 'message': + sendMessageToRenderer('ipc-microcontroller', { + success: true, + data: data, + }); + break; + case 'error': + log.warn(`[RUNNER] <${data.type}>`, runnerProcess?.pid, data.message, timer.duration); + let notificationTimeout: NodeJS.Timeout | null = null; + try { + if (ipRegex.test(port.path)) { + return reject(new Error(data.message ?? 'Unknown error')); + } + + // Prevents double error messages from causing multiple flashers + runnerProcess?.off('message', handleMessage); + + notificationTimeout = setTimeout(() => { + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'info', + message: getRandomMessage('wait'), + }, + } satisfies IpcResponse); + }, 7500); + await flashFirmataToBoard(board, port); + return checkBoardOnPort(port, board); + } catch (error) { + try { + await checkPortError(error, port.path, 'flashing'); + // Port still exists or not a port error - reject with original error + reject(error); + } catch (portError) { + // Port disconnected or already PortDisconnectedError - reject with port error + reject(portError); + } + } finally { + if (notificationTimeout) clearTimeout(notificationTimeout); + } + break; + case 'close': + case 'exit': + case 'fail': + log.warn(`[RUNNER] <${data.type}>`, runnerProcess?.pid, data.message, timer.duration); + reject(new Error(data.message ?? 'Unknown error')); + break; + case 'ready': + log.debug(`[RUNNER] <${data.type}>`, runnerProcess?.pid, timer.duration); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'ready', port: port.path, pins: data.pins }, + }); + resolve(null); + break; + } + } catch (e) { + reject(e); + } + } + + runnerProcess?.on('message', handleMessage); + }); +} + +async function flashFirmataToBoard(board: BoardName, port: Pick) { + const flashTimer = new Timer(); + + const firmataPath = resolve(__dirname, 'hex', board, 'StandardFirmata.ino.hex'); + + // Check if file exists + if (!existsSync(firmataPath)) { + log.error('[FLASH] ', 'Firmata file not found', firmataPath); + throw new Error(`[FLASH] Firmata file not found at ${firmataPath}`); + } + + await killRunnerProcess(); + log.debug('[FLASH] ', firmataPath, board, port.path, flashTimer.duration); + return new Promise(async (resolve, reject) => { + try { + log.debug(`[FLASH] `, flashTimer.duration); + await new Flasher(board, port.path).flash(firmataPath); + log.debug('[FLASH] ', flashTimer.duration); + resolve(null); + } catch (flashError) { + log.error('[FLASH] ', flashError, flashTimer.duration); + + try { + await checkPortError(flashError, port.path, 'flashing'); + // Port still exists but couldn't open - preserve original error + reject(flashError); + } catch (portError) { + // Port disconnected or already PortDisconnectedError - reject with port error + reject(portError); + } + } + }); +} diff --git a/apps/electron-app/src/main/ipc.ts b/apps/electron-app/src/main/ipc.ts index 9e4b4da9..c0d33e81 100644 --- a/apps/electron-app/src/main/ipc.ts +++ b/apps/electron-app/src/main/ipc.ts @@ -1,27 +1,12 @@ -import { - BOARDS, - Flasher, - getConnectedPorts, - type BoardName, - type PortInfo, -} from '@microflow/flasher'; import type { Edge, Node } from '@xyflow/react'; -import { app, ipcMain, Menu, session } from 'electron'; -import { fork, ChildProcess } from 'child_process'; -import { mainWindowReady, sendMessageToRenderer } from './window'; +import { app, ipcMain, Menu } from 'electron'; +import { mainWindowReady } from './window'; import log from 'electron-log/node'; -import { existsSync, writeFile } from 'fs'; -import { join, resolve } from 'path'; -import { Board, IpcResponse, UploadedCodeMessage } from '../common/types'; import { exportFlow } from './file'; -import { getRandomMessage } from '../common/messages'; - -// ipcMain.on("shell:open", () => { -// const pageDirectory = __dirname.replace('app.asar', 'app.asar.unpacked') -// const pagePath = path.join('file://', pageDirectory, 'index.html') -// shell.openExternal(pagePath) -// }) +import { ensureRunnerProcess, getRunnerProcess, killRunnerProcess } from './board-connection'; +import { checkConnectedPort, setupUSBDeviceListeners, stopPortPolling } from './port-manager'; +import { Timer } from './utils'; ipcMain.on('ipc-export-flow', async (_event, data: { nodes: Node[]; edges: Edge[] }) => { await exportFlow(data.nodes, data.edges); @@ -42,8 +27,6 @@ ipcMain.on('ipc-menu', async (_event, data: { action: string; args: any }) => { } }); -let runnerProcess: ChildProcess | undefined; -let connectedPort: PortInfo | undefined; ipcMain.on('ipc-flow', async (event, data: { ip?: string; nodes: Node[]; edges: Edge[] }) => { const timer = new Timer(); @@ -51,552 +34,34 @@ ipcMain.on('ipc-flow', async (event, data: { ip?: string; nodes: Node[]; edges: await ensureRunnerProcess(data.nodes, data.edges, data.ip); - log.debug('[FLOW] ', runnerProcess?.pid, timer.duration); + const runnerProcess = getRunnerProcess(); + log.debug( + '[FLOW] ', + runnerProcess?.pid, + JSON.stringify(data.nodes, null, 2), + JSON.stringify(data.edges, null, 2), + timer.duration + ); runnerProcess?.send({ type: 'flow', nodes: data.nodes, edges: data.edges }); }); -async function ensureRunnerProcess(nodes: Node[], edges: Edge[], ip?: string) { - if (!runnerProcess) return startRunnerProcess(ip); - - if (await didPinsChange(nodes)) { - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'info', message: 'Reconfiguring microcontroller...' }, - }); - await killRunnerProcess(); - await startRunnerProcess(ip); - } -} - -async function startRunnerProcess(ip?: string) { - const timer = new Timer(); - - const boardOverIp: Awaited> = [ - ['BOARD_OVER_IP' as BoardName, [{ path: ip ?? '' } as PortInfo]], - ]; - - const boardsAndPorts = ip ? boardOverIp : await getKnownBoardsWithPorts(); - - if (!boardsAndPorts.length) { - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: 'No boards found' }, - }); - return; - } - - checkBoard: for (const [board, ports] of boardsAndPorts) { - for (const port of ports) { - log.debug('[CHECK] ', board, port.path, timer.duration); - - try { - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'info', port: port.path, message: `Connecting to ${port.path}` }, - }); - - await checkBoardOnPort(port, board); - connectedPort = port; - log.debug(`[CHECK] ${port.path}`, timer.duration); - break checkBoard; - } catch (error) { - await killRunnerProcess(); - log.warn('[CHECK] ', board, port.path, error); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'info', message: (error as any).message ?? getRandomMessage('wait') }, - }); - } - } - } - - if (!connectedPort) { - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'warn', message: 'Unable to connect to board' }, - }); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: 'No board found' }, - }); - return; - } -} - -async function killRunnerProcess() { - runnerProcess?.kill('SIGKILL'); - runnerProcess = undefined; - connectedPort = undefined; - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for the process to die -} - -let lastUsedPinsHash: string | null = null; -async function didPinsChange(nodes: Node[]) { - const pins = nodes - .map(node => { - if ('pins' in node.data) return Object.values(node.data.pins as Record); - if ('pin' in node.data) return [node.data.pin]; - }) - .flat(); - - // TODO: this can be a bit more efficient - // E.g., If we add new pins, it is okay. - const pinsHash = pins.sort().join(','); - if (!lastUsedPinsHash || pinsHash === lastUsedPinsHash) return false; - lastUsedPinsHash = pinsHash; - return true; -} - -const ipRegex = new RegExp( - /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/ -); - -async function checkBoardOnPort(port: Pick, board: BoardName) { - await killRunnerProcess(); - - const timer = new Timer(); - const filePath = join(__dirname, 'workers', 'runner.js'); - - return new Promise((resolve, reject) => { - log.debug('[RUNNER] ', filePath, timer.duration); - runnerProcess = fork(filePath, [port.path], { - // serviceName: 'Microflow studio - microcontroller validator', - stdio: 'pipe', - }); - - runnerProcess.on('spawn', () => { - log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration); - }); - - runnerProcess.stderr?.on('data', async data => { - log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration, data.toString()); - sendMessageToRenderer('ipc-board', { - success: false, - error: data.toString(), - }); - }); - - runnerProcess.stdout?.on('data', async data => { - log.debug('[RUNNER] ', runnerProcess?.pid, timer.duration, data.toString()); - }); - - async function handleMessage(data: Board | UploadedCodeMessage) { - // log.debug('[RUNNER] ', runnerProcess?.pid, data.type, timer.duration); - try { - switch (data.type) { - case 'message': - sendMessageToRenderer('ipc-microcontroller', { - success: true, - data: data, - }); - break; - case 'error': - log.warn(`[RUNNER] <${data.type}>`, runnerProcess?.pid, data.message, timer.duration); - let notificationTimeout: NodeJS.Timeout | null = null; - try { - if (ipRegex.test(port.path)) { - return reject(new Error(data.message ?? 'Unknown error')); - } - - // Prevents double error messages from causing multiple flashers - runnerProcess?.off('message', handleMessage); - - notificationTimeout = setTimeout(() => { - sendMessageToRenderer('ipc-board', { - success: true, - data: { - type: 'info', - message: getRandomMessage('wait'), - }, - } satisfies IpcResponse); - }, 7500); - await flashBoard(board, port); - return checkBoardOnPort(port, board); - } catch (error) { - reject(error); - } finally { - if (notificationTimeout) clearTimeout(notificationTimeout); - } - break; - case 'close': - case 'exit': - case 'fail': - log.warn(`[RUNNER] <${data.type}>`, runnerProcess?.pid, data.message, timer.duration); - reject(new Error(data.message ?? 'Unknown error')); - break; - case 'ready': - log.debug(`[RUNNER] <${data.type}>`, runnerProcess?.pid, timer.duration); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'ready', port: port.path, pins: data.pins }, - }); - resolve(); - break; - } - } catch (e) { - reject(e); - } - } - - runnerProcess?.on('message', handleMessage); - }); -} - -async function flashBoard(board: BoardName, port: Pick): Promise { - const flashTimer = new Timer(); - - const firmataPath = resolve(__dirname, 'hex', board, 'StandardFirmata.ino.hex'); - - // Check if file exists - if (!existsSync(firmataPath)) { - log.error('[FLASH] ', 'Firmata file not found', firmataPath); - throw new Error(`[FLASH] Firmata file not found at ${firmataPath}`); - } - - await killRunnerProcess(); - log.debug('[FLASH] ', firmataPath, board, port.path, flashTimer.duration); - return new Promise(async (resolve, reject) => { - try { - log.debug(`[FLASH] `, flashTimer.duration); - await new Flasher(board, port.path).flash(firmataPath); - log.debug('[FLASH] ', flashTimer.duration); - resolve(); - } catch (flashError) { - log.error('[FLASH] ', flashError, flashTimer.duration); - reject(new Error(getRandomMessage('wait'))); - } - }); -} - ipcMain.on('ipc-external-value', (_event, data: { nodeId: string; value: unknown }) => { log.debug('[EXTERNAL] ', data); + const runnerProcess = getRunnerProcess(); runnerProcess?.send({ type: 'setExternal', nodeId: data.nodeId, value: data.value }); }); -/** - * Converts a USB device product ID (number) to a lowercase hex string for matching - */ -function productIdToHex(productId: number): string { - return productId.toString(16).padStart(4, '0').toLowerCase(); -} - -/** - * Checks if a USB device matches any known board by product ID - */ -function isKnownBoard(productId: number): boolean { - const productIdHex = productIdToHex(productId); - return BOARDS.some(board => board.productIds.includes(productIdHex as never)); -} - -/** - * Checks if the currently connected port still exists - */ -async function checkConnectedPort() { - if (!connectedPort) return; - - const ports = await getConnectedPorts(); - const portStillExists = ports.find(({ path }) => path === connectedPort?.path); - - if (!portStillExists) { - log.debug('[PORTS] ', connectedPort?.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: `${connectedPort?.path} is no longer connected` }, - }); - await killRunnerProcess(); - } -} - -/** - * Sets up device event listeners using Electron's native session API - * Uses serial port events for Arduino devices (which appear as serial ports) - */ -function setupUSBDeviceListeners() { - const defaultSession = session.defaultSession; - - // Set up device permission handler to automatically grant permissions - defaultSession.setDevicePermissionHandler(details => { - log.debug('[DEVICE] ', { - deviceType: details.deviceType, - origin: details.origin, - device: details.device, - }); - - // Auto-grant permissions for serial ports (Arduino devices) - if (details.deviceType === 'serial') { - return true; - } - - // Auto-grant permissions for USB devices if they match known boards - if (details.deviceType === 'usb' && details.device) { - const productId = (details.device as any).productId; - if (productId && isKnownBoard(productId)) { - return true; - } - } - - return false; - }); - - // Handle select-serial-port event - this enables serial port monitoring - defaultSession.on('select-serial-port', (event, portList, webContents, callback) => { - log.debug('[SERIAL] ', { - portCount: portList.length, - ports: portList.map(p => ({ - portId: p.portId, - portName: p.portName, - displayName: p.displayName, - })), - }); - - // Cancel the selection - we handle port selection ourselves - // Note: serial-port-added/removed events only fire when handling this event - event.preventDefault(); - // Pass empty string to cancel the selection - callback(''); - }); - - // Handle serial port added (Arduino devices appear as serial ports) - defaultSession.on('serial-port-added', async (_event, port) => { - log.debug('[SERIAL] ', { - portId: port.portId, - portName: port.portName, - displayName: port.displayName, - vendorId: port.vendorId, - productId: port.productId, - }); - - // Check if this is a known board - // port.productId is a number (USB product ID) - const productId = typeof port.productId === 'number' ? port.productId : undefined; - if (productId !== undefined && isKnownBoard(productId)) { - log.debug('[SERIAL] ', productIdToHex(productId), port.portName); - - // Wait a bit for the port to be fully available - setTimeout(async () => { - const ports = await getConnectedPorts(); - const matchingPort = ports.find(p => { - // Try to match by port name/path or product ID - if (p.path === port.portName) return true; - const portProductId = p.productId || p.pnpId; - if (portProductId && productId !== undefined) { - return productIdToHex(productId) === portProductId.toLowerCase(); - } - return false; - }); - - if (matchingPort) { - log.debug('[PORTS] ', matchingPort.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'connect', message: 'New port connected' }, - }); - } - }, 500); - } - }); - - // Handle serial port removed - defaultSession.on('serial-port-removed', async (_event, port) => { - log.debug('[SERIAL] ', { - portId: port.portId, - portName: port.portName, - displayName: port.displayName, - }); - - // Check if this was the connected port - if ( - connectedPort && - (connectedPort.path === port.portName || connectedPort.path === port.displayName) - ) { - log.debug('[PORTS] ', connectedPort.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: `${connectedPort.path} is no longer connected` }, - }); - await killRunnerProcess(); - } - }); - - // Handle serial port revoked - defaultSession.on('serial-port-revoked', async (_event, details) => { - log.debug('[SERIAL] ', details); - - // When a port is revoked, check if our connected port is still accessible - if (connectedPort) { - const ports = await getConnectedPorts(); - const portStillExists = ports.find(({ path }) => path === connectedPort?.path); - - if (!portStillExists) { - log.debug('[SERIAL] ', connectedPort.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: `${connectedPort.path} access was revoked` }, - }); - await killRunnerProcess(); - } - } - }); - - // Also listen to USB device events as a fallback (though they may not fire for serial devices) - defaultSession.on('usb-device-added', async (_event, device) => { - log.debug('[USB] ', { - vendorId: device.vendorId, - productId: device.productId, - serialNumber: device.serialNumber, - }); - - // Check if this is a known board - if (isKnownBoard(device.productId)) { - log.debug('[USB] ', productIdToHex(device.productId)); - - // Wait a bit for the serial port to be created - setTimeout(async () => { - const ports = await getConnectedPorts(); - const newPorts = ports.filter(port => { - const portProductId = port.productId || port.pnpId; - if (!portProductId) return false; - return productIdToHex(device.productId) === portProductId.toLowerCase(); - }); - - if (newPorts.length > 0) { - log.debug('[PORTS] ', newPorts.map(p => p.path).join(', ')); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'connect', message: 'New port connected' }, - }); - } - }, 500); - } - }); - - defaultSession.on('usb-device-removed', async (_event, device) => { - log.debug('[USB] ', { - vendorId: device.vendorId, - productId: device.productId, - serialNumber: device.serialNumber, - }); - - // Check if this is a known board - if (isKnownBoard(device.productId)) { - log.debug('[USB] ', productIdToHex(device.productId)); - - // Check if this was the connected port - if (connectedPort) { - const ports = await getConnectedPorts(); - const portStillExists = ports.find(({ path }) => path === connectedPort?.path); - - if (!portStillExists) { - log.debug('[PORTS] ', connectedPort?.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: `${connectedPort?.path} is no longer connected` }, - }); - await killRunnerProcess(); - } - } - } - }); - - log.debug('[DEVICE] ', 'Device event listeners initialized (serial + USB)'); - - // Fallback: Lightweight polling since Electron events may not fire automatically - // These events are tied to Web Serial/USB API usage from renderer - // We'll poll less frequently as a fallback - startPortPolling(); -} - -const PORT_POLL_INTERVAL_MS = 1000; // Poll every second as fallback -let portPollingInterval: NodeJS.Timeout | null = null; -let lastKnownPorts: PortInfo[] = []; - -async function startPortPolling() { - // Initial port list - lastKnownPorts = await getConnectedPorts(); - - portPollingInterval = setInterval(async () => { - const currentPorts = await getConnectedPorts(); - - // Check for disconnected port - if (connectedPort && !currentPorts.find(p => p.path === connectedPort?.path)) { - log.debug('[POLL] ', connectedPort.path); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'close', message: `${connectedPort.path} is no longer connected` }, - }); - await killRunnerProcess(); - } - - // Check for new ports - if (currentPorts.length > lastKnownPorts.length) { - const newPorts = currentPorts.filter(p => !lastKnownPorts.find(lp => lp.path === p.path)); - - if (newPorts.length > 0) { - log.debug('[POLL] ', newPorts.map(p => p.path).join(', ')); - sendMessageToRenderer('ipc-board', { - success: true, - data: { type: 'connect', message: 'New port connected' }, - }); - } - } - - lastKnownPorts = currentPorts; - }, PORT_POLL_INTERVAL_MS); - - log.debug('[POLL] ', `Polling every ${PORT_POLL_INTERVAL_MS}ms as fallback`); -} - -async function getKnownBoardsWithPorts() { - try { - const ports = await getConnectedPorts(); - - const boardsWithPorts = BOARDS.reduce( - (acc, board) => { - const matchingDevices = ports.filter(port => { - const productId = port.productId || port.pnpId; - if (!productId) return false; - return board.productIds.includes(productId.toLowerCase() as never); - }); - - if (matchingDevices.length) { - acc.push([board.name, matchingDevices]); - } - - return acc; - }, - [] as [BoardName, PortInfo[]][] - ); - - log.debug('[PORTS] ', boardsWithPorts.length); - boardsWithPorts.forEach(([board, devices]) => { - log.debug('[PORTS] ', board, devices.map(device => device.path).join(', ')); - }); - - return boardsWithPorts; - } catch (error) { - log.warn('[PORTS] ', error); - return []; - } -} - -class Timer { - constructor(private readonly startTime = performance.now()) {} - - get duration() { - return performance.now() - this.startTime + 'ms'; - } -} - killRunnerProcess().catch(log.debug); app.on('before-quit', async event => { log.debug('[PROCESS] ', event); void killRunnerProcess(); + stopPortPolling(); }); function waitForMainWindow() { if (mainWindowReady) { - setupUSBDeviceListeners(); + setupUSBDeviceListeners(killRunnerProcess); // Initial check for connected port checkConnectedPort(); return; diff --git a/apps/electron-app/src/main/port-manager.ts b/apps/electron-app/src/main/port-manager.ts new file mode 100644 index 00000000..f226fd88 --- /dev/null +++ b/apps/electron-app/src/main/port-manager.ts @@ -0,0 +1,378 @@ +import { BOARDS, getConnectedPorts, type BoardName, type PortInfo } from '@microflow/flasher'; +import { session } from 'electron'; +import log from 'electron-log/node'; +import { sendMessageToRenderer } from './window'; +import { Board } from '../common/types'; + +/** + * Error thrown when a port is no longer available (device disconnected) + * This allows the board checking loop to continue to the next port/board + */ +export class PortDisconnectedError extends Error { + constructor( + public readonly portPath: string, + message?: string + ) { + super(message ?? `Port ${portPath} is no longer available`); + this.name = 'PortDisconnectedError'; + } +} + +let connectedPort: PortInfo | undefined; +let portPollingInterval: NodeJS.Timeout | null = null; +let lastKnownPorts: PortInfo[] = []; +const PORT_POLL_INTERVAL_MS = 1000; // Poll every second as fallback + +/** + * Gets the currently connected port + */ +export function getConnectedPort(): PortInfo | undefined { + return connectedPort; +} + +/** + * Sets the currently connected port + */ +export function setConnectedPort(port: PortInfo | undefined): void { + connectedPort = port; +} + +/** + * Converts a USB device product ID (number) to a lowercase hex string for matching + */ +function productIdToHex(productId: number): string { + return productId.toString(16).padStart(4, '0').toLowerCase(); +} + +/** + * Checks if a USB device matches any known board by product ID + */ +function isKnownBoard(productId: number): boolean { + const productIdHex = productIdToHex(productId); + return BOARDS.some(board => board.productIds.includes(productIdHex as never)); +} + +/** + * Checks if the currently connected port still exists + */ +export async function checkConnectedPort(): Promise { + if (!connectedPort) return; + + const ports = await getConnectedPorts(); + const portStillExists = ports.find(({ path }) => path === connectedPort?.path); + + if (!portStillExists) { + log.debug('[PORTS] ', connectedPort?.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'close', + port: connectedPort?.path, + message: `${connectedPort?.path} is no longer connected`, + }, + }); + } +} + +/** + * Gets all known boards with their matching ports + */ +export async function getKnownBoardsWithPorts(): Promise<[BoardName, PortInfo[]][]> { + try { + const ports = await getConnectedPorts(); + + const boardsWithPorts = BOARDS.reduce( + (acc, board) => { + const matchingDevices = ports.filter(port => { + const productId = port.productId || port.pnpId; + if (!productId) return false; + return board.productIds.includes(productId.toLowerCase() as never); + }); + + if (matchingDevices.length) { + acc.push([board.name, matchingDevices]); + } + + return acc; + }, + [] as [BoardName, PortInfo[]][] + ); + + log.debug('[PORTS] ', boardsWithPorts.length); + boardsWithPorts.forEach(([board, devices]) => { + log.debug('[PORTS] ', board, devices.map(device => device.path).join(', ')); + }); + + return boardsWithPorts; + } catch (error) { + log.warn('[PORTS] ', error); + return []; + } +} + +/** + * Starts polling for port changes as a fallback mechanism + */ +export function startPortPolling(onPortDisconnected: () => Promise): void { + // Initial port list + getConnectedPorts().then(ports => { + lastKnownPorts = ports; + }); + + portPollingInterval = setInterval(async () => { + const currentPorts = await getConnectedPorts(); + + // Check for disconnected port + if (connectedPort && !currentPorts.find(p => p.path === connectedPort?.path)) { + log.debug('[POLL] ', connectedPort.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'close', + port: connectedPort.path, + message: `${connectedPort.path} is no longer connected`, + }, + }); + await onPortDisconnected(); + } + + // Check for new ports + if (currentPorts.length > lastKnownPorts.length) { + const newPorts = currentPorts.filter(p => !lastKnownPorts.find(lp => lp.path === p.path)); + + if (newPorts.length > 0) { + log.debug('[POLL] ', newPorts.map(p => p.path).join(', ')); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'connect', message: 'New port connected' }, + }); + } + } + + lastKnownPorts = currentPorts; + }, PORT_POLL_INTERVAL_MS); + + log.debug('[POLL] ', `Polling every ${PORT_POLL_INTERVAL_MS}ms as fallback`); +} + +/** + * Stops port polling + */ +export function stopPortPolling(): void { + if (portPollingInterval) { + clearInterval(portPollingInterval); + portPollingInterval = null; + log.debug('[POLL] '); + } +} + +/** + * Sets up device event listeners using Electron's native session API + * Uses serial port events for Arduino devices (which appear as serial ports) + */ +export function setupUSBDeviceListeners(onPortDisconnected: () => Promise): void { + const defaultSession = session.defaultSession; + + // Set up device permission handler to automatically grant permissions + defaultSession.setDevicePermissionHandler(details => { + log.debug('[DEVICE] ', { + deviceType: details.deviceType, + origin: details.origin, + device: details.device, + }); + + // Auto-grant permissions for serial ports (Arduino devices) + if (details.deviceType === 'serial') { + return true; + } + + // Auto-grant permissions for USB devices if they match known boards + if (details.deviceType === 'usb' && details.device) { + const productId = (details.device as any).productId; + if (productId && isKnownBoard(productId)) { + return true; + } + } + + return false; + }); + + // Handle select-serial-port event - this enables serial port monitoring + defaultSession.on('select-serial-port', (event, portList, webContents, callback) => { + log.debug('[SERIAL] ', { + portCount: portList.length, + ports: portList.map(p => ({ + portId: p.portId, + portName: p.portName, + displayName: p.displayName, + })), + }); + + // Cancel the selection - we handle port selection ourselves + // Note: serial-port-added/removed events only fire when handling this event + event.preventDefault(); + // Pass empty string to cancel the selection + callback(''); + }); + + // Handle serial port added (Arduino devices appear as serial ports) + defaultSession.on('serial-port-added', async (_event, port) => { + log.debug('[SERIAL] ', { + portId: port.portId, + portName: port.portName, + displayName: port.displayName, + vendorId: port.vendorId, + productId: port.productId, + }); + + // Check if this is a known board + // port.productId is a number (USB product ID) + const productId = typeof port.productId === 'number' ? port.productId : undefined; + if (productId !== undefined && isKnownBoard(productId)) { + log.debug('[SERIAL] ', productIdToHex(productId), port.portName); + + // Wait a bit for the port to be fully available + setTimeout(async () => { + const ports = await getConnectedPorts(); + const matchingPort = ports.find(p => { + // Try to match by port name/path or product ID + if (p.path === port.portName) return true; + const portProductId = p.productId || p.pnpId; + if (portProductId && productId !== undefined) { + return productIdToHex(productId) === portProductId.toLowerCase(); + } + return false; + }); + + if (matchingPort) { + log.debug('[PORTS] ', matchingPort.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'connect', message: 'New port connected' }, + }); + } + }, 500); + } + }); + + // Handle serial port removed + defaultSession.on('serial-port-removed', async (_event, port) => { + log.debug('[SERIAL] ', { + portId: port.portId, + portName: port.portName, + displayName: port.displayName, + }); + + // Check if this was the connected port + if ( + connectedPort && + (connectedPort.path === port.portName || connectedPort.path === port.displayName) + ) { + log.debug('[PORTS] ', connectedPort.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'close', + port: connectedPort.path, + message: `${connectedPort.path} is no longer connected`, + }, + }); + await onPortDisconnected(); + } + }); + + // Handle serial port revoked + defaultSession.on('serial-port-revoked', async (_event, details) => { + log.debug('[SERIAL] ', details); + + // When a port is revoked, check if our connected port is still accessible + if (connectedPort) { + const ports = await getConnectedPorts(); + const portStillExists = ports.find(({ path }) => path === connectedPort?.path); + + if (!portStillExists) { + log.debug('[SERIAL] ', connectedPort.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'close', + port: connectedPort.path, + message: `${connectedPort.path} access was revoked`, + }, + }); + await onPortDisconnected(); + } + } + }); + + // Also listen to USB device events as a fallback (though they may not fire for serial devices) + defaultSession.on('usb-device-added', async (_event, device) => { + log.debug('[USB] ', { + vendorId: device.vendorId, + productId: device.productId, + serialNumber: device.serialNumber, + }); + + // Check if this is a known board + if (isKnownBoard(device.productId)) { + log.debug('[USB] ', productIdToHex(device.productId)); + + // Wait a bit for the serial port to be created + setTimeout(async () => { + const ports = await getConnectedPorts(); + const newPorts = ports.filter(port => { + const portProductId = port.productId || port.pnpId; + if (!portProductId) return false; + return productIdToHex(device.productId) === portProductId.toLowerCase(); + }); + + if (newPorts.length > 0) { + log.debug('[PORTS] ', newPorts.map(p => p.path).join(', ')); + sendMessageToRenderer('ipc-board', { + success: true, + data: { type: 'connect', message: 'New port connected' }, + }); + } + }, 500); + } + }); + + defaultSession.on('usb-device-removed', async (_event, device) => { + log.debug('[USB] ', { + vendorId: device.vendorId, + productId: device.productId, + serialNumber: device.serialNumber, + }); + + // Check if this is a known board + if (isKnownBoard(device.productId)) { + log.debug('[USB] ', productIdToHex(device.productId)); + + // Check if this was the connected port + if (connectedPort) { + const ports = await getConnectedPorts(); + const portStillExists = ports.find(({ path }) => path === connectedPort?.path); + + if (!portStillExists) { + log.debug('[PORTS] ', connectedPort?.path); + sendMessageToRenderer('ipc-board', { + success: true, + data: { + type: 'close', + port: connectedPort?.path, + message: `${connectedPort?.path} is no longer connected`, + }, + }); + await onPortDisconnected(); + } + } + } + }); + + log.debug('[DEVICE] ', 'Device event listeners initialized (serial + USB)'); + + // Fallback: Lightweight polling since Electron events may not fire automatically + // These events are tied to Web Serial/USB API usage from renderer + // We'll poll less frequently as a fallback + startPortPolling(onPortDisconnected); +} diff --git a/apps/electron-app/src/main/utils.ts b/apps/electron-app/src/main/utils.ts new file mode 100644 index 00000000..d41f57c3 --- /dev/null +++ b/apps/electron-app/src/main/utils.ts @@ -0,0 +1,10 @@ +/** + * Timer utility class for measuring elapsed time + */ +export class Timer { + constructor(private readonly startTime = performance.now()) {} + + get duration() { + return performance.now() - this.startTime + 'ms'; + } +} diff --git a/apps/electron-app/src/main/window.ts b/apps/electron-app/src/main/window.ts index d68f5bc2..4dbe7186 100644 --- a/apps/electron-app/src/main/window.ts +++ b/apps/electron-app/src/main/window.ts @@ -50,7 +50,7 @@ export function handleSecondInstance(commandLine: string[]) { export async function recreateWindowWhenNeeded() { if (windows.length === 0) { await createWindow(); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 500)); } return Promise.resolve(); @@ -59,12 +59,14 @@ export async function recreateWindowWhenNeeded() { // Check if we're in development mode const isDevelopment = !!app.isPackaged; +const [mainWindowWidth, mainWindowHeight] = [1024, 768]; + export async function createWindow() { const window = new BrowserWindow({ - width: 1024, - minWidth: 1024, - height: 768, - minHeight: 768, + width: mainWindowWidth, + minWidth: mainWindowWidth, + height: mainWindowHeight, + minHeight: mainWindowHeight, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, diff --git a/apps/electron-app/src/render/components/react-flow/nodes/pixel/Pixel.tsx b/apps/electron-app/src/render/components/react-flow/nodes/pixel/Pixel.tsx new file mode 100644 index 00000000..d537c8a4 --- /dev/null +++ b/apps/electron-app/src/render/components/react-flow/nodes/pixel/Pixel.tsx @@ -0,0 +1,219 @@ +import { type Data, type Value, dataSchema } from '@microflow/runtime/src/pixel/pixel.types'; +import { COLORS, DEFAULT_OFF_PIXEL_COLOR } from '@microflow/runtime/src/pixel/pixel.constants'; +import { BaseNode, NodeContainer, useNodeControls, useNodeData } from '../Node'; +import { Handle } from '../../Handle'; +import { Position } from '@xyflow/react'; +import { usePins } from '../../../../stores/board'; +import { MODES } from '../../../../../common/types'; +import { reducePinsToOptions } from '../../../../../common/pin'; +import { useNodeValue } from '../../../../stores/node-data'; +import { folder, button } from 'leva'; +import { + Icons, + Button, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@microflow/ui'; +import { useState, useMemo } from 'react'; +import { PixelEditor } from './PixelEditor'; +import { PixelDisplay } from './PixelDisplay'; + +// Create a simple hash for the preset to use as a key +function presetKey(preset: Value, index: number): string { + return `preset-${index}-${JSON.stringify(preset)}`; +} + +export function Pixel(props: Props) { + return ( + + + + + + + + + + ); +} + +function Value() { + const data = useNodeData(); + const value = useNodeValue(Array(data.length).fill(DEFAULT_OFF_PIXEL_COLOR)); + + return ; +} + +function Settings() { + const data = useNodeData(); + const pins = usePins([MODES.OUTPUT], [MODES.ANALOG]); + const [editorOpened, setEditorOpened] = useState(false); + const [presets, setPresets] = useState(data.presets ?? [[]]); + + const { render, setNodeData } = useNodeControls({ + pin: { + value: data.pin, + options: pins.reduce(reducePinsToOptions, {}), + label: 'pin', + }, + length: { + value: data.length, + min: 1, + max: 144, + step: 1, + }, + 'edit presets': button(() => setEditorOpened(true)), + advanced: folder( + { + gamma: { + value: data.gamma, + min: 0, + max: 10, + step: 0.1, + }, + color_order: { + value: data.color_order, + label: 'color order', + hint: 'The order of the colors in the pixel strip', + options: COLORS, + }, + }, + { collapsed: true } + ), + }); + + function updatePresets(newPresets: Value[]) { + setPresets(newPresets); + data.presets = newPresets; + setNodeData(data); + } + + function swapPresets(left: number, right: number) { + const nextPresets = [...presets]; + nextPresets[left] = presets[right]; + nextPresets[right] = presets[left]; + updatePresets(nextPresets); + } + + return ( + <> + {render()} + {editorOpened && ( + + + + Presets + + When showing a preset the input handle will round to the closest preset number + + +
+ + + {presets.map((preset, index) => { + return ( + + { + const nextPresets = [...presets]; + nextPresets[index] = newPreset; + updatePresets(nextPresets); + }} + onDelete={() => { + const nextPresets = [...presets]; + nextPresets.splice(index, 1); + updatePresets(nextPresets); + }} + > +
+
+ +
+
+
+
+ +
+ Preset #{index + 1} of {presets.length} +
+ +
+
+ ); + })} +
+ + +
+
+ updatePresets([...presets, newPreset])} + length={data.length} + preset={[]} + > + + +
+
+ )} + + ); +} + +type Props = BaseNode; +Pixel.defaultProps = { + data: { + ...dataSchema.parse({}), + group: 'hardware', + tags: ['output', 'analog'], + label: 'LED Strip', + icon: 'RainbowIcon', + description: 'Control a strip of addressable RGB LEDs (WS2812, NeoPixel, etc.)', + } satisfies Props['data'], +}; diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Pixel.tsx b/apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelDisplay.tsx similarity index 51% rename from apps/electron-app/src/render/components/react-flow/nodes/Pixel.tsx rename to apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelDisplay.tsx index edf3cb71..08870a88 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Pixel.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelDisplay.tsx @@ -1,42 +1,14 @@ -import { type Data, type Value, dataSchema } from '@microflow/runtime/src/pixel/pixel.types'; -import { COLORS } from '@microflow/runtime/src/pixel/pixel.constants'; -import { BaseNode, NodeContainer, useNodeControls, useNodeData } from './Node'; -import { Handle } from '../Handle'; -import { Position } from '@xyflow/react'; -import { usePins } from '../../../stores/board'; -import { MODES } from '../../../../common/types'; -import { reducePinsToOptions } from '../../../../common/pin'; -import { useNodeValue } from '../../../stores/node-data'; -import { folder } from 'leva'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/components/ui/tooltip'; import { Icons } from '@microflow/ui'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/components/ui/tooltip'; import { useMemo } from 'react'; - -export function Pixel(props: Props) { - return ( - - - - - - - - - - ); -} - -type GridItem = { index: number; row: number; col: number }; +import type { Value } from '@microflow/runtime/src/pixel/pixel.types'; +import { DEFAULT_OFF_PIXEL_COLOR } from '@microflow/runtime/src/pixel/pixel.constants'; const LEDS_PER_ROW = 8; const totalCols = LEDS_PER_ROW + 1; // +1 for the icon column +type GridItem = { index: number; row: number; col: number }; + function createSnakeLayout(length: number): GridItem[] { const gridItems: GridItem[] = []; @@ -109,14 +81,30 @@ function createSnakeLayout(length: number): GridItem[] { return gridItems; } -function Value() { - const data = useNodeData(); - const value = useNodeValue(Array(data.length).fill('#000000')); +type Props = { + value: Value; + length: number; + showLabel?: boolean; + onPixelClick?: (index: number) => void; + selectedPixel?: number | null; +}; + +export function PixelDisplay({ + value, + length, + showLabel = false, + onPixelClick, + selectedPixel, +}: Props) { + const gridItems = useMemo(() => createSnakeLayout(length), [length]); - const gridItems = useMemo(() => createSnakeLayout(data.length), [data.length]); + // Ensure value array matches length + const paddedValue = useMemo(() => { + return Array.from({ length }, (_, i) => value[i] || DEFAULT_OFF_PIXEL_COLOR); + }, [value, length]); return ( -
+
onPixelClick?.(item.index) : undefined} + /> + ); + + if (isClickable) { + return ( + + {pixelElement} + + Pixel {item.index + 1}: {color} + + + ); + } + return ( - -
- + {pixelElement} LED {item.index + 1}: {color} @@ -158,57 +169,9 @@ function Value() { ); })}
-
{`${value.length} LEDs`}
+ {showLabel && ( +
{`${paddedValue.length} LEDs`}
+ )}
); } - -function Settings() { - const data = useNodeData(); - const pins = usePins([MODES.OUTPUT], [MODES.ANALOG]); - - const { render } = useNodeControls({ - pin: { - value: data.pin, - options: pins.reduce(reducePinsToOptions, {}), - label: 'pin', - }, - length: { - value: data.length, - min: 1, - max: 144, - step: 1, - }, - advanced: folder( - { - gamma: { - value: data.gamma, - min: 0, - max: 10, - step: 0.1, - }, - color_order: { - value: data.color_order, - label: 'color order', - hint: 'The order of the colors in the pixel strip', - options: COLORS, - }, - }, - { collapsed: true } - ), - }); - - return <>{render()}; -} - -type Props = BaseNode; -Pixel.defaultProps = { - data: { - ...dataSchema.parse({}), - group: 'hardware', - tags: ['output', 'analog'], - label: 'LED Strip', - icon: 'RainbowIcon', - description: 'Control a strip of addressable RGB LEDs (WS2812, NeoPixel, etc.)', - } satisfies Props['data'], -}; diff --git a/apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelEditor.tsx b/apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelEditor.tsx new file mode 100644 index 00000000..aef97502 --- /dev/null +++ b/apps/electron-app/src/render/components/react-flow/nodes/pixel/PixelEditor.tsx @@ -0,0 +1,152 @@ +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@microflow/ui'; +import { PropsWithChildren, useState, useEffect, useMemo } from 'react'; +import { HexColorPicker } from 'react-colorful'; +import type { Value } from '@microflow/runtime/src/pixel/pixel.types'; +import { PixelDisplay } from './PixelDisplay'; +import { DEFAULT_OFF_PIXEL_COLOR } from '@microflow/runtime/src/pixel/pixel.constants'; + +function newPreset(options: { length: number; preset?: Value; fill?: string }): Value { + const preset = Array.from({ length: options.length }, (_, i) => { + if (options.preset && options.preset[i]) { + return options.preset[i]; + } + return options.fill ?? DEFAULT_OFF_PIXEL_COLOR; + }); + + return preset; +} + +export function PixelEditor(props: Props) { + const [preset, setPreset] = useState(() => + newPreset({ + length: props.length, + preset: props.preset, + }) + ); + const [selectedPixel, setSelectedPixel] = useState(null); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + + // Sync preset state when props.preset changes + useEffect(() => { + setPreset( + newPreset({ + length: props.length, + preset: props.preset, + }) + ); + setSelectedPixel(null); // Reset selection when preset changes + setColorPickerOpen(false); + }, [props.preset, props.length]); + + const handlePixelClick = (index: number) => { + setSelectedPixel(index); + setColorPickerOpen(true); + }; + + return ( + + {props.children} + + + {!!props.onDelete ? 'Edit' : 'Add new'} preset + +
+ +
+ {selectedPixel !== null && ( + + + + Pixel {selectedPixel + 1} color + +
+ { + setPreset(prev => { + const newPreset = [...prev]; + newPreset[selectedPixel] = color; + return newPreset; + }); + }} + /> + +
+ + + + + +
+
+ )} +
+ + +
+ + {props.onDelete && ( + + + + )} + + + + +
+
+ ); +} + +type Props = PropsWithChildren & { + length: number; + preset?: Value; + onSave: (preset: Value) => void; + onDelete?: () => void; +}; diff --git a/apps/electron-app/src/render/hooks/useFlowSync.ts b/apps/electron-app/src/render/hooks/useFlowSync.ts index 0644ac67..de116a2b 100644 --- a/apps/electron-app/src/render/hooks/useFlowSync.ts +++ b/apps/electron-app/src/render/hooks/useFlowSync.ts @@ -93,7 +93,8 @@ export function useFlowSync() { console.debug(`[FLOW] <<<< `, result); if (!result.success) { - setBoard({ type: 'error', message: result.error }); + toast.error(result.error); + setBoard({ type: 'error', message: 'Something went wrong' }); return; } diff --git a/apps/electron-app/vite.main.config.mjs b/apps/electron-app/vite.main.config.mjs index 7b17d8db..7a496d5f 100644 --- a/apps/electron-app/vite.main.config.mjs +++ b/apps/electron-app/vite.main.config.mjs @@ -1,8 +1,10 @@ import copy from 'rollup-plugin-copy'; import { defineConfig } from 'vite'; +import path from 'path'; const root = '.vite'; const build = `${root}/build`; +const projectRootDir = path.resolve(__dirname); // https://vitejs.dev/config export default defineConfig({ diff --git a/apps/electron-app/vite.renderer.config.mjs b/apps/electron-app/vite.renderer.config.mjs index 8a0695c7..47c42125 100644 --- a/apps/electron-app/vite.renderer.config.mjs +++ b/apps/electron-app/vite.renderer.config.mjs @@ -25,7 +25,4 @@ export default defineConfig({ 'process.browser': 'true', global: 'globalThis', }, - optimizeDeps: { - exclude: ['johnny-five', 'node-pixel'], - }, }); diff --git a/package.json b/package.json index 9f4e440b..ab1f0710 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "scripts": { "start": "yarn dev", - "dev": "concurrently --names \"APP,COMPONENTS\" --prefix-colors \"blue,green\" \"yarn workspace microflow-studio start\" \"yarn workspace @microflow/runtime dev\"", + "dev": "concurrently --names \"APP,RUNTIME,FLASHER\" --prefix-colors \"blue,green,yellow\" \"yarn workspace microflow-studio start\" \"yarn workspace @microflow/runtime dev\" \"yarn workspace @microflow/flasher dev\"", "dev:plugin": "yarn workspace microflow-hardware-bridge dev", "build": "yarn workspaces foreach -p run build", "make": "yarn workspace microflow-studio make", diff --git a/packages/runtime/src/pixel/pixel.constants.ts b/packages/runtime/src/pixel/pixel.constants.ts index 69e43ec5..c4f250c9 100644 --- a/packages/runtime/src/pixel/pixel.constants.ts +++ b/packages/runtime/src/pixel/pixel.constants.ts @@ -1 +1,2 @@ export const COLORS = ['GRB', 'RGB', 'BRG'] as const; +export const DEFAULT_OFF_PIXEL_COLOR = '#000000'; diff --git a/packages/runtime/src/pixel/pixel.ts b/packages/runtime/src/pixel/pixel.ts index 4275ead4..079795f2 100644 --- a/packages/runtime/src/pixel/pixel.ts +++ b/packages/runtime/src/pixel/pixel.ts @@ -3,14 +3,33 @@ import { RGBA } from '../base.types'; import type { Data, Value } from './pixel.types'; import { dataSchema } from './pixel.types'; import pixel from 'node-pixel'; +import { transformValueToNumber } from '../_utils/transformUnknownValues'; +import { DEFAULT_OFF_PIXEL_COLOR } from './pixel.constants'; export class Pixel extends Hardware { + private lastFlushTime: number = 0; + private flushTimeout: NodeJS.Timeout | null = null; + private readonly FLUSH_INTERVAL_MS = 50; + constructor(data: Data) { - super(dataSchema.parse(data), Array(data.length).fill('#000000')); + super(dataSchema.parse(data), Array(data.length).fill(DEFAULT_OFF_PIXEL_COLOR)); } turnOff() { - this.flush(Array(this.data.length).fill('#000000')); + this.flush(Array(this.data.length).fill(DEFAULT_OFF_PIXEL_COLOR)); + } + + show(index: unknown) { + // Find the preset at the rounded index + const preset = this.data.presets.at(Math.round(transformValueToNumber(index) - 1)); + + if (!preset) return; + + const paddedPreset = Array(this.data.length) + .fill(DEFAULT_OFF_PIXEL_COLOR) + .map((_, i) => preset[i] || DEFAULT_OFF_PIXEL_COLOR); + + this.colorPixels(paddedPreset); } color(color: Value | Value[number] | RGBA) { @@ -21,51 +40,63 @@ export class Pixel extends Hardware { return this.colorPixels(color); } - forward(amount: number = 1) { + move(amount: number = 1) { + if (amount > 0) this.forward(amount); + else this.backward(-amount); + } + + private forward(amount: number = 1) { const newValue = this.value.map((_color, index) => { - const newIndex = index + amount; - return this.value[newIndex % this.data.length]; + const newIndex = (index - amount + this.data.length) % this.data.length; + return this.value[newIndex]; }); this.component?.shift(amount, pixel.FORWARD, true); this.flush(newValue); } - backward(amount: number = 1) { + private backward(amount: number = 1) { const newValue = this.value.map((_color, index) => { - const newIndex = index - amount; - return this.value[newIndex % this.data.length]; + const newIndex = (index + amount) % this.data.length; + return this.value[newIndex]; }); this.component?.shift(amount, pixel.BACKWARD, true); this.flush(newValue); } - private rgbaToHex(color: RGBA) { - const redHex = color.r.toString(16).padStart(2, '0'); - const greenHex = color.g.toString(16).padStart(2, '0'); - const blueHex = color.b.toString(16).padStart(2, '0'); - return `#${redHex}${greenHex}${blueHex}`; - } - private colorStrip(color: Value[number]) { this.component?.color(color); this.flush(this.value.map(() => color)); } - private colorPixels(color: Value) { - color.forEach((color, index) => { + private colorPixels(colors: Value) { + colors.forEach((color, index) => { this.component?.pixel(index).color(color); }); - this.flush(color); + this.flush(colors); } private flush(color: Value) { - this.value = color; - this.component?.show(); + const now = Date.now(); + const timeSinceLastFlush = now - this.lastFlushTime; + + if (this.flushTimeout) clearTimeout(this.flushTimeout); + this.flushTimeout = setTimeout( + () => { + if (!this.component) { + console.warn('[PIXEL] flushing too early'); + return; + } + this.lastFlushTime = Date.now(); + this.value = color; + this.component?.show(); + this.flushTimeout = null; + }, + Math.max(5, this.FLUSH_INTERVAL_MS - timeSinceLastFlush) + ); } createComponent(data: Data): pixel.Strip { - this.component?.shift; - this.component = new pixel.Strip({ + const component = new pixel.Strip({ ...data, strips: [ { @@ -76,10 +107,11 @@ export class Pixel extends Hardware { color_order: data.color_order as any, board: this.data.board as any, }); - this.component.on('ready', () => { + component.on('ready', () => { + this.component = component; this.turnOff(); this.emit('ready'); }); - return this.component; + return component; } } diff --git a/packages/runtime/src/pixel/pixel.types.ts b/packages/runtime/src/pixel/pixel.types.ts index 54e073f7..8a01511d 100644 --- a/packages/runtime/src/pixel/pixel.types.ts +++ b/packages/runtime/src/pixel/pixel.types.ts @@ -1,11 +1,14 @@ import z from 'zod'; -import { COLORS } from './pixel.constants'; +import { COLORS, DEFAULT_OFF_PIXEL_COLOR } from './pixel.constants'; import { baseDataSchema } from '../base.types'; const valueSchema = z - .array(z.hex()) - .default(['#000000']) - .default([]) + .array( + z.string().regex(/^#([0-9A-F]{3}|[0-9A-F]{6})$/i, { + message: 'Invalid hex color format. Expected #RGB or #RRGGBB', + }) + ) + .default([DEFAULT_OFF_PIXEL_COLOR]) .describe('The colors of the pixel strip'); export type Value = z.infer; @@ -21,5 +24,6 @@ export const dataSchema = baseDataSchema.extend({ skip_firmware_check: z.boolean().default(true).describe('Whether to skip the firmware check'), gamma: z.number().default(2.8).describe('The gamma correction factor for the pixel strip'), color_order: z.enum(COLORS).default('BRG').describe('The color order of the pixel strip'), + presets: z.array(valueSchema).default([[]]).describe('Preset color patterns for the pixel strip'), }); export type Data = z.infer; diff --git a/packages/runtime/test.ts b/packages/runtime/test.ts index 0615718e..0d1bfdaf 100644 --- a/packages/runtime/test.ts +++ b/packages/runtime/test.ts @@ -32,7 +32,7 @@ function makeStrip() { strip.color(['#4f39f6', '#e60076', '#f54a00']); setInterval(() => { - strip.forward(); + strip.move(1); // const randomHex = `#${Math.floor(Math.random() * 16777215).toString(16)}`; // strip.color(randomHex); }, 1000);