Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ Options:
- `--ignore-warnings` - Only report errors
- `--warnings-as-errors` - Exit with error code if warnings are found (useful for CI)
- `--offline` - Skip downloading latest board definitions
- `--boards-url <url>` - Load board definitions from a custom URL
- `--boards-file <path>` - Load board definitions from a local bundle.json file

### Custom Board Definitions

To use custom board definitions, you can specify a remote URL or a local bundle.json file:

```bash
# Load from remote URL
wokwi-cli . --boards-url https://wokwi.com/custom-boards/bundle.json

# Load from local file
wokwi-cli . --boards-file /path/to/wokwi-boards/boards/bundle.json
```

The bundle.json file format is defined in the [wokwi-boards repository](https://github.com/wokwi/wokwi-boards). You can generate a bundle.json by running the `make-bundle.js` script in the wokwi-boards tools directory.

## MCP Server

Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface LintOptions {
ignoreWarnings?: boolean;
warningsAsErrors?: boolean;
offline?: boolean;
boardsUrl?: string;
boardsFile?: string;
}

export function lintCommand(program: Command): void {
Expand All @@ -24,14 +26,16 @@ export function lintCommand(program: Command): void {
.option('--ignore-warnings', 'Do not report warnings')
.option('--warnings-as-errors', 'Treat warnings as errors (exit code 1)')
.option('--offline', 'Skip downloading latest board definitions')
.option('--boards-url <url>', 'Custom URL for board definitions bundle')
.option('--boards-file <path>', 'Local path to board definitions bundle.json')
.action(async (projectPath: string, options: LintOptions) => {
await runLint(projectPath, options);
});
}

async function runLint(projectPath: string, options: LintOptions) {
const { ignoreWarnings, warningsAsErrors, offline } = options;
const shouldFetch = !offline;
const { ignoreWarnings, warningsAsErrors, offline, boardsUrl, boardsFile } = options;
const shouldFetch = !offline || boardsUrl || boardsFile;

// Resolve diagram path
let diagramPath = path.resolve(projectPath);
Expand All @@ -56,7 +60,9 @@ async function runLint(projectPath: string, options: LintOptions) {

// Try to fetch remote boards (only for lint command)
if (shouldFetch) {
const remoteBoards = await fetchRemoteBoards();
const remoteBoards = await fetchRemoteBoards(
boardsFile ? { file: boardsFile } : boardsUrl ? { url: boardsUrl } : undefined,
);
if (remoteBoards) {
linter.getRegistry().loadBoardsBundle(remoteBoards);
}
Expand Down
217 changes: 214 additions & 3 deletions packages/cli/src/commands/simulate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { TestScenario } from '../TestScenario.js';
import { parseConfig } from '../config.js';
import { DEFAULT_SERVER } from '../constants.js';
import { idfProjectConfig } from '../esp/idfProjectConfig.js';
import { displayLintResults } from '../lint/index.js';
import { displayLintResults, fetchRemoteBoards } from '../lint/index.js';
import { loadChips } from '../loadChips.js';
import { readVersion } from '../readVersion.js';
import { DelayCommand } from '../scenario/DelayCommand.js';
Expand Down Expand Up @@ -54,6 +54,171 @@ interface SimulateOptions {
timeoutExitCode?: string;
quiet?: boolean;
vcdFile?: string;
boardsUrl?: string;
boardsFile?: string;
}

/**
* Expand custom board definitions into inline parts
*
* For boards not known to the server, we expand them into:
* - ESP32 devkit part (for the MCU)
* - LCD part (for displays)
* - Touch part (for touch controllers)
* - Internal connections
*/
function expandCustomBoards(
diagramJson: string,
remoteBoards: Record<string, { def: any; rev: string }>,
quiet: boolean,
): { diagram: string; partIdMap: Record<string, string>; lcdIdMap: Record<string, string> } {
const diagram = JSON.parse(diagramJson);
const parts = diagram.parts || [];
const connections = diagram.connections || [];
const newParts: any[] = [];
const newConnections: any[] = [];

// Track part ID mapping: old board ID -> new MCU part ID
const partIdMap: Record<string, string> = {};
const lcdIdMap: Record<string, string> = {};

for (const part of parts) {
const boardId = part.type.replace('board-', '');

if (remoteBoards[boardId]) {
const boardDef = remoteBoards[boardId].def;
const mcuType = boardDef.mcu;

// Map MCU type to devkit board type
let devkitType = 'board-esp32-devkitc-1';
if (mcuType === 'esp32-s3') {
devkitType = 'board-esp32-s3-devkitc-1';
} else if (mcuType === 'esp32-c3') {
devkitType = 'board-esp32-c3-devkitm-1';
}

// Create MCU part
const mcuPartId = `${part.id}-mcu`;
const mcuPart: any = {
type: devkitType,
id: mcuPartId,
attrs: {
psramSize: boardDef.mcuAttrs?.psramSize || '8',
psramType: boardDef.mcuAttrs?.psramType || 'quad',
flashSize: boardDef.mcuAttrs?.flashSize || '8',
},
};
newParts.push(mcuPart);
partIdMap[part.id] = mcuPartId;

// Store LCD ID for screenshot mapping
let lcdPartId: string | undefined;
if (boardDef.displays && boardDef.displays.length > 0) {
const display = boardDef.displays[0];

// Map unsupported LCD chips to supported ones
// Most common supported chip: ili9341
const supportedChips = ['ili9341', 'ili9342', 'st7789', 'ssd1306'];
const lcdChip = supportedChips.includes(display.chip) ? display.chip : 'ili9341';

const lcdPart: any = {
type: `wokwi-${lcdChip}`,
id: display.id,
attrs: {
width: String(display.pixelWidth),
height: String(display.pixelHeight),
rotate: '0',
},
};
newParts.push(lcdPart);
if (lcdPartId) {
lcdIdMap[part.id] = lcdPartId;
}

// Add internal connections from board definition
for (const [pinName, pinDef] of Object.entries(boardDef.pins || {})) {
// Handle $gpioXX special pins that map to LCD/touch pins
if (
pinName.startsWith('$gpio') &&
typeof pinDef === 'object' &&
pinDef !== null &&
'target' in pinDef
) {
const target = (pinDef as any).target;
if (Array.isArray(target) && target.length >= 2) {
// First element is GPIO pin, second is LCD/touch pin
const gpioPin = target[0]; // "GPIO18"
const lcdPin = target[1]; // "lcd1:SCK"

if (
typeof gpioPin === 'string' &&
typeof lcdPin === 'string' &&
lcdPin.includes(':')
) {
const [, targetPin] = lcdPin.split(':');
const targetPartId = lcdPin.split(':')[0]; // "lcd1" or "touch1"

// Convert GPIO pin to devkit pin format (GPIO18 -> G18)
const devkitPin = gpioPin.replace('GPIO', 'G');

newConnections.push([
`${mcuPartId}:${devkitPin}`,
`${targetPartId}:${targetPin}`,
'',
]);
}
}
}
}

// Create touch part if exists
if (display.touch) {
const touchPart: any = {
type: `wokwi-${display.touch.chip}`,
id: display.touch.id,
attrs: {},
};
newParts.push(touchPart);
}
}

// Remap existing connections to use new MCU part ID
for (const conn of connections) {
if (conn[0]?.startsWith(`${part.id}:`) || conn[1]?.startsWith(`${part.id}:`)) {
const newConn = [...conn];
if (newConn[0]?.startsWith(`${part.id}:`)) {
newConn[0] = newConn[0].replace(`${part.id}:`, `${mcuPartId}:`);
}
if (newConn[1]?.startsWith(`${part.id}:`)) {
newConn[1] = newConn[1].replace(`${part.id}:`, `${mcuPartId}:`);
}
newConnections.push(newConn);
} else {
newConnections.push(conn);
}
}

if (!quiet) {
console.log(`Expanded custom board: ${part.type} → ${devkitType} + LCD`);
}
} else {
// Not a custom board, keep as-is
newParts.push(part);
}
}

diagram.parts = newParts;
diagram.connections = newConnections;

const expanded = JSON.stringify(diagram, null, 2);

// Log expanded diagram for diagnostics
if (!quiet) {
console.log('Expanded diagram (for diagnostics):');
console.log(expanded);
}

return { diagram: expanded, partIdMap, lcdIdMap };
}

export function simulateCommand(program: Command): void {
Expand All @@ -73,6 +238,8 @@ export function simulateCommand(program: Command): void {
.option('--timeout-exit-code <code>', 'Exit code on timeout', '42')
.option('-q, --quiet', 'Suppress status messages')
.option('--vcd-file <path>', 'Output path for VCD (logic analyzer) file')
.option('--boards-url <url>', 'Custom URL for board definitions bundle')
.option('--boards-file <path>', 'Local path to board definitions bundle.json')
.action((projectPath: string, options: SimulateOptions, command: Command) => {
return runSimulation(projectPath, options, command);
});
Expand Down Expand Up @@ -200,10 +367,31 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
process.exit(1);
}

const diagram = readFileSync(diagramFilePath, 'utf8');
let diagram = readFileSync(diagramFilePath, 'utf8');

// Lint the diagram before simulation
const linter = new DiagramLinter();

// Fetch and load custom board definitions if --boards-url or --boards-file is provided
let remoteBoards: Awaited<ReturnType<typeof fetchRemoteBoards>> = null;
if (options.boardsUrl || options.boardsFile) {
remoteBoards = await fetchRemoteBoards(
options.boardsFile ? { file: options.boardsFile } : { url: options.boardsUrl },
);
if (remoteBoards) {
const count = linter.getRegistry().loadBoardsBundle(remoteBoards);
if (!quiet) {
const source = options.boardsFile ? options.boardsFile : options.boardsUrl;
console.log(`Loaded ${count} board definitions from ${source}`);
}
} else {
const source = options.boardsFile ? options.boardsFile : options.boardsUrl;
console.error(
chalkTemplate`{yellow Warning:} Failed to fetch boards from {yellow ${source}}`,
);
}
}

const lintResult = linter.lintJSON(diagram);

if (lintResult.stats.errors > 0 || lintResult.stats.warnings > 0) {
Expand All @@ -213,6 +401,28 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
displayLintResults(lintResult, { quiet });
}

// Expand custom boards into inline parts
let expandedScreenshotPart = screenshotPart;
if (remoteBoards) {
try {
const expansionResult = expandCustomBoards(diagram, remoteBoards, quiet ?? false);
diagram = expansionResult.diagram;
// Map screenshot part if board was expanded
if (screenshotPart && expansionResult.partIdMap[screenshotPart]) {
const lcdId = expansionResult.lcdIdMap[screenshotPart];
if (lcdId) {
expandedScreenshotPart = lcdId;
if (!quiet) {
console.log(`Mapped screenshot part: ${screenshotPart} → ${lcdId}`);
}
}
}
} catch (err) {
console.error(chalkTemplate`{red Error expanding custom boards:} ${(err as Error).message}`);
process.exit(1);
}
}

const rfc2217ServerPort = config?.wokwi.rfc2217ServerPort;
const chips = loadChips(config?.chip ?? [], rootDir);

Expand Down Expand Up @@ -263,6 +473,7 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
try {
await client.connected;
await client.fileUpload('diagram.json', diagram);

const firmwareParams = await uploadFirmware(client, firmwarePath);
if (elfPath != null) {
await client.fileUpload('firmware.elf', new Uint8Array(readFileSync(elfPath)));
Expand Down Expand Up @@ -309,7 +520,7 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
}
screenshotPromise = client.atNanos(screenshotTime * millis).then(async () => {
try {
const result = await client.framebufferRead(screenshotPart);
const result = await client.framebufferRead(expandedScreenshotPart ?? screenshotPart);
writeFileSync(screenshotFile, result.png, 'base64');
await client.simResume();
} catch (err) {
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/lint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type LintResult,
} from '@wokwi/diagram-lint';
import chalkTemplate from 'chalk-template';
import { readFileSync } from 'fs';

export interface LintDisplayOptions {
/** Only show errors, hide warnings and info */
Expand Down Expand Up @@ -90,18 +91,36 @@ export function formatLintSummary(result: LintResult): string {
export interface FetchBoardsOptions {
/** Timeout in milliseconds (default: 5000) */
timeout?: number;
/** Custom URL for board definitions (overrides default) */
url?: string;
/** Local path to bundle.json file */
file?: string;
}

/**
* Fetch board definitions from the remote registry
* Fetch board definitions from the remote registry or local file
*
* @returns Board bundle, or null if fetch fails
*/
export async function fetchRemoteBoards(options?: FetchBoardsOptions): Promise<BoardBundle | null> {
const timeout = options?.timeout ?? 5000;
const url = options?.url ?? REMOTE_BOARDS_URL;
const filePath = options?.file;

// Load from local file if specified
if (filePath) {
try {
const content = readFileSync(filePath, 'utf-8');
return JSON.parse(content) as BoardBundle;
} catch (err) {
console.error(`Error reading local bundle file: ${(err as Error).message}`);
return null;
}
}

// Fetch from remote URL
try {
const response = await fetch(REMOTE_BOARDS_URL, {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeout),
});

Expand Down
Loading