From ddfaec0f9de198c5759f5d040135fc33ac2798e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 4 Feb 2026 14:23:39 -0300 Subject: [PATCH 01/31] feat: add EtherCAT device discovery and interface scan - Add TypeScript types for EtherCAT discovery service API - Add IPC handlers for EtherCAT endpoints (interfaces, status, scan, test, validate) - Add EtherCATEditor component with network interface selector and device scan table - Enable EtherCAT protocol option in remote device creation - Integrate EtherCAT editor into workspace screen Co-Authored-By: Claude Opus 4.5 --- src/main/modules/ipc/main.ts | 276 +++++++++++++++ src/main/modules/ipc/renderer.ts | 59 ++++ .../create-element/element-card/index.tsx | 2 +- .../ethercat/components/device-scan-table.tsx | 127 +++++++ .../components/interface-selector.tsx | 94 +++++ .../editor/device/ethercat/index.tsx | 323 ++++++++++++++++++ src/renderer/screens/workspace-screen.tsx | 8 +- src/types/ethercat/index.ts | 235 +++++++++++++ 8 files changed, 1122 insertions(+), 2 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx create mode 100644 src/types/ethercat/index.ts diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index eed952ad9..4c9f5686f 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,4 +1,14 @@ import { getProjectPath } from '@root/main/utils' +import type { + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' @@ -510,6 +520,265 @@ 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(scanRequest) + + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: '/api/discovery/ethercat/scan', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + Authorization: `Bearer ${jwtToken}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + const response = JSON.parse(data) as EtherCATScanResponse + resolve({ success: true, data: response }) + } catch { + resolve({ success: false, error: 'Invalid response format' }) + } + } else if (res.statusCode === 403) { + resolve({ success: false, error: 'Permission denied - CAP_NET_RAW required' }) + } else if (res.statusCode === 404) { + resolve({ success: false, error: 'Interface not found' }) + } else if (res.statusCode === 503) { + resolve({ success: false, error: 'Discovery service not available' }) + } else if (res.statusCode === 504) { + resolve({ success: false, error: 'Scan timeout' }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } + }) + }, + ) + // Use longer timeout for scan operations (scan timeout + buffer) + const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 + req.setTimeout(scanTimeout, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + req.write(postData) + req.end() + }) + } 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) + + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: '/api/discovery/ethercat/test', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + Authorization: `Bearer ${jwtToken}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + const response = JSON.parse(data) as EtherCATTestResponse + resolve({ success: true, data: response }) + } catch { + resolve({ success: false, error: 'Invalid response format' }) + } + } else if (res.statusCode === 403) { + resolve({ success: false, error: 'Permission denied - CAP_NET_RAW required' }) + } else if (res.statusCode === 404) { + resolve({ success: false, error: 'Interface not found' }) + } else if (res.statusCode === 503) { + resolve({ success: false, error: 'Discovery service not available' }) + } else if (res.statusCode === 504) { + resolve({ success: false, error: 'Connection test timeout' }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } + }) + }, + ) + const testTimeout = (testRequest.timeout_ms || 3000) + 10000 + req.setTimeout(testTimeout, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + req.write(postData) + req.end() + }) + } 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) + + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: '/api/discovery/ethercat/validate', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + Authorization: `Bearer ${jwtToken}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + const response = JSON.parse(data) as EtherCATValidateResponse + resolve({ success: true, data: response }) + } catch { + resolve({ success: false, error: 'Invalid response format' }) + } + } else if (res.statusCode === 400) { + resolve({ success: false, error: 'Invalid configuration format' }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } + }) + }, + ) + req.setTimeout(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(postData) + req.end() + }) + } catch (error) { + return { success: false, error: String(error) } + } + } + // ===================== IPC HANDLER REGISTRATION ===================== setupMainIpcListener() { // Project-related handlers @@ -589,6 +858,13 @@ class MainProcessBridge implements MainIpcModule { this.ipcMain.handle('runtime:get-logs', this.handleRuntimeGetLogs) 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) } // ===================== HANDLER METHODS ===================== diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 4f7f5c3f3..9a030d822 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ +import type { + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' 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' @@ -359,5 +369,54 @@ const rendererProcessBridge = { ipcRenderer.on('runtime:token-refreshed', callback) 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), } export default rendererProcessBridge 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 a68a01bed..d0eac4e7d 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 @@ -55,7 +55,7 @@ const ServerProtocolSources = [ const RemoteDeviceProtocolSources = [ { value: 'modbus-tcp', label: 'Modbus/TCP', 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/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/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/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx new file mode 100644 index 000000000..f1c779da6 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -0,0 +1,323 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import { useOpenPLCStore } from '@root/renderer/store' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import { cn } from '@root/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { DeviceScanTable } from './components/device-scan-table' +import { InterfaceSelector } from './components/interface-selector' + +/** + * EtherCAT Device Editor + * + * Provides interface for: + * - Selecting network interface for EtherCAT communication + * - Scanning for EtherCAT devices on the selected interface + * - Displaying discovered devices with their properties + */ +const EtherCATEditor = () => { + const { editor, runtimeConnection } = useOpenPLCStore() + + const deviceName = editor.type === 'plc-remote-device' ? editor.meta.name : '' + + // Runtime connection state + const { connectionStatus, jwtToken, ipAddress } = runtimeConnection + const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null && jwtToken !== null + + // 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 [devices, setDevices] = useState([]) + const [isScanning, setIsScanning] = useState(false) + const [scanError, setScanError] = useState(null) + const [scanMessage, setScanMessage] = useState('') + const [scanTimeMs, setScanTimeMs] = useState(null) + + // Selected device for details + const [selectedDevicePosition, setSelectedDevicePosition] = useState(null) + + // Check EtherCAT service status when connected to runtime + 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) { + setInterfaces(result.data) + // Auto-select first interface if available + if (result.data.length > 0 && !selectedInterface) { + setSelectedInterface(result.data[0].name) + } + } else { + setInterfaces([]) + setInterfaceError(result.error || 'Failed to fetch interfaces') + } + } catch (error) { + setInterfaces([]) + setInterfaceError(String(error)) + } finally { + setIsLoadingInterfaces(false) + } + }, [isConnectedToRuntime, ipAddress, jwtToken, selectedInterface]) + + // 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('') + setDevices([]) + setSelectedDevicePosition(null) + + try { + const result = await window.bridge.etherCATScan(ipAddress, jwtToken, { + interface: selectedInterface, + timeout_ms: 5000, + }) + + if (result.success && result.data) { + setDevices(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]) + + // Check service status and fetch interfaces when runtime connection changes + useEffect(() => { + if (isConnectedToRuntime) { + void checkServiceStatus() + void fetchInterfaces() + } else { + setServiceAvailable(null) + setInterfaces([]) + setDevices([]) + setSelectedInterface('') + } + }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) + + // Get selected device details + const selectedDevice = useMemo(() => { + if (selectedDevicePosition === null) return null + return devices.find((d) => d.position === selectedDevicePosition) || null + }, [devices, selectedDevicePosition]) + + // Render not connected state + if (!isConnectedToRuntime) { + return ( +
+
+

+ EtherCAT Device: {deviceName} +

+

Protocol: EtherCAT

+
+
+
+

Not connected to runtime

+

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

+
+
+
+ ) + } + + // Render service not available state + if (serviceAvailable === false) { + return ( +
+
+

+ EtherCAT Device: {deviceName} +

+

Protocol: EtherCAT

+
+
+
+

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

EtherCAT Device: {deviceName}

+

Protocol: EtherCAT

+
+ + {/* Interface Selection and Scan Controls */} +
+ void fetchInterfaces()} + /> + + + + {scanTimeMs !== null && ( + Scan completed in {scanTimeMs}ms + )} +
+ + {/* Error/Status Messages */} + {scanError && ( +
+

{scanError}

+
+ )} + + {scanMessage && !scanError && ( +
+

{scanMessage}

+
+ )} + + {/* Devices Table */} +
+
+

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

+
+ + +
+ + {/* Selected Device Details */} + {selectedDevice && ( +
+

+ Device Details: {selectedDevice.name} +

+
+
+ Position:{' '} + {selectedDevice.position} +
+
+ Vendor ID:{' '} + 0x{selectedDevice.vendor_id.toString(16)} +
+
+ Product Code:{' '} + + 0x{selectedDevice.product_code.toString(16)} + +
+
+ Revision:{' '} + 0x{selectedDevice.revision.toString(16)} +
+
+ Serial:{' '} + {selectedDevice.serial_number || 'N/A'} +
+
+ State:{' '} + {selectedDevice.state} +
+
+ CoE Support:{' '} + {selectedDevice.has_coe ? 'Yes' : 'No'} +
+
+ I/O:{' '} + + {selectedDevice.input_bytes}B in / {selectedDevice.output_bytes}B out + +
+
+
+ )} +
+ ) +} + +export { EtherCATEditor } diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index 0880f12c2..f31ee6330 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -18,6 +18,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' @@ -1587,7 +1588,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') && ( +} + +/** + * Request body for POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateRequest { + /** Network interface to use */ + interface: string + /** List of slave configurations */ + slaves: EtherCATSlaveConfig[] + /** 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 From 078f40fdd4a0f033ed950f860bcc535e08e31584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 5 Feb 2026 09:57:17 -0300 Subject: [PATCH 02/31] feat: add EtherCAT ESI repository and device configuration UI Redesigns the EtherCAT editor with a 3-tab architecture: - Repository: Upload and manage multiple ESI XML files with batch processing - Discovery: Scan network with automatic device-to-repository matching - Configured Devices: View and manage added devices with expandable details New components: - ESI repository table with expandable file/device views - Device browser modal for manual device selection - Discovered device table with match quality badges - Configured device rows with info panels New utilities: - ESI parser for XML file processing - Device matcher for scan-to-repository matching (exact/partial/none) Co-Authored-By: Claude Opus 4.5 --- .../components/configured-device-row.tsx | 167 ++++++ .../components/configured-devices.tsx | 122 +++++ .../components/device-browser-modal.tsx | 272 ++++++++++ .../components/discovered-device-table.tsx | 184 +++++++ .../components/esi-channels-table.tsx | 234 +++++++++ .../ethercat/components/esi-device-info.tsx | 165 ++++++ .../components/esi-repository-table.tsx | 215 ++++++++ .../ethercat/components/esi-repository.tsx | 108 ++++ .../device/ethercat/components/esi-upload.tsx | 198 +++++++ .../editor/device/ethercat/index.tsx | 452 ++++++++++------ src/types/ethercat/esi-types.ts | 363 +++++++++++++ src/types/ethercat/index.ts | 3 + src/utils/ethercat/device-matcher.ts | 169 ++++++ src/utils/ethercat/esi-parser.ts | 489 ++++++++++++++++++ 14 files changed, 2983 insertions(+), 158 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx create mode 100644 src/types/ethercat/esi-types.ts create mode 100644 src/utils/ethercat/device-matcher.ts create mode 100644 src/utils/ethercat/esi-parser.ts 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..6b5d8366d --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -0,0 +1,167 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { ConfiguredEtherCATDevice, ESIDevice, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { getDeviceSummary } from '@root/utils/ethercat/esi-parser' +import { useMemo } from 'react' + +type ConfiguredDeviceRowProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItem[] + isExpanded: boolean + onToggleExpand: () => void + isSelected: boolean + onSelect: () => void +} + +/** + * Configured Device Row Component + * + * Displays a configured EtherCAT device with expandable details. + * Follows the IOGroupRow pattern from remote-device editor. + */ +const ConfiguredDeviceRow = ({ + device, + repository, + isExpanded, + onToggleExpand, + isSelected, + onSelect, +}: ConfiguredDeviceRowProps) => { + // Resolve the ESI device from repository + 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 summary = useMemo(() => { + if (!esiDevice) return null + return getDeviceSummary(esiDevice) + }, [esiDevice]) + + const ioSummary = summary ? `${summary.totalInputBytes}B/${summary.totalOutputBytes}B` : '-' + + return ( + <> + {/* Main row */} + + + + + {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} +
+ )} + {summary && ( + <> +
+ Input Channels:{' '} + {summary.inputChannelCount} +
+
+ Output Channels:{' '} + {summary.outputChannelCount} +
+ + )} +
+
+ + + + {/* Configuration Section (placeholder for future IO mapping) */} + + + +
+
+ Configuration +
+

+ IO mapping and device configuration will be available here. +

