From dc7febaefcfc8f375ccfbc57d637d12e3665f5ca Mon Sep 17 00:00:00 2001 From: sander boer Date: Tue, 3 Dec 2024 21:56:21 +0100 Subject: [PATCH] feat: firmata over wifi (#32) * feat: allow to connect to `Firmata` running on ESP device * handling of killing child processes * bump version * remove dependency on etherport-client * remove unused code --- apps/electron-app/package.json | 5 +- apps/electron-app/src/common/types.ts | 3 +- apps/electron-app/src/main/deepLink.ts | 13 +- apps/electron-app/src/main/ipc.ts | 220 +++++++++++------- apps/electron-app/src/main/menu.ts | 66 ++++-- apps/electron-app/src/preload.ts | 19 +- .../render/components/IpcDeepLinkListener.tsx | 12 +- .../src/render/components/IpcMenuListener.tsx | 29 ++- .../components/forms/AdvancedSettingsForm.tsx | 118 ++++++++++ .../components/forms/MqttSettingsForm.tsx | 46 ++-- .../components/react-flow/ReactFlowCanvas.tsx | 4 +- .../components/react-flow/nodes/Figma.tsx | 40 ++-- .../components/react-flow/nodes/Mqtt.tsx | 20 +- .../panels/SerialConnectionStatus.tsx | 63 ----- .../panels/SerialConnectionStatusPanel.tsx | 77 ++++++ .../src/render/hooks/useBoard.tsx | 20 +- .../src/render/hooks/useCodeUploader.ts | 48 +++- .../src/render/hooks/useSaveFlow.ts | 2 +- .../render/hooks/useSignalNodesAndEdges.ts | 14 +- apps/electron-app/src/render/stores/app.ts | 15 ++ apps/electron-app/src/render/stores/board.ts | 4 +- apps/electron-app/src/utils/generateCode.ts | 21 +- apps/electron-app/test.js | 31 --- apps/electron-app/test.ts | 78 +++++++ apps/electron-app/tsconfig.json | 3 +- apps/electron-app/workers/check.js | 22 +- apps/figma-plugin/package.json | 2 +- apps/nextjs-app/components/DownloadApp.tsx | 2 +- apps/nextjs-app/package.json | 2 +- packages/components/README.md | 0 packages/components/index.ts | 1 + packages/components/src/TcpSerial.ts | 118 ++++++++++ packages/flasher/README.md | 1 + packages/flasher/package.json | 3 +- packages/flasher/src/UdpSerial.ts | 84 +++++++ packages/flasher/test.ts | 32 +-- 36 files changed, 915 insertions(+), 323 deletions(-) create mode 100644 apps/electron-app/src/render/components/forms/AdvancedSettingsForm.tsx delete mode 100644 apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatus.tsx create mode 100644 apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatusPanel.tsx create mode 100644 apps/electron-app/src/render/stores/app.ts delete mode 100644 apps/electron-app/test.js create mode 100644 apps/electron-app/test.ts create mode 100644 packages/components/README.md create mode 100644 packages/components/src/TcpSerial.ts create mode 100644 packages/flasher/README.md create mode 100644 packages/flasher/src/UdpSerial.ts diff --git a/apps/electron-app/package.json b/apps/electron-app/package.json index 32a3bf2..f62e557 100644 --- a/apps/electron-app/package.json +++ b/apps/electron-app/package.json @@ -6,7 +6,7 @@ "type": "git", "url": "https://github.com/xiduzo/microflow" }, - "version": "0.6.4", + "version": "0.7.0", "description": "An application which allows you to create flow-based logic for microcontrollers", "author": { "name": "Sander Boer", @@ -21,7 +21,8 @@ "make:debug": "DEBUG=electron-osx-sign electron-forge make", "publish": "electron-forge publish", "publish:intel": "electron-forge publish --arch x64", - "lint": "echo \"No linting configured\"" + "lint": "echo \"No linting configured\"", + "test": "npx tsx ./test.ts" }, "keywords": [], "devDependencies": { diff --git a/apps/electron-app/src/common/types.ts b/apps/electron-app/src/common/types.ts index 0ecfd25..5af9c62 100644 --- a/apps/electron-app/src/common/types.ts +++ b/apps/electron-app/src/common/types.ts @@ -1,4 +1,3 @@ -import { Modes } from '@tsparticles/engine'; import type { Edge, Node } from '@xyflow/react'; export enum MODES { @@ -71,3 +70,5 @@ export type FlowFile = { nodes: Node[]; edges: Edge[]; }; + +export type IpcResponse = { success: true; data: T } | { success: false; error: string }; diff --git a/apps/electron-app/src/main/deepLink.ts b/apps/electron-app/src/main/deepLink.ts index 7ca2fa8..44a5d24 100644 --- a/apps/electron-app/src/main/deepLink.ts +++ b/apps/electron-app/src/main/deepLink.ts @@ -12,12 +12,11 @@ export function handleDeepLink(mainWindow: BrowserWindow, link: string) { if (figmaVariableMatch?.length) { const [_link, variableId, value] = figmaVariableMatch; - mainWindow.webContents.send( - 'ipc-deep-link', - 'figma', - decodeURIComponent(variableId), - decodeURIComponent(value), - ); + mainWindow.webContents.send('ipc-deep-link', { + from: 'figma', + variableId: decodeURIComponent(variableId), + value: decodeURIComponent(value), + }); return; } @@ -25,7 +24,7 @@ export function handleDeepLink(mainWindow: BrowserWindow, link: string) { const linkWebMatch = linkWebRegex.exec(link); if (linkWebMatch?.length) { - mainWindow.webContents.send('ipc-deep-link', 'web'); + mainWindow.webContents.send('ipc-deep-link', { from: 'web' }); return; } } diff --git a/apps/electron-app/src/main/ipc.ts b/apps/electron-app/src/main/ipc.ts index c85be3d..6096d1e 100644 --- a/apps/electron-app/src/main/ipc.ts +++ b/apps/electron-app/src/main/ipc.ts @@ -11,11 +11,9 @@ import { ipcMain, IpcMainEvent, Menu, utilityProcess, UtilityProcess } from 'ele import log from 'electron-log/node'; import { existsSync, writeFile } from 'fs'; import { join, resolve } from 'path'; -import { BoardResult, UploadResult, UploadedCodeMessage } from '../common/types'; +import { BoardResult, IpcResponse, UploadResult, UploadedCodeMessage } from '../common/types'; import { exportFlow } from './file'; -let childProcess: UtilityProcess | null = null; - // ipcMain.on("shell:open", () => { // const pageDirectory = __dirname.replace('app.asar', 'app.asar.unpacked') // const pagePath = path.join('file://', pageDirectory, 'index.html') @@ -23,23 +21,40 @@ let childProcess: UtilityProcess | null = null; // }) // -ipcMain.on('ipc-export-flow', async (_event, nodes: Node[], edges: Edge[]) => { - await exportFlow(nodes, edges); +ipcMain.on('ipc-export-flow', async (_event, data: { nodes: Node[]; edges: Edge[] }) => { + await exportFlow(data.nodes, data.edges); }); -ipcMain.on('ipc-menu', (_event, action, ...args) => { - switch (action) { +ipcMain.on('ipc-menu', (_event, data: { action: string; args: any }) => { + switch (data.action) { case 'auto-save': - const checked = Boolean(args[0]); - Menu.getApplicationMenu().getMenuItemById('autosave').checked = checked; + const checked = Boolean(data.args); + const menu = Menu.getApplicationMenu(); + if (!menu) return; + + const menuItem = menu.getMenuItemById('autosave'); + if (!menuItem) return; + + menuItem.checked = checked; break; } }); -ipcMain.on('ipc-check-board', async event => { - childProcess?.kill(); +ipcMain.on('ipc-check-board', async (event, data: { ip: string | undefined }) => { + checkProcess?.kill(); + log.debug('Checking board', { data }); - let connectedPort: PortInfo | null = null; + if (data.ip) { + log.debug(`Checking board on IP ${data.ip}`); + const result = await checkBoardOnPort(event, data.ip); + + event.reply('ipc-check-board', { + success: true, + data: { ...result, port: data.ip }, + } satisfies IpcResponse); + + return; + } // Check board on all ports which match the known product IDs const boardsAndPorts = await getKnownBoardsWithPorts(); @@ -48,46 +63,21 @@ ipcMain.on('ipc-check-board', async event => { boardsAndPorts: JSON.stringify(boardsAndPorts), }); - const filePath = join(__dirname, 'workers', 'check.js'); + let connectedPort: PortInfo | undefined = undefined; checkBoard: for (const [board, ports] of boardsAndPorts) { for (const port of ports) { - log.debug(`checking board ${board} on path ${port.path}`, { filePath }); - - const result = await new Promise(resolve => { - childProcess = utilityProcess.fork(filePath, [port.path], { - serviceName: 'Microflow studio - microcontroller validator', - stdio: 'pipe', - }); - - childProcess.stderr?.on('data', data => { - log.error('board check child process error', { - data: data.toString(), - }); - }); - - log.debug('Child process forked', { - filePath, - port: port.path, - }); + log.debug(`checking board ${board} on path ${port.path}`); - childProcess.on('message', async (message: BoardResult) => { - log.debug('board check child process process message', { message }); - if (message.type !== 'info') { - childProcess?.kill(); // Free up the port again - resolve(message); - return; - } - - // Inform info messages to the renderer - event.reply('ipc-check-board', { ...message, port: port.path }); - }); - }); + const result = await checkBoardOnPort(event, port.path); if (result.type === 'ready') { // board is ready, no need to check other ports connectedPort = port; - event.reply('ipc-check-board', { ...result, port: port.path }); + event.reply('ipc-check-board', { + success: true, + data: { ...result, port: port.path }, + } satisfies IpcResponse); break checkBoard; } @@ -99,18 +89,27 @@ ipcMain.on('ipc-check-board', async event => { log.debug('Board flashed', { board, port }); connectedPort = port; event.reply('ipc-check-board', { - ...result, - port: port.path, - type: 'ready', - } satisfies BoardResult); + success: true, + data: { + ...result, + port: port.path, + type: 'ready', + }, + } satisfies IpcResponse); break checkBoard; // board is flashed with firmata, no need to check other ports } catch (error) { - const [lastBoard, ports] = boardsAndPorts.at(-1); + const next = boardsAndPorts.at(-1); + if (!next) return; + + const [lastBoard, ports] = next; const lastPort = ports.at(-1); log.warn('Error flashing board', { error }); // we should not return as we still want to sniff the ports 🐕 - if (board === lastBoard && port.path === lastPort.path) { - event.reply('ipc-check-board', { ...result, port: port.path }); + if (board === lastBoard && port.path === lastPort?.path) { + event.reply('ipc-check-board', { + success: true, + data: { ...result, port: port.path }, + } satisfies IpcResponse); } } } @@ -119,64 +118,123 @@ ipcMain.on('ipc-check-board', async event => { // Start sniffing ports for changes in connections sniffPorts(event, { connectedPort }); - childProcess?.kill(); }); -ipcMain.on('ipc-upload-code', (event, code: string, portPath: string) => { - childProcess?.kill(); - log.info(`Uploading code to port ${portPath}`); +let checkProcess: UtilityProcess | null = null; +async function checkBoardOnPort(event: IpcMainEvent, port: string) { + checkProcess?.kill(); + const filePath = join(__dirname, 'workers', 'check.js'); - if (!portPath) { + return new Promise(resolve => { + checkProcess = utilityProcess.fork(filePath, [port], { + serviceName: 'Microflow studio - microcontroller validator', + stdio: 'pipe', + }); + + checkProcess.stderr?.on('data', data => { + log.error('board check child process error', { + data: data.toString(), + }); + checkProcess?.kill(); + event.reply('ipc-check-board', { + success: true, + data: { type: 'error' }, + } satisfies IpcResponse); + }); + + log.debug('Child process forked', { + filePath, + port: port, + }); + + checkProcess.on('message', async (message: BoardResult) => { + log.debug('board check child process process message', { message }); + if (message.type !== 'info') { + checkProcess?.kill(); // Free up the port again + resolve(message); + return; + } + + // Inform info messages to the renderer + event.reply('ipc-check-board', { + success: true, + data: { ...message, port: port }, + } satisfies IpcResponse); + }); + }); +} + +let uploadProcess: UtilityProcess | null = null; +ipcMain.on('ipc-upload-code', (event, data: { code: string; port: string }) => { + uploadProcess?.kill(); + checkProcess?.kill(); + log.info(`Uploading code to port ${data.port}`); + + if (!data.port) { log.warn('No port path provided for uploading code'); return; } const filePath = join(__dirname, 'temp.js'); - log.debug('Writing code to file', { filePath }); - writeFile(filePath, code, error => { + writeFile(filePath, data.code, error => { if (error) { log.error('write file error', { error }); event.reply('ipc-upload-code', { - type: 'error', - message: error.message, - } satisfies UploadResult); + error: error.message, + success: false, + } satisfies IpcResponse); return; } - childProcess = utilityProcess.fork(filePath, [portPath], { + uploadProcess = utilityProcess.fork(filePath, [data.port], { serviceName: 'Microflow studio - microcontroller runner', stdio: 'pipe', }); - childProcess.stdout?.on('data', data => { - log.info('board check child process stdout', { + uploadProcess.stdout?.on('data', data => { + log.info('uploaded code child process stdout', { data: data.toString(), }); }); - childProcess.stderr?.on('data', data => { - log.error('board check child process error', { + uploadProcess.stderr?.on('data', data => { + log.error('uploaded code child process error', { data: data.toString(), }); + uploadProcess?.kill(); + event.reply('ipc-upload-code', { + success: true, + data: { type: 'error' }, + } satisfies IpcResponse); }); - childProcess.on('message', (message: UploadedCodeMessage | UploadResult) => { + uploadProcess.on('message', (message: UploadedCodeMessage | UploadResult) => { if ('type' in message) { - event.reply('ipc-upload-code', message); + event.reply('ipc-upload-code', { + data: message, + success: true, + } satisfies IpcResponse); + + if (message.type === 'close') { + uploadProcess?.kill(); + } return; } - event.reply('ipc-microcontroller', message); + event.reply('ipc-microcontroller', { + data: message, + success: true, + } satisfies IpcResponse); }); }); }); -ipcMain.on('ipc-external-value', (_event, nodeId: string, value: unknown) => { - childProcess?.postMessage({ nodeId, value }); +ipcMain.on('ipc-external-value', (_event, data: { nodeId: string; value: unknown }) => { + uploadProcess?.postMessage(data); }); async function flashBoard(board: BoardName, port: PortInfo): Promise { - childProcess?.kill(); + checkProcess?.kill(); log.debug(`Try flashing firmata to ${board} on ${port.path}`); const firmataPath = resolve(__dirname, 'hex', board, 'StandardFirmata.cpp.hex'); @@ -214,11 +272,14 @@ function sniffPorts( getConnectedPorts() .then(ports => { // Check if the connected port is still connected - if (options.connectedPort && !ports.find(port => port.path === options.connectedPort.path)) { + if (options.connectedPort && !ports.find(port => port.path === options.connectedPort?.path)) { event.reply('ipc-check-board', { - type: 'exit', - port: options.connectedPort.path, - } satisfies BoardResult); + success: true, + data: { + type: 'exit', + port: options.connectedPort.path, + }, + } satisfies IpcResponse); return; } @@ -226,8 +287,11 @@ function sniffPorts( // We only care about this if we don't have a connected port if (!options.connectedPort && ports.length !== options.portsConnected?.length) { event.reply('ipc-check-board', { - type: 'exit', - } satisfies BoardResult); + success: true, + data: { + type: 'exit', + }, + } satisfies IpcResponse); return; } diff --git a/apps/electron-app/src/main/menu.ts b/apps/electron-app/src/main/menu.ts index 97dd70b..d6c50f3 100644 --- a/apps/electron-app/src/main/menu.ts +++ b/apps/electron-app/src/main/menu.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions } from 'electron'; import { importFlow } from './file'; +import { IpcResponse } from '../common/types'; const isMac = process.platform === 'darwin'; @@ -18,12 +19,13 @@ const appMenu: (MenuItemConstructorOptions | MenuItem)[] = isMac { type: 'separator' }, { role: 'quit' }, { role: 'editMenu' }, - isMac ? { role: 'close' } : undefined, + isMac ? { role: 'close' } : {}, ], }, ] : []; +type MenuResponse = IpcResponse<{ button: string; args?: any }>; export function createMenu(mainWindow: BrowserWindow) { const menuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [ ...appMenu, @@ -34,22 +36,31 @@ export function createMenu(mainWindow: BrowserWindow) { label: 'Insert node', accelerator: isMac ? 'Cmd+K' : 'Ctrl+K', click: () => { - mainWindow.webContents.send('ipc-menu', 'add-node'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'add-node' }, + } satisfies MenuResponse); }, }, { type: 'separator' }, { - label: 'Previous state', - accelerator: isMac ? 'Cmd+P' : 'Ctrl+P', + label: 'Undo', + accelerator: isMac ? 'Cmd+U' : 'Ctrl+U', click: () => { - mainWindow.webContents.send('ipc-menu', 'undo'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'undo' }, + } satisfies MenuResponse); }, }, { - label: 'Next state', - accelerator: isMac ? 'Cmd+N' : 'Ctrl+N', + label: 'Redo', + accelerator: isMac ? 'Cmd+Shift+U' : 'Ctrl+Shift+U', click: () => { - mainWindow.webContents.send('ipc-menu', 'redo'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'redo' }, + } satisfies MenuResponse); }, }, { type: 'separator' }, @@ -57,7 +68,10 @@ export function createMenu(mainWindow: BrowserWindow) { label: 'Save flow', accelerator: isMac ? 'Cmd+S' : 'Ctrl+S', click: () => { - mainWindow.webContents.send('ipc-menu', 'save-flow'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'save-flow' }, + } satisfies MenuResponse); }, }, { @@ -66,7 +80,10 @@ export function createMenu(mainWindow: BrowserWindow) { type: 'checkbox', checked: true, click: menuItem => { - mainWindow.webContents.send('ipc-menu', 'toggle-autosave', menuItem.checked); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'toggle-autosave', args: menuItem.checked }, + } satisfies MenuResponse); }, }, { type: 'separator' }, @@ -74,14 +91,20 @@ export function createMenu(mainWindow: BrowserWindow) { label: 'New flow', accelerator: isMac ? 'Cmd+N' : 'Ctrl+N', click: () => { - mainWindow.webContents.send('ipc-menu', 'new-flow'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'new-flow' }, + } satisfies MenuResponse); }, }, { label: 'Export flow', accelerator: isMac ? 'Cmd+E' : 'Ctrl+E', click: () => { - mainWindow.webContents.send('ipc-menu', 'export-flow'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'export-flow' }, + } satisfies MenuResponse); }, }, { @@ -91,7 +114,10 @@ export function createMenu(mainWindow: BrowserWindow) { const flow = await importFlow(); if (!flow) return; - mainWindow.webContents.send('ipc-menu', 'import-flow', flow); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'import-flow', args: flow }, + } satisfies MenuResponse); }, }, ], @@ -99,10 +125,22 @@ export function createMenu(mainWindow: BrowserWindow) { { label: 'Settings', submenu: [ + { + label: 'Microcontroller settings', + click: () => { + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'board-settings' }, + } satisfies MenuResponse); + }, + }, { label: 'MQTT settings', click: () => { - mainWindow.webContents.send('ipc-menu', 'mqtt-settings'); + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button: 'mqtt-settings' }, + } satisfies MenuResponse); }, }, ], diff --git a/apps/electron-app/src/preload.ts b/apps/electron-app/src/preload.ts index 3854354..1275306 100644 --- a/apps/electron-app/src/preload.ts +++ b/apps/electron-app/src/preload.ts @@ -1,6 +1,7 @@ // See the Electron documentation for details on how to use preload scripts: // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import { IpcResponse } from './common/types'; type Channels = | 'ipc-check-board' @@ -10,21 +11,23 @@ type Channels = | 'ipc-menu' | 'ipc-deep-link' | 'ipc-export-flow'; + export const electronHandler = { ipcRenderer: { - send(channel: Channels, ...args: unknown[]) { - ipcRenderer.send(channel, ...args); + send(channel: Channels, data?: Data) { + ipcRenderer.send(channel, data); }, - on(channel: Channels, func: (...args: unknown[]) => void): () => void { - const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => func(...args); - ipcRenderer.on(channel, subscription); + on(channel: Channels, callback: (response: IpcResponse) => void): () => void { + const listner = (_event: IpcRendererEvent, response: IpcResponse) => callback(response); + + ipcRenderer.on(channel, listner); return () => { - ipcRenderer.removeListener(channel, subscription); + ipcRenderer.removeListener(channel, listner); }; }, - once(channel: Channels, func: (...args: unknown[]) => void) { - ipcRenderer.once(channel, (_event, ...args) => func(...args)); + once(channel: Channels, callback: (response: IpcResponse) => void) { + ipcRenderer.once(channel, (_event, args) => callback(args)); }, }, }; diff --git a/apps/electron-app/src/render/components/IpcDeepLinkListener.tsx b/apps/electron-app/src/render/components/IpcDeepLinkListener.tsx index c6c391b..281f84b 100644 --- a/apps/electron-app/src/render/components/IpcDeepLinkListener.tsx +++ b/apps/electron-app/src/render/components/IpcDeepLinkListener.tsx @@ -1,12 +1,14 @@ -import { toast } from "@ui/index"; -import { useEffect } from "react"; +import { toast } from '@ui/index'; +import { useEffect } from 'react'; export function IpcDeepLinkListener() { useEffect(() => { - return window.electron.ipcRenderer.on('ipc-deep-link', (event, ...args) => { - console.log('ipc-deep-link', event, args); + return window.electron.ipcRenderer.on<{ from: string }>('ipc-deep-link', result => { + if (!result.success) return; - switch (event) { + console.log('ipc-deep-link', result); + + switch (result.data.from) { case 'web': toast.success('Microflow studio successfully linked!'); break; diff --git a/apps/electron-app/src/render/components/IpcMenuListener.tsx b/apps/electron-app/src/render/components/IpcMenuListener.tsx index 52ef916..82ed4fc 100644 --- a/apps/electron-app/src/render/components/IpcMenuListener.tsx +++ b/apps/electron-app/src/render/components/IpcMenuListener.tsx @@ -6,6 +6,8 @@ import { useSaveFlow } from '../hooks/useSaveFlow'; import { useNewNode } from '../providers/NewNodeProvider'; import { useReactFlowStore } from '../stores/react-flow'; import { MqttSettingsForm } from './forms/MqttSettingsForm'; +import { AdvancedSettingsForm } from './forms/AdvancedSettingsForm'; +import { useAppStore } from '../stores/app'; export function IpcMenuListeners() { const { getNodes, getEdges } = useReactFlow(); @@ -15,11 +17,15 @@ export function IpcMenuListeners() { const [, setLocalNodes] = useLocalStorage('nodes', []); const [, setLocalEdges] = useLocalStorage('edges', []); const { setOpen } = useNewNode(); - const [showMqttSettings, setShowMqttSettings] = useState(false); + const { settingsOpen, setSettingsOpen } = useAppStore(); useEffect(() => { - window.electron.ipcRenderer.on('ipc-menu', (button: string, ...props: unknown[]) => { - switch (button) { + window.electron.ipcRenderer.on<{ button: string; args: any }>('ipc-menu', result => { + if (!result.success) return; + + console.log(result); + + switch (result.data.button) { case 'save-flow': saveNodesAndEdges(); break; @@ -33,17 +39,21 @@ export function IpcMenuListeners() { setOpen(true); break; case 'toggle-autosave': - setAutoSave(Boolean(props[0])); + setAutoSave(Boolean(result.data.args)); break; case 'mqtt-settings': - setShowMqttSettings(true); + case 'board-settings': + setSettingsOpen(result.data.button); break; case 'export-flow': - window.electron.ipcRenderer.send('ipc-export-flow', getNodes(), getEdges()); + window.electron.ipcRenderer.send('ipc-export-flow', { + nodes: getNodes(), + edges: getEdges(), + }); break; case 'import-flow': // TODO: data validation - const { nodes, edges } = props[0] as FlowFile; + const { nodes, edges } = result.data.args as FlowFile; setNodes(nodes); setEdges(edges); break; @@ -57,9 +67,10 @@ export function IpcMenuListeners() { break; } }); - }, [saveNodesAndEdges, setOpen]); + }, [saveNodesAndEdges, setSettingsOpen, setOpen]); - if (showMqttSettings) return setShowMqttSettings(false)} />; + if (settingsOpen === 'mqtt-settings') return ; + if (settingsOpen === 'board-settings') return ; return null; } diff --git a/apps/electron-app/src/render/components/forms/AdvancedSettingsForm.tsx b/apps/electron-app/src/render/components/forms/AdvancedSettingsForm.tsx new file mode 100644 index 0000000..4a4e6e0 --- /dev/null +++ b/apps/electron-app/src/render/components/forms/AdvancedSettingsForm.tsx @@ -0,0 +1,118 @@ +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + useForm, + zodResolver, + Zod, + Form, + Button, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + SheetClose, + SheetFooter, + FormDescription, + Icons, +} from '@microflow/ui'; +import { useLocalStorage } from 'usehooks-ts'; +import { useAppStore } from '../../stores/app'; + +const schema = Zod.object({ + ip: Zod.string() + .regex(/^(?:\d{1,3}\.){3}\d{1,3}$/) + .or(Zod.literal('')) + .optional(), +}); + +type Schema = Zod.infer; + +export type AdvancedConfig = Schema; + +export function AdvancedSettingsForm(props: Props) { + const [config, setConfig] = useLocalStorage('advanced-config', { + ip: undefined, + }); + const { setSettingsOpen } = useAppStore(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: config, + }); + + function onSubmit(data: Schema) { + setConfig(data); + closeForm(); + } + + function closeForm() { + setSettingsOpen(undefined); + props.onClose?.(); + } + + return ( + { + if (opened) return; + closeForm(); + }} + > + + + + + Microcontroller settings + + + These settings will apply to any connected microcontroller. + + +
+ + ( + + IP-address + + + + + + The IP-address your of your microcontroller running{' '} + + StandardFirmataWifi + + . Leave blank if you want to connect via USB. + + + )} + /> + + + + + + + + +
+
+ ); +} + +type Props = { + open: boolean; + onClose?: () => void; +}; diff --git a/apps/electron-app/src/render/components/forms/MqttSettingsForm.tsx b/apps/electron-app/src/render/components/forms/MqttSettingsForm.tsx index 0721388..4e0172b 100644 --- a/apps/electron-app/src/render/components/forms/MqttSettingsForm.tsx +++ b/apps/electron-app/src/render/components/forms/MqttSettingsForm.tsx @@ -1,4 +1,3 @@ -import { MqttConfig } from '@microflow/mqtt-provider/client'; import { Button, Form, @@ -21,9 +20,9 @@ import { Zod, zodResolver, } from '@microflow/ui'; -import { useEffect } from 'react'; import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator'; import { useLocalStorage } from 'usehooks-ts'; +import { useAppStore } from '../../stores/app'; const schema = Zod.object({ host: Zod.string().optional(), @@ -38,22 +37,20 @@ const schema = Zod.object({ type Schema = Zod.infer; -const defaultValues: Schema = { - host: 'test.mosquitto.org', - port: 8081, - uniqueId: '', - protocol: 'wss', -}; - export function MqttSettingsForm(props: Props) { + const [mqttConfig, setMqttConfig] = useLocalStorage('mqtt-config', { + uniqueId: uniqueNamesGenerator({ dictionaries: [adjectives, animals] }), + host: 'test.mosquitto.org', + port: 8081, + protocol: 'wss', + }); + const form = useForm({ resolver: zodResolver(schema), - defaultValues: defaultValues, + defaultValues: mqttConfig, }); - const [mqttConfig, setMqttConfig] = useLocalStorage('mqtt-config', { - uniqueId: uniqueNamesGenerator({ dictionaries: [adjectives, animals] }), - }); + const { setSettingsOpen } = useAppStore(); function setRandomUniqueName() { form.clearErrors('uniqueId'); @@ -61,29 +58,30 @@ export function MqttSettingsForm(props: Props) { } function onSubmit(data: Schema) { - setMqttConfig(data as MqttConfig); - props.onClose?.(); + setMqttConfig(data); + setSettingsOpen(undefined); + closeForm(); } - useEffect(() => { - if (!mqttConfig) return; - form.reset({ - ...defaultValues, - ...(mqttConfig as Schema), - }); - }, [mqttConfig, form.reset]); + function closeForm() { + setSettingsOpen(undefined); + props.onClose?.(); + } return ( { if (opened) return; - props.onClose?.(); + closeForm(); }} > - MQTT Settings + + + MQTT settings + When using Figma nodes, make sure to configure the same MQTT broker in the{' '} ({ nodes: state.nodes, @@ -34,7 +34,7 @@ export function ReactFlowCanvas() { - + (); const { status, publish, appName, connectedClients, uniqueId } = useMqtt(); - const componentValue = useNodeValue(props.id, undefined); + const componentValue = useNodeValue(props.id, ''); const updateNode = useUpdateNode(props.id); @@ -44,7 +44,7 @@ export function Figma(props: Props) { console.debug('<<<', value); - window.electron.ipcRenderer.send('ipc-external-value', props.id, value); + window.electron.ipcRenderer.send('ipc-external-value', { nodeId: props.id, value }); }, [value, props.id]); useEffect(() => { @@ -73,28 +73,32 @@ export function Figma(props: Props) { if (!variable?.resolvedType) return; const value = DEFAULT_FIGMA_VALUE_PER_TYPE[variable.resolvedType]; - window.electron.ipcRenderer.send('ipc-external-value', props.id, value); + window.electron.ipcRenderer.send('ipc-external-value', { nodeId: props.id, value }); }, [uploadResult, variable?.resolvedType, props.id]); useEffect(() => { - return window.electron.ipcRenderer.on('ipc-deep-link', (event, id, value) => { - if (event !== 'figma') return; - if (id !== variable?.id) return; - - // TODO: do some processing on the value received from the plugin - // Eg. convert the color value to rgba - // + of - to increment or decrement the value - // true/false values - window.electron.ipcRenderer.send('ipc-external-value', props.id, value); - - // TODO: should we already publish the value? - // this would probably mean we publish it twice but it does not require a - // microcontroller to be connected and active - }); + return window.electron.ipcRenderer.on<{ from: string; variableId: string; value: unknown }>( + 'ipc-deep-link', + result => { + if (!result.success) return; + if (result.data.from !== 'figma') return; + if (result.data.variableId !== variable?.id) return; + + // TODO: do some processing on the value received from the plugin + // Eg. convert the color value to rgba + // + of - to increment or decrement the value + // true/false values + window.electron.ipcRenderer.send('ipc-external-value', { nodeId: props.id, value }); + + // TODO: should we already publish the value? + // this would probably mean we publish it twice but it does not require a + // microcontroller to be connected and active + }, + ); }, [variable?.id, props.id]); return ( - + diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Mqtt.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Mqtt.tsx index 23b8044..a188310 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Mqtt.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Mqtt.tsx @@ -11,7 +11,7 @@ export function Mqtt(props: Props) { const { status } = useMqtt(); return ( - + @@ -32,7 +32,7 @@ function Subscriber() { useEffect(() => { if (data.type !== 'subscribe') return; - if (!data.topic.length) return; + if (!data.topic?.length) return; const unsubFromTopic = subscribe(data.topic, (_topic, message) => { let value: unknown; @@ -45,7 +45,7 @@ function Subscriber() { if (!isNaN(parsed)) value = parsed; } - window.electron.ipcRenderer.send('ipc-external-value', id, value); + window.electron.ipcRenderer.send('ipc-external-value', { nodeId: id, value }); }); return () => { @@ -63,13 +63,13 @@ function Value() { const value = useNodeValue(id, ''); useEffect(() => { - if (data.type !== 'publish') return; - if (!data.topic.length) return; + if (data.direction !== 'publish') return; + if (!data.topic?.length) return; publish(data.topic, JSON.stringify(value)); - }, [value, data.topic, data.type, publish]); + }, [value, data.topic, data.direction, publish]); - if (data.type === 'publish') return ; + if (data.direction === 'publish') return ; return ; } @@ -79,10 +79,10 @@ function Settings() { useEffect(() => { if (!pane) return; - const initialType = settings.type; + const initialType = settings.direction; pane - .addBinding(settings, 'type', { + .addBinding(settings, 'direction', { view: 'list', index: 0, options: [ @@ -106,6 +106,6 @@ function Settings() { type Props = BaseNode; export const DEFAULT_MQTT_DATA: Props['data'] = { label: 'MQTT', - type: 'publish', + direction: 'publish', topic: '', }; diff --git a/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatus.tsx b/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatus.tsx deleted file mode 100644 index 8583402..0000000 --- a/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatus.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Badge, Icons } from '@microflow/ui'; -import { useAutoCodeUploader } from '../../../hooks/useCodeUploader'; -import { useBoardResult, useUploadResult } from '../../../stores/board'; - -export function SerialConnectionStatus() { - const boardResult = useBoardResult(); - const uploadResult = useUploadResult(); - useAutoCodeUploader(); - - if (uploadResult === 'error') { - console.debug('SerialConnectionStatus - uploadResult', uploadResult); - return ( - - Upload failed for unknown reasons - {/* */} - - ); - } - - if (boardResult === 'ready') { - if (uploadResult === 'info') { - return ( - - Uploading your flow - - - ); - } - - return ( - - Microcontroller up to date - {uploadResult === 'ready' && } - {uploadResult === 'close' && } - - ); - } - - if (boardResult === 'info') { - return ( - - Connecting your microcontroller - - - ); - } - - if (['fail', 'warn', 'errror'].includes(boardResult)) { - console.debug('SerialConnectionStatus - checkResult', boardResult); - return ( - - Unknown error occurred - - ); - } - - return ( - - Connect your microcontroller by USB - - - ); -} diff --git a/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatusPanel.tsx b/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatusPanel.tsx new file mode 100644 index 0000000..14d0f36 --- /dev/null +++ b/apps/electron-app/src/render/components/react-flow/panels/SerialConnectionStatusPanel.tsx @@ -0,0 +1,77 @@ +import { Badge, cva, Icons } from '@microflow/ui'; +import { useAutoCodeUploader } from '../../../hooks/useCodeUploader'; +import { useBoardResult, useUploadResult } from '../../../stores/board'; +import { useLocalStorage } from 'usehooks-ts'; +import { AdvancedConfig } from '../../forms/AdvancedSettingsForm'; + +export function SerialConnectionStatusPanel() { + const [{ ip }] = useLocalStorage('advanced-config', { ip: undefined }); + const boardResult = useBoardResult(); + const uploadResult = useUploadResult(); + useAutoCodeUploader(); + + if (uploadResult === 'error') { + return ( + + Upload failed for unknown reasons + {/* */} + + ); + } + + if (boardResult === 'ready') { + if (uploadResult === 'info' || uploadResult === 'close') { + return ( + + Uploading your flow + + + ); + } + + return ( + + Microcontroller in sync with flow + {uploadResult === 'ready' && } + + ); + } + + if (boardResult === 'info') { + return ( + + Connecting to your microcontroller + + + ); + } + + if (['fail', 'warn', 'error'].includes(boardResult)) { + console.debug('SerialConnectionStatus - checkResult', boardResult); + return ( + + Unknown error occurred + + ); + } + + return ( + + Connect your microcontroller {!!ip ? `on ${ip}` : 'by USB'} + {!ip && } + {!!ip && } + + ); +} + +const badge = cva('pointer-events-none select-none', { + variants: { + variant: { + success: 'bg-green-400 text-green-900', + destructive: 'bg-red-400 text-red-900', + warning: 'bg-orange-400 text-orange-900', + info: 'bg-blue-400 text-blue-900', + plain: 'bg-muted text-muted-foreground animate-pulse ', + }, + }, +}); diff --git a/apps/electron-app/src/render/hooks/useBoard.tsx b/apps/electron-app/src/render/hooks/useBoard.tsx index 67980cf..c92b272 100644 --- a/apps/electron-app/src/render/hooks/useBoard.tsx +++ b/apps/electron-app/src/render/hooks/useBoard.tsx @@ -3,6 +3,8 @@ import { useLocalStorage } from 'usehooks-ts'; import { BoardResult } from '../../common/types'; import { useCelebration } from '../providers/CelebrationProvider'; import { useBoardResult, useBoardStore } from '../stores/board'; +import { AdvancedConfig } from '../components/forms/AdvancedSettingsForm'; +import { toast } from '@microflow/ui'; export function useCelebrateFirstUpload() { const [isFirstUpload, setIsFirstUpload] = useLocalStorage('isFirstUpload', true); @@ -21,22 +23,28 @@ export function useCelebrateFirstUpload() { export function useCheckBoard() { const { setBoardResult } = useBoardStore(); + const [{ ip }] = useLocalStorage('advanced-config', { + ip: undefined, + }); useEffect(() => { - window.electron.ipcRenderer.send('ipc-check-board'); + window.electron.ipcRenderer.send('ipc-check-board', { ip }); - return window.electron.ipcRenderer.on('ipc-check-board', (result: BoardResult) => { - setBoardResult(result); + return window.electron.ipcRenderer.on('ipc-check-board', result => { + if (!result.success) return; + setBoardResult(result.data); - switch (result.type) { + switch (result.data.type) { + case 'error': case 'exit': case 'fail': case 'close': + result.data.message && toast.warning(result.data.message); setTimeout(() => { - window.electron.ipcRenderer.send('ipc-check-board'); + window.electron.ipcRenderer.send('ipc-check-board', { ip }); }, 1000); // don't force it too much, give the boards some time break; } }); - }, []); + }, [ip]); } diff --git a/apps/electron-app/src/render/hooks/useCodeUploader.ts b/apps/electron-app/src/render/hooks/useCodeUploader.ts index e0f9098..02edef2 100644 --- a/apps/electron-app/src/render/hooks/useCodeUploader.ts +++ b/apps/electron-app/src/render/hooks/useCodeUploader.ts @@ -2,17 +2,21 @@ import { useReactFlow } from '@xyflow/react'; import { useCallback, useEffect, useRef } from 'react'; import { generateCode, isNodeTypeACodeType } from '../../utils/generateCode'; import { useNodeAndEdgeCount } from '../stores/react-flow'; -import { useBoardPort, useBoardResult, useBoardStore } from '../stores/board'; +import { useBoardPort, useBoardResult, useBoardStore, useUploadResult } from '../stores/board'; import { UploadResult } from '../../common/types'; import { toast } from '@ui/index'; import { useClearNodeData } from '../stores/node-data'; import { useNewNode } from '../providers/NewNodeProvider'; +import { useLocalStorage } from 'usehooks-ts'; +import { AdvancedConfig } from '../components/forms/AdvancedSettingsForm'; export function useCodeUploader() { const clearNodeData = useClearNodeData(); const boardResult = useBoardResult(); + const port = useBoardPort(); - const { setUploadResult } = useBoardStore(); + const [config] = useLocalStorage('advanced-config', { ip: undefined }); + const { setUploadResult, setBoardResult } = useBoardStore(); const { updateNodeData, getNodes, getEdges, getInternalNode } = useReactFlow(); @@ -32,14 +36,14 @@ export function useCodeUploader() { const allowedEdges = edges.filter(edge => { const sourceNode = internalNodes.find( node => - node.id === edge.source && - (node.internals.handleBounds?.source?.find(handle => handle.id === edge.sourceHandle) ?? + node?.id === edge.source && + (node?.internals.handleBounds?.source?.find(handle => handle.id === edge.sourceHandle) ?? true), ); const targetNode = internalNodes.find( node => - node.id === edge.target && - (node.internals.handleBounds?.target?.find(handle => handle.id === edge.targetHandle) ?? + node?.id === edge.target && + (node?.internals.handleBounds?.target?.find(handle => handle.id === edge.targetHandle) ?? true), ); @@ -48,14 +52,27 @@ export function useCodeUploader() { const code = generateCode(nodes, allowedEdges); - const off = window.electron.ipcRenderer.on('ipc-upload-code', (result: UploadResult) => { - setUploadResult(result); + const off = window.electron.ipcRenderer.on('ipc-upload-code', result => { + if (!result.success) { + toast.error(result.error); + return; + } + + setUploadResult(result.data); - if (result.type !== 'info') off(); - if (result.type === 'error') toast.error(result.message); + if (result.data.type === 'error') toast.error(result.data.message); + if (result.data.type === 'close') { + result.data.message && toast.warning(result.data.message); + setBoardResult({ type: 'close' }); + window.electron.ipcRenderer.send('ipc-check-board', { ip: config.ip }); + } }); - window.electron.ipcRenderer.send('ipc-upload-code', code, port); + window.electron.ipcRenderer.send('ipc-upload-code', { code, port: config.ip ?? port }); + + return () => { + off(); + }; }, [ getNodes, getEdges, @@ -64,6 +81,7 @@ export function useCodeUploader() { boardResult, setUploadResult, port, + config.ip, clearNodeData, ]); @@ -74,6 +92,7 @@ export function useAutoCodeUploader() { const uploadCode = useCodeUploader(); const { nodeToAdd } = useNewNode(); const boardResult = useBoardResult(); + const uploadResult = useUploadResult(); const debounce = useRef(); const { nodesCount, edgesCount } = useNodeAndEdgeCount(); @@ -98,4 +117,11 @@ export function useAutoCodeUploader() { clearTimeout(debounce.current); }; }, [nodesCount, edgesCount, uploadCode, boardResult, nodeToAdd]); + + useEffect(() => { + if (uploadResult !== 'close') return; + + lastNodesCount.current = -1; + lastEdgesCount.current = -1; + }, [boardResult]); } diff --git a/apps/electron-app/src/render/hooks/useSaveFlow.ts b/apps/electron-app/src/render/hooks/useSaveFlow.ts index 6255f56..2501c06 100644 --- a/apps/electron-app/src/render/hooks/useSaveFlow.ts +++ b/apps/electron-app/src/render/hooks/useSaveFlow.ts @@ -35,7 +35,7 @@ export function useSaveFlow() { }, [autoSave, saveNodesAndEdges]); useEffect(() => { - window.electron.ipcRenderer.send('ipc-menu', 'auto-save', autoSave); + window.electron.ipcRenderer.send('ipc-menu', { action: 'auto-save', args: autoSave }); }, [autoSave]); return { autoSave, setAutoSave, saveNodesAndEdges }; diff --git a/apps/electron-app/src/render/hooks/useSignalNodesAndEdges.ts b/apps/electron-app/src/render/hooks/useSignalNodesAndEdges.ts index 6614092..f021229 100644 --- a/apps/electron-app/src/render/hooks/useSignalNodesAndEdges.ts +++ b/apps/electron-app/src/render/hooks/useSignalNodesAndEdges.ts @@ -10,21 +10,23 @@ export function useSignalNodesAndEdges() { const timeouts = useRef>(new Map()); useEffect(() => { - return window.electron.ipcRenderer.on('ipc-microcontroller', (message: UploadedCodeMessage) => { - if (message.value instanceof Error) { - toast.error(message.value.message, { + return window.electron.ipcRenderer.on('ipc-microcontroller', result => { + if (!result.success) return; + + if (result.data.value instanceof Error) { + toast.error(result.data.value.message, { important: true, - description: `Error in node ${message.nodeId} with handle ${message.action}`, + description: `Error in node ${result.data.nodeId} with handle ${result.data.action}`, }); return; } - update(message.nodeId, message.value); + update(result.data.nodeId, result.data.value); getEdges() .filter( ({ source, sourceHandle }) => - source === message.nodeId && sourceHandle === message.action, + source === result.data.nodeId && sourceHandle === result.data.action, ) .map(edge => { const timeout = timeouts.current.get(edge.id); diff --git a/apps/electron-app/src/render/stores/app.ts b/apps/electron-app/src/render/stores/app.ts new file mode 100644 index 0000000..08529b7 --- /dev/null +++ b/apps/electron-app/src/render/stores/app.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +type AppState = { + settingsOpen: string | undefined; + setSettingsOpen: (settings: string | undefined) => void; +}; + +export const useAppStore = create(set => { + return { + settingsOpen: undefined, + setSettingsOpen: (settingsOpen: string | undefined) => { + set({ settingsOpen }); + }, + }; +}); diff --git a/apps/electron-app/src/render/stores/board.ts b/apps/electron-app/src/render/stores/board.ts index db2c473..ce4fb63 100644 --- a/apps/electron-app/src/render/stores/board.ts +++ b/apps/electron-app/src/render/stores/board.ts @@ -11,11 +11,11 @@ type BoardState = { export const useBoardStore = create(set => { return { - board: { type: 'info' }, + board: { type: 'close' }, setBoardResult: (result: BoardResult) => { set({ board: result }); }, - upload: { type: 'info' }, + upload: { type: 'close' }, setUploadResult: (result: UploadResult) => { set({ upload: result }); }, diff --git a/apps/electron-app/src/utils/generateCode.ts b/apps/electron-app/src/utils/generateCode.ts index 732b51c..d6ee8c5 100644 --- a/apps/electron-app/src/utils/generateCode.ts +++ b/apps/electron-app/src/utils/generateCode.ts @@ -1,6 +1,7 @@ import { Edge, Node } from '@xyflow/react'; -export function isNodeTypeACodeType(type: string) { +export function isNodeTypeACodeType(type?: string) { + if (!type) return false; return !['note'].includes(type.toLowerCase()); } @@ -116,10 +117,26 @@ const log = require("electron-log/node"); function addBoard() { return ` +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])$/); +const portIsIp = ipRegex.test(String(port)); + +let connection; + +if(portIsIp) { + connection = new MicroflowComponents.TcpSerial({ + host: port, + port: 3030, + }); + + connection.on('close', e => { + process.parentPort.postMessage({ type: "close", message: \`Connection lost to \${port}\` }); + }) +} + const board = new MicroflowComponents.Board({ repl: false, debug: false, - port: port, + port: connection || port, }); log.info("Board is created", { port: board.port }); diff --git a/apps/electron-app/test.js b/apps/electron-app/test.js deleted file mode 100644 index 71793f1..0000000 --- a/apps/electron-app/test.js +++ /dev/null @@ -1,31 +0,0 @@ -const { SerialPort } = require('serialport'); - -const KNOWN_BOARD_PRODUCT_IDS = [ - ['uno', ['0043', '7523', '0001', 'ea60', '6015']], - ['mega', ['0042', '6001', '0010', '7523']], - ['leonardo', ['0036', '8036', '800c']], - ['micro', ['0037', '8037', '0036', '0237']], - ['nano', ['6001', '7523']], - ['yun', ['0041', '8041']], -]; - -async function getDevices() { - // const usbDevices = usb.getDeviceList(); - // const webUsbDevices = await new WebUSB({ - // allowAllDevices: true, - // }).getDevices(); - const serialPortDevices = await SerialPort.list(); - - serialPortDevices.forEach(device => { - const productId = device.productId || device.pnpId; - if (productId) { - KNOWN_BOARD_PRODUCT_IDS.forEach(([board, productIds]) => { - if (productIds.includes(productId)) { - console.log('SerialPort', board, device); - } - }); - } - }); -} - -getDevices(); diff --git a/apps/electron-app/test.ts b/apps/electron-app/test.ts new file mode 100644 index 0000000..f7a0f6f --- /dev/null +++ b/apps/electron-app/test.ts @@ -0,0 +1,78 @@ +import { TcpSerial, EtherPortClient } from '@microflow/components'; +import { getConnectedPorts } from '@microflow/flasher'; +import * as JohnnyFive from 'johnny-five'; + +const KNOWN_BOARD_PRODUCT_IDS = [ + ['uno', ['0043', '7523', '0001', 'ea60', '6015']], + ['mega', ['0042', '6001', '0010', '7523']], + ['leonardo', ['0036', '8036', '800c']], + ['micro', ['0037', '8037', '0036', '0237']], + ['nano', ['6001', '7523']], + ['yun', ['0041', '8041']], +]; + +async function test() { + const ports = await getConnectedPorts(); + console.log(ports); + const connection = new TcpSerial({ + host: '192.168.2.26', + port: 3030, + }); + const board = new JohnnyFive.Board({ + port: connection, + repl: false, + debug: true, + }); + + // connection.on('connect', e => { + // console.log('connected', e); + // }); + // connection.on('data', e => { + // console.log('data', (e as Buffer).toString()); + // }); + // connection.on('error', e => { + // console.log('error', e); + // }); + // connection.on('timeout', e => { + // console.log('timeout', e); + // }); + connection.on('close', e => { + console.log('close', e); + }); + + board.on('ready', () => { + console.log('ready'); + const led = new JohnnyFive.Led({ + pin: 13, + }); + + setInterval(() => { + led.toggle(); + }, 500); + }); + board.on('error', event => { + console.info('board error', { event }); + }); // board - error + + board.on('fail', event => { + console.info('board fail', { event }); + }); // board - fail + + board.on('warn', event => { + console.info('board warn', { event }); + }); // board - warn + + board.on('exit', () => { + console.info('board exit', {}); + }); // board - exit + + board.on('close', () => { + console.info('board close', {}); + }); // board - close + + board.on('info', event => { + console.info('board info', { event }); + }); // board - info +} + +test(); diff --git a/apps/electron-app/tsconfig.json b/apps/electron-app/tsconfig.json index cddb88f..93b10cf 100644 --- a/apps/electron-app/tsconfig.json +++ b/apps/electron-app/tsconfig.json @@ -3,6 +3,7 @@ "jsx": "react-jsx", "paths": { "@ui/*": ["../../packages/ui/*"] - } + }, + "strict": true } } diff --git a/apps/electron-app/workers/check.js b/apps/electron-app/workers/check.js index ff4bd4d..2b0aa70 100644 --- a/apps/electron-app/workers/check.js +++ b/apps/electron-app/workers/check.js @@ -1,4 +1,4 @@ -const { Board } = require('@microflow/components'); +const { Board, TcpSerial } = require('@microflow/components'); const port = process.argv.at(-1); @@ -13,10 +13,28 @@ if (!port) { let board; try { + 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])$/, + ); + const portIsIp = ipRegex.test(port); + let connection; + + if (portIsIp) { + connection = new TcpSerial({ host: port, port: 3030 }); + + connection.on('close', () => { + process.parentPort.postMessage({ + type: 'close', + message: `Connection not reachable on ${port}`, + class: TcpSerial.name, + }); + }); + } + board = new Board({ repl: false, debug: true, - port, + port: connection || port, }); process.parentPort.postMessage({ diff --git a/apps/figma-plugin/package.json b/apps/figma-plugin/package.json index a55221d..e4f2767 100644 --- a/apps/figma-plugin/package.json +++ b/apps/figma-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@microflow/figma-plugin", "private": true, - "version": "0.6.4", + "version": "0.7.0", "scripts": { "dev": "concurrently \"yarn watch:ui\" \"yarn watch:plugin\"", "dev:ui-only": "vite -c ./vite.config.ui.ts", diff --git a/apps/nextjs-app/components/DownloadApp.tsx b/apps/nextjs-app/components/DownloadApp.tsx index bfabd9b..7d49125 100644 --- a/apps/nextjs-app/components/DownloadApp.tsx +++ b/apps/nextjs-app/components/DownloadApp.tsx @@ -23,7 +23,7 @@ export function DownloadApp() { const [os, setOs] = useState(); function downloadApp() { - const version = '0.6.4'; + const version = '0.7.0'; const baseUrl = `https://github.com/xiduzo/microflow/releases/download/v${version}`; switch (os) { diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index 422d0c2..4f6cb0e 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -1,6 +1,6 @@ { "name": "nextjs-app", - "version": "0.6.4", + "version": "0.7.0", "private": true, "scripts": { "dev": "next dev", diff --git a/packages/components/README.md b/packages/components/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/index.ts b/packages/components/index.ts index f5bd800..202139d 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -15,3 +15,4 @@ export * from './src/components/RGB'; export * from './src/components/Sensor'; export * from './src/components/Servo'; export * from './src/types'; +export * from './src/TcpSerial'; diff --git a/packages/components/src/TcpSerial.ts b/packages/components/src/TcpSerial.ts new file mode 100644 index 0000000..149b781 --- /dev/null +++ b/packages/components/src/TcpSerial.ts @@ -0,0 +1,118 @@ +// from https://github.com/mwittig/etherport-client/blob/master/index.js +import { EventEmitter } from 'events'; +import { Socket } from 'net'; + +type Options = { + port: number; + host: string; +}; + +const PING_INTERVAL_MS = 1000; + +export class TcpSerial extends EventEmitter { + private socket: Socket; + private disconnectTimeout: NodeJS.Timeout | undefined = undefined; + private pingInterval: NodeJS.Timeout | undefined = undefined; + + name = 'UdpSerial'; + + constructor(private readonly options: Options) { + super(); + + this.socket = new Socket(); + + this.socket.on('close', this.onCloseHandler.bind(this)); + this.socket.on('connect', this.onConnectHandler.bind(this)); + this.socket.on('data', this.onDataHandler.bind(this)); + this.socket.on('end', this.onEndHandler.bind(this)); + this.socket.on('error', this.onErrorHandler.bind(this)); + this.socket.on('ready', this.onReadyHandler.bind(this)); + this.socket.on('timeout', this.onTimeoutHandler.bind(this)); + + this.connect(); + } + + write(buffer: Buffer, callback?: any) { + if (!Buffer.isBuffer(buffer)) { + buffer = Buffer.from(buffer); + } + + if (!this.socket.writable) { + console.warn('Socket not writable'); + return; + } + + this.socket.write(buffer, error => { + if (!error) return; + console.error('Write error', error); + this.socket.destroy(error); + }); + + if (typeof callback === 'function') { + process.nextTick(callback); + } + } + + private connect() { + clearInterval(this.pingInterval); + + this.socket.setNoDelay(true); + this.socket.setTimeout(5000); + this.socket.connect(this.options.port, this.options.host, () => { + console.log('[CONNECTED]'); + this.socket.setTimeout(0); + }); + + this.pingInterval = setInterval(() => { + // https://github.com/firmata/protocol/blob/master/protocol.md#query-firmware-name-and-version + this.write(Buffer.from([0xf0, 0x79, 0xf7])); + }, PING_INTERVAL_MS); + } + + private timeout() { + clearTimeout(this.disconnectTimeout); + + this.disconnectTimeout = setTimeout(() => { + this.socket.destroy(); + }, PING_INTERVAL_MS * 1.25); + } + + private onCloseHandler(event: unknown) { + console.debug('onCloseHandler', event); + this.emit('close', event); + } + + private onConnectHandler(event: unknown) { + console.debug('onConnectHandler', event); + this.socket.setTimeout(0); + this.emit('connect', event); + } + + private onDataHandler(event: Buffer) { + console.debug('onDataHandler', event.toString()); + this.timeout(); // Start timeout after we have received the first data + this.emit('data', event); + } + + private onErrorHandler(event: unknown) { + console.debug('onErrorHandler', event); + this.emit('error', event); + this.socket.destroy(); + } + + private onTimeoutHandler(event: unknown) { + console.debug('onTimeoutHandler', event); + this.emit('timeout', event); + this.socket.destroy(); + } + + private onEndHandler(event: unknown) { + console.debug('onEndHandler', event); + this.emit('end', event); + } + + private onReadyHandler(event: unknown) { + console.debug('onReadyHandler', event); + this.emit('ready', event); + } +} diff --git a/packages/flasher/README.md b/packages/flasher/README.md new file mode 100644 index 0000000..d775675 --- /dev/null +++ b/packages/flasher/README.md @@ -0,0 +1 @@ +HUGE thanks to [avrgirl-arduino](https://github.com/noopkat/avrgirl-arduino) which is the foudation of this package. diff --git a/packages/flasher/package.json b/packages/flasher/package.json index 91a69c9..43535e3 100644 --- a/packages/flasher/package.json +++ b/packages/flasher/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "dev": "tsc -w", - "build": "tsc" + "build": "tsc", + "test": "npx tsx ./test.ts" }, "devDependencies": { "typescript": "5.6.2" diff --git a/packages/flasher/src/UdpSerial.ts b/packages/flasher/src/UdpSerial.ts new file mode 100644 index 0000000..2aa5513 --- /dev/null +++ b/packages/flasher/src/UdpSerial.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from 'events'; +import { Socket } from 'net'; + +type Options = { + port: number; + host: string; + reconnectTimeoutSecs?: number; +}; +export class UdpSerial extends EventEmitter { + private socket: Socket; + private reconnectTimer: NodeJS.Timeout | undefined = undefined; + private queue: Buffer[] = []; + + constructor(private readonly options: Options) { + super(); + + this.socket = new Socket(); + this.socket.on('connect', this.onConnectHandler.bind(this)); + this.socket.on('data', this.onDataHandler.bind(this)); + this.socket.on('error', this.onErrorHandler.bind(this)); + this.socket.on('timeout', this.onTimeoutHandler.bind(this)); + this.socket.on('close', this.onCloseHandler.bind(this)); + + this.connect(); + } + + private connect() { + this.socket.setNoDelay(true); + this.socket.setTimeout(5000); + this.socket.connect(this.options.port, this.options.host, () => { + console.log('connected'); + }); + } + + private reconnect() { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = setTimeout( + () => { + this.connect(); + }, + (this.options.reconnectTimeoutSecs ?? 15) * 1000, + ); + } + + private onCloseHandler(event: unknown) { + console.debug('onCloseHandler', event); + this.reconnect(); + } + + private onConnectHandler(event: unknown) { + console.debug('onConnectHandler', event); + this.socket.setTimeout(0); + this.flushTo(); + } + + private onDataHandler(event: unknown) { + console.debug('onDataHandler', event); + this.emit('data', event); + } + + private onErrorHandler(event: unknown) { + console.debug('onErrorHandler', event); + this.emit('error', event); + this.reconnect(); + } + + private onTimeoutHandler(event: unknown) { + console.debug('onTimeoutHandler', event); + this.emit('timeout', event); + this.socket.destroy(); + this.reconnect(); + } + + private flushTo() { + this.emit('open'); + + this.queue.forEach(buffer => { + this.socket.write(buffer); + }); + + // Clear queue + this.queue.length = 0; + } +} diff --git a/packages/flasher/test.ts b/packages/flasher/test.ts index 6106d00..621d80a 100644 --- a/packages/flasher/test.ts +++ b/packages/flasher/test.ts @@ -6,22 +6,22 @@ import { getConnectedPorts } from './src/serialport'; async function flash() { const ports = await getConnectedPorts(); console.log(ports); - const board = 'mega'; - try { - const __dirname = path.resolve(path.dirname('')); - const filePath = path.resolve( - __dirname, - `../../apps/electron-app/hex/${board}/StandardFirmata.cpp.hex`, - ); - await new Flasher(board, '/dev/tty.usbmodem1401').flash(filePath); - console.log('done'); - } catch (error) { - if (error instanceof UnableToOpenSerialConnection) { - console.log('Unable to open serial connection'); - return; - } - console.log(error); - } + // const board = 'mega'; + // try { + // const __dirname = path.resolve(path.dirname('')); + // const filePath = path.resolve( + // __dirname, + // `../../apps/electron-app/hex/${board}/StandardFirmata.cpp.hex`, + // ); + // await new Flasher(board, '/dev/tty.usbmodem1401').flash(filePath); + // console.log('done'); + // } catch (error) { + // if (error instanceof UnableToOpenSerialConnection) { + // console.log('Unable to open serial connection'); + // return; + // } + // console.log(error); + // } } flash();