diff --git a/configs/webpack/webpack.config.renderer.dev.ts b/configs/webpack/webpack.config.renderer.dev.ts index 28649a5d3..a6db53806 100644 --- a/configs/webpack/webpack.config.renderer.dev.ts +++ b/configs/webpack/webpack.config.renderer.dev.ts @@ -206,6 +206,7 @@ const configuration: ICustomConfiguration = { configType: 'flat', extensions: ['ts', 'tsx'], eslintPath: 'eslint/use-at-your-own-risk', + cache: false, }), ], diff --git a/package-lock.json b/package-lock.json index 7e3d431cb..92b8005a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.3.4", "i18next": "^23.5.1", "immer": "^10.1.1", "lodash": "^4.17.21", @@ -15736,6 +15737,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -23252,6 +23288,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -26731,6 +26782,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/package.json b/package.json index 51b1c7adf..1bdd3077f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "setup:binaries": "ts-node scripts/download-binaries.ts", "package": "ts-node scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", - "prestart": "ts-node scripts/download-binaries.ts && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.dev.ts", + "prestart": "rimraf configs/dll/tsconfig.tsbuildinfo && ts-node scripts/download-binaries.ts && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.dev.ts", "start:dev": "ts-node scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", "start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./configs/webpack/webpack.config.main.dev.ts\" \"electronmon .\"", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.preload.dev.ts", @@ -64,6 +64,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.3.4", "i18next": "^23.5.1", "immer": "^10.1.1", "lodash": "^4.17.21", diff --git a/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts index e0a0a374d..f35b0cd15 100644 --- a/src/main/modules/compiler/compiler-module.ts +++ b/src/main/modules/compiler/compiler-module.ts @@ -14,6 +14,7 @@ import type { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' import { XmlGenerator } from '@root/utils' import { type CppPouData as CppPouDataCode, generateCBlocksCode } from '@root/utils/cpp/generateCBlocksCode' import { type CppPouData as CppPouDataHeader, generateCBlocksHeader } from '@root/utils/cpp/generateCBlocksHeader' +import { generateEthercatConfig } from '@root/utils/ethercat/generate-ethercat-config' import { generateModbusMasterConfig } from '@root/utils/modbus/generate-modbus-master-config' import { generateModbusSlaveConfig } from '@root/utils/modbus/generate-modbus-slave-config' import { generateOpcUaConfig, OpcUaConfigError } from '@root/utils/opcua' @@ -1311,6 +1312,24 @@ class CompilerModule { } } + async handleGenerateEthercatConfig( + sourceTargetFolderPath: string, + projectData: ProjectState['data'], + handleOutputData: HandleOutputDataCallback, + ): Promise { + const ethercatConfig = generateEthercatConfig(projectData.remoteDevices) + + if (ethercatConfig) { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + const configFilePath = join(confFolderPath, 'ethercat.json') + await writeFile(configFilePath, ethercatConfig, 'utf-8') + handleOutputData('Generated conf/ethercat.json', 'info') + } else { + handleOutputData('No EtherCAT devices configured, skipping ethercat.json generation', 'info') + } + } + async embedCBlocksInProgramSt( sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, @@ -1745,6 +1764,11 @@ class CompilerModule { _mainProcessPort.postMessage({ logLevel, message: data }) }) + // Generate EtherCAT config for Runtime v4 + await this.handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compressing source files for OpenPLC Runtime v4...', diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index ae73dde85..8241d328d 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,4 +1,18 @@ +import { ESIService } from '@root/main/services/esi-service' +import { parseESIDeviceFull } from '@root/main/services/esi-service/esi-parser-main' import { getProjectPath } from '@root/main/utils' +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' +import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' @@ -41,6 +55,7 @@ class MainProcessBridge implements MainIpcModule { pouService compilerModule hardwareModule + private esiService = new ESIService() private debuggerModbusClient: ModbusTcpClient | ModbusRtuClient | null = null private debuggerWebSocketClient: WebSocketDebugClient | null = null private debuggerTargetIp: string | null = null @@ -270,6 +285,18 @@ class MainProcessBridge implements MainIpcModule { ) } + /** + * Wrap a service call with standardized error handling. + * Returns { success: false, error } on any thrown exception. + */ + private async wrapServiceCall(fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + return { success: false, error: String(error) } + } + } + makeRuntimeApiRequest( ipAddress: string, jwtToken: string, @@ -369,6 +396,78 @@ class MainProcessBridge implements MainIpcModule { }) } + /** + * Make an authenticated POST request to the runtime API with automatic token refresh on 401/403. + */ + makeRuntimeApiPostRequest( + ipAddress: string, + jwtToken: string, + endpoint: string, + body: string, + responseParser: (data: string) => T, + timeoutMs?: number, + ): Promise<{ success: true; data: T } | { success: false; error: string }> { + const doRequest = (token: string): Promise<{ success: true; data: T } | { success: false; error: string }> => { + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + resolve({ success: true, data: responseParser(data) }) + } catch { + resolve({ success: false, error: 'Invalid response format' }) + } + } else { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } + }) + }, + ) + req.setTimeout(timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + req.write(body) + req.end() + }) + } + + return doRequest(jwtToken).then((result) => { + if (!result.success && this.isTokenExpiredError(undefined, result.error)) { + return this.attemptTokenRefresh().then((refreshResult) => { + if (refreshResult.success && refreshResult.accessToken) { + if (this.mainWindow && this.mainWindow.webContents) { + this.mainWindow.webContents.send('runtime:token-refreshed', refreshResult.accessToken) + } + return doRequest(refreshResult.accessToken) + } + return { success: false as const, error: `Token refresh failed: ${refreshResult.error || 'Unknown error'}` } + }) + } + return result + }) + } + handleRuntimeGetStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, @@ -519,6 +618,271 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== ETHERCAT DISCOVERY HANDLERS ===================== + + /** + * Get list of network interfaces available for EtherCAT communication + */ + handleEtherCATGetInterfaces = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => { + try { + const result = await this.makeRuntimeApiRequest<{ interfaces: NetworkInterface[] }>( + ipAddress, + jwtToken, + '/api/discovery/interfaces', + (data: string) => { + const response = JSON.parse(data) as { status: string; interfaces: NetworkInterface[] } + return { interfaces: response.interfaces || [] } + }, + ) + if (result.success && result.data) { + return { success: true, data: result.data.interfaces } + } else { + return { success: false, error: result.success ? 'No data returned' : result.error } + } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Check if EtherCAT discovery service is available on the runtime + */ + handleEtherCATGetStatus = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => { + try { + const result = await this.makeRuntimeApiRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/status', + (data: string) => { + const response = JSON.parse(data) as EtherCATServiceStatusResponse + return response + }, + ) + if (result.success && result.data) { + return { success: true, data: result.data } + } else { + return { success: false, error: result.success ? 'No data returned' : result.error } + } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Scan for EtherCAT devices on a network interface + */ + handleEtherCATScan = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'scan', + params: { interface: scanRequest.interface, timeout_ms: scanRequest.timeout_ms }, + }) + const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return { + status: (pluginResponse.status as string) ?? 'success', + devices: (pluginResponse.devices as EtherCATScanResponse['devices']) ?? [], + message: (pluginResponse.message as string) ?? '', + scan_time_ms: 0, + interface: scanRequest.interface, + } as EtherCATScanResponse + }, + scanTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Test connection to a specific EtherCAT slave + */ + handleEtherCATTest = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { + try { + const postData = JSON.stringify(testRequest) + const testTimeout = (testRequest.timeout_ms || 3000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/test', + postData, + (data: string) => JSON.parse(data) as EtherCATTestResponse, + testTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Validate an EtherCAT configuration + */ + handleEtherCATValidate = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => { + try { + const postData = JSON.stringify(validateRequest) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/validate', + postData, + (data: string) => JSON.parse(data) as EtherCATValidateResponse, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Get EtherCAT runtime status (state machine state, slave states, metrics) + */ + handleEtherCATGetRuntimeStatus = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'status', + }) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return pluginResponse as unknown as EtherCATRuntimeStatusResponse + }, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + // ===================== ESI REPOSITORY HANDLERS ===================== + + handleESILoadRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(async () => { + const index = await this.esiService.loadRepositoryIndex(projectPath) + return { success: true as const, data: index } + }) + + handleESISaveRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string, items: ESIRepositoryItem[]) => + this.wrapServiceCall(() => this.esiService.saveRepositoryIndex(projectPath, items)) + + handleESISaveXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string, xmlContent: string) => + this.wrapServiceCall(() => this.esiService.saveXmlFile(projectPath, itemId, xmlContent)) + + handleESILoadXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.loadXmlFile(projectPath, itemId)) + + handleESIDeleteXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.deleteRepositoryItemV2(projectPath, itemId)) + + handleESISaveRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ) => this.wrapServiceCall(() => this.esiService.saveRepositoryItem(projectPath, item, xmlContent, existingItems)) + + handleESIDeleteRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ) => this.wrapServiceCall(() => this.esiService.deleteRepositoryItem(projectPath, itemId, existingItems)) + + // ===================== ESI OPTIMIZED HANDLERS ===================== + + handleESIParseAndSaveFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + filename: string, + content: string, + ) => this.wrapServiceCall(() => this.esiService.parseAndSaveFile(projectPath, filename, content)) + + handleESIClearRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.clearRepository(projectPath)) + + handleESILoadDeviceFull = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + deviceIndex: number, + ) => + this.wrapServiceCall(async () => { + const xmlResult = await this.esiService.loadXmlFile(projectPath, itemId) + if (!xmlResult.success || !xmlResult.content) { + return { success: false as const, error: xmlResult.error || 'XML file not found' } + } + return parseESIDeviceFull(xmlResult.content, deviceIndex) + }) + + handleESILoadRepositoryLight = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.loadRepositoryLight(projectPath)) + + handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + // ===================== IPC HANDLER REGISTRATION ===================== setupMainIpcListener() { // Project-related handlers @@ -599,6 +963,30 @@ class MainProcessBridge implements MainIpcModule { this.ipcMain.handle('runtime:clear-credentials', this.handleRuntimeClearCredentials) this.ipcMain.handle('runtime:get-serial-ports', this.handleRuntimeGetSerialPorts) + // ===================== ETHERCAT DISCOVERY ===================== + this.ipcMain.handle('ethercat:get-interfaces', this.handleEtherCATGetInterfaces) + this.ipcMain.handle('ethercat:get-status', this.handleEtherCATGetStatus) + this.ipcMain.handle('ethercat:scan', this.handleEtherCATScan) + this.ipcMain.handle('ethercat:test', this.handleEtherCATTest) + this.ipcMain.handle('ethercat:validate', this.handleEtherCATValidate) + this.ipcMain.handle('ethercat:get-runtime-status', this.handleEtherCATGetRuntimeStatus) + + // ===================== ESI REPOSITORY ===================== + this.ipcMain.handle('esi:load-repository-index', this.handleESILoadRepositoryIndex) + this.ipcMain.handle('esi:save-repository-index', this.handleESISaveRepositoryIndex) + this.ipcMain.handle('esi:save-xml-file', this.handleESISaveXmlFile) + this.ipcMain.handle('esi:load-xml-file', this.handleESILoadXmlFile) + this.ipcMain.handle('esi:delete-xml-file', this.handleESIDeleteXmlFile) + this.ipcMain.handle('esi:save-repository-item', this.handleESISaveRepositoryItem) + this.ipcMain.handle('esi:delete-repository-item', this.handleESIDeleteRepositoryItem) + + // ===================== ESI OPTIMIZED (v2) ===================== + this.ipcMain.handle('esi:parse-and-save-file', this.handleESIParseAndSaveFile) + this.ipcMain.handle('esi:clear-repository', this.handleESIClearRepository) + this.ipcMain.handle('esi:load-device-full', this.handleESILoadDeviceFull) + this.ipcMain.handle('esi:load-repository-light', this.handleESILoadRepositoryLight) + this.ipcMain.handle('esi:migrate-repository', this.handleESIMigrateRepository) + // ===================== SIMULATOR ===================== this.ipcMain.handle('simulator:load-firmware', this.handleSimulatorLoadFirmware) this.ipcMain.handle('simulator:stop', this.handleSimulatorStop) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 24d3d5e0b..5c02c5f46 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' +import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' @@ -360,6 +372,187 @@ const rendererProcessBridge = { return () => ipcRenderer.removeListener('runtime:token-refreshed', callback) }, + // ===================== ETHERCAT DISCOVERY METHODS ===================== + /** + * Get list of network interfaces available for EtherCAT communication + */ + etherCATGetInterfaces: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => + ipcRenderer.invoke('ethercat:get-interfaces', ipAddress, jwtToken), + + /** + * Check if EtherCAT discovery service is available on the runtime + */ + etherCATGetStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-status', ipAddress, jwtToken), + + /** + * Scan for EtherCAT devices on a network interface + */ + etherCATScan: ( + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => + ipcRenderer.invoke('ethercat:scan', ipAddress, jwtToken, scanRequest), + + /** + * Test connection to a specific EtherCAT slave + */ + etherCATTest: ( + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => + ipcRenderer.invoke('ethercat:test', ipAddress, jwtToken, testRequest), + + /** + * Validate an EtherCAT configuration + */ + etherCATValidate: ( + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => + ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + + /** + * Get EtherCAT runtime status (state machine state, slave states, metrics) + */ + etherCATGetRuntimeStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress, jwtToken), + + // ===================== ESI REPOSITORY METHODS ===================== + + /** + * Load ESI repository index from project + */ + esiLoadRepositoryIndex: ( + projectPath: string, + ): Promise<{ + success: boolean + data?: { + version: number + items: Array<{ + id: string + filename: string + vendorId: string + vendorName: string + deviceCount: number + loadedAt: number + warnings?: string[] + }> + } | null + error?: string + }> => ipcRenderer.invoke('esi:load-repository-index', projectPath), + + /** + * Save ESI repository index to project + */ + esiSaveRepositoryIndex: ( + projectPath: string, + items: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-repository-index', projectPath, items), + + /** + * Save an ESI XML file to project + */ + esiSaveXmlFile: ( + projectPath: string, + itemId: string, + xmlContent: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-xml-file', projectPath, itemId, xmlContent), + + /** + * Load an ESI XML file from project + */ + esiLoadXmlFile: ( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> => + ipcRenderer.invoke('esi:load-xml-file', projectPath, itemId), + + /** + * Delete an ESI XML file from project + */ + esiDeleteXmlFile: (projectPath: string, itemId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:delete-xml-file', projectPath, itemId), + + /** + * Save a complete ESI repository item (XML + update index) + */ + esiSaveRepositoryItem: ( + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-repository-item', projectPath, item, xmlContent, existingItems), + + /** + * Delete an ESI repository item (XML + update index) + */ + esiDeleteRepositoryItem: ( + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:delete-repository-item', projectPath, itemId, existingItems), + + // ===================== ESI OPTIMIZED (v2) METHODS ===================== + + /** + * Parse and save a single ESI file in the main process + */ + esiParseAndSaveFile: ( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> => + ipcRenderer.invoke('esi:parse-and-save-file', projectPath, filename, content), + + /** + * Clear the entire ESI repository (bulk delete all files + reset index) + */ + esiClearRepository: (projectPath: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:clear-repository', projectPath), + + /** + * Load a full ESI device on-demand (with PDOs, SM, FMMU) + */ + esiLoadDeviceFull: ( + projectPath: string, + itemId: string, + deviceIndex: number, + ): Promise<{ success: boolean; device?: ESIDevice; error?: string }> => + ipcRenderer.invoke('esi:load-device-full', projectPath, itemId, deviceIndex), + + /** + * Load repository as lightweight items (instant from v2 cache) + */ + esiLoadRepositoryLight: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> => + ipcRenderer.invoke('esi:load-repository-light', projectPath), + + /** + * Migrate v1 repository to v2 with device summaries + */ + esiMigrateRepository: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> => + ipcRenderer.invoke('esi:migrate-repository', projectPath), + // ===================== SIMULATOR METHODS ===================== simulatorLoadFirmware: (hexPath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('simulator:load-firmware', hexPath), diff --git a/src/main/services/esi-service/esi-parser-main.ts b/src/main/services/esi-service/esi-parser-main.ts new file mode 100644 index 000000000..e8c741afd --- /dev/null +++ b/src/main/services/esi-service/esi-parser-main.ts @@ -0,0 +1,659 @@ +/** + * ESI XML Parser for Main Process + * + * Uses fast-xml-parser for high-performance parsing in the Node.js main process. + * Provides two parsing levels: + * - parseESILight: Extracts lightweight device summaries (fast, for repository listing) + * - parseESIDeviceFull: Extracts complete device data on-demand (for configuration) + */ + +import type { + ESICoEObject, + ESICoESubItem, + ESIDevice, + ESIDeviceSummary, + ESIDeviceType, + ESIFMMU, + ESIGroup, + ESIPdo, + ESIPdoEntry, + ESISyncManager, + ESIVendor, +} from '@root/types/ethercat/esi-types' +import { XMLParser } from 'fast-xml-parser' + +// ===================== SHARED HELPERS ===================== + +/** + * Parse hex string to normalized 0x format + */ +function parseHexValue(value: string | number | undefined | null): string { + if (value === undefined || value === null) return '0x0' + const str = String(value) + const cleaned = str.replace(/#x/gi, '0x') + return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` +} + +/** + * Ensure a value is always an array (fast-xml-parser returns single items as objects) + */ +function ensureArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return [] + return Array.isArray(value) ? value : [value] +} + +/** + * Get text value from a parsed element that may be string, number, object with #text, + * or array of localized objects (e.g. [{#text: "Name EN", @_LcId: "1033"}, {#text: "Name DE", @_LcId: "1031"}]). + * For arrays, prefers LcId 1033 (English) then falls back to the first element. + */ +function getTextValue(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value.trim() + if (typeof value === 'number') return String(value) + if (Array.isArray(value)) { + if (value.length === 0) return '' + // Prefer English (LcId 1033), fall back to first element + const english = (value as Record[]).find( + (v) => typeof v === 'object' && v !== null && '@_LcId' in v && String(v['@_LcId'] as string) === '1033', + ) + return getTextValue(english ?? value[0]) + } + if (typeof value === 'object' && value !== null && '#text' in value) { + return getTextValue((value as { '#text': unknown })['#text']) + } + return typeof value === 'object' ? JSON.stringify(value) : String(value as string) +} + +/** + * Create a configured XMLParser instance + */ +function createParser(): XMLParser { + return new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: false, + trimValues: true, + isArray: (tagName: string) => { + // Tags that can appear multiple times and should always be arrays + const arrayTags = ['Device', 'Group', 'RxPdo', 'TxPdo', 'Entry', 'Fmmu', 'Sm', 'Object', 'SubItem', 'DataType'] + return arrayTags.includes(tagName) + }, + }) +} + +// ===================== LIGHT PARSING ===================== + +interface ESILightResult { + success: boolean + vendor?: ESIVendor + devices?: ESIDeviceSummary[] + warnings?: string[] + error?: string +} + +/** + * Parse ESI XML extracting only lightweight device summaries. + * Counts PDO entries and sums bit lengths without building full entry objects. + */ +export function parseESILight(xmlString: string, filename?: string): ESILightResult { + const warnings: string[] = [] + + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + // Parse Vendor + const vendorObj = root['Vendor'] as Record | undefined + if (!vendorObj) { + return { success: false, error: 'Invalid ESI file: Missing Vendor information' } + } + + const vendor: ESIVendor = { + id: parseHexValue(vendorObj['Id'] as string | number | undefined), + name: getTextValue(vendorObj['Name']) || 'Unknown Vendor', + } + + // Parse Groups + const descriptions = root['Descriptions'] as Record | undefined + const groupsMap = new Map() + + if (descriptions) { + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groups = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const group of groups) { + const groupType = getTextValue(group['Type']) + const groupName = getTextValue(group['Name']) + if (groupType) { + groupsMap.set(groupType, groupName) + } + } + } + } + + // Parse Devices (lightweight) + const devices: ESIDeviceSummary[] = [] + + if (descriptions) { + const devicesObj = descriptions['Devices'] as Record | undefined + if (devicesObj) { + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + for (const deviceEl of deviceElements) { + const summary = parseDeviceSummary(deviceEl, groupsMap) + devices.push(summary) + } + } + } + + if (devices.length === 0) { + warnings.push(`No devices found in ESI file${filename ? ` (${filename})` : ''}`) + } + + return { + success: true, + vendor, + devices, + warnings: warnings.length > 0 ? warnings : undefined, + } + } catch (error) { + return { + success: false, + error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** + * Extract a lightweight device summary from a parsed device element + */ +function parseDeviceSummary(deviceEl: Record, groupsMap: Map): ESIDeviceSummary { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const groupName = groupsMap.get(groupType) || undefined + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Count PDO entries and compute bytes + const txPdos = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + const rxPdos = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + + const { channelCount: inputChannelCount, totalBits: inputBits } = countPdoEntries(txPdos) + const { channelCount: outputChannelCount, totalBits: outputBits } = countPdoEntries(rxPdos) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName, + physics, + inputChannelCount, + outputChannelCount, + totalInputBytes: Math.ceil(inputBits / 8), + totalOutputBytes: Math.ceil(outputBits / 8), + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Count non-padding entries and total bits in PDO list (without building full entry objects) + */ +function countPdoEntries(pdos: Record[]): { + channelCount: number + totalBits: number +} { + let channelCount = 0 + let totalBits = 0 + + for (const pdo of pdos) { + const entries = ensureArray(pdo['Entry'] as Record | Record[]) + for (const entry of entries) { + const bitLen = parseInt(getTextValue(entry['BitLen']) || '0', 10) || 0 + totalBits += bitLen + + // Count non-padding entries + const index = entry['Index'] + if (index !== undefined && index !== null) { + const indexStr = getTextValue(index) + if (indexStr && indexStr !== '0' && indexStr !== '#x0000' && indexStr !== '0x0000') { + channelCount++ + } + } + } + } + + return { channelCount, totalBits } +} + +// ===================== FULL DEVICE PARSING ===================== + +interface ESIDeviceFullResult { + success: boolean + device?: ESIDevice + error?: string +} + +/** + * Parse a single device from ESI XML at the given index with full detail. + * Used on-demand when complete PDO/SM/FMMU data is needed. + */ +export function parseESIDeviceFull(xmlString: string, deviceIndex: number): ESIDeviceFullResult { + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + const descriptions = root['Descriptions'] as Record | undefined + if (!descriptions) { + return { success: false, error: 'No Descriptions element found' } + } + + // Parse groups for name lookup + const groups: ESIGroup[] = [] + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groupElements = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const g of groupElements) { + groups.push({ + type: getTextValue(g['Type']), + name: getTextValue(g['Name']), + imageUrl: getTextValue(g['ImageData16x14']) || undefined, + description: getTextValue(g['Comment']) || undefined, + }) + } + } + + const devicesObj = descriptions['Devices'] as Record | undefined + if (!devicesObj) { + return { success: false, error: 'No Devices element found' } + } + + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + if (deviceIndex < 0 || deviceIndex >= deviceElements.length) { + return { success: false, error: `Device index ${deviceIndex} out of range (0-${deviceElements.length - 1})` } + } + + const deviceEl = deviceElements[deviceIndex] + const device = parseFullDevice(deviceEl, groups) + + return { success: true, device } + } catch (error) { + return { + success: false, + error: `Failed to parse device: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ===================== COE DICTIONARY PARSING ===================== + +/** + * Parsed DataType info from the Dictionary's DataTypes section. + */ +interface ParsedDataTypeInfo { + name: string + bitSize: number + subItems: { + subIdx: number + name: string + type: string + bitSize: number + bitOffset: number + access: 'RO' | 'RW' | 'WO' + defaultValue?: string + pdoMapping?: boolean + }[] +} + +/** + * Parse access string from ESI XML to normalized access type. + */ +function parseAccessRights(accessStr: string | undefined): 'RO' | 'RW' | 'WO' { + if (!accessStr) return 'RO' + const lower = accessStr.toLowerCase().trim() + if (lower === 'rw' || lower === 'readwrite' || lower === 'read/write') return 'RW' + if (lower === 'wo' || lower === 'writeonly' || lower === 'write') return 'WO' + return 'RO' +} + +/** + * Parse the CoE Object Dictionary from a device element. + * Navigates Device > Profile > Dictionary and extracts DataTypes and Objects. + */ +function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | undefined { + // Navigate to Profile > Dictionary + const profile = deviceEl['Profile'] as Record | undefined + if (!profile) return undefined + + const dictionary = profile['Dictionary'] as Record | undefined + if (!dictionary) return undefined + + // Step 1: Build DataType map + const dataTypeMap = new Map() + const dataTypesEl = dictionary['DataTypes'] as Record | undefined + if (dataTypesEl) { + const dtElements = ensureArray(dataTypesEl['DataType'] as Record | Record[]) + for (const dt of dtElements) { + const dtName = getTextValue(dt['Name']) + if (!dtName) continue + + const dtBitSize = parseInt(getTextValue(dt['BitSize']) || '0', 10) || 0 + const subItems: ParsedDataTypeInfo['subItems'] = [] + + const subItemElements = ensureArray(dt['SubItem'] as Record | Record[]) + for (const si of subItemElements) { + const siSubIdx = parseInt(getTextValue(si['SubIdx']) || '0', 10) + const siName = getTextValue(si['Name']) + const siType = getTextValue(si['Type']) + const siBitSize = parseInt(getTextValue(si['BitSize']) || '0', 10) || 0 + const siBitOffset = parseInt(getTextValue(si['BitOffs']) || '0', 10) || 0 + + // Parse flags + let siAccess: 'RO' | 'RW' | 'WO' = 'RO' + let siPdoMapping: boolean | undefined + const siFlags = si['Flags'] as Record | undefined + if (siFlags) { + siAccess = parseAccessRights(getTextValue(siFlags['Access'])) + const pdoMappingStr = getTextValue(siFlags['PdoMapping']) + if (pdoMappingStr) siPdoMapping = pdoMappingStr.toLowerCase() !== 'false' && pdoMappingStr !== '0' + } + + const siDefaultValue = getTextValue(si['DefaultValue']) || undefined + + subItems.push({ + subIdx: siSubIdx, + name: siName, + type: siType, + bitSize: siBitSize, + bitOffset: siBitOffset, + access: siAccess, + defaultValue: siDefaultValue, + pdoMapping: siPdoMapping, + }) + } + + dataTypeMap.set(dtName, { name: dtName, bitSize: dtBitSize, subItems }) + } + } + + // Step 2: Parse Objects + const objectsEl = dictionary['Objects'] as Record | undefined + if (!objectsEl) return undefined + + const objectElements = ensureArray(objectsEl['Object'] as Record | Record[]) + if (objectElements.length === 0) return undefined + + const coeObjects: ESICoEObject[] = [] + + for (const objEl of objectElements) { + const indexStr = getTextValue(objEl['Index']) + if (!indexStr) continue + + const index = parseHexValue(indexStr) + const name = getTextValue(objEl['Name']) || 'Unnamed' + const typeName = getTextValue(objEl['Type']) + const bitSize = parseInt(getTextValue(objEl['BitSize']) || '0', 10) || 0 + + // Parse object-level flags + let access: 'RO' | 'RW' | 'WO' = 'RO' + let pdoMapping = false + let category: 'M' | 'O' | 'C' | undefined + let pdoMappingDirection: 'R' | 'T' | 'RT' | undefined + + const flags = objEl['Flags'] as Record | undefined + if (flags) { + access = parseAccessRights(getTextValue(flags['Access'])) + const pdoMappingStr = getTextValue(flags['PdoMapping']) + if (pdoMappingStr) { + const lower = pdoMappingStr.toLowerCase() + if (lower === 'r') { + pdoMapping = true + pdoMappingDirection = 'R' + } else if (lower === 't') { + pdoMapping = true + pdoMappingDirection = 'T' + } else if (lower === 'rt' || lower === 'tr') { + pdoMapping = true + pdoMappingDirection = 'RT' + } else if (lower !== 'false' && lower !== '0' && lower !== '') { + pdoMapping = true + } + } + const categoryStr = getTextValue(flags['Category']) + if (categoryStr === 'M' || categoryStr === 'O' || categoryStr === 'C') { + category = categoryStr + } + } + + // Parse default value from object-level Info + let defaultValue = getTextValue(objEl['DefaultValue']) || undefined + + // Resolve DataType to build sub-items for complex objects + const dtInfo = typeName ? dataTypeMap.get(typeName) : undefined + let subItems: ESICoESubItem[] | undefined + + if (dtInfo && dtInfo.subItems.length > 0) { + // Build override map from object-level Info > SubItem + const overrideMap = new Map() + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + const infoSubItems = ensureArray(infoEl['SubItem'] as Record | Record[]) + for (const isi of infoSubItems) { + const isiName = getTextValue(isi['Name']) + const isiInfo = isi['Info'] as Record | undefined + const isiDefaultValue = isiInfo ? getTextValue(isiInfo['DefaultValue']) || undefined : undefined + if (isiName) { + overrideMap.set(isiName, { defaultValue: isiDefaultValue }) + } + } + } + + // Merge DataType sub-items with object-level overrides + subItems = dtInfo.subItems.map((si): ESICoESubItem => { + const override = overrideMap.get(si.name) + return { + subIndex: String(si.subIdx), + name: si.name, + type: si.type, + bitSize: si.bitSize, + access: si.access, + pdoMapping: si.pdoMapping, + defaultValue: override?.defaultValue ?? si.defaultValue, + } + }) + } + + // If no sub-items were built and there's an Info > DefaultValue at object level + if (!subItems && !defaultValue) { + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + defaultValue = getTextValue(infoEl['DefaultValue']) || undefined + } + } + + coeObjects.push({ + index, + name, + type: typeName, + bitSize, + access, + pdoMapping, + category, + pdoMappingDirection, + defaultValue, + subItems, + }) + } + + return coeObjects.length > 0 ? coeObjects : undefined +} + +/** + * Parse a complete ESIDevice from a parsed device element + */ +function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): ESIDevice { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const group = groups.find((g) => g.type === groupType) + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Parse FMMUs + const fmmu: ESIFMMU[] = [] + const fmmuElements = ensureArray( + deviceEl['Fmmu'] as (Record | string) | (Record | string)[], + ) + const validFmmuTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] + for (const f of fmmuElements) { + const fmmuText = typeof f === 'string' ? f : getTextValue(f['#text'] ?? f) + const fmmuType = validFmmuTypes.includes(fmmuText as ESIFMMU['type']) ? (fmmuText as ESIFMMU['type']) : 'Outputs' + fmmu.push({ type: fmmuType }) + } + + // Parse Sync Managers + const syncManagers: ESISyncManager[] = [] + const smElements = ensureArray(deviceEl['Sm'] as Record | Record[]) + for (let i = 0; i < smElements.length; i++) { + const sm = smElements[i] + const smTypeMap: Record = { + MbxOut: 'MbxOut', + MbxIn: 'MbxIn', + Outputs: 'Outputs', + Inputs: 'Inputs', + } + const smText = getTextValue(sm['#text'] ?? sm) + syncManagers.push({ + index: i, + startAddress: parseHexValue(sm['@_StartAddress'] as string | undefined), + controlByte: parseHexValue(sm['@_ControlByte'] as string | undefined), + defaultSize: parseInt(getTextValue(sm['@_DefaultSize']) || '0', 10) || 0, + enable: getTextValue(sm['@_Enable']) !== '0', + type: smTypeMap[smText] || 'Outputs', + }) + } + + // Parse RxPDOs + const rxPdo: ESIPdo[] = [] + const rxPdoElements = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + for (const pdoEl of rxPdoElements) { + rxPdo.push(parseFullPdo(pdoEl)) + } + + // Parse TxPDOs + const txPdo: ESIPdo[] = [] + const txPdoElements = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + for (const pdoEl of txPdoElements) { + txPdo.push(parseFullPdo(pdoEl)) + } + + // Parse CoE Object Dictionary + const coeObjects = parseCoEDictionary(deviceEl) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName: group?.name, + physics, + fmmu, + syncManagers, + rxPdo, + txPdo, + coeObjects, + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Parse a full PDO with all entries + */ +function parseFullPdo(pdoEl: Record): ESIPdo { + const entries: ESIPdoEntry[] = [] + const entryElements = ensureArray(pdoEl['Entry'] as Record | Record[]) + + for (const entryEl of entryElements) { + const entry = parseFullPdoEntry(entryEl) + if (entry) { + entries.push(entry) + } + } + + return { + index: parseHexValue(pdoEl['Index'] as string | undefined), + name: getTextValue(pdoEl['Name']) || 'Unnamed PDO', + fixed: getTextValue(pdoEl['@_Fixed']).toLowerCase() === 'true', + mandatory: getTextValue(pdoEl['@_Mandatory']).toLowerCase() === 'true', + smIndex: pdoEl['@_Sm'] !== undefined ? parseInt(getTextValue(pdoEl['@_Sm']) || '0', 10) : undefined, + entries, + } +} + +/** + * Parse a single PDO entry + */ +function parseFullPdoEntry(entryEl: Record): ESIPdoEntry | null { + const indexValue = entryEl['Index'] + const bitLen = parseInt(getTextValue(entryEl['BitLen']) || '0', 10) || 0 + + const indexStr = getTextValue(indexValue) + + // Padding entry (no index but has bit length) + if (!indexStr && bitLen > 0) { + return { + index: '0x0000', + subIndex: '0x00', + bitLen, + name: 'Padding', + dataType: 'BIT', + } + } + + if (!indexStr) return null + + return { + index: parseHexValue(indexStr), + subIndex: parseHexValue(getTextValue(entryEl['SubIndex'])), + bitLen, + name: getTextValue(entryEl['Name']) || 'Unnamed', + dataType: getTextValue(entryEl['DataType']) || 'BYTE', + comment: getTextValue(entryEl['Comment']) || undefined, + } +} diff --git a/src/main/services/esi-service/index.ts b/src/main/services/esi-service/index.ts new file mode 100644 index 000000000..19122097e --- /dev/null +++ b/src/main/services/esi-service/index.ts @@ -0,0 +1,550 @@ +import { fileOrDirectoryExists } from '@root/main/utils' +import type { ESIDeviceSummary, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { promises } from 'fs' +import { basename, dirname, join } from 'path' +import { v4 as uuidv4 } from 'uuid' + +import { parseESILight } from './esi-parser-main' + +/** + * ESI Repository Index stored in devices/esi/repository.json + * Version 2 includes device summaries inline for instant loading. + */ +interface ESIRepositoryIndex { + version: number + items: Array<{ + id: string + filename: string + vendorId: string + vendorName: string + deviceCount: number + loadedAt: number + warnings?: string[] + devices?: ESIDeviceSummary[] + }> +} + +/** + * Response type for ESI service operations + */ +interface ESIServiceResponse { + success: boolean + error?: string +} + +/** + * ESI Service - Handles persistence of ESI XML files in the project + * + * ESI files are stored in the project's devices/esi/ directory. + * Each XML file is saved with its UUID as filename. + * A repository.json index file tracks all loaded ESI files. + */ +class ESIService { + private readonly ESI_DIR = 'devices/esi' + private readonly REPOSITORY_FILE = 'repository.json' + private indexLock: Promise = Promise.resolve() + + /** + * Serialize access to the repository index to prevent race conditions. + */ + private withIndexLock(fn: () => Promise): Promise { + const current = this.indexLock + let resolve: () => void + this.indexLock = new Promise((r) => (resolve = r)) + return current.then(fn).finally(() => resolve!()) + } + + /** + * Get the ESI directory path for a project + */ + private getEsiDir(projectPath: string): string { + const basePath = basename(projectPath) === 'project.json' ? dirname(projectPath) : projectPath + return join(basePath, this.ESI_DIR) + } + + /** + * Get the repository index file path + */ + private getRepositoryPath(projectPath: string): string { + return join(this.getEsiDir(projectPath), this.REPOSITORY_FILE) + } + + private static readonly UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + /** + * Get the path for an ESI XML file (validates itemId is a UUID to prevent path traversal) + */ + private getXmlPath(projectPath: string, itemId: string): string { + if (!ESIService.UUID_REGEX.test(itemId)) { + throw new Error(`Invalid item ID: ${itemId}`) + } + return join(this.getEsiDir(projectPath), `${itemId}.xml`) + } + + /** + * Ensure the ESI directory exists + */ + private async ensureEsiDir(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) { + await promises.mkdir(esiDir, { recursive: true }) + } + } + + /** + * Load the ESI repository index from disk + */ + async loadRepositoryIndex(projectPath: string): Promise { + const repoPath = this.getRepositoryPath(projectPath) + + try { + const content = await promises.readFile(repoPath, 'utf-8') + const index = JSON.parse(content) as ESIRepositoryIndex + return index + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + // Re-throw for corrupted/unreadable files so callers don't silently overwrite + throw error + } + } + + /** + * Save the ESI repository index to disk (v1 format for backward compat) + */ + async saveRepositoryIndex(projectPath: string, items: ESIRepositoryItem[]): Promise { + try { + await this.ensureEsiDir(projectPath) + + const index: ESIRepositoryIndex = { + version: 1, + items: items.map((item) => ({ + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + })), + } + + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify(index, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI repository index:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save repository index', + } + } + } + + /** + * Save the ESI repository index to disk in v2 format with device summaries + */ + async saveRepositoryIndexV2(projectPath: string, items: ESIRepositoryItemLight[]): Promise { + try { + await this.ensureEsiDir(projectPath) + + const index: ESIRepositoryIndex = { + version: 2, + items: items.map((item) => ({ + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + devices: item.devices, + })), + } + + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify(index, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI repository index v2:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save repository index', + } + } + } + + /** + * Save an ESI XML file to disk + */ + async saveXmlFile(projectPath: string, itemId: string, xmlContent: string): Promise { + try { + await this.ensureEsiDir(projectPath) + + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.writeFile(xmlPath, xmlContent, 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save XML file', + } + } + } + + /** + * Load an ESI XML file from disk + */ + async loadXmlFile( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + const content = await promises.readFile(xmlPath, 'utf-8') + return { success: true, content } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: 'XML file not found' } + } + console.error('Error loading ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load XML file', + } + } + } + + /** + * Delete an ESI XML file from disk + */ + async deleteXmlFile(projectPath: string, itemId: string): Promise { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.unlink(xmlPath) + return { success: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: true } // already deleted + } + console.error('Error deleting ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete XML file', + } + } + } + + /** + * Save a complete ESI repository item (XML + update index) + */ + async saveRepositoryItem( + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + _existingItems: ESIRepositoryItem[], + ): Promise { + return this.withIndexLock(async () => { + // Save the XML file + const xmlResult = await this.saveXmlFile(projectPath, item.id, xmlContent) + if (!xmlResult.success) { + return xmlResult + } + + // Read current index from disk instead of trusting caller snapshot + const currentIndex = await this.loadRepositoryIndex(projectPath) + const currentItems = currentIndex?.items ?? [] + const updatedItems = [ + ...currentItems.filter((i) => i.id !== item.id), + { + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + }, + ] + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile( + repoPath, + JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), + 'utf-8', + ) + return { success: true } + }) + } + + /** + * Delete a repository item (XML + update index) + */ + async deleteRepositoryItem( + projectPath: string, + itemId: string, + _existingItems: ESIRepositoryItem[], + ): Promise { + return this.withIndexLock(async () => { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Read current index from disk instead of trusting caller snapshot + const currentIndex = await this.loadRepositoryIndex(projectPath) + const currentItems = currentIndex?.items ?? [] + const updatedItems = currentItems.filter((i) => i.id !== itemId) + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile( + repoPath, + JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), + 'utf-8', + ) + return { success: true } + }) + } + + /** + * Delete a repository item and update the v2 index (no re-parsing) + */ + async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { + return this.withIndexLock(async () => { + try { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Update the v2 index without the deleted item + const currentItems = await this.loadLightItemsFromIndex(projectPath) + const updatedItems = currentItems.filter((i) => i.id !== itemId) + return this.saveRepositoryIndexV2(projectPath, updatedItems) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete repository item', + } + } + }) + } + + /** + * Load light items from the v2 repository index + */ + private async loadLightItemsFromIndex(projectPath: string): Promise { + const index = await this.loadRepositoryIndex(projectPath) + if (!index || index.version !== 2) return [] + return index.items + .filter((i) => i.devices) + .map((i) => ({ + id: i.id, + filename: i.filename, + vendor: { id: i.vendorId, name: i.vendorName }, + devices: i.devices || [], + loadedAt: i.loadedAt, + warnings: i.warnings, + })) + } + + /** + * Load repository as lightweight items (v2 instant, v1 needs migration) + */ + async loadRepositoryLight( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> { + try { + const index = await this.loadRepositoryIndex(projectPath) + + if (!index) { + return { success: true, items: [] } + } + + // V2 index has device summaries inline + if (index.version === 2 && index.items.length > 0 && index.items[0].devices) { + const items = await this.loadLightItemsFromIndex(projectPath) + return { success: true, items } + } + + // V1 index needs migration + if (index.items.length > 0) { + return { success: true, needsMigration: true } + } + + return { success: true, items: [] } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load repository', + } + } + } + + /** + * Migrate a v1 repository to v2 by re-parsing all XML files with parseESILight + */ + async migrateRepositoryToV2( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> { + return this.withIndexLock(async () => { + try { + const index = await this.loadRepositoryIndex(projectPath) + if (!index) { + return { success: true, items: [] } + } + + const items: ESIRepositoryItemLight[] = [] + + for (const indexItem of index.items) { + try { + const xmlResult = await this.loadXmlFile(projectPath, indexItem.id) + if (xmlResult.success && xmlResult.content) { + const parseResult = parseESILight(xmlResult.content, indexItem.filename) + if (parseResult.success && parseResult.vendor && parseResult.devices) { + items.push({ + id: indexItem.id, + filename: indexItem.filename, + vendor: parseResult.vendor, + devices: parseResult.devices, + loadedAt: indexItem.loadedAt, + warnings: parseResult.warnings || indexItem.warnings, + }) + } + } + } catch (err) { + console.error(`Failed to migrate ESI file ${indexItem.filename}:`, err) + } + } + + // Save as v2 + const saveResult = await this.saveRepositoryIndexV2(projectPath, items) + if (!saveResult.success) { + return { success: false, error: saveResult.error || 'Failed to save migrated index' } + } + + return { success: true, items } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate repository', + } + } + }) + } + + /** + * Parse and save a single ESI file. Returns the saved item on success. + * Called once per file from the renderer's sequential upload loop. + */ + async parseAndSaveFile( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> { + // Parse outside the lock (CPU-bound, no index access) + const parseResult = parseESILight(content, filename) + if (!parseResult.success || !parseResult.vendor || !parseResult.devices) { + return { success: false, error: parseResult.error || 'Parse failed' } + } + + return this.withIndexLock(async () => { + try { + // Check for duplicate + const existingIndex = await this.loadRepositoryIndex(projectPath) + const existingFilenames = new Set(existingIndex?.items.map((i) => i.filename) ?? []) + if (existingFilenames.has(filename)) { + return { success: true } // skip duplicate silently + } + + // Save XML to disk + const itemId = uuidv4() + const saveResult = await this.saveXmlFile(projectPath, itemId, content) + if (!saveResult.success) { + return { success: false, error: saveResult.error ?? 'Failed to save XML file' } + } + + const item: ESIRepositoryItemLight = { + id: itemId, + filename, + vendor: parseResult.vendor!, + devices: parseResult.devices!, + loadedAt: Date.now(), + warnings: parseResult.warnings, + } + + // Append to v2 index + const currentItems = await this.loadLightItemsFromIndex(projectPath) + await this.saveRepositoryIndexV2(projectPath, [...currentItems, item]) + + return { success: true, item } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + }) + } + + /** + * Clear the entire ESI repository: delete all XML files and reset the index. + */ + async clearRepository(projectPath: string): Promise { + return this.withIndexLock(async () => { + try { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) return { success: true } + + // Delete only XML files, not the index + const entries = await promises.readdir(esiDir) + const xmlFiles = entries.filter((entry) => entry.endsWith('.xml')) + await Promise.all(xmlFiles.map((entry) => promises.unlink(join(esiDir, entry)))) + + // Write empty v2 index + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify({ version: 2, items: [] }, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear repository', + } + } + }) + } + + /** + * Check if ESI directory exists for a project + */ + hasEsiDirectory(projectPath: string): boolean { + return fileOrDirectoryExists(this.getEsiDir(projectPath)) + } + + /** + * Get list of XML files in the ESI directory + */ + async listXmlFiles(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + + if (!fileOrDirectoryExists(esiDir)) { + return [] + } + + try { + const entries = await promises.readdir(esiDir) + return entries.filter((entry) => entry.endsWith('.xml')) + } catch { + return [] + } + } +} + +export { ESIService } +export type { ESIRepositoryIndex, ESIServiceResponse } diff --git a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx index 6a0a7b749..2cde2ee7e 100644 --- a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx @@ -56,7 +56,7 @@ const ServerProtocolSources = [ const RemoteDeviceProtocolSources = [ { value: 'modbus-tcp', label: 'Modbus', disabled: false }, { value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true }, - { value: 'ethercat', label: 'EtherCAT', disabled: true }, + { value: 'ethercat', label: 'EtherCAT', disabled: false }, { value: 'profinet', label: 'PROFINET', disabled: true }, ] as const diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx new file mode 100644 index 000000000..e9971bd4a --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -0,0 +1,232 @@ +import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +type ChannelMappingTableProps = { + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onAliasChange: (channelId: string, newAlias: string) => void +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * Alias cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const AliasCell = React.memo( + ({ + channelId, + alias, + onAliasChange, + }: { + channelId: string + alias: string + onAliasChange: (channelId: string, newAlias: string) => void + }) => { + const [localAlias, setLocalAlias] = useState(alias) + + useEffect(() => { + setLocalAlias(alias) + }, [alias]) + + const handleBlur = useCallback(() => { + if (localAlias !== alias) { + onAliasChange(channelId, localAlias) + } + }, [channelId, localAlias, alias, onAliasChange]) + + return ( + setLocalAlias(e.target.value)} + onBlur={handleBlur} + placeholder='Alias' + className='h-[24px] w-full rounded border border-neutral-300 bg-white px-1.5 font-mono text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + /> + ) + }, +) + +/** + * Channel Mapping Table Component + * + * Displays channels with auto-generated IEC 61131-3 located variable addresses (read-only) + * and editable alias column. + */ +const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappingTableProps) => { + const [filterDirection, setFilterDirection] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + // Build a lookup map from channelId to mapping + const mappingMap = useMemo(() => { + const map = new Map() + for (const m of mappings) { + map.set(m.channelId, m) + } + return map + }, [mappings]) + + // Build a 1-based per-direction index for each channel (stable regardless of filtering) + const channelIndexMap = useMemo(() => { + const map = new Map() + let inputIdx = 0 + let outputIdx = 0 + for (const ch of channels) { + if (ch.direction === 'input') { + inputIdx++ + map.set(ch.id, inputIdx) + } else { + outputIdx++ + map.set(ch.id, outputIdx) + } + } + return map + }, [channels]) + + // Filter channels based on direction and search + const filteredChannels = useMemo(() => { + return channels.filter((channel) => { + if (filterDirection !== 'all' && channel.direction !== filterDirection) { + return false + } + + if (searchTerm) { + const search = searchTerm.toLowerCase() + const mapping = mappingMap.get(channel.id) + return ( + channel.iecType.toLowerCase().includes(search) || + (mapping?.iecLocation.toLowerCase().includes(search) ?? false) || + (mapping?.alias?.toLowerCase().includes(search) ?? false) + ) + } + + return true + }) + }, [channels, filterDirection, searchTerm, mappingMap]) + + const inputCount = channels.filter((c) => c.direction === 'input').length + const outputCount = channels.filter((c) => c.direction === 'output').length + + return ( +
+ {/* Filters */} +
+ {/* Direction Filter */} +
+ + + +
+ + {/* Search */} + setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredChannels.length === 0 ? ( + + + + ) : ( + filteredChannels.map((channel) => { + const mapping = mappingMap.get(channel.id) + return ( + + + + + + + + ) + }) + )} + +
+ # + + Dir + + IEC Type + + Address + Alias
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ {channelIndexMap.get(channel.id) ?? ''} + + + {channel.direction === 'input' ? 'IN' : 'OUT'} + + + {channel.iecType} + + {mapping?.iecLocation ?? ''} + + +
+
+
+ ) +} + +export { ChannelMappingTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx new file mode 100644 index 000000000..35756e8a5 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -0,0 +1,242 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import { useDeviceConfiguration } from '@root/renderer/hooks/use-device-configuration' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useMemo } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type ConfiguredDeviceRowProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + isExpanded: boolean + onToggleExpand: () => void + isSelected: boolean + onSelect: () => void + onUpdateDevice: (config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +const ConfiguredDeviceRow = ({ + device, + repository, + isExpanded, + onToggleExpand, + isSelected, + onSelect, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDeviceRowProps) => { + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const ioSummary = esiDevice ? `${esiDevice.inputChannelCount} / ${esiDevice.outputChannelCount}` : '-' + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + enabled: isExpanded, + }) + + return ( + <> + {/* Main row */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect() + } + }} + className={cn( + 'cursor-pointer border-b border-neutral-200 dark:border-neutral-800', + isSelected && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + + {device.name} + {esiDevice?.name || 'Unknown'} + + {device.position !== undefined ? device.position : '-'} + + {ioSummary} + + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + + + + + {/* Expanded details */} + {isExpanded && ( + <> + {/* Device Info Section */} + + + +
+
+ Device Info +
+
+
+ Vendor:{' '} + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID:{' '} + {device.vendorId} +
+
+ Product Code:{' '} + {device.productCode} +
+
+ Revision:{' '} + {device.revisionNo} +
+
+ ESI File:{' '} + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group:{' '} + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels:{' '} + {esiDevice.inputChannelCount} +
+
+ Output Channels:{' '} + {esiDevice.outputChannelCount} +
+ + )} +
+
+ + + + {/* Configuration Section */} + + + +
+
+ Configuration +
+ +
+ + + + {/* Startup Parameters (SDO) Section */} + + + +
+
+ Startup Parameters (SDO) +
+ +
+ + + + {/* Channel Mappings Section */} + + + +
+
+ Channel Mappings +
+ +
+ + + + )} + + ) +} + +export { ConfiguredDeviceRow } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx new file mode 100644 index 000000000..b4a0d09de --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -0,0 +1,159 @@ +import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' +import TableActions from '@root/renderer/components/_atoms/table-actions' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { useCallback, useEffect, useState } from 'react' + +import { ConfiguredDeviceRow } from './configured-device-row' + +type ConfiguredDevicesProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onAddDevice: () => void + onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +/** + * Configured Devices Component + * + * Displays the list of configured EtherCAT devices with add/remove functionality. + */ +const ConfiguredDevices = ({ + devices, + repository, + onAddDevice, + onRemoveDevice, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDevicesProps) => { + const [expandedDevices, setExpandedDevices] = useState>(new Set()) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + // Clear stale selection when device list changes externally + useEffect(() => { + if (selectedDeviceId && !devices.some((d) => d.id === selectedDeviceId)) { + setSelectedDeviceId(null) + } + }, [devices, selectedDeviceId]) + + const handleToggleExpand = useCallback((deviceId: string) => { + setExpandedDevices((prev) => { + const next = new Set(prev) + if (next.has(deviceId)) { + next.delete(deviceId) + } else { + next.add(deviceId) + } + return next + }) + }, []) + + const handleRemoveSelected = useCallback(() => { + if (selectedDeviceId) { + onRemoveDevice(selectedDeviceId) + setSelectedDeviceId(null) + } + }, [selectedDeviceId, onRemoveDevice]) + + return ( +
+ {/* Header with actions */} +
+

+ Configured Devices {devices.length > 0 && `(${devices.length})`} +

+ , + id: 'add-device-button', + }, + { + ariaLabel: 'Remove Device', + onClick: handleRemoveSelected, + disabled: !selectedDeviceId, + icon: , + id: 'remove-device-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
+ + {/* Devices table */} +
+ + + + + + + + + + + + + {devices.length === 0 ? ( + + + + ) : ( + devices.map((device) => ( + handleToggleExpand(device.id)} + isSelected={selectedDeviceId === device.id} + onSelect={() => setSelectedDeviceId(device.id)} + onUpdateDevice={(config) => onUpdateDevice(device.id, config)} + projectPath={projectPath} + onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(device.id, mappings)} + onEnrichDevice={(data) => onEnrichDevice(device.id, data)} + onUpdateSdoConfigurations={(configs) => onUpdateSdoConfigurations(device.id, configs)} + usedAddresses={usedAddresses} + /> + )) + )} + +
+ Name + + Type + + Position + + Channels (In/Out) + Source
+ No devices configured. Click the + button to add a device from the repository, or use the Discovery + tab to scan and add devices. +
+
+
+ ) +} + +export { ConfiguredDevices } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx new file mode 100644 index 000000000..aa5c6fbb1 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx @@ -0,0 +1,274 @@ +import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal' +import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useMemo, useState } from 'react' + +type DeviceBrowserModalProps = { + isOpen: boolean + onClose: () => void + onSelectDevice: (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => void + repository: ESIRepositoryItemLight[] +} + +/** + * Device Browser Modal Component + * + * Modal for browsing and selecting devices from the ESI repository. + * Groups devices by vendor for easier navigation. + */ +const DeviceBrowserModal = ({ isOpen, onClose, onSelectDevice, repository }: DeviceBrowserModalProps) => { + const [searchTerm, setSearchTerm] = useState('') + const [selectedRef, setSelectedRef] = useState(null) + const [expandedVendors, setExpandedVendors] = useState>(new Set()) + + // Group devices by vendor + const groupedDevices = useMemo(() => { + const groups: Map< + string, + { + vendorId: string + vendorName: string + devices: Array<{ + repoItem: ESIRepositoryItemLight + device: ESIDeviceSummary + deviceIndex: number + }> + } + > = new Map() + + for (const repoItem of repository) { + const vendorKey = repoItem.vendor.id + if (!groups.has(vendorKey)) { + groups.set(vendorKey, { + vendorId: repoItem.vendor.id, + vendorName: repoItem.vendor.name, + devices: [], + }) + } + + for (let i = 0; i < repoItem.devices.length; i++) { + const device = repoItem.devices[i] + // Apply search filter + if (searchTerm) { + const search = searchTerm.toLowerCase() + const matches = + device.name.toLowerCase().includes(search) || + device.type.productCode.toLowerCase().includes(search) || + repoItem.vendor.name.toLowerCase().includes(search) + if (!matches) continue + } + + groups.get(vendorKey)!.devices.push({ + repoItem, + device, + deviceIndex: i, + }) + } + } + + // Remove empty groups + for (const [key, group] of groups) { + if (group.devices.length === 0) { + groups.delete(key) + } + } + + return Array.from(groups.values()) + }, [repository, searchTerm]) + + const handleToggleVendor = useCallback((vendorId: string) => { + setExpandedVendors((prev) => { + const next = new Set(prev) + if (next.has(vendorId)) { + next.delete(vendorId) + } else { + next.add(vendorId) + } + return next + }) + }, []) + + const handleSelectDevice = useCallback((repoItemId: string, deviceIndex: number) => { + setSelectedRef({ repositoryItemId: repoItemId, deviceIndex }) + }, []) + + const handleConfirm = useCallback(() => { + if (!selectedRef) return + + const repoItem = repository.find((r) => r.id === selectedRef.repositoryItemId) + if (!repoItem) return + + const device = repoItem.devices[selectedRef.deviceIndex] + if (!device) return + + onSelectDevice(selectedRef, device, repoItem) + setSelectedRef(null) + setSearchTerm('') + onClose() + }, [selectedRef, repository, onSelectDevice, onClose]) + + const handleClose = useCallback(() => { + setSelectedRef(null) + setSearchTerm('') + onClose() + }, [onClose]) + + // Auto-expand all vendors when searching + const effectiveExpandedVendors = useMemo(() => { + if (searchTerm) { + return new Set(groupedDevices.map((g) => g.vendorId)) + } + return expandedVendors + }, [searchTerm, groupedDevices, expandedVendors]) + + const totalDevices = groupedDevices.reduce((sum, g) => sum + g.devices.length, 0) + + return ( + !open && handleClose()}> + + + Add Device from Repository + + + {/* Search */} +
+ setSearchTerm(e.target.value)} + className='h-[34px] w-full rounded-md border border-neutral-300 bg-white px-3 text-sm text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> +
+ + {/* Device count */} +
+ {totalDevices} device(s) in {groupedDevices.length} vendor(s) +
+ + {/* Device list */} +
+ {groupedDevices.length === 0 ? ( +
+

+ {repository.length === 0 + ? 'No ESI files loaded. Upload files in the Repository tab first.' + : 'No devices match your search.'} +

+
+ ) : ( +
+ {groupedDevices.map((group) => ( +
+ {/* Vendor header */} + + + {/* Devices */} + {effectiveExpandedVendors.has(group.vendorId) && ( +
+ {group.devices.map(({ repoItem, device, deviceIndex }) => { + const isSelected = + selectedRef?.repositoryItemId === repoItem.id && selectedRef?.deviceIndex === deviceIndex + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} + +export { DeviceBrowserModal } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx new file mode 100644 index 000000000..08443aa3e --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx @@ -0,0 +1,422 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import { Checkbox } from '@root/renderer/components/_atoms/checkbox' +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import type { + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { extractDefaultSdoConfigurations } from '@root/utils/ethercat/sdo-config-defaults' + +import { ChannelMappingTable } from './channel-mapping-table' +import { SdoParametersTable } from './sdo-parameters-table' + +const inputClassName = + 'h-[26px] w-24 rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus:border-brand-medium-dark dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + +const disabledInputClassName = 'cursor-not-allowed opacity-50' + +const parseNumericInput = (value: string, min = 0): number | undefined => { + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed < min) return undefined + return parsed +} + +// ===================== Configuration Form ===================== + +type DeviceConfigurationFormProps = { + config: EtherCATSlaveConfig + updateConfig: (section: K, updates: Partial) => void +} + +const DeviceConfigurationForm = ({ config, updateConfig }: DeviceConfigurationFormProps) => ( +
+ {/* Startup Checks */} +
+
Startup Checks
+
+ + +
+
+ + {/* Addressing */} +
+
Addressing
+
+
+ EtherCAT Address + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('addressing', { ethercatAddress: val }) + }} + min={0} + max={65535} + className={inputClassName} + /> + 0 = auto +
+
+
+ + {/* Timeouts */} +
+
Timeouts
+
+
+ SDO (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { sdoTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+ I→P (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { initToPreOpTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+ + P→S/S→O (ms) + + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { safeOpToOpTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+
+ + {/* Watchdog */} +
+
Watchdog
+
+
+ +
+ Time (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('watchdog', { smWatchdogMs: val }) + }} + min={0} + className={cn(inputClassName, !config.watchdog.smWatchdogEnabled && disabledInputClassName)} + /> +
+
+
+ +
+ Time (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('watchdog', { pdiWatchdogMs: val }) + }} + min={0} + className={cn(inputClassName, !config.watchdog.pdiWatchdogEnabled && disabledInputClassName)} + /> +
+
+
+
+ + {/* Distributed Clocks (DC) */} +
+
Distributed Clocks (DC)
+
+
+ +
+ + Sync Unit Cycle (us) + + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSyncUnitCycleUs: val }) + }} + min={0} + className={cn(inputClassName, !config.distributedClocks.dcEnabled && disabledInputClassName)} + /> + 0 = master cycle +
+
+ + {/* SYNC0 */} +
+ +
+ Cycle (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync0CycleUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync0Enabled) && + disabledInputClassName, + )} + /> +
+
+ Shift (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync0ShiftUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync0Enabled) && + disabledInputClassName, + )} + /> +
+
+ + {/* SYNC1 */} +
+ +
+ Cycle (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync1CycleUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync1Enabled) && + disabledInputClassName, + )} + /> +
+
+ Shift (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync1ShiftUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync1Enabled) && + disabledInputClassName, + )} + /> +
+
+
+
+
+) + +// ===================== SDO Parameters Section ===================== + +type SdoParametersSectionProps = { + isLoading: boolean + loadError: string | null + sdoConfigurations: SDOConfigurationEntry[] | undefined + coeObjects: ESICoEObject[] | undefined + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +const SdoParametersSection = ({ + isLoading, + loadError, + sdoConfigurations, + coeObjects, + onUpdateSdoConfigurations, +}: SdoParametersSectionProps) => ( + <> + {isLoading && ( +
+ + Loading CoE data... +
+ )} + + {!isLoading && sdoConfigurations && sdoConfigurations.length > 0 && ( + + )} + + {!isLoading && !loadError && sdoConfigurations && sdoConfigurations.length === 0 && ( +

+ No configurable SDO parameters found in this device's CoE dictionary. +

+ )} + + {!isLoading && !loadError && !sdoConfigurations && coeObjects && coeObjects.length > 0 && ( +
+

+ CoE Object Dictionary available. Auto-configure startup parameters? +

+ +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {!isLoading && !loadError && !sdoConfigurations && !coeObjects && ( +

+ No CoE Object Dictionary available for this device. +

+ )} + +) + +// ===================== Channel Mappings Section ===================== + +type ChannelMappingsSectionProps = { + isLoading: boolean + loadError: string | null + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onAliasChange: (channelId: string, alias: string) => void +} + +const ChannelMappingsSection = ({ + isLoading, + loadError, + channels, + mappings, + onAliasChange, +}: ChannelMappingsSectionProps) => ( + <> + {isLoading && ( +
+ + Loading channels... +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {!isLoading && !loadError && channels.length === 0 && ( +

+ No channels available for this device. +

+ )} + + {!isLoading && !loadError && channels.length > 0 && ( + + )} + +) + +export { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx new file mode 100644 index 000000000..57b83d948 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx @@ -0,0 +1,219 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { useDeviceConfiguration } from '@root/renderer/hooks/use-device-configuration' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useMemo, useState } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type DeviceDetailPanelProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + projectPath: string + usedAddresses: Set + onUpdateDevice: (config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +const DeviceDetailPanel = ({ + device, + repository, + projectPath, + usedAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, +}: DeviceDetailPanelProps) => { + const [activeTab, setActiveTab] = useState('info') + + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + }) + + return ( +
+ {/* Device header */} +
+

{device.name}

+

+ {esiDevice?.name || 'Unknown'} — Position {device.position ?? '-'} +

+
+ + {/* Sub-tabs */} + setActiveTab(v as DeviceDetailTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + {/* Device Info Tab */} + +
+
+
+ Vendor + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID + {device.vendorId} +
+
+ Product Code + {device.productCode} +
+
+ Revision + {device.revisionNo} +
+
+ ESI File + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels + {esiDevice.inputChannelCount} +
+
+ Output Channels + {esiDevice.outputChannelCount} +
+ + )} +
+ Source + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + +
+
+
+
+ + {/* Configuration Tab */} + +
+
+ +
+
+
+ + {/* Startup Parameters Tab */} + +
+ +
+
+ + {/* Channel Mappings Tab */} + +
+ +
+
+
+
+ ) +} + +export { DeviceDetailPanel } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx new file mode 100644 index 000000000..5855a2215 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx @@ -0,0 +1,127 @@ +import type { EtherCATDevice } from '@root/types/ethercat' +import { cn } from '@root/utils' + +type DeviceScanTableProps = { + devices: EtherCATDevice[] + selectedPosition: number | null + onSelectDevice: (position: number) => void + isScanning: boolean +} + +/** + * Get CSS class for EtherCAT state badge + */ +const getStateBadgeClass = (state: EtherCATDevice['state']) => { + switch (state) { + case 'OP': + return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + case 'SAFE-OP': + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + case 'PRE-OP': + return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + case 'INIT': + return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + case 'BOOT': + return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + case 'NONE': + case 'UNKNOWN': + default: + return 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400' + } +} + +/** + * Table displaying discovered EtherCAT devices + */ +const DeviceScanTable = ({ devices, selectedPosition, onSelectDevice, isScanning }: DeviceScanTableProps) => { + return ( +
+ + + + + + + + + + + + + + {isScanning ? ( + + + + ) : devices.length === 0 ? ( + + + + ) : ( + devices.map((device) => ( + onSelectDevice(device.position)} + className={cn( + 'cursor-pointer border-b border-neutral-200 transition-colors dark:border-neutral-800', + 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + selectedPosition === device.position && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + + + + + + + )) + )} + +
+ Pos + + Name + + Vendor + + Product + + State + + CoE + I/O Size
+
+
+ Scanning for devices... +
+
+ No devices found. Select an interface and click "Scan Devices". +
+ {device.position} + + {device.name} + + 0x{device.vendor_id.toString(16).toUpperCase().padStart(4, '0')} + + 0x{device.product_code.toString(16).toUpperCase().padStart(8, '0')} + + + {device.state} + + + {device.has_coe ? 'Yes' : 'No'} + + {device.input_bytes}B / {device.output_bytes}B +
+
+ ) +} + +export { DeviceScanTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx new file mode 100644 index 000000000..c543cc039 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -0,0 +1,252 @@ +import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' +import TableActions from '@root/renderer/components/_atoms/table-actions' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useMemo, useState } from 'react' + +import { DeviceBrowserModal } from './device-browser-modal' +import { DeviceDetailPanel } from './device-detail-panel' +import { ESIRepository } from './esi-repository' + +type DevicesTabProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onRepositoryChange: (items: ESIRepositoryItemLight[]) => void + projectPath: string + isLoadingRepository: boolean + repositoryError: string | null + onRetryRepository: () => void + usedAddresses: Set + onAddDeviceFromBrowser: ( + ref: ESIDeviceRef, + device: ESIDeviceSummary, + repoItem: ESIRepositoryItemLight, + ) => void | Promise + onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void +} + +const DevicesTab = ({ + devices, + repository, + onRepositoryChange, + projectPath, + isLoadingRepository, + repositoryError, + onRetryRepository, + usedAddresses, + onAddDeviceFromBrowser, + onRemoveDevice, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, +}: DevicesTabProps) => { + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + const [showRepository, setShowRepository] = useState(false) + + const selectedDevice = useMemo(() => { + return devices.find((d) => d.id === selectedDeviceId) ?? null + }, [devices, selectedDeviceId]) + + // Auto-select first device if current selection is invalid + const effectiveSelectedId = selectedDevice ? selectedDeviceId : devices.length > 0 ? devices[0].id : null + + const effectiveDevice = useMemo(() => { + return devices.find((d) => d.id === effectiveSelectedId) ?? null + }, [devices, effectiveSelectedId]) + + const handleRemoveSelected = useCallback(() => { + if (effectiveSelectedId) { + onRemoveDevice(effectiveSelectedId) + setSelectedDeviceId(null) + } + }, [effectiveSelectedId, onRemoveDevice]) + + return ( +
+ {/* Repository Section (collapsible) */} +
+ + + {showRepository && ( +
+ {repositoryError && ( +
+

Failed to load repository: {repositoryError}

+ +
+ )} + +
+ )} +
+ + {/* Configured Devices - Split Pane */} +
+ {/* Left Panel - Device List */} +
+ {/* Header */} +
+

+ Configured Devices + {devices.length > 0 && ({devices.length})} +

+ setIsDeviceBrowserOpen(true), + icon: , + id: 'add-device-button', + }, + { + ariaLabel: 'Remove Device', + onClick: handleRemoveSelected, + disabled: !effectiveSelectedId, + icon: , + id: 'remove-device-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
+ + {/* Device List */} +
+ {devices.length === 0 ? ( +
+

+ No devices configured. Click + to add a device from the repository. +

+
+ ) : ( + devices.map((device) => { + const isActive = device.id === effectiveSelectedId + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + const esiDevice = repoItem?.devices[device.esiDeviceRef.deviceIndex] + + return ( + + ) + }) + )} +
+
+ + {/* Right Panel - Device Detail */} +
+ {effectiveDevice ? ( + onUpdateDevice(effectiveDevice.id, config)} + onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(effectiveDevice.id, mappings)} + onEnrichDevice={(data) => onEnrichDevice(effectiveDevice.id, data)} + onUpdateSdoConfigurations={(configs) => onUpdateSdoConfigurations(effectiveDevice.id, configs)} + /> + ) : ( +
+

+ Select a device from the list or add a new one +

+
+ )} +
+
+ + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={(...args) => void onAddDeviceFromBrowser(...args)} + repository={repository} + /> +
+ ) +} + +export { DevicesTab } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx new file mode 100644 index 000000000..232a2f70c --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -0,0 +1,179 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { ScannedDeviceMatch } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' + +import { DiscoveredDeviceTable } from './discovered-device-table' +import { InterfaceSelector } from './interface-selector' +import { RuntimeStatusPanel } from './runtime-status-panel' + +type DiagnosticsTabProps = { + isConnectedToRuntime: boolean + ipAddress: string | null + jwtToken: string | null + // Service status + serviceAvailable: boolean | null + serviceMessage: string + // Network interfaces + interfaces: NetworkInterface[] + selectedInterface: string + onSelectInterface: (value: string) => void + isLoadingInterfaces: boolean + interfaceError: string | null + onRefreshInterfaces: () => void + // Scan + isScanning: boolean + scanError: string | null + scanTimeMs: number | null + scanMessage: string + scannedDevices: EtherCATDevice[] + onScan: () => void + // Match results + deviceMatches: ScannedDeviceMatch[] + matchCounts: { total: number; exact: number; partial: number; none: number } + // Selection + selectedScannedDevices: Set + onSelectScannedDevice: (position: number, selected: boolean) => void + onSelectAllScanned: (selected: boolean) => void + onAddSelectedFromScan: () => void +} + +const DiagnosticsTab = ({ + isConnectedToRuntime, + ipAddress, + jwtToken, + serviceAvailable, + serviceMessage, + interfaces, + selectedInterface, + onSelectInterface, + isLoadingInterfaces, + interfaceError, + onRefreshInterfaces, + isScanning, + scanError, + scanTimeMs, + scanMessage, + onScan, + deviceMatches, + matchCounts, + selectedScannedDevices, + onSelectScannedDevice, + onSelectAllScanned, + onAddSelectedFromScan, +}: DiagnosticsTabProps) => { + return ( +
+ {/* Runtime Status */} + {isConnectedToRuntime && ipAddress && jwtToken && ( + + )} + + {/* Not connected state */} + {!isConnectedToRuntime && ( +
+
+

Not connected to runtime

+

+ Connect to the OpenPLC Runtime to scan for EtherCAT devices. +

+
+
+ )} + + {/* Service not available state */} + {isConnectedToRuntime && serviceAvailable === false && ( +
+
+

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+
+ )} + + {/* Connected state - Discovery */} + {isConnectedToRuntime && serviceAvailable !== false && ( +
+ {/* Interface Selection and Scan Controls */} +
+ + + + + {scanTimeMs !== null && ( + + Completed in {scanTimeMs}ms{scanMessage ? ` — ${scanMessage}` : ''} + + )} +
+ + {/* Error/Status Messages */} + {scanError && ( +
+

{scanError}

+
+ )} + + {/* Match summary */} + {deviceMatches.length > 0 && ( +
+
+ + Found {matchCounts.total} device(s): + + {matchCounts.exact} exact + {matchCounts.partial} partial + {matchCounts.none} no match +
+ {selectedScannedDevices.size > 0 && ( + + )} +
+ )} + + {/* Discovered Devices Table */} + +
+ )} +
+ ) +} + +export { DiagnosticsTab } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx new file mode 100644 index 000000000..a998c5e78 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -0,0 +1,184 @@ +import { Checkbox } from '@root/renderer/components/_atoms/checkbox' +import type { DeviceMatchQuality, ScannedDeviceMatch } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { getBestMatchQuality } from '@root/utils/ethercat/device-matcher' + +type DiscoveredDeviceTableProps = { + deviceMatches: ScannedDeviceMatch[] + selectedDevices: Set + onSelectDevice: (position: number, selected: boolean) => void + onSelectAll: (selected: boolean) => void + isScanning: boolean +} + +/** + * Get badge styling for match quality + */ +function getMatchBadge(quality: DeviceMatchQuality): { label: string; className: string } { + switch (quality) { + case 'exact': + return { + label: 'Exact', + className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + } + case 'partial': + return { + label: 'Partial', + className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + } + case 'none': + return { + label: 'None', + className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + } + } +} + +/** + * Discovered Device Table Component + * + * Displays scanned EtherCAT devices with match indicators and selection checkboxes. + */ +const DiscoveredDeviceTable = ({ + deviceMatches, + selectedDevices, + onSelectDevice, + onSelectAll, + isScanning, +}: DiscoveredDeviceTableProps) => { + // Calculate selection state + const selectableDevices = deviceMatches.filter((dm) => getBestMatchQuality(dm.matches) !== 'none') + const allSelected = + selectableDevices.length > 0 && selectableDevices.every((dm) => selectedDevices.has(dm.device.position)) + const someSelected = selectableDevices.some((dm) => selectedDevices.has(dm.device.position)) + + const handleSelectAll = () => { + onSelectAll(!allSelected) + } + + return ( +
+ + + + + + + + + + + + + + + {deviceMatches.length === 0 ? ( + + + + ) : ( + deviceMatches.map((dm) => { + const bestQuality = getBestMatchQuality(dm.matches) + const badge = getMatchBadge(bestQuality) + const isSelectable = bestQuality !== 'none' + const isSelected = selectedDevices.has(dm.device.position) + + return ( + isSelectable && onSelectDevice(dm.device.position, !isSelected)} + className={cn( + 'border-b border-neutral-200 transition-colors dark:border-neutral-800', + isSelectable && 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + isSelected && 'bg-brand/10 dark:bg-brand/20', + !isSelectable && 'opacity-60', + )} + > + + + + + + + + + + ) + }) + )} + +
+ + + Pos + + Name + + Vendor + + Product + + State + + I/O + Match
+ {isScanning + ? 'Scanning for devices...' + : 'No devices found. Click "Scan" to discover EtherCAT devices on the network.'} +
+ onSelectDevice(dm.device.position, !!checked)} + onClick={(e) => e.stopPropagation()} + disabled={!isSelectable} + /> + + {dm.device.position} + + {dm.device.name} + + 0x{dm.device.vendor_id.toString(16).padStart(4, '0').toUpperCase()} + + 0x{dm.device.product_code.toString(16).padStart(8, '0').toUpperCase()} + + + {dm.device.state} + + + {dm.device.input_bytes}B / {dm.device.output_bytes}B + +
+ + {bestQuality === 'exact' && '✓ '} + {bestQuality === 'partial' && '~ '} + {bestQuality === 'none' && '✗ '} + {badge.label} + + {dm.matches.length > 1 && ( + + ({dm.matches.length} matches) + + )} +
+
+
+ ) +} + +export { DiscoveredDeviceTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx new file mode 100644 index 000000000..fdd674c03 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx @@ -0,0 +1,234 @@ +import { Checkbox } from '@root/renderer/components/_atoms/checkbox' +import type { ESIChannel } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useMemo, useState } from 'react' + +type ESIChannelsTableProps = { + channels: ESIChannel[] + onChannelSelect?: (channelId: string, selected: boolean) => void + onChannelSelectAll?: (selected: boolean) => void + selectedChannels?: Set + showSelection?: boolean +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * ESI Channels Table Component + * + * Displays PDO channels from an ESI file with filtering and selection capabilities. + */ +const ESIChannelsTable = ({ + channels, + onChannelSelect, + onChannelSelectAll, + selectedChannels = new Set(), + showSelection = true, +}: ESIChannelsTableProps) => { + const [filterDirection, setFilterDirection] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + // Filter channels based on direction and search + const filteredChannels = useMemo(() => { + return channels.filter((channel) => { + // Direction filter + if (filterDirection !== 'all' && channel.direction !== filterDirection) { + return false + } + + // Search filter + if (searchTerm) { + const search = searchTerm.toLowerCase() + return ( + channel.name.toLowerCase().includes(search) || + channel.pdoName.toLowerCase().includes(search) || + channel.dataType.toLowerCase().includes(search) || + channel.entryIndex.toLowerCase().includes(search) + ) + } + + return true + }) + }, [channels, filterDirection, searchTerm]) + + // Count by direction + const inputCount = channels.filter((c) => c.direction === 'input').length + const outputCount = channels.filter((c) => c.direction === 'output').length + + // Check if all filtered channels are selected + const allSelected = filteredChannels.length > 0 && filteredChannels.every((c) => selectedChannels.has(c.id)) + const someSelected = filteredChannels.some((c) => selectedChannels.has(c.id)) + + const handleSelectAll = () => { + if (onChannelSelectAll) { + onChannelSelectAll(!allSelected) + } + } + + return ( +
+ {/* Filters */} +
+ {/* Direction Filter */} +
+ + + +
+ + {/* Search */} + setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> + + {/* Selection count */} + {showSelection && selectedChannels.size > 0 && ( + + {selectedChannels.size} channel(s) selected + + )} +
+ + {/* Table */} +
+ + + + {showSelection && ( + + )} + + + + + + + + + + + {filteredChannels.length === 0 ? ( + + + + ) : ( + filteredChannels.map((channel) => ( + showSelection && onChannelSelect?.(channel.id, !selectedChannels.has(channel.id))} + className={cn( + 'cursor-pointer border-b border-neutral-200 transition-colors dark:border-neutral-800', + 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + selectedChannels.has(channel.id) && 'bg-brand/10 dark:bg-brand/20', + )} + > + {showSelection && ( + + )} + + + + + + + + + )) + )} + +
+ + + Dir + + Name + + PDO + + Index + + Type + + Bits + + IEC Type +
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ onChannelSelect?.(channel.id, !!checked)} + onClick={(e) => e.stopPropagation()} + /> + + + {channel.direction === 'input' ? 'IN' : 'OUT'} + + + {channel.name} + + {channel.pdoName} + + {channel.entryIndex}:{channel.entrySubIndex} + {channel.dataType}{channel.bitLen} + {channel.iecType} +
+
+
+ ) +} + +export { ESIChannelsTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx new file mode 100644 index 000000000..d38da95ca --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx @@ -0,0 +1,165 @@ +import type { ESIDevice, ESIFile } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { getDeviceSummary } from '@root/utils/ethercat/esi-parser' + +type ESIDeviceInfoProps = { + esiFile: ESIFile + selectedDeviceIndex: number + onSelectDevice: (index: number) => void +} + +/** + * ESI Device Info Component + * + * Displays vendor information and device details from an ESI file. + */ +const ESIDeviceInfo = ({ esiFile, selectedDeviceIndex, onSelectDevice }: ESIDeviceInfoProps) => { + const selectedDevice: ESIDevice | undefined = esiFile.devices[selectedDeviceIndex] + const summary = selectedDevice ? getDeviceSummary(selectedDevice) : null + + return ( +
+ {/* Vendor Information */} +
+

Vendor

+
+
+ Name: + {esiFile.vendor.name} +
+
+ ID: + {esiFile.vendor.id} +
+
+
+ + {/* Device Selector (if multiple devices) */} + {esiFile.devices.length > 1 && ( +
+ +
+ {esiFile.devices.map((device, index) => ( + + ))} +
+
+ )} + + {/* Selected Device Information */} + {selectedDevice && ( +
+
+
+

{selectedDevice.name}

+

{selectedDevice.type.name}

+
+ {selectedDevice.groupName && ( + + {selectedDevice.groupName} + + )} +
+ + {/* Device Details Grid */} +
+
+ Product Code +

+ {selectedDevice.type.productCode} +

+
+
+ Revision +

+ {selectedDevice.type.revisionNo} +

+
+
+ Physics +

{selectedDevice.physics || 'N/A'}

+
+
+ CoE Support +

{summary?.hasCoe ? 'Yes' : 'No'}

+
+
+ + {/* I/O Summary */} + {summary && ( +
+

I/O Summary

+
+
+

{summary.inputChannelCount}

+

Input Channels

+
+
+

{summary.outputChannelCount}

+

Output Channels

+
+
+

+ {summary.totalInputBytes} B +

+

Input Size

+
+
+

+ {summary.totalOutputBytes} B +

+

Output Size

+
+
+
+ )} + + {/* Sync Managers */} + {selectedDevice.syncManagers.length > 0 && ( +
+

+ Sync Managers +

+
+ {selectedDevice.syncManagers.map((sm) => ( +
+ SM{sm.index}: {sm.type} +
+ ))} +
+
+ )} + + {/* Description */} + {selectedDevice.description && ( +
+

Description

+

{selectedDevice.description}

+
+ )} +
+ )} +
+ ) +} + +export { ESIDeviceInfo } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx new file mode 100644 index 000000000..a91b91180 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx @@ -0,0 +1,41 @@ +type ESIParseProgressProps = { + currentFile?: string + currentFileIndex: number + totalFiles: number + percentage: number +} + +/** + * ESI Parse Progress Component + * + * Shows a progress bar with file-by-file status during ESI XML parsing. + */ +const ESIParseProgress = ({ currentFile, currentFileIndex, totalFiles, percentage }: ESIParseProgressProps) => { + return ( +
+
+ + Processing file {currentFileIndex + 1} / {totalFiles} + + {percentage}% +
+ + {/* Progress bar */} +
+
+
+ + {/* Current file name */} + {currentFile && ( + + {currentFile} + + )} +
+ ) +} + +export { ESIParseProgress } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx new file mode 100644 index 000000000..81010bae0 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -0,0 +1,222 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useState } from 'react' + +type ESIRepositoryTableProps = { + repository: ESIRepositoryItemLight[] + onRemoveItem: (itemId: string) => void | Promise + onClearAll: () => void | Promise + isLoading?: boolean +} + +/** + * ESI Repository Table Component + * + * Displays loaded ESI files with expandable rows showing contained devices. + */ +const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll, isLoading = false }: ESIRepositoryTableProps) => { + const [expandedItems, setExpandedItems] = useState>(new Set()) + + const handleToggleExpand = useCallback((itemId: string) => { + setExpandedItems((prev) => { + const next = new Set(prev) + if (next.has(itemId)) { + next.delete(itemId) + } else { + next.add(itemId) + } + return next + }) + }, []) + + if (repository.length === 0) { + return ( +
+

+ No ESI files loaded. Upload files above to populate the repository. +

+
+ ) + } + + const totalDevices = repository.reduce((sum, item) => sum + item.devices.length, 0) + + return ( +
+ {/* Header with count and clear button */} +
+ + Loaded Files ({repository.length}) - {totalDevices} device(s) + + +
+ + {/* Repository list */} +
+ + + + + + + + + + + + {repository.map((item) => ( + handleToggleExpand(item.id)} + onRemove={() => void onRemoveItem(item.id)} + /> + ))} + +
+ Filename + + Vendor + + Devices + + Actions +
+
+
+ ) +} + +type RepositoryItemRowProps = { + item: ESIRepositoryItemLight + isExpanded: boolean + onToggleExpand: () => void + onRemove: () => void +} + +const RepositoryItemRow = ({ item, isExpanded, onToggleExpand, onRemove }: RepositoryItemRowProps) => { + return ( + <> + {/* Main row */} + + + + + +
+ + + + + {item.filename} + + {item.warnings && item.warnings.length > 0 && ( + + {item.warnings.length} warning(s) + + )} +
+ + + {item.vendor.name} + ({item.vendor.id}) + + {item.devices.length} + + + + + + {/* Expanded device rows */} + {isExpanded && + item.devices.map((device, index) => ( + + + +
+
+ + + + {device.name} +
+ + {device.type.productCode} + + + Rev: {device.type.revisionNo} + + {device.groupName && ( + + {device.groupName} + + )} +
+ + + ))} + + {/* Warnings row */} + {isExpanded && item.warnings && item.warnings.length > 0 && ( + + + +
+ {item.warnings.map((warning, index) => ( + + {warning} + + ))} +
+ + + )} + + ) +} + +export { ESIRepositoryTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx new file mode 100644 index 000000000..732120818 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -0,0 +1,151 @@ +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { useCallback, useState } from 'react' + +import { ESIRepositoryTable } from './esi-repository-table' +import { ESIUpload } from './esi-upload' + +type ESIServiceResponse = { success: boolean; error?: string } + +type ESIRepositoryProps = { + repository: ESIRepositoryItemLight[] + onRepositoryChange: (repository: ESIRepositoryItemLight[]) => void + projectPath: string + isLoading?: boolean +} + +/** + * ESI Repository Component + * + * Manages the ESI file repository with upload and display functionality. + * Combines upload zone with repository table. + * Files are parsed and saved in the main process; this component receives ready items. + */ +const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading = false }: ESIRepositoryProps) => { + const [uploadErrors, setUploadErrors] = useState>([]) + const [isSaving, setIsSaving] = useState(false) + + const handleFilesLoaded = useCallback( + (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => { + // Items are already parsed and saved by the main process + onRepositoryChange(items) + setUploadErrors(errors ?? []) + }, + [onRepositoryChange], + ) + + const handleRemoveItem = useCallback( + async (itemId: string) => { + setIsSaving(true) + + try { + const result: ESIServiceResponse = await window.bridge.esiDeleteXmlFile(projectPath, itemId) + + if (result.success) { + onRepositoryChange(repository.filter((item) => item.id !== itemId)) + } else { + console.error('Failed to delete ESI item:', result.error) + } + } catch (err) { + console.error('Error deleting ESI item:', err) + } finally { + setIsSaving(false) + } + }, + [repository, onRepositoryChange, projectPath], + ) + + const handleClearAll = useCallback(async () => { + setIsSaving(true) + + try { + const result: ESIServiceResponse = await window.bridge.esiClearRepository(projectPath) + if (result.success) { + onRepositoryChange([]) + setUploadErrors([]) + } else { + console.error('Failed to clear ESI repository:', result.error) + } + } catch (err) { + console.error('Error clearing ESI repository:', err) + } finally { + setIsSaving(false) + } + }, [onRepositoryChange, projectPath]) + + const [errorsExpanded, setErrorsExpanded] = useState(false) + + const isProcessing = isLoading || isSaving + + return ( +
+ {/* Upload Area */} + + + {/* Error Summary (collapsible) */} + {uploadErrors.length > 0 && ( +
+
+ + +
+ {errorsExpanded && ( +
+ {uploadErrors.map((error, index) => ( +
+ {error.filename || 'File'}: {error.error} +
+ ))} +
+ )} +
+ )} + + {/* Repository Table */} +
+ +
+
+ ) +} + +export { ESIRepository } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx new file mode 100644 index 000000000..23c7b6584 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -0,0 +1,221 @@ +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useRef, useState } from 'react' + +import { ESIParseProgress } from './esi-parse-progress' + +type ParseProgress = { + active: boolean + currentFile?: string + currentFileIndex: number + totalFiles: number + percentage: number +} + +type ESIUploadProps = { + onFilesLoaded: (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => void + repository: ESIRepositoryItemLight[] + isLoading?: boolean + projectPath: string +} + +/** + * ESI File Upload Component + * + * Allows users to upload multiple EtherCAT ESI XML files via drag-and-drop or file picker. + * Files are read and sent to the main process one at a time to avoid memory issues. + */ +const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath }: ESIUploadProps) => { + const [isDragging, setIsDragging] = useState(false) + const [parseProgress, setParseProgress] = useState({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 0, + }) + const fileInputRef = useRef(null) + + const processFiles = useCallback( + async (files: FileList) => { + const xmlFiles = Array.from(files).filter((file) => file.name.toLowerCase().endsWith('.xml')) + + if (xmlFiles.length === 0) { + onFilesLoaded(repository, [{ filename: '', error: 'No XML files found. Please upload .xml ESI files.' }]) + return + } + + setParseProgress({ + active: true, + currentFile: xmlFiles[0].name, + currentFileIndex: 0, + totalFiles: xmlFiles.length, + percentage: 0, + }) + + const newItems: ESIRepositoryItemLight[] = [] + const errors: Array<{ filename: string; error: string }> = [] + + const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB + + // Process files one at a time to avoid memory issues + for (let i = 0; i < xmlFiles.length; i++) { + const file = xmlFiles[i] + + setParseProgress({ + active: true, + currentFile: file.name, + currentFileIndex: i, + totalFiles: xmlFiles.length, + percentage: Math.round((i / xmlFiles.length) * 100), + }) + + if (file.size > MAX_FILE_SIZE) { + errors.push({ + filename: file.name, + error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 100MB.`, + }) + continue + } + + try { + const text = await file.text() + const result = await window.bridge.esiParseAndSaveFile(projectPath, file.name, text) + + if (result.success && result.item) { + newItems.push(result.item) + } else if (result.error) { + errors.push({ filename: file.name, error: result.error }) + } + } catch (err) { + errors.push({ filename: file.name, error: err instanceof Error ? err.message : String(err) }) + } + } + + setParseProgress({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 100, + }) + + onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) + }, + [onFilesLoaded, repository, projectPath], + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = e.dataTransfer.files + if (files.length > 0) { + void processFiles(files) + } + }, + [processFiles], + ) + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + void processFiles(files) + } + // Reset input so same files can be selected again + e.target.value = '' + }, + [processFiles], + ) + + const handleClick = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const isProcessing = isLoading || parseProgress.active + + return ( +
+ + + {/* Drop zone */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + aria-label='Upload ESI XML files. Click or drag and drop.' + className={cn( + 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors', + isDragging + ? 'bg-brand/10 dark:bg-brand/20 border-brand' + : 'border-neutral-300 hover:border-brand hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800/50', + isProcessing && 'pointer-events-none opacity-50', + )} + > + + + {parseProgress.active ? ( +
+ +
+ ) : ( +
+ + + + + Drop ESI files here or browse + + + Supports multiple .xml ESI files (ETG.2000) + + {repository.length > 0 && ( + + {repository.length} file(s) currently loaded + + )} +
+ )} +
+
+ ) +} + +export { ESIUpload } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx new file mode 100644 index 000000000..465700b40 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -0,0 +1,155 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select' +import type { NetworkInterface } from '@root/types/ethercat' +import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' + +type GlobalSettingsTabProps = { + masterConfig: EtherCATMasterConfig + onUpdateMasterConfig: (updates: Partial) => void + isConnectedToRuntime: boolean + interfaces: NetworkInterface[] + isLoadingInterfaces: boolean + onRefreshInterfaces: () => void +} + +const inputClassName = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const GlobalSettingsTab = ({ + masterConfig, + onUpdateMasterConfig, + isConnectedToRuntime, + interfaces, + isLoadingInterfaces, + onRefreshInterfaces, +}: GlobalSettingsTabProps) => { + return ( +
+ {/* Network Interface */} +
+

+ Network Interface +

+
+ {isConnectedToRuntime && interfaces.length > 0 ? ( +
+ + +
+ ) : ( + onUpdateMasterConfig({ networkInterface: e.target.value })} + placeholder='eth0' + className={cn(inputClassName, 'max-w-[320px]')} + /> + )} + + {isConnectedToRuntime && interfaces.length > 0 + ? 'Select from runtime interfaces' + : 'Interface name on the runtime host (e.g. eth0, enp3s0)'} + +
+
+ + {/* Cycle Time */} +
+

+ Cycle Time (microseconds) +

+
+ onUpdateMasterConfig({ cycleTimeUs: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 100) onUpdateMasterConfig({ cycleTimeUs: 100 }) + else if (val > 100000) onUpdateMasterConfig({ cycleTimeUs: 100000 }) + }} + min={100} + max={100000} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + EtherCAT bus cycle time in microseconds (100 - 100000) + +
+
+ + {/* Watchdog Timeout */} +
+

+ Watchdog Timeout (cycles) +

+
+ onUpdateMasterConfig({ watchdogTimeoutCycles: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 1) onUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) + else if (val > 100) onUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) + }} + min={1} + max={100} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + Number of missed cycles before watchdog triggers (1 - 100) + +
+
+
+ ) +} + +export { GlobalSettingsTab } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx new file mode 100644 index 000000000..cdb62091c --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx @@ -0,0 +1,94 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import { Label } from '@root/renderer/components/_atoms/label' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select' +import type { NetworkInterface } from '@root/types/ethercat' +import { cn } from '@root/utils' + +type InterfaceSelectorProps = { + interfaces: NetworkInterface[] + selectedInterface: string + onSelectInterface: (value: string) => void + isLoading: boolean + error: string | null + onRefresh: () => void +} + +const selectTriggerStyles = + 'flex h-[30px] w-full min-w-[200px] max-w-[300px] items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const selectContentStyles = + 'h-fit max-h-[200px] w-[--radix-select-trigger-width] overflow-y-auto rounded-lg border border-neutral-300 bg-white outline-none drop-shadow-lg dark:border-brand-medium-dark dark:bg-neutral-950' + +const selectItemStyles = cn( + 'data-[state=checked]:[&:not(:hover)]:bg-neutral-100 data-[state=checked]:dark:[&:not(:hover)]:bg-neutral-900', + 'flex w-full cursor-pointer flex-col items-start justify-start px-2 py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-800', +) + +/** + * Network interface selector component for EtherCAT configuration + */ +const InterfaceSelector = ({ + interfaces, + selectedInterface, + onSelectInterface, + isLoading, + error, + onRefresh, +}: InterfaceSelectorProps) => { + return ( +
+ +
+ + + +
+ + {error &&

{error}

} + + {!error && interfaces.length === 0 && !isLoading && ( +

No network interfaces available

+ )} +
+ ) +} + +export { InterfaceSelector } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx new file mode 100644 index 000000000..807432664 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -0,0 +1,307 @@ +import type { + EtherCATCycleMetrics, + EtherCATPluginState, + EtherCATRuntimeStatusResponse, + EtherCATSlaveStatus, +} from '@root/types/ethercat' +import { cn } from '@root/utils' +import { useCallback, useEffect, useRef, useState } from 'react' + +const POLL_INTERVAL_MS = 2000 + +type StatusColor = 'green' | 'yellow' | 'red' | 'gray' | 'blue' + +const stateColorMap: Record = { + IDLE: 'gray', + SCANNING: 'blue', + CONFIGURING: 'blue', + TRANSITIONING: 'blue', + OPERATIONAL: 'green', + RECOVERING: 'yellow', + ERROR: 'red', + STOPPED: 'gray', +} + +const colorClasses: Record = { + green: 'bg-green-500', + yellow: 'bg-yellow-500', + red: 'bg-red-500', + gray: 'bg-neutral-400', + blue: 'bg-blue-500', +} + +const colorTextClasses: Record = { + green: 'text-green-700 dark:text-green-400', + yellow: 'text-yellow-700 dark:text-yellow-400', + red: 'text-red-700 dark:text-red-400', + gray: 'text-neutral-600 dark:text-neutral-400', + blue: 'text-blue-700 dark:text-blue-400', +} + +function StatusDot({ color }: { color: StatusColor }) { + return +} + +function SlaveStateCell({ status }: { status: EtherCATSlaveStatus }) { + const isOp = status.state === 'OP' + const hasError = status.has_error + return ( + + {status.state} + + ) +} + +function MetricCard({ label, value, unit }: { label: string; value: string | number; unit?: string }) { + return ( +
+ {label} + + {value} + {unit && {unit}} + +
+ ) +} + +interface RuntimeStatusPanelProps { + ipAddress: string | null + jwtToken: string | null + isConnected: boolean +} + +function extractErrorMessage(rawError: string): string { + // Try to parse JSON error responses from the runtime (e.g. {"status":"error","message":"..."}) + try { + const parsed = JSON.parse(rawError) as Record + if (typeof parsed.message === 'string') return parsed.message + if (typeof parsed.error === 'string') return parsed.error + } catch { + // Not JSON, continue with raw string + } + return rawError +} + +function isPluginNotActiveError(message: string): boolean { + const lower = message.toLowerCase() + return ( + lower.includes('not found') || + lower.includes('not loaded') || + lower.includes('not available') || + lower.includes('no response from runtime') || + lower.includes('timeout') || + lower.includes('connection refused') || + lower.includes('(null) + const [error, setError] = useState(null) + const [pluginNotActive, setPluginNotActive] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const intervalRef = useRef | null>(null) + + const fetchStatus = useCallback(async () => { + if (!isConnected || !ipAddress || !jwtToken) { + setStatus(null) + return + } + + setIsLoading(true) + try { + const result = await window.bridge.etherCATGetRuntimeStatus(ipAddress, jwtToken) + if (result.success && result.data) { + setStatus(result.data) + setError(null) + setPluginNotActive(false) + } else { + const rawError = result.error ?? 'Failed to get status' + const cleanMessage = extractErrorMessage(rawError) + if (isPluginNotActiveError(cleanMessage)) { + setPluginNotActive(true) + setError(null) + setStatus(null) + } else { + setPluginNotActive(false) + setError(cleanMessage) + } + } + } catch (err) { + setError(String(err)) + setPluginNotActive(false) + } finally { + setIsLoading(false) + } + }, [isConnected, ipAddress, jwtToken]) + + // Start polling when connected + useEffect(() => { + if (!isConnected) { + setStatus(null) + setError(null) + setPluginNotActive(false) + return + } + + void fetchStatus() + + intervalRef.current = setInterval(() => { + void fetchStatus() + }, POLL_INTERVAL_MS) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [isConnected, fetchStatus]) + + if (!isConnected) { + return ( +
+ + Not connected to runtime +
+ ) + } + + if (pluginNotActive) { + return ( +
+ + + EtherCAT plugin not active - start PLC with EtherCAT configuration + +
+ ) + } + + if (error && !status) { + return ( +
+
+ + {error} +
+ +
+ ) + } + + if (!status) { + return ( +
+ + {isLoading ? 'Loading status...' : 'Waiting for status...'} + +
+ ) + } + + const pluginState = status.plugin_state + const stateColor = stateColorMap[pluginState] ?? 'gray' + const metrics: EtherCATCycleMetrics = status.metrics + + return ( +
+ {/* Plugin state header */} +
+
+ + {pluginState} + + ({status.slave_count} slave{status.slave_count !== 1 ? 's' : ''}, WKC={status.expected_wkc}) + +
+ +
+ + {/* Cycle metrics */} + {(pluginState === 'OPERATIONAL' || pluginState === 'RECOVERING' || pluginState === 'ERROR') && ( +
+ + + + + + {metrics.recovery_attempts > 0 && } +
+ )} + + {/* Slave table */} + {status.slaves.length > 0 && ( +
+ + + + + + + + + + + + {status.slaves.map((slave) => ( + + + + + + + + ))} + +
PosNameStateAL StatusErrors
{slave.position}{slave.name} + + + {slave.al_status_code === 0 + ? '-' + : `0x${slave.al_status_code.toString(16).toUpperCase().padStart(4, '0')}`} + + {slave.error_count > 0 ? ( + {slave.error_count} + ) : ( + '0' + )} +
+
+ )} +
+ ) +} + +export { RuntimeStatusPanel } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx new file mode 100644 index 000000000..e4206c28f --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -0,0 +1,389 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { SDOConfigurationEntry } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' + +type SdoParametersTableProps = { + sdoConfigurations: SDOConfigurationEntry[] + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +/** + * A group of SDO entries sharing the same parent object (index + objectName). + */ +interface SdoObjectGroup { + index: string + objectName: string + entries: SDOConfigurationEntry[] +} + +/** + * Get numeric range for a data type. + */ +function getDataTypeRange(dataType: string, bitLength: number): { min: number; max: number } | null { + const upper = dataType.toUpperCase() + + if (upper === 'BOOL') return { min: 0, max: 1 } + if (upper === 'USINT' || upper === 'UINT8') return { min: 0, max: 255 } + if (upper === 'SINT' || upper === 'INT8') return { min: -128, max: 127 } + if (upper === 'UINT' || upper === 'UINT16') return { min: 0, max: 65535 } + if (upper === 'INT' || upper === 'INT16') return { min: -32768, max: 32767 } + if (upper === 'UDINT' || upper === 'UINT32') return { min: 0, max: 4294967295 } + if (upper === 'DINT' || upper === 'INT32') return { min: -2147483648, max: 2147483647 } + if (upper === 'REAL' || upper === 'REAL32' || upper === 'FLOAT') return { min: -3.4028235e38, max: 3.4028235e38 } + if (upper === 'LREAL' || upper === 'REAL64' || upper === 'DOUBLE') + return { min: -1.7976931348623157e308, max: 1.7976931348623157e308 } + + // Fallback based on bit length for unsigned + if (bitLength > 0 && bitLength <= 32) { + return { min: 0, max: Math.pow(2, bitLength) - 1 } + } + + return null +} + +/** + * Check if the data type is boolean. + */ +function isBoolType(dataType: string): boolean { + return dataType.toUpperCase() === 'BOOL' +} + +/** + * Value cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const ValueCell = ({ + entry, + onValueChange, +}: { + entry: SDOConfigurationEntry + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + const [localValue, setLocalValue] = useState(entry.value) + + useEffect(() => { + setLocalValue(entry.value) + }, [entry.value]) + + const handleBlur = useCallback(() => { + if (localValue !== entry.value) { + const range = getDataTypeRange(entry.dataType, entry.bitLength) + if (range && localValue !== '') { + const num = Number(localValue) + if (!isNaN(num)) { + const clamped = Math.max(range.min, Math.min(range.max, num)) + const clampedStr = String(clamped) + setLocalValue(clampedStr) + onValueChange(entry.index, entry.subIndex, clampedStr) + return + } + } + onValueChange(entry.index, entry.subIndex, localValue) + } + }, [entry.index, entry.subIndex, entry.value, entry.dataType, entry.bitLength, localValue, onValueChange]) + + if (isBoolType(entry.dataType)) { + return ( + { + const newVal = e.target.checked ? '1' : '0' + setLocalValue(newVal) + onValueChange(entry.index, entry.subIndex, newVal) + }} + className='h-4 w-4 accent-brand' + /> + ) + } + + const range = getDataTypeRange(entry.dataType, entry.bitLength) + const isFloat = ['REAL', 'REAL32', 'FLOAT', 'LREAL', 'REAL64', 'DOUBLE'].includes(entry.dataType.toUpperCase()) + const isNumeric = range !== null + + return ( + setLocalValue(e.target.value)} + onBlur={handleBlur} + min={isNumeric ? range?.min : undefined} + max={isNumeric ? range?.max : undefined} + className='h-[24px] w-full max-w-[120px] rounded border border-neutral-300 bg-white px-1.5 font-mono text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + /> + ) +} + +/** + * SDO Parameters Table Component + * + * Displays configurable SDO startup parameters grouped by parent CoE object. + * Each object is a collapsible section header; sub-items are shown as editable rows beneath. + */ +const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: SdoParametersTableProps) => { + const [searchTerm, setSearchTerm] = useState('') + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + + // Group entries by parent object index + const groups = useMemo(() => { + const map = new Map() + for (const entry of sdoConfigurations) { + const existing = map.get(entry.index) + if (existing) { + existing.entries.push(entry) + } else { + map.set(entry.index, { index: entry.index, objectName: entry.objectName, entries: [entry] }) + } + } + return Array.from(map.values()) + }, [sdoConfigurations]) + + // Filter groups and entries by search term + const filteredGroups = useMemo(() => { + if (!searchTerm) return groups + + const search = searchTerm.toLowerCase() + const result: SdoObjectGroup[] = [] + + for (const group of groups) { + // If group name/index matches, include entire group + if (group.objectName.toLowerCase().includes(search) || group.index.toLowerCase().includes(search)) { + result.push(group) + continue + } + + // Otherwise filter entries within the group + const matchedEntries = group.entries.filter( + (entry) => + entry.name.toLowerCase().includes(search) || + entry.dataType.toLowerCase().includes(search) || + entry.index.toLowerCase().includes(search), + ) + + if (matchedEntries.length > 0) { + result.push({ ...group, entries: matchedEntries }) + } + } + + return result + }, [groups, searchTerm]) + + const handleToggleGroup = useCallback((index: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + }, []) + + const handleExpandAll = useCallback(() => { + setCollapsedGroups(new Set()) + }, []) + + const handleCollapseAll = useCallback(() => { + setCollapsedGroups(new Set(groups.map((g) => g.index))) + }, [groups]) + + const handleValueChange = useCallback( + (index: string, subIndex: number, value: string) => { + const updated = sdoConfigurations.map((entry) => + entry.index === index && entry.subIndex === subIndex ? { ...entry, value } : entry, + ) + onUpdateSdoConfigurations(updated) + }, + [sdoConfigurations, onUpdateSdoConfigurations], + ) + + const handleResetAll = useCallback(() => { + const reset = sdoConfigurations.map((entry) => ({ ...entry, value: entry.defaultValue })) + onUpdateSdoConfigurations(reset) + }, [sdoConfigurations, onUpdateSdoConfigurations]) + + const hasModifiedValues = sdoConfigurations.some((entry) => entry.value !== entry.defaultValue) + const totalEntries = sdoConfigurations.length + + return ( +
+ {/* Toolbar */} +
+ setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> + +
+ + +
+ + {filteredGroups.length} object(s), {totalEntries} parameter(s) + {hasModifiedValues && ' — modified values highlighted'} + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {filteredGroups.length === 0 ? ( + + + + ) : ( + filteredGroups.map((group) => { + const isCollapsed = collapsedGroups.has(group.index) + const groupHasModified = group.entries.some((e) => e.value !== e.defaultValue) + + return ( + handleToggleGroup(group.index)} + onValueChange={handleValueChange} + /> + ) + }) + )} + +
+ Sub + + Name + + Type + + Bits + + Default + + Value +
+ {sdoConfigurations.length === 0 + ? 'No configurable SDO parameters found' + : 'No parameters match the current filter'} +
+
+
+ ) +} + +/** + * Renders a group header row + its sub-item rows (when expanded). + * Extracted as a component to avoid key issues with fragments in map. + */ +const GroupRows = ({ + group, + isCollapsed, + hasModified, + onToggle, + onValueChange, +}: { + group: SdoObjectGroup + isCollapsed: boolean + hasModified: boolean + onToggle: () => void + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + return ( + <> + {/* Group header row */} + + + + + +
+ {group.index} + {group.objectName} + + ({group.entries.length} param{group.entries.length !== 1 && 's'}) + + {hasModified && ( + + modified + + )} +
+ + + + {/* Sub-item rows */} + {!isCollapsed && + group.entries.map((entry) => { + const isModified = entry.value !== entry.defaultValue + return ( + + + {entry.subIndex} + + {entry.name} + + {entry.dataType} + {entry.bitLength} + + {entry.defaultValue || '-'} + + + + + + ) + })} + + ) +} + +export { SdoParametersTable } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx new file mode 100644 index 000000000..b761ce0c2 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -0,0 +1,605 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { useOpenPLCStore } from '@root/renderer/store' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + ScannedDeviceMatch, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' +import { createDefaultSlaveConfig } from '@root/utils/ethercat/device-config-defaults' +import { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from '@root/utils/ethercat/device-matcher' +import { enrichDeviceData } from '@root/utils/ethercat/enrich-device-data' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { DevicesTab } from './components/devices-tab' +import { DiagnosticsTab } from './components/diagnostics-tab' +import { GlobalSettingsTab } from './components/global-settings-tab' + +type EditorTab = 'global-settings' | 'diagnostics' | 'devices' + +const TabItem = ({ + value, + label, + isActive, + badge, +}: { + value: string + label: string + isActive: boolean + badge?: React.ReactNode +}) => ( + + {label} + {badge} + +) + +/** + * EtherCAT Device Editor + * + * Three-tab layout: + * - Global Settings: Master configuration (network interface, cycle time, watchdog) + * - Diagnostics: Runtime status monitoring and device discovery/scanning + * - Devices: ESI repository management and configured device editing + */ +const EtherCATEditor = () => { + const { editor, runtimeConnection, project, projectActions } = useOpenPLCStore() + + const deviceName = editor.type === 'plc-remote-device' ? editor.meta.name : '' + const projectPath = project.meta.path + + // Runtime connection state + const { connectionStatus, jwtToken, ipAddress } = runtimeConnection + const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null && jwtToken !== null + + // Tab state + const [activeTab, setActiveTab] = useState('devices') + + // Repository state + const [repository, setRepository] = useState([]) + const [isLoadingRepository, setIsLoadingRepository] = useState(false) + const [repositoryError, setRepositoryError] = useState(null) + const [repositoryLoadRetry, setRepositoryLoadRetry] = useState(0) + const repositoryLoadedRef = useRef(false) + + // Configured devices from Zustand store + const remoteDevice = useMemo(() => { + return project.data.remoteDevices?.find((d) => d.name === deviceName) + }, [project.data.remoteDevices, deviceName]) + + const configuredDevices = useMemo(() => { + return (remoteDevice?.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] + }, [remoteDevice]) + + // Collect all IEC addresses used across all remote devices (Modbus + EtherCAT) + const usedAddresses = useMemo(() => { + const addresses = new Set() + const allRemoteDevices = project.data.remoteDevices || [] + + for (const rd of allRemoteDevices) { + if (rd.modbusTcpConfig?.ioGroups) { + for (const group of rd.modbusTcpConfig.ioGroups) { + for (const point of group.ioPoints) { + addresses.add(point.iecLocation) + } + } + } + if (rd.ethercatConfig?.devices) { + for (const dev of rd.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + addresses.add(mapping.iecLocation) + } + } + } + } + return addresses + }, [project.data.remoteDevices]) + + const masterConfig = useMemo(() => { + return ( + remoteDevice?.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + } + ) + }, [remoteDevice]) + + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(deviceName, { masterConfig, devices }) + }, + [deviceName, projectActions, masterConfig], + ) + + const handleUpdateMasterConfig = useCallback( + (updates: Partial) => { + const newMasterConfig = { ...masterConfig, ...updates } + projectActions.updateEthercatConfig(deviceName, { + masterConfig: newMasterConfig, + devices: configuredDevices, + }) + }, + [deviceName, projectActions, masterConfig, configuredDevices], + ) + + // Network interfaces state + const [interfaces, setInterfaces] = useState([]) + const [selectedInterface, setSelectedInterface] = useState('') + const [isLoadingInterfaces, setIsLoadingInterfaces] = useState(false) + const [interfaceError, setInterfaceError] = useState(null) + + // EtherCAT service status + const [serviceAvailable, setServiceAvailable] = useState(null) + const [serviceMessage, setServiceMessage] = useState('') + + // Scan state + const [scannedDevices, setScannedDevices] = useState([]) + const [isScanning, setIsScanning] = useState(false) + const [scanError, setScanError] = useState(null) + const [scanMessage, setScanMessage] = useState('') + const [scanTimeMs, setScanTimeMs] = useState(null) + + // Discovery selection state + const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + + // Matched devices + const deviceMatches = useMemo(() => { + return matchDevicesToRepository(scannedDevices, repository) + }, [scannedDevices, repository]) + + const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) + + // Check EtherCAT service status + const checkServiceStatus = useCallback(async () => { + if (!isConnectedToRuntime || !ipAddress || !jwtToken) { + setServiceAvailable(null) + setServiceMessage('') + return + } + + try { + const result = await window.bridge.etherCATGetStatus(ipAddress, jwtToken) + if (result.success && result.data) { + setServiceAvailable(result.data.available) + setServiceMessage(result.data.message) + } else { + setServiceAvailable(false) + setServiceMessage(result.error || 'Failed to check service status') + } + } catch (error) { + setServiceAvailable(false) + setServiceMessage(String(error)) + } + }, [isConnectedToRuntime, ipAddress, jwtToken]) + + // Fetch network interfaces from runtime + const fetchInterfaces = useCallback(async () => { + if (!isConnectedToRuntime || !ipAddress || !jwtToken) { + setInterfaces([]) + setInterfaceError('Not connected to runtime') + return + } + + setIsLoadingInterfaces(true) + setInterfaceError(null) + + try { + const result = await window.bridge.etherCATGetInterfaces(ipAddress, jwtToken) + if (result.success && result.data) { + const fetchedInterfaces = result.data + setInterfaces(fetchedInterfaces) + const names = new Set(fetchedInterfaces.map((i) => i.name)) + if (fetchedInterfaces.length > 0) { + setSelectedInterface((prev) => (prev && names.has(prev) ? prev : fetchedInterfaces[0].name)) + } else { + setSelectedInterface('') + } + } else { + setInterfaces([]) + setInterfaceError(result.error || 'Failed to fetch interfaces') + } + } catch (error) { + setInterfaces([]) + setInterfaceError(String(error)) + } finally { + setIsLoadingInterfaces(false) + } + }, [isConnectedToRuntime, ipAddress, jwtToken]) + + // Scan for EtherCAT devices + const scanDevices = useCallback(async () => { + if (!isConnectedToRuntime || !ipAddress || !jwtToken || !selectedInterface) { + setScanError('Please select a network interface') + return + } + + setIsScanning(true) + setScanError(null) + setScanMessage('') + setScannedDevices([]) + setSelectedScannedDevices(new Set()) + setScanTimeMs(null) + + try { + const result = await window.bridge.etherCATScan(ipAddress, jwtToken, { + interface: selectedInterface, + timeout_ms: 5000, + }) + + if (result.success && result.data) { + setScannedDevices(result.data.devices) + setScanMessage(result.data.message) + setScanTimeMs(result.data.scan_time_ms) + + if (result.data.status !== 'success') { + setScanError(`Scan completed with status: ${result.data.status}`) + } + } else { + setScanError(result.error || 'Scan failed') + } + } catch (error) { + setScanError(String(error)) + } finally { + setIsScanning(false) + } + }, [isConnectedToRuntime, ipAddress, jwtToken, selectedInterface]) + + // Reset repository loaded flag when project changes + useEffect(() => { + repositoryLoadedRef.current = false + }, [projectPath]) + + // Load ESI repository + useEffect(() => { + let cancelled = false + + const loadRepository = async () => { + if (!projectPath || repositoryLoadedRef.current) return + + setIsLoadingRepository(true) + setRepositoryError(null) + + try { + const result = await window.bridge.esiLoadRepositoryLight(projectPath) + if (cancelled) return + + if (result.success && result.items) { + setRepository(result.items) + repositoryLoadedRef.current = true + } else if (result.needsMigration) { + // One-time migration from v1 to v2 + const migrationResult = await window.bridge.esiMigrateRepository(projectPath) + if (cancelled) return + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) + repositoryLoadedRef.current = true + } else { + setRepositoryError(migrationResult.error || 'Failed to migrate repository') + } + } else if (result.error) { + setRepositoryError(result.error) + } else { + repositoryLoadedRef.current = true + } + } catch (error) { + if (cancelled) return + console.error('Failed to load ESI repository:', error) + setRepositoryError(String(error)) + } finally { + if (!cancelled) setIsLoadingRepository(false) + } + } + + void loadRepository() + return () => { + cancelled = true + } + }, [projectPath, repositoryLoadRetry]) + + // Check service status and fetch interfaces when runtime connection changes + useEffect(() => { + if (isConnectedToRuntime) { + void checkServiceStatus() + void fetchInterfaces() + } else { + setServiceAvailable(null) + setInterfaces([]) + setScannedDevices([]) + setSelectedInterface('') + } + }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) + + // Initialize ethercatConfig in store if missing + useEffect(() => { + if (remoteDevice && !remoteDevice.ethercatConfig) { + projectActions.updateEthercatConfig(deviceName, { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: [], + }) + } + }, [remoteDevice, deviceName, projectActions]) + + // Discovery handlers + const handleSelectScannedDevice = useCallback((position: number, selected: boolean) => { + setSelectedScannedDevices((prev) => { + const next = new Set(prev) + if (selected) { + next.add(position) + } else { + next.delete(position) + } + return next + }) + }, []) + + const handleSelectAllScanned = useCallback( + (selected: boolean) => { + if (selected) { + const selectable = deviceMatches + .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') + .map((dm) => dm.device.position) + setSelectedScannedDevices(new Set(selectable)) + } else { + setSelectedScannedDevices(new Set()) + } + }, + [deviceMatches], + ) + + const handleAddSelectedFromScan = useCallback(async () => { + const newDevices: ConfiguredEtherCATDevice[] = [] + const existingPositions = new Set(configuredDevices.map((d) => d.position)) + + for (const position of selectedScannedDevices) { + // Skip devices already configured at this position + if (existingPositions.has(position)) continue + const match = deviceMatches.find((dm) => dm.device.position === position) + if (!match || match.matches.length === 0) continue + + // Use the best match (first one, which is sorted by quality) + const bestMatch = match.matches[0] + const repoItem = repository.find((r) => r.id === bestMatch.repositoryItemId) + if (!repoItem) continue + + let enriched = {} + const result = await window.bridge.esiLoadDeviceFull( + projectPath, + bestMatch.repositoryItemId, + bestMatch.deviceIndex, + ) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device) + } + + newDevices.push({ + id: uuidv4(), + position: match.device.position, + name: match.device.name, + esiDeviceRef: { + repositoryItemId: bestMatch.repositoryItemId, + deviceIndex: bestMatch.deviceIndex, + }, + vendorId: repoItem.vendor.id, + productCode: bestMatch.esiDevice.type.productCode, + revisionNo: bestMatch.esiDevice.type.revisionNo, + addedFrom: 'scan', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + }) + } + + if (newDevices.length > 0) { + syncDevicesToStore([...configuredDevices, ...newDevices]) + setSelectedScannedDevices(new Set()) + setActiveTab('devices') + } + }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) + + // Device management handlers + const handleAddDeviceFromBrowser = useCallback( + async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + let enriched = {} + const result = await window.bridge.esiLoadDeviceFull(projectPath, ref.repositoryItemId, ref.deviceIndex) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device) + } + + const nextPosition = + configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? -1)) + 1 : 0 + + const newDevice: ConfiguredEtherCATDevice = { + id: uuidv4(), + position: nextPosition, + name: device.name, + esiDeviceRef: ref, + vendorId: repoItem.vendor.id, + productCode: device.type.productCode, + revisionNo: device.type.revisionNo, + addedFrom: 'repository', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + } + syncDevicesToStore([...configuredDevices, newDevice]) + }, + [configuredDevices, syncDevicesToStore, projectPath], + ) + + const handleRemoveDevice = useCallback( + (deviceId: string) => { + syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateDevice = useCallback( + (deviceId: string, config: EtherCATSlaveConfig) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateChannelMappings = useCallback( + (deviceId: string, channelMappings: EtherCATChannelMapping[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleEnrichDevice = useCallback( + (deviceId: string, data: Partial) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateSdoConfigurations = useCallback( + (deviceId: string, sdoConfigurations: SDOConfigurationEntry[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleRetryRepository = useCallback(() => { + setRepositoryError(null) + repositoryLoadedRef.current = false + setRepositoryLoadRetry((c) => c + 1) + }, []) + + return ( +
+ {/* Header */} +
+

EtherCAT Device: {deviceName}

+

Protocol: EtherCAT

+
+ + {/* Tabs */} + setActiveTab(v as EditorTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + 0 ? ( + + {scannedDevices.length} + + ) : undefined + } + /> + 0 ? ( + + {configuredDevices.length} + + ) : undefined + } + /> + + + {/* Global Settings Tab */} + + void fetchInterfaces()} + /> + + + {/* Diagnostics Tab */} + + void fetchInterfaces()} + isScanning={isScanning} + scanError={scanError} + scanTimeMs={scanTimeMs} + scanMessage={scanMessage} + scannedDevices={scannedDevices} + onScan={() => void scanDevices()} + deviceMatches={deviceMatches} + matchCounts={matchCounts} + selectedScannedDevices={selectedScannedDevices} + onSelectScannedDevice={handleSelectScannedDevice} + onSelectAllScanned={handleSelectAllScanned} + onAddSelectedFromScan={() => void handleAddSelectedFromScan()} + /> + + + {/* Devices Tab */} + + + + +
+ ) +} + +export { EtherCATEditor } diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index d87094af3..7b08fae1d 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -1,3 +1,4 @@ export * from './use-compiler' export * from './use-debug-composite-key' +export * from './use-device-configuration' export * from './use-store-selectors' diff --git a/src/renderer/hooks/use-device-configuration.ts b/src/renderer/hooks/use-device-configuration.ts new file mode 100644 index 000000000..64190bee1 --- /dev/null +++ b/src/renderer/hooks/use-device-configuration.ts @@ -0,0 +1,131 @@ +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, +} from '@root/types/ethercat/esi-types' +import { enrichDeviceData } from '@root/utils/ethercat/enrich-device-data' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/utils/ethercat/esi-parser' +import { extractDefaultSdoConfigurations } from '@root/utils/ethercat/sdo-config-defaults' +import { useCallback, useEffect, useRef, useState } from 'react' + +type UseDeviceConfigurationParams = { + device: ConfiguredEtherCATDevice + projectPath: string + externalAddresses: Set + onUpdateDevice: (config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + enabled?: boolean +} + +type UseDeviceConfigurationResult = { + channels: ESIChannel[] + coeObjects: ESICoEObject[] | undefined + isLoadingChannels: boolean + channelLoadError: string | null + handleAliasChange: (channelId: string, alias: string) => void + updateConfig: (section: K, updates: Partial) => void +} + +export function useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + enabled = true, +}: UseDeviceConfigurationParams): UseDeviceConfigurationResult { + const [channels, setChannels] = useState([]) + const [coeObjects, setCoeObjects] = useState(undefined) + const [isLoadingChannels, setIsLoadingChannels] = useState(false) + const [channelLoadError, setChannelLoadError] = useState(null) + const fullDeviceLoadedRef = useRef(false) + + // Capture latest callback refs to avoid stale closures and unstable deps + const onUpdateDeviceRef = useRef(onUpdateDevice) + onUpdateDeviceRef.current = onUpdateDevice + const onUpdateChannelMappingsRef = useRef(onUpdateChannelMappings) + onUpdateChannelMappingsRef.current = onUpdateChannelMappings + const onEnrichDeviceRef = useRef(onEnrichDevice) + onEnrichDeviceRef.current = onEnrichDevice + + useEffect(() => { + if (!enabled || fullDeviceLoadedRef.current) return + + const loadFullDevice = async () => { + setIsLoadingChannels(true) + setChannelLoadError(null) + + try { + const result = await window.bridge.esiLoadDeviceFull( + projectPath, + device.esiDeviceRef.repositoryItemId, + device.esiDeviceRef.deviceIndex, + ) + + if (result.success && result.device) { + const deviceChannels = pdoToChannels(result.device) + setChannels(deviceChannels) + setCoeObjects(result.device.coeObjects) + fullDeviceLoadedRef.current = true + + if (device.channelMappings.length === 0 && deviceChannels.length > 0) { + onUpdateChannelMappingsRef.current(generateDefaultChannelMappings(deviceChannels, externalAddresses)) + } + + if (!device.channelInfo || !device.rxPdos || !device.txPdos) { + const { sdoConfigurations, ...rest } = enrichDeviceData(result.device) + onEnrichDeviceRef.current(device.sdoConfigurations !== undefined ? rest : { ...rest, sdoConfigurations }) + } else if (device.sdoConfigurations === undefined && result.device.coeObjects?.length) { + onEnrichDeviceRef.current({ + channelInfo: device.channelInfo, + rxPdos: device.rxPdos, + txPdos: device.txPdos, + slaveType: device.slaveType ?? '', + sdoConfigurations: extractDefaultSdoConfigurations(result.device.coeObjects), + }) + } + } else { + setChannelLoadError(result.error || 'Failed to load device data') + } + } catch (error) { + setChannelLoadError(String(error)) + } finally { + setIsLoadingChannels(false) + } + } + + void loadFullDevice() + }, [enabled, projectPath, device.esiDeviceRef.repositoryItemId, device.esiDeviceRef.deviceIndex]) + + const handleAliasChange = useCallback( + (channelId: string, alias: string) => { + const updated = device.channelMappings.map((m) => (m.channelId === channelId ? { ...m, alias } : m)) + onUpdateChannelMappingsRef.current(updated) + }, + [device.channelMappings], + ) + + const updateConfig = useCallback( + (section: K, updates: Partial) => { + onUpdateDeviceRef.current({ + ...device.config, + [section]: { ...device.config[section], ...updates }, + }) + }, + [device.config], + ) + + return { + channels, + coeObjects, + isLoadingChannels, + channelLoadError, + handleAliasChange, + updateConfig, + } +} diff --git a/src/renderer/hooks/use-store-selectors.ts b/src/renderer/hooks/use-store-selectors.ts index 991d35d1a..cb0ab3328 100644 --- a/src/renderer/hooks/use-store-selectors.ts +++ b/src/renderer/hooks/use-store-selectors.ts @@ -92,18 +92,37 @@ const remoteDeviceSelectors = { const ioPoints: RemoteDeviceIOPoint[] = [] for (const device of remoteDevices) { - if (!device.modbusTcpConfig?.ioGroups) continue - for (const ioGroup of device.modbusTcpConfig.ioGroups) { - for (const point of ioGroup.ioPoints) { - ioPoints.push({ - deviceName: device.name, - ioGroupName: ioGroup.name, - ioPointId: point.id, - ioPointName: point.name, - ioPointType: point.type, - iecLocation: point.iecLocation, - alias: point.alias, - }) + // Modbus IO points + if (device.modbusTcpConfig?.ioGroups) { + for (const ioGroup of device.modbusTcpConfig.ioGroups) { + for (const point of ioGroup.ioPoints) { + ioPoints.push({ + deviceName: device.name, + ioGroupName: ioGroup.name, + ioPointId: point.id, + ioPointName: point.name, + ioPointType: point.type, + iecLocation: point.iecLocation, + alias: point.alias, + }) + } + } + } + // EtherCAT channel mappings + if (device.ethercatConfig?.devices) { + for (const dev of device.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + if (!mapping.alias) continue + ioPoints.push({ + deviceName: device.name, + ioGroupName: dev.name, + ioPointId: mapping.channelId, + ioPointName: mapping.channelId, + ioPointType: 'ethercat', + iecLocation: mapping.iecLocation, + alias: mapping.alias, + }) + } } } } diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index abcce7e8c..f33760aa2 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -21,6 +21,7 @@ import { ImperativePanelHandle } from 'react-resizable-panels' import { ExitIcon } from '../assets' import { DataTypeEditor, MonacoEditor } from '../components/_features/[workspace]/editor' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' +import { EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' import { GraphicalEditor } from '../components/_features/[workspace]/editor/graphical' import { ResourcesEditor } from '../components/_features/[workspace]/editor/resource-editor' @@ -1827,7 +1828,12 @@ const WorkspaceScreen = () => { )} {editor['type'] === 'plc-server' && editor.meta.protocol === 's7comm' && } {editor['type'] === 'plc-server' && editor.meta.protocol === 'opcua' && } - {editor['type'] === 'plc-remote-device' && } + {editor['type'] === 'plc-remote-device' && editor.meta.protocol === 'ethercat' && ( + + )} + {editor['type'] === 'plc-remote-device' && editor.meta.protocol !== 'ethercat' && ( + + )} {(editor['type'] === 'plc-textual' || editor['type'] === 'plc-graphical') && ( = (se return response }, + updateEthercatConfig: (deviceName: string, ethercatConfig: EthercatConfig): ProjectResponse => { + let response: ProjectResponse = { ok: true } + setState( + produce(({ project }: ProjectSlice) => { + if (!project.data.remoteDevices) { + response = { ok: false, message: 'No remote devices found' } + return + } + const device = project.data.remoteDevices.find((d) => d.name === deviceName) + if (!device) { + response = { ok: false, message: 'Remote device not found' } + return + } + if (device.protocol !== 'ethercat') { + response = { ok: false, message: 'Device is not an EtherCAT device' } + return + } + device.ethercatConfig = ethercatConfig + }), + ) + return response + }, + addIOGroup: ( deviceName: string, ioGroup: { @@ -2308,6 +2332,13 @@ const createProjectSlice: StateCreator = (se } } } + if (remoteDevice.ethercatConfig?.devices) { + for (const dev of remoteDevice.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + usedAddresses.add(mapping.iecLocation) + } + } + } } const ioPoints = generateIOPoints(ioGroup.functionCode, ioGroup.length, ioGroup.name, usedAddresses) modbusCfg.ioGroups.push({ @@ -2365,6 +2396,13 @@ const createProjectSlice: StateCreator = (se } } } + if (remoteDevice.ethercatConfig?.devices) { + for (const dev of remoteDevice.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + usedAddresses.add(mapping.iecLocation) + } + } + } } ioGroup.ioPoints = generateIOPoints(ioGroup.functionCode, ioGroup.length, ioGroup.name, usedAddresses) } diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts index b38f58088..b4df84c9d 100644 --- a/src/renderer/store/slices/project/types.ts +++ b/src/renderer/store/slices/project/types.ts @@ -1,5 +1,6 @@ import { bodySchema, + EthercatConfigSchema, OpcUaNodeConfigSchema, PLCDataTypeSchema, PLCFunctionBlockSchema, @@ -556,6 +557,7 @@ const _projectActionsSchema = z.object({ .returns(projectResponseSchema), deleteIOGroup: z.function().args(z.string(), z.string()).returns(projectResponseSchema), updateIOPointAlias: z.function().args(z.string(), z.string(), z.string(), z.string()).returns(projectResponseSchema), + updateEthercatConfig: z.function().args(z.string(), EthercatConfigSchema).returns(projectResponseSchema), }) type ProjectActions = z.infer diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index 2f4cdd4bc..478a61cd0 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -610,10 +610,134 @@ type ModbusTcpConfig = z.infer const PLCRemoteDeviceProtocolSchema = z.enum(['modbus-tcp', 'ethernet-ip', 'ethercat', 'profinet']) type PLCRemoteDeviceProtocol = z.infer +// ---- EtherCAT Configuration Schemas ---- + +const EtherCATChannelMappingSchema = z.object({ + channelId: z.string(), + iecLocation: z.string(), + userEdited: z.boolean(), + alias: z.string().optional(), +}) + +const ESIDeviceRefSchema = z.object({ + repositoryItemId: z.string(), + deviceIndex: z.number(), +}) + +const EtherCATStartupChecksSchema = z.object({ + checkVendorId: z.boolean(), + checkProductCode: z.boolean(), +}) + +const EtherCATAddressingSchema = z.object({ + ethercatAddress: z.number().int().min(0).max(65535), +}) + +const EtherCATTimeoutsSchema = z.object({ + sdoTimeoutMs: z.number().int().min(0), + initToPreOpTimeoutMs: z.number().int().min(0), + safeOpToOpTimeoutMs: z.number().int().min(0), +}) + +const EtherCATWatchdogSchema = z.object({ + smWatchdogEnabled: z.boolean(), + smWatchdogMs: z.number().int().min(0), + pdiWatchdogEnabled: z.boolean(), + pdiWatchdogMs: z.number().int().min(0), +}) + +const EtherCATDistributedClocksSchema = z.object({ + dcEnabled: z.boolean(), + dcSyncUnitCycleUs: z.number().int().min(0), + dcSync0Enabled: z.boolean(), + dcSync0CycleUs: z.number().int().min(0), + dcSync0ShiftUs: z.number().int().min(0), + dcSync1Enabled: z.boolean(), + dcSync1CycleUs: z.number().int().min(0), + dcSync1ShiftUs: z.number().int().min(0), +}) + +const EtherCATSlaveConfigSchema = z.object({ + startupChecks: EtherCATStartupChecksSchema, + addressing: EtherCATAddressingSchema, + timeouts: EtherCATTimeoutsSchema, + watchdog: EtherCATWatchdogSchema, + distributedClocks: EtherCATDistributedClocksSchema, +}) + +const PersistedPdoEntrySchema = z.object({ + index: z.string(), + subIndex: z.string(), + bitLen: z.number(), + name: z.string(), + dataType: z.string(), +}) + +const PersistedPdoSchema = z.object({ + index: z.string(), + name: z.string(), + entries: z.array(PersistedPdoEntrySchema), +}) + +const PersistedChannelInfoSchema = z.object({ + channelId: z.string(), + name: z.string(), + direction: z.enum(['input', 'output']), + pdoIndex: z.string(), + entryIndex: z.string(), + entrySubIndex: z.string(), + dataType: z.string(), + bitLen: z.number(), + iecType: z.string(), +}) + +const SDOConfigurationEntrySchema = z.object({ + index: z.string(), + subIndex: z.number(), + value: z.string(), + defaultValue: z.string(), + dataType: z.string(), + bitLength: z.number(), + name: z.string(), + objectName: z.string(), +}) + +const ConfiguredEtherCATDeviceSchema = z.object({ + id: z.string(), + position: z.number().optional(), + name: z.string(), + esiDeviceRef: ESIDeviceRefSchema, + vendorId: z.string(), + productCode: z.string(), + revisionNo: z.string(), + addedFrom: z.enum(['repository', 'scan']), + config: EtherCATSlaveConfigSchema, + channelMappings: z.array(EtherCATChannelMappingSchema), + channelInfo: z.array(PersistedChannelInfoSchema).optional(), + rxPdos: z.array(PersistedPdoSchema).optional(), + txPdos: z.array(PersistedPdoSchema).optional(), + slaveType: z.string().optional(), + sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), +}) + +const EtherCATMasterConfigSchema = z.object({ + networkInterface: z.string(), + cycleTimeUs: z.number().int().min(100).max(100000), + watchdogTimeoutCycles: z.number().int().min(1).max(100).optional(), +}) +type EtherCATMasterConfig = z.infer + +const EthercatConfigSchema = z.object({ + masterConfig: EtherCATMasterConfigSchema.optional(), + devices: z.array(ConfiguredEtherCATDeviceSchema), +}) +type EthercatConfig = z.infer + const PLCRemoteDeviceSchema = z.object({ name: z.string(), protocol: PLCRemoteDeviceProtocolSchema, modbusTcpConfig: ModbusTcpConfigSchema.optional(), + ethercatConfig: EthercatConfigSchema.optional(), }) type PLCRemoteDevice = z.infer @@ -683,6 +807,9 @@ type PLCProject = z.infer export { baseTypeSchema, bodySchema, + ConfiguredEtherCATDeviceSchema, + EthercatConfigSchema, + EtherCATMasterConfigSchema, ModbusErrorHandlingSchema, ModbusFunctionCodeSchema, ModbusIOGroupSchema, @@ -737,11 +864,14 @@ export { S7CommSlaveConfigSchema, S7CommSystemAreaSchema, S7CommSystemAreasSchema, + SDOConfigurationEntrySchema, } export type { BaseType, BodySchema, + EthercatConfig, + EtherCATMasterConfig, ModbusErrorHandling, ModbusFunctionCode, ModbusIOGroup, diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts new file mode 100644 index 000000000..4ec1f0f30 --- /dev/null +++ b/src/types/ethercat/esi-types.ts @@ -0,0 +1,626 @@ +/** + * EtherCAT Slave Information (ESI) Types + * + * Types for parsing and representing ESI XML files following ETG.2000 specification. + * ESI files describe EtherCAT slave device properties, PDO mappings, and communication settings. + */ + +// ===================== VENDOR ===================== + +/** + * Vendor information from ESI file + */ +export interface ESIVendor { + /** Vendor ID (hex format, e.g., "0x0002" for Beckhoff) */ + id: string + /** Vendor name */ + name: string +} + +// ===================== DEVICE INFO ===================== + +/** + * Device type information + */ +export interface ESIDeviceType { + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** Type name/description */ + name: string +} + +/** + * Sync Manager configuration + */ +export interface ESISyncManager { + /** SM index (0-3 typically) */ + index: number + /** Start address */ + startAddress: string + /** Control byte */ + controlByte: string + /** Default size */ + defaultSize: number + /** Enable flag */ + enable: boolean + /** SM type: Mailbox Out, Mailbox In, Process Data Out, Process Data In */ + type: 'MbxOut' | 'MbxIn' | 'Outputs' | 'Inputs' +} + +/** + * FMMU (Fieldbus Memory Management Unit) configuration + */ +export interface ESIFMMU { + /** FMMU type: Outputs, Inputs, MbxState */ + type: 'Outputs' | 'Inputs' | 'MbxState' +} + +// ===================== PDO ENTRIES ===================== + +/** + * EtherCAT data types used in PDO entries + * Common types: BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, + * REAL, LREAL, STRING, BYTE, WORD, DWORD, BIT, BIT2-BIT7 + * Using string to allow vendor-specific custom types + */ +export type ESIDataType = string + +/** + * PDO Entry - represents a single variable in a PDO + */ +export interface ESIPdoEntry { + /** Entry index (hex, e.g., "#x6000") */ + index: string + /** Entry subindex (hex, e.g., "#x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name/identifier */ + name: string + /** Data type */ + dataType: ESIDataType + /** Optional: Comment/description */ + comment?: string +} + +/** + * Process Data Object - TxPdo (slave to master) or RxPdo (master to slave) + */ +export interface ESIPdo { + /** PDO index (hex, e.g., "#x1600" for RxPdo, "#x1A00" for TxPdo) */ + index: string + /** PDO name */ + name: string + /** Whether this PDO is fixed (cannot be modified) */ + fixed: boolean + /** Whether this PDO is mandatory */ + mandatory: boolean + /** SM index this PDO is assigned to */ + smIndex?: number + /** List of entries in this PDO */ + entries: ESIPdoEntry[] +} + +// ===================== COE (CANopen over EtherCAT) ===================== + +/** + * CoE Object Dictionary entry + */ +export interface ESICoEObject { + /** Object index (hex) */ + index: string + /** Object name */ + name: string + /** Object type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** PDO mapping allowed */ + pdoMapping: boolean + /** Object category: M=Mandatory, O=Optional, C=Conditional */ + category?: 'M' | 'O' | 'C' + /** PDO mapping direction: R=RxPDO, T=TxPDO, RT=both */ + pdoMappingDirection?: 'R' | 'T' | 'RT' + /** Default value */ + defaultValue?: string + /** Subindexes for complex objects */ + subItems?: ESICoESubItem[] +} + +/** + * CoE Object subitem (for array/record types) + */ +export interface ESICoESubItem { + /** Subindex */ + subIndex: string + /** Name */ + name: string + /** Data type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** Whether this sub-item can be PDO-mapped */ + pdoMapping?: boolean + /** Default value */ + defaultValue?: string +} + +// ===================== SDO CONFIGURATION ===================== + +/** + * SDO (Service Data Object) configuration entry for startup parameters. + * Each entry represents a single parameter to be written to the slave at startup. + */ +export interface SDOConfigurationEntry { + /** Object index (hex, e.g., "0x8000") */ + index: string + /** Subindex: 0 for simple objects, 1+ for sub-items */ + subIndex: number + /** Value configured by the user */ + value: string + /** Default value from the ESI */ + defaultValue: string + /** Data type (e.g., "UINT16", "BOOL") */ + dataType: string + /** Bit length of the parameter */ + bitLength: number + /** Parameter name */ + name: string + /** Parent object name */ + objectName: string +} + +// ===================== DEVICE ENRICHMENT ===================== + +/** + * Data extracted from a full ESIDevice for persistence into ConfiguredEtherCATDevice. + * Returned by enrichDeviceData() and used by device configuration components. + */ +export type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== DEVICE ===================== + +/** + * Complete ESI Device representation + */ +export interface ESIDevice { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** FMMU configurations */ + fmmu: ESIFMMU[] + /** Sync Manager configurations */ + syncManagers: ESISyncManager[] + /** RxPDOs (master to slave) */ + rxPdo: ESIPdo[] + /** TxPDOs (slave to master) */ + txPdo: ESIPdo[] + /** CoE objects (optional) */ + coeObjects?: ESICoEObject[] + /** Device image URL (optional) */ + imageUrl?: string + /** Additional description */ + description?: string +} + +// ===================== GROUP ===================== + +/** + * Device group/category + */ +export interface ESIGroup { + /** Group type identifier */ + type: string + /** Group name */ + name: string + /** Group image URL (optional) */ + imageUrl?: string + /** Group description */ + description?: string +} + +// ===================== COMPLETE ESI FILE ===================== + +/** + * Complete ESI file representation + */ +export interface ESIFile { + /** Vendor information */ + vendor: ESIVendor + /** Device groups */ + groups: ESIGroup[] + /** Devices in the file */ + devices: ESIDevice[] + /** Original filename */ + filename?: string + /** File version info */ + version?: string + /** Creation/modification info */ + infoData?: { + version?: string + creationDate?: string + modificationDate?: string + vendorUrl?: string + } +} + +// ===================== PARSED CHANNEL (for UI) ===================== + +/** + * Represents a channel that can be mapped to a located variable + * This is a flattened view of PDO entries for easier UI handling + */ +export interface ESIChannel { + /** Unique identifier for this channel */ + id: string + /** PDO type: input (TxPdo) or output (RxPdo) */ + direction: 'input' | 'output' + /** Parent PDO index */ + pdoIndex: string + /** Parent PDO name */ + pdoName: string + /** Entry index */ + entryIndex: string + /** Entry subindex */ + entrySubIndex: string + /** Channel name */ + name: string + /** Data type */ + dataType: ESIDataType + /** Bit length */ + bitLen: number + /** Bit offset within the PDO */ + bitOffset: number + /** Byte offset (calculated) */ + byteOffset: number + /** IEC 61131-3 compatible type */ + iecType: string + /** Whether this channel is selected for mapping */ + selected?: boolean + /** Mapped variable name (if assigned) */ + mappedVariable?: string +} + +// ===================== PERSISTED PDO/CHANNEL DATA ===================== + +/** + * Persisted PDO entry - stored in project.json for runtime config generation. + * Includes padding entries (index "0x0000") for complete PDO layout. + */ +export interface PersistedPdoEntry { + /** Entry index (hex, e.g., "0x6000") */ + index: string + /** Entry subindex (hex, e.g., "0x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name */ + name: string + /** Data type (e.g., "BOOL", "INT16", "BIT" for padding) */ + dataType: string +} + +/** + * Persisted PDO - stored in project.json for runtime config generation. + */ +export interface PersistedPdo { + /** PDO index (hex, e.g., "0x1A00") */ + index: string + /** PDO name */ + name: string + /** PDO entries including padding */ + entries: PersistedPdoEntry[] +} + +/** + * Persisted channel info with full metadata from ESI. + * Enriches the minimal channelId stored in EtherCATChannelMapping. + */ +export interface PersistedChannelInfo { + /** Unique channel ID matching ESIChannel.id format */ + channelId: string + /** Channel display name from ESI */ + name: string + /** Channel direction */ + direction: 'input' | 'output' + /** Parent PDO index (hex) */ + pdoIndex: string + /** Entry index (hex) */ + entryIndex: string + /** Entry subindex (hex) */ + entrySubIndex: string + /** ESI data type */ + dataType: string + /** Bit length */ + bitLen: number + /** IEC 61131-3 compatible type */ + iecType: string +} + +// ===================== CHANNEL MAPPING ===================== + +/** + * Mapping of an ESI channel to an IEC 61131-3 located variable address + */ +export interface EtherCATChannelMapping { + /** Matches ESIChannel.id */ + channelId: string + /** IEC 61131-3 located variable address (e.g., "%IX0.0", "%QW2") */ + iecLocation: string + /** True if the user manually edited this address */ + userEdited: boolean + /** User-editable alias for this channel mapping */ + alias?: string +} + +// ===================== PARSE RESULT ===================== + +/** + * Result of parsing an ESI file + */ +export interface ESIParseResult { + success: boolean + data?: ESIFile + error?: string + warnings?: string[] +} + +// ===================== DEVICE SUMMARY (lightweight) ===================== + +/** + * Lightweight device metadata without PDOs/SM/FMMU. + * Used for repository listing and device matching without full parsing. + */ +export interface ESIDeviceSummary { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** Pre-computed count of non-padding TxPDO entries */ + inputChannelCount: number + /** Pre-computed count of non-padding RxPDO entries */ + outputChannelCount: number + /** Pre-computed total input bytes */ + totalInputBytes: number + /** Pre-computed total output bytes */ + totalOutputBytes: number + /** Additional description */ + description?: string +} + +// ===================== REPOSITORY ===================== + +/** + * Item in the ESI repository (a loaded ESI file) + */ +export interface ESIRepositoryItem { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Devices contained in this file */ + devices: ESIDevice[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +/** + * Lightweight repository item with device summaries instead of full ESIDevice objects. + * Used for UI display and matching without loading full PDO data. + */ +export interface ESIRepositoryItemLight { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Lightweight device summaries */ + devices: ESIDeviceSummary[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +// ===================== CONFIGURED DEVICES ===================== + +/** + * Reference to an ESI device in the repository + */ +export interface ESIDeviceRef { + /** ID of the repository item containing the device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number +} + +/** + * A configured EtherCAT device in the project + */ +export interface ConfiguredEtherCATDevice { + /** Unique identifier */ + id: string + /** Position in the EtherCAT network (from scan or manual assignment) */ + position?: number + /** User-editable name for this device */ + name: string + /** Reference to the ESI device definition */ + esiDeviceRef: ESIDeviceRef + /** Vendor ID (hex format) */ + vendorId: string + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** How this device was added */ + addedFrom: 'repository' | 'scan' + /** Per-slave configuration settings */ + config: EtherCATSlaveConfig + /** Channel-to-located-variable mappings */ + channelMappings: EtherCATChannelMapping[] + /** Enriched channel metadata from ESI (persisted for runtime config generation) */ + channelInfo?: PersistedChannelInfo[] + /** RxPDOs with full layout including padding (persisted for runtime config generation) */ + rxPdos?: PersistedPdo[] + /** TxPDOs with full layout including padding (persisted for runtime config generation) */ + txPdos?: PersistedPdo[] + /** Slave device type classification (e.g., "digital_input", "coupler") */ + slaveType?: string + /** SDO startup parameters extracted from CoE Object Dictionary */ + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== PER-SLAVE CONFIGURATION ===================== + +/** + * Startup identity checks for an EtherCAT slave. + * When enabled, the master verifies the slave's identity during startup. + */ +export interface EtherCATStartupChecks { + /** Verify slave vendor ID matches ESI definition */ + checkVendorId: boolean + /** Verify slave product code matches ESI definition */ + checkProductCode: boolean +} + +/** + * Addressing configuration for an EtherCAT slave. + */ +export interface EtherCATAddressing { + /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ + ethercatAddress: number +} + +/** + * Timeout settings for an EtherCAT slave. + */ +export interface EtherCATTimeouts { + /** SDO (Service Data Object) operation timeout in milliseconds */ + sdoTimeoutMs: number + /** Init to Pre-Operational state transition timeout in milliseconds */ + initToPreOpTimeoutMs: number + /** Pre-Op to Safe-Op and Safe-Op to Operational transition timeout in milliseconds */ + safeOpToOpTimeoutMs: number +} + +/** + * Watchdog settings for an EtherCAT slave. + */ +export interface EtherCATWatchdog { + /** Enable Sync Manager watchdog */ + smWatchdogEnabled: boolean + /** Sync Manager watchdog time in milliseconds */ + smWatchdogMs: number + /** Enable Process Data Interface (PDI) watchdog */ + pdiWatchdogEnabled: boolean + /** PDI watchdog time in milliseconds */ + pdiWatchdogMs: number +} + +/** + * Distributed Clocks (DC) settings for an EtherCAT slave. + * DC provides synchronized timing across all slaves in the network. + */ +export interface EtherCATDistributedClocks { + /** Enable Distributed Clocks for this slave */ + dcEnabled: boolean + /** Base sync unit cycle time in microseconds. 0 = use master cycle time */ + dcSyncUnitCycleUs: number + /** Enable SYNC0 pulse generation */ + dcSync0Enabled: boolean + /** SYNC0 cycle time in microseconds. 0 = use master cycle time */ + dcSync0CycleUs: number + /** SYNC0 shift/offset time in microseconds */ + dcSync0ShiftUs: number + /** Enable SYNC1 pulse generation */ + dcSync1Enabled: boolean + /** SYNC1 cycle time in microseconds. 0 = use master cycle time */ + dcSync1CycleUs: number + /** SYNC1 shift/offset time in microseconds */ + dcSync1ShiftUs: number +} + +/** + * Complete per-slave configuration for a configured EtherCAT device. + */ +export interface EtherCATSlaveConfig { + /** Identity verification during startup */ + startupChecks: EtherCATStartupChecks + /** Network addressing */ + addressing: EtherCATAddressing + /** Communication timeouts */ + timeouts: EtherCATTimeouts + /** Watchdog settings */ + watchdog: EtherCATWatchdog + /** Distributed Clocks (DC) settings */ + distributedClocks: EtherCATDistributedClocks +} + +// ===================== DEVICE MATCHING ===================== + +/** + * Quality of match between a scanned device and ESI device + */ +export type DeviceMatchQuality = 'exact' | 'partial' | 'none' + +/** + * A potential match for a scanned device + */ +export interface DeviceMatch { + /** ID of the repository item containing the matched device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number + /** Quality of the match */ + matchQuality: DeviceMatchQuality + /** The matched ESI device (lightweight summary) */ + esiDevice: ESIDeviceSummary +} + +/** + * A scanned device with its potential matches from the repository + */ +export interface ScannedDeviceMatch { + /** The scanned device from network discovery */ + device: { + position: number + name: string + vendor_id: number + product_code: number + revision: number + serial_number: number + state: string + input_bytes: number + output_bytes: number + } + /** List of potential matches from the repository */ + matches: DeviceMatch[] + /** The match selected by the user for addition */ + selectedMatch?: ESIDeviceRef +} diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts new file mode 100644 index 000000000..bd395e7fd --- /dev/null +++ b/src/types/ethercat/index.ts @@ -0,0 +1,313 @@ +/** + * EtherCAT Discovery Service Types + * + * Types for communication with the OpenPLC Runtime EtherCAT discovery endpoints. + * Based on the runtime's /api/discovery/* REST API. + */ + +// Re-export ESI types +export * from './esi-types' + +// ===================== ENUMS ===================== + +/** + * Status codes returned by the discovery service + */ +export type EtherCATDiscoveryStatus = + | 'success' + | 'error' + | 'timeout' + | 'permission_denied' + | 'interface_not_found' + | 'not_available' + +/** + * EtherCAT slave states as reported by the discovery service + */ +export type EtherCATSlaveState = 'NONE' | 'INIT' | 'PRE-OP' | 'BOOT' | 'SAFE-OP' | 'OP' | 'UNKNOWN' + +// ===================== NETWORK INTERFACES ===================== + +/** + * Represents a network interface available for EtherCAT communication + */ +export interface NetworkInterface { + /** Interface name (e.g., "eth0", "enp3s0") */ + name: string + /** Human-readable description of the interface */ + description: string +} + +/** + * Response from GET /api/discovery/interfaces + */ +export interface NetworkInterfacesResponse { + status: 'success' | 'error' + interfaces: NetworkInterface[] + message?: string +} + +// ===================== ETHERCAT DEVICE ===================== + +/** + * Represents an EtherCAT slave device discovered on the network + */ +export interface EtherCATDevice { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name (e.g., "EK1100", "EL1008") */ + name: string + /** Vendor ID (e.g., 2 for Beckhoff) */ + vendor_id: number + /** Product code identifying the device type */ + product_code: number + /** Hardware revision number */ + revision: number + /** Serial number (0 if not available) */ + serial_number: number + /** Configured station address */ + config_address: number + /** Alias address (0 if not set) */ + alias: number + /** Current EtherCAT state */ + state: EtherCATSlaveState + /** AL (Application Layer) status code */ + al_status_code: number + /** Whether the device supports CoE (CANopen over EtherCAT) */ + has_coe: boolean + /** Number of input bytes */ + input_bytes: number + /** Number of output bytes */ + output_bytes: number +} + +// ===================== SERVICE STATUS ===================== + +/** + * Response from GET /api/discovery/ethercat/status + */ +export interface EtherCATServiceStatusResponse { + /** Whether the EtherCAT discovery service is available */ + available: boolean + /** Status message */ + message: string +} + +// ===================== SCAN ===================== + +/** + * Request body for POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanRequest { + /** Network interface to scan (e.g., "eth0") */ + interface: string + /** Scan timeout in milliseconds (default: 5000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanResponse { + status: EtherCATDiscoveryStatus + /** List of discovered EtherCAT devices */ + devices: EtherCATDevice[] + /** Human-readable result message */ + message: string + /** Time taken to complete the scan in milliseconds */ + scan_time_ms: number + /** Interface that was scanned */ + interface: string +} + +// ===================== CONNECTION TEST ===================== + +/** + * Request body for POST /api/discovery/ethercat/test + */ +export interface EtherCATTestRequest { + /** Network interface to use */ + interface: string + /** Position of the slave to test (1-indexed) */ + position: number + /** Connection test timeout in milliseconds (default: 3000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/test + */ +export interface EtherCATTestResponse { + status: EtherCATDiscoveryStatus + /** Whether the connection was successful */ + connected: boolean + /** Device information if connected */ + device?: EtherCATDevice + /** Human-readable result message */ + message: string + /** Response time in milliseconds */ + response_time_ms: number +} + +// ===================== VALIDATION ===================== + +/** + * PDO mapping entry for validation + */ +export interface PDOMappingEntry { + /** PDO address */ + address: string + /** Optional: data type */ + type?: string + /** Optional: bit offset */ + bit_offset?: number +} + +/** + * Slave configuration entry for validation requests. + * Not to be confused with EtherCATSlaveConfig from esi-types.ts (per-slave runtime config). + */ +export interface EtherCATValidationSlaveEntry { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Vendor ID */ + vendor_id?: number + /** Product code */ + product_code?: number + /** PDO mapping configuration */ + pdo_mapping?: Record +} + +/** + * Request body for POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateRequest { + /** Network interface to use */ + interface: string + /** List of slave configurations */ + slaves: EtherCATValidationSlaveEntry[] + /** Cycle time in milliseconds */ + cycle_time_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateResponse { + /** Whether the configuration is valid */ + valid: boolean + /** List of validation errors (configuration is invalid if non-empty) */ + errors: string[] + /** List of warnings (configuration is valid but may have issues) */ + warnings: string[] +} + +// ===================== IPC RESPONSE WRAPPERS ===================== + +/** + * Generic IPC response wrapper for EtherCAT operations + */ +export interface EtherCATIPCResponse { + success: boolean + data?: T + error?: string +} + +/** + * IPC response for listing network interfaces + */ +export type ListInterfacesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for checking service status + */ +export type ServiceStatusIPCResponse = EtherCATIPCResponse + +/** + * IPC response for scanning EtherCAT devices + */ +export type ScanDevicesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for testing connection to a device + */ +export type TestConnectionIPCResponse = EtherCATIPCResponse + +/** + * IPC response for validating configuration + */ +export type ValidateConfigIPCResponse = EtherCATIPCResponse + +// ===================== RUNTIME STATUS MONITORING ===================== + +/** + * EtherCAT plugin state machine states as reported by the runtime + */ +export type EtherCATPluginState = + | 'IDLE' + | 'SCANNING' + | 'CONFIGURING' + | 'TRANSITIONING' + | 'OPERATIONAL' + | 'RECOVERING' + | 'ERROR' + | 'STOPPED' + +/** + * Per-slave status snapshot from the runtime + */ +export interface EtherCATSlaveStatus { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name */ + name: string + /** Current EtherCAT AL state (e.g., "OP", "SAFE-OP", "INIT") */ + state: string + /** AL status code (0 = no error) */ + al_status_code: number + /** Cumulative error count for this slave */ + error_count: number + /** Whether the slave has an error condition */ + has_error: boolean +} + +/** + * Cycle performance metrics from the EtherCAT thread + */ +export interface EtherCATCycleMetrics { + /** Total cycles executed since last reset */ + cycle_count: number + /** Total WKC errors since last reset */ + wkc_error_count: number + /** Average cycle time in microseconds */ + avg_cycle_us: number + /** Maximum cycle time in microseconds */ + max_cycle_us: number + /** Maximum process data exchange time in microseconds */ + max_exchange_us: number + /** Current consecutive WKC error count */ + consecutive_wkc_errors: number + /** Number of recovery attempts since last successful recovery */ + recovery_attempts: number +} + +/** + * Response from GET /api/discovery/ethercat/runtime-status + */ +export interface EtherCATRuntimeStatusResponse { + /** Current plugin state */ + plugin_state: EtherCATPluginState + /** Number of configured slaves */ + slave_count: number + /** Expected working counter value */ + expected_wkc: number + /** Per-slave status array */ + slaves: EtherCATSlaveStatus[] + /** Cycle performance metrics */ + metrics: EtherCATCycleMetrics +} + +/** + * IPC response for getting runtime status + */ +export type RuntimeStatusIPCResponse = EtherCATIPCResponse diff --git a/src/utils/ethercat/device-config-defaults.ts b/src/utils/ethercat/device-config-defaults.ts new file mode 100644 index 000000000..d1c1d6403 --- /dev/null +++ b/src/utils/ethercat/device-config-defaults.ts @@ -0,0 +1,50 @@ +import type { EtherCATSlaveConfig } from '@root/types/ethercat/esi-types' + +/** + * Default per-slave configuration for a newly added EtherCAT device. + * Values based on SOEM defaults and industrial best practices. + */ +export const DEFAULT_SLAVE_CONFIG: Readonly = { + startupChecks: { + checkVendorId: true, + checkProductCode: true, + }, + addressing: { + ethercatAddress: 0, + }, + timeouts: { + sdoTimeoutMs: 1000, + initToPreOpTimeoutMs: 3000, + safeOpToOpTimeoutMs: 10000, + }, + watchdog: { + smWatchdogEnabled: true, + smWatchdogMs: 100, + pdiWatchdogEnabled: false, + pdiWatchdogMs: 100, + }, + distributedClocks: { + dcEnabled: false, + dcSyncUnitCycleUs: 0, + dcSync0Enabled: false, + dcSync0CycleUs: 0, + dcSync0ShiftUs: 0, + dcSync1Enabled: false, + dcSync1CycleUs: 0, + dcSync1ShiftUs: 0, + }, +} + +/** + * Creates a fresh mutable copy of the default slave config. + * Each device gets its own config object to avoid shared-reference mutations. + */ +export function createDefaultSlaveConfig(): EtherCATSlaveConfig { + return { + startupChecks: { ...DEFAULT_SLAVE_CONFIG.startupChecks }, + addressing: { ...DEFAULT_SLAVE_CONFIG.addressing }, + timeouts: { ...DEFAULT_SLAVE_CONFIG.timeouts }, + watchdog: { ...DEFAULT_SLAVE_CONFIG.watchdog }, + distributedClocks: { ...DEFAULT_SLAVE_CONFIG.distributedClocks }, + } +} diff --git a/src/utils/ethercat/device-matcher.ts b/src/utils/ethercat/device-matcher.ts new file mode 100644 index 000000000..cb267d051 --- /dev/null +++ b/src/utils/ethercat/device-matcher.ts @@ -0,0 +1,169 @@ +/** + * EtherCAT Device Matcher Utility + * + * Provides functions to match scanned EtherCAT devices against ESI repository items. + */ + +import type { EtherCATDevice } from '@root/types/ethercat' +import type { + DeviceMatch, + DeviceMatchQuality, + ESIRepositoryItemLight, + ScannedDeviceMatch, +} from '@root/types/ethercat/esi-types' + +/** + * Parse a hex string to a number for comparison + * Handles formats: "0x1234", "#x1234", "1234" + */ +function parseHexToNumber(hexString: string): number { + const cleaned = hexString.replace(/#x/gi, '0x') + return Number(cleaned) || 0 +} + +/** + * Determine the match quality between a scanned device and an ESI device + */ +function getMatchQuality( + scannedVendorId: number, + scannedProductCode: number, + scannedRevision: number, + esiVendorId: string, + esiProductCode: string, + esiRevisionNo: string, +): DeviceMatchQuality { + const esiVendorNum = parseHexToNumber(esiVendorId) + const esiProductNum = parseHexToNumber(esiProductCode) + const esiRevisionNum = parseHexToNumber(esiRevisionNo) + + // Check vendor ID first - must match for any level of match + if (scannedVendorId !== esiVendorNum) { + return 'none' + } + + // Check product code - must match for partial or exact + if (scannedProductCode !== esiProductNum) { + return 'none' + } + + // Check revision - exact match requires revision match + if (scannedRevision === esiRevisionNum) { + return 'exact' + } + + // Vendor and product match, but different revision + return 'partial' +} + +/** + * Find all matches for a single scanned device in the repository + */ +function findMatchesForDevice(scannedDevice: EtherCATDevice, repository: ESIRepositoryItemLight[]): DeviceMatch[] { + const matches: DeviceMatch[] = [] + + for (const repoItem of repository) { + const esiVendorId = repoItem.vendor.id + + for (let deviceIndex = 0; deviceIndex < repoItem.devices.length; deviceIndex++) { + const esiDevice = repoItem.devices[deviceIndex] + const matchQuality = getMatchQuality( + scannedDevice.vendor_id, + scannedDevice.product_code, + scannedDevice.revision, + esiVendorId, + esiDevice.type.productCode, + esiDevice.type.revisionNo, + ) + + if (matchQuality !== 'none') { + matches.push({ + repositoryItemId: repoItem.id, + deviceIndex, + matchQuality, + esiDevice, + }) + } + } + } + + // Sort matches: exact first, then partial + matches.sort((a, b) => { + if (a.matchQuality === 'exact' && b.matchQuality !== 'exact') return -1 + if (a.matchQuality !== 'exact' && b.matchQuality === 'exact') return 1 + return 0 + }) + + return matches +} + +/** + * Match an array of scanned devices against the ESI repository + * + * @param scannedDevices - Array of devices discovered via network scan + * @param repository - Array of loaded ESI repository items + * @returns Array of ScannedDeviceMatch objects with match information + */ +export function matchDevicesToRepository( + scannedDevices: EtherCATDevice[], + repository: ESIRepositoryItemLight[], +): ScannedDeviceMatch[] { + return scannedDevices.map((device) => { + const matches = findMatchesForDevice(device, repository) + + return { + device: { + position: device.position, + name: device.name, + vendor_id: device.vendor_id, + product_code: device.product_code, + revision: device.revision, + serial_number: device.serial_number, + state: device.state, + input_bytes: device.input_bytes, + output_bytes: device.output_bytes, + }, + matches, + // Auto-select the best match if there's an exact match + selectedMatch: + matches.length > 0 && matches[0].matchQuality === 'exact' + ? { + repositoryItemId: matches[0].repositoryItemId, + deviceIndex: matches[0].deviceIndex, + } + : undefined, + } + }) +} + +/** + * Get the best match quality from a list of matches + */ +export function getBestMatchQuality(matches: DeviceMatch[]): DeviceMatchQuality { + if (matches.length === 0) return 'none' + if (matches.some((m) => m.matchQuality === 'exact')) return 'exact' + if (matches.some((m) => m.matchQuality === 'partial')) return 'partial' + return 'none' +} + +/** + * Count devices by match quality + */ +export function countMatchedDevices(deviceMatches: ScannedDeviceMatch[]): { + exact: number + partial: number + none: number + total: number +} { + let exact = 0 + let partial = 0 + let none = 0 + + for (const dm of deviceMatches) { + const bestQuality = getBestMatchQuality(dm.matches) + if (bestQuality === 'exact') exact++ + else if (bestQuality === 'partial') partial++ + else none++ + } + + return { exact, partial, none, total: deviceMatches.length } +} diff --git a/src/utils/ethercat/enrich-device-data.ts b/src/utils/ethercat/enrich-device-data.ts new file mode 100644 index 000000000..9bf2e347d --- /dev/null +++ b/src/utils/ethercat/enrich-device-data.ts @@ -0,0 +1,113 @@ +/** + * EtherCAT Device Data Enrichment + * + * Pure functions that extract persistable data from a full ESIDevice. + * Used when adding devices to persist channel/PDO metadata for runtime config generation. + */ + +import type { + ESIDevice, + ESIPdo, + PersistedChannelInfo, + PersistedPdo, + PersistedPdoEntry, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' + +import { esiTypeToIecType, pdoToChannels } from './esi-parser' +import { extractDefaultSdoConfigurations } from './sdo-config-defaults' + +/** + * Convert ESIPdo[] to PersistedPdo[] format. + * Preserves all entries including padding for complete PDO layout. + */ +export function persistPdos(pdos: ESIPdo[]): PersistedPdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): PersistedPdoEntry => ({ + index: entry.index, + subIndex: entry.subIndex, + bitLen: entry.bitLen, + name: entry.name, + dataType: entry.dataType, + }), + ), + })) +} + +/** + * Build persisted channel info from ESIDevice using pdoToChannels. + * Extracts full metadata needed for runtime config generation. + */ +export function buildChannelInfo(device: ESIDevice): PersistedChannelInfo[] { + const channels = pdoToChannels(device) + return channels.map( + (ch): PersistedChannelInfo => ({ + channelId: ch.id, + name: ch.name, + direction: ch.direction, + pdoIndex: ch.pdoIndex, + entryIndex: ch.entryIndex, + entrySubIndex: ch.entrySubIndex, + dataType: ch.dataType, + bitLen: ch.bitLen, + iecType: esiTypeToIecType(ch.dataType, ch.bitLen), + }), + ) +} + +/** + * Derive slave device type from PDO structure. + * Uses heuristics based on PDO direction and data sizes. + */ +export function deriveSlaveType(device: ESIDevice): string { + const hasNonPaddingEntry = (pdos: ESIPdo[]): boolean => + pdos.some((pdo) => pdo.entries.some((e) => e.name !== 'Padding' && e.index !== '0x0000')) + + const allBitSized = (pdos: ESIPdo[]): boolean => + pdos.every((pdo) => + pdo.entries.filter((e) => e.name !== 'Padding' && e.index !== '0x0000').every((e) => e.bitLen === 1), + ) + + const hasTxData = hasNonPaddingEntry(device.txPdo) + const hasRxData = hasNonPaddingEntry(device.rxPdo) + + if (!hasTxData && !hasRxData) return 'coupler' + + const txAllBit = hasTxData && allBitSized(device.txPdo) + const rxAllBit = hasRxData && allBitSized(device.rxPdo) + + if (hasTxData && !hasRxData) { + return txAllBit ? 'digital_input' : 'analog_input' + } + + if (hasRxData && !hasTxData) { + return rxAllBit ? 'digital_output' : 'analog_output' + } + + // Both directions + if (txAllBit && rxAllBit) return 'digital_io' + return 'analog_io' +} + +/** + * Enrich device data by extracting all persistable info from a full ESIDevice. + * Returns fields to spread into ConfiguredEtherCATDevice. + */ +export function enrichDeviceData(device: ESIDevice): { + channelInfo: PersistedChannelInfo[] + rxPdos: PersistedPdo[] + txPdos: PersistedPdo[] + slaveType: string + sdoConfigurations?: SDOConfigurationEntry[] +} { + return { + channelInfo: buildChannelInfo(device), + rxPdos: persistPdos(device.rxPdo), + txPdos: persistPdos(device.txPdo), + slaveType: deriveSlaveType(device), + sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, + } +} diff --git a/src/utils/ethercat/esi-parser.ts b/src/utils/ethercat/esi-parser.ts new file mode 100644 index 000000000..983146817 --- /dev/null +++ b/src/utils/ethercat/esi-parser.ts @@ -0,0 +1,307 @@ +/** + * EtherCAT ESI Channel Utilities + * + * Functions for working with parsed ESI device data: + * - PDO-to-channel conversion for UI + * - IEC 61131-3 address generation + * - Default channel mapping generation + * - Device summary calculation + * + * XML parsing is handled exclusively by the main-process parser + * in src/main/services/esi-service/esi-parser-main.ts. + */ + +import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' + +/** + * Map ESI data type to IEC 61131-3 type + */ +export function esiTypeToIecType(esiType: ESIDataType, bitLen: number): string { + const typeMap: Record = { + BOOL: 'BOOL', + SINT: 'SINT', + INT: 'INT', + DINT: 'DINT', + LINT: 'LINT', + USINT: 'USINT', + UINT: 'UINT', + UDINT: 'UDINT', + ULINT: 'ULINT', + REAL: 'REAL', + LREAL: 'LREAL', + STRING: 'STRING', + BYTE: 'BYTE', + WORD: 'WORD', + DWORD: 'DWORD', + } + + if (typeMap[esiType]) { + return typeMap[esiType] + } + + // Handle BIT types + if (esiType.startsWith('BIT') || bitLen === 1) { + return 'BOOL' + } + + // Infer from bit length + switch (bitLen) { + case 1: + return 'BOOL' + case 8: + return 'BYTE' + case 16: + return 'WORD' + case 32: + return 'DWORD' + case 64: + return 'LWORD' + default: + return 'BYTE' + } +} + +/** + * Convert PDO entries to channels for UI + */ +export function pdoToChannels(device: ESIDevice): ESIChannel[] { + const channels: ESIChannel[] = [] + let inputBitOffset = 0 + let outputBitOffset = 0 + + // Process TxPDOs (inputs - slave to master) + for (const pdo of device.txPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + inputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'input', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: inputBitOffset, + byteOffset: Math.floor(inputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + inputBitOffset += entry.bitLen + } + } + + // Process RxPDOs (outputs - master to slave) + for (const pdo of device.rxPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + outputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'output', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: outputBitOffset, + byteOffset: Math.floor(outputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + outputBitOffset += entry.bitLen + } + } + + return channels +} + +/** + * Generate an IEC 61131-3 located variable address for a channel. + * Direction: input -> %I, output -> %Q + * Size prefix based on iecType: + * BOOL -> X (bit addressing): %IX. + * BYTE/SINT/USINT -> B: %IB + * WORD/INT/UINT -> W: %IW + * DWORD/DINT/UDINT/REAL -> D: %ID + * LWORD/LINT/ULINT/LREAL -> L: %IL + * + * When `globalBitOffset` is provided, it overrides the channel's own byte/bit offsets + * to allow generating globally unique addresses across multiple slaves. + */ +export function generateIecLocation(channel: ESIChannel, globalBitOffset?: number): string { + const dirPrefix = channel.direction === 'input' ? '%I' : '%Q' + + const byteOffset = globalBitOffset !== undefined ? Math.floor(globalBitOffset / 8) : channel.byteOffset + const bitOffset = globalBitOffset !== undefined ? globalBitOffset % 8 : channel.bitOffset % 8 + + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return `${dirPrefix}X${byteOffset}.${bitOffset}` + case 'BYTE': + case 'SINT': + case 'USINT': + return `${dirPrefix}B${byteOffset}` + case 'WORD': + case 'INT': + case 'UINT': + return `${dirPrefix}W${byteOffset}` + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return `${dirPrefix}D${byteOffset}` + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return `${dirPrefix}L${byteOffset}` + default: + return `${dirPrefix}B${byteOffset}` + } +} + +/** + * Get the size in bits for a channel based on its IEC type. + */ +function getChannelBitSize(channel: ESIChannel): number { + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return 1 + case 'BYTE': + case 'SINT': + case 'USINT': + return 8 + case 'WORD': + case 'INT': + case 'UINT': + return 16 + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return 32 + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return 64 + default: + return 8 + } +} + +/** + * Generate default channel mappings with auto-generated IEC addresses for all channels. + * When `usedAddresses` is provided, addresses are generated to avoid conflicts with + * already-used locations from other devices (Modbus or EtherCAT). + */ +export function generateDefaultChannelMappings( + channels: ESIChannel[], + usedAddresses?: Set, +): EtherCATChannelMapping[] { + const used = new Set(usedAddresses) + const inputChannels = channels.filter((c) => c.direction === 'input') + const outputChannels = channels.filter((c) => c.direction === 'output') + + const mappings: EtherCATChannelMapping[] = [] + + for (const group of [inputChannels, outputChannels]) { + let currentBitOffset = 0 + + for (const channel of group) { + const bitSize = getChannelBitSize(channel) + + // Align to byte boundary for non-bit types + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + + let candidate = generateIecLocation(channel, currentBitOffset) + + // Find a non-conflicting address + while (used.has(candidate)) { + currentBitOffset += bitSize + // Re-align if needed + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + candidate = generateIecLocation(channel, currentBitOffset) + } + + used.add(candidate) + mappings.push({ + channelId: channel.id, + iecLocation: candidate, + userEdited: false, + alias: '', + }) + + currentBitOffset += bitSize + } + } + + return mappings +} + +/** + * Calculate total PDO size in bytes + */ +export function calculatePdoSize(pdos: ESIPdo[]): number { + let totalBits = 0 + for (const pdo of pdos) { + for (const entry of pdo.entries) { + totalBits += entry.bitLen + } + } + return Math.ceil(totalBits / 8) +} + +/** + * Get device summary information + */ +export function getDeviceSummary(device: ESIDevice): { + totalInputBytes: number + totalOutputBytes: number + inputChannelCount: number + outputChannelCount: number + hasCoe: boolean +} { + const inputBytes = calculatePdoSize(device.txPdo) + const outputBytes = calculatePdoSize(device.rxPdo) + + let inputChannels = 0 + let outputChannels = 0 + + for (const pdo of device.txPdo) { + inputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + for (const pdo of device.rxPdo) { + outputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + return { + totalInputBytes: inputBytes, + totalOutputBytes: outputBytes, + inputChannelCount: inputChannels, + outputChannelCount: outputChannels, + hasCoe: device.coeObjects !== undefined && device.coeObjects.length > 0, + } +} diff --git a/src/utils/ethercat/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts new file mode 100644 index 000000000..4341ce39b --- /dev/null +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -0,0 +1,327 @@ +import type { + ConfiguredEtherCATDevice, + PersistedChannelInfo, + PersistedPdo, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' + +// Runtime JSON interfaces (snake_case for plugin consumption) + +interface RuntimePdoEntry { + index: string + subindex: number + bit_length: number + name: string + data_type: string +} + +interface RuntimePdo { + index: string + name: string + entries: RuntimePdoEntry[] +} + +interface RuntimeChannel { + index: number + name: string + type: string + bit_length: number + iec_location: string + pdo_index: string + pdo_entry_index: string + pdo_entry_subindex: number +} + +interface RuntimeSdoConfig { + index: string + subindex: number + value: number + data_type: string + bit_length: number + name: string + comment: string +} + +interface RuntimeSlaveConfig { + startup_checks: { + check_vendor_id: boolean + check_product_code: boolean + } + addressing: { + ethercat_address: number + } + timeouts: { + sdo_timeout_ms: number + init_to_preop_timeout_ms: number + safeop_to_op_timeout_ms: number + } + watchdog: { + sm_watchdog_enabled: boolean + sm_watchdog_ms: number + pdi_watchdog_enabled: boolean + pdi_watchdog_ms: number + } + distributed_clocks: { + enabled: boolean + sync_unit_cycle_us: number + sync0_enabled: boolean + sync0_cycle_us: number + sync0_shift_us: number + sync1_enabled: boolean + sync1_cycle_us: number + sync1_shift_us: number + } +} + +interface RuntimeSlave { + position: number + name: string + type: string + vendor_id: string + product_code: string + revision: string + config: RuntimeSlaveConfig + channels: RuntimeChannel[] + sdo_configurations: RuntimeSdoConfig[] + rx_pdos: RuntimePdo[] + tx_pdos: RuntimePdo[] +} + +interface RuntimeMaster { + interface: string + cycle_time_us: number + watchdog_timeout_cycles: number +} + +interface RuntimeDiagnostics { + log_connections: boolean + log_data_access: boolean + log_errors: boolean + max_log_entries: number + status_update_interval_ms: number +} + +interface RuntimeConfig { + master: RuntimeMaster + slaves: RuntimeSlave[] + diagnostics: RuntimeDiagnostics +} + +interface RuntimeRootEntry { + name: string + protocol: string + config: RuntimeConfig +} + +/** + * Converts a hex string (e.g., "0x01") to an integer. + */ +function hexToInt(hex: string): number { + return parseInt(hex, 16) +} + +/** + * Parses a user-entered value string into a numeric value. + * Handles decimal ("100"), hex ("0xFF", "#xFF"), float ("3.14"), and negative ("-50"). + * Returns 0 for empty or unparseable strings. + */ +function parseNumericValue(str: string): number { + if (!str || str.trim() === '') return 0 + + const trimmed = str.trim() + + // Handle hex prefixes: "0x" / "0X" / "#x" / "#X" + if (/^(0x|#x)/i.test(trimmed)) { + const hexStr = trimmed.replace(/^#x/i, '0x') + const parsed = Number(hexStr) + return isNaN(parsed) ? 0 : parsed + } + + const parsed = Number(trimmed) + return isNaN(parsed) ? 0 : parsed +} + +/** + * Derives the channel type string from direction and bit length. + */ +function deriveChannelType(direction: 'input' | 'output', bitLen: number): string { + if (direction === 'input') { + return bitLen === 1 ? 'digital_input' : 'analog_input' + } + return bitLen === 1 ? 'digital_output' : 'analog_output' +} + +/** + * Converts persisted PDOs to runtime PDO format. + * Entries with index "0x0000" are treated as padding. + */ +function convertPdos(pdos: PersistedPdo[]): RuntimePdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): RuntimePdoEntry => ({ + index: entry.index, + subindex: hexToInt(entry.subIndex), + bit_length: entry.bitLen, + name: entry.name, + data_type: entry.index === '0x0000' ? 'PAD' : entry.dataType, + }), + ), + })) +} + +/** + * Builds runtime channels by joining channelInfo with channelMappings. + */ +function buildChannels( + channelInfo: PersistedChannelInfo[], + channelMappings: { channelId: string; iecLocation: string }[], +): RuntimeChannel[] { + const mappingMap = new Map(channelMappings.map((m) => [m.channelId, m.iecLocation])) + + return channelInfo.map((ch, index) => ({ + index, + name: ch.name, + type: deriveChannelType(ch.direction, ch.bitLen), + bit_length: ch.bitLen, + iec_location: mappingMap.get(ch.channelId) ?? '', + pdo_index: ch.pdoIndex, + pdo_entry_index: ch.entryIndex, + pdo_entry_subindex: hexToInt(ch.entrySubIndex), + })) +} + +/** + * Converts SDOConfigurationEntry[] to RuntimeSdoConfig[] for the runtime plugin. + */ +function buildSdoConfigurations(entries: SDOConfigurationEntry[] | undefined): RuntimeSdoConfig[] { + if (!entries || entries.length === 0) return [] + + return entries.map( + (entry): RuntimeSdoConfig => ({ + index: entry.index, + subindex: entry.subIndex, + value: parseNumericValue(entry.value), + data_type: entry.dataType, + bit_length: entry.bitLength, + name: entry.name, + comment: `Startup SDO: ${entry.objectName}`, + }), + ) +} + +/** + * Builds a runtime slave from a configured device. + */ +function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSlave { + const position = device.position ?? index + const channels = device.channelInfo ? buildChannels(device.channelInfo, device.channelMappings) : [] + const rxPdos = device.rxPdos ? convertPdos(device.rxPdos) : [] + const txPdos = device.txPdos ? convertPdos(device.txPdos) : [] + + const cfg = device.config + + return { + position, + name: device.name, + type: device.slaveType ?? 'coupler', + vendor_id: device.vendorId, + product_code: device.productCode, + revision: device.revisionNo, + config: { + startup_checks: { + check_vendor_id: cfg.startupChecks.checkVendorId, + check_product_code: cfg.startupChecks.checkProductCode, + }, + addressing: { + ethercat_address: cfg.addressing.ethercatAddress, + }, + timeouts: { + sdo_timeout_ms: cfg.timeouts.sdoTimeoutMs, + init_to_preop_timeout_ms: cfg.timeouts.initToPreOpTimeoutMs, + safeop_to_op_timeout_ms: cfg.timeouts.safeOpToOpTimeoutMs, + }, + watchdog: { + sm_watchdog_enabled: cfg.watchdog.smWatchdogEnabled, + sm_watchdog_ms: cfg.watchdog.smWatchdogMs, + pdi_watchdog_enabled: cfg.watchdog.pdiWatchdogEnabled, + pdi_watchdog_ms: cfg.watchdog.pdiWatchdogMs, + }, + distributed_clocks: { + enabled: cfg.distributedClocks.dcEnabled, + sync_unit_cycle_us: cfg.distributedClocks.dcSyncUnitCycleUs, + sync0_enabled: cfg.distributedClocks.dcSync0Enabled, + sync0_cycle_us: cfg.distributedClocks.dcSync0CycleUs, + sync0_shift_us: cfg.distributedClocks.dcSync0ShiftUs, + sync1_enabled: cfg.distributedClocks.dcSync1Enabled, + sync1_cycle_us: cfg.distributedClocks.dcSync1CycleUs, + sync1_shift_us: cfg.distributedClocks.dcSync1ShiftUs, + }, + }, + channels, + sdo_configurations: buildSdoConfigurations(device.sdoConfigurations), + rx_pdos: rxPdos, + tx_pdos: txPdos, + } +} + +/** + * Generates the EtherCAT plugin configuration JSON from the project's remote devices. + * Produces the exact contract expected by the OpenPLC runtime EtherCAT plugin. + * + * Output format: array root `[{ name, protocol: "ETHERCAT", config: { master, slaves[], diagnostics } }]` + * + * @param remoteDevices - Array of PLCRemoteDevice from the project data + * @returns The EtherCAT configuration as a JSON string, or null if no devices are configured + */ +export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefined): string | null => { + if (!remoteDevices || remoteDevices.length === 0) { + return null + } + + const ethercatRemoteDevices = remoteDevices.filter( + (device) => device.protocol === 'ethercat' && device.ethercatConfig, + ) + + if (ethercatRemoteDevices.length === 0) { + return null + } + + // Build one root entry per EtherCAT remote device + const rootEntries: RuntimeRootEntry[] = [] + + for (const remoteDevice of ethercatRemoteDevices) { + const devices = (remoteDevice.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] + const slaves = devices.map((device, i) => buildSlave(device, i)) + + if (slaves.length === 0) continue + + rootEntries.push({ + name: remoteDevice.name || 'ethercat_master', + protocol: 'ETHERCAT', + config: { + master: { + interface: remoteDevice.ethercatConfig?.masterConfig?.networkInterface || 'eth0', + cycle_time_us: remoteDevice.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000, + watchdog_timeout_cycles: remoteDevice.ethercatConfig?.masterConfig?.watchdogTimeoutCycles ?? 3, + }, + slaves, + diagnostics: { + log_connections: true, + log_data_access: false, + log_errors: true, + max_log_entries: 10000, + status_update_interval_ms: 500, + }, + }, + }) + } + + if (rootEntries.length === 0) { + return null + } + + return JSON.stringify(rootEntries, null, 2) +} diff --git a/src/utils/ethercat/sdo-config-defaults.ts b/src/utils/ethercat/sdo-config-defaults.ts new file mode 100644 index 000000000..25c7dfc7a --- /dev/null +++ b/src/utils/ethercat/sdo-config-defaults.ts @@ -0,0 +1,84 @@ +/** + * SDO Configuration Defaults Extraction + * + * Extracts configurable SDO parameters from CoE Object Dictionary entries. + * Filters to only include user-configurable (RW) parameters in the + * manufacturer-specific and profile-specific ranges. + */ + +import type { ESICoEObject, SDOConfigurationEntry } from '@root/types/ethercat/esi-types' + +/** + * Parse a hex index string to a number for range comparison. + */ +function hexToNumber(hex: string): number { + return parseInt(hex.replace('0x', ''), 16) || 0 +} + +/** + * Check if an object index is in a configurable range. + * Excludes: + * 0x0000-0x0FFF: Data types (internal) + * 0x1000-0x1FFF: Communication / identity parameters (managed by master) + */ +function isConfigurableRange(index: string): boolean { + const num = hexToNumber(index) + return num >= 0x2000 +} + +/** + * Extract default SDO configurations from CoE Object Dictionary. + * + * Returns one SDOConfigurationEntry per RW parameter found in the + * configurable ranges (0x2000+). For complex objects, each RW sub-item + * (except subIndex 0 which is the max subindex counter) gets its own entry. + */ +export function extractDefaultSdoConfigurations(coeObjects: ESICoEObject[]): SDOConfigurationEntry[] { + const entries: SDOConfigurationEntry[] = [] + + for (const obj of coeObjects) { + if (!isConfigurableRange(obj.index)) continue + + if (obj.subItems && obj.subItems.length > 0) { + // Complex object: iterate sub-items + for (const sub of obj.subItems) { + const subIdx = parseInt(sub.subIndex, 10) + // Skip subIndex 0 (max subindex counter) + if (subIdx === 0) continue + // Only include RW sub-items with a defined default value + if (sub.access !== 'RW') continue + if (sub.defaultValue === undefined || sub.defaultValue === null) continue + const defaultValue = sub.defaultValue + + entries.push({ + index: obj.index, + subIndex: subIdx, + value: defaultValue, + defaultValue, + dataType: sub.type, + bitLength: sub.bitSize, + name: sub.name, + objectName: obj.name, + }) + } + } else { + // Simple object: use object-level values + if (obj.access !== 'RW') continue + if (obj.defaultValue === undefined || obj.defaultValue === null) continue + const defaultValue = obj.defaultValue + + entries.push({ + index: obj.index, + subIndex: 0, + value: defaultValue, + defaultValue, + dataType: obj.type, + bitLength: obj.bitSize, + name: obj.name, + objectName: obj.name, + }) + } + } + + return entries +}