+
+ + + + )} + + ) +} + +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..44ef26c06 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -0,0 +1,122 @@ +import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' +import TableActions from '@root/renderer/components/_atoms/table-actions' +import type { ConfiguredEtherCATDevice, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { useCallback, useState } from 'react' + +import { ConfiguredDeviceRow } from './configured-device-row' + +type ConfiguredDevicesProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItem[] + onAddDevice: () => void + onRemoveDevice: (deviceId: string) => void +} + +/** + * Configured Devices Component + * + * Displays the list of configured EtherCAT devices with add/remove functionality. + */ +const ConfiguredDevices = ({ devices, repository, onAddDevice, onRemoveDevice }: ConfiguredDevicesProps) => { + const [expandedDevices, setExpandedDevices] = useState>(new Set()) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + 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)} + /> + )) + )} + +
+ Name + + Type + + Position + + I/O + 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..07cd784d8 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx @@ -0,0 +1,272 @@ +import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal' +import type { ESIDevice, ESIDeviceRef, ESIRepositoryItem } 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: ESIDevice, repoItem: ESIRepositoryItem) => void + repository: ESIRepositoryItem[] +} + +/** + * 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: ESIRepositoryItem + device: ESIDevice + 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 ( +
handleSelectDevice(repoItem.id, deviceIndex)} + className={cn( + 'flex cursor-pointer items-center gap-3 px-3 py-2 pl-8 hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + isSelected && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + +
+
+ + {device.name} + + {device.groupName && ( + + {device.groupName} + + )} +
+
+ {device.type.productCode} + Rev: {device.type.revisionNo} + from {repoItem.filename} +
+
+ {isSelected && ( + + + + )} +
+ ) + })} +
+ )} +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} + +export { DeviceBrowserModal } 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-repository-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx new file mode 100644 index 000000000..bd3141389 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -0,0 +1,215 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useState } from 'react' + +type ESIRepositoryTableProps = { + repository: ESIRepositoryItem[] + onRemoveItem: (itemId: string) => void + onClearAll: () => void +} + +/** + * ESI Repository Table Component + * + * Displays loaded ESI files with expandable rows showing contained devices. + */ +const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: 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={() => onRemoveItem(item.id)} + /> + ))} + +
+ Filename + + Vendor + + Devices + + Actions +
+
+
+ ) +} + +type RepositoryItemRowProps = { + item: ESIRepositoryItem + 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..23e771e8c --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -0,0 +1,108 @@ +import type { ESIParseResult, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { useCallback, useState } from 'react' + +import { ESIRepositoryTable } from './esi-repository-table' +import { ESIUpload, parseResultsToRepositoryItems } from './esi-upload' + +type ESIRepositoryProps = { + repository: ESIRepositoryItem[] + onRepositoryChange: (repository: ESIRepositoryItem[]) => void +} + +/** + * ESI Repository Component + * + * Manages the ESI file repository with upload and display functionality. + * Combines upload zone with repository table. + */ +const ESIRepository = ({ repository, onRepositoryChange }: ESIRepositoryProps) => { + const [uploadErrors, setUploadErrors] = useState>([]) + const [isLoading] = useState(false) + + const handleFilesLoaded = useCallback( + (results: Array<{ result: ESIParseResult; filename: string }>) => { + const { items, errors } = parseResultsToRepositoryItems(results) + + // Add new items to existing repository (avoiding duplicates by filename) + const existingFilenames = new Set(repository.map((r) => r.filename)) + const newItems = items.filter((item) => !existingFilenames.has(item.filename)) + + if (newItems.length > 0) { + onRepositoryChange([...repository, ...newItems]) + } + + // Show errors for failed files + setUploadErrors(errors) + }, + [repository, onRepositoryChange], + ) + + const handleRemoveItem = useCallback( + (itemId: string) => { + onRepositoryChange(repository.filter((item) => item.id !== itemId)) + }, + [repository, onRepositoryChange], + ) + + const handleClearAll = useCallback(() => { + onRepositoryChange([]) + setUploadErrors([]) + }, [onRepositoryChange]) + + const handleDismissError = useCallback((filename: string) => { + setUploadErrors((prev) => prev.filter((e) => e.filename !== filename)) + }, []) + + return ( +
+ {/* Upload Area */} + + + {/* Error Messages */} + {uploadErrors.length > 0 && ( +
+ {uploadErrors.map((error) => ( +
+
+ + + + + {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..b5bf0cb22 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -0,0 +1,198 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { ESIParseResult, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { parseESI } from '@root/utils/ethercat/esi-parser' +import { useCallback, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +type ESIUploadProps = { + onFilesLoaded: (results: Array<{ result: ESIParseResult; filename: string }>) => void + repositoryCount?: number + isLoading?: boolean +} + +/** + * ESI File Upload Component + * + * Allows users to upload multiple EtherCAT ESI XML files via drag-and-drop or file picker. + * Supports batch processing with non-blocking error handling. + */ +const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ESIUploadProps) => { + const [isDragging, setIsDragging] = useState(false) + const [processingCount, setProcessingCount] = useState(0) + const fileInputRef = useRef(null) + + const processFiles = useCallback( + async (files: FileList) => { + const xmlFiles = Array.from(files).filter((file) => file.name.endsWith('.xml')) + + if (xmlFiles.length === 0) { + onFilesLoaded([ + { + result: { success: false, error: 'No XML files found. Please upload .xml ESI files.' }, + filename: '', + }, + ]) + return + } + + setProcessingCount(xmlFiles.length) + + const results: Array<{ result: ESIParseResult; filename: string }> = [] + + for (const file of xmlFiles) { + try { + const text = await file.text() + const result = parseESI(text, file.name) + results.push({ result, filename: file.name }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to read file' + results.push({ + result: { success: false, error: errorMessage }, + filename: file.name, + }) + } + } + + setProcessingCount(0) + onFilesLoaded(results) + }, + [onFilesLoaded], + ) + + 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 || processingCount > 0 + + return ( +
+ + + {/* Drop zone */} +
+ + + {isProcessing ? ( +
+ + + Processing {processingCount > 0 ? `${processingCount} file(s)` : ''}... + +
+ ) : ( +
+ + + + + Drop ESI files here or browse + + + Supports multiple .xml ESI files (ETG.2000) + + {repositoryCount > 0 && ( + + {repositoryCount} file(s) currently loaded + + )} +
+ )} +
+
+ ) +} + +/** + * Convert parse results to repository items + */ +export function parseResultsToRepositoryItems(results: Array<{ result: ESIParseResult; filename: string }>): { + items: ESIRepositoryItem[] + errors: Array<{ filename: string; error: string }> +} { + const items: ESIRepositoryItem[] = [] + const errors: Array<{ filename: string; error: string }> = [] + + for (const { result, filename } of results) { + if (result.success && result.data) { + items.push({ + id: uuidv4(), + filename: result.data.filename || filename, + vendor: result.data.vendor, + devices: result.data.devices, + loadedAt: Date.now(), + warnings: result.warnings, + }) + } else { + errors.push({ + filename, + error: result.error || 'Unknown parsing error', + }) + } + } + + return { items, errors } +} + +export { ESIUpload } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index f1c779da6..ce5e9283d 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,19 +1,33 @@ import { ArrowIcon } from '@root/renderer/assets/icons' import { useOpenPLCStore } from '@root/renderer/store' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { + ConfiguredEtherCATDevice, + ESIDevice, + ESIDeviceRef, + ESIRepositoryItem, + ScannedDeviceMatch, +} from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' +import { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from '@root/utils/ethercat/device-matcher' import { useCallback, useEffect, useMemo, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' -import { DeviceScanTable } from './components/device-scan-table' +import { ConfiguredDevices } from './components/configured-devices' +import { DeviceBrowserModal } from './components/device-browser-modal' +import { DiscoveredDeviceTable } from './components/discovered-device-table' +import { ESIRepository } from './components/esi-repository' import { InterfaceSelector } from './components/interface-selector' +type EditorTab = 'repository' | 'discovery' | 'configured' + /** * EtherCAT Device Editor * * Provides interface for: - * - Selecting network interface for EtherCAT communication - * - Scanning for EtherCAT devices on the selected interface - * - Displaying discovered devices with their properties + * - Managing ESI file repository (Repository tab) + * - Scanning for EtherCAT devices and matching with repository (Discovery tab) + * - Viewing and configuring added devices (Configured Devices tab) */ const EtherCATEditor = () => { const { editor, runtimeConnection } = useOpenPLCStore() @@ -24,6 +38,16 @@ const EtherCATEditor = () => { const { connectionStatus, jwtToken, ipAddress } = runtimeConnection const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null && jwtToken !== null + // Tab state + const [activeTab, setActiveTab] = useState('repository') + + // Repository state + const [repository, setRepository] = useState([]) + + // Configured devices state + const [configuredDevices, setConfiguredDevices] = useState([]) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + // Network interfaces state const [interfaces, setInterfaces] = useState([]) const [selectedInterface, setSelectedInterface] = useState('') @@ -35,14 +59,21 @@ const EtherCATEditor = () => { const [serviceMessage, setServiceMessage] = useState('') // Scan state - const [devices, setDevices] = useState([]) + const [scannedDevices, setScannedDevices] = useState([]) const [isScanning, setIsScanning] = useState(false) const [scanError, setScanError] = useState(null) - const [scanMessage, setScanMessage] = useState('') + const [_scanMessage, setScanMessage] = useState('') const [scanTimeMs, setScanTimeMs] = useState(null) - // Selected device for details - const [selectedDevicePosition, setSelectedDevicePosition] = useState(null) + // Discovery selection state + const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + + // Matched devices (scanned devices with repository matches) + const deviceMatches = useMemo(() => { + return matchDevicesToRepository(scannedDevices, repository) + }, [scannedDevices, repository]) + + const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) // Check EtherCAT service status when connected to runtime const checkServiceStatus = useCallback(async () => { @@ -82,7 +113,6 @@ const EtherCATEditor = () => { const result = await window.bridge.etherCATGetInterfaces(ipAddress, jwtToken) if (result.success && result.data) { setInterfaces(result.data) - // Auto-select first interface if available if (result.data.length > 0 && !selectedInterface) { setSelectedInterface(result.data[0].name) } @@ -108,8 +138,9 @@ const EtherCATEditor = () => { setIsScanning(true) setScanError(null) setScanMessage('') - setDevices([]) - setSelectedDevicePosition(null) + setScannedDevices([]) + setSelectedScannedDevices(new Set()) + setScanTimeMs(null) try { const result = await window.bridge.etherCATScan(ipAddress, jwtToken, { @@ -118,7 +149,7 @@ const EtherCATEditor = () => { }) if (result.success && result.data) { - setDevices(result.data.devices) + setScannedDevices(result.data.devices) setScanMessage(result.data.message) setScanTimeMs(result.data.scan_time_ms) @@ -143,60 +174,96 @@ const EtherCATEditor = () => { } else { setServiceAvailable(null) setInterfaces([]) - setDevices([]) + setScannedDevices([]) setSelectedInterface('') } }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) - // Get selected device details - const selectedDevice = useMemo(() => { - if (selectedDevicePosition === null) return null - return devices.find((d) => d.position === selectedDevicePosition) || null - }, [devices, selectedDevicePosition]) - - // Render not connected state - if (!isConnectedToRuntime) { - return ( -
-
-

- EtherCAT Device: {deviceName} -

-

Protocol: EtherCAT

-
-
-
-

Not connected to runtime

-

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

-
-
-
- ) - } - - // Render service not available state - if (serviceAvailable === false) { - return ( -
-
-

- EtherCAT Device: {deviceName} -

-

Protocol: EtherCAT

-
-
-
-

- EtherCAT Discovery Service Not Available -

-

{serviceMessage}

-
-
-
- ) - } + // Handle device selection from scan + 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 + }) + }, []) + + // Handle select all scanned devices + const handleSelectAllScanned = useCallback( + (selected: boolean) => { + if (selected) { + // Select only devices with matches + const selectable = deviceMatches + .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') + .map((dm) => dm.device.position) + setSelectedScannedDevices(new Set(selectable)) + } else { + setSelectedScannedDevices(new Set()) + } + }, + [deviceMatches], + ) + + // Add selected scanned devices to configured devices + const handleAddSelectedFromScan = useCallback(() => { + const newDevices: ConfiguredEtherCATDevice[] = [] + + for (const position of selectedScannedDevices) { + 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 + + 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', + }) + } + + if (newDevices.length > 0) { + setConfiguredDevices((prev) => [...prev, ...newDevices]) + setSelectedScannedDevices(new Set()) + setActiveTab('configured') + } + }, [selectedScannedDevices, deviceMatches, repository]) + + // Handle adding device from browser modal + const handleAddDeviceFromBrowser = useCallback( + (ref: ESIDeviceRef, device: ESIDevice, repoItem: ESIRepositoryItem) => { + const newDevice: ConfiguredEtherCATDevice = { + id: uuidv4(), + name: device.name, + esiDeviceRef: ref, + vendorId: repoItem.vendor.id, + productCode: device.type.productCode, + revisionNo: device.type.revisionNo, + addedFrom: 'repository', + } + setConfiguredDevices((prev) => [...prev, newDevice]) + }, + [], + ) + + // Handle removing a configured device + const handleRemoveDevice = useCallback((deviceId: string) => { + setConfiguredDevices((prev) => prev.filter((d) => d.id !== deviceId)) + }, []) return (
@@ -206,116 +273,185 @@ const EtherCATEditor = () => {

Protocol: EtherCAT

- {/* Interface Selection and Scan Controls */} -
- void fetchInterfaces()} - /> - + {/* Tabs */} +
+ + - - {scanTimeMs !== null && ( - Scan completed in {scanTimeMs}ms - )}
- {/* Error/Status Messages */} - {scanError && ( -
-

{scanError}

-
- )} + {/* Repository Tab */} + {activeTab === 'repository' && } + + {/* Discovery Tab */} + {activeTab === 'discovery' && ( +
+ {/* Not connected state */} + {!isConnectedToRuntime && ( +
+
+

Not connected to runtime

+

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

+
+
+ )} - {scanMessage && !scanError && ( -
-

{scanMessage}

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

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+
+ )} - {/* Devices Table */} -
-
-

- Discovered Devices {devices.length > 0 && `(${devices.length})`} -

+ {/* Connected state */} + {isConnectedToRuntime && serviceAvailable !== false && ( + <> + {/* Interface Selection and Scan Controls */} +
+ void fetchInterfaces()} + /> + + + + {scanTimeMs !== null && ( + Completed in {scanTimeMs}ms + )} +
+ + {/* 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 */} + + + )}
+ )} - setIsDeviceBrowserOpen(true)} + onRemoveDevice={handleRemoveDevice} /> -
- - {/* Selected Device Details */} - {selectedDevice && ( -
-

- Device Details: {selectedDevice.name} -

-
-
- Position:{' '} - {selectedDevice.position} -
-
- Vendor ID:{' '} - 0x{selectedDevice.vendor_id.toString(16)} -
-
- Product Code:{' '} - - 0x{selectedDevice.product_code.toString(16)} - -
-
- Revision:{' '} - 0x{selectedDevice.revision.toString(16)} -
-
- Serial:{' '} - {selectedDevice.serial_number || 'N/A'} -
-
- State:{' '} - {selectedDevice.state} -
-
- CoE Support:{' '} - {selectedDevice.has_coe ? 'Yes' : 'No'} -
-
- I/O:{' '} - - {selectedDevice.input_bytes}B in / {selectedDevice.output_bytes}B out - -
-
-
)} + + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={handleAddDeviceFromBrowser} + repository={repository} + />
) } diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts new file mode 100644 index 000000000..c31f735c4 --- /dev/null +++ b/src/types/ethercat/esi-types.ts @@ -0,0 +1,363 @@ +/** + * 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 + /** 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' + /** Default value */ + defaultValue?: string +} + +// ===================== 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 +} + +// ===================== PARSE RESULT ===================== + +/** + * Result of parsing an ESI file + */ +export interface ESIParseResult { + success: boolean + data?: ESIFile + error?: string + warnings?: 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[] +} + +// ===================== 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' +} + +// ===================== 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 */ + esiDevice: ESIDevice +} + +/** + * 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 index a6fb81ed8..e547c2115 100644 --- a/src/types/ethercat/index.ts +++ b/src/types/ethercat/index.ts @@ -5,6 +5,9 @@ * Based on the runtime's /api/discovery/* REST API. */ +// Re-export ESI types +export * from './esi-types' + // ===================== ENUMS ===================== /** diff --git a/src/utils/ethercat/device-matcher.ts b/src/utils/ethercat/device-matcher.ts new file mode 100644 index 000000000..fbab25563 --- /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, + ESIRepositoryItem, + 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', '0x').replace('#X', '0x') + return parseInt(cleaned, 16) || 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: ESIRepositoryItem[]): 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: ESIRepositoryItem[], +): 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/esi-parser.ts b/src/utils/ethercat/esi-parser.ts new file mode 100644 index 000000000..4b538b13f --- /dev/null +++ b/src/utils/ethercat/esi-parser.ts @@ -0,0 +1,489 @@ +/** + * EtherCAT ESI (EtherCAT Slave Information) XML Parser + * + * Parses ESI XML files following ETG.2000 specification and extracts + * device information, PDO mappings, and channel configurations. + */ + +import type { + ESIChannel, + ESIDataType, + ESIDevice, + ESIDeviceType, + ESIFile, + ESIFMMU, + ESIGroup, + ESIParseResult, + ESIPdo, + ESIPdoEntry, + ESISyncManager, + ESIVendor, +} from '@root/types/ethercat/esi-types' + +/** + * Parse hex string to number + * Handles formats: "#x1234", "0x1234", "1234" + */ +function parseHexValue(value: string | undefined | null): string { + if (!value) return '0x0' + const cleaned = value.replace('#x', '0x').replace('#X', '0x') + return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` +} + +/** + * Parse hex string to decimal number + */ +function _hexToNumber(value: string | undefined | null): number { + if (!value) return 0 + const hex = parseHexValue(value) + return parseInt(hex, 16) || 0 +} + +/** + * Get text content from an element by tag name + */ +function getElementText(parent: Element, tagName: string): string | undefined { + const element = parent.getElementsByTagName(tagName)[0] + return element?.textContent?.trim() || undefined +} + +/** + * Get attribute value from an element + */ +function getAttribute(element: Element, attrName: string): string | undefined { + return element.getAttribute(attrName) || undefined +} + +/** + * Parse Vendor information + */ +function parseVendor(vendorElement: Element): ESIVendor { + return { + id: parseHexValue(getElementText(vendorElement, 'Id')), + name: getElementText(vendorElement, 'Name') || 'Unknown Vendor', + } +} + +/** + * Parse Group information + */ +function parseGroup(groupElement: Element): ESIGroup { + return { + type: getElementText(groupElement, 'Type') || '', + name: getElementText(groupElement, 'Name') || '', + imageUrl: getElementText(groupElement, 'ImageData16x14'), + description: getElementText(groupElement, 'Comment'), + } +} + +/** + * Parse FMMU configuration + */ +function parseFMMU(fmmuElement: Element): ESIFMMU { + const text = fmmuElement.textContent?.trim() || 'Outputs' + return { + type: text as ESIFMMU['type'], + } +} + +/** + * Parse Sync Manager configuration + */ +function parseSyncManager(smElement: Element, index: number): ESISyncManager { + const typeMap: Record = { + MbxOut: 'MbxOut', + MbxIn: 'MbxIn', + Outputs: 'Outputs', + Inputs: 'Inputs', + } + + return { + index, + startAddress: parseHexValue(getAttribute(smElement, 'StartAddress')), + controlByte: parseHexValue(getAttribute(smElement, 'ControlByte')), + defaultSize: parseInt(getAttribute(smElement, 'DefaultSize') || '0', 10), + enable: getAttribute(smElement, 'Enable') !== '0', + type: typeMap[smElement.textContent?.trim() || 'Outputs'] || 'Outputs', + } +} + +/** + * Parse PDO Entry + */ +function parsePdoEntry(entryElement: Element): ESIPdoEntry | null { + const index = getElementText(entryElement, 'Index') + const bitLen = parseInt(getElementText(entryElement, 'BitLen') || '0', 10) + + // Skip padding entries (entries without index or with 0 bitlen used for alignment) + if (!index && bitLen > 0) { + // This is likely a padding entry, we'll include it but mark it + return { + index: '0x0000', + subIndex: '0x00', + bitLen, + name: 'Padding', + dataType: 'BIT', + } + } + + if (!index) return null + + return { + index: parseHexValue(index), + subIndex: parseHexValue(getElementText(entryElement, 'SubIndex')), + bitLen, + name: getElementText(entryElement, 'Name') || 'Unnamed', + dataType: getElementText(entryElement, 'DataType') || 'BYTE', + comment: getElementText(entryElement, 'Comment'), + } +} + +/** + * Parse PDO (RxPdo or TxPdo) + */ +function parsePdo(pdoElement: Element): ESIPdo { + const entries: ESIPdoEntry[] = [] + const entryElements = pdoElement.getElementsByTagName('Entry') + + for (let i = 0; i < entryElements.length; i++) { + const entry = parsePdoEntry(entryElements[i]) + if (entry) { + entries.push(entry) + } + } + + return { + index: parseHexValue(getElementText(pdoElement, 'Index')), + name: getElementText(pdoElement, 'Name') || 'Unnamed PDO', + fixed: getAttribute(pdoElement, 'Fixed')?.toLowerCase() === 'true', + mandatory: getAttribute(pdoElement, 'Mandatory')?.toLowerCase() === 'true', + smIndex: getAttribute(pdoElement, 'Sm') ? parseInt(getAttribute(pdoElement, 'Sm')!, 10) : undefined, + entries, + } +} + +/** + * Parse Device Type information + */ +function parseDeviceType(typeElement: Element): ESIDeviceType { + return { + productCode: parseHexValue(getAttribute(typeElement, 'ProductCode')), + revisionNo: parseHexValue(getAttribute(typeElement, 'RevisionNo')), + name: typeElement.textContent?.trim() || 'Unknown Type', + } +} + +/** + * Parse Device + */ +function parseDevice(deviceElement: Element, groups: ESIGroup[]): ESIDevice { + // Parse Type + const typeElement = deviceElement.getElementsByTagName('Type')[0] + const type = typeElement ? parseDeviceType(typeElement) : { productCode: '0x0', revisionNo: '0x0', name: 'Unknown' } + + // Get group reference + const groupType = getElementText(deviceElement, 'GroupType') + const group = groups.find((g) => g.type === groupType) + + // Parse FMMUs + const fmmu: ESIFMMU[] = [] + const fmmuElements = deviceElement.getElementsByTagName('Fmmu') + for (let i = 0; i < fmmuElements.length; i++) { + fmmu.push(parseFMMU(fmmuElements[i])) + } + + // Parse Sync Managers + const syncManagers: ESISyncManager[] = [] + const smElements = deviceElement.getElementsByTagName('Sm') + for (let i = 0; i < smElements.length; i++) { + syncManagers.push(parseSyncManager(smElements[i], i)) + } + + // Parse RxPDOs + const rxPdo: ESIPdo[] = [] + const rxPdoElements = deviceElement.getElementsByTagName('RxPdo') + for (let i = 0; i < rxPdoElements.length; i++) { + rxPdo.push(parsePdo(rxPdoElements[i])) + } + + // Parse TxPDOs + const txPdo: ESIPdo[] = [] + const txPdoElements = deviceElement.getElementsByTagName('TxPdo') + for (let i = 0; i < txPdoElements.length; i++) { + txPdo.push(parsePdo(txPdoElements[i])) + } + + return { + type, + name: getElementText(deviceElement, 'Name') || 'Unknown Device', + groupName: group?.name, + physics: getAttribute(deviceElement, 'Physics'), + fmmu, + syncManagers, + rxPdo, + txPdo, + description: getElementText(deviceElement, 'Comment'), + } +} + +/** + * 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 +} + +/** + * Parse ESI XML string + */ +export function parseESI(xmlString: string, filename?: string): ESIParseResult { + const warnings: string[] = [] + + try { + const parser = new DOMParser() + const doc = parser.parseFromString(xmlString, 'text/xml') + + // Check for parse errors + const parseError = doc.getElementsByTagName('parsererror')[0] + if (parseError) { + return { + success: false, + error: `XML parse error: ${parseError.textContent}`, + } + } + + // Get root element + const root = doc.getElementsByTagName('EtherCATInfo')[0] + if (!root) { + return { + success: false, + error: 'Invalid ESI file: Missing EtherCATInfo root element', + } + } + + // Parse Vendor + const vendorElement = root.getElementsByTagName('Vendor')[0] + if (!vendorElement) { + return { + success: false, + error: 'Invalid ESI file: Missing Vendor information', + } + } + const vendor = parseVendor(vendorElement) + + // Parse Groups + const groups: ESIGroup[] = [] + const descriptionsElement = root.getElementsByTagName('Descriptions')[0] + if (descriptionsElement) { + const groupsElement = descriptionsElement.getElementsByTagName('Groups')[0] + if (groupsElement) { + const groupElements = groupsElement.getElementsByTagName('Group') + for (let i = 0; i < groupElements.length; i++) { + groups.push(parseGroup(groupElements[i])) + } + } + } + + // Parse Devices + const devices: ESIDevice[] = [] + if (descriptionsElement) { + const devicesElement = descriptionsElement.getElementsByTagName('Devices')[0] + if (devicesElement) { + const deviceElements = devicesElement.getElementsByTagName('Device') + for (let i = 0; i < deviceElements.length; i++) { + devices.push(parseDevice(deviceElements[i], groups)) + } + } + } + + if (devices.length === 0) { + warnings.push('No devices found in ESI file') + } + + // Parse InfoData (optional metadata) + const infoDataElement = root.getElementsByTagName('InfoData')[0] + const infoData = infoDataElement + ? { + version: getElementText(infoDataElement, 'Version'), + creationDate: getElementText(infoDataElement, 'CreationDate'), + modificationDate: getElementText(infoDataElement, 'ModificationDate'), + vendorUrl: getElementText(infoDataElement, 'VendorUrl'), + } + : undefined + + const result: ESIFile = { + vendor, + groups, + devices, + filename, + infoData, + } + + return { + success: true, + data: result, + warnings: warnings.length > 0 ? warnings : undefined, + } + } catch (error) { + return { + success: false, + error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** + * 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, + } +} From a9e3d661735f583adcbd4d87c68a43f8285777d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 5 Feb 2026 14:58:05 -0300 Subject: [PATCH 03/31] fix: improve repository table scroll and show channel counts instead of bytes - Add flex-1 and overflow-hidden to repository table container for proper scrolling - Display input/output channel counts instead of byte sizes in configured devices - Rename I/O column header to "Channels (In/Out)" for clarity Co-Authored-By: Claude Opus 4.5 --- .../editor/device/ethercat/components/configured-device-row.tsx | 2 +- .../editor/device/ethercat/components/configured-devices.tsx | 2 +- .../editor/device/ethercat/components/esi-repository-table.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index 6b5d8366d..e62d640d4 100644 --- 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 @@ -43,7 +43,7 @@ const ConfiguredDeviceRow = ({ return getDeviceSummary(esiDevice) }, [esiDevice]) - const ioSummary = summary ? `${summary.totalInputBytes}B/${summary.totalOutputBytes}B` : '-' + const ioSummary = summary ? `${summary.inputChannelCount} / ${summary.outputChannelCount}` : '-' return ( <> 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 index 44ef26c06..980b54eeb 100644 --- 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 @@ -86,7 +86,7 @@ const ConfiguredDevices = ({ devices, repository, onAddDevice, onRemoveDevice }: Position - I/O + Channels (In/Out) Source 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 index bd3141389..fa1e49595 100644 --- 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 @@ -42,7 +42,7 @@ const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: ESIReposit const totalDevices = repository.reduce((sum, item) => sum + item.devices.length, 0) return ( -
+
{/* Header with count and clear button */}
From d73e20d11d1dc1d385b4fd0d8d6ca8373cfc3dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 5 Feb 2026 17:03:03 -0300 Subject: [PATCH 04/31] feat: add ESI XML file persistence to project directory Add ESIService to persist ESI XML files in the project's devices/esi/ directory. XMLs are now saved when uploaded and loaded automatically when the project is opened, ensuring they persist across sessions. - Create ESIService with load/save/delete operations for ESI files - Add IPC handlers for ESI repository management - Update EtherCAT editor to load repository from disk on mount - Update ESIRepository component to persist changes automatically - Store XML files with UUID filenames and maintain repository.json index Co-Authored-By: Claude Opus 4.5 --- src/main/modules/ipc/main.ts | 138 ++++++++++ src/main/modules/ipc/renderer.ts | 80 ++++++ src/main/services/esi-service/index.ts | 258 ++++++++++++++++++ .../components/esi-repository-table.tsx | 8 +- .../ethercat/components/esi-repository.tsx | 120 ++++++-- .../device/ethercat/components/esi-upload.tsx | 12 +- .../editor/device/ethercat/index.tsx | 81 +++++- 7 files changed, 670 insertions(+), 27 deletions(-) create mode 100644 src/main/services/esi-service/index.ts diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 4c9f5686f..ba8e84f75 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,3 +1,4 @@ +import { ESIService } from '@root/main/services/esi-service' import { getProjectPath } from '@root/main/utils' import type { EtherCATScanRequest, @@ -9,6 +10,7 @@ import type { 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' @@ -48,6 +50,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 @@ -779,6 +782,132 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== ESI REPOSITORY HANDLERS ===================== + + /** + * Load ESI repository index from project + */ + handleESILoadRepositoryIndex = async ( + _event: IpcMainInvokeEvent, + 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 + }> => { + try { + const index = await this.esiService.loadRepositoryIndex(projectPath) + return { success: true, data: index } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save ESI repository index to project + */ + handleESISaveRepositoryIndex = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + items: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveRepositoryIndex(projectPath, items) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save an ESI XML file to project + */ + handleESISaveXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + xmlContent: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveXmlFile(projectPath, itemId, xmlContent) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Load an ESI XML file from project + */ + handleESILoadXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> => { + try { + return await this.esiService.loadXmlFile(projectPath, itemId) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Delete an ESI XML file from project + */ + handleESIDeleteXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.deleteXmlFile(projectPath, itemId) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save a complete ESI repository item (XML + update index) + */ + handleESISaveRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveRepositoryItem(projectPath, item, xmlContent, existingItems) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Delete an ESI repository item (XML + update index) + */ + handleESIDeleteRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.deleteRepositoryItem(projectPath, itemId, existingItems) + } catch (error) { + return { success: false, error: String(error) } + } + } + // ===================== IPC HANDLER REGISTRATION ===================== setupMainIpcListener() { // Project-related handlers @@ -865,6 +994,15 @@ class MainProcessBridge implements MainIpcModule { this.ipcMain.handle('ethercat:scan', this.handleEtherCATScan) this.ipcMain.handle('ethercat:test', this.handleEtherCATTest) this.ipcMain.handle('ethercat:validate', this.handleEtherCATValidate) + + // ===================== 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) } // ===================== HANDLER METHODS ===================== diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 9a030d822..13cf02471 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -9,6 +9,7 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' +import type { ESIRepositoryItem } 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' @@ -418,5 +419,84 @@ const rendererProcessBridge = { validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + + // ===================== 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), } export default rendererProcessBridge diff --git a/src/main/services/esi-service/index.ts b/src/main/services/esi-service/index.ts new file mode 100644 index 000000000..14c8e6572 --- /dev/null +++ b/src/main/services/esi-service/index.ts @@ -0,0 +1,258 @@ +import { fileOrDirectoryExists } from '@root/main/utils' +import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import { promises } from 'fs' +import { join } from 'path' + +/** + * ESI Repository Index stored in devices/esi/repository.json + */ +interface ESIRepositoryIndex { + version: number + items: Array<{ + id: string + filename: string + vendorId: string + vendorName: string + deviceCount: number + loadedAt: number + warnings?: string[] + }> +} + +/** + * 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' + + /** + * Get the ESI directory path for a project + */ + private getEsiDir(projectPath: string): string { + const basePath = projectPath.endsWith('/project.json') ? projectPath.slice(0, -'/project.json'.length) : 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) + } + + /** + * Get the path for an ESI XML file + */ + private getXmlPath(projectPath: string, itemId: string): string { + 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) + + if (!fileOrDirectoryExists(repoPath)) { + return null + } + + try { + const content = await promises.readFile(repoPath, 'utf-8') + const index = JSON.parse(content) as ESIRepositoryIndex + return index + } catch (error) { + console.error('Error reading ESI repository index:', error) + return null + } + } + + /** + * Save the ESI repository index to disk + */ + 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 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) + + if (!fileOrDirectoryExists(xmlPath)) { + return { success: false, error: 'XML file not found' } + } + + const content = await promises.readFile(xmlPath, 'utf-8') + return { success: true, content } + } catch (error) { + 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) + + if (fileOrDirectoryExists(xmlPath)) { + await promises.unlink(xmlPath) + } + + return { success: true } + } catch (error) { + 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 { + // Save the XML file + const xmlResult = await this.saveXmlFile(projectPath, item.id, xmlContent) + if (!xmlResult.success) { + return xmlResult + } + + // Update the index with the new item + const updatedItems = [...existingItems.filter((i) => i.id !== item.id), item] + return this.saveRepositoryIndex(projectPath, updatedItems) + } + + /** + * Delete a repository item (XML + update index) + */ + async deleteRepositoryItem( + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ): Promise { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Update the index without the deleted item + const updatedItems = existingItems.filter((i) => i.id !== itemId) + return this.saveRepositoryIndex(projectPath, updatedItems) + } + + /** + * 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]/editor/device/ethercat/components/esi-repository-table.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx index fa1e49595..b92c0327b 100644 --- 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 @@ -5,8 +5,8 @@ import { useCallback, useState } from 'react' type ESIRepositoryTableProps = { repository: ESIRepositoryItem[] - onRemoveItem: (itemId: string) => void - onClearAll: () => void + onRemoveItem: (itemId: string) => void | Promise + onClearAll: () => void | Promise } /** @@ -49,7 +49,7 @@ const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: ESIReposit Loaded Files ({repository.length}) - {totalDevices} device(s)
)} - {summary && ( + {esiDevice && ( <>
Input Channels:{' '} - {summary.inputChannelCount} + {esiDevice.inputChannelCount}
Output Channels:{' '} - {summary.outputChannelCount} + {esiDevice.outputChannelCount}
)} 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 index 980b54eeb..06dc10fc3 100644 --- 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 @@ -1,13 +1,13 @@ import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' import TableActions from '@root/renderer/components/_atoms/table-actions' -import type { ConfiguredEtherCATDevice, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import type { ConfiguredEtherCATDevice, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { useCallback, useState } from 'react' import { ConfiguredDeviceRow } from './configured-device-row' type ConfiguredDevicesProps = { devices: ConfiguredEtherCATDevice[] - repository: ESIRepositoryItem[] + repository: ESIRepositoryItemLight[] onAddDevice: () => void onRemoveDevice: (deviceId: string) => void } 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 index 07cd784d8..2941638f7 100644 --- 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 @@ -1,13 +1,13 @@ import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal' -import type { ESIDevice, ESIDeviceRef, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +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: ESIDevice, repoItem: ESIRepositoryItem) => void - repository: ESIRepositoryItem[] + onSelectDevice: (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => void + repository: ESIRepositoryItemLight[] } /** @@ -29,8 +29,8 @@ const DeviceBrowserModal = ({ isOpen, onClose, onSelectDevice, repository }: Dev vendorId: string vendorName: string devices: Array<{ - repoItem: ESIRepositoryItem - device: ESIDevice + repoItem: ESIRepositoryItemLight + device: ESIDeviceSummary deviceIndex: number }> } 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 index b92c0327b..f5be04129 100644 --- 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 @@ -1,10 +1,10 @@ import { ArrowIcon } from '@root/renderer/assets/icons' -import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' import { useCallback, useState } from 'react' type ESIRepositoryTableProps = { - repository: ESIRepositoryItem[] + repository: ESIRepositoryItemLight[] onRemoveItem: (itemId: string) => void | Promise onClearAll: () => void | Promise } @@ -94,7 +94,7 @@ const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: ESIReposit } type RepositoryItemRowProps = { - item: ESIRepositoryItem + item: ESIRepositoryItemLight isExpanded: boolean onToggleExpand: () => void onRemove: () => void 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 index 7b1b64bfa..60e437caa 100644 --- 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 @@ -1,14 +1,14 @@ -import type { ESIParseResult, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { useCallback, useState } from 'react' import { ESIRepositoryTable } from './esi-repository-table' -import { ESIUpload, parseResultsToRepositoryItems } from './esi-upload' +import { ESIUpload } from './esi-upload' type ESIServiceResponse = { success: boolean; error?: string } type ESIRepositoryProps = { - repository: ESIRepositoryItem[] - onRepositoryChange: (repository: ESIRepositoryItem[]) => void + repository: ESIRepositoryItemLight[] + onRepositoryChange: (repository: ESIRepositoryItemLight[]) => void projectPath: string isLoading?: boolean } @@ -18,75 +18,19 @@ type ESIRepositoryProps = { * * Manages the ESI file repository with upload and display functionality. * Combines upload zone with repository table. - * Automatically persists changes to disk. + * 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( - async (results: Array<{ result: ESIParseResult; filename: string; xmlContent: string }>) => { - const { items, errors } = parseResultsToRepositoryItems(results) - - // Add new items to existing repository (avoiding duplicates by filename) - const existingFilenames = new Set(repository.map((r) => r.filename)) - const newItems = items.filter((item) => !existingFilenames.has(item.filename)) - - if (newItems.length > 0) { - setIsSaving(true) - - // Save each new item to disk - const saveErrors: Array<{ filename: string; error: string }> = [] - const savedItems: ESIRepositoryItem[] = [] - - for (let i = 0; i < newItems.length; i++) { - const item = newItems[i] - // Find the corresponding XML content from the results - const resultEntry = results.find( - (r) => r.result.success && r.result.data && r.result.data.filename === item.filename, - ) - - if (resultEntry && resultEntry.xmlContent) { - try { - const saveResult: ESIServiceResponse = await window.bridge.esiSaveRepositoryItem( - projectPath, - item, - resultEntry.xmlContent, - [...repository, ...savedItems], - ) - - if (saveResult.success) { - savedItems.push(item) - } else { - saveErrors.push({ - filename: item.filename, - error: saveResult.error ?? 'Failed to save file', - }) - } - } catch (err) { - saveErrors.push({ - filename: item.filename, - error: String(err), - }) - } - } - } - - setIsSaving(false) - - // Update repository with successfully saved items - if (savedItems.length > 0) { - onRepositoryChange([...repository, ...savedItems]) - } - - // Show errors for failed files (both parse errors and save errors) - setUploadErrors([...errors, ...saveErrors]) - } else { - // Show only parse errors - setUploadErrors(errors) - } + (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => { + // Items are already parsed and saved by the main process + onRepositoryChange(items) + setUploadErrors(errors ?? []) }, - [repository, onRepositoryChange, projectPath], + [onRepositoryChange], ) const handleRemoveItem = useCallback( @@ -94,10 +38,16 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading setIsSaving(true) try { - const result: ESIServiceResponse = await window.bridge.esiDeleteRepositoryItem(projectPath, itemId, repository) + // We still use the old delete IPC since it works for both v1/v2 + const result: ESIServiceResponse = await window.bridge.esiDeleteXmlFile(projectPath, itemId) if (result.success) { - onRepositoryChange(repository.filter((item) => item.id !== itemId)) + const updatedRepo = repository.filter((item) => item.id !== itemId) + // Re-save the v2 index + await window.bridge.esiMigrateRepository(projectPath).catch(() => { + // Fallback: just update state + }) + onRepositoryChange(updatedRepo) } else { console.error('Failed to delete ESI item:', result.error) } @@ -114,22 +64,19 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading setIsSaving(true) try { - // Delete all items from disk - for (const item of repository) { - await (window.bridge.esiDeleteXmlFile(projectPath, item.id) as Promise) + const result: ESIServiceResponse = await window.bridge.esiClearRepository(projectPath) + if (result.success) { + onRepositoryChange([]) + setUploadErrors([]) + } else { + console.error('Failed to clear ESI repository:', result.error) } - - // Save empty repository index - await (window.bridge.esiSaveRepositoryIndex(projectPath, []) as Promise) - - onRepositoryChange([]) - setUploadErrors([]) } catch (err) { console.error('Error clearing ESI repository:', err) } finally { setIsSaving(false) } - }, [repository, onRepositoryChange, projectPath]) + }, [onRepositoryChange, projectPath]) const handleDismissError = useCallback((filename: string) => { setUploadErrors((prev) => prev.filter((e) => e.filename !== filename)) @@ -141,9 +88,10 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading
{/* Upload Area */} void handleFilesLoaded(results)} - repositoryCount={repository.length} + onFilesLoaded={handleFilesLoaded} + repository={repository} isLoading={isProcessing} + projectPath={projectPath} /> {/* Error Messages */} 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 index 31360447a..6ddd3ac5f 100644 --- 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 @@ -1,25 +1,38 @@ -import { ArrowIcon } from '@root/renderer/assets/icons' -import type { ESIParseResult, ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -import { parseESI } from '@root/utils/ethercat/esi-parser' import { useCallback, useRef, useState } from 'react' -import { v4 as uuidv4 } from 'uuid' + +import { ESIParseProgress } from './esi-parse-progress' + +type ParseProgress = { + active: boolean + currentFile?: string + currentFileIndex: number + totalFiles: number + percentage: number +} type ESIUploadProps = { - onFilesLoaded: (results: Array<{ result: ESIParseResult; filename: string; xmlContent: string }>) => void - repositoryCount?: number + 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. - * Supports batch processing with non-blocking error handling. + * Files are read and sent to the main process one at a time to avoid memory issues. */ -const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ESIUploadProps) => { +const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath }: ESIUploadProps) => { const [isDragging, setIsDragging] = useState(false) - const [processingCount, setProcessingCount] = useState(0) + const [parseProgress, setParseProgress] = useState({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 0, + }) const fileInputRef = useRef(null) const processFiles = useCallback( @@ -27,39 +40,57 @@ const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ES const xmlFiles = Array.from(files).filter((file) => file.name.endsWith('.xml')) if (xmlFiles.length === 0) { - onFilesLoaded([ - { - result: { success: false, error: 'No XML files found. Please upload .xml ESI files.' }, - filename: '', - xmlContent: '', - }, - ]) + onFilesLoaded(repository, [{ filename: '', error: 'No XML files found. Please upload .xml ESI files.' }]) return } - setProcessingCount(xmlFiles.length) + 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 results: Array<{ result: ESIParseResult; filename: string; xmlContent: string }> = [] + // 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), + }) - for (const file of xmlFiles) { try { const text = await file.text() - const result = parseESI(text, file.name) - results.push({ result, filename: file.name, xmlContent: 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) { - const errorMessage = err instanceof Error ? err.message : 'Failed to read file' - results.push({ - result: { success: false, error: errorMessage }, - filename: file.name, - xmlContent: '', - }) + errors.push({ filename: file.name, error: err instanceof Error ? err.message : String(err) }) } } - setProcessingCount(0) - onFilesLoaded(results) + setParseProgress({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 100, + }) + + onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) }, - [onFilesLoaded], + [onFilesLoaded, repository, projectPath], ) const handleDragOver = useCallback((e: React.DragEvent) => { @@ -104,7 +135,7 @@ const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ES fileInputRef.current?.click() }, []) - const isProcessing = isLoading || processingCount > 0 + const isProcessing = isLoading || parseProgress.active return (
@@ -126,12 +157,14 @@ const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ES > - {isProcessing ? ( -
- - - Processing {processingCount > 0 ? `${processingCount} file(s)` : ''}... - + {parseProgress.active ? ( +
+
) : (
@@ -154,9 +187,9 @@ const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ES Supports multiple .xml ESI files (ETG.2000) - {repositoryCount > 0 && ( + {repository.length > 0 && ( - {repositoryCount} file(s) currently loaded + {repository.length} file(s) currently loaded )}
@@ -166,37 +199,4 @@ const ESIUpload = ({ onFilesLoaded, repositoryCount = 0, isLoading = false }: ES ) } -/** - * Convert parse results to repository items - */ -export function parseResultsToRepositoryItems( - results: Array<{ result: ESIParseResult; filename: string; xmlContent: string }>, -): { - items: ESIRepositoryItem[] - errors: Array<{ filename: string; error: string }> -} { - const items: ESIRepositoryItem[] = [] - const errors: Array<{ filename: string; error: string }> = [] - - for (const { result, filename } of results) { - if (result.success && result.data) { - items.push({ - id: uuidv4(), - filename: result.data.filename || filename, - vendor: result.data.vendor, - devices: result.data.devices, - loadedAt: Date.now(), - warnings: result.warnings, - }) - } else { - errors.push({ - filename, - error: result.error || 'Unknown parsing error', - }) - } - } - - return { items, errors } -} - export { ESIUpload } diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 1340d0297..802a5b2e6 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -3,14 +3,13 @@ import { useOpenPLCStore } from '@root/renderer/store' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { ConfiguredEtherCATDevice, - ESIDevice, ESIDeviceRef, - ESIRepositoryItem, + ESIDeviceSummary, + ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' import { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from '@root/utils/ethercat/device-matcher' -import { parseESI } from '@root/utils/ethercat/esi-parser' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' @@ -43,8 +42,8 @@ const EtherCATEditor = () => { // Tab state const [activeTab, setActiveTab] = useState('repository') - // Repository state - const [repository, setRepository] = useState([]) + // Repository state (now lightweight) + const [repository, setRepository] = useState([]) const [isLoadingRepository, setIsLoadingRepository] = useState(false) const [_repositoryError, setRepositoryError] = useState(null) const repositoryLoadedRef = useRef(false) @@ -171,7 +170,7 @@ const EtherCATEditor = () => { } }, [isConnectedToRuntime, ipAddress, jwtToken, selectedInterface]) - // Load ESI repository from disk when component mounts + // Load ESI repository from cache (v2) or migrate (v1) useEffect(() => { const loadRepository = async () => { if (!projectPath || repositoryLoadedRef.current) return @@ -180,48 +179,18 @@ const EtherCATEditor = () => { setRepositoryError(null) try { - // Load the repository index from disk - const indexResult = await window.bridge.esiLoadRepositoryIndex(projectPath) - - if (!indexResult.success || !indexResult.data) { - // No repository exists yet - that's okay - repositoryLoadedRef.current = true - setIsLoadingRepository(false) - return - } - - // For each item in the index, load and parse the XML file - const loadedItems: ESIRepositoryItem[] = [] - - for (const indexItem of indexResult.data.items) { - const { id, filename, loadedAt, warnings: indexWarnings } = indexItem - - try { - const xmlResult = await window.bridge.esiLoadXmlFile(projectPath, id) - - if (xmlResult.success && xmlResult.content) { - // Re-parse the XML to get full device information - const content: string = xmlResult.content - const fname: string = filename - const parseResult = parseESI(content, fname) - - if (parseResult.success && parseResult.data) { - loadedItems.push({ - id, - filename, - vendor: parseResult.data.vendor, - devices: parseResult.data.devices, - loadedAt, - warnings: parseResult.warnings || indexWarnings, - }) - } - } - } catch (err) { - console.error(`Failed to load ESI file ${filename}:`, err) + const result = await window.bridge.esiLoadRepositoryLight(projectPath) + + if (result.success && result.items) { + setRepository(result.items) + } else if (result.needsMigration) { + // One-time migration from v1 to v2 + const migrationResult = await window.bridge.esiMigrateRepository(projectPath) + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) } } - setRepository(loadedItems) repositoryLoadedRef.current = true } catch (error) { console.error('Failed to load ESI repository:', error) @@ -313,7 +282,7 @@ const EtherCATEditor = () => { // Handle adding device from browser modal const handleAddDeviceFromBrowser = useCallback( - (ref: ESIDeviceRef, device: ESIDevice, repoItem: ESIRepositoryItem) => { + (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { const newDevice: ConfiguredEtherCATDevice = { id: uuidv4(), name: device.name, diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index c31f735c4..6f84e62bd 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -265,6 +265,33 @@ export interface ESIParseResult { 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 ===================== /** @@ -285,6 +312,25 @@ export interface ESIRepositoryItem { 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 ===================== /** @@ -336,8 +382,8 @@ export interface DeviceMatch { deviceIndex: number /** Quality of the match */ matchQuality: DeviceMatchQuality - /** The matched ESI device */ - esiDevice: ESIDevice + /** The matched ESI device (lightweight summary) */ + esiDevice: ESIDeviceSummary } /** diff --git a/src/utils/ethercat/device-matcher.ts b/src/utils/ethercat/device-matcher.ts index fbab25563..cf796d1d2 100644 --- a/src/utils/ethercat/device-matcher.ts +++ b/src/utils/ethercat/device-matcher.ts @@ -8,7 +8,7 @@ import type { EtherCATDevice } from '@root/types/ethercat' import type { DeviceMatch, DeviceMatchQuality, - ESIRepositoryItem, + ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/types/ethercat/esi-types' @@ -58,7 +58,7 @@ function getMatchQuality( /** * Find all matches for a single scanned device in the repository */ -function findMatchesForDevice(scannedDevice: EtherCATDevice, repository: ESIRepositoryItem[]): DeviceMatch[] { +function findMatchesForDevice(scannedDevice: EtherCATDevice, repository: ESIRepositoryItemLight[]): DeviceMatch[] { const matches: DeviceMatch[] = [] for (const repoItem of repository) { @@ -105,7 +105,7 @@ function findMatchesForDevice(scannedDevice: EtherCATDevice, repository: ESIRepo */ export function matchDevicesToRepository( scannedDevices: EtherCATDevice[], - repository: ESIRepositoryItem[], + repository: ESIRepositoryItemLight[], ): ScannedDeviceMatch[] { return scannedDevices.map((device) => { const matches = findMatchesForDevice(device, repository) From f45963376a9a0627c10977ac6ff8046c3b45d209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 6 Feb 2026 11:43:35 -0300 Subject: [PATCH 06/31] fix: replace individual error banners with collapsible summary Parse errors no longer take over the screen. Shows a single compact banner with error count, expandable to a scrollable detail list. Co-Authored-By: Claude Opus 4.6 --- .../ethercat/components/esi-repository.tsx | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) 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 index 60e437caa..e3d29292f 100644 --- 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 @@ -78,9 +78,7 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading } }, [onRepositoryChange, projectPath]) - const handleDismissError = useCallback((filename: string) => { - setUploadErrors((prev) => prev.filter((e) => e.filename !== filename)) - }, []) + const [errorsExpanded, setErrorsExpanded] = useState(false) const isProcessing = isLoading || isSaving @@ -94,42 +92,52 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading projectPath={projectPath} /> - {/* Error Messages */} + {/* Error Summary (collapsible) */} {uploadErrors.length > 0 && ( -
- {uploadErrors.map((error) => ( -
+
+ + +
+ {errorsExpanded && ( +
+ {uploadErrors.map((error) => ( +
{error.filename || 'File'}: {error.error} - -
- +
+ ))}
- ))} + )}
)} From 335e76b218b188b16c34f4c29e08662c9e99bb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 6 Feb 2026 11:53:03 -0300 Subject: [PATCH 07/31] fix: add loading feedback to Clear All button in ESI repository Co-Authored-By: Claude Opus 4.6 --- .../device/ethercat/components/esi-repository-table.tsx | 8 +++++--- .../editor/device/ethercat/components/esi-repository.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) 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 index f5be04129..42e4fd32e 100644 --- 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 @@ -7,6 +7,7 @@ type ESIRepositoryTableProps = { repository: ESIRepositoryItemLight[] onRemoveItem: (itemId: string) => void | Promise onClearAll: () => void | Promise + isLoading?: boolean } /** @@ -14,7 +15,7 @@ type ESIRepositoryTableProps = { * * Displays loaded ESI files with expandable rows showing contained devices. */ -const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: ESIRepositoryTableProps) => { +const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll, isLoading = false }: ESIRepositoryTableProps) => { const [expandedItems, setExpandedItems] = useState>(new Set()) const handleToggleExpand = useCallback((itemId: string) => { @@ -50,9 +51,10 @@ const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll }: ESIReposit
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 index e3d29292f..ce97cf01e 100644 --- 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 @@ -143,7 +143,12 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading {/* Repository Table */}
- +
) From b0cf48e779af491c41879bd64d4ac8c66bc47d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 9 Feb 2026 09:05:22 -0300 Subject: [PATCH 08/31] fix: address code review issues in ESI subsystem - Fix critical parseHexToNumber bug (parseInt with 0x prefix returned 0) - Fix Windows path separator in ESI service getEsiDir - Replace esiMigrateRepository with targeted v2 index update on delete - Add try/catch to IPC handlers for parseAndSaveFile and clearRepository - Display repository load errors with retry button - Add 100MB file size limit to ESI upload - Validate FMMU type against allowed values in both parsers - Fix TOCTOU race conditions in file operations (use ENOENT handling) - Add UUID validation on itemId to prevent path traversal - Fix PDO index forced to 0x0 when entries are empty - Fix parseHexValue regex to replace all #x occurrences - Remove dead _hexToNumber function from renderer parser Co-Authored-By: Claude Opus 4.6 --- src/main/modules/ipc/main.ts | 14 ++++- .../services/esi-service/esi-parser-main.ts | 8 ++- src/main/services/esi-service/index.ts | 58 +++++++++++++------ .../ethercat/components/esi-repository.tsx | 8 +-- .../device/ethercat/components/esi-upload.tsx | 10 ++++ .../editor/device/ethercat/index.tsx | 50 ++++++++++++---- src/utils/ethercat/device-matcher.ts | 4 +- src/utils/ethercat/esi-parser.ts | 14 +---- 8 files changed, 111 insertions(+), 55 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 62ef83095..ee607dc1f 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -870,7 +870,7 @@ class MainProcessBridge implements MainIpcModule { itemId: string, ): Promise<{ success: boolean; error?: string }> => { try { - return await this.esiService.deleteXmlFile(projectPath, itemId) + return await this.esiService.deleteRepositoryItemV2(projectPath, itemId) } catch (error) { return { success: false, error: String(error) } } @@ -920,7 +920,11 @@ class MainProcessBridge implements MainIpcModule { filename: string, content: string, ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> => { - return this.esiService.parseAndSaveFile(projectPath, filename, content) + try { + return await this.esiService.parseAndSaveFile(projectPath, filename, content) + } catch (error) { + return { success: false, error: String(error) } + } } /** @@ -930,7 +934,11 @@ class MainProcessBridge implements MainIpcModule { _event: IpcMainInvokeEvent, projectPath: string, ): Promise<{ success: boolean; error?: string }> => { - return this.esiService.clearRepository(projectPath) + try { + return await this.esiService.clearRepository(projectPath) + } catch (error) { + return { success: false, error: String(error) } + } } /** diff --git a/src/main/services/esi-service/esi-parser-main.ts b/src/main/services/esi-service/esi-parser-main.ts index c343f636a..d818595f2 100644 --- a/src/main/services/esi-service/esi-parser-main.ts +++ b/src/main/services/esi-service/esi-parser-main.ts @@ -28,7 +28,7 @@ import { XMLParser } from 'fast-xml-parser' function parseHexValue(value: string | number | undefined | null): string { if (value === undefined || value === null) return '0x0' const str = String(value) - const cleaned = str.replace('#x', '0x').replace('#X', '0x') + const cleaned = str.replace(/#x/gi, '0x') return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` } @@ -337,9 +337,11 @@ function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): 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) - fmmu.push({ type: (fmmuText || 'Outputs') as ESIFMMU['type'] }) + const fmmuType = validFmmuTypes.includes(fmmuText as ESIFMMU['type']) ? (fmmuText as ESIFMMU['type']) : 'Outputs' + fmmu.push({ type: fmmuType }) } // Parse Sync Managers @@ -406,7 +408,7 @@ function parseFullPdo(pdoEl: Record): ESIPdo { } return { - index: parseHexValue(entryElements.length > 0 ? (pdoEl['Index'] as string | undefined) : undefined), + 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', diff --git a/src/main/services/esi-service/index.ts b/src/main/services/esi-service/index.ts index 7b48e7139..18c293446 100644 --- a/src/main/services/esi-service/index.ts +++ b/src/main/services/esi-service/index.ts @@ -1,7 +1,7 @@ import { fileOrDirectoryExists } from '@root/main/utils' import type { ESIDeviceSummary, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { promises } from 'fs' -import { join } from 'path' +import { basename, dirname, join } from 'path' import { v4 as uuidv4 } from 'uuid' import { parseESILight } from './esi-parser-main' @@ -47,7 +47,7 @@ class ESIService { * Get the ESI directory path for a project */ private getEsiDir(projectPath: string): string { - const basePath = projectPath.endsWith('/project.json') ? projectPath.slice(0, -'/project.json'.length) : projectPath + const basePath = basename(projectPath) === 'project.json' ? dirname(projectPath) : projectPath return join(basePath, this.ESI_DIR) } @@ -58,10 +58,15 @@ class ESIService { 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 + * 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`) } @@ -81,15 +86,14 @@ class ESIService { async loadRepositoryIndex(projectPath: string): Promise { const repoPath = this.getRepositoryPath(projectPath) - if (!fileOrDirectoryExists(repoPath)) { - return null - } - 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 + } console.error('Error reading ESI repository index:', error) return null } @@ -191,14 +195,12 @@ class ESIService { ): Promise<{ success: boolean; content?: string; error?: string }> { try { const xmlPath = this.getXmlPath(projectPath, itemId) - - if (!fileOrDirectoryExists(xmlPath)) { - return { success: false, error: 'XML file not found' } - } - 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, @@ -213,13 +215,12 @@ class ESIService { async deleteXmlFile(projectPath: string, itemId: string): Promise { try { const xmlPath = this.getXmlPath(projectPath, itemId) - - if (fileOrDirectoryExists(xmlPath)) { - await promises.unlink(xmlPath) - } - + 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, @@ -267,6 +268,29 @@ class ESIService { return this.saveRepositoryIndex(projectPath, updatedItems) } + /** + * Delete a repository item and update the v2 index (no re-parsing) + */ + async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { + 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 */ 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 index ce97cf01e..e673c8429 100644 --- 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 @@ -38,16 +38,10 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading setIsSaving(true) try { - // We still use the old delete IPC since it works for both v1/v2 const result: ESIServiceResponse = await window.bridge.esiDeleteXmlFile(projectPath, itemId) if (result.success) { - const updatedRepo = repository.filter((item) => item.id !== itemId) - // Re-save the v2 index - await window.bridge.esiMigrateRepository(projectPath).catch(() => { - // Fallback: just update state - }) - onRepositoryChange(updatedRepo) + onRepositoryChange(repository.filter((item) => item.id !== itemId)) } else { console.error('Failed to delete ESI item:', result.error) } 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 index 6ddd3ac5f..921ffe3cb 100644 --- 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 @@ -55,6 +55,8 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath } 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] @@ -67,6 +69,14 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath } 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) diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 802a5b2e6..d4ad172b6 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -45,7 +45,8 @@ const EtherCATEditor = () => { // Repository state (now lightweight) const [repository, setRepository] = useState([]) const [isLoadingRepository, setIsLoadingRepository] = useState(false) - const [_repositoryError, setRepositoryError] = useState(null) + const [repositoryError, setRepositoryError] = useState(null) + const [repositoryLoadRetry, setRepositoryLoadRetry] = useState(0) const repositoryLoadedRef = useRef(false) // Configured devices state @@ -66,7 +67,7 @@ const EtherCATEditor = () => { const [scannedDevices, setScannedDevices] = useState([]) const [isScanning, setIsScanning] = useState(false) const [scanError, setScanError] = useState(null) - const [_scanMessage, setScanMessage] = useState('') + const [scanMessage, setScanMessage] = useState('') const [scanTimeMs, setScanTimeMs] = useState(null) // Discovery selection state @@ -183,15 +184,21 @@ const EtherCATEditor = () => { 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 (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 } - - repositoryLoadedRef.current = true } catch (error) { console.error('Failed to load ESI repository:', error) setRepositoryError(String(error)) @@ -201,7 +208,7 @@ const EtherCATEditor = () => { } void loadRepository() - }, [projectPath]) + }, [projectPath, repositoryLoadRetry]) // Check service status and fetch interfaces when runtime connection changes useEffect(() => { @@ -364,12 +371,29 @@ const EtherCATEditor = () => { {/* Repository Tab */} {activeTab === 'repository' && ( - + <> + {repositoryError && ( +
+

Failed to load repository: {repositoryError}

+ +
+ )} + + )} {/* Discovery Tab */} @@ -433,7 +457,9 @@ const EtherCATEditor = () => { {scanTimeMs !== null && ( - Completed in {scanTimeMs}ms + + Completed in {scanTimeMs}ms{scanMessage ? ` — ${scanMessage}` : ''} + )}
diff --git a/src/utils/ethercat/device-matcher.ts b/src/utils/ethercat/device-matcher.ts index cf796d1d2..cb267d051 100644 --- a/src/utils/ethercat/device-matcher.ts +++ b/src/utils/ethercat/device-matcher.ts @@ -17,8 +17,8 @@ import type { * Handles formats: "0x1234", "#x1234", "1234" */ function parseHexToNumber(hexString: string): number { - const cleaned = hexString.replace('#x', '0x').replace('#X', '0x') - return parseInt(cleaned, 16) || 0 + const cleaned = hexString.replace(/#x/gi, '0x') + return Number(cleaned) || 0 } /** diff --git a/src/utils/ethercat/esi-parser.ts b/src/utils/ethercat/esi-parser.ts index 4b538b13f..364354657 100644 --- a/src/utils/ethercat/esi-parser.ts +++ b/src/utils/ethercat/esi-parser.ts @@ -26,19 +26,10 @@ import type { */ function parseHexValue(value: string | undefined | null): string { if (!value) return '0x0' - const cleaned = value.replace('#x', '0x').replace('#X', '0x') + const cleaned = value.replace(/#x/gi, '0x') return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` } -/** - * Parse hex string to decimal number - */ -function _hexToNumber(value: string | undefined | null): number { - if (!value) return 0 - const hex = parseHexValue(value) - return parseInt(hex, 16) || 0 -} - /** * Get text content from an element by tag name */ @@ -81,8 +72,9 @@ function parseGroup(groupElement: Element): ESIGroup { */ function parseFMMU(fmmuElement: Element): ESIFMMU { const text = fmmuElement.textContent?.trim() || 'Outputs' + const validTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] return { - type: text as ESIFMMU['type'], + type: validTypes.includes(text as ESIFMMU['type']) ? (text as ESIFMMU['type']) : 'Outputs', } } From 017982f35346dc4f7605c50d6e650c0d49f23d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 9 Feb 2026 09:46:24 -0300 Subject: [PATCH 09/31] feat: add EtherCAT ESI repository and device configuration UI (#588) * feat: add EtherCAT ESI repository and device configuration UI Redesigns the EtherCAT editor with a 3-tab architecture: - Repository: Upload and manage multiple ESI XML files with batch processing - Discovery: Scan network with automatic device-to-repository matching - Configured Devices: View and manage added devices with expandable details New components: - ESI repository table with expandable file/device views - Device browser modal for manual device selection - Discovered device table with match quality badges - Configured device rows with info panels New utilities: - ESI parser for XML file processing - Device matcher for scan-to-repository matching (exact/partial/none) Co-Authored-By: Claude Opus 4.5 * fix: improve repository table scroll and show channel counts instead of bytes - Add flex-1 and overflow-hidden to repository table container for proper scrolling - Display input/output channel counts instead of byte sizes in configured devices - Rename I/O column header to "Channels (In/Out)" for clarity Co-Authored-By: Claude Opus 4.5 * feat: add ESI XML file persistence to project directory Add ESIService to persist ESI XML files in the project's devices/esi/ directory. XMLs are now saved when uploaded and loaded automatically when the project is opened, ensuring they persist across sessions. - Create ESIService with load/save/delete operations for ESI files - Add IPC handlers for ESI repository management - Update EtherCAT editor to load repository from disk on mount - Update ESIRepository component to persist changes automatically - Store XML files with UUID filenames and maintain repository.json index Co-Authored-By: Claude Opus 4.5 * feat: optimize ESI XML parsing with lazy loading and sequential upload - Add fast-xml-parser for high-performance XML parsing in main process - Implement two-level parsing: light summaries for listing, full on-demand - Upload files one at a time to prevent renderer memory crash with large batches - Cache device summaries in v2 repository index for instant reload - Add bulk clear repository operation (single IPC call instead of N) - Support automatic v1 to v2 repository migration - Handle multilingual ESI Name elements (prefer English LcId 1033) Co-Authored-By: Claude Opus 4.6 * fix: replace individual error banners with collapsible summary Parse errors no longer take over the screen. Shows a single compact banner with error count, expandable to a scrollable detail list. Co-Authored-By: Claude Opus 4.6 * fix: add loading feedback to Clear All button in ESI repository Co-Authored-By: Claude Opus 4.6 * fix: address code review issues in ESI subsystem - Fix critical parseHexToNumber bug (parseInt with 0x prefix returned 0) - Fix Windows path separator in ESI service getEsiDir - Replace esiMigrateRepository with targeted v2 index update on delete - Add try/catch to IPC handlers for parseAndSaveFile and clearRepository - Display repository load errors with retry button - Add 100MB file size limit to ESI upload - Validate FMMU type against allowed values in both parsers - Fix TOCTOU race conditions in file operations (use ENOENT handling) - Add UUID validation on itemId to prevent path traversal - Fix PDO index forced to 0x0 when entries are empty - Fix parseHexValue regex to replace all #x occurrences - Remove dead _hexToNumber function from renderer parser Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.5 --- package-lock.json | 38 +- package.json | 1 + src/main/modules/ipc/main.ts | 228 ++++++++ src/main/modules/ipc/renderer.ts | 124 +++++ .../services/esi-service/esi-parser-main.ts | 450 +++++++++++++++ src/main/services/esi-service/index.ts | 497 +++++++++++++++++ .../components/configured-device-row.tsx | 161 ++++++ .../components/configured-devices.tsx | 122 ++++ .../components/device-browser-modal.tsx | 272 +++++++++ .../components/discovered-device-table.tsx | 184 ++++++ .../components/esi-channels-table.tsx | 234 ++++++++ .../ethercat/components/esi-device-info.tsx | 165 ++++++ .../components/esi-parse-progress.tsx | 41 ++ .../components/esi-repository-table.tsx | 217 ++++++++ .../ethercat/components/esi-repository.tsx | 151 +++++ .../device/ethercat/components/esi-upload.tsx | 212 +++++++ .../editor/device/ethercat/index.tsx | 524 ++++++++++++------ src/types/ethercat/esi-types.ts | 409 ++++++++++++++ src/types/ethercat/index.ts | 3 + src/utils/ethercat/device-matcher.ts | 169 ++++++ src/utils/ethercat/esi-parser.ts | 481 ++++++++++++++++ 21 files changed, 4515 insertions(+), 168 deletions(-) create mode 100644 src/main/services/esi-service/esi-parser-main.ts create mode 100644 src/main/services/esi-service/index.ts create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx create mode 100644 src/types/ethercat/esi-types.ts create mode 100644 src/utils/ethercat/device-matcher.ts create mode 100644 src/utils/ethercat/esi-parser.ts diff --git a/package-lock.json b/package-lock.json index 2ee3a7545..fbb3ab19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "@tanstack/react-table": "^8.10.7", "@xyflow/react": "^12.0.1", "auto-zustand-selectors-hook": "^2.0.0", - "bcryptjs": "^3.0.3", "clsx": "^2.0.0", "cva": "npm:class-variance-authority@^0.7.0", "dompurify": "^3.2.4", @@ -43,6 +42,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", @@ -11663,14 +11663,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -15735,6 +15727,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^2.1.0" + }, + "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", @@ -26730,6 +26739,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, "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 c696c3ea8..11fededdf 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,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/ipc/main.ts b/src/main/modules/ipc/main.ts index 4c9f5686f..ee607dc1f 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,3 +1,5 @@ +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 { EtherCATScanRequest, @@ -9,6 +11,7 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' +import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } 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' @@ -48,6 +51,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 @@ -779,6 +783,214 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== ESI REPOSITORY HANDLERS ===================== + + /** + * Load ESI repository index from project + */ + handleESILoadRepositoryIndex = async ( + _event: IpcMainInvokeEvent, + 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 + }> => { + try { + const index = await this.esiService.loadRepositoryIndex(projectPath) + return { success: true, data: index } + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save ESI repository index to project + */ + handleESISaveRepositoryIndex = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + items: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveRepositoryIndex(projectPath, items) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save an ESI XML file to project + */ + handleESISaveXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + xmlContent: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveXmlFile(projectPath, itemId, xmlContent) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Load an ESI XML file from project + */ + handleESILoadXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> => { + try { + return await this.esiService.loadXmlFile(projectPath, itemId) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Delete an ESI XML file from project + */ + handleESIDeleteXmlFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.deleteRepositoryItemV2(projectPath, itemId) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Save a complete ESI repository item (XML + update index) + */ + handleESISaveRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.saveRepositoryItem(projectPath, item, xmlContent, existingItems) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Delete an ESI repository item (XML + update index) + */ + handleESIDeleteRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.deleteRepositoryItem(projectPath, itemId, existingItems) + } catch (error) { + return { success: false, error: String(error) } + } + } + + // ===================== ESI OPTIMIZED HANDLERS ===================== + + /** + * Parse and save a single ESI file + */ + handleESIParseAndSaveFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> => { + try { + return await this.esiService.parseAndSaveFile(projectPath, filename, content) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Clear the entire ESI repository + */ + handleESIClearRepository = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + return await this.esiService.clearRepository(projectPath) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Load a full ESI device on-demand (with PDOs, SM, FMMU) + */ + handleESILoadDeviceFull = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + deviceIndex: number, + ): Promise<{ success: boolean; device?: ESIDevice; error?: string }> => { + try { + const xmlResult = await this.esiService.loadXmlFile(projectPath, itemId) + if (!xmlResult.success || !xmlResult.content) { + return { success: false, error: xmlResult.error || 'XML file not found' } + } + + const result = parseESIDeviceFull(xmlResult.content, deviceIndex) + return result + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Load repository as lightweight items (instant from v2 cache) + */ + handleESILoadRepositoryLight = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> => { + try { + return await this.esiService.loadRepositoryLight(projectPath) + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Migrate v1 repository to v2 with device summaries + */ + handleESIMigrateRepository = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> => { + try { + return await this.esiService.migrateRepositoryToV2(projectPath) + } catch (error) { + return { success: false, error: String(error) } + } + } + // ===================== IPC HANDLER REGISTRATION ===================== setupMainIpcListener() { // Project-related handlers @@ -865,6 +1077,22 @@ class MainProcessBridge implements MainIpcModule { this.ipcMain.handle('ethercat:scan', this.handleEtherCATScan) this.ipcMain.handle('ethercat:test', this.handleEtherCATTest) this.ipcMain.handle('ethercat:validate', this.handleEtherCATValidate) + + // ===================== 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) } // ===================== HANDLER METHODS ===================== diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 9a030d822..8ff5fb7da 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -9,6 +9,7 @@ import type { 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' @@ -418,5 +419,128 @@ const rendererProcessBridge = { validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + + // ===================== 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), } export default rendererProcessBridge 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..d818595f2 --- /dev/null +++ b/src/main/services/esi-service/esi-parser-main.ts @@ -0,0 +1,450 @@ +/** + * 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 { + 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'] + 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)}`, + } + } +} + +/** + * 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)) + } + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName: group?.name, + physics, + fmmu, + syncManagers, + rxPdo, + txPdo, + 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..18c293446 --- /dev/null +++ b/src/main/services/esi-service/index.ts @@ -0,0 +1,497 @@ +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' + + /** + * 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 + } + console.error('Error reading ESI repository index:', error) + return null + } + } + + /** + * 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 { + // Save the XML file + const xmlResult = await this.saveXmlFile(projectPath, item.id, xmlContent) + if (!xmlResult.success) { + return xmlResult + } + + // Update the index with the new item + const updatedItems = [...existingItems.filter((i) => i.id !== item.id), item] + return this.saveRepositoryIndex(projectPath, updatedItems) + } + + /** + * Delete a repository item (XML + update index) + */ + async deleteRepositoryItem( + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ): Promise { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Update the index without the deleted item + const updatedItems = existingItems.filter((i) => i.id !== itemId) + return this.saveRepositoryIndex(projectPath, updatedItems) + } + + /** + * Delete a repository item and update the v2 index (no re-parsing) + */ + async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { + 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 }> { + 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 + await this.saveRepositoryIndexV2(projectPath, items) + + 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 }> { + 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 + } + + // Parse + const parseResult = parseESILight(content, filename) + if (!parseResult.success || !parseResult.vendor || !parseResult.devices) { + return { success: false, error: parseResult.error || 'Parse failed' } + } + + // 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 { + try { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) return { success: true } + + // Delete all files in the ESI directory + const entries = await promises.readdir(esiDir) + await Promise.all(entries.map((entry) => promises.unlink(join(esiDir, entry)))) + + // Recreate directory with empty v2 index + await this.ensureEsiDir(projectPath) + 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]/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..6072236b3 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -0,0 +1,161 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' +import type { ConfiguredEtherCATDevice, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useMemo } from 'react' + +type ConfiguredDeviceRowProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + isExpanded: boolean + onToggleExpand: () => void + isSelected: boolean + onSelect: () => void +} + +/** + * Configured Device Row Component + * + * Displays a configured EtherCAT device with expandable details. + * Follows the IOGroupRow pattern from remote-device editor. + */ +const ConfiguredDeviceRow = ({ + device, + repository, + isExpanded, + onToggleExpand, + isSelected, + onSelect, +}: ConfiguredDeviceRowProps) => { + // Resolve the ESI device summary from repository + 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}` : '-' + + return ( + <> + {/* Main row */} + + + + + {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 (placeholder for future IO mapping) */} + + + +
+
+ Configuration +
+

+ IO mapping and device configuration will be available here. +

+
+ + + + )} + + ) +} + +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..06dc10fc3 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -0,0 +1,122 @@ +import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' +import TableActions from '@root/renderer/components/_atoms/table-actions' +import type { ConfiguredEtherCATDevice, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { useCallback, useState } from 'react' + +import { ConfiguredDeviceRow } from './configured-device-row' + +type ConfiguredDevicesProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onAddDevice: () => void + onRemoveDevice: (deviceId: string) => void +} + +/** + * Configured Devices Component + * + * Displays the list of configured EtherCAT devices with add/remove functionality. + */ +const ConfiguredDevices = ({ devices, repository, onAddDevice, onRemoveDevice }: ConfiguredDevicesProps) => { + const [expandedDevices, setExpandedDevices] = useState>(new Set()) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + 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)} + /> + )) + )} + +
+ 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..2941638f7 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx @@ -0,0 +1,272 @@ +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 ( +
handleSelectDevice(repoItem.id, deviceIndex)} + className={cn( + 'flex cursor-pointer items-center gap-3 px-3 py-2 pl-8 hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + isSelected && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + +
+
+ + {device.name} + + {device.groupName && ( + + {device.groupName} + + )} +
+
+ {device.type.productCode} + Rev: {device.type.revisionNo} + from {repoItem.filename} +
+
+ {isSelected && ( + + + + )} +
+ ) + })} +
+ )} +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} + +export { DeviceBrowserModal } 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..42e4fd32e --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -0,0 +1,217 @@ +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..e673c8429 --- /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) => ( +
+ {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..921ffe3cb --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -0,0 +1,212 @@ +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.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 */} +
+ + + {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/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index f1c779da6..d4ad172b6 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,29 +1,58 @@ import { ArrowIcon } from '@root/renderer/assets/icons' import { useOpenPLCStore } from '@root/renderer/store' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + ScannedDeviceMatch, +} from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -import { useCallback, useEffect, useMemo, useState } from 'react' - -import { DeviceScanTable } from './components/device-scan-table' +import { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from '@root/utils/ethercat/device-matcher' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { ConfiguredDevices } from './components/configured-devices' +import { DeviceBrowserModal } from './components/device-browser-modal' +import { DiscoveredDeviceTable } from './components/discovered-device-table' +import { ESIRepository } from './components/esi-repository' import { InterfaceSelector } from './components/interface-selector' +type EditorTab = 'repository' | 'discovery' | 'configured' + /** * EtherCAT Device Editor * * Provides interface for: - * - Selecting network interface for EtherCAT communication - * - Scanning for EtherCAT devices on the selected interface - * - Displaying discovered devices with their properties + * - Managing ESI file repository (Repository tab) + * - Scanning for EtherCAT devices and matching with repository (Discovery tab) + * - Viewing and configuring added devices (Configured Devices tab) */ const EtherCATEditor = () => { - const { editor, runtimeConnection } = useOpenPLCStore() + const { editor, runtimeConnection, project } = 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('repository') + + // Repository state (now lightweight) + 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 state + const [configuredDevices, setConfiguredDevices] = useState([]) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + // Network interfaces state const [interfaces, setInterfaces] = useState([]) const [selectedInterface, setSelectedInterface] = useState('') @@ -35,14 +64,21 @@ const EtherCATEditor = () => { const [serviceMessage, setServiceMessage] = useState('') // Scan state - const [devices, setDevices] = useState([]) + const [scannedDevices, setScannedDevices] = useState([]) const [isScanning, setIsScanning] = useState(false) const [scanError, setScanError] = useState(null) const [scanMessage, setScanMessage] = useState('') const [scanTimeMs, setScanTimeMs] = useState(null) - // Selected device for details - const [selectedDevicePosition, setSelectedDevicePosition] = useState(null) + // Discovery selection state + const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + + // Matched devices (scanned devices with repository matches) + const deviceMatches = useMemo(() => { + return matchDevicesToRepository(scannedDevices, repository) + }, [scannedDevices, repository]) + + const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) // Check EtherCAT service status when connected to runtime const checkServiceStatus = useCallback(async () => { @@ -82,7 +118,6 @@ const EtherCATEditor = () => { const result = await window.bridge.etherCATGetInterfaces(ipAddress, jwtToken) if (result.success && result.data) { setInterfaces(result.data) - // Auto-select first interface if available if (result.data.length > 0 && !selectedInterface) { setSelectedInterface(result.data[0].name) } @@ -108,8 +143,9 @@ const EtherCATEditor = () => { setIsScanning(true) setScanError(null) setScanMessage('') - setDevices([]) - setSelectedDevicePosition(null) + setScannedDevices([]) + setSelectedScannedDevices(new Set()) + setScanTimeMs(null) try { const result = await window.bridge.etherCATScan(ipAddress, jwtToken, { @@ -118,7 +154,7 @@ const EtherCATEditor = () => { }) if (result.success && result.data) { - setDevices(result.data.devices) + setScannedDevices(result.data.devices) setScanMessage(result.data.message) setScanTimeMs(result.data.scan_time_ms) @@ -135,6 +171,45 @@ const EtherCATEditor = () => { } }, [isConnectedToRuntime, ipAddress, jwtToken, selectedInterface]) + // Load ESI repository from cache (v2) or migrate (v1) + useEffect(() => { + const loadRepository = async () => { + if (!projectPath || repositoryLoadedRef.current) return + + setIsLoadingRepository(true) + setRepositoryError(null) + + try { + const result = await window.bridge.esiLoadRepositoryLight(projectPath) + + 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 (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) { + console.error('Failed to load ESI repository:', error) + setRepositoryError(String(error)) + } finally { + setIsLoadingRepository(false) + } + } + + void loadRepository() + }, [projectPath, repositoryLoadRetry]) + // Check service status and fetch interfaces when runtime connection changes useEffect(() => { if (isConnectedToRuntime) { @@ -143,60 +218,96 @@ const EtherCATEditor = () => { } else { setServiceAvailable(null) setInterfaces([]) - setDevices([]) + setScannedDevices([]) setSelectedInterface('') } }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) - // Get selected device details - const selectedDevice = useMemo(() => { - if (selectedDevicePosition === null) return null - return devices.find((d) => d.position === selectedDevicePosition) || null - }, [devices, selectedDevicePosition]) - - // Render not connected state - if (!isConnectedToRuntime) { - return ( -
-
-

- EtherCAT Device: {deviceName} -

-

Protocol: EtherCAT

-
-
-
-

Not connected to runtime

-

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

-
-
-
- ) - } - - // Render service not available state - if (serviceAvailable === false) { - return ( -
-
-

- EtherCAT Device: {deviceName} -

-

Protocol: EtherCAT

-
-
-
-

- EtherCAT Discovery Service Not Available -

-

{serviceMessage}

-
-
-
- ) - } + // Handle device selection from scan + 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 + }) + }, []) + + // Handle select all scanned devices + const handleSelectAllScanned = useCallback( + (selected: boolean) => { + if (selected) { + // Select only devices with matches + const selectable = deviceMatches + .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') + .map((dm) => dm.device.position) + setSelectedScannedDevices(new Set(selectable)) + } else { + setSelectedScannedDevices(new Set()) + } + }, + [deviceMatches], + ) + + // Add selected scanned devices to configured devices + const handleAddSelectedFromScan = useCallback(() => { + const newDevices: ConfiguredEtherCATDevice[] = [] + + for (const position of selectedScannedDevices) { + 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 + + 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', + }) + } + + if (newDevices.length > 0) { + setConfiguredDevices((prev) => [...prev, ...newDevices]) + setSelectedScannedDevices(new Set()) + setActiveTab('configured') + } + }, [selectedScannedDevices, deviceMatches, repository]) + + // Handle adding device from browser modal + const handleAddDeviceFromBrowser = useCallback( + (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + const newDevice: ConfiguredEtherCATDevice = { + id: uuidv4(), + name: device.name, + esiDeviceRef: ref, + vendorId: repoItem.vendor.id, + productCode: device.type.productCode, + revisionNo: device.type.revisionNo, + addedFrom: 'repository', + } + setConfiguredDevices((prev) => [...prev, newDevice]) + }, + [], + ) + + // Handle removing a configured device + const handleRemoveDevice = useCallback((deviceId: string) => { + setConfiguredDevices((prev) => prev.filter((d) => d.id !== deviceId)) + }, []) return (
@@ -206,116 +317,211 @@ const EtherCATEditor = () => {

Protocol: EtherCAT

- {/* Interface Selection and Scan Controls */} -
- void fetchInterfaces()} - /> - + {/* Tabs */} +
+ + - - {scanTimeMs !== null && ( - Scan completed in {scanTimeMs}ms - )}
- {/* Error/Status Messages */} - {scanError && ( -
-

{scanError}

-
+ {/* Repository Tab */} + {activeTab === 'repository' && ( + <> + {repositoryError && ( +
+

Failed to load repository: {repositoryError}

+ +
+ )} + + )} - {scanMessage && !scanError && ( -
-

{scanMessage}

-
- )} + {/* Discovery Tab */} + {activeTab === 'discovery' && ( +
+ {/* Not connected state */} + {!isConnectedToRuntime && ( +
+
+

Not connected to runtime

+

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

+
+
+ )} - {/* Devices Table */} -
-
-

- Discovered Devices {devices.length > 0 && `(${devices.length})`} -

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

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+
+ )} + + {/* Connected state */} + {isConnectedToRuntime && serviceAvailable !== false && ( + <> + {/* Interface Selection and Scan Controls */} +
+ void fetchInterfaces()} + /> + + + + {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 */} + + + )}
+ )} - setIsDeviceBrowserOpen(true)} + onRemoveDevice={handleRemoveDevice} /> -
- - {/* Selected Device Details */} - {selectedDevice && ( -
-

- Device Details: {selectedDevice.name} -

-
-
- Position:{' '} - {selectedDevice.position} -
-
- Vendor ID:{' '} - 0x{selectedDevice.vendor_id.toString(16)} -
-
- Product Code:{' '} - - 0x{selectedDevice.product_code.toString(16)} - -
-
- Revision:{' '} - 0x{selectedDevice.revision.toString(16)} -
-
- Serial:{' '} - {selectedDevice.serial_number || 'N/A'} -
-
- State:{' '} - {selectedDevice.state} -
-
- CoE Support:{' '} - {selectedDevice.has_coe ? 'Yes' : 'No'} -
-
- I/O:{' '} - - {selectedDevice.input_bytes}B in / {selectedDevice.output_bytes}B out - -
-
-
)} + + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={handleAddDeviceFromBrowser} + repository={repository} + />
) } diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts new file mode 100644 index 000000000..6f84e62bd --- /dev/null +++ b/src/types/ethercat/esi-types.ts @@ -0,0 +1,409 @@ +/** + * 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 + /** 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' + /** Default value */ + defaultValue?: string +} + +// ===================== 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 +} + +// ===================== 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' +} + +// ===================== 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 index a6fb81ed8..e547c2115 100644 --- a/src/types/ethercat/index.ts +++ b/src/types/ethercat/index.ts @@ -5,6 +5,9 @@ * Based on the runtime's /api/discovery/* REST API. */ +// Re-export ESI types +export * from './esi-types' + // ===================== ENUMS ===================== /** 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/esi-parser.ts b/src/utils/ethercat/esi-parser.ts new file mode 100644 index 000000000..364354657 --- /dev/null +++ b/src/utils/ethercat/esi-parser.ts @@ -0,0 +1,481 @@ +/** + * EtherCAT ESI (EtherCAT Slave Information) XML Parser + * + * Parses ESI XML files following ETG.2000 specification and extracts + * device information, PDO mappings, and channel configurations. + */ + +import type { + ESIChannel, + ESIDataType, + ESIDevice, + ESIDeviceType, + ESIFile, + ESIFMMU, + ESIGroup, + ESIParseResult, + ESIPdo, + ESIPdoEntry, + ESISyncManager, + ESIVendor, +} from '@root/types/ethercat/esi-types' + +/** + * Parse hex string to number + * Handles formats: "#x1234", "0x1234", "1234" + */ +function parseHexValue(value: string | undefined | null): string { + if (!value) return '0x0' + const cleaned = value.replace(/#x/gi, '0x') + return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` +} + +/** + * Get text content from an element by tag name + */ +function getElementText(parent: Element, tagName: string): string | undefined { + const element = parent.getElementsByTagName(tagName)[0] + return element?.textContent?.trim() || undefined +} + +/** + * Get attribute value from an element + */ +function getAttribute(element: Element, attrName: string): string | undefined { + return element.getAttribute(attrName) || undefined +} + +/** + * Parse Vendor information + */ +function parseVendor(vendorElement: Element): ESIVendor { + return { + id: parseHexValue(getElementText(vendorElement, 'Id')), + name: getElementText(vendorElement, 'Name') || 'Unknown Vendor', + } +} + +/** + * Parse Group information + */ +function parseGroup(groupElement: Element): ESIGroup { + return { + type: getElementText(groupElement, 'Type') || '', + name: getElementText(groupElement, 'Name') || '', + imageUrl: getElementText(groupElement, 'ImageData16x14'), + description: getElementText(groupElement, 'Comment'), + } +} + +/** + * Parse FMMU configuration + */ +function parseFMMU(fmmuElement: Element): ESIFMMU { + const text = fmmuElement.textContent?.trim() || 'Outputs' + const validTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] + return { + type: validTypes.includes(text as ESIFMMU['type']) ? (text as ESIFMMU['type']) : 'Outputs', + } +} + +/** + * Parse Sync Manager configuration + */ +function parseSyncManager(smElement: Element, index: number): ESISyncManager { + const typeMap: Record = { + MbxOut: 'MbxOut', + MbxIn: 'MbxIn', + Outputs: 'Outputs', + Inputs: 'Inputs', + } + + return { + index, + startAddress: parseHexValue(getAttribute(smElement, 'StartAddress')), + controlByte: parseHexValue(getAttribute(smElement, 'ControlByte')), + defaultSize: parseInt(getAttribute(smElement, 'DefaultSize') || '0', 10), + enable: getAttribute(smElement, 'Enable') !== '0', + type: typeMap[smElement.textContent?.trim() || 'Outputs'] || 'Outputs', + } +} + +/** + * Parse PDO Entry + */ +function parsePdoEntry(entryElement: Element): ESIPdoEntry | null { + const index = getElementText(entryElement, 'Index') + const bitLen = parseInt(getElementText(entryElement, 'BitLen') || '0', 10) + + // Skip padding entries (entries without index or with 0 bitlen used for alignment) + if (!index && bitLen > 0) { + // This is likely a padding entry, we'll include it but mark it + return { + index: '0x0000', + subIndex: '0x00', + bitLen, + name: 'Padding', + dataType: 'BIT', + } + } + + if (!index) return null + + return { + index: parseHexValue(index), + subIndex: parseHexValue(getElementText(entryElement, 'SubIndex')), + bitLen, + name: getElementText(entryElement, 'Name') || 'Unnamed', + dataType: getElementText(entryElement, 'DataType') || 'BYTE', + comment: getElementText(entryElement, 'Comment'), + } +} + +/** + * Parse PDO (RxPdo or TxPdo) + */ +function parsePdo(pdoElement: Element): ESIPdo { + const entries: ESIPdoEntry[] = [] + const entryElements = pdoElement.getElementsByTagName('Entry') + + for (let i = 0; i < entryElements.length; i++) { + const entry = parsePdoEntry(entryElements[i]) + if (entry) { + entries.push(entry) + } + } + + return { + index: parseHexValue(getElementText(pdoElement, 'Index')), + name: getElementText(pdoElement, 'Name') || 'Unnamed PDO', + fixed: getAttribute(pdoElement, 'Fixed')?.toLowerCase() === 'true', + mandatory: getAttribute(pdoElement, 'Mandatory')?.toLowerCase() === 'true', + smIndex: getAttribute(pdoElement, 'Sm') ? parseInt(getAttribute(pdoElement, 'Sm')!, 10) : undefined, + entries, + } +} + +/** + * Parse Device Type information + */ +function parseDeviceType(typeElement: Element): ESIDeviceType { + return { + productCode: parseHexValue(getAttribute(typeElement, 'ProductCode')), + revisionNo: parseHexValue(getAttribute(typeElement, 'RevisionNo')), + name: typeElement.textContent?.trim() || 'Unknown Type', + } +} + +/** + * Parse Device + */ +function parseDevice(deviceElement: Element, groups: ESIGroup[]): ESIDevice { + // Parse Type + const typeElement = deviceElement.getElementsByTagName('Type')[0] + const type = typeElement ? parseDeviceType(typeElement) : { productCode: '0x0', revisionNo: '0x0', name: 'Unknown' } + + // Get group reference + const groupType = getElementText(deviceElement, 'GroupType') + const group = groups.find((g) => g.type === groupType) + + // Parse FMMUs + const fmmu: ESIFMMU[] = [] + const fmmuElements = deviceElement.getElementsByTagName('Fmmu') + for (let i = 0; i < fmmuElements.length; i++) { + fmmu.push(parseFMMU(fmmuElements[i])) + } + + // Parse Sync Managers + const syncManagers: ESISyncManager[] = [] + const smElements = deviceElement.getElementsByTagName('Sm') + for (let i = 0; i < smElements.length; i++) { + syncManagers.push(parseSyncManager(smElements[i], i)) + } + + // Parse RxPDOs + const rxPdo: ESIPdo[] = [] + const rxPdoElements = deviceElement.getElementsByTagName('RxPdo') + for (let i = 0; i < rxPdoElements.length; i++) { + rxPdo.push(parsePdo(rxPdoElements[i])) + } + + // Parse TxPDOs + const txPdo: ESIPdo[] = [] + const txPdoElements = deviceElement.getElementsByTagName('TxPdo') + for (let i = 0; i < txPdoElements.length; i++) { + txPdo.push(parsePdo(txPdoElements[i])) + } + + return { + type, + name: getElementText(deviceElement, 'Name') || 'Unknown Device', + groupName: group?.name, + physics: getAttribute(deviceElement, 'Physics'), + fmmu, + syncManagers, + rxPdo, + txPdo, + description: getElementText(deviceElement, 'Comment'), + } +} + +/** + * 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 +} + +/** + * Parse ESI XML string + */ +export function parseESI(xmlString: string, filename?: string): ESIParseResult { + const warnings: string[] = [] + + try { + const parser = new DOMParser() + const doc = parser.parseFromString(xmlString, 'text/xml') + + // Check for parse errors + const parseError = doc.getElementsByTagName('parsererror')[0] + if (parseError) { + return { + success: false, + error: `XML parse error: ${parseError.textContent}`, + } + } + + // Get root element + const root = doc.getElementsByTagName('EtherCATInfo')[0] + if (!root) { + return { + success: false, + error: 'Invalid ESI file: Missing EtherCATInfo root element', + } + } + + // Parse Vendor + const vendorElement = root.getElementsByTagName('Vendor')[0] + if (!vendorElement) { + return { + success: false, + error: 'Invalid ESI file: Missing Vendor information', + } + } + const vendor = parseVendor(vendorElement) + + // Parse Groups + const groups: ESIGroup[] = [] + const descriptionsElement = root.getElementsByTagName('Descriptions')[0] + if (descriptionsElement) { + const groupsElement = descriptionsElement.getElementsByTagName('Groups')[0] + if (groupsElement) { + const groupElements = groupsElement.getElementsByTagName('Group') + for (let i = 0; i < groupElements.length; i++) { + groups.push(parseGroup(groupElements[i])) + } + } + } + + // Parse Devices + const devices: ESIDevice[] = [] + if (descriptionsElement) { + const devicesElement = descriptionsElement.getElementsByTagName('Devices')[0] + if (devicesElement) { + const deviceElements = devicesElement.getElementsByTagName('Device') + for (let i = 0; i < deviceElements.length; i++) { + devices.push(parseDevice(deviceElements[i], groups)) + } + } + } + + if (devices.length === 0) { + warnings.push('No devices found in ESI file') + } + + // Parse InfoData (optional metadata) + const infoDataElement = root.getElementsByTagName('InfoData')[0] + const infoData = infoDataElement + ? { + version: getElementText(infoDataElement, 'Version'), + creationDate: getElementText(infoDataElement, 'CreationDate'), + modificationDate: getElementText(infoDataElement, 'ModificationDate'), + vendorUrl: getElementText(infoDataElement, 'VendorUrl'), + } + : undefined + + const result: ESIFile = { + vendor, + groups, + devices, + filename, + infoData, + } + + return { + success: true, + data: result, + warnings: warnings.length > 0 ? warnings : undefined, + } + } catch (error) { + return { + success: false, + error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** + * 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, + } +} From 83eb1d55bb0db537a58cf32dd5bedbe7e215bbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 9 Feb 2026 15:25:06 -0300 Subject: [PATCH 10/31] feat: add channel mapping with IEC 61131-3 located variables for EtherCAT devices Add channel-to-located-variable mapping table in the expanded view of each configured EtherCAT device. Channels extracted from ESI PDOs are auto-assigned IEC addresses (%IX, %QX, %IW, %QD, etc.) based on direction and data type. Users can manually edit any address. - Add EtherCATChannelMapping type and channelMappings field - Add generateIecLocation() and generateDefaultChannelMappings() utils - Create ChannelMappingTable component with direction filter and search - Load full device data lazily on row expand via esiLoadDeviceFull IPC - Fix stale eslint/tsc cache: disable eslint-webpack-plugin cache and clear tsbuildinfo on prestart Co-Authored-By: Claude Opus 4.6 --- .../webpack/webpack.config.renderer.dev.ts | 1 + package.json | 2 +- .../components/channel-mapping-table.tsx | 202 +++++++ .../components/configured-device-row.tsx | 495 +++++++++++++++++- .../components/configured-devices.tsx | 23 +- .../editor/device/ethercat/index.tsx | 20 + src/types/ethercat/esi-types.ts | 110 ++++ src/utils/ethercat/esi-parser.ts | 52 ++ 8 files changed, 892 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx 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.json b/package.json index 11fededdf..3408f29df 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "ts-node scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "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": "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 && 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", 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..a8b6a5a7c --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -0,0 +1,202 @@ +import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useMemo, useState } from 'react' + +type ChannelMappingTableProps = { + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onLocationChange: (channelId: string, newLocation: string) => void +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * Channel Mapping Table Component + * + * Displays channels with editable IEC 61131-3 located variable addresses. + */ +const ChannelMappingTable = ({ channels, mappings, onLocationChange }: 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]) + + // 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.name.toLowerCase().includes(search) || + channel.dataType.toLowerCase().includes(search) || + channel.entryIndex.toLowerCase().includes(search) || + channel.iecType.toLowerCase().includes(search) || + (mapping?.iecLocation.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 + + Name + + Index + + Type + + Bits + + IEC Type + + IEC Location +
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ + {channel.direction === 'input' ? 'IN' : 'OUT'} + + + {channel.name} + + {channel.entryIndex}:{channel.entrySubIndex} + {channel.dataType}{channel.bitLen} + {channel.iecType} + + onLocationChange(channel.id, e.target.value)} + className={cn( + 'h-[24px] w-full rounded border px-1.5 font-mono text-xs outline-none', + 'border-neutral-300 bg-white text-neutral-700 focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300', + mapping?.userEdited && + 'border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20', + )} + title={mapping?.userEdited ? 'Manually edited' : 'Auto-generated'} + /> +
+
+
+ ) +} + +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 index 6072236b3..dfd6d674c 100644 --- 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 @@ -1,7 +1,19 @@ import { ArrowIcon } from '@root/renderer/assets/icons' -import type { ConfiguredEtherCATDevice, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import { Checkbox } from '@root/renderer/components/_atoms/checkbox' +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import type { + ConfiguredEtherCATDevice, + ESIChannel, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, +} from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -import { useMemo } from 'react' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/utils/ethercat/esi-parser' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { ChannelMappingTable } from './channel-mapping-table' type ConfiguredDeviceRowProps = { device: ConfiguredEtherCATDevice @@ -10,13 +22,20 @@ type ConfiguredDeviceRowProps = { onToggleExpand: () => void isSelected: boolean onSelect: () => void + onUpdateDevice: (config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void } +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' + /** * Configured Device Row Component * - * Displays a configured EtherCAT device with expandable details. - * Follows the IOGroupRow pattern from remote-device editor. + * Displays a configured EtherCAT device with expandable details and configuration form. */ const ConfiguredDeviceRow = ({ device, @@ -25,6 +44,9 @@ const ConfiguredDeviceRow = ({ onToggleExpand, isSelected, onSelect, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, }: ConfiguredDeviceRowProps) => { // Resolve the ESI device summary from repository const esiDevice = useMemo(() => { @@ -39,6 +61,83 @@ const ConfiguredDeviceRow = ({ const ioSummary = esiDevice ? `${esiDevice.inputChannelCount} / ${esiDevice.outputChannelCount}` : '-' + const config = device.config + + // Channel loading state + const [channels, setChannels] = useState([]) + const [isLoadingChannels, setIsLoadingChannels] = useState(false) + const [channelLoadError, setChannelLoadError] = useState(null) + const fullDeviceLoadedRef = useRef(false) + + // Load full device data when expanded + useEffect(() => { + if (!isExpanded || 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) + fullDeviceLoadedRef.current = true + + // Generate default mappings if none exist + if (device.channelMappings.length === 0 && deviceChannels.length > 0) { + onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels)) + } + } else { + setChannelLoadError(result.error || 'Failed to load device data') + } + } catch (error) { + setChannelLoadError(String(error)) + } finally { + setIsLoadingChannels(false) + } + } + + void loadFullDevice() + }, [isExpanded, projectPath, device.esiDeviceRef, device.channelMappings.length, onUpdateChannelMappings]) + + const handleLocationChange = useCallback( + (channelId: string, newLocation: string) => { + const updated = device.channelMappings.map((m) => + m.channelId === channelId ? { ...m, iecLocation: newLocation, userEdited: true } : m, + ) + onUpdateChannelMappings(updated) + }, + [device.channelMappings, onUpdateChannelMappings], + ) + + /** + * Update a nested section of the config. + */ + const updateConfig = useCallback( + (section: K, updates: Partial) => { + onUpdateDevice({ + ...config, + [section]: { ...config[section], ...updates }, + }) + }, + [config, onUpdateDevice], + ) + + /** + * Parse a number input value, returning the parsed int or undefined if invalid. + */ + const parseNumericInput = (value: string, min = 0): number | undefined => { + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed < min) return undefined + return parsed + } + return ( <> {/* Main row */} @@ -138,17 +237,393 @@ const ConfiguredDeviceRow = ({ - {/* Configuration Section (placeholder for future IO mapping) */} + {/* Configuration Section */} -
-
+
+
Configuration
-

- IO mapping and device configuration will be available here. -

+
+ {/* 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) +
+
+ {/* DC Enable + Sync Unit Cycle */} +
+ +
+ + 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 row */} +
+ +
+ + 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 row */} +
+ +
+ + 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, + )} + /> +
+
+
+
+
+
+ + + + {/* Channel Mappings Section */} + + + +
+
+ Channel Mappings +
+ + {isLoadingChannels && ( +
+ + Loading channels... +
+ )} + + {channelLoadError && ( +
+ {channelLoadError} +
+ )} + + {!isLoadingChannels && !channelLoadError && channels.length === 0 && ( +

+ No channels available for this device. +

+ )} + + {!isLoadingChannels && !channelLoadError && channels.length > 0 && ( + + )}
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 index 06dc10fc3..01e258598 100644 --- 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 @@ -1,6 +1,11 @@ import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' import TableActions from '@root/renderer/components/_atoms/table-actions' -import type { ConfiguredEtherCATDevice, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { + ConfiguredEtherCATDevice, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, +} from '@root/types/ethercat/esi-types' import { useCallback, useState } from 'react' import { ConfiguredDeviceRow } from './configured-device-row' @@ -10,6 +15,9 @@ type ConfiguredDevicesProps = { repository: ESIRepositoryItemLight[] onAddDevice: () => void onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void } /** @@ -17,7 +25,15 @@ type ConfiguredDevicesProps = { * * Displays the list of configured EtherCAT devices with add/remove functionality. */ -const ConfiguredDevices = ({ devices, repository, onAddDevice, onRemoveDevice }: ConfiguredDevicesProps) => { +const ConfiguredDevices = ({ + devices, + repository, + onAddDevice, + onRemoveDevice, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, +}: ConfiguredDevicesProps) => { const [expandedDevices, setExpandedDevices] = useState>(new Set()) const [selectedDeviceId, setSelectedDeviceId] = useState(null) @@ -109,6 +125,9 @@ const ConfiguredDevices = ({ devices, repository, onAddDevice, onRemoveDevice }: onToggleExpand={() => 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)} /> )) )} diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index d4ad172b6..262904aca 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -6,9 +6,12 @@ import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, ScannedDeviceMatch, } from '@root/types/ethercat/esi-types' 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' @@ -277,6 +280,8 @@ const EtherCATEditor = () => { productCode: bestMatch.esiDevice.type.productCode, revisionNo: bestMatch.esiDevice.type.revisionNo, addedFrom: 'scan', + config: createDefaultSlaveConfig(), + channelMappings: [], }) } @@ -298,6 +303,8 @@ const EtherCATEditor = () => { productCode: device.type.productCode, revisionNo: device.type.revisionNo, addedFrom: 'repository', + config: createDefaultSlaveConfig(), + channelMappings: [], } setConfiguredDevices((prev) => [...prev, newDevice]) }, @@ -309,6 +316,16 @@ const EtherCATEditor = () => { setConfiguredDevices((prev) => prev.filter((d) => d.id !== deviceId)) }, []) + // Handle updating a configured device's configuration + const handleUpdateDevice = useCallback((deviceId: string, config: EtherCATSlaveConfig) => { + setConfiguredDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, []) + + // Handle updating a configured device's channel mappings + const handleUpdateChannelMappings = useCallback((deviceId: string, channelMappings: EtherCATChannelMapping[]) => { + setConfiguredDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, []) + return (
{/* Header */} @@ -512,6 +529,9 @@ const EtherCATEditor = () => { repository={repository} onAddDevice={() => setIsDeviceBrowserOpen(true)} onRemoveDevice={handleRemoveDevice} + onUpdateDevice={handleUpdateDevice} + projectPath={projectPath} + onUpdateChannelMappings={handleUpdateChannelMappings} /> )} diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index 6f84e62bd..e92574760 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -253,6 +253,20 @@ export interface ESIChannel { mappedVariable?: 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 +} + // ===================== PARSE RESULT ===================== /** @@ -363,6 +377,102 @@ export interface ConfiguredEtherCATDevice { revisionNo: string /** How this device was added */ addedFrom: 'repository' | 'scan' + /** Per-slave configuration settings */ + config: EtherCATSlaveConfig + /** Channel-to-located-variable mappings */ + channelMappings: EtherCATChannelMapping[] +} + +// ===================== 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 + /** Verify slave revision number matches ESI definition */ + checkRevisionNumber: boolean + /** Download expected PDO/slot configuration to slave at startup */ + downloadPdoConfig: boolean +} + +/** + * Addressing configuration for an EtherCAT slave. + */ +export interface EtherCATAddressing { + /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ + ethercatAddress: number + /** Mark slave as optional - network continues if this slave is absent */ + optionalSlave: boolean +} + +/** + * 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 ===================== diff --git a/src/utils/ethercat/esi-parser.ts b/src/utils/ethercat/esi-parser.ts index 364354657..55e4453da 100644 --- a/src/utils/ethercat/esi-parser.ts +++ b/src/utils/ethercat/esi-parser.ts @@ -18,6 +18,7 @@ import type { ESIPdoEntry, ESISyncManager, ESIVendor, + EtherCATChannelMapping, } from '@root/types/ethercat/esi-types' /** @@ -335,6 +336,57 @@ export function pdoToChannels(device: ESIDevice): ESIChannel[] { 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 + */ +export function generateIecLocation(channel: ESIChannel): string { + const dirPrefix = channel.direction === 'input' ? '%I' : '%Q' + + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return `${dirPrefix}X${channel.byteOffset}.${channel.bitOffset % 8}` + case 'BYTE': + case 'SINT': + case 'USINT': + return `${dirPrefix}B${channel.byteOffset}` + case 'WORD': + case 'INT': + case 'UINT': + return `${dirPrefix}W${channel.byteOffset}` + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return `${dirPrefix}D${channel.byteOffset}` + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return `${dirPrefix}L${channel.byteOffset}` + default: + return `${dirPrefix}B${channel.byteOffset}` + } +} + +/** + * Generate default channel mappings with auto-generated IEC addresses for all channels. + */ +export function generateDefaultChannelMappings(channels: ESIChannel[]): EtherCATChannelMapping[] { + return channels.map((channel) => ({ + channelId: channel.id, + iecLocation: generateIecLocation(channel), + userEdited: false, + })) +} + /** * Parse ESI XML string */ From 681d106079fcde9523a0124b891d09725e25f917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 9 Feb 2026 16:55:53 -0300 Subject: [PATCH 11/31] feat: persist EtherCAT devices in Zustand store and generate ethercat.json during compilation Add Zod schemas for EtherCAT configuration in PLCRemoteDeviceSchema, replace local useState with Zustand store read/write in the EtherCAT editor, and generate conf/ethercat.json in the compiler pipeline following the existing Modbus/S7Comm/OPC-UA pattern. Persistence to disk is handled automatically by the existing project-service. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/compiler/compiler-module.ts | 24 +++ .../editor/device/ethercat/index.tsx | 62 +++++-- src/renderer/store/slices/project/slice.ts | 24 +++ src/renderer/store/slices/project/types.ts | 2 + src/types/PLC/open-plc.ts | 79 +++++++++ .../ethercat/generate-ethercat-config.ts | 165 ++++++++++++++++++ 6 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 src/utils/ethercat/generate-ethercat-config.ts diff --git a/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts index 3ba726d3d..f6ac31d7b 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' @@ -1279,6 +1280,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, @@ -1713,6 +1732,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/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 262904aca..926bf023e 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -33,7 +33,7 @@ type EditorTab = 'repository' | 'discovery' | 'configured' * - Viewing and configuring added devices (Configured Devices tab) */ const EtherCATEditor = () => { - const { editor, runtimeConnection, project } = useOpenPLCStore() + const { editor, runtimeConnection, project, projectActions } = useOpenPLCStore() const deviceName = editor.type === 'plc-remote-device' ? editor.meta.name : '' const projectPath = project.meta.path @@ -52,8 +52,22 @@ const EtherCATEditor = () => { const [repositoryLoadRetry, setRepositoryLoadRetry] = useState(0) const repositoryLoadedRef = useRef(false) - // Configured devices state - const [configuredDevices, setConfiguredDevices] = useState([]) + // 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]) + + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(deviceName, { devices }) + }, + [deviceName, projectActions], + ) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) // Network interfaces state @@ -226,6 +240,13 @@ const EtherCATEditor = () => { } }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) + // Initialize ethercatConfig in store if missing + useEffect(() => { + if (remoteDevice && !remoteDevice.ethercatConfig) { + projectActions.updateEthercatConfig(deviceName, { devices: [] }) + } + }, [remoteDevice, deviceName, projectActions]) + // Handle device selection from scan const handleSelectScannedDevice = useCallback((position: number, selected: boolean) => { setSelectedScannedDevices((prev) => { @@ -286,11 +307,11 @@ const EtherCATEditor = () => { } if (newDevices.length > 0) { - setConfiguredDevices((prev) => [...prev, ...newDevices]) + syncDevicesToStore([...configuredDevices, ...newDevices]) setSelectedScannedDevices(new Set()) setActiveTab('configured') } - }, [selectedScannedDevices, deviceMatches, repository]) + }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore]) // Handle adding device from browser modal const handleAddDeviceFromBrowser = useCallback( @@ -306,25 +327,34 @@ const EtherCATEditor = () => { config: createDefaultSlaveConfig(), channelMappings: [], } - setConfiguredDevices((prev) => [...prev, newDevice]) + syncDevicesToStore([...configuredDevices, newDevice]) }, - [], + [configuredDevices, syncDevicesToStore], ) // Handle removing a configured device - const handleRemoveDevice = useCallback((deviceId: string) => { - setConfiguredDevices((prev) => prev.filter((d) => d.id !== deviceId)) - }, []) + const handleRemoveDevice = useCallback( + (deviceId: string) => { + syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) + }, + [configuredDevices, syncDevicesToStore], + ) // Handle updating a configured device's configuration - const handleUpdateDevice = useCallback((deviceId: string, config: EtherCATSlaveConfig) => { - setConfiguredDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, config } : d))) - }, []) + const handleUpdateDevice = useCallback( + (deviceId: string, config: EtherCATSlaveConfig) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) // Handle updating a configured device's channel mappings - const handleUpdateChannelMappings = useCallback((deviceId: string, channelMappings: EtherCATChannelMapping[]) => { - setConfiguredDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) - }, []) + const handleUpdateChannelMappings = useCallback( + (deviceId: string, channelMappings: EtherCATChannelMapping[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) return (
diff --git a/src/renderer/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts index c9bed8415..3ef107f43 100644 --- a/src/renderer/store/slices/project/slice.ts +++ b/src/renderer/store/slices/project/slice.ts @@ -1,5 +1,6 @@ import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast' import type { + EthercatConfig, OpcUaNodeConfig, OpcUaSecurityProfile, OpcUaServerConfig, @@ -2261,6 +2262,29 @@ const createProjectSlice: StateCreator = (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: { 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 95f6e316d..d2d2ee134 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -610,10 +610,86 @@ 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(), +}) + +const ESIDeviceRefSchema = z.object({ + repositoryItemId: z.string(), + deviceIndex: z.number(), +}) + +const EtherCATStartupChecksSchema = z.object({ + checkVendorId: z.boolean(), + checkProductCode: z.boolean(), + checkRevisionNumber: z.boolean(), + downloadPdoConfig: z.boolean(), +}) + +const EtherCATAddressingSchema = z.object({ + ethercatAddress: z.number(), + optionalSlave: z.boolean(), +}) + +const EtherCATTimeoutsSchema = z.object({ + sdoTimeoutMs: z.number(), + initToPreOpTimeoutMs: z.number(), + safeOpToOpTimeoutMs: z.number(), +}) + +const EtherCATWatchdogSchema = z.object({ + smWatchdogEnabled: z.boolean(), + smWatchdogMs: z.number(), + pdiWatchdogEnabled: z.boolean(), + pdiWatchdogMs: z.number(), +}) + +const EtherCATDistributedClocksSchema = z.object({ + dcEnabled: z.boolean(), + dcSyncUnitCycleUs: z.number(), + dcSync0Enabled: z.boolean(), + dcSync0CycleUs: z.number(), + dcSync0ShiftUs: z.number(), + dcSync1Enabled: z.boolean(), + dcSync1CycleUs: z.number(), + dcSync1ShiftUs: z.number(), +}) + +const EtherCATSlaveConfigSchema = z.object({ + startupChecks: EtherCATStartupChecksSchema, + addressing: EtherCATAddressingSchema, + timeouts: EtherCATTimeoutsSchema, + watchdog: EtherCATWatchdogSchema, + distributedClocks: EtherCATDistributedClocksSchema, +}) + +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), +}) + +const EthercatConfigSchema = z.object({ + 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 +759,8 @@ type PLCProject = z.infer export { baseTypeSchema, bodySchema, + ConfiguredEtherCATDeviceSchema, + EthercatConfigSchema, ModbusErrorHandlingSchema, ModbusFunctionCodeSchema, ModbusIOGroupSchema, @@ -742,6 +820,7 @@ export { export type { BaseType, BodySchema, + EthercatConfig, ModbusErrorHandling, ModbusFunctionCode, ModbusIOGroup, diff --git a/src/utils/ethercat/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts new file mode 100644 index 000000000..bf781157e --- /dev/null +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -0,0 +1,165 @@ +import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' + +// Runtime JSON interfaces (snake_case for plugin consumption) + +interface EthercatDcConfig { + enabled: boolean + sync0_cycle_us?: number + sync0_shift_us?: number +} + +interface EthercatPdoEntry { + index: string + address: string +} + +interface EthercatPdoMapping { + inputs?: EthercatPdoEntry[] + outputs?: EthercatPdoEntry[] +} + +interface EthercatSlaveJson { + position: number + name: string + vendor_id: string + product_code: string + revision: string + check_vendor: boolean + check_product: boolean + dc: EthercatDcConfig + pdo_mapping?: EthercatPdoMapping +} + +interface EthercatMasterJson { + interface: string + cycle_time_us: number + dc_enabled: boolean + dc_sync_offset_percent: number +} + +interface EthercatConfigJson { + master: EthercatMasterJson + slaves: EthercatSlaveJson[] +} + +/** + * Generates the EtherCAT plugin configuration JSON from the project's remote devices. + * Returns null if there are no EtherCAT devices configured. + * + * @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 + } + + // Collect all configured slaves across all EtherCAT remote devices + const allSlaves: EthercatSlaveJson[] = [] + let anyDcEnabled = false + + for (const remoteDevice of ethercatRemoteDevices) { + const devices = remoteDevice.ethercatConfig?.devices ?? [] + + for (let i = 0; i < devices.length; i++) { + const device = devices[i] + const position = device.position ?? i + const dc = device.config.distributedClocks + + if (dc.dcEnabled) { + anyDcEnabled = true + } + + // Build PDO mapping from channel mappings + const pdoMapping = buildPdoMapping(device.channelMappings) + + const slave: EthercatSlaveJson = { + position, + name: device.name, + vendor_id: device.vendorId, + product_code: device.productCode, + revision: device.revisionNo, + check_vendor: device.config.startupChecks.checkVendorId, + check_product: device.config.startupChecks.checkProductCode, + dc: { + enabled: dc.dcEnabled, + ...(dc.dcEnabled && { + sync0_cycle_us: dc.dcSync0CycleUs, + sync0_shift_us: dc.dcSync0ShiftUs, + }), + }, + ...(pdoMapping && { pdo_mapping: pdoMapping }), + } + + allSlaves.push(slave) + } + } + + if (allSlaves.length === 0) { + return null + } + + const config: EthercatConfigJson = { + master: { + interface: 'eth0', + cycle_time_us: 4000, + dc_enabled: anyDcEnabled, + dc_sync_offset_percent: 20, + }, + slaves: allSlaves, + } + + return JSON.stringify(config, null, 2) +} + +/** + * Builds PDO mapping from channel mappings. + * Groups channel mappings by direction (input/output) based on IEC location prefix. + */ +const buildPdoMapping = ( + channelMappings: { channelId: string; iecLocation: string; userEdited: boolean }[], +): EthercatPdoMapping | null => { + if (!channelMappings || channelMappings.length === 0) { + return null + } + + const inputs: EthercatPdoEntry[] = [] + const outputs: EthercatPdoEntry[] = [] + + for (const mapping of channelMappings) { + const loc = mapping.iecLocation + // Extract PDO index from channelId (format: "pdo_0x1A00_entry_0x6000_01" or similar) + const pdoIndexMatch = mapping.channelId.match(/pdo_(0x[0-9A-Fa-f]+)/) + const pdoIndex = pdoIndexMatch ? pdoIndexMatch[1] : '0x0000' + + const entry: EthercatPdoEntry = { + index: pdoIndex, + address: loc, + } + + // IEC location prefix determines direction: %I = input, %Q = output + if (loc.startsWith('%I')) { + inputs.push(entry) + } else if (loc.startsWith('%Q')) { + outputs.push(entry) + } + } + + if (inputs.length === 0 && outputs.length === 0) { + return null + } + + const result: EthercatPdoMapping = {} + if (inputs.length > 0) result.inputs = inputs + if (outputs.length > 0) result.outputs = outputs + + return result +} From 40e4bc9e846e8839a23b7525cb98880e9798f527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 10 Feb 2026 09:09:44 -0300 Subject: [PATCH 12/31] feat: add default EtherCAT slave configuration factory Co-Authored-By: Claude Opus 4.6 --- src/utils/ethercat/device-config-defaults.ts | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/utils/ethercat/device-config-defaults.ts diff --git a/src/utils/ethercat/device-config-defaults.ts b/src/utils/ethercat/device-config-defaults.ts new file mode 100644 index 000000000..e5d575005 --- /dev/null +++ b/src/utils/ethercat/device-config-defaults.ts @@ -0,0 +1,53 @@ +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, + checkRevisionNumber: false, + downloadPdoConfig: false, + }, + addressing: { + ethercatAddress: 0, + optionalSlave: false, + }, + 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 }, + } +} From cc0d8134db7d119be8bf800d32ef00ba53dacf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 10 Feb 2026 16:59:46 -0300 Subject: [PATCH 13/31] feat: enrich EtherCAT device persistence with full PDO/channel data for runtime contract Persist complete channel metadata, PDO layouts, and slave type classification when adding devices, and rewrite the JSON generator to produce the exact contract expected by the OpenPLC runtime EtherCAT plugin. Old projects are backward-compatible and get enriched on first device expand. Co-Authored-By: Claude Opus 4.6 --- .../components/configured-device-row.tsx | 29 +- .../components/configured-devices.tsx | 12 + .../editor/device/ethercat/index.tsx | 42 ++- src/types/PLC/open-plc.ts | 30 +++ src/types/ethercat/esi-types.ts | 64 +++++ src/utils/ethercat/enrich-device-data.ts | 109 ++++++++ .../ethercat/generate-ethercat-config.ts | 254 ++++++++++-------- 7 files changed, 427 insertions(+), 113 deletions(-) create mode 100644 src/utils/ethercat/enrich-device-data.ts 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 index dfd6d674c..a017b6c15 100644 --- 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 @@ -8,13 +8,23 @@ import type { ESIRepositoryItemLight, EtherCATChannelMapping, EtherCATSlaveConfig, + PersistedChannelInfo, + PersistedPdo, } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' +import { enrichDeviceData } from '@root/utils/ethercat/enrich-device-data' import { generateDefaultChannelMappings, pdoToChannels } from '@root/utils/ethercat/esi-parser' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChannelMappingTable } from './channel-mapping-table' +type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string +} + type ConfiguredDeviceRowProps = { device: ConfiguredEtherCATDevice repository: ESIRepositoryItemLight[] @@ -25,6 +35,7 @@ type ConfiguredDeviceRowProps = { onUpdateDevice: (config: EtherCATSlaveConfig) => void projectPath: string onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void } const inputClassName = @@ -47,6 +58,7 @@ const ConfiguredDeviceRow = ({ onUpdateDevice, projectPath, onUpdateChannelMappings, + onEnrichDevice, }: ConfiguredDeviceRowProps) => { // Resolve the ESI device summary from repository const esiDevice = useMemo(() => { @@ -93,6 +105,11 @@ const ConfiguredDeviceRow = ({ if (device.channelMappings.length === 0 && deviceChannels.length > 0) { onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels)) } + + // Enrich if data is missing (backward compat for projects created before enrichment) + if (!device.channelInfo || !device.rxPdos || !device.txPdos) { + onEnrichDevice(enrichDeviceData(result.device)) + } } else { setChannelLoadError(result.error || 'Failed to load device data') } @@ -104,7 +121,17 @@ const ConfiguredDeviceRow = ({ } void loadFullDevice() - }, [isExpanded, projectPath, device.esiDeviceRef, device.channelMappings.length, onUpdateChannelMappings]) + }, [ + isExpanded, + projectPath, + device.esiDeviceRef, + device.channelMappings.length, + device.channelInfo, + device.rxPdos, + device.txPdos, + onUpdateChannelMappings, + onEnrichDevice, + ]) const handleLocationChange = useCallback( (channelId: string, newLocation: string) => { 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 index 01e258598..9bc3315d6 100644 --- 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 @@ -5,11 +5,20 @@ import type { ESIRepositoryItemLight, EtherCATChannelMapping, EtherCATSlaveConfig, + PersistedChannelInfo, + PersistedPdo, } from '@root/types/ethercat/esi-types' import { useCallback, useState } from 'react' import { ConfiguredDeviceRow } from './configured-device-row' +type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string +} + type ConfiguredDevicesProps = { devices: ConfiguredEtherCATDevice[] repository: ESIRepositoryItemLight[] @@ -18,6 +27,7 @@ type ConfiguredDevicesProps = { onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void projectPath: string onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void } /** @@ -33,6 +43,7 @@ const ConfiguredDevices = ({ onUpdateDevice, projectPath, onUpdateChannelMappings, + onEnrichDevice, }: ConfiguredDevicesProps) => { const [expandedDevices, setExpandedDevices] = useState>(new Set()) const [selectedDeviceId, setSelectedDeviceId] = useState(null) @@ -128,6 +139,7 @@ const ConfiguredDevices = ({ onUpdateDevice={(config) => onUpdateDevice(device.id, config)} projectPath={projectPath} onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(device.id, mappings)} + onEnrichDevice={(data) => onEnrichDevice(device.id, data)} /> )) )} diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 926bf023e..50ea6e901 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -13,6 +13,7 @@ import type { 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' @@ -277,7 +278,7 @@ const EtherCATEditor = () => { ) // Add selected scanned devices to configured devices - const handleAddSelectedFromScan = useCallback(() => { + const handleAddSelectedFromScan = useCallback(async () => { const newDevices: ConfiguredEtherCATDevice[] = [] for (const position of selectedScannedDevices) { @@ -289,6 +290,17 @@ const EtherCATEditor = () => { const repoItem = repository.find((r) => r.id === bestMatch.repositoryItemId) if (!repoItem) continue + // Enrich with full ESI data + 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, @@ -303,6 +315,7 @@ const EtherCATEditor = () => { addedFrom: 'scan', config: createDefaultSlaveConfig(), channelMappings: [], + ...enriched, }) } @@ -311,11 +324,18 @@ const EtherCATEditor = () => { setSelectedScannedDevices(new Set()) setActiveTab('configured') } - }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore]) + }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) // Handle adding device from browser modal const handleAddDeviceFromBrowser = useCallback( - (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + // Enrich with full ESI data + let enriched = {} + const result = await window.bridge.esiLoadDeviceFull(projectPath, ref.repositoryItemId, ref.deviceIndex) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device) + } + const newDevice: ConfiguredEtherCATDevice = { id: uuidv4(), name: device.name, @@ -326,10 +346,11 @@ const EtherCATEditor = () => { addedFrom: 'repository', config: createDefaultSlaveConfig(), channelMappings: [], + ...enriched, } syncDevicesToStore([...configuredDevices, newDevice]) }, - [configuredDevices, syncDevicesToStore], + [configuredDevices, syncDevicesToStore, projectPath], ) // Handle removing a configured device @@ -356,6 +377,14 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) + // Handle enriching a device with persisted ESI data (backward compat for old projects) + const handleEnrichDevice = useCallback( + (deviceId: string, data: Partial) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + return (
{/* Header */} @@ -530,7 +559,7 @@ const EtherCATEditor = () => {
{selectedScannedDevices.size > 0 && (
diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index d2d2ee134..46e61b165 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -667,6 +667,32 @@ const EtherCATSlaveConfigSchema = z.object({ 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 ConfiguredEtherCATDeviceSchema = z.object({ id: z.string(), position: z.number().optional(), @@ -678,6 +704,10 @@ const ConfiguredEtherCATDeviceSchema = z.object({ 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(), }) const EthercatConfigSchema = z.object({ diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index e92574760..548e98cae 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -253,6 +253,62 @@ export interface ESIChannel { 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 ===================== /** @@ -381,6 +437,14 @@ export interface ConfiguredEtherCATDevice { 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 } // ===================== PER-SLAVE CONFIGURATION ===================== diff --git a/src/utils/ethercat/enrich-device-data.ts b/src/utils/ethercat/enrich-device-data.ts new file mode 100644 index 000000000..c0676c618 --- /dev/null +++ b/src/utils/ethercat/enrich-device-data.ts @@ -0,0 +1,109 @@ +/** + * 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, +} from '@root/types/ethercat/esi-types' + +import { esiTypeToIecType, pdoToChannels } from './esi-parser' + +/** + * 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 +} { + return { + channelInfo: buildChannelInfo(device), + rxPdos: persistPdos(device.rxPdo), + txPdos: persistPdos(device.txPdo), + slaveType: deriveSlaveType(device), + } +} diff --git a/src/utils/ethercat/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts index bf781157e..c2f081be6 100644 --- a/src/utils/ethercat/generate-ethercat-config.ts +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -1,50 +1,157 @@ +import type { ConfiguredEtherCATDevice, PersistedChannelInfo, PersistedPdo } from '@root/types/ethercat/esi-types' import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' // Runtime JSON interfaces (snake_case for plugin consumption) -interface EthercatDcConfig { - enabled: boolean - sync0_cycle_us?: number - sync0_shift_us?: number +interface RuntimePdoEntry { + index: string + subindex: number + bit_length: number + name: string + data_type: string } -interface EthercatPdoEntry { +interface RuntimePdo { index: string - address: string + name: string + entries: RuntimePdoEntry[] } -interface EthercatPdoMapping { - inputs?: EthercatPdoEntry[] - outputs?: EthercatPdoEntry[] +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 EthercatSlaveJson { +interface RuntimeSlave { position: number name: string + type: string vendor_id: string product_code: string revision: string - check_vendor: boolean - check_product: boolean - dc: EthercatDcConfig - pdo_mapping?: EthercatPdoMapping + channels: RuntimeChannel[] + sdo_configurations: unknown[] + rx_pdos: RuntimePdo[] + tx_pdos: RuntimePdo[] } -interface EthercatMasterJson { +interface RuntimeMaster { interface: string cycle_time_us: number - dc_enabled: boolean - dc_sync_offset_percent: number } -interface EthercatConfigJson { - master: EthercatMasterJson - slaves: EthercatSlaveJson[] +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) +} + +/** + * 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), + })) +} + +/** + * 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) : [] + + return { + position, + name: device.name, + type: device.slaveType ?? 'coupler', + vendor_id: device.vendorId, + product_code: device.productCode, + revision: device.revisionNo, + channels, + sdo_configurations: [], + rx_pdos: rxPdos, + tx_pdos: txPdos, + } } /** * Generates the EtherCAT plugin configuration JSON from the project's remote devices. - * Returns null if there are no EtherCAT devices configured. + * 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 @@ -63,43 +170,13 @@ export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefi } // Collect all configured slaves across all EtherCAT remote devices - const allSlaves: EthercatSlaveJson[] = [] - let anyDcEnabled = false + const allSlaves: RuntimeSlave[] = [] for (const remoteDevice of ethercatRemoteDevices) { - const devices = remoteDevice.ethercatConfig?.devices ?? [] + const devices = (remoteDevice.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] for (let i = 0; i < devices.length; i++) { - const device = devices[i] - const position = device.position ?? i - const dc = device.config.distributedClocks - - if (dc.dcEnabled) { - anyDcEnabled = true - } - - // Build PDO mapping from channel mappings - const pdoMapping = buildPdoMapping(device.channelMappings) - - const slave: EthercatSlaveJson = { - position, - name: device.name, - vendor_id: device.vendorId, - product_code: device.productCode, - revision: device.revisionNo, - check_vendor: device.config.startupChecks.checkVendorId, - check_product: device.config.startupChecks.checkProductCode, - dc: { - enabled: dc.dcEnabled, - ...(dc.dcEnabled && { - sync0_cycle_us: dc.dcSync0CycleUs, - sync0_shift_us: dc.dcSync0ShiftUs, - }), - }, - ...(pdoMapping && { pdo_mapping: pdoMapping }), - } - - allSlaves.push(slave) + allSlaves.push(buildSlave(devices[i], i)) } } @@ -107,59 +184,24 @@ export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefi return null } - const config: EthercatConfigJson = { - master: { - interface: 'eth0', - cycle_time_us: 4000, - dc_enabled: anyDcEnabled, - dc_sync_offset_percent: 20, + const rootEntry: RuntimeRootEntry = { + name: 'ethercat_master', + protocol: 'ETHERCAT', + config: { + master: { + interface: 'eth0', + cycle_time_us: 1000, + }, + slaves: allSlaves, + diagnostics: { + log_connections: true, + log_data_access: false, + log_errors: true, + max_log_entries: 10000, + status_update_interval_ms: 500, + }, }, - slaves: allSlaves, } - return JSON.stringify(config, null, 2) -} - -/** - * Builds PDO mapping from channel mappings. - * Groups channel mappings by direction (input/output) based on IEC location prefix. - */ -const buildPdoMapping = ( - channelMappings: { channelId: string; iecLocation: string; userEdited: boolean }[], -): EthercatPdoMapping | null => { - if (!channelMappings || channelMappings.length === 0) { - return null - } - - const inputs: EthercatPdoEntry[] = [] - const outputs: EthercatPdoEntry[] = [] - - for (const mapping of channelMappings) { - const loc = mapping.iecLocation - // Extract PDO index from channelId (format: "pdo_0x1A00_entry_0x6000_01" or similar) - const pdoIndexMatch = mapping.channelId.match(/pdo_(0x[0-9A-Fa-f]+)/) - const pdoIndex = pdoIndexMatch ? pdoIndexMatch[1] : '0x0000' - - const entry: EthercatPdoEntry = { - index: pdoIndex, - address: loc, - } - - // IEC location prefix determines direction: %I = input, %Q = output - if (loc.startsWith('%I')) { - inputs.push(entry) - } else if (loc.startsWith('%Q')) { - outputs.push(entry) - } - } - - if (inputs.length === 0 && outputs.length === 0) { - return null - } - - const result: EthercatPdoMapping = {} - if (inputs.length > 0) result.inputs = inputs - if (outputs.length > 0) result.outputs = outputs - - return result + return JSON.stringify([rootEntry], null, 2) } From 603f1817a4c768d5e949c821376c8abdf61061c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 11 Feb 2026 12:07:39 -0300 Subject: [PATCH 14/31] feat: add EtherCAT master network interface and cycle time configuration Allow users to configure the EtherCAT master network interface and cycle time from the editor UI instead of relying on hardcoded defaults. When connected to runtime, a dropdown selector shows available interfaces; when offline, a text input allows manual entry. The config generator now produces one root entry per remote device with its own master settings. Co-Authored-By: Claude Opus 4.6 --- .../editor/device/ethercat/index.tsx | 115 +++++++++++++++++- src/types/PLC/open-plc.ts | 9 ++ .../ethercat/generate-ethercat-config.ts | 53 ++++---- 3 files changed, 147 insertions(+), 30 deletions(-) diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 50ea6e901..afd955b6f 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,4 +1,6 @@ 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 { useOpenPLCStore } from '@root/renderer/store' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { @@ -10,6 +12,7 @@ import type { EtherCATSlaveConfig, ScannedDeviceMatch, } 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' @@ -62,11 +65,26 @@ const EtherCATEditor = () => { return (remoteDevice?.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] }, [remoteDevice]) + const masterConfig = useMemo(() => { + return remoteDevice?.ethercatConfig?.masterConfig ?? { networkInterface: 'eth0', cycleTimeUs: 1000 } + }, [remoteDevice]) + const syncDevicesToStore = useCallback( (devices: ConfiguredEtherCATDevice[]) => { - projectActions.updateEthercatConfig(deviceName, { devices }) + 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], + [deviceName, projectActions, masterConfig, configuredDevices], ) const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) @@ -244,7 +262,10 @@ const EtherCATEditor = () => { // Initialize ethercatConfig in store if missing useEffect(() => { if (remoteDevice && !remoteDevice.ethercatConfig) { - projectActions.updateEthercatConfig(deviceName, { devices: [] }) + projectActions.updateEthercatConfig(deviceName, { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000 }, + devices: [], + }) } }, [remoteDevice, deviceName, projectActions]) @@ -393,6 +414,94 @@ const EtherCATEditor = () => {

Protocol: EtherCAT

+ {/* Master Settings */} +
+ Master Settings +
+ Network Interface + {isConnectedToRuntime && interfaces.length > 0 ? ( +
+ + +
+ ) : ( + handleUpdateMasterConfig({ networkInterface: e.target.value })} + placeholder='eth0' + className='h-[26px] w-36 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' + /> + )} + + {isConnectedToRuntime && interfaces.length > 0 + ? 'Select from runtime interfaces' + : 'Interface name on the runtime host (e.g. eth0, enp3s0)'} + +
+
+ Cycle Time (us) + { + const val = parseInt(e.target.value, 10) + if (!isNaN(val)) handleUpdateMasterConfig({ cycleTimeUs: val }) + }} + min={100} + max={100000} + className='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' + /> + + EtherCAT bus cycle time in microseconds + +
+
+ {/* Tabs */}
+
+ Watchdog Timeout (cycles) + { + const val = parseInt(e.target.value, 10) + if (!isNaN(val)) handleUpdateMasterConfig({ watchdogTimeoutCycles: val }) + }} + min={1} + max={100} + className='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' + /> + + Missed cycles before watchdog triggers + +
{/* Tabs */} diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index da899066d..371e70214 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -713,6 +713,7 @@ const ConfiguredEtherCATDeviceSchema = z.object({ 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 diff --git a/src/utils/ethercat/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts index 7e592a17e..8117d0aa8 100644 --- a/src/utils/ethercat/generate-ethercat-config.ts +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -44,6 +44,7 @@ interface RuntimeSlave { interface RuntimeMaster { interface: string cycle_time_us: number + watchdog_timeout_cycles: number } interface RuntimeDiagnostics { @@ -185,6 +186,7 @@ export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefi 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: { From baf61d9b3b48a8179f5da6551679fd5ca93a0553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 11 Feb 2026 16:38:32 -0300 Subject: [PATCH 16/31] fix: allow clearing numeric inputs in EtherCAT master settings Replace parseInt + isNaN guard with Number() on change and onBlur clamping. This lets users backspace to clear the field before typing a new value, instead of the input snapping back immediately. Co-Authored-By: Claude Opus 4.6 --- .../[workspace]/editor/device/ethercat/index.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 99ec97c9c..086b858db 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -494,9 +494,11 @@ const EtherCATEditor = () => { { - const val = parseInt(e.target.value, 10) - if (!isNaN(val)) handleUpdateMasterConfig({ cycleTimeUs: val }) + onChange={(e) => handleUpdateMasterConfig({ cycleTimeUs: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 100) handleUpdateMasterConfig({ cycleTimeUs: 100 }) + else if (val > 100000) handleUpdateMasterConfig({ cycleTimeUs: 100000 }) }} min={100} max={100000} @@ -511,9 +513,11 @@ const EtherCATEditor = () => { { - const val = parseInt(e.target.value, 10) - if (!isNaN(val)) handleUpdateMasterConfig({ watchdogTimeoutCycles: val }) + onChange={(e) => handleUpdateMasterConfig({ watchdogTimeoutCycles: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 1) handleUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) + else if (val > 100) handleUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) }} min={1} max={100} From 01c3c39c9f5ff5827ead7f66dffe1cf8fe95d66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 12 Feb 2026 15:32:32 -0300 Subject: [PATCH 17/31] feat: refactor EtherCAT channel mapping table with alias support and global address uniqueness - Add optional alias field to EtherCATChannelMapping (matching Modbus pattern) - Make IEC Location column read-only (auto-generated), add editable Alias column - Generate globally unique IEC addresses across all protocols (Modbus + EtherCAT) - Modbus IO group generation now also checks EtherCAT addresses to avoid conflicts - EtherCAT aliases appear in variable location picker suggestions alongside Modbus aliases Co-Authored-By: Claude Opus 4.6 --- .../components/channel-mapping-table.tsx | 75 +++++++++---- .../components/configured-device-row.tsx | 24 ++-- .../components/configured-devices.tsx | 3 + .../editor/device/ethercat/index.tsx | 27 +++++ src/renderer/hooks/use-store-selectors.ts | 43 +++++-- src/renderer/store/slices/project/slice.ts | 14 +++ src/types/PLC/open-plc.ts | 1 + src/types/ethercat/esi-types.ts | 2 + src/utils/ethercat/esi-parser.ts | 105 +++++++++++++++--- 9 files changed, 238 insertions(+), 56 deletions(-) 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 index a8b6a5a7c..bf19e1b5e 100644 --- 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 @@ -1,21 +1,54 @@ import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' type ChannelMappingTableProps = { channels: ESIChannel[] mappings: EtherCATChannelMapping[] - onLocationChange: (channelId: string, newLocation: string) => void + 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 = ({ + channelId, + alias, + onAliasChange, +}: { + channelId: string + alias: string + onAliasChange: (channelId: string, newAlias: string) => void +}) => { + const [localAlias, setLocalAlias] = useState(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 editable IEC 61131-3 located variable addresses. + * Displays channels with auto-generated IEC 61131-3 located variable addresses (read-only) + * and editable alias column. */ -const ChannelMappingTable = ({ channels, mappings, onLocationChange }: ChannelMappingTableProps) => { +const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappingTableProps) => { const [filterDirection, setFilterDirection] = useState('all') const [searchTerm, setSearchTerm] = useState('') @@ -43,7 +76,8 @@ const ChannelMappingTable = ({ channels, mappings, onLocationChange }: ChannelMa channel.dataType.toLowerCase().includes(search) || channel.entryIndex.toLowerCase().includes(search) || channel.iecType.toLowerCase().includes(search) || - (mapping?.iecLocation.toLowerCase().includes(search) ?? false) + (mapping?.iecLocation.toLowerCase().includes(search) ?? false) || + (mapping?.alias?.toLowerCase().includes(search) ?? false) ) } @@ -113,30 +147,31 @@ const ChannelMappingTable = ({ channels, mappings, onLocationChange }: ChannelMa Dir - + Name - + Index - + Type - + Bits - + IEC Type - + IEC Location + Alias {filteredChannels.length === 0 ? ( - + {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} @@ -174,19 +209,11 @@ const ChannelMappingTable = ({ channels, mappings, onLocationChange }: ChannelMa {channel.iecType} + + {mapping?.iecLocation ?? ''} + - onLocationChange(channel.id, e.target.value)} - className={cn( - 'h-[24px] w-full rounded border px-1.5 font-mono text-xs outline-none', - 'border-neutral-300 bg-white text-neutral-700 focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300', - mapping?.userEdited && - 'border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20', - )} - title={mapping?.userEdited ? 'Manually edited' : 'Auto-generated'} - /> + ) 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 index a017b6c15..543ef128f 100644 --- 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 @@ -36,6 +36,7 @@ type ConfiguredDeviceRowProps = { projectPath: string onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void onEnrichDevice: (data: EnrichDeviceData) => void + usedAddresses: Set } const inputClassName = @@ -59,6 +60,7 @@ const ConfiguredDeviceRow = ({ projectPath, onUpdateChannelMappings, onEnrichDevice, + usedAddresses, }: ConfiguredDeviceRowProps) => { // Resolve the ESI device summary from repository const esiDevice = useMemo(() => { @@ -75,6 +77,15 @@ const ConfiguredDeviceRow = ({ const config = device.config + // Compute addresses used by other devices (excluding this device's own mappings) + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + // Channel loading state const [channels, setChannels] = useState([]) const [isLoadingChannels, setIsLoadingChannels] = useState(false) @@ -103,7 +114,7 @@ const ConfiguredDeviceRow = ({ // Generate default mappings if none exist if (device.channelMappings.length === 0 && deviceChannels.length > 0) { - onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels)) + onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels, externalAddresses)) } // Enrich if data is missing (backward compat for projects created before enrichment) @@ -131,13 +142,12 @@ const ConfiguredDeviceRow = ({ device.txPdos, onUpdateChannelMappings, onEnrichDevice, + externalAddresses, ]) - const handleLocationChange = useCallback( - (channelId: string, newLocation: string) => { - const updated = device.channelMappings.map((m) => - m.channelId === channelId ? { ...m, iecLocation: newLocation, userEdited: true } : m, - ) + const handleAliasChange = useCallback( + (channelId: string, alias: string) => { + const updated = device.channelMappings.map((m) => (m.channelId === channelId ? { ...m, alias } : m)) onUpdateChannelMappings(updated) }, [device.channelMappings, onUpdateChannelMappings], @@ -648,7 +658,7 @@ const 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 index 9bc3315d6..f9e176ed8 100644 --- 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 @@ -28,6 +28,7 @@ type ConfiguredDevicesProps = { projectPath: string onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + usedAddresses: Set } /** @@ -44,6 +45,7 @@ const ConfiguredDevices = ({ projectPath, onUpdateChannelMappings, onEnrichDevice, + usedAddresses, }: ConfiguredDevicesProps) => { const [expandedDevices, setExpandedDevices] = useState>(new Set()) const [selectedDeviceId, setSelectedDeviceId] = useState(null) @@ -140,6 +142,7 @@ const ConfiguredDevices = ({ projectPath={projectPath} onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(device.id, mappings)} onEnrichDevice={(data) => onEnrichDevice(device.id, data)} + usedAddresses={usedAddresses} /> )) )} diff --git a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index 086b858db..ff0cfa754 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -65,6 +65,32 @@ const EtherCATEditor = () => { 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) { + // Modbus devices + if (rd.modbusTcpConfig?.ioGroups) { + for (const group of rd.modbusTcpConfig.ioGroups) { + for (const point of group.ioPoints) { + addresses.add(point.iecLocation) + } + } + } + // EtherCAT devices + 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 ?? { @@ -728,6 +754,7 @@ const EtherCATEditor = () => { projectPath={projectPath} onUpdateChannelMappings={handleUpdateChannelMappings} onEnrichDevice={handleEnrichDevice} + usedAddresses={usedAddresses} /> )} 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/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts index 3ef107f43..b70703df4 100644 --- a/src/renderer/store/slices/project/slice.ts +++ b/src/renderer/store/slices/project/slice.ts @@ -2332,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({ @@ -2389,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/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index 371e70214..c2b489a44 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -616,6 +616,7 @@ const EtherCATChannelMappingSchema = z.object({ channelId: z.string(), iecLocation: z.string(), userEdited: z.boolean(), + alias: z.string().optional(), }) const ESIDeviceRefSchema = z.object({ diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index 548e98cae..ff85fc825 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -321,6 +321,8 @@ export interface EtherCATChannelMapping { iecLocation: string /** True if the user manually edited this address */ userEdited: boolean + /** User-editable alias for this channel mapping */ + alias?: string } // ===================== PARSE RESULT ===================== diff --git a/src/utils/ethercat/esi-parser.ts b/src/utils/ethercat/esi-parser.ts index 55e4453da..495e5971b 100644 --- a/src/utils/ethercat/esi-parser.ts +++ b/src/utils/ethercat/esi-parser.ts @@ -345,46 +345,125 @@ export function pdoToChannels(device: ESIDevice): ESIChannel[] { * 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): string { +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 `${dirPrefix}X${channel.byteOffset}.${channel.bitOffset % 8}` + return 1 case 'BYTE': case 'SINT': case 'USINT': - return `${dirPrefix}B${channel.byteOffset}` + return 8 case 'WORD': case 'INT': case 'UINT': - return `${dirPrefix}W${channel.byteOffset}` + return 16 case 'DWORD': case 'DINT': case 'UDINT': case 'REAL': - return `${dirPrefix}D${channel.byteOffset}` + return 32 case 'LWORD': case 'LINT': case 'ULINT': case 'LREAL': - return `${dirPrefix}L${channel.byteOffset}` + return 64 default: - return `${dirPrefix}B${channel.byteOffset}` + 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[]): EtherCATChannelMapping[] { - return channels.map((channel) => ({ - channelId: channel.id, - iecLocation: generateIecLocation(channel), - userEdited: false, - })) +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 } /** From dadfbe14aa71596ba97651dff8b3ec45a44c5d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 12 Feb 2026 16:18:39 -0300 Subject: [PATCH 18/31] refactor: simplify EtherCAT channel mapping table columns Remove Index, Type, and Bits columns. Replace Name with a 1-based per-direction counter (#). Rename IEC Location to Address. Co-Authored-By: Claude Opus 4.6 --- .../components/channel-mapping-table.tsx | 57 +++++++++---------- 1 file changed, 27 insertions(+), 30 deletions(-) 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 index bf19e1b5e..63d6fb7fb 100644 --- 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 @@ -61,6 +61,23 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi 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) => { @@ -72,9 +89,6 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi const search = searchTerm.toLowerCase() const mapping = mappingMap.get(channel.id) return ( - channel.name.toLowerCase().includes(search) || - channel.dataType.toLowerCase().includes(search) || - channel.entryIndex.toLowerCase().includes(search) || channel.iecType.toLowerCase().includes(search) || (mapping?.iecLocation.toLowerCase().includes(search) ?? false) || (mapping?.alias?.toLowerCase().includes(search) ?? false) @@ -144,26 +158,17 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi - - - - - - @@ -171,7 +176,7 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi {filteredChannels.length === 0 ? ( - @@ -183,6 +188,9 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi key={channel.id} className='border-b border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-800/50' > + - - - - From b8483ab20cc20ac9cf24c7e305bf5715a5c98ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 18 Feb 2026 08:13:48 -0300 Subject: [PATCH 19/31] refactor: use generic plugin-command API for EtherCAT scan Migrate EtherCAT scan from dedicated /api/discovery/ethercat/scan endpoint to the new generic /api/plugin-command infrastructure on the runtime side. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/ipc/main.ts | 38 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index ee607dc1f..1224509f8 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -592,14 +592,18 @@ class MainProcessBridge implements MainIpcModule { scanRequest: EtherCATScanRequest, ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { try { - const postData = JSON.stringify(scanRequest) + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'scan', + params: { interface: scanRequest.interface }, + }) return new Promise((resolve) => { const req = https.request( { hostname: ipAddress, port: this.RUNTIME_API_PORT, - path: '/api/discovery/ethercat/scan', + path: '/api/plugin-command', method: 'POST', headers: { 'Content-Type': 'application/json', @@ -616,21 +620,31 @@ class MainProcessBridge implements MainIpcModule { res.on('end', () => { if (res.statusCode === 200) { try { - const response = JSON.parse(data) as EtherCATScanResponse + const pluginResponse = JSON.parse(data) + + if (pluginResponse.error) { + resolve({ success: false, error: pluginResponse.error }) + return + } + + const response: EtherCATScanResponse = { + status: pluginResponse.status ?? 'success', + devices: pluginResponse.devices ?? [], + message: pluginResponse.message ?? '', + scan_time_ms: 0, + interface: scanRequest.interface, + } resolve({ success: true, data: response }) } catch { resolve({ success: false, error: 'Invalid response format' }) } - } else if (res.statusCode === 403) { - resolve({ success: false, error: 'Permission denied - CAP_NET_RAW required' }) - } else if (res.statusCode === 404) { - resolve({ success: false, error: 'Interface not found' }) - } else if (res.statusCode === 503) { - resolve({ success: false, error: 'Discovery service not available' }) - } else if (res.statusCode === 504) { - resolve({ success: false, error: 'Scan timeout' }) } else { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + try { + const errorResponse = JSON.parse(data) + resolve({ success: false, error: errorResponse.error || `Unexpected status: ${res.statusCode}` }) + } catch { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } } }) }, From fcc521f8e8fbd4c1cabccce2444d21616e93cc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 26 Feb 2026 13:08:06 +0100 Subject: [PATCH 20/31] feat: add SDO/CoE Object Dictionary support to EtherCAT editor - Parse CoE Dictionary from ESI XML (DataTypes + Objects with sub-item resolution) - Extract configurable RW parameters as SDOConfigurationEntry for startup - Auto-populate SDO defaults when enriching devices from ESI - Generate sdo_configurations in runtime JSON config - Add SdoParametersTable UI with type-aware inputs and reset-to-defaults - Auto-assign position for manually added devices - Add Zod schema for persistence (SDOConfigurationEntrySchema) Co-Authored-By: Claude Opus 4.6 --- .../services/esi-service/esi-parser-main.ts | 211 ++++++++++++++- .../components/configured-device-row.tsx | 67 ++++- .../components/configured-devices.tsx | 5 + .../components/sdo-parameters-table.tsx | 252 ++++++++++++++++++ .../editor/device/ethercat/index.tsx | 15 ++ src/types/PLC/open-plc.ts | 13 + src/types/ethercat/esi-types.ts | 33 +++ src/utils/ethercat/enrich-device-data.ts | 4 + .../ethercat/generate-ethercat-config.ts | 40 ++- src/utils/ethercat/sdo-config-defaults.ts | 83 ++++++ 10 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx create mode 100644 src/utils/ethercat/sdo-config-defaults.ts diff --git a/src/main/services/esi-service/esi-parser-main.ts b/src/main/services/esi-service/esi-parser-main.ts index d818595f2..e8c741afd 100644 --- a/src/main/services/esi-service/esi-parser-main.ts +++ b/src/main/services/esi-service/esi-parser-main.ts @@ -8,6 +8,8 @@ */ import type { + ESICoEObject, + ESICoESubItem, ESIDevice, ESIDeviceSummary, ESIDeviceType, @@ -75,7 +77,7 @@ function createParser(): XMLParser { 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'] + const arrayTags = ['Device', 'Group', 'RxPdo', 'TxPdo', 'Entry', 'Fmmu', 'Sm', 'Object', 'SubItem', 'DataType'] return arrayTags.includes(tagName) }, }) @@ -308,6 +310,209 @@ export function parseESIDeviceFull(xmlString: string, deviceIndex: number): ESID } } +// ===================== 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 */ @@ -380,6 +585,9 @@ function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): txPdo.push(parseFullPdo(pdoEl)) } + // Parse CoE Object Dictionary + const coeObjects = parseCoEDictionary(deviceEl) + return { type, name: getTextValue(deviceEl['Name']) || 'Unknown Device', @@ -389,6 +597,7 @@ function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): syncManagers, rxPdo, txPdo, + coeObjects, description: getTextValue(deviceEl['Comment']) || undefined, } } 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 index 543ef128f..e5e7ee3c8 100644 --- 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 @@ -4,12 +4,14 @@ import { InputWithRef } from '@root/renderer/components/_atoms/input' import type { ConfiguredEtherCATDevice, ESIChannel, + ESICoEObject, ESIDeviceSummary, ESIRepositoryItemLight, EtherCATChannelMapping, EtherCATSlaveConfig, PersistedChannelInfo, PersistedPdo, + SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' import { enrichDeviceData } from '@root/utils/ethercat/enrich-device-data' @@ -17,12 +19,14 @@ import { generateDefaultChannelMappings, pdoToChannels } from '@root/utils/ether import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChannelMappingTable } from './channel-mapping-table' +import { SdoParametersTable } from './sdo-parameters-table' type EnrichDeviceData = { channelInfo?: PersistedChannelInfo[] rxPdos?: PersistedPdo[] txPdos?: PersistedPdo[] slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] } type ConfiguredDeviceRowProps = { @@ -36,6 +40,7 @@ type ConfiguredDeviceRowProps = { projectPath: string onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void usedAddresses: Set } @@ -60,6 +65,7 @@ const ConfiguredDeviceRow = ({ projectPath, onUpdateChannelMappings, onEnrichDevice, + onUpdateSdoConfigurations, usedAddresses, }: ConfiguredDeviceRowProps) => { // Resolve the ESI device summary from repository @@ -88,6 +94,7 @@ const ConfiguredDeviceRow = ({ // Channel loading state const [channels, setChannels] = useState([]) + const [coeObjects, setCoeObjects] = useState(undefined) const [isLoadingChannels, setIsLoadingChannels] = useState(false) const [channelLoadError, setChannelLoadError] = useState(null) const fullDeviceLoadedRef = useRef(false) @@ -110,6 +117,7 @@ const ConfiguredDeviceRow = ({ if (result.success && result.device) { const deviceChannels = pdoToChannels(result.device) setChannels(deviceChannels) + setCoeObjects(result.device.coeObjects) fullDeviceLoadedRef.current = true // Generate default mappings if none exist @@ -118,7 +126,12 @@ const ConfiguredDeviceRow = ({ } // Enrich if data is missing (backward compat for projects created before enrichment) - if (!device.channelInfo || !device.rxPdos || !device.txPdos) { + const needsEnrichment = + !device.channelInfo || + !device.rxPdos || + !device.txPdos || + (device.sdoConfigurations === undefined && result.device.coeObjects?.length) + if (needsEnrichment) { onEnrichDevice(enrichDeviceData(result.device)) } } else { @@ -626,6 +639,58 @@ const ConfiguredDeviceRow = ({ + {/* Startup Parameters (SDO) Section */} + + + + + {/* Channel Mappings Section */} 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 index f9e176ed8..37770cfe5 100644 --- 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 @@ -7,6 +7,7 @@ import type { EtherCATSlaveConfig, PersistedChannelInfo, PersistedPdo, + SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import { useCallback, useState } from 'react' @@ -17,6 +18,7 @@ type EnrichDeviceData = { rxPdos?: PersistedPdo[] txPdos?: PersistedPdo[] slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] } type ConfiguredDevicesProps = { @@ -28,6 +30,7 @@ type ConfiguredDevicesProps = { projectPath: string onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void usedAddresses: Set } @@ -45,6 +48,7 @@ const ConfiguredDevices = ({ projectPath, onUpdateChannelMappings, onEnrichDevice, + onUpdateSdoConfigurations, usedAddresses, }: ConfiguredDevicesProps) => { const [expandedDevices, setExpandedDevices] = useState>(new Set()) @@ -142,6 +146,7 @@ const ConfiguredDevices = ({ projectPath={projectPath} onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(device.id, mappings)} onEnrichDevice={(data) => onEnrichDevice(device.id, data)} + onUpdateSdoConfigurations={(configs) => onUpdateSdoConfigurations(device.id, configs)} usedAddresses={usedAddresses} /> )) 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..ce203b326 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -0,0 +1,252 @@ +import type { SDOConfigurationEntry } from '@root/types/ethercat/esi-types' +import { cn } from '@root/utils' +import { useCallback, useMemo, useState } from 'react' + +type SdoParametersTableProps = { + sdoConfigurations: SDOConfigurationEntry[] + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +/** + * 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 } + + // 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) + + const handleBlur = useCallback(() => { + if (localValue !== entry.value) { + // Validate before committing + 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) + + return ( + setLocalValue(e.target.value)} + onBlur={handleBlur} + min={range?.min} + max={range?.max} + 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 extracted from the CoE Object Dictionary. + * Allows editing values that will be written to the slave during EtherCAT startup. + */ +const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: SdoParametersTableProps) => { + const [searchTerm, setSearchTerm] = useState('') + + const filteredEntries = useMemo(() => { + if (!searchTerm) return sdoConfigurations + + const search = searchTerm.toLowerCase() + return sdoConfigurations.filter( + (entry) => + entry.name.toLowerCase().includes(search) || + entry.objectName.toLowerCase().includes(search) || + entry.index.toLowerCase().includes(search) || + entry.dataType.toLowerCase().includes(search), + ) + }, [sdoConfigurations, searchTerm]) + + 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) + + 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' + /> + + + {sdoConfigurations.length} parameter(s) + {hasModifiedValues && ' — modified values highlighted'} + +
+ + {/* Table */} +
+
- Dir - - Name + + # - Index - - Type - - Bits + Dir + IEC Type - IEC Location + + Address Alias
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'}
+ {channelIndexMap.get(channel.id) ?? ''} + - {channel.name} - - {channel.entryIndex}:{channel.entrySubIndex} - {channel.dataType}{channel.bitLen} {channel.iecType}
+
+
+ Startup Parameters (SDO) +
+ + {isLoadingChannels && ( +
+ + Loading CoE data... +
+ )} + + {!isLoadingChannels && device.sdoConfigurations && device.sdoConfigurations.length > 0 && ( + + )} + + {!isLoadingChannels && device.sdoConfigurations && device.sdoConfigurations.length === 0 && ( +

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

+ )} + + {!isLoadingChannels && !device.sdoConfigurations && coeObjects && coeObjects.length > 0 && ( +
+

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

+ +
+ )} + + {!isLoadingChannels && !device.sdoConfigurations && !coeObjects && ( +

+ No CoE Object Dictionary available for this device. +

+ )} +
+
+ + + + + + + + + + + + + + {filteredEntries.length === 0 ? ( + + + + ) : ( + filteredEntries.map((entry) => { + const isModified = entry.value !== entry.defaultValue + return ( + + + + + + + + + + + ) + }) + )} + +
+ Index + + Sub + + Name + + Type + + Bits + + Default + + Value + Object
+ {sdoConfigurations.length === 0 + ? 'No configurable SDO parameters found' + : 'No parameters match the current filter'} +
+ {entry.index} + + {entry.subIndex} + + {entry.name} + {entry.dataType}{entry.bitLength} + {entry.defaultValue || '-'} + + + + {entry.objectName} +
+
+
+ ) +} + +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 index ff0cfa754..bea900d47 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -11,6 +11,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, ScannedDeviceMatch, + SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' import { cn } from '@root/utils' @@ -389,8 +390,13 @@ const EtherCATEditor = () => { enriched = enrichDeviceData(result.device) } + // Compute next available position (max existing position + 1, or 0 if none) + 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, @@ -438,6 +444,14 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) + // Handle updating a configured device's SDO configurations + const handleUpdateSdoConfigurations = useCallback( + (deviceId: string, sdoConfigurations: SDOConfigurationEntry[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + return (
{/* Header */} @@ -754,6 +768,7 @@ const EtherCATEditor = () => { projectPath={projectPath} onUpdateChannelMappings={handleUpdateChannelMappings} onEnrichDevice={handleEnrichDevice} + onUpdateSdoConfigurations={handleUpdateSdoConfigurations} usedAddresses={usedAddresses} /> )} diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index c2b489a44..cac6d5bdd 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -694,6 +694,17 @@ const PersistedChannelInfoSchema = z.object({ 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(), @@ -709,6 +720,7 @@ const ConfiguredEtherCATDeviceSchema = z.object({ rxPdos: z.array(PersistedPdoSchema).optional(), txPdos: z.array(PersistedPdoSchema).optional(), slaveType: z.string().optional(), + sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), }) const EtherCATMasterConfigSchema = z.object({ @@ -855,6 +867,7 @@ export { S7CommSlaveConfigSchema, S7CommSystemAreaSchema, S7CommSystemAreasSchema, + SDOConfigurationEntrySchema, } export type { diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index ff85fc825..000983078 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -121,6 +121,10 @@ export interface ESICoEObject { 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 */ @@ -141,10 +145,37 @@ export interface ESICoESubItem { 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 ===================== /** @@ -447,6 +478,8 @@ export interface ConfiguredEtherCATDevice { 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 ===================== diff --git a/src/utils/ethercat/enrich-device-data.ts b/src/utils/ethercat/enrich-device-data.ts index c0676c618..9bf2e347d 100644 --- a/src/utils/ethercat/enrich-device-data.ts +++ b/src/utils/ethercat/enrich-device-data.ts @@ -11,9 +11,11 @@ import type { 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. @@ -99,11 +101,13 @@ export function enrichDeviceData(device: ESIDevice): { 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/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts index 8117d0aa8..d95e884ea 100644 --- a/src/utils/ethercat/generate-ethercat-config.ts +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -1,4 +1,9 @@ -import type { ConfiguredEtherCATDevice, PersistedChannelInfo, PersistedPdo } from '@root/types/ethercat/esi-types' +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) @@ -28,6 +33,16 @@ interface RuntimeChannel { pdo_entry_subindex: number } +interface RuntimeSdoConfig { + index: string + subindex: number + value: string + data_type: string + bit_length: number + name: string + comment: string +} + interface RuntimeSlave { position: number name: string @@ -36,7 +51,7 @@ interface RuntimeSlave { product_code: string revision: string channels: RuntimeChannel[] - sdo_configurations: unknown[] + sdo_configurations: RuntimeSdoConfig[] rx_pdos: RuntimePdo[] tx_pdos: RuntimePdo[] } @@ -125,6 +140,25 @@ function buildChannels( })) } +/** + * 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: 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. */ @@ -142,7 +176,7 @@ function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSla product_code: device.productCode, revision: device.revisionNo, channels, - sdo_configurations: [], + sdo_configurations: buildSdoConfigurations(device.sdoConfigurations), rx_pdos: rxPdos, tx_pdos: txPdos, } diff --git a/src/utils/ethercat/sdo-config-defaults.ts b/src/utils/ethercat/sdo-config-defaults.ts new file mode 100644 index 000000000..24e711a60 --- /dev/null +++ b/src/utils/ethercat/sdo-config-defaults.ts @@ -0,0 +1,83 @@ +/** + * 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 + if (sub.access !== 'RW') continue + // Must have a default value + 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 + 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 +} From a6604686d90985da8b5b9fe256fa35905d310e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 3 Mar 2026 10:35:11 +0100 Subject: [PATCH 21/31] refactor: group SDO parameters by parent object with collapsible sections Co-Authored-By: Claude Opus 4.6 --- .../components/sdo-parameters-table.tsx | 249 +++++++++++++----- 1 file changed, 188 insertions(+), 61 deletions(-) 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 index ce203b326..e18225a73 100644 --- 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 @@ -1,3 +1,4 @@ +import { ArrowIcon } from '@root/renderer/assets/icons' import type { SDOConfigurationEntry } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' import { useCallback, useMemo, useState } from 'react' @@ -7,6 +8,15 @@ type SdoParametersTableProps = { 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. */ @@ -50,7 +60,6 @@ const ValueCell = ({ const handleBlur = useCallback(() => { if (localValue !== entry.value) { - // Validate before committing const range = getDataTypeRange(entry.dataType, entry.bitLength) if (range && localValue !== '') { const num = Number(localValue) @@ -99,24 +108,76 @@ const ValueCell = ({ /** * SDO Parameters Table Component * - * Displays configurable SDO startup parameters extracted from the CoE Object Dictionary. - * Allows editing values that will be written to the slave during EtherCAT startup. + * 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]) - const filteredEntries = useMemo(() => { - if (!searchTerm) return sdoConfigurations + // Filter groups and entries by search term + const filteredGroups = useMemo(() => { + if (!searchTerm) return groups const search = searchTerm.toLowerCase() - return sdoConfigurations.filter( - (entry) => - entry.name.toLowerCase().includes(search) || - entry.objectName.toLowerCase().includes(search) || - entry.index.toLowerCase().includes(search) || - entry.dataType.toLowerCase().includes(search), - ) - }, [sdoConfigurations, searchTerm]) + 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) => { @@ -134,6 +195,7 @@ const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: Sd }, [sdoConfigurations, onUpdateSdoConfigurations]) const hasModifiedValues = sdoConfigurations.some((entry) => entry.value !== entry.defaultValue) + const totalEntries = sdoConfigurations.length return (
@@ -157,8 +219,22 @@ const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: Sd > Reset All to Defaults +
+ + +
- {sdoConfigurations.length} parameter(s) + {filteredGroups.length} object(s), {totalEntries} parameter(s) {hasModifiedValues && ' — modified values highlighted'}
@@ -166,15 +242,13 @@ const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: Sd {/* Table */}
- + - - + - - - - - {filteredEntries.length === 0 ? ( + {filteredGroups.length === 0 ? ( - ) : ( - filteredEntries.map((entry) => { - const isModified = entry.value !== entry.defaultValue + 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} + /> ) }) )} @@ -249,4 +298,82 @@ const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: Sd ) } +/** + * 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 */} + + + + + + {/* Sub-item rows */} + {!isCollapsed && + group.entries.map((entry) => { + const isModified = entry.value !== entry.defaultValue + return ( + + + + + + + + + + ) + })} + + ) +} + export { SdoParametersTable } From 68703ee2d59b1afd0ec81ea20a004e314118f7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 4 Mar 2026 12:11:13 +0100 Subject: [PATCH 22/31] feat: add EtherCAT runtime status monitoring panel Add real-time runtime status polling via plugin-command API, with a new RuntimeStatusPanel component that displays plugin state, per-slave status table, and cycle performance metrics when connected to the runtime. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/ipc/main.ts | 78 +++++ src/main/modules/ipc/renderer.ts | 10 + .../components/runtime-status-panel.tsx | 307 ++++++++++++++++++ .../editor/device/ethercat/index.tsx | 8 + src/types/ethercat/index.ts | 74 +++++ 5 files changed, 477 insertions(+) create mode 100644 src/renderer/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 1224509f8..341364d5d 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -2,6 +2,7 @@ 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, @@ -797,6 +798,82 @@ class MainProcessBridge implements MainIpcModule { } } + /** + * 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', + }) + + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: '/api/plugin-command', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + Authorization: `Bearer ${jwtToken}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + const pluginResponse = JSON.parse(data) + + if (pluginResponse.error) { + resolve({ success: false, error: pluginResponse.error }) + return + } + + resolve({ success: true, data: pluginResponse as EtherCATRuntimeStatusResponse }) + } catch { + resolve({ success: false, error: 'Invalid response format' }) + } + } else { + try { + const errorResponse = JSON.parse(data) + resolve({ + success: false, + error: errorResponse.error || `Unexpected status: ${res.statusCode}`, + }) + } catch { + resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + } + } + }) + }, + ) + req.setTimeout(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(postData) + req.end() + }) + } catch (error) { + return { success: false, error: String(error) } + } + } + // ===================== ESI REPOSITORY HANDLERS ===================== /** @@ -1091,6 +1168,7 @@ class MainProcessBridge implements MainIpcModule { 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) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 8ff5fb7da..9f22ddf62 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ import type { + EtherCATRuntimeStatusResponse, EtherCATScanRequest, EtherCATScanResponse, EtherCATServiceStatusResponse, @@ -420,6 +421,15 @@ const rendererProcessBridge = { ): 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 ===================== /** 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 && ( +
+
- Index - + Sub + Name @@ -183,62 +257,37 @@ const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: Sd Bits + Default + Value Object
+ {sdoConfigurations.length === 0 ? 'No configurable SDO parameters found' : 'No parameters match the current filter'}
- {entry.index} - - {entry.subIndex} - - {entry.name} - {entry.dataType}{entry.bitLength} - {entry.defaultValue || '-'} - - - - {entry.objectName} -
+ + +
+ {group.index} + {group.objectName} + + ({group.entries.length} param{group.entries.length !== 1 && 's'}) + + {hasModified && ( + + modified + + )} +
+
{entry.subIndex} + {entry.name} + {entry.dataType}{entry.bitLength} + {entry.defaultValue || '-'} + + +
+ + + + + + + + + + + {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/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index bea900d47..b1eb71970 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -26,6 +26,7 @@ import { DeviceBrowserModal } from './components/device-browser-modal' import { DiscoveredDeviceTable } from './components/discovered-device-table' import { ESIRepository } from './components/esi-repository' import { InterfaceSelector } from './components/interface-selector' +import { RuntimeStatusPanel } from './components/runtime-status-panel' type EditorTab = 'repository' | 'discovery' | 'configured' @@ -569,6 +570,13 @@ const EtherCATEditor = () => {
+ {/* Runtime Status */} + {isConnectedToRuntime && ( +
+ +
+ )} + {/* Tabs */}
+
+ )} + + {!isLoadingChannels && !device.sdoConfigurations && !coeObjects && ( +

+ No CoE Object Dictionary available for this device. +

+ )} +
+ + + {/* Channel Mappings Tab */} + +
+ {isLoadingChannels && ( +
+ + Loading channels... +
+ )} + + {channelLoadError && ( +
+ {channelLoadError} +
+ )} + + {!isLoadingChannels && !channelLoadError && channels.length === 0 && ( +

+ No channels available for this device. +

+ )} + + {!isLoadingChannels && !channelLoadError && channels.length > 0 && ( + + )} +
+
+ + + ) +} + +export { DeviceDetailPanel } 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..43cd1ce72 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -0,0 +1,261 @@ +import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' +import TableActions from '@root/renderer/components/_atoms/table-actions' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + PersistedChannelInfo, + PersistedPdo, + 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 EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] +} + +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/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/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx index b1eb71970..62ac186c7 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,6 +1,4 @@ -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 * as Tabs from '@radix-ui/react-tabs' import { useOpenPLCStore } from '@root/renderer/store' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { @@ -21,22 +19,46 @@ import { enrichDeviceData } from '@root/utils/ethercat/enrich-device-data' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' -import { ConfiguredDevices } from './components/configured-devices' -import { DeviceBrowserModal } from './components/device-browser-modal' -import { DiscoveredDeviceTable } from './components/discovered-device-table' -import { ESIRepository } from './components/esi-repository' -import { InterfaceSelector } from './components/interface-selector' -import { RuntimeStatusPanel } from './components/runtime-status-panel' - -type EditorTab = 'repository' | 'discovery' | 'configured' +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 * - * Provides interface for: - * - Managing ESI file repository (Repository tab) - * - Scanning for EtherCAT devices and matching with repository (Discovery tab) - * - Viewing and configuring added devices (Configured Devices tab) + * 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() @@ -49,9 +71,9 @@ const EtherCATEditor = () => { const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null && jwtToken !== null // Tab state - const [activeTab, setActiveTab] = useState('repository') + const [activeTab, setActiveTab] = useState('devices') - // Repository state (now lightweight) + // Repository state const [repository, setRepository] = useState([]) const [isLoadingRepository, setIsLoadingRepository] = useState(false) const [repositoryError, setRepositoryError] = useState(null) @@ -73,7 +95,6 @@ const EtherCATEditor = () => { const allRemoteDevices = project.data.remoteDevices || [] for (const rd of allRemoteDevices) { - // Modbus devices if (rd.modbusTcpConfig?.ioGroups) { for (const group of rd.modbusTcpConfig.ioGroups) { for (const point of group.ioPoints) { @@ -81,7 +102,6 @@ const EtherCATEditor = () => { } } } - // EtherCAT devices if (rd.ethercatConfig?.devices) { for (const dev of rd.ethercatConfig.devices) { for (const mapping of dev.channelMappings) { @@ -121,8 +141,6 @@ const EtherCATEditor = () => { [deviceName, projectActions, masterConfig, configuredDevices], ) - const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) - // Network interfaces state const [interfaces, setInterfaces] = useState([]) const [selectedInterface, setSelectedInterface] = useState('') @@ -143,14 +161,14 @@ const EtherCATEditor = () => { // Discovery selection state const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) - // Matched devices (scanned devices with repository matches) + // Matched devices const deviceMatches = useMemo(() => { return matchDevicesToRepository(scannedDevices, repository) }, [scannedDevices, repository]) const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) - // Check EtherCAT service status when connected to runtime + // Check EtherCAT service status const checkServiceStatus = useCallback(async () => { if (!isConnectedToRuntime || !ipAddress || !jwtToken) { setServiceAvailable(null) @@ -241,7 +259,7 @@ const EtherCATEditor = () => { } }, [isConnectedToRuntime, ipAddress, jwtToken, selectedInterface]) - // Load ESI repository from cache (v2) or migrate (v1) + // Load ESI repository useEffect(() => { const loadRepository = async () => { if (!projectPath || repositoryLoadedRef.current) return @@ -256,7 +274,6 @@ const EtherCATEditor = () => { 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 (migrationResult.success && migrationResult.items) { setRepository(migrationResult.items) @@ -303,7 +320,7 @@ const EtherCATEditor = () => { } }, [remoteDevice, deviceName, projectActions]) - // Handle device selection from scan + // Discovery handlers const handleSelectScannedDevice = useCallback((position: number, selected: boolean) => { setSelectedScannedDevices((prev) => { const next = new Set(prev) @@ -316,11 +333,9 @@ const EtherCATEditor = () => { }) }, []) - // Handle select all scanned devices const handleSelectAllScanned = useCallback( (selected: boolean) => { if (selected) { - // Select only devices with matches const selectable = deviceMatches .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') .map((dm) => dm.device.position) @@ -332,7 +347,6 @@ const EtherCATEditor = () => { [deviceMatches], ) - // Add selected scanned devices to configured devices const handleAddSelectedFromScan = useCallback(async () => { const newDevices: ConfiguredEtherCATDevice[] = [] @@ -340,12 +354,10 @@ const EtherCATEditor = () => { 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 - // Enrich with full ESI data let enriched = {} const result = await window.bridge.esiLoadDeviceFull( projectPath, @@ -377,21 +389,19 @@ const EtherCATEditor = () => { if (newDevices.length > 0) { syncDevicesToStore([...configuredDevices, ...newDevices]) setSelectedScannedDevices(new Set()) - setActiveTab('configured') + setActiveTab('devices') } }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) - // Handle adding device from browser modal + // Device management handlers const handleAddDeviceFromBrowser = useCallback( async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { - // Enrich with full ESI data let enriched = {} const result = await window.bridge.esiLoadDeviceFull(projectPath, ref.repositoryItemId, ref.deviceIndex) if (result.success && result.device) { enriched = enrichDeviceData(result.device) } - // Compute next available position (max existing position + 1, or 0 if none) const nextPosition = configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? -1)) + 1 : 0 @@ -413,7 +423,6 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore, projectPath], ) - // Handle removing a configured device const handleRemoveDevice = useCallback( (deviceId: string) => { syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) @@ -421,7 +430,6 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) - // Handle updating a configured device's configuration const handleUpdateDevice = useCallback( (deviceId: string, config: EtherCATSlaveConfig) => { syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) @@ -429,7 +437,6 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) - // Handle updating a configured device's channel mappings const handleUpdateChannelMappings = useCallback( (deviceId: string, channelMappings: EtherCATChannelMapping[]) => { syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) @@ -437,7 +444,6 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) - // Handle enriching a device with persisted ESI data (backward compat for old projects) const handleEnrichDevice = useCallback( (deviceId: string, data: Partial) => { syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) @@ -445,7 +451,6 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) - // Handle updating a configured device's SDO configurations const handleUpdateSdoConfigurations = useCallback( (deviceId: string, sdoConfigurations: SDOConfigurationEntry[]) => { syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) @@ -453,341 +458,124 @@ const EtherCATEditor = () => { [configuredDevices, syncDevicesToStore], ) + const handleRetryRepository = useCallback(() => { + setRepositoryError(null) + repositoryLoadedRef.current = false + setRepositoryLoadRetry((c) => c + 1) + }, []) + return (
{/* Header */} -
+

EtherCAT Device: {deviceName}

Protocol: EtherCAT

- {/* Master Settings */} -
- Master Settings -
- Network Interface - {isConnectedToRuntime && interfaces.length > 0 ? ( -
- - -
- ) : ( - handleUpdateMasterConfig({ networkInterface: e.target.value })} - placeholder='eth0' - className='h-[26px] w-36 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' - /> - )} - - {isConnectedToRuntime && interfaces.length > 0 - ? 'Select from runtime interfaces' - : 'Interface name on the runtime host (e.g. eth0, enp3s0)'} - -
-
- Cycle Time (us) - handleUpdateMasterConfig({ cycleTimeUs: Number(e.target.value) })} - onBlur={(e) => { - const val = Number(e.target.value) - if (!val || val < 100) handleUpdateMasterConfig({ cycleTimeUs: 100 }) - else if (val > 100000) handleUpdateMasterConfig({ cycleTimeUs: 100000 }) - }} - min={100} - max={100000} - className='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' + {/* Tabs */} + setActiveTab(v as EditorTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + 0 ? ( + + {scannedDevices.length} + + ) : undefined + } /> - - EtherCAT bus cycle time in microseconds - -
-
- Watchdog Timeout (cycles) - handleUpdateMasterConfig({ watchdogTimeoutCycles: Number(e.target.value) })} - onBlur={(e) => { - const val = Number(e.target.value) - if (!val || val < 1) handleUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) - else if (val > 100) handleUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) - }} - min={1} - max={100} - className='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' + 0 ? ( + + {configuredDevices.length} + + ) : undefined + } /> - - Missed cycles before watchdog triggers - -
-
- - {/* Runtime Status */} - {isConnectedToRuntime && ( -
- -
- )} + - {/* Tabs */} -
- - - -
+ 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()} + /> + - {/* Repository Tab */} - {activeTab === 'repository' && ( - <> - {repositoryError && ( -
-

Failed to load repository: {repositoryError}

- -
- )} - + - - )} - - {/* Discovery Tab */} - {activeTab === 'discovery' && ( -
- {/* 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 */} - {isConnectedToRuntime && serviceAvailable !== false && ( - <> - {/* Interface Selection and Scan Controls */} -
- void fetchInterfaces()} - /> - - - - {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 */} - - - )} -
- )} - - {/* Configured Devices Tab */} - {activeTab === 'configured' && ( - setIsDeviceBrowserOpen(true)} - onRemoveDevice={handleRemoveDevice} - onUpdateDevice={handleUpdateDevice} - projectPath={projectPath} - onUpdateChannelMappings={handleUpdateChannelMappings} - onEnrichDevice={handleEnrichDevice} - onUpdateSdoConfigurations={handleUpdateSdoConfigurations} - usedAddresses={usedAddresses} - /> - )} - - {/* Device Browser Modal */} - setIsDeviceBrowserOpen(false)} - onSelectDevice={(...args) => void handleAddDeviceFromBrowser(...args)} - repository={repository} - /> + +
) } From 3a6acc1193f73c47131dba032f50fa73b5f35a14 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Wed, 25 Mar 2026 12:46:06 +0100 Subject: [PATCH 25/31] refactor: remove unused config fields and add full slave config to runtime output Remove optionalSlave, checkRevisionNumber, and downloadPdoConfig fields that are no longer used by the runtime. Add complete RuntimeSlaveConfig (startup checks, addressing, timeouts, watchdog, distributed clocks) to the generated ethercat.json output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/configured-device-row.tsx | 25 -------- .../components/device-detail-panel.tsx | 25 -------- src/types/PLC/open-plc.ts | 3 - src/types/ethercat/esi-types.ts | 6 -- src/utils/ethercat/device-config-defaults.ts | 3 - .../ethercat/generate-ethercat-config.ts | 64 +++++++++++++++++++ 6 files changed, 64 insertions(+), 62 deletions(-) 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 index e5e7ee3c8..edd2a9182 100644 --- 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 @@ -318,24 +318,6 @@ const ConfiguredDeviceRow = ({ /> Verify Product Code - -
@@ -360,13 +342,6 @@ const ConfiguredDeviceRow = ({ /> 0 = auto - 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 index 121a78d5c..4f4222f8a 100644 --- 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 @@ -296,24 +296,6 @@ const DeviceDetailPanel = ({ /> Verify Product Code - - @@ -338,13 +320,6 @@ const DeviceDetailPanel = ({ /> 0 = auto - diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index cac6d5bdd..6b8e98a8a 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -627,13 +627,10 @@ const ESIDeviceRefSchema = z.object({ const EtherCATStartupChecksSchema = z.object({ checkVendorId: z.boolean(), checkProductCode: z.boolean(), - checkRevisionNumber: z.boolean(), - downloadPdoConfig: z.boolean(), }) const EtherCATAddressingSchema = z.object({ ethercatAddress: z.number(), - optionalSlave: z.boolean(), }) const EtherCATTimeoutsSchema = z.object({ diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index 000983078..6c2fe981a 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -493,10 +493,6 @@ export interface EtherCATStartupChecks { checkVendorId: boolean /** Verify slave product code matches ESI definition */ checkProductCode: boolean - /** Verify slave revision number matches ESI definition */ - checkRevisionNumber: boolean - /** Download expected PDO/slot configuration to slave at startup */ - downloadPdoConfig: boolean } /** @@ -505,8 +501,6 @@ export interface EtherCATStartupChecks { export interface EtherCATAddressing { /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ ethercatAddress: number - /** Mark slave as optional - network continues if this slave is absent */ - optionalSlave: boolean } /** diff --git a/src/utils/ethercat/device-config-defaults.ts b/src/utils/ethercat/device-config-defaults.ts index e5d575005..d1c1d6403 100644 --- a/src/utils/ethercat/device-config-defaults.ts +++ b/src/utils/ethercat/device-config-defaults.ts @@ -8,12 +8,9 @@ export const DEFAULT_SLAVE_CONFIG: Readonly = { startupChecks: { checkVendorId: true, checkProductCode: true, - checkRevisionNumber: false, - downloadPdoConfig: false, }, addressing: { ethercatAddress: 0, - optionalSlave: false, }, timeouts: { sdoTimeoutMs: 1000, diff --git a/src/utils/ethercat/generate-ethercat-config.ts b/src/utils/ethercat/generate-ethercat-config.ts index 0c52f95bc..4341ce39b 100644 --- a/src/utils/ethercat/generate-ethercat-config.ts +++ b/src/utils/ethercat/generate-ethercat-config.ts @@ -43,6 +43,37 @@ interface RuntimeSdoConfig { 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 @@ -50,6 +81,7 @@ interface RuntimeSlave { vendor_id: string product_code: string revision: string + config: RuntimeSlaveConfig channels: RuntimeChannel[] sdo_configurations: RuntimeSdoConfig[] rx_pdos: RuntimePdo[] @@ -189,6 +221,8 @@ function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSla const rxPdos = device.rxPdos ? convertPdos(device.rxPdos) : [] const txPdos = device.txPdos ? convertPdos(device.txPdos) : [] + const cfg = device.config + return { position, name: device.name, @@ -196,6 +230,36 @@ function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSla 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, From f87367b7c8037eaedcde6a7075e08f81bf45ea88 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Wed, 25 Mar 2026 17:51:37 +0100 Subject: [PATCH 26/31] fix: address code review issues in EtherCAT subsystem - Sync local state with props in AliasCell and ValueCell (useEffect) - Wrap AliasCell in React.memo to prevent unnecessary re-renders - Add index lock to ESI service to prevent race conditions in parseAndSaveFile, deleteRepositoryItemV2, and clearRepository - Fix clearRepository to only delete .xml files, not repository.json - Reset repositoryLoadedRef when projectPath changes - Remove selectedInterface from fetchInterfaces deps to prevent re-fetch cycle - Add Zod range constraints (int, min, max) to EtherCAT config schemas - Hoist parseNumericInput to module level in both detail components - Replace unsafe `as never` cast with direct extractDefaultSdoConfigurations call Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/esi-service/index.ts | 155 ++++++++++-------- .../components/channel-mapping-table.tsx | 64 ++++---- .../components/configured-device-row.tsx | 26 +-- .../components/device-detail-panel.tsx | 12 +- .../components/sdo-parameters-table.tsx | 6 +- .../editor/device/ethercat/index.tsx | 14 +- src/types/PLC/open-plc.ts | 22 +-- 7 files changed, 168 insertions(+), 131 deletions(-) diff --git a/src/main/services/esi-service/index.ts b/src/main/services/esi-service/index.ts index 18c293446..86d1a8b5b 100644 --- a/src/main/services/esi-service/index.ts +++ b/src/main/services/esi-service/index.ts @@ -42,6 +42,17 @@ interface ESIServiceResponse { 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 @@ -272,23 +283,25 @@ class ESIService { * Delete a repository item and update the v2 index (no re-parsing) */ async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { - try { - // Delete the XML file - const deleteResult = await this.deleteXmlFile(projectPath, itemId) - if (!deleteResult.success) { - return deleteResult - } + 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', + // 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', + } } - } + }) } /** @@ -398,73 +411,77 @@ class ESIService { filename: string, content: string, ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> { - 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 - } + // 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' } + } - // Parse - 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' } - } + // 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, - } + 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]) + // 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), + 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 { - try { - const esiDir = this.getEsiDir(projectPath) - if (!fileOrDirectoryExists(esiDir)) return { success: true } - - // Delete all files in the ESI directory - const entries = await promises.readdir(esiDir) - await Promise.all(entries.map((entry) => promises.unlink(join(esiDir, entry)))) - - // Recreate directory with empty v2 index - await this.ensureEsiDir(projectPath) - 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', + 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', + } } - } + }) } /** 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 index 63d6fb7fb..e9971bd4a 100644 --- 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 @@ -1,6 +1,6 @@ import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -import { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' type ChannelMappingTableProps = { channels: ESIChannel[] @@ -13,34 +13,40 @@ type FilterDirection = 'all' | 'input' | 'output' /** * Alias cell with local state to avoid re-rendering the entire table on every keystroke. */ -const AliasCell = ({ - channelId, - alias, - onAliasChange, -}: { - channelId: string - alias: string - onAliasChange: (channelId: string, newAlias: string) => void -}) => { - const [localAlias, setLocalAlias] = useState(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' - /> - ) -} +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 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 index edd2a9182..85f5f31b1 100644 --- 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 @@ -16,6 +16,7 @@ import type { import { cn } from '@root/utils' 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, useMemo, useRef, useState } from 'react' import { ChannelMappingTable } from './channel-mapping-table' @@ -49,6 +50,12 @@ const inputClassName = 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 +} + /** * Configured Device Row Component * @@ -148,11 +155,13 @@ const ConfiguredDeviceRow = ({ }, [ isExpanded, projectPath, - device.esiDeviceRef, + device.esiDeviceRef.repositoryItemId, + device.esiDeviceRef.deviceIndex, device.channelMappings.length, device.channelInfo, device.rxPdos, device.txPdos, + device.sdoConfigurations, onUpdateChannelMappings, onEnrichDevice, externalAddresses, @@ -179,15 +188,6 @@ const ConfiguredDeviceRow = ({ [config, onUpdateDevice], ) - /** - * Parse a number input value, returning the parsed int or undefined if invalid. - */ - const parseNumericInput = (value: string, min = 0): number | undefined => { - const parsed = parseInt(value, 10) - if (isNaN(parsed) || parsed < min) return undefined - return parsed - } - return ( <> {/* Main row */} @@ -649,7 +649,11 @@ const ConfiguredDeviceRow = ({ CoE Object Dictionary available. Auto-configure startup parameters?

+ )} + + {!isLoadingChannels && + !channelLoadError && + !device.sdoConfigurations && + coeObjects && + coeObjects.length > 0 && ( +
+

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

+ +
+ )} + + {channelLoadError && ( +
+ {channelLoadError}
)} - {!isLoadingChannels && !device.sdoConfigurations && !coeObjects && ( + {!isLoadingChannels && !channelLoadError && !device.sdoConfigurations && !coeObjects && (

No CoE Object Dictionary available for this device.

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 index e432bd104..c34f1b263 100644 --- 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 @@ -136,13 +136,23 @@ const DeviceDetailPanel = ({ onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels, externalAddresses)) } - const needsEnrichment = - !device.channelInfo || - !device.rxPdos || - !device.txPdos || - (device.sdoConfigurations === undefined && result.device.coeObjects?.length) - if (needsEnrichment) { - onEnrichDevice(enrichDeviceData(result.device)) + // Only enrich PDO/channel data if missing (never overwrite user-edited sdoConfigurations) + if (!device.channelInfo || !device.rxPdos || !device.txPdos) { + const enriched = enrichDeviceData(result.device) + // Preserve existing sdoConfigurations if already set + if (device.sdoConfigurations !== undefined) { + delete enriched.sdoConfigurations + } + onEnrichDevice(enriched) + } else if (device.sdoConfigurations === undefined && result.device.coeObjects?.length) { + // Only backfill SDO configs when they've never been set + onEnrichDevice({ + 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') @@ -601,31 +611,44 @@ const DeviceDetailPanel = ({ /> )} - {!isLoadingChannels && device.sdoConfigurations && device.sdoConfigurations.length === 0 && ( -

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

- )} - - {!isLoadingChannels && !device.sdoConfigurations && coeObjects && coeObjects.length > 0 && ( -
-

- CoE Object Dictionary available. Auto-configure startup parameters? + {!isLoadingChannels && + !channelLoadError && + device.sdoConfigurations && + device.sdoConfigurations.length === 0 && ( +

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

- + )} + + {!isLoadingChannels && + !channelLoadError && + !device.sdoConfigurations && + coeObjects && + coeObjects.length > 0 && ( +
+

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

+ +
+ )} + + {channelLoadError && ( +
+ {channelLoadError}
)} - {!isLoadingChannels && !device.sdoConfigurations && !coeObjects && ( + {!isLoadingChannels && !channelLoadError && !device.sdoConfigurations && !coeObjects && (

No CoE Object Dictionary available for this device.

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 index e673c8429..36d33d383 100644 --- 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 @@ -125,8 +125,8 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading
{errorsExpanded && (
- {uploadErrors.map((error) => ( -
+ {uploadErrors.map((error, index) => ( +
{error.filename || 'File'}: {error.error}
))} 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 index 12fb7a062..e4206c28f 100644 --- 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 @@ -99,16 +99,17 @@ const ValueCell = ({ 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={range?.min} - max={range?.max} + 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' /> ) From fc91224dcdffd7361f78bb94de9f391ecd400729 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Thu, 26 Mar 2026 10:57:21 +0100 Subject: [PATCH 29/31] refactor: consolidate EtherCAT POST handlers with token refresh support - Add makeRuntimeApiPostRequest with automatic token refresh on 401/403 - Refactor handleEtherCATScan, Test, Validate, and GetRuntimeStatus to use the shared POST helper instead of inline https.request - Guard loadFullDevice against concurrent in-flight loads - Pass combined isProcessing state to ESIRepositoryTable Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/modules/ipc/main.ts | 351 +++++++----------- .../components/configured-device-row.tsx | 2 +- .../ethercat/components/esi-repository.tsx | 2 +- 3 files changed, 138 insertions(+), 217 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 2dd373d78..04d4d12a5 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -375,6 +375,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, @@ -598,70 +670,31 @@ class MainProcessBridge implements MainIpcModule { command: 'scan', params: { interface: scanRequest.interface, timeout_ms: scanRequest.timeout_ms }, }) + const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 - return new Promise((resolve) => { - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: '/api/plugin-command', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - Authorization: `Bearer ${jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - try { - const pluginResponse = JSON.parse(data) - - if (pluginResponse.error) { - resolve({ success: false, error: pluginResponse.error }) - return - } + 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, + ) - const response: EtherCATScanResponse = { - status: pluginResponse.status ?? 'success', - devices: pluginResponse.devices ?? [], - message: pluginResponse.message ?? '', - scan_time_ms: 0, - interface: scanRequest.interface, - } - resolve({ success: true, data: response }) - } catch { - resolve({ success: false, error: 'Invalid response format' }) - } - } else { - try { - const errorResponse = JSON.parse(data) - resolve({ success: false, error: errorResponse.error || `Unexpected status: ${res.statusCode}` }) - } catch { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) - } - } - }) - }, - ) - // Use longer timeout for scan operations (scan timeout + buffer) - const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 - req.setTimeout(scanTimeout, () => { - req.destroy() - resolve({ success: false, error: 'Connection timeout' }) - }) - req.on('error', (error: Error) => { - resolve({ success: false, error: error.message }) - }) - req.write(postData) - req.end() - }) + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } } catch (error) { return { success: false, error: String(error) } } @@ -678,59 +711,21 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { try { const postData = JSON.stringify(testRequest) + const testTimeout = (testRequest.timeout_ms || 3000) + 10000 - return new Promise((resolve) => { - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: '/api/discovery/ethercat/test', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - Authorization: `Bearer ${jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - try { - const response = JSON.parse(data) as EtherCATTestResponse - resolve({ success: true, data: response }) - } catch { - resolve({ success: false, error: 'Invalid response format' }) - } - } else if (res.statusCode === 403) { - resolve({ success: false, error: 'Permission denied - CAP_NET_RAW required' }) - } else if (res.statusCode === 404) { - resolve({ success: false, error: 'Interface not found' }) - } else if (res.statusCode === 503) { - resolve({ success: false, error: 'Discovery service not available' }) - } else if (res.statusCode === 504) { - resolve({ success: false, error: 'Connection test timeout' }) - } else { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) - } - }) - }, - ) - const testTimeout = (testRequest.timeout_ms || 3000) + 10000 - req.setTimeout(testTimeout, () => { - req.destroy() - resolve({ success: false, error: 'Connection timeout' }) - }) - req.on('error', (error: Error) => { - resolve({ success: false, error: error.message }) - }) - req.write(postData) - req.end() - }) + 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) } } @@ -748,51 +743,18 @@ class MainProcessBridge implements MainIpcModule { try { const postData = JSON.stringify(validateRequest) - return new Promise((resolve) => { - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: '/api/discovery/ethercat/validate', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - Authorization: `Bearer ${jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - try { - const response = JSON.parse(data) as EtherCATValidateResponse - resolve({ success: true, data: response }) - } catch { - resolve({ success: false, error: 'Invalid response format' }) - } - } else if (res.statusCode === 400) { - resolve({ success: false, error: 'Invalid configuration format' }) - } else { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) - } - }) - }, - ) - req.setTimeout(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(postData) - req.end() - }) + 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) } } @@ -812,63 +774,22 @@ class MainProcessBridge implements MainIpcModule { command: 'status', }) - return new Promise((resolve) => { - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: '/api/plugin-command', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - Authorization: `Bearer ${jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - try { - const pluginResponse = JSON.parse(data) - - if (pluginResponse.error) { - resolve({ success: false, error: pluginResponse.error }) - return - } + 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 + }, + ) - resolve({ success: true, data: pluginResponse as EtherCATRuntimeStatusResponse }) - } catch { - resolve({ success: false, error: 'Invalid response format' }) - } - } else { - try { - const errorResponse = JSON.parse(data) - resolve({ - success: false, - error: errorResponse.error || `Unexpected status: ${res.statusCode}`, - }) - } catch { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) - } - } - }) - }, - ) - req.setTimeout(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(postData) - req.end() - }) + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } } catch (error) { return { success: false, error: String(error) } } 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 index 13b9e7b35..de2c69842 100644 --- 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 @@ -108,7 +108,7 @@ const ConfiguredDeviceRow = ({ // Load full device data when expanded useEffect(() => { - if (!isExpanded || fullDeviceLoadedRef.current) return + if (!isExpanded || fullDeviceLoadedRef.current || isLoadingChannels) return const loadFullDevice = async () => { setIsLoadingChannels(true) 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 index 36d33d383..732120818 100644 --- 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 @@ -141,7 +141,7 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading repository={repository} onRemoveItem={handleRemoveItem} onClearAll={handleClearAll} - isLoading={isSaving} + isLoading={isProcessing} />
From 4bde73c4c5037db719e46b53af220378a8a32c0c Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Thu, 26 Mar 2026 11:29:34 +0100 Subject: [PATCH 30/31] fix: improve keyboard accessibility in EtherCAT editor components - device-browser-modal: replace interactive div with button for device rows, add aria-pressed state - esi-repository-table: add aria-expanded and aria-label to expand button - configured-device-row: add tabIndex and onKeyDown to table row for keyboard selection, add aria-expanded/aria-label to expand button - esi-upload: add role=button, tabIndex, onKeyDown and aria-label to drop zone for keyboard activation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ethercat/components/configured-device-row.tsx | 10 ++++++++++ .../ethercat/components/device-browser-modal.tsx | 8 +++++--- .../ethercat/components/esi-repository-table.tsx | 7 ++++++- .../editor/device/ethercat/components/esi-upload.tsx | 9 +++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) 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 index de2c69842..c2d2aaed6 100644 --- 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 @@ -199,7 +199,15 @@ const ConfiguredDeviceRow = ({ <> {/* 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', @@ -211,6 +219,8 @@ const ConfiguredDeviceRow = ({ e.stopPropagation() onToggleExpand() }} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${device.name}`} className='flex items-center justify-center' > handleSelectDevice(repoItem.id, deviceIndex)} + aria-pressed={isSelected} className={cn( - 'flex cursor-pointer items-center gap-3 px-3 py-2 pl-8 hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + 'flex w-full cursor-pointer items-center gap-3 px-3 py-2 pl-8 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/50', isSelected && 'bg-brand/10 dark:bg-brand/20', )} > @@ -238,7 +240,7 @@ const DeviceBrowserModal = ({ isOpen, onClose, onSelectDevice, repository }: Dev )} - + ) })} 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 index 42e4fd32e..81010bae0 100644 --- 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 @@ -108,7 +108,12 @@ const RepositoryItemRow = ({ item, isExpanded, onToggleExpand, onRemove }: Repos {/* Main row */} - - - )} - - {channelLoadError && ( -
- {channelLoadError} -
- )} - - {!isLoadingChannels && !channelLoadError && !device.sdoConfigurations && !coeObjects && ( -

- No CoE Object Dictionary available for this device. -

- )} + @@ -708,33 +223,13 @@ const ConfiguredDeviceRow = ({
Channel Mappings
- - {isLoadingChannels && ( -
- - Loading channels... -
- )} - - {channelLoadError && ( -
- {channelLoadError} -
- )} - - {!isLoadingChannels && !channelLoadError && channels.length === 0 && ( -

- No channels available for this device. -

- )} - - {!isLoadingChannels && !channelLoadError && channels.length > 0 && ( - - )} + 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 index 829f81aa0..b4a0d09de 100644 --- 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 @@ -2,25 +2,16 @@ import { MinusIcon, PlusIcon } from '@root/renderer/assets/icons' import TableActions from '@root/renderer/components/_atoms/table-actions' import type { ConfiguredEtherCATDevice, + EnrichDeviceData, ESIRepositoryItemLight, EtherCATChannelMapping, EtherCATSlaveConfig, - PersistedChannelInfo, - PersistedPdo, SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import { useCallback, useEffect, useState } from 'react' import { ConfiguredDeviceRow } from './configured-device-row' -type EnrichDeviceData = { - channelInfo?: PersistedChannelInfo[] - rxPdos?: PersistedPdo[] - txPdos?: PersistedPdo[] - slaveType?: string - sdoConfigurations?: SDOConfigurationEntry[] -} - type ConfiguredDevicesProps = { devices: ConfiguredEtherCATDevice[] repository: ESIRepositoryItemLight[] 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 index c34f1b263..57b83d948 100644 --- 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 @@ -1,35 +1,18 @@ import * as Tabs from '@radix-ui/react-tabs' -import { ArrowIcon } from '@root/renderer/assets/icons' -import { Checkbox } from '@root/renderer/components/_atoms/checkbox' -import { InputWithRef } from '@root/renderer/components/_atoms/input' +import { useDeviceConfiguration } from '@root/renderer/hooks/use-device-configuration' import type { ConfiguredEtherCATDevice, - ESIChannel, - ESICoEObject, + EnrichDeviceData, ESIDeviceSummary, ESIRepositoryItemLight, EtherCATChannelMapping, EtherCATSlaveConfig, - PersistedChannelInfo, - PersistedPdo, SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' -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, useMemo, useRef, useState } from 'react' +import { useMemo, useState } from 'react' -import { ChannelMappingTable } from './channel-mapping-table' -import { SdoParametersTable } from './sdo-parameters-table' - -type EnrichDeviceData = { - channelInfo?: PersistedChannelInfo[] - rxPdos?: PersistedPdo[] - txPdos?: PersistedPdo[] - slaveType?: string - sdoConfigurations?: SDOConfigurationEntry[] -} +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' type DeviceDetailPanelProps = { device: ConfiguredEtherCATDevice @@ -42,17 +25,6 @@ type DeviceDetailPanelProps = { onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void } -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 -} - type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( @@ -93,9 +65,6 @@ const DeviceDetailPanel = ({ return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) }, [repository, device.esiDeviceRef.repositoryItemId]) - const config = device.config - - // Compute addresses used by other devices (excluding this device's own mappings) const externalAddresses = useMemo(() => { const filtered = new Set(usedAddresses) for (const mapping of device.channelMappings) { @@ -104,96 +73,15 @@ const DeviceDetailPanel = ({ return filtered }, [usedAddresses, device.channelMappings]) - // Channel loading state - const [channels, setChannels] = useState([]) - const [coeObjects, setCoeObjects] = useState(undefined) - const [isLoadingChannels, setIsLoadingChannels] = useState(false) - const [channelLoadError, setChannelLoadError] = useState(null) - const fullDeviceLoadedRef = useRef(false) - - // Load full device data on mount - useEffect(() => { - if (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) { - onUpdateChannelMappings(generateDefaultChannelMappings(deviceChannels, externalAddresses)) - } - - // Only enrich PDO/channel data if missing (never overwrite user-edited sdoConfigurations) - if (!device.channelInfo || !device.rxPdos || !device.txPdos) { - const enriched = enrichDeviceData(result.device) - // Preserve existing sdoConfigurations if already set - if (device.sdoConfigurations !== undefined) { - delete enriched.sdoConfigurations - } - onEnrichDevice(enriched) - } else if (device.sdoConfigurations === undefined && result.device.coeObjects?.length) { - // Only backfill SDO configs when they've never been set - onEnrichDevice({ - 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() - }, [ - projectPath, - device.esiDeviceRef, - device.channelMappings.length, - device.channelInfo, - device.rxPdos, - device.txPdos, - onUpdateChannelMappings, - onEnrichDevice, - externalAddresses, - ]) - - const handleAliasChange = useCallback( - (channelId: string, alias: string) => { - const updated = device.channelMappings.map((m) => (m.channelId === channelId ? { ...m, alias } : m)) - onUpdateChannelMappings(updated) - }, - [device.channelMappings, onUpdateChannelMappings], - ) - - const updateConfig = useCallback( - (section: K, updates: Partial) => { - onUpdateDevice({ - ...config, - [section]: { ...config[section], ...updates }, - }) - }, - [config, onUpdateDevice], - ) + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + }) return (
@@ -287,306 +175,7 @@ const DeviceDetailPanel = ({ >
- {/* 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, - )} - /> -
-
-
-
+
@@ -597,62 +186,13 @@ const DeviceDetailPanel = ({ className='flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden' >
- {isLoadingChannels && ( -
- - Loading CoE data... -
- )} - - {!isLoadingChannels && device.sdoConfigurations && device.sdoConfigurations.length > 0 && ( - - )} - - {!isLoadingChannels && - !channelLoadError && - device.sdoConfigurations && - device.sdoConfigurations.length === 0 && ( -

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

- )} - - {!isLoadingChannels && - !channelLoadError && - !device.sdoConfigurations && - coeObjects && - coeObjects.length > 0 && ( -
-

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

- -
- )} - - {channelLoadError && ( -
- {channelLoadError} -
- )} - - {!isLoadingChannels && !channelLoadError && !device.sdoConfigurations && !coeObjects && ( -

- No CoE Object Dictionary available for this device. -

- )} +
@@ -662,32 +202,13 @@ const DeviceDetailPanel = ({ className='flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden' >
- {isLoadingChannels && ( -
- - Loading channels... -
- )} - - {channelLoadError && ( -
- {channelLoadError} -
- )} - - {!isLoadingChannels && !channelLoadError && channels.length === 0 && ( -

- No channels available for this device. -

- )} - - {!isLoadingChannels && !channelLoadError && channels.length > 0 && ( - - )} +
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 index 43cd1ce72..c543cc039 100644 --- 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 @@ -2,13 +2,12 @@ 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, - PersistedChannelInfo, - PersistedPdo, SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import { cn } from '@root/utils' @@ -18,14 +17,6 @@ import { DeviceBrowserModal } from './device-browser-modal' import { DeviceDetailPanel } from './device-detail-panel' import { ESIRepository } from './esi-repository' -type EnrichDeviceData = { - channelInfo?: PersistedChannelInfo[] - rxPdos?: PersistedPdo[] - txPdos?: PersistedPdo[] - slaveType?: string - sdoConfigurations?: SDOConfigurationEntry[] -} - type DevicesTabProps = { devices: ConfiguredEtherCATDevice[] repository: ESIRepositoryItemLight[] 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/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index 6c2fe981a..4ec1f0f30 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -176,6 +176,20 @@ export interface SDOConfigurationEntry { 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 ===================== /** diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts index e3f531f32..bd395e7fd 100644 --- a/src/types/ethercat/index.ts +++ b/src/types/ethercat/index.ts @@ -164,9 +164,10 @@ export interface PDOMappingEntry { } /** - * Slave configuration for validation + * Slave configuration entry for validation requests. + * Not to be confused with EtherCATSlaveConfig from esi-types.ts (per-slave runtime config). */ -export interface EtherCATSlaveConfig { +export interface EtherCATValidationSlaveEntry { /** Position in the EtherCAT chain (1-indexed) */ position: number /** Vendor ID */ @@ -184,7 +185,7 @@ export interface EtherCATValidateRequest { /** Network interface to use */ interface: string /** List of slave configurations */ - slaves: EtherCATSlaveConfig[] + slaves: EtherCATValidationSlaveEntry[] /** Cycle time in milliseconds */ cycle_time_ms?: number } diff --git a/src/utils/ethercat/esi-parser.ts b/src/utils/ethercat/esi-parser.ts index 495e5971b..983146817 100644 --- a/src/utils/ethercat/esi-parser.ts +++ b/src/utils/ethercat/esi-parser.ts @@ -1,223 +1,17 @@ /** - * EtherCAT ESI (EtherCAT Slave Information) XML Parser + * EtherCAT ESI Channel Utilities * - * Parses ESI XML files following ETG.2000 specification and extracts - * device information, PDO mappings, and channel configurations. - */ - -import type { - ESIChannel, - ESIDataType, - ESIDevice, - ESIDeviceType, - ESIFile, - ESIFMMU, - ESIGroup, - ESIParseResult, - ESIPdo, - ESIPdoEntry, - ESISyncManager, - ESIVendor, - EtherCATChannelMapping, -} from '@root/types/ethercat/esi-types' - -/** - * Parse hex string to number - * Handles formats: "#x1234", "0x1234", "1234" - */ -function parseHexValue(value: string | undefined | null): string { - if (!value) return '0x0' - const cleaned = value.replace(/#x/gi, '0x') - return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` -} - -/** - * Get text content from an element by tag name - */ -function getElementText(parent: Element, tagName: string): string | undefined { - const element = parent.getElementsByTagName(tagName)[0] - return element?.textContent?.trim() || undefined -} - -/** - * Get attribute value from an element - */ -function getAttribute(element: Element, attrName: string): string | undefined { - return element.getAttribute(attrName) || undefined -} - -/** - * Parse Vendor information - */ -function parseVendor(vendorElement: Element): ESIVendor { - return { - id: parseHexValue(getElementText(vendorElement, 'Id')), - name: getElementText(vendorElement, 'Name') || 'Unknown Vendor', - } -} - -/** - * Parse Group information - */ -function parseGroup(groupElement: Element): ESIGroup { - return { - type: getElementText(groupElement, 'Type') || '', - name: getElementText(groupElement, 'Name') || '', - imageUrl: getElementText(groupElement, 'ImageData16x14'), - description: getElementText(groupElement, 'Comment'), - } -} - -/** - * Parse FMMU configuration - */ -function parseFMMU(fmmuElement: Element): ESIFMMU { - const text = fmmuElement.textContent?.trim() || 'Outputs' - const validTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] - return { - type: validTypes.includes(text as ESIFMMU['type']) ? (text as ESIFMMU['type']) : 'Outputs', - } -} - -/** - * Parse Sync Manager configuration - */ -function parseSyncManager(smElement: Element, index: number): ESISyncManager { - const typeMap: Record = { - MbxOut: 'MbxOut', - MbxIn: 'MbxIn', - Outputs: 'Outputs', - Inputs: 'Inputs', - } - - return { - index, - startAddress: parseHexValue(getAttribute(smElement, 'StartAddress')), - controlByte: parseHexValue(getAttribute(smElement, 'ControlByte')), - defaultSize: parseInt(getAttribute(smElement, 'DefaultSize') || '0', 10), - enable: getAttribute(smElement, 'Enable') !== '0', - type: typeMap[smElement.textContent?.trim() || 'Outputs'] || 'Outputs', - } -} - -/** - * Parse PDO Entry - */ -function parsePdoEntry(entryElement: Element): ESIPdoEntry | null { - const index = getElementText(entryElement, 'Index') - const bitLen = parseInt(getElementText(entryElement, 'BitLen') || '0', 10) - - // Skip padding entries (entries without index or with 0 bitlen used for alignment) - if (!index && bitLen > 0) { - // This is likely a padding entry, we'll include it but mark it - return { - index: '0x0000', - subIndex: '0x00', - bitLen, - name: 'Padding', - dataType: 'BIT', - } - } - - if (!index) return null - - return { - index: parseHexValue(index), - subIndex: parseHexValue(getElementText(entryElement, 'SubIndex')), - bitLen, - name: getElementText(entryElement, 'Name') || 'Unnamed', - dataType: getElementText(entryElement, 'DataType') || 'BYTE', - comment: getElementText(entryElement, 'Comment'), - } -} - -/** - * Parse PDO (RxPdo or TxPdo) - */ -function parsePdo(pdoElement: Element): ESIPdo { - const entries: ESIPdoEntry[] = [] - const entryElements = pdoElement.getElementsByTagName('Entry') - - for (let i = 0; i < entryElements.length; i++) { - const entry = parsePdoEntry(entryElements[i]) - if (entry) { - entries.push(entry) - } - } - - return { - index: parseHexValue(getElementText(pdoElement, 'Index')), - name: getElementText(pdoElement, 'Name') || 'Unnamed PDO', - fixed: getAttribute(pdoElement, 'Fixed')?.toLowerCase() === 'true', - mandatory: getAttribute(pdoElement, 'Mandatory')?.toLowerCase() === 'true', - smIndex: getAttribute(pdoElement, 'Sm') ? parseInt(getAttribute(pdoElement, 'Sm')!, 10) : undefined, - entries, - } -} - -/** - * Parse Device Type information - */ -function parseDeviceType(typeElement: Element): ESIDeviceType { - return { - productCode: parseHexValue(getAttribute(typeElement, 'ProductCode')), - revisionNo: parseHexValue(getAttribute(typeElement, 'RevisionNo')), - name: typeElement.textContent?.trim() || 'Unknown Type', - } -} - -/** - * Parse Device + * 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. */ -function parseDevice(deviceElement: Element, groups: ESIGroup[]): ESIDevice { - // Parse Type - const typeElement = deviceElement.getElementsByTagName('Type')[0] - const type = typeElement ? parseDeviceType(typeElement) : { productCode: '0x0', revisionNo: '0x0', name: 'Unknown' } - - // Get group reference - const groupType = getElementText(deviceElement, 'GroupType') - const group = groups.find((g) => g.type === groupType) - - // Parse FMMUs - const fmmu: ESIFMMU[] = [] - const fmmuElements = deviceElement.getElementsByTagName('Fmmu') - for (let i = 0; i < fmmuElements.length; i++) { - fmmu.push(parseFMMU(fmmuElements[i])) - } - - // Parse Sync Managers - const syncManagers: ESISyncManager[] = [] - const smElements = deviceElement.getElementsByTagName('Sm') - for (let i = 0; i < smElements.length; i++) { - syncManagers.push(parseSyncManager(smElements[i], i)) - } - // Parse RxPDOs - const rxPdo: ESIPdo[] = [] - const rxPdoElements = deviceElement.getElementsByTagName('RxPdo') - for (let i = 0; i < rxPdoElements.length; i++) { - rxPdo.push(parsePdo(rxPdoElements[i])) - } - - // Parse TxPDOs - const txPdo: ESIPdo[] = [] - const txPdoElements = deviceElement.getElementsByTagName('TxPdo') - for (let i = 0; i < txPdoElements.length; i++) { - txPdo.push(parsePdo(txPdoElements[i])) - } - - return { - type, - name: getElementText(deviceElement, 'Name') || 'Unknown Device', - groupName: group?.name, - physics: getAttribute(deviceElement, 'Physics'), - fmmu, - syncManagers, - rxPdo, - txPdo, - description: getElementText(deviceElement, 'Comment'), - } -} +import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' /** * Map ESI data type to IEC 61131-3 type @@ -466,105 +260,6 @@ export function generateDefaultChannelMappings( return mappings } -/** - * Parse ESI XML string - */ -export function parseESI(xmlString: string, filename?: string): ESIParseResult { - const warnings: string[] = [] - - try { - const parser = new DOMParser() - const doc = parser.parseFromString(xmlString, 'text/xml') - - // Check for parse errors - const parseError = doc.getElementsByTagName('parsererror')[0] - if (parseError) { - return { - success: false, - error: `XML parse error: ${parseError.textContent}`, - } - } - - // Get root element - const root = doc.getElementsByTagName('EtherCATInfo')[0] - if (!root) { - return { - success: false, - error: 'Invalid ESI file: Missing EtherCATInfo root element', - } - } - - // Parse Vendor - const vendorElement = root.getElementsByTagName('Vendor')[0] - if (!vendorElement) { - return { - success: false, - error: 'Invalid ESI file: Missing Vendor information', - } - } - const vendor = parseVendor(vendorElement) - - // Parse Groups - const groups: ESIGroup[] = [] - const descriptionsElement = root.getElementsByTagName('Descriptions')[0] - if (descriptionsElement) { - const groupsElement = descriptionsElement.getElementsByTagName('Groups')[0] - if (groupsElement) { - const groupElements = groupsElement.getElementsByTagName('Group') - for (let i = 0; i < groupElements.length; i++) { - groups.push(parseGroup(groupElements[i])) - } - } - } - - // Parse Devices - const devices: ESIDevice[] = [] - if (descriptionsElement) { - const devicesElement = descriptionsElement.getElementsByTagName('Devices')[0] - if (devicesElement) { - const deviceElements = devicesElement.getElementsByTagName('Device') - for (let i = 0; i < deviceElements.length; i++) { - devices.push(parseDevice(deviceElements[i], groups)) - } - } - } - - if (devices.length === 0) { - warnings.push('No devices found in ESI file') - } - - // Parse InfoData (optional metadata) - const infoDataElement = root.getElementsByTagName('InfoData')[0] - const infoData = infoDataElement - ? { - version: getElementText(infoDataElement, 'Version'), - creationDate: getElementText(infoDataElement, 'CreationDate'), - modificationDate: getElementText(infoDataElement, 'ModificationDate'), - vendorUrl: getElementText(infoDataElement, 'VendorUrl'), - } - : undefined - - const result: ESIFile = { - vendor, - groups, - devices, - filename, - infoData, - } - - return { - success: true, - data: result, - warnings: warnings.length > 0 ? warnings : undefined, - } - } catch (error) { - return { - success: false, - error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, - } - } -} - /** * Calculate total PDO size in bytes */