diff --git a/package-lock.json b/package-lock.json index 61ada1ebb..7e3d431cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@tanstack/react-table": "^8.10.7", "@xyflow/react": "^12.0.1", "auto-zustand-selectors-hook": "^2.0.0", + "avr8js": "0.20.0", "clsx": "^2.0.0", "cva": "npm:class-variance-authority@^0.7.0", "dompurify": "^3.2.4", @@ -11549,6 +11550,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avr8js": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/avr8js/-/avr8js-0.20.0.tgz", + "integrity": "sha512-FXScGqctUpVr0mxFAceWSyKRrPbXftu+RfKCwu4Ie2bNel+1KdUbMF6TdCjwBXFBMxjDueDq7k/hzw2hLGtTWg==", + "engines": { + "node": ">= 8.0.0", + "npm": ">= 5.0.0" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.11", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", diff --git a/package.json b/package.json index cd7789b5d..51b1c7adf 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@tanstack/react-table": "^8.10.7", "@xyflow/react": "^12.0.1", "auto-zustand-selectors-hook": "^2.0.0", + "avr8js": "0.20.0", "clsx": "^2.0.0", "cva": "npm:class-variance-authority@^0.7.0", "dompurify": "^3.2.4", diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 61cf6273a..a55b64469 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -339,4 +339,12 @@ void loop() modbusTask(); } #endif + + #ifdef SIMULATOR_MODE + // In the emulated ATmega2560, busy-waiting wastes host CPU executing + // millions of useless instructions. SLEEP (opcode 0x9588) is detected + // by the emulator which fast-forwards the clock to the next timer event + // instead of stepping through every idle cycle. + __asm volatile("sleep"); + #endif } diff --git a/resources/sources/boards/hals.json b/resources/sources/boards/hals.json index cbd98db34..f905da3b8 100644 --- a/resources/sources/boards/hals.json +++ b/resources/sources/boards/hals.json @@ -1,4 +1,29 @@ { + "OpenPLC Simulator": { + "compiler": "simulator", + "core": "arduino:avr", + "c_flags": ["-MMD", "-c", "-Wno-incompatible-pointer-types"], + "ld_flags": ["-Wl,--defsym,__DATA_REGION_LENGTH__=0xFE00", "-Wl,--defsym,__stack=0x80FFFF"], + "default_ain": "A0, A1, A2, A3, A4, A5, A6, A7", + "default_aout": "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13", + "default_din": "62, 63, 64, 65, 66, 67, 68, 69, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52", + "default_dout": "14, 15, 16, 17, 18, 19, 20, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49, 51, 53", + "extra_libraries": [], + "platform": "arduino:avr:mega", + "source": "mega_due.cpp", + "preview": "simulator.png", + "specs": { + "CPU": "Emulated ATmega2560 at 16MHz", + "RAM": "63.5 KB", + "Flash": "256 KB", + "Digital Pins": "70", + "Analog Pins": "16", + "PWM Pins": "15", + "WiFi": "No", + "Bluetooth": "No", + "Ethernet": "No" + } + }, "Arduino Due (native USB port)": { "compiler": "arduino-cli", "core": "arduino:sam", diff --git a/resources/sources/boards/previews/simulator.png b/resources/sources/boards/previews/simulator.png new file mode 100644 index 000000000..003939d21 Binary files /dev/null and b/resources/sources/boards/previews/simulator.png differ diff --git a/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts index 1ca4ced6c..e0a0a374d 100644 --- a/src/main/modules/compiler/compiler-module.ts +++ b/src/main/modules/compiler/compiler-module.ts @@ -687,11 +687,13 @@ class CompilerModule { projectPath, buildMD5Hash, boardTarget, + boardRuntime, _handleOutputData, }: { projectPath: string boardTarget: string buildMD5Hash: string + boardRuntime: string _handleOutputData: HandleOutputDataCallback }) { let DEFINES_CONTENT: string = '' @@ -753,10 +755,19 @@ class CompilerModule { // 3.2. Device Configuration DEFINES_CONTENT += '//Comms Configuration\n' - DEFINES_CONTENT += `#define MBSERIAL_IFACE ${modbusRTU.rtuInterface}\n` - DEFINES_CONTENT += `#define MBSERIAL_BAUD ${modbusRTU.rtuBaudRate}\n` - if (modbusRTU.rtuSlaveId !== null) DEFINES_CONTENT += `#define MBSERIAL_SLAVE ${modbusRTU.rtuSlaveId}\n` - if (modbusRTU.rtuRS485ENPin !== null) DEFINES_CONTENT += `#define MBSERIAL_TXPIN ${modbusRTU.rtuRS485ENPin}\n` + if (boardRuntime === 'simulator') { + // Simulator forces fixed Modbus RTU settings over emulated USART0. + // On ATmega2560, Serial = USART0. avr8js bridges usart0. + DEFINES_CONTENT += '#define SIMULATOR_MODE\n' + DEFINES_CONTENT += '#define MBSERIAL_IFACE Serial\n' + DEFINES_CONTENT += '#define MBSERIAL_BAUD 115200\n' + DEFINES_CONTENT += '#define MBSERIAL_SLAVE 1\n' + } else { + DEFINES_CONTENT += `#define MBSERIAL_IFACE ${modbusRTU.rtuInterface}\n` + DEFINES_CONTENT += `#define MBSERIAL_BAUD ${modbusRTU.rtuBaudRate}\n` + if (modbusRTU.rtuSlaveId !== null) DEFINES_CONTENT += `#define MBSERIAL_SLAVE ${modbusRTU.rtuSlaveId}\n` + if (modbusRTU.rtuRS485ENPin !== null) DEFINES_CONTENT += `#define MBSERIAL_TXPIN ${modbusRTU.rtuRS485ENPin}\n` + } if (modbusTCP.tcpMacAddress !== null) DEFINES_CONTENT += `#define MBTCP_MAC ${FormatMacAddress(modbusTCP.tcpMacAddress)}\n` // OBS: This is giving us an empty string and this is being printed as a space @@ -769,7 +780,7 @@ class CompilerModule { if (modbusTCP.tcpStaticHostConfiguration.subnet !== null) DEFINES_CONTENT += `#define MBTCP_SUBNET ${modbusTCP.tcpStaticHostConfiguration.subnet.replaceAll('.', ',')}\n` - if (communicationPreferences.enabledRTU) { + if (communicationPreferences.enabledRTU || boardRuntime === 'simulator') { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' } @@ -1004,6 +1015,14 @@ class CompilerModule { ] } + if (boardHalsContent['ld_flags']) { + buildProjectFlags = [ + ...buildProjectFlags, + '--build-property', + `compiler.c.elf.extra_flags=${boardHalsContent['ld_flags'].map((f: string) => f).join(' ')}`, + ] + } + buildProjectFlags = [ ...buildProjectFlags, '--library', @@ -2019,6 +2038,7 @@ class CompilerModule { projectPath: normalizedProjectPath, boardTarget, buildMD5Hash, + boardRuntime, _handleOutputData: (data, logLevel) => { _mainProcessPort.postMessage({ logLevel, message: data }) }, @@ -2065,7 +2085,25 @@ class CompilerModule { return } - // Step 13: Upload program to board if necessary + // Step 13: Upload program to board or load into simulator + if (boardRuntime === 'simulator') { + // For simulator targets, send the HEX firmware path back to the renderer. + // Derive the build sub-directory from the platform FQBN (e.g. "arduino:avr:mega" → "arduino.avr.mega") + // so it stays in sync with the hals.json entry. + const fqbnSubDir = halsContent[boardTarget]['platform'].replaceAll(':', '.') + const hexPath = join(compilationPath, 'examples', 'Baremetal', 'build', fqbnSubDir, 'Baremetal.ino.hex') + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Compilation successful. Loading firmware into simulator...', + }) + _mainProcessPort.postMessage({ + simulatorFirmwarePath: hexPath, + closePort: true, + }) + _mainProcessPort.close() + return + } + if (!compileOnly) { _mainProcessPort.postMessage({ logLevel: 'info', message: 'Uploading program to board...' }) try { diff --git a/src/main/modules/compiler/compiler-types.ts b/src/main/modules/compiler/compiler-types.ts index d402eee86..2d3bdea8f 100644 --- a/src/main/modules/compiler/compiler-types.ts +++ b/src/main/modules/compiler/compiler-types.ts @@ -16,7 +16,7 @@ const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer const BoardInfoSchema = z.object({ - compiler: z.enum(['arduino-cli', 'openplc-compiler']), + compiler: z.enum(['arduino-cli', 'openplc-compiler', 'simulator']), core: z.string(), default_ain: z.string(), default_aout: z.string(), @@ -35,6 +35,7 @@ const BoardInfoSchema = z.object({ user_dout: z.string().optional(), c_flags: z.array(z.string()).optional(), cxx_flags: z.array(z.string()).optional(), + ld_flags: z.array(z.string()).optional(), arch: z.string().optional(), }) diff --git a/src/main/modules/hardware/hardware-module.ts b/src/main/modules/hardware/hardware-module.ts index c628a82f1..ad8c2695f 100644 --- a/src/main/modules/hardware/hardware-module.ts +++ b/src/main/modules/hardware/hardware-module.ts @@ -182,11 +182,9 @@ class HardwareModule { }) }) } - // TODO: Improve error handling and return type - // if (availableBoards.size === 0) { - // return { success: false, data: undefined } - // } - return availableBoards + // Sort boards alphabetically by name + const sortedBoards: AvailableBoards = new Map([...availableBoards.entries()].sort(([a], [b]) => a.localeCompare(b))) + return sortedBoards } async getBoardImagePreview(image: string) { diff --git a/src/main/modules/hardware/hardware-types.ts b/src/main/modules/hardware/hardware-types.ts index b9200af49..6d69ec4c1 100644 --- a/src/main/modules/hardware/hardware-types.ts +++ b/src/main/modules/hardware/hardware-types.ts @@ -13,6 +13,7 @@ const BoardInfoSchema = z.object({ core: z.string(), c_flags: z.array(z.string()).optional(), cxx_flags: z.array(z.string()).optional(), + ld_flags: z.array(z.string()).optional(), default_ain: z.string(), default_aout: z.string(), default_din: z.string(), diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index cbb6b0911..55154b123 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -18,6 +18,8 @@ import { MainIpcModule, MainIpcModuleConstructor } from '../../contracts/types/m import { logger } from '../../services' import { ModbusTcpClient } from '../modbus/modbus-client' import { ModbusRtuClient } from '../modbus/modbus-rtu-client' +import { SimulatorModule } from '../simulator/simulator-module' +import { VirtualSerialPort } from '../simulator/virtual-serial-port' import { WebSocketDebugClient } from '../websocket/websocket-debug-client' type IDataToWrite = { @@ -43,7 +45,7 @@ class MainProcessBridge implements MainIpcModule { private debuggerWebSocketClient: WebSocketDebugClient | null = null private debuggerTargetIp: string | null = null private debuggerReconnecting: boolean = false - private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | null = null + private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' | null = null private debuggerRtuPort: string | null = null private debuggerRtuBaudRate: number | null = null private debuggerRtuSlaveId: number | null = null @@ -54,6 +56,8 @@ class MainProcessBridge implements MainIpcModule { private currentProjectPath: string | null = null // File watchers for auto-reload functionality (using watchFile for better macOS compatibility) private fileWatchers: Map = new Map() + // avr8js ATmega2560 emulator instance for the built-in simulator + private simulatorModule = new SimulatorModule() constructor({ ipcMain, @@ -595,6 +599,11 @@ class MainProcessBridge implements MainIpcModule { this.ipcMain.handle('runtime:clear-credentials', this.handleRuntimeClearCredentials) this.ipcMain.handle('runtime:get-serial-ports', this.handleRuntimeGetSerialPorts) + // ===================== SIMULATOR ===================== + this.ipcMain.handle('simulator:load-firmware', this.handleSimulatorLoadFirmware) + this.ipcMain.handle('simulator:stop', this.handleSimulatorStop) + this.ipcMain.handle('simulator:is-running', this.handleSimulatorIsRunning) + // ===================== FILE WATCHER ===================== this.ipcMain.handle('file:watch-start', this.handleFileWatchStart) this.ipcMain.handle('file:watch-stop', this.handleFileWatchStop) @@ -605,10 +614,12 @@ class MainProcessBridge implements MainIpcModule { // ===================== HANDLER METHODS ===================== // Project-related handlers handleProjectCreate = async (_event: IpcMainInvokeEvent, data: CreateProjectFileProps) => { + this.stopSimulatorAndNotify() const response = await this.projectService.createProject(data) return response } handleProjectOpen = async () => { + this.stopSimulatorAndNotify() const response = await this.projectService.openProject() if (response.success && response.data?.meta.path) { this.currentProjectPath = response.data.meta.path @@ -648,6 +659,7 @@ class MainProcessBridge implements MainIpcModule { handleProjectSave = (_event: IpcMainInvokeEvent, { projectPath, content }: IDataToWrite) => this.projectService.saveProject({ projectPath, content }) handleProjectOpenByPath = async (_event: IpcMainInvokeEvent, projectPath: string) => { + this.stopSimulatorAndNotify() try { const response = await this.projectService.openProjectByPath(projectPath) if (response.success && response.data?.meta.path) { @@ -759,6 +771,7 @@ class MainProcessBridge implements MainIpcModule { } } handleAppQuit = () => { + this.simulatorModule.stop() if (this.mainWindow) { this.mainWindow.destroy() } @@ -823,7 +836,10 @@ class MainProcessBridge implements MainIpcModule { this.mainWindow?.maximize() } } - handleWindowReload = () => this.mainWindow?.webContents.reload() + handleWindowReload = () => { + this.simulatorModule.stop() + this.mainWindow?.webContents.reload() + } handleWindowRebuildMenu = () => void this.menuBuilder.buildMenu() // Hardware handlers @@ -862,7 +878,7 @@ class MainProcessBridge implements MainIpcModule { handleDebuggerVerifyMd5 = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket', + connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', connectionParams: { ipAddress?: string port?: string @@ -875,7 +891,25 @@ class MainProcessBridge implements MainIpcModule { let client: ModbusTcpClient | ModbusRtuClient | null = null let wsClient: WebSocketDebugClient | null = null try { - if (connectionType === 'websocket') { + if (connectionType === 'simulator') { + const virtualPort = new VirtualSerialPort(this.simulatorModule) + client = new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout: 5000, + serialPort: virtualPort, + }) + await client.connect() + const targetMd5 = await client.getMd5Hash() + const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + + // Keep the client for subsequent debug operations + this.debuggerModbusClient = client + this.debuggerConnectionType = 'simulator' + + return { success: true, match, targetMd5 } + } else if (connectionType === 'websocket') { if (!connectionParams.ipAddress || !connectionParams.jwtToken) { return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } } @@ -1054,7 +1088,16 @@ class MainProcessBridge implements MainIpcModule { this.debuggerReconnecting = true try { - if (this.debuggerConnectionType === 'tcp') { + if (this.debuggerConnectionType === 'simulator') { + const virtualPort = new VirtualSerialPort(this.simulatorModule) + this.debuggerModbusClient = new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout: 5000, + serialPort: virtualPort, + }) + } else if (this.debuggerConnectionType === 'tcp') { if (!this.debuggerTargetIp) { this.debuggerReconnecting = false return { success: false, error: 'No target IP address stored', needsReconnect: true } @@ -1113,7 +1156,7 @@ class MainProcessBridge implements MainIpcModule { handleDebuggerConnect = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket', + connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', connectionParams: { ipAddress?: string port?: string @@ -1123,7 +1166,22 @@ class MainProcessBridge implements MainIpcModule { }, ): Promise<{ success: boolean; error?: string }> => { try { - if (connectionType === 'websocket') { + if (connectionType === 'simulator') { + if (this.debuggerModbusClient) { + this.debuggerModbusClient.disconnect() + this.debuggerModbusClient = null + } + + const virtualPort = new VirtualSerialPort(this.simulatorModule) + this.debuggerModbusClient = new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout: 5000, + serialPort: virtualPort, + }) + await this.debuggerModbusClient.connect() + } else if (connectionType === 'websocket') { if (this.debuggerModbusClient) { this.debuggerModbusClient.disconnect() this.debuggerModbusClient = null @@ -1294,6 +1352,37 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== SIMULATOR HANDLERS ===================== + + /** Stops the simulator and notifies the renderer so it can update UI state. */ + private stopSimulatorAndNotify(): void { + if (this.simulatorModule.isRunning()) { + this.simulatorModule.stop() + this.mainWindow?.webContents.send('simulator:stopped') + } + } + + handleSimulatorLoadFirmware = async ( + _event: IpcMainInvokeEvent, + hexPath: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + await this.simulatorModule.loadAndRun(hexPath) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + handleSimulatorStop = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.simulatorModule.stop() + return Promise.resolve({ success: true }) + } + + handleSimulatorIsRunning = (_event: IpcMainInvokeEvent): Promise => { + return Promise.resolve(this.simulatorModule.isRunning()) + } + // Using watchFile (polling-based) instead of watch for better macOS compatibility // fs.watch can fail when editors use "safe write" (write to temp file, then rename) handleFileWatchStart = ( diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index ae94613aa..24d3d5e0b 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -203,7 +203,7 @@ const rendererProcessBridge = { Map< string, { - compiler: 'arduino-cli' | 'openplc-compiler' + compiler: 'arduino-cli' | 'openplc-compiler' | 'simulator' core: string preview: string specs: { @@ -244,7 +244,7 @@ const rendererProcessBridge = { ipcRenderer.invoke('util:read-debug-file', projectPath, boardTarget), debuggerVerifyMd5: ( - connectionType: 'tcp' | 'rtu' | 'websocket', + connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', connectionParams: { ipAddress?: string port?: string @@ -281,7 +281,7 @@ const rendererProcessBridge = { ipcRenderer.invoke('debugger:set-variable', variableIndex, force, valueBuffer), debuggerConnect: ( - connectionType: 'tcp' | 'rtu' | 'websocket', + connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', connectionParams: { ipAddress?: string port?: string @@ -360,6 +360,17 @@ const rendererProcessBridge = { return () => ipcRenderer.removeListener('runtime:token-refreshed', callback) }, + // ===================== SIMULATOR METHODS ===================== + simulatorLoadFirmware: (hexPath: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('simulator:load-firmware', hexPath), + simulatorStop: (): Promise<{ success: boolean }> => ipcRenderer.invoke('simulator:stop'), + simulatorIsRunning: (): Promise => ipcRenderer.invoke('simulator:is-running'), + onSimulatorStopped: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('simulator:stopped', listener) + return () => ipcRenderer.removeListener('simulator:stopped', listener) + }, + // ===================== FILE WATCHER METHODS ===================== fileWatchStart: (filePath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('file:watch-start', filePath), diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index 21f25c5b9..539b9690e 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -9,6 +9,8 @@ interface ModbusRtuClientOptions { baudRate: number slaveId: number timeout: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serialPort?: any // Pre-built serial port (e.g. VirtualSerialPort for simulator) } const ARDUINO_BOOTLOADER_DELAY_MS = 2500 @@ -24,6 +26,8 @@ export class ModbusRtuClient { private timeout: number // eslint-disable-next-line @typescript-eslint/no-explicit-any private serialPort: any = null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private injectedSerialPort: any = null private static readonly CRC_HI_TABLE = [ 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, @@ -64,6 +68,7 @@ export class ModbusRtuClient { this.baudRate = options.baudRate this.slaveId = options.slaveId this.timeout = options.timeout + this.injectedSerialPort = options.serialPort ?? null } private calculateCrc(buffer: Buffer): number { @@ -94,6 +99,16 @@ export class ModbusRtuClient { } async connect(): Promise { + // If a pre-built serial port was provided (e.g. VirtualSerialPort), use it directly + if (this.injectedSerialPort) { + this.serialPort = this.injectedSerialPort + return new Promise((resolve, reject) => { + this.serialPort.on('open', () => resolve()) + this.serialPort.on('error', (err: Error) => reject(err)) + this.serialPort.open() + }) + } + return new Promise((resolve, reject) => { try { this.serialPort = new SerialPort({ @@ -161,14 +176,23 @@ export class ModbusRtuClient { await this.flushInputBuffer() return new Promise((resolve, reject) => { + let responseBuffer = Buffer.alloc(0) + let frameCompleteTimeout: NodeJS.Timeout | null = null + + // Forward-declared so the timeout handler can reference them for cleanup + const cleanup = () => { + this.serialPort?.removeListener('data', onData) + this.serialPort?.removeListener('error', onError) + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } + } + const timeoutHandle = setTimeout(() => { + cleanup() reject(new Error('Request timeout')) }, this.timeout) - let responseBuffer = Buffer.alloc(0) - - let frameCompleteTimeout: NodeJS.Timeout | null = null - const onData = (data: Buffer) => { responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) @@ -177,18 +201,14 @@ export class ModbusRtuClient { } frameCompleteTimeout = setTimeout(() => { + clearTimeout(timeoutHandle) + cleanup() + if (responseBuffer.length < 5) { - clearTimeout(timeoutHandle) - this.serialPort?.removeListener('data', onData) - this.serialPort?.removeListener('error', onError) reject(new Error('Response too short')) return } - clearTimeout(timeoutHandle) - this.serialPort?.removeListener('data', onData) - this.serialPort?.removeListener('error', onError) - const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) @@ -207,11 +227,7 @@ export class ModbusRtuClient { const onError = (error: Error) => { clearTimeout(timeoutHandle) - if (frameCompleteTimeout) { - clearTimeout(frameCompleteTimeout) - } - this.serialPort?.removeListener('data', onData) - this.serialPort?.removeListener('error', onError) + cleanup() reject(error) } @@ -220,8 +236,7 @@ export class ModbusRtuClient { this.serialPort!.write(request as unknown as Uint8Array, (error: unknown) => { if (error) { clearTimeout(timeoutHandle) - this.serialPort?.removeListener('data', onData) - this.serialPort?.removeListener('error', onError) + cleanup() const errorMessage = typeof error === 'string' ? error diff --git a/src/main/modules/simulator/simulator-module.ts b/src/main/modules/simulator/simulator-module.ts new file mode 100644 index 000000000..cf8f5eb75 --- /dev/null +++ b/src/main/modules/simulator/simulator-module.ts @@ -0,0 +1,332 @@ +import { + AVRClock, + avrInstruction, + AVRTimer, + AVRUSART, + clockConfig, + CPU, + timer0Config, + timer1Config, + timer2Config, + usart0Config, +} from 'avr8js' +import { readFile } from 'fs/promises' + +// ATmega2560 specs +const CPU_FREQ_HZ = 16_000_000 +const FLASH_SIZE_BYTES = 256 * 1024 +// Expanded SRAM: fill the entire 16-bit address space (64 KB). +// The CPU constructor adds 0x100 internally for registers + standard I/O (0x00–0xFF). +// We supply 0xFF00 (65280) to cover extended I/O (0x100–0x1FF) plus usable SRAM +// (0x200–0xFFFF = 65024 bytes ≈ 63.5 KB). This is the maximum addressable with +// AVR's 16-bit data pointers. The linker flags in hals.json tell avr-gcc about +// the expanded space so it actually uses it. +const SRAM_BYTES = 0xff00 + +// SLEEP opcode – the firmware inserts `__asm volatile("sleep")` at the end +// of each loop() iteration. We detect it before execution and fast-forward +// the clock to the next timer event, avoiding millions of idle cycles. +const SLEEP_OPCODE = 0x9588 + +// Nanoseconds per CPU cycle at 16 MHz +const CYCLE_NS = 1e9 / CPU_FREQ_HZ // 62.5 ns + +// Maximum real (non-skipped) instructions per batch. SLEEP fast-forwards +// don't count against this budget, so idle periods are essentially free. +const MAX_REAL_INSTRUCTIONS = 100_000 + +// Maximum simulated time per batch (in CPU cycles). Prevents runaway +// batches when the firmware is mostly idle (SLEEP fast-forwards could +// cover seconds of sim time without hitting the instruction limit). +const MAX_SIM_CYCLES_PER_BATCH = CPU_FREQ_HZ / 10 // 100ms + +// --------------------------------------------------------------------------- +// ATmega2560 peripheral configs – register addresses are identical to the +// ATmega328p defaults exported by avr8js, only the interrupt vector addresses +// differ because ATmega2560 has more interrupt sources. +// Vector addresses are word addresses matching the datasheet. +// --------------------------------------------------------------------------- + +// ATmega2560 vector addresses (word addresses). +// IMPORTANT: ATmega2560 has TIMER1_COMPC at vector 19 (word 0x26) which +// ATmega328p lacks. This shifts Timer1 OVF and all subsequent vectors by 2 +// compared to a naive mapping from the ATmega328p table. +// Vectors verified against avr-objdump of compiled firmware. +const mega2560Timer0Config = { + ...timer0Config, + compAInterrupt: 0x2a, // vector 21 + compBInterrupt: 0x2c, // vector 22 + ovfInterrupt: 0x2e, // vector 23 +} + +const mega2560Timer1Config = { + ...timer1Config, + captureInterrupt: 0x20, // vector 16 + compAInterrupt: 0x22, // vector 17 + compBInterrupt: 0x24, // vector 18 + // Note: TIMER1_COMPC at vector 19 (0x26) not modeled by avr8js + ovfInterrupt: 0x28, // vector 20 +} + +const mega2560Timer2Config = { + ...timer2Config, + compAInterrupt: 0x1a, // vector 13 + compBInterrupt: 0x1c, // vector 14 + ovfInterrupt: 0x1e, // vector 15 +} + +const mega2560Usart0Config = { + ...usart0Config, + rxCompleteInterrupt: 0x32, // vector 25 + dataRegisterEmptyInterrupt: 0x34, // vector 26 + txCompleteInterrupt: 0x36, // vector 27 +} + +// --------------------------------------------------------------------------- +// Intel HEX parser +// --------------------------------------------------------------------------- + +/** + * Parses an Intel HEX string into a Uint16Array suitable for the AVR CPU. + * Supports record types 00 (data), 01 (EOF), 02 (extended segment address), + * and 04 (extended linear address) for flash sizes >64 KB. + */ +function parseIntelHex(hex: string, flashSizeBytes: number): Uint16Array { + const flash = new Uint8Array(flashSizeBytes) + let extendedAddress = 0 + + for (const rawLine of hex.split('\n')) { + const line = rawLine.trim() + if (!line.startsWith(':')) continue + + const byteCount = parseInt(line.substring(1, 3), 16) + const address = parseInt(line.substring(3, 7), 16) + const recordType = parseInt(line.substring(7, 9), 16) + + if (recordType === 0x00) { + // Data record + const fullAddress = extendedAddress + address + for (let i = 0; i < byteCount; i++) { + const byte = parseInt(line.substring(9 + i * 2, 11 + i * 2), 16) + if (fullAddress + i < flashSizeBytes) { + flash[fullAddress + i] = byte + } + } + } else if (recordType === 0x01) { + // End of file + break + } else if (recordType === 0x02) { + // Extended segment address (address << 4) + extendedAddress = parseInt(line.substring(9, 13), 16) << 4 + } else if (recordType === 0x04) { + // Extended linear address (upper 16 bits) + extendedAddress = parseInt(line.substring(9, 13), 16) << 16 + } + } + + // Convert byte array to 16-bit little-endian words for the AVR CPU + const words = new Uint16Array(flashSizeBytes / 2) + for (let i = 0; i < flashSizeBytes; i += 2) { + words[i / 2] = flash[i] | (flash[i + 1] << 8) + } + return words +} + +// --------------------------------------------------------------------------- +// Simulator module +// --------------------------------------------------------------------------- + +/** + * Manages the avr8js ATmega2560 emulator lifecycle in the main process. + * + * The firmware is compiled with SIMULATOR_MODE which inserts a SLEEP + * instruction at the end of each loop() iteration. When the CPU hits SLEEP, + * the execution loop fast-forwards the clock to the next timer event + * (typically Timer0 overflow at ~1 ms), avoiding millions of wasted + * busy-wait instruction cycles and allowing the simulation to run at + * near real-time speed. + */ +export class SimulatorModule { + private cpu: CPU | null = null + private running = false + private timerHandle: ReturnType | null = null + + // Peripherals (kept alive so they process register read/write hooks) + private timer0: AVRTimer | null = null + private timer1: AVRTimer | null = null + private timer2: AVRTimer | null = null + private usart0: AVRUSART | null = null + private clock: AVRClock | null = null + + // RX byte queue – avr8js USART accepts one byte at a time (returns false + // while rxBusy). Incoming bytes are queued and drained after the firmware + // reads UDR (via the read hook), ensuring the RXC ISR processes each byte + // before the next one overwrites rxByte. + private rxQueue: number[] = [] + + // Wall-clock pacing + private wallStartMs = 0 + private simStartCycles = 0 + + /** Callback fired for each byte transmitted by the emulated USART0 */ + onUartByte: ((byte: number) => void) | null = null + + /** + * Loads an Intel HEX firmware file and starts the emulated ATmega2560. + * Stops any currently running emulation first. + */ + async loadAndRun(hexPath: string): Promise { + this.stop() + + const hexData = await readFile(hexPath, 'utf-8') + const progMem = parseIntelHex(hexData, FLASH_SIZE_BYTES) + + this.cpu = new CPU(progMem, SRAM_BYTES) + + // Instantiate peripherals – they register read/write hooks on the CPU + this.timer0 = new AVRTimer(this.cpu, mega2560Timer0Config) + this.timer1 = new AVRTimer(this.cpu, mega2560Timer1Config) + this.timer2 = new AVRTimer(this.cpu, mega2560Timer2Config) + this.usart0 = new AVRUSART(this.cpu, mega2560Usart0Config, CPU_FREQ_HZ) + this.clock = new AVRClock(this.cpu, CPU_FREQ_HZ, clockConfig) + + // Wrap the UDR read hook so that after the firmware reads a received byte, + // the next queued byte is fed into the USART. This ensures the RXC ISR + // has consumed the current byte before the next one arrives, preventing + // rxByte from being silently overwritten when interrupts are disabled + // (e.g. while the CPU is inside another ISR like Timer0). + const udrAddr = mega2560Usart0Config.UDR + const originalUdrReadHook = this.cpu.readHooks[udrAddr] + this.cpu.readHooks[udrAddr] = (addr: number) => { + const result = originalUdrReadHook?.(addr) + this.drainRxQueue() + return result + } + + // Wire USART0 TX to the Modbus RTU bridge callback + this.usart0.onByteTransmit = (byte: number) => { + this.onUartByte?.(byte) + } + + // Begin execution + this.running = true + this.wallStartMs = performance.now() + this.simStartCycles = 0 + this.executeBatch() + } + + /** + * Runs a batch of CPU instructions, then reschedules. + * + * When the CPU hits a SLEEP opcode, the loop fast-forwards the clock to + * the next scheduled timer event instead of stepping through idle cycles. + * SLEEP fast-forwards don't count against the instruction budget, so idle + * periods between scan cycles are essentially free. + * + * After each batch, compares simulated time against wall time: + * - If sim is ahead: schedules next batch with setTimeout(delay) to + * let wall time catch up, keeping timers accurate. + * - If sim is behind or on time: schedules with setTimeout(0). + */ + private executeBatch = (): void => { + if (!this.running || !this.cpu) return + + this.timerHandle = null + const { cpu } = this + + // Kick-start the RX delivery chain if bytes are queued. + // feedByte() only attempts writeByte when the queue transitions from + // empty to non-empty. If that initial attempt is rejected (rxBusy was + // true because a previous byte's clock event hadn't fired yet), no + // further delivery attempts happen until drainRxQueue() is called from + // the UDR read hook — which itself requires a successful delivery. + // Retrying here at the top of each batch breaks that deadlock. + if (this.rxQueue.length > 0 && this.usart0) { + const byte = this.rxQueue[0] + if (this.usart0.writeByte(byte)) { + this.rxQueue.shift() + } + } + + const simCycleCap = cpu.cycles + MAX_SIM_CYCLES_PER_BATCH + let realCount = 0 + + while (this.running && realCount < MAX_REAL_INSTRUCTIONS && cpu.cycles < simCycleCap) { + if (cpu.progMem[cpu.pc] === SLEEP_OPCODE) { + // Execute the SLEEP instruction (advances PC, adds 1 cycle) + avrInstruction(cpu) + // Fast-forward to next scheduled clock event. + // NOTE: nextClockEvent is private in avr8js — pinned to 0.20.0 in package.json. + // If upgrading avr8js, verify this field still exists and has a `cycles` property. + const nextEvent = (cpu as unknown as { nextClockEvent: { cycles: number } | null }).nextClockEvent + if (nextEvent && nextEvent.cycles > cpu.cycles) { + cpu.cycles = nextEvent.cycles + } + cpu.tick() + } else { + avrInstruction(cpu) + cpu.tick() + realCount++ + } + } + + if (this.running) { + // Pace simulation to wall time + const simElapsedMs = ((cpu.cycles - this.simStartCycles) * CYCLE_NS) / 1e6 + const wallElapsedMs = performance.now() - this.wallStartMs + const aheadMs = simElapsedMs - wallElapsedMs + this.timerHandle = setTimeout(this.executeBatch, aheadMs > 1 ? Math.floor(aheadMs) : 0) + } + } + + /** Send a byte to the emulated USART0 RX (host → device) */ + feedByte(byte: number): void { + this.rxQueue.push(byte) + if (this.rxQueue.length === 1 && this.usart0) { + const accepted = this.usart0.writeByte(byte) + if (accepted) { + this.rxQueue.shift() + } + } + } + + /** + * Tries to deliver the next queued byte to the USART. Called after the + * firmware reads UDR (via the read hook). This pacing ensures the RXC ISR + * processes each byte before the next one arrives, avoiding data loss + * from rxByte overwrites. + */ + private drainRxQueue(): void { + if (!this.usart0 || this.rxQueue.length === 0) return + const byte = this.rxQueue[0] + const accepted = this.usart0.writeByte(byte) + if (accepted) { + this.rxQueue.shift() + } + } + + /** Stop the emulator and release resources */ + stop(): void { + this.running = false + if (this.timerHandle !== null) { + clearTimeout(this.timerHandle) + this.timerHandle = null + } + if (this.usart0) { + this.usart0.onByteTransmit = null + this.usart0 = null + } + this.rxQueue = [] + this.cpu = null + this.timer0 = null + this.timer1 = null + this.timer2 = null + this.clock = null + this.onUartByte = null + } + + /** Check if the emulator is currently running */ + isRunning(): boolean { + return this.running + } +} diff --git a/src/main/modules/simulator/virtual-serial-port.ts b/src/main/modules/simulator/virtual-serial-port.ts new file mode 100644 index 000000000..d620e1a94 --- /dev/null +++ b/src/main/modules/simulator/virtual-serial-port.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'events' + +import { SimulatorModule } from './simulator-module' + +/** + * A virtual serial port that mimics the `serialport` npm package's event-based API. + * Routes bytes through SimulatorModule's UART bridge, allowing the existing + * ModbusRtuClient to communicate with the avr8js emulator unchanged. + */ +export class VirtualSerialPort extends EventEmitter { + public isOpen = false + private simulator: SimulatorModule + + constructor(simulator: SimulatorModule) { + super() + this.simulator = simulator + } + + open(): void { + this.isOpen = true + // Wire UART RX: bytes from emulated device → ModbusRtuClient via 'data' events + this.simulator.onUartByte = (byte: number) => { + this.emit('data', Buffer.from([byte])) + } + // Emit 'open' asynchronously (matches real SerialPort behavior) + process.nextTick(() => this.emit('open')) + } + + write(data: Uint8Array | Buffer, callback?: (err?: Error | null) => void): void { + // Send each byte to the emulated UART TX (host → device) + for (const byte of data) { + this.simulator.feedByte(byte) + } + callback?.(null) + } + + flush(callback?: (err?: Error | null) => void): void { + // No hardware buffer to flush in virtual port + callback?.(null) + } + + close(): void { + this.isOpen = false + this.simulator.onUartByte = null + this.removeAllListeners() + } +} diff --git a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx index c145e41ef..7d3dd9c46 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -12,6 +12,7 @@ import { isArduinoTarget, isOpenPLCRuntimeTarget, isOpenPLCRuntimeV4Target, + isSimulatorTarget, validateRuntimeVersion, } from '@root/utils' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -89,7 +90,8 @@ const Board = memo(function () { const handleDeviceValueAtFirstRender = () => { const boardInfos = availableBoards.get(deviceBoard) if (boardInfos) { - const coreVersionAsString = `${boardInfos.coreVersion ? ` [${boardInfos.coreVersion}]` : ''}` + const showVersion = !isSimulatorTarget(boardInfos) && boardInfos.coreVersion + const coreVersionAsString = showVersion ? ` [${boardInfos.coreVersion}]` : '' const initialBoard = `${deviceBoard}${coreVersionAsString}` if (initialBoard === formattedBoardState) return setFormattedBoardState(initialBoard) @@ -298,17 +300,19 @@ const Board = memo(function () { return ( -
- - -
+ {!isSimulatorTarget(currentBoardInfo) && ( +
+ + +
+ )}
{Array.from(availableBoards.entries()).map(([board, data]) => { - const formattedBoard = `${board}${data.coreVersion ? ` [${data.coreVersion}]` : ''}` + const showVersion = !isSimulatorTarget(data) && data.coreVersion + const formattedBoard = `${board}${showVersion ? ` [${data.coreVersion}]` : ''}` return (
- {isOpenPLCRuntimeTarget(currentBoardInfo) ? ( + {isSimulatorTarget(currentBoardInfo) ? ( +
+

+ Built-in simulator — no configuration required. Press Build to compile and run. +

+
+ ) : isOpenPLCRuntimeTarget(currentBoardInfo) ? ( <>
)} - {!isOpenPLCRuntimeTarget(currentBoardInfo) && ( + {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && (
-
- {isOpenPLCRuntimeTarget(currentBoardInfo) ? ( + {!isSimulatorTarget(currentBoardInfo) && ( +
+ )} + {isSimulatorTarget(currentBoardInfo) ? null : isOpenPLCRuntimeTarget(currentBoardInfo) ? ( connectionStatus === 'connected' && timingStats && timingStats.scan_count > 0 && ( diff --git a/src/renderer/components/_features/[workspace]/editor/device/configuration/communication.tsx b/src/renderer/components/_features/[workspace]/editor/device/configuration/communication.tsx index 1f71585c6..47df727c8 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/configuration/communication.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/configuration/communication.tsx @@ -2,7 +2,7 @@ import { communicationSelectors } from '@hooks/use-store-selectors' import { Checkbox, Label } from '@root/renderer/components/_atoms' import { DeviceEditorSlot } from '@root/renderer/components/_templates/[editors]' import { useOpenPLCStore } from '@root/renderer/store' -import { cn, isOpenPLCRuntimeTarget } from '@root/utils' +import { cn, isOpenPLCRuntimeTarget, isSimulatorTarget } from '@root/utils' import { useEffect, useMemo } from 'react' import { ModbusRTUComponent } from './components/modbus-rtu' @@ -21,6 +21,7 @@ const Communication = () => { const currentBoardInfo = availableBoards.get(deviceBoard) const isRuntimeTarget = isOpenPLCRuntimeTarget(currentBoardInfo) + const isSimulator = isSimulatorTarget(currentBoardInfo) const isRTUEnabled = communicationPreferences.enabledRTU const isTCPEnabled = communicationPreferences.enabledTCP @@ -47,6 +48,16 @@ const Communication = () => { } const memoizedIsModbusTCPEnabled = useMemo(() => isTCPEnabled ?? false, [isTCPEnabled]) + if (isSimulator) { + return ( + +

+ Modbus RTU is automatically configured for the simulator. +

+
+ ) + } + return (
diff --git a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx index 558cb2af8..ba7c675d1 100644 --- a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx @@ -6,7 +6,7 @@ import type { RuntimeConnection } from '@root/renderer/store/slices/device/types import { buildDebugTree } from '@root/renderer/utils/debug-tree-builder' import type { DebugTreeNode, FbInstanceInfo } from '@root/types/debugger' import { PLCPou, PLCProjectData } from '@root/types/PLC/open-plc' -import { BufferToStringArray, cn, isOpenPLCRuntimeTarget } from '@root/utils' +import { BufferToStringArray, cn, isOpenPLCRuntimeTarget, isSimulatorTarget } from '@root/utils' import { addCppLocalVariables } from '@root/utils/cpp/addCppLocalVariables' import { generateSTCode as generateCppSTCode } from '@root/utils/cpp/generateSTCode' import { validateCppCode } from '@root/utils/cpp/validateCppCode' @@ -27,7 +27,7 @@ import { parsePlcStatus } from '@root/utils/plc-status' import { addPythonLocalVariables } from '@root/utils/python/addPythonLocalVariables' import { generateSTCode } from '@root/utils/python/generateSTCode' import { injectPythonCode } from '@root/utils/python/injectPythonCode' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { DebuggerButton, @@ -85,6 +85,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const [isCompiling, setIsCompiling] = useState(false) const [isDebuggerProcessing, setIsDebuggerProcessing] = useState(false) + const [simulatorRunning, setSimulatorRunning] = useState(false) const disabledButtonClass = 'disabled cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent' @@ -107,6 +108,18 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) + const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) + const isCurrentBoardSimulator = isSimulatorTarget(currentBoardInfo) + + // Sync simulatorRunning when the main process stops the simulator + // (e.g. on project open/create) so the UI reflects the actual state. + useEffect(() => { + const cleanup = (window.bridge.onSimulatorStopped as (cb: () => void) => () => void)(() => { + setSimulatorRunning(false) + }) + return cleanup + }, []) + const applyEarlyCommentWrapping = (projectData: PLCProjectData): PLCProjectData => { return { ...projectData, @@ -279,6 +292,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa message: string | Buffer plcStatus?: string closePort?: boolean + simulatorFirmwarePath?: string }) => { setIsCompiling(true) @@ -310,6 +324,33 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) }) } + + // Load firmware into simulator when compilation finishes with a HEX path + if (data.simulatorFirmwarePath) { + ;(window.bridge.simulatorLoadFirmware as (p: string) => Promise<{ success: boolean; error?: string }>)( + data.simulatorFirmwarePath, + ) + .then((result) => { + if (result.success) { + setSimulatorRunning(true) + addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) + } else { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Failed to start simulator: ${result.error}`, + }) + } + }) + .catch((err: unknown) => { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Simulator error: ${err instanceof Error ? err.message : String(err)}`, + }) + }) + } + if (data.closePort) { setIsCompiling(false) } @@ -374,6 +415,25 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } + const handleSimulatorControl = async (): Promise => { + try { + if (simulatorRunning) { + await (window.bridge.simulatorStop as () => Promise<{ success: boolean }>)() + setSimulatorRunning(false) + addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator stopped.' }) + } else { + // Re-build to get a fresh firmware and start the simulator + void verifyAndCompile() + } + } catch (error) { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Simulator control error: ${String(error)}`, + }) + } + } + const handleDebuggerClick = async () => { const { workspace, project, deviceDefinitions, workspaceActions, consoleActions, deviceActions } = useOpenPLCStore.getState() @@ -405,13 +465,34 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const isRuntimeV4 = boardTarget === 'OpenPLC Runtime v4' let targetIpAddress: string | undefined - let connectionType: 'tcp' | 'rtu' | 'websocket' = 'tcp' + let connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' = 'tcp' let rtuPort: string | undefined let rtuBaudRate: number | undefined let rtuSlaveId: number | undefined let jwtToken: string | undefined - if (isRuntimeTarget) { + if (isSimulatorTarget(currentBoardInfo)) { + // Check if simulator has firmware loaded + const running = await (window.bridge.simulatorIsRunning as () => Promise)() + if (!running) { + const response = await showDebuggerMessage( + 'warning', + 'Simulator Empty', + 'No firmware is running on the simulator. Would you like to build and upload the project first?', + ['Build & Upload', 'Cancel'], + ) + if (response === 0) { + // Trigger full build, then restart debugger flow + setIsDebuggerProcessing(false) + void verifyAndCompile() + return + } else { + setIsDebuggerProcessing(false) + return + } + } + connectionType = 'simulator' + } else if (isRuntimeTarget) { const connectionStatus = useOpenPLCStore.getState().runtimeConnection.connectionStatus const runtimeIpAddress = deviceDefinitions.configuration.runtimeIpAddress @@ -725,7 +806,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handleMd5Verification = async ( projectPath: string, boardTarget: string, - connectionType: 'tcp' | 'rtu' | 'websocket', + connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', connectionParams: { ipAddress?: string port?: string @@ -828,7 +909,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) const targetDisplay = - connectionType === 'tcp' || connectionType === 'websocket' ? targetIpAddress : connectionParams.port + connectionType === 'simulator' + ? 'simulator' + : connectionType === 'tcp' || connectionType === 'websocket' + ? targetIpAddress + : connectionParams.port consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', @@ -1175,19 +1260,31 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa void handlePlcControl()} - disabled={connectionStatus !== 'connected'} - className={cn(connectionStatus !== 'connected' ? disabledButtonClass : '')} + onClick={isCurrentBoardSimulator ? () => void handleSimulatorControl() : () => void handlePlcControl()} + disabled={isCurrentBoardSimulator ? isCompiling : connectionStatus !== 'connected'} + className={cn( + isCurrentBoardSimulator + ? isCompiling + ? disabledButtonClass + : '' + : connectionStatus !== 'connected' + ? disabledButtonClass + : '', + )} > - {plcStatus === 'RUNNING' ? : null} + {(isCurrentBoardSimulator ? simulatorRunning : plcStatus === 'RUNNING') ? : null} diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index d603cc694..2ad35c224 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -5,7 +5,7 @@ import * as Tabs from '@radix-ui/react-tabs' import { useRuntimePolling } from '@root/renderer/hooks/use-runtime-polling' import { DebugTreeNode } from '@root/types/debugger' // Note: Logs polling is now handled by useRuntimePolling hook -import { cn, isOpenPLCRuntimeTarget } from '@root/utils' +import { cn, isOpenPLCRuntimeTarget, isSimulatorTarget } from '@root/utils' import { appendToDebugPath, buildDebugPath, @@ -213,6 +213,7 @@ const WorkspaceScreen = () => { const boardTarget = deviceDefinitions.configuration.deviceBoard const currentBoardInfo = availableBoards.get(boardTarget) const isRuntimeTarget = isOpenPLCRuntimeTarget(currentBoardInfo) + const isSimulator = isSimulatorTarget(currentBoardInfo) const isRTU = deviceDefinitions.configuration.communicationConfiguration.communicationPreferences.enabledRTU const isTCP = deviceDefinitions.configuration.communicationConfiguration.communicationPreferences.enabledTCP @@ -230,7 +231,7 @@ const WorkspaceScreen = () => { console.warn('No runtime IP address configured') return } - } else { + } else if (!isSimulator) { if (isTCP && !debuggerTargetIp) { console.warn('No debugger target IP address configured') return @@ -243,7 +244,7 @@ const WorkspaceScreen = () => { } let batchSize = 60 - if (isRTU && !isTCP) { + if ((isRTU && !isTCP) || isSimulator) { batchSize = 20 } diff --git a/src/renderer/store/slices/device/data/constants.ts b/src/renderer/store/slices/device/data/constants.ts index cae9d96db..a6419986f 100644 --- a/src/renderer/store/slices/device/data/constants.ts +++ b/src/renderer/store/slices/device/data/constants.ts @@ -2,7 +2,7 @@ import { DeviceConfiguration } from '@root/types/PLC/devices' // Default configuration for deviceDefinitions.configuration export const defaultDeviceConfiguration: DeviceConfiguration = { - deviceBoard: 'OpenPLC Runtime v3', + deviceBoard: 'OpenPLC Simulator', communicationPort: '', runtimeIpAddress: '', compileOnly: false, diff --git a/src/renderer/store/slices/device/types.ts b/src/renderer/store/slices/device/types.ts index 7cddcec70..9dae9bd9b 100644 --- a/src/renderer/store/slices/device/types.ts +++ b/src/renderer/store/slices/device/types.ts @@ -63,7 +63,7 @@ const runtimeConnectionSchema = z.object({ type RuntimeConnection = z.infer const availableBoardInfo = z.object({ - compiler: z.enum(['arduino-cli', 'openplc-compiler']), + compiler: z.enum(['arduino-cli', 'openplc-compiler', 'simulator']), core: z.string(), preview: z.string(), specs: z.object({ diff --git a/src/utils/device.ts b/src/utils/device.ts index 014508e5e..be8753aab 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -27,6 +27,20 @@ export function isOpenPLCRuntimeTarget(boardInfo: AvailableBoardInfo | undefined return boardInfo.compiler === 'openplc-compiler' } +/** + * Determines if a board is the built-in simulator target. + * The simulator uses an emulated ATmega2560 and requires no physical hardware. + * + * @param boardInfo - The board information from availableBoards map + * @returns true if the board is the simulator target, false otherwise + */ +export function isSimulatorTarget(boardInfo: AvailableBoardInfo | undefined): boolean { + if (!boardInfo) { + return false + } + return boardInfo.compiler === 'simulator' +} + /** * Extracts the expected runtime version from the board target name. * This is used to validate that the connected runtime matches the selected target.