diff --git a/src/main/modules/modbus/modbus-client.ts b/src/main/modules/modbus/modbus-client.ts index 27f68b43c..a742d411b 100644 --- a/src/main/modules/modbus/modbus-client.ts +++ b/src/main/modules/modbus/modbus-client.ts @@ -26,6 +26,7 @@ export class ModbusTcpClient { private timeout: number private socket: Socket | null = null private transactionId: number = 0 + private sendRequestMutex: Promise = Promise.resolve() constructor(options: ModbusTcpClientOptions) { this.host = options.host @@ -66,6 +67,48 @@ export class ModbusTcpClient { } } + private sendTcpRequestImpl(request: Buffer): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error('Not connected to target')) + return + } + + const timeoutHandle = setTimeout(() => { + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(new Error('Request timeout')) + }, this.timeout) + + const onData = (data: Buffer) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + resolve(data) + } + + const onError = (error: Error) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(error) + } + + this.socket.once('data', onData) + this.socket.once('error', onError) + this.socket.write(request as unknown as Uint8Array) + }) + } + + private sendTcpRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendTcpRequestImpl(request).then(resolve, reject), + () => this.sendTcpRequestImpl(request).then(resolve, reject), + ) + }) + } + async getMd5Hash(): Promise { if (!this.socket) { throw new Error('Not connected to target') @@ -87,59 +130,30 @@ export class ModbusTcpClient { request.writeUInt8(0, 10) request.writeUInt8(0, 11) - return new Promise((resolve, reject) => { - const timeoutHandle = setTimeout(() => { - reject(new Error('Request timeout')) - }, this.timeout) + const data = await this.sendTcpRequest(request) - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + if (data.length < 9) { + throw new Error('Invalid response: too short') + } - try { - if (data.length < 9) { - reject(new Error('Invalid response: too short')) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - reject(new Error('Transaction ID mismatch')) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { - reject(new Error('Function code mismatch')) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - reject(new Error(`Target returned error code: 0x${statusCode.toString(16)}`)) - return - } - - const md5String = data.slice(9).toString('utf-8').trim() - resolve(md5String) - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))) - } - } + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - reject(error) - } + if (responseTransactionId !== transactionId) { + throw new Error('Transaction ID mismatch') + } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + } + + const md5String = data.slice(9).toString('utf-8').trim() + return md5String } async getVariablesList(variableIndexes: number[]): Promise<{ @@ -173,95 +187,66 @@ export class ModbusTcpClient { request.writeUInt16BE(variableIndexes[i], 10 + i * 2) } - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, this.timeout) + try { + const data = await this.sendTcpRequest(request) - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } - try { - if (data.length < 9) { - resolve({ success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` }) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - resolve({ success: false, error: 'Transaction ID mismatch' }) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - if (data.length < 17) { - resolve({ - success: false, - error: `Incomplete success response (${data.length} bytes, expected at least 17)`, - }) - return - } - - const lastIndex = data.readUInt16BE(9) - const tick = data.readUInt32BE(11) - const responseSize = data.readUInt16BE(15) - - if (data.length < 17 + responseSize) { - resolve({ - success: false, - error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, - }) - return - } - - const variableData = data.slice(17, 17 + responseSize) - - resolve({ - success: true, - tick, - lastIndex, - data: variableData, - }) - } catch (error) { - resolve({ success: false, error: String(error) }) + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) + + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + if (data.length < 17) { + return { + success: false, + error: `Incomplete success response (${data.length} bytes, expected at least 17)`, } } - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - resolve({ success: false, error: error.message }) + const lastIndex = data.readUInt16BE(9) + const tick = data.readUInt32BE(11) + const responseSize = data.readUInt16BE(15) + + if (data.length < 17 + responseSize) { + return { + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, + } } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + const variableData = data.slice(17, 17 + responseSize) + + return { + success: true, + tick, + lastIndex, + data: variableData, + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } async setVariable( @@ -272,10 +257,7 @@ export class ModbusTcpClient { success: boolean error?: string }> { - console.log('[ModbusTcpClient] setVariable called with:', { variableIndex, force, valueBuffer }) - if (!this.socket) { - console.log('[ModbusTcpClient] Socket not connected') return { success: false, error: 'Not connected to target' } } @@ -305,101 +287,40 @@ export class ModbusTcpClient { request.writeUInt8(0, 13) } - console.log('[ModbusTcpClient] Sending request:', { - transactionId, - protocolId, - pduLength, - unitId, - functionCode: `0x${functionCode.toString(16)}`, - variableIndex, - forceFlag: force ? 1 : 0, - dataLength, - valueBuffer: valueBuffer?.toString('hex'), - requestHex: request.toString('hex'), - }) + try { + const data = await this.sendTcpRequest(request) - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, this.timeout) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) - console.log('[ModbusTcpClient] Received response:', { - length: data.length, - hex: data.toString('hex'), - }) - - try { - if (data.length < 9) { - console.log('[ModbusTcpClient] Response too short') - resolve({ success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` }) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - console.log('[ModbusTcpClient] Response parsed:', { - responseTransactionId, - expectedTransactionId: transactionId, - responseFunctionCode: `0x${responseFunctionCode.toString(16)}`, - expectedFunctionCode: `0x${ModbusFunctionCode.DEBUG_SET.toString(16)}`, - statusCode: `0x${statusCode.toString(16)}`, - }) - - if (responseTransactionId !== transactionId) { - console.log('[ModbusTcpClient] Transaction ID mismatch') - resolve({ success: false, error: 'Transaction ID mismatch' }) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { - console.log('[ModbusTcpClient] Function code mismatch') - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - console.log('[ModbusTcpClient] ERROR_OUT_OF_BOUNDS') - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - console.log('[ModbusTcpClient] ERROR_OUT_OF_MEMORY') - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - console.log('[ModbusTcpClient] Unknown error code') - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - console.log('[ModbusTcpClient] Success!') - resolve({ success: true }) - } catch (error) { - console.error('[ModbusTcpClient] Error parsing response:', error) - resolve({ success: false, error: String(error) }) - } + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } } - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - resolve({ success: false, error: error.message }) + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { success: false, error: 'Function code mismatch' } } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } } diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index b7770b34e..21f25c5b9 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -14,7 +14,8 @@ interface ModbusRtuClientOptions { const ARDUINO_BOOTLOADER_DELAY_MS = 2500 const MD5_REQUEST_MAX_RETRIES = 3 const MD5_REQUEST_RETRY_DELAY_MS = 500 -const FRAME_COMPLETE_TIMEOUT_MS = 50 + +const FRAME_COMPLETE_TIMEOUT_MS = 10 export class ModbusRtuClient { private port: string @@ -141,7 +142,18 @@ export class ModbusRtuClient { }) } + private sendRequestMutex: Promise = Promise.resolve() + private async sendRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request).then(resolve, reject), + ) + }) + } + + private async sendRequestImpl(request: Buffer): Promise { if (!this.serialPort || !this.serialPort.isOpen) { throw new Error('Serial port is not open') } @@ -181,10 +193,7 @@ export class ModbusRtuClient { const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) if (receivedCrc !== calculatedCrc) { - // OpenPLC debugger ignores CRC errors - //reject(new Error('CRC validation failed')) - //return - console.warn('Warning: CRC validation failed, but continuing anyway') + // OpenPLC debugger ignores CRC errors — mismatch is non-fatal } const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index b3b7295b3..d603cc694 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -50,7 +50,7 @@ import { StandardFunctionBlocks } from '../data/library/standard-function-blocks import { useOpenPLCStore } from '../store' import { getVariableSize, parseVariableValue } from '../utils/variable-sizes' -const DEBUGGER_POLL_INTERVAL_MS = 200 +const DEBUGGER_POLL_INTERVAL_MS = 50 const WorkspaceScreen = () => { const { @@ -171,6 +171,7 @@ const WorkspaceScreen = () => { const pollingIntervalRef = useRef(null) const isMountedRef = useRef(true) const graphListRef = useRef([]) + const batchOffsetRef = useRef(0) useEffect(() => { isMountedRef.current = true @@ -1377,63 +1378,69 @@ const WorkspaceScreen = () => { return } + // Single-batch-per-cycle: use batchOffsetRef to track position across poll cycles + // Values from previous batches persist because newValues starts as a copy of the current store const { workspace: currentWorkspace } = useOpenPLCStore.getState() const newValues = new Map() currentWorkspace.debugVariableValues.forEach((value: string, key: string) => { newValues.set(key, value) }) + let currentBatchSize = batchSize - let processedCount = 0 - while (processedCount < allIndexes.length) { - const batch = allIndexes.slice(processedCount, processedCount + currentBatchSize) + // Clamp batchOffset to valid range (handles list size changes between cycles) + let batchOffset = batchOffsetRef.current + if (batchOffset >= allIndexes.length) { + batchOffset = 0 + } + + // Slice one batch from the current offset + let batch = allIndexes.slice(batchOffset, batchOffset + currentBatchSize) + + // First request + let result = await window.bridge.debuggerGetVariablesList(batch) - const result = await window.bridge.debuggerGetVariablesList(batch) + // Handle ERROR_OUT_OF_MEMORY with retry (halve batch size, retry same offset) + while (!result.success && result.error === 'ERROR_OUT_OF_MEMORY' && currentBatchSize > 2) { + currentBatchSize = Math.max(2, Math.floor(currentBatchSize / 2)) + batch = allIndexes.slice(batchOffset, batchOffset + currentBatchSize) + result = await window.bridge.debuggerGetVariablesList(batch) + } + + if (!result.success) { + if (result.needsReconnect) { + const { consoleActions, workspaceActions: wsReconnect } = useOpenPLCStore.getState() + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Debugger connection lost: ${result.error || 'Unknown error'}. Attempting to reconnect...`, + }) - if (!result.success) { - if (result.needsReconnect) { - const { consoleActions, workspaceActions } = useOpenPLCStore.getState() + if (result.error?.includes('Failed to reconnect')) { + wsReconnect.setDebuggerVisible(false) + wsReconnect.setDebugForcedVariables(new Map()) consoleActions.addLog({ id: crypto.randomUUID(), level: 'error', - message: `Debugger connection lost: ${result.error || 'Unknown error'}. Attempting to reconnect...`, + message: 'Debugger session closed due to connection failure.', }) - - if (result.error?.includes('Failed to reconnect')) { - workspaceActions.setDebuggerVisible(false) - workspaceActions.setDebugForcedVariables(new Map()) - consoleActions.addLog({ - id: crypto.randomUUID(), - level: 'error', - message: 'Debugger session closed due to connection failure.', - }) - return - } + return } - - if (result.error === 'ERROR_OUT_OF_MEMORY' && currentBatchSize > 2) { - currentBatchSize = Math.max(2, Math.floor(currentBatchSize / 2)) - continue - } else { - break - } - } - - if (!result.data || result.lastIndex === undefined) { - break } + return + } - if (!Array.isArray(result.data)) { - break - } + let itemsProcessed = 0 + if (result.data && result.lastIndex !== undefined && Array.isArray(result.data)) { const responseBuffer = new Uint8Array(result.data) let bufferOffset = 0 - let itemsProcessed = 0 for (const index of batch) { const varInfos = variableInfoMapRef.current?.get(index) - if (!varInfos || varInfos.length === 0) continue + if (!varInfos || varInfos.length === 0) { + continue + } // Use the first entry for parsing (all entries share the same debug index and type) const { variable } = varInfos[0] @@ -1465,8 +1472,11 @@ const WorkspaceScreen = () => { break } } + } - processedCount += itemsProcessed + // Advance offset for next poll cycle (wraps around) + if (itemsProcessed > 0) { + batchOffsetRef.current = (batchOffset + itemsProcessed) % allIndexes.length } if (isMountedRef.current) { @@ -1482,9 +1492,19 @@ const WorkspaceScreen = () => { } } - void pollVariables() + let isPolling = false + // Fire first poll immediately + isPolling = true + void pollVariables().finally(() => { + isPolling = false + }) + // Schedule fixed-rate polling; skip tick if previous poll is still in progress pollingIntervalRef.current = setInterval(() => { - void pollVariables() + if (!isMountedRef.current || isPolling) return + isPolling = true + void pollVariables().finally(() => { + isPolling = false + }) }, DEBUGGER_POLL_INTERVAL_MS) return () => {