Skip to content

Commit

Permalink
feat: firmata over wifi (#32)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
xiduzo authored Dec 3, 2024
1 parent 08ca347 commit dc7feba
Show file tree
Hide file tree
Showing 36 changed files with 915 additions and 323 deletions.
5 changes: 3 additions & 2 deletions apps/electron-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion apps/electron-app/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Modes } from '@tsparticles/engine';
import type { Edge, Node } from '@xyflow/react';

export enum MODES {
Expand Down Expand Up @@ -71,3 +70,5 @@ export type FlowFile = {
nodes: Node[];
edges: Edge[];
};

export type IpcResponse<T> = { success: true; data: T } | { success: false; error: string };
13 changes: 6 additions & 7 deletions apps/electron-app/src/main/deepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,19 @@ 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;
}

const linkWebRegex = new RegExp(/^(?:mfs|microflow-studio):\/\/link-web$/);
const linkWebMatch = linkWebRegex.exec(link);

if (linkWebMatch?.length) {
mainWindow.webContents.send('ipc-deep-link', 'web');
mainWindow.webContents.send('ipc-deep-link', { from: 'web' });
return;
}
}
220 changes: 142 additions & 78 deletions apps/electron-app/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,50 @@ 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')
// shell.openExternal(pagePath)
// })
//

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<BoardResult>);

return;
}

// Check board on all ports which match the known product IDs
const boardsAndPorts = await getKnownBoardsWithPorts();
Expand All @@ -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<BoardResult>(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<BoardResult>);
break checkBoard;
}

Expand All @@ -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<BoardResult>);
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<BoardResult>);
}
}
}
Expand All @@ -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<BoardResult>(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<BoardResult>);
});

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<BoardResult>);
});
});
}

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<UploadResult>);
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<BoardResult>);
});

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<UploadResult>);

if (message.type === 'close') {
uploadProcess?.kill();
}
return;
}

event.reply('ipc-microcontroller', message);
event.reply('ipc-microcontroller', {
data: message,
success: true,
} satisfies IpcResponse<UploadedCodeMessage>);
});
});
});

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<void> {
childProcess?.kill();
checkProcess?.kill();
log.debug(`Try flashing firmata to ${board} on ${port.path}`);

const firmataPath = resolve(__dirname, 'hex', board, 'StandardFirmata.cpp.hex');
Expand Down Expand Up @@ -214,20 +272,26 @@ 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<BoardResult>);
return;
}

// Check if new ports are connected
// 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<BoardResult>);
return;
}

Expand Down
Loading

0 comments on commit dc7feba

Please sign in to comment.