From b7ca2d3881689b920b74b84cf9af25c6f0d86d5b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 08:55:25 -0400 Subject: [PATCH 01/30] feat: add EtherCAT types and shared backend logic Port EtherCAT type definitions and PLC schema extensions. Move all EtherCAT business logic to src/backend/shared/ethercat/ as the single source of truth for both Electron and web backends. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 63 ++ package.json | 1 + .../shared/ethercat/device-config-defaults.ts | 50 ++ src/backend/shared/ethercat/device-matcher.ts | 169 +++++ .../shared/ethercat/enrich-device-data.ts | 113 +++ .../shared/ethercat/esi-parser-main.ts | 659 ++++++++++++++++++ src/backend/shared/ethercat/esi-parser.ts | 307 ++++++++ .../ethercat/generate-ethercat-config.ts | 327 +++++++++ src/backend/shared/ethercat/index.ts | 7 + .../shared/ethercat/sdo-config-defaults.ts | 84 +++ src/middleware/shared/ports/types.ts | 18 + src/types/PLC/open-plc.ts | 130 ++++ src/types/ethercat/esi-types.ts | 626 +++++++++++++++++ src/types/ethercat/index.ts | 313 +++++++++ 14 files changed, 2867 insertions(+) create mode 100644 src/backend/shared/ethercat/device-config-defaults.ts create mode 100644 src/backend/shared/ethercat/device-matcher.ts create mode 100644 src/backend/shared/ethercat/enrich-device-data.ts create mode 100644 src/backend/shared/ethercat/esi-parser-main.ts create mode 100644 src/backend/shared/ethercat/esi-parser.ts create mode 100644 src/backend/shared/ethercat/generate-ethercat-config.ts create mode 100644 src/backend/shared/ethercat/index.ts create mode 100644 src/backend/shared/ethercat/sdo-config-defaults.ts create mode 100644 src/types/ethercat/esi-types.ts create mode 100644 src/types/ethercat/index.ts diff --git a/package-lock.json b/package-lock.json index 4d83d74c7..2beface5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.5.11", "i18next": "^23.5.1", "immer": "^10.1.1", "lodash": "^4.17.21", @@ -15586,6 +15587,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", + "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.4.0", + "strnum": "^2.2.3" + }, + "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", @@ -23565,6 +23601,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -27007,6 +27058,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/package.json b/package.json index a9958b33e..dbc3edc6e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.5.11", "i18next": "^23.5.1", "immer": "^10.1.1", "lodash": "^4.17.21", diff --git a/src/backend/shared/ethercat/device-config-defaults.ts b/src/backend/shared/ethercat/device-config-defaults.ts new file mode 100644 index 000000000..d1c1d6403 --- /dev/null +++ b/src/backend/shared/ethercat/device-config-defaults.ts @@ -0,0 +1,50 @@ +import type { EtherCATSlaveConfig } from '@root/types/ethercat/esi-types' + +/** + * Default per-slave configuration for a newly added EtherCAT device. + * Values based on SOEM defaults and industrial best practices. + */ +export const DEFAULT_SLAVE_CONFIG: Readonly = { + startupChecks: { + checkVendorId: true, + checkProductCode: true, + }, + addressing: { + ethercatAddress: 0, + }, + timeouts: { + sdoTimeoutMs: 1000, + initToPreOpTimeoutMs: 3000, + safeOpToOpTimeoutMs: 10000, + }, + watchdog: { + smWatchdogEnabled: true, + smWatchdogMs: 100, + pdiWatchdogEnabled: false, + pdiWatchdogMs: 100, + }, + distributedClocks: { + dcEnabled: false, + dcSyncUnitCycleUs: 0, + dcSync0Enabled: false, + dcSync0CycleUs: 0, + dcSync0ShiftUs: 0, + dcSync1Enabled: false, + dcSync1CycleUs: 0, + dcSync1ShiftUs: 0, + }, +} + +/** + * Creates a fresh mutable copy of the default slave config. + * Each device gets its own config object to avoid shared-reference mutations. + */ +export function createDefaultSlaveConfig(): EtherCATSlaveConfig { + return { + startupChecks: { ...DEFAULT_SLAVE_CONFIG.startupChecks }, + addressing: { ...DEFAULT_SLAVE_CONFIG.addressing }, + timeouts: { ...DEFAULT_SLAVE_CONFIG.timeouts }, + watchdog: { ...DEFAULT_SLAVE_CONFIG.watchdog }, + distributedClocks: { ...DEFAULT_SLAVE_CONFIG.distributedClocks }, + } +} diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts new file mode 100644 index 000000000..cb267d051 --- /dev/null +++ b/src/backend/shared/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/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts new file mode 100644 index 000000000..9bf2e347d --- /dev/null +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -0,0 +1,113 @@ +/** + * EtherCAT Device Data Enrichment + * + * Pure functions that extract persistable data from a full ESIDevice. + * Used when adding devices to persist channel/PDO metadata for runtime config generation. + */ + +import type { + ESIDevice, + ESIPdo, + PersistedChannelInfo, + PersistedPdo, + PersistedPdoEntry, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' + +import { esiTypeToIecType, pdoToChannels } from './esi-parser' +import { extractDefaultSdoConfigurations } from './sdo-config-defaults' + +/** + * Convert ESIPdo[] to PersistedPdo[] format. + * Preserves all entries including padding for complete PDO layout. + */ +export function persistPdos(pdos: ESIPdo[]): PersistedPdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): PersistedPdoEntry => ({ + index: entry.index, + subIndex: entry.subIndex, + bitLen: entry.bitLen, + name: entry.name, + dataType: entry.dataType, + }), + ), + })) +} + +/** + * Build persisted channel info from ESIDevice using pdoToChannels. + * Extracts full metadata needed for runtime config generation. + */ +export function buildChannelInfo(device: ESIDevice): PersistedChannelInfo[] { + const channels = pdoToChannels(device) + return channels.map( + (ch): PersistedChannelInfo => ({ + channelId: ch.id, + name: ch.name, + direction: ch.direction, + pdoIndex: ch.pdoIndex, + entryIndex: ch.entryIndex, + entrySubIndex: ch.entrySubIndex, + dataType: ch.dataType, + bitLen: ch.bitLen, + iecType: esiTypeToIecType(ch.dataType, ch.bitLen), + }), + ) +} + +/** + * Derive slave device type from PDO structure. + * Uses heuristics based on PDO direction and data sizes. + */ +export function deriveSlaveType(device: ESIDevice): string { + const hasNonPaddingEntry = (pdos: ESIPdo[]): boolean => + pdos.some((pdo) => pdo.entries.some((e) => e.name !== 'Padding' && e.index !== '0x0000')) + + const allBitSized = (pdos: ESIPdo[]): boolean => + pdos.every((pdo) => + pdo.entries.filter((e) => e.name !== 'Padding' && e.index !== '0x0000').every((e) => e.bitLen === 1), + ) + + const hasTxData = hasNonPaddingEntry(device.txPdo) + const hasRxData = hasNonPaddingEntry(device.rxPdo) + + if (!hasTxData && !hasRxData) return 'coupler' + + const txAllBit = hasTxData && allBitSized(device.txPdo) + const rxAllBit = hasRxData && allBitSized(device.rxPdo) + + if (hasTxData && !hasRxData) { + return txAllBit ? 'digital_input' : 'analog_input' + } + + if (hasRxData && !hasTxData) { + return rxAllBit ? 'digital_output' : 'analog_output' + } + + // Both directions + if (txAllBit && rxAllBit) return 'digital_io' + return 'analog_io' +} + +/** + * Enrich device data by extracting all persistable info from a full ESIDevice. + * Returns fields to spread into ConfiguredEtherCATDevice. + */ +export function enrichDeviceData(device: ESIDevice): { + channelInfo: PersistedChannelInfo[] + rxPdos: PersistedPdo[] + txPdos: PersistedPdo[] + slaveType: string + sdoConfigurations?: SDOConfigurationEntry[] +} { + return { + channelInfo: buildChannelInfo(device), + rxPdos: persistPdos(device.rxPdo), + txPdos: persistPdos(device.txPdo), + slaveType: deriveSlaveType(device), + sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, + } +} diff --git a/src/backend/shared/ethercat/esi-parser-main.ts b/src/backend/shared/ethercat/esi-parser-main.ts new file mode 100644 index 000000000..e8c741afd --- /dev/null +++ b/src/backend/shared/ethercat/esi-parser-main.ts @@ -0,0 +1,659 @@ +/** + * ESI XML Parser for Main Process + * + * Uses fast-xml-parser for high-performance parsing in the Node.js main process. + * Provides two parsing levels: + * - parseESILight: Extracts lightweight device summaries (fast, for repository listing) + * - parseESIDeviceFull: Extracts complete device data on-demand (for configuration) + */ + +import type { + ESICoEObject, + ESICoESubItem, + ESIDevice, + ESIDeviceSummary, + ESIDeviceType, + ESIFMMU, + ESIGroup, + ESIPdo, + ESIPdoEntry, + ESISyncManager, + ESIVendor, +} from '@root/types/ethercat/esi-types' +import { XMLParser } from 'fast-xml-parser' + +// ===================== SHARED HELPERS ===================== + +/** + * Parse hex string to normalized 0x format + */ +function parseHexValue(value: string | number | undefined | null): string { + if (value === undefined || value === null) return '0x0' + const str = String(value) + const cleaned = str.replace(/#x/gi, '0x') + return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` +} + +/** + * Ensure a value is always an array (fast-xml-parser returns single items as objects) + */ +function ensureArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return [] + return Array.isArray(value) ? value : [value] +} + +/** + * Get text value from a parsed element that may be string, number, object with #text, + * or array of localized objects (e.g. [{#text: "Name EN", @_LcId: "1033"}, {#text: "Name DE", @_LcId: "1031"}]). + * For arrays, prefers LcId 1033 (English) then falls back to the first element. + */ +function getTextValue(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value.trim() + if (typeof value === 'number') return String(value) + if (Array.isArray(value)) { + if (value.length === 0) return '' + // Prefer English (LcId 1033), fall back to first element + const english = (value as Record[]).find( + (v) => typeof v === 'object' && v !== null && '@_LcId' in v && String(v['@_LcId'] as string) === '1033', + ) + return getTextValue(english ?? value[0]) + } + if (typeof value === 'object' && value !== null && '#text' in value) { + return getTextValue((value as { '#text': unknown })['#text']) + } + return typeof value === 'object' ? JSON.stringify(value) : String(value as string) +} + +/** + * Create a configured XMLParser instance + */ +function createParser(): XMLParser { + return new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: false, + trimValues: true, + isArray: (tagName: string) => { + // Tags that can appear multiple times and should always be arrays + const arrayTags = ['Device', 'Group', 'RxPdo', 'TxPdo', 'Entry', 'Fmmu', 'Sm', 'Object', 'SubItem', 'DataType'] + return arrayTags.includes(tagName) + }, + }) +} + +// ===================== LIGHT PARSING ===================== + +interface ESILightResult { + success: boolean + vendor?: ESIVendor + devices?: ESIDeviceSummary[] + warnings?: string[] + error?: string +} + +/** + * Parse ESI XML extracting only lightweight device summaries. + * Counts PDO entries and sums bit lengths without building full entry objects. + */ +export function parseESILight(xmlString: string, filename?: string): ESILightResult { + const warnings: string[] = [] + + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + // Parse Vendor + const vendorObj = root['Vendor'] as Record | undefined + if (!vendorObj) { + return { success: false, error: 'Invalid ESI file: Missing Vendor information' } + } + + const vendor: ESIVendor = { + id: parseHexValue(vendorObj['Id'] as string | number | undefined), + name: getTextValue(vendorObj['Name']) || 'Unknown Vendor', + } + + // Parse Groups + const descriptions = root['Descriptions'] as Record | undefined + const groupsMap = new Map() + + if (descriptions) { + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groups = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const group of groups) { + const groupType = getTextValue(group['Type']) + const groupName = getTextValue(group['Name']) + if (groupType) { + groupsMap.set(groupType, groupName) + } + } + } + } + + // Parse Devices (lightweight) + const devices: ESIDeviceSummary[] = [] + + if (descriptions) { + const devicesObj = descriptions['Devices'] as Record | undefined + if (devicesObj) { + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + for (const deviceEl of deviceElements) { + const summary = parseDeviceSummary(deviceEl, groupsMap) + devices.push(summary) + } + } + } + + if (devices.length === 0) { + warnings.push(`No devices found in ESI file${filename ? ` (${filename})` : ''}`) + } + + return { + success: true, + vendor, + devices, + warnings: warnings.length > 0 ? warnings : undefined, + } + } catch (error) { + return { + success: false, + error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** + * Extract a lightweight device summary from a parsed device element + */ +function parseDeviceSummary(deviceEl: Record, groupsMap: Map): ESIDeviceSummary { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const groupName = groupsMap.get(groupType) || undefined + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Count PDO entries and compute bytes + const txPdos = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + const rxPdos = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + + const { channelCount: inputChannelCount, totalBits: inputBits } = countPdoEntries(txPdos) + const { channelCount: outputChannelCount, totalBits: outputBits } = countPdoEntries(rxPdos) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName, + physics, + inputChannelCount, + outputChannelCount, + totalInputBytes: Math.ceil(inputBits / 8), + totalOutputBytes: Math.ceil(outputBits / 8), + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Count non-padding entries and total bits in PDO list (without building full entry objects) + */ +function countPdoEntries(pdos: Record[]): { + channelCount: number + totalBits: number +} { + let channelCount = 0 + let totalBits = 0 + + for (const pdo of pdos) { + const entries = ensureArray(pdo['Entry'] as Record | Record[]) + for (const entry of entries) { + const bitLen = parseInt(getTextValue(entry['BitLen']) || '0', 10) || 0 + totalBits += bitLen + + // Count non-padding entries + const index = entry['Index'] + if (index !== undefined && index !== null) { + const indexStr = getTextValue(index) + if (indexStr && indexStr !== '0' && indexStr !== '#x0000' && indexStr !== '0x0000') { + channelCount++ + } + } + } + } + + return { channelCount, totalBits } +} + +// ===================== FULL DEVICE PARSING ===================== + +interface ESIDeviceFullResult { + success: boolean + device?: ESIDevice + error?: string +} + +/** + * Parse a single device from ESI XML at the given index with full detail. + * Used on-demand when complete PDO/SM/FMMU data is needed. + */ +export function parseESIDeviceFull(xmlString: string, deviceIndex: number): ESIDeviceFullResult { + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + const descriptions = root['Descriptions'] as Record | undefined + if (!descriptions) { + return { success: false, error: 'No Descriptions element found' } + } + + // Parse groups for name lookup + const groups: ESIGroup[] = [] + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groupElements = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const g of groupElements) { + groups.push({ + type: getTextValue(g['Type']), + name: getTextValue(g['Name']), + imageUrl: getTextValue(g['ImageData16x14']) || undefined, + description: getTextValue(g['Comment']) || undefined, + }) + } + } + + const devicesObj = descriptions['Devices'] as Record | undefined + if (!devicesObj) { + return { success: false, error: 'No Devices element found' } + } + + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + if (deviceIndex < 0 || deviceIndex >= deviceElements.length) { + return { success: false, error: `Device index ${deviceIndex} out of range (0-${deviceElements.length - 1})` } + } + + const deviceEl = deviceElements[deviceIndex] + const device = parseFullDevice(deviceEl, groups) + + return { success: true, device } + } catch (error) { + return { + success: false, + error: `Failed to parse device: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ===================== COE DICTIONARY PARSING ===================== + +/** + * Parsed DataType info from the Dictionary's DataTypes section. + */ +interface ParsedDataTypeInfo { + name: string + bitSize: number + subItems: { + subIdx: number + name: string + type: string + bitSize: number + bitOffset: number + access: 'RO' | 'RW' | 'WO' + defaultValue?: string + pdoMapping?: boolean + }[] +} + +/** + * Parse access string from ESI XML to normalized access type. + */ +function parseAccessRights(accessStr: string | undefined): 'RO' | 'RW' | 'WO' { + if (!accessStr) return 'RO' + const lower = accessStr.toLowerCase().trim() + if (lower === 'rw' || lower === 'readwrite' || lower === 'read/write') return 'RW' + if (lower === 'wo' || lower === 'writeonly' || lower === 'write') return 'WO' + return 'RO' +} + +/** + * Parse the CoE Object Dictionary from a device element. + * Navigates Device > Profile > Dictionary and extracts DataTypes and Objects. + */ +function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | undefined { + // Navigate to Profile > Dictionary + const profile = deviceEl['Profile'] as Record | undefined + if (!profile) return undefined + + const dictionary = profile['Dictionary'] as Record | undefined + if (!dictionary) return undefined + + // Step 1: Build DataType map + const dataTypeMap = new Map() + const dataTypesEl = dictionary['DataTypes'] as Record | undefined + if (dataTypesEl) { + const dtElements = ensureArray(dataTypesEl['DataType'] as Record | Record[]) + for (const dt of dtElements) { + const dtName = getTextValue(dt['Name']) + if (!dtName) continue + + const dtBitSize = parseInt(getTextValue(dt['BitSize']) || '0', 10) || 0 + const subItems: ParsedDataTypeInfo['subItems'] = [] + + const subItemElements = ensureArray(dt['SubItem'] as Record | Record[]) + for (const si of subItemElements) { + const siSubIdx = parseInt(getTextValue(si['SubIdx']) || '0', 10) + const siName = getTextValue(si['Name']) + const siType = getTextValue(si['Type']) + const siBitSize = parseInt(getTextValue(si['BitSize']) || '0', 10) || 0 + const siBitOffset = parseInt(getTextValue(si['BitOffs']) || '0', 10) || 0 + + // Parse flags + let siAccess: 'RO' | 'RW' | 'WO' = 'RO' + let siPdoMapping: boolean | undefined + const siFlags = si['Flags'] as Record | undefined + if (siFlags) { + siAccess = parseAccessRights(getTextValue(siFlags['Access'])) + const pdoMappingStr = getTextValue(siFlags['PdoMapping']) + if (pdoMappingStr) siPdoMapping = pdoMappingStr.toLowerCase() !== 'false' && pdoMappingStr !== '0' + } + + const siDefaultValue = getTextValue(si['DefaultValue']) || undefined + + subItems.push({ + subIdx: siSubIdx, + name: siName, + type: siType, + bitSize: siBitSize, + bitOffset: siBitOffset, + access: siAccess, + defaultValue: siDefaultValue, + pdoMapping: siPdoMapping, + }) + } + + dataTypeMap.set(dtName, { name: dtName, bitSize: dtBitSize, subItems }) + } + } + + // Step 2: Parse Objects + const objectsEl = dictionary['Objects'] as Record | undefined + if (!objectsEl) return undefined + + const objectElements = ensureArray(objectsEl['Object'] as Record | Record[]) + if (objectElements.length === 0) return undefined + + const coeObjects: ESICoEObject[] = [] + + for (const objEl of objectElements) { + const indexStr = getTextValue(objEl['Index']) + if (!indexStr) continue + + const index = parseHexValue(indexStr) + const name = getTextValue(objEl['Name']) || 'Unnamed' + const typeName = getTextValue(objEl['Type']) + const bitSize = parseInt(getTextValue(objEl['BitSize']) || '0', 10) || 0 + + // Parse object-level flags + let access: 'RO' | 'RW' | 'WO' = 'RO' + let pdoMapping = false + let category: 'M' | 'O' | 'C' | undefined + let pdoMappingDirection: 'R' | 'T' | 'RT' | undefined + + const flags = objEl['Flags'] as Record | undefined + if (flags) { + access = parseAccessRights(getTextValue(flags['Access'])) + const pdoMappingStr = getTextValue(flags['PdoMapping']) + if (pdoMappingStr) { + const lower = pdoMappingStr.toLowerCase() + if (lower === 'r') { + pdoMapping = true + pdoMappingDirection = 'R' + } else if (lower === 't') { + pdoMapping = true + pdoMappingDirection = 'T' + } else if (lower === 'rt' || lower === 'tr') { + pdoMapping = true + pdoMappingDirection = 'RT' + } else if (lower !== 'false' && lower !== '0' && lower !== '') { + pdoMapping = true + } + } + const categoryStr = getTextValue(flags['Category']) + if (categoryStr === 'M' || categoryStr === 'O' || categoryStr === 'C') { + category = categoryStr + } + } + + // Parse default value from object-level Info + let defaultValue = getTextValue(objEl['DefaultValue']) || undefined + + // Resolve DataType to build sub-items for complex objects + const dtInfo = typeName ? dataTypeMap.get(typeName) : undefined + let subItems: ESICoESubItem[] | undefined + + if (dtInfo && dtInfo.subItems.length > 0) { + // Build override map from object-level Info > SubItem + const overrideMap = new Map() + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + const infoSubItems = ensureArray(infoEl['SubItem'] as Record | Record[]) + for (const isi of infoSubItems) { + const isiName = getTextValue(isi['Name']) + const isiInfo = isi['Info'] as Record | undefined + const isiDefaultValue = isiInfo ? getTextValue(isiInfo['DefaultValue']) || undefined : undefined + if (isiName) { + overrideMap.set(isiName, { defaultValue: isiDefaultValue }) + } + } + } + + // Merge DataType sub-items with object-level overrides + subItems = dtInfo.subItems.map((si): ESICoESubItem => { + const override = overrideMap.get(si.name) + return { + subIndex: String(si.subIdx), + name: si.name, + type: si.type, + bitSize: si.bitSize, + access: si.access, + pdoMapping: si.pdoMapping, + defaultValue: override?.defaultValue ?? si.defaultValue, + } + }) + } + + // If no sub-items were built and there's an Info > DefaultValue at object level + if (!subItems && !defaultValue) { + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + defaultValue = getTextValue(infoEl['DefaultValue']) || undefined + } + } + + coeObjects.push({ + index, + name, + type: typeName, + bitSize, + access, + pdoMapping, + category, + pdoMappingDirection, + defaultValue, + subItems, + }) + } + + return coeObjects.length > 0 ? coeObjects : undefined +} + +/** + * Parse a complete ESIDevice from a parsed device element + */ +function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): ESIDevice { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const group = groups.find((g) => g.type === groupType) + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Parse FMMUs + const fmmu: ESIFMMU[] = [] + const fmmuElements = ensureArray( + deviceEl['Fmmu'] as (Record | string) | (Record | string)[], + ) + const validFmmuTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] + for (const f of fmmuElements) { + const fmmuText = typeof f === 'string' ? f : getTextValue(f['#text'] ?? f) + const fmmuType = validFmmuTypes.includes(fmmuText as ESIFMMU['type']) ? (fmmuText as ESIFMMU['type']) : 'Outputs' + fmmu.push({ type: fmmuType }) + } + + // Parse Sync Managers + const syncManagers: ESISyncManager[] = [] + const smElements = ensureArray(deviceEl['Sm'] as Record | Record[]) + for (let i = 0; i < smElements.length; i++) { + const sm = smElements[i] + const smTypeMap: Record = { + MbxOut: 'MbxOut', + MbxIn: 'MbxIn', + Outputs: 'Outputs', + Inputs: 'Inputs', + } + const smText = getTextValue(sm['#text'] ?? sm) + syncManagers.push({ + index: i, + startAddress: parseHexValue(sm['@_StartAddress'] as string | undefined), + controlByte: parseHexValue(sm['@_ControlByte'] as string | undefined), + defaultSize: parseInt(getTextValue(sm['@_DefaultSize']) || '0', 10) || 0, + enable: getTextValue(sm['@_Enable']) !== '0', + type: smTypeMap[smText] || 'Outputs', + }) + } + + // Parse RxPDOs + const rxPdo: ESIPdo[] = [] + const rxPdoElements = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + for (const pdoEl of rxPdoElements) { + rxPdo.push(parseFullPdo(pdoEl)) + } + + // Parse TxPDOs + const txPdo: ESIPdo[] = [] + const txPdoElements = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + for (const pdoEl of txPdoElements) { + txPdo.push(parseFullPdo(pdoEl)) + } + + // Parse CoE Object Dictionary + const coeObjects = parseCoEDictionary(deviceEl) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName: group?.name, + physics, + fmmu, + syncManagers, + rxPdo, + txPdo, + coeObjects, + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Parse a full PDO with all entries + */ +function parseFullPdo(pdoEl: Record): ESIPdo { + const entries: ESIPdoEntry[] = [] + const entryElements = ensureArray(pdoEl['Entry'] as Record | Record[]) + + for (const entryEl of entryElements) { + const entry = parseFullPdoEntry(entryEl) + if (entry) { + entries.push(entry) + } + } + + return { + index: parseHexValue(pdoEl['Index'] as string | undefined), + name: getTextValue(pdoEl['Name']) || 'Unnamed PDO', + fixed: getTextValue(pdoEl['@_Fixed']).toLowerCase() === 'true', + mandatory: getTextValue(pdoEl['@_Mandatory']).toLowerCase() === 'true', + smIndex: pdoEl['@_Sm'] !== undefined ? parseInt(getTextValue(pdoEl['@_Sm']) || '0', 10) : undefined, + entries, + } +} + +/** + * Parse a single PDO entry + */ +function parseFullPdoEntry(entryEl: Record): ESIPdoEntry | null { + const indexValue = entryEl['Index'] + const bitLen = parseInt(getTextValue(entryEl['BitLen']) || '0', 10) || 0 + + const indexStr = getTextValue(indexValue) + + // Padding entry (no index but has bit length) + if (!indexStr && bitLen > 0) { + return { + index: '0x0000', + subIndex: '0x00', + bitLen, + name: 'Padding', + dataType: 'BIT', + } + } + + if (!indexStr) return null + + return { + index: parseHexValue(indexStr), + subIndex: parseHexValue(getTextValue(entryEl['SubIndex'])), + bitLen, + name: getTextValue(entryEl['Name']) || 'Unnamed', + dataType: getTextValue(entryEl['DataType']) || 'BYTE', + comment: getTextValue(entryEl['Comment']) || undefined, + } +} diff --git a/src/backend/shared/ethercat/esi-parser.ts b/src/backend/shared/ethercat/esi-parser.ts new file mode 100644 index 000000000..983146817 --- /dev/null +++ b/src/backend/shared/ethercat/esi-parser.ts @@ -0,0 +1,307 @@ +/** + * EtherCAT ESI Channel Utilities + * + * Functions for working with parsed ESI device data: + * - PDO-to-channel conversion for UI + * - IEC 61131-3 address generation + * - Default channel mapping generation + * - Device summary calculation + * + * XML parsing is handled exclusively by the main-process parser + * in src/main/services/esi-service/esi-parser-main.ts. + */ + +import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' + +/** + * Map ESI data type to IEC 61131-3 type + */ +export function esiTypeToIecType(esiType: ESIDataType, bitLen: number): string { + const typeMap: Record = { + BOOL: 'BOOL', + SINT: 'SINT', + INT: 'INT', + DINT: 'DINT', + LINT: 'LINT', + USINT: 'USINT', + UINT: 'UINT', + UDINT: 'UDINT', + ULINT: 'ULINT', + REAL: 'REAL', + LREAL: 'LREAL', + STRING: 'STRING', + BYTE: 'BYTE', + WORD: 'WORD', + DWORD: 'DWORD', + } + + if (typeMap[esiType]) { + return typeMap[esiType] + } + + // Handle BIT types + if (esiType.startsWith('BIT') || bitLen === 1) { + return 'BOOL' + } + + // Infer from bit length + switch (bitLen) { + case 1: + return 'BOOL' + case 8: + return 'BYTE' + case 16: + return 'WORD' + case 32: + return 'DWORD' + case 64: + return 'LWORD' + default: + return 'BYTE' + } +} + +/** + * Convert PDO entries to channels for UI + */ +export function pdoToChannels(device: ESIDevice): ESIChannel[] { + const channels: ESIChannel[] = [] + let inputBitOffset = 0 + let outputBitOffset = 0 + + // Process TxPDOs (inputs - slave to master) + for (const pdo of device.txPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + inputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'input', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: inputBitOffset, + byteOffset: Math.floor(inputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + inputBitOffset += entry.bitLen + } + } + + // Process RxPDOs (outputs - master to slave) + for (const pdo of device.rxPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + outputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'output', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: outputBitOffset, + byteOffset: Math.floor(outputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + outputBitOffset += entry.bitLen + } + } + + return channels +} + +/** + * Generate an IEC 61131-3 located variable address for a channel. + * Direction: input -> %I, output -> %Q + * Size prefix based on iecType: + * BOOL -> X (bit addressing): %IX. + * BYTE/SINT/USINT -> B: %IB + * WORD/INT/UINT -> W: %IW + * DWORD/DINT/UDINT/REAL -> D: %ID + * LWORD/LINT/ULINT/LREAL -> L: %IL + * + * When `globalBitOffset` is provided, it overrides the channel's own byte/bit offsets + * to allow generating globally unique addresses across multiple slaves. + */ +export function generateIecLocation(channel: ESIChannel, globalBitOffset?: number): string { + const dirPrefix = channel.direction === 'input' ? '%I' : '%Q' + + const byteOffset = globalBitOffset !== undefined ? Math.floor(globalBitOffset / 8) : channel.byteOffset + const bitOffset = globalBitOffset !== undefined ? globalBitOffset % 8 : channel.bitOffset % 8 + + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return `${dirPrefix}X${byteOffset}.${bitOffset}` + case 'BYTE': + case 'SINT': + case 'USINT': + return `${dirPrefix}B${byteOffset}` + case 'WORD': + case 'INT': + case 'UINT': + return `${dirPrefix}W${byteOffset}` + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return `${dirPrefix}D${byteOffset}` + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return `${dirPrefix}L${byteOffset}` + default: + return `${dirPrefix}B${byteOffset}` + } +} + +/** + * Get the size in bits for a channel based on its IEC type. + */ +function getChannelBitSize(channel: ESIChannel): number { + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return 1 + case 'BYTE': + case 'SINT': + case 'USINT': + return 8 + case 'WORD': + case 'INT': + case 'UINT': + return 16 + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return 32 + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return 64 + default: + return 8 + } +} + +/** + * Generate default channel mappings with auto-generated IEC addresses for all channels. + * When `usedAddresses` is provided, addresses are generated to avoid conflicts with + * already-used locations from other devices (Modbus or EtherCAT). + */ +export function generateDefaultChannelMappings( + channels: ESIChannel[], + usedAddresses?: Set, +): EtherCATChannelMapping[] { + const used = new Set(usedAddresses) + const inputChannels = channels.filter((c) => c.direction === 'input') + const outputChannels = channels.filter((c) => c.direction === 'output') + + const mappings: EtherCATChannelMapping[] = [] + + for (const group of [inputChannels, outputChannels]) { + let currentBitOffset = 0 + + for (const channel of group) { + const bitSize = getChannelBitSize(channel) + + // Align to byte boundary for non-bit types + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + + let candidate = generateIecLocation(channel, currentBitOffset) + + // Find a non-conflicting address + while (used.has(candidate)) { + currentBitOffset += bitSize + // Re-align if needed + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + candidate = generateIecLocation(channel, currentBitOffset) + } + + used.add(candidate) + mappings.push({ + channelId: channel.id, + iecLocation: candidate, + userEdited: false, + alias: '', + }) + + currentBitOffset += bitSize + } + } + + return mappings +} + +/** + * Calculate total PDO size in bytes + */ +export function calculatePdoSize(pdos: ESIPdo[]): number { + let totalBits = 0 + for (const pdo of pdos) { + for (const entry of pdo.entries) { + totalBits += entry.bitLen + } + } + return Math.ceil(totalBits / 8) +} + +/** + * Get device summary information + */ +export function getDeviceSummary(device: ESIDevice): { + totalInputBytes: number + totalOutputBytes: number + inputChannelCount: number + outputChannelCount: number + hasCoe: boolean +} { + const inputBytes = calculatePdoSize(device.txPdo) + const outputBytes = calculatePdoSize(device.rxPdo) + + let inputChannels = 0 + let outputChannels = 0 + + for (const pdo of device.txPdo) { + inputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + for (const pdo of device.rxPdo) { + outputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + return { + totalInputBytes: inputBytes, + totalOutputBytes: outputBytes, + inputChannelCount: inputChannels, + outputChannelCount: outputChannels, + hasCoe: device.coeObjects !== undefined && device.coeObjects.length > 0, + } +} diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts new file mode 100644 index 000000000..4341ce39b --- /dev/null +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -0,0 +1,327 @@ +import type { + ConfiguredEtherCATDevice, + PersistedChannelInfo, + PersistedPdo, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' + +// Runtime JSON interfaces (snake_case for plugin consumption) + +interface RuntimePdoEntry { + index: string + subindex: number + bit_length: number + name: string + data_type: string +} + +interface RuntimePdo { + index: string + name: string + entries: RuntimePdoEntry[] +} + +interface RuntimeChannel { + index: number + name: string + type: string + bit_length: number + iec_location: string + pdo_index: string + pdo_entry_index: string + pdo_entry_subindex: number +} + +interface RuntimeSdoConfig { + index: string + subindex: number + value: number + data_type: string + bit_length: number + name: string + comment: string +} + +interface RuntimeSlaveConfig { + startup_checks: { + check_vendor_id: boolean + check_product_code: boolean + } + addressing: { + ethercat_address: number + } + timeouts: { + sdo_timeout_ms: number + init_to_preop_timeout_ms: number + safeop_to_op_timeout_ms: number + } + watchdog: { + sm_watchdog_enabled: boolean + sm_watchdog_ms: number + pdi_watchdog_enabled: boolean + pdi_watchdog_ms: number + } + distributed_clocks: { + enabled: boolean + sync_unit_cycle_us: number + sync0_enabled: boolean + sync0_cycle_us: number + sync0_shift_us: number + sync1_enabled: boolean + sync1_cycle_us: number + sync1_shift_us: number + } +} + +interface RuntimeSlave { + position: number + name: string + type: string + vendor_id: string + product_code: string + revision: string + config: RuntimeSlaveConfig + channels: RuntimeChannel[] + sdo_configurations: RuntimeSdoConfig[] + rx_pdos: RuntimePdo[] + tx_pdos: RuntimePdo[] +} + +interface RuntimeMaster { + interface: string + cycle_time_us: number + watchdog_timeout_cycles: number +} + +interface RuntimeDiagnostics { + log_connections: boolean + log_data_access: boolean + log_errors: boolean + max_log_entries: number + status_update_interval_ms: number +} + +interface RuntimeConfig { + master: RuntimeMaster + slaves: RuntimeSlave[] + diagnostics: RuntimeDiagnostics +} + +interface RuntimeRootEntry { + name: string + protocol: string + config: RuntimeConfig +} + +/** + * Converts a hex string (e.g., "0x01") to an integer. + */ +function hexToInt(hex: string): number { + return parseInt(hex, 16) +} + +/** + * Parses a user-entered value string into a numeric value. + * Handles decimal ("100"), hex ("0xFF", "#xFF"), float ("3.14"), and negative ("-50"). + * Returns 0 for empty or unparseable strings. + */ +function parseNumericValue(str: string): number { + if (!str || str.trim() === '') return 0 + + const trimmed = str.trim() + + // Handle hex prefixes: "0x" / "0X" / "#x" / "#X" + if (/^(0x|#x)/i.test(trimmed)) { + const hexStr = trimmed.replace(/^#x/i, '0x') + const parsed = Number(hexStr) + return isNaN(parsed) ? 0 : parsed + } + + const parsed = Number(trimmed) + return isNaN(parsed) ? 0 : parsed +} + +/** + * Derives the channel type string from direction and bit length. + */ +function deriveChannelType(direction: 'input' | 'output', bitLen: number): string { + if (direction === 'input') { + return bitLen === 1 ? 'digital_input' : 'analog_input' + } + return bitLen === 1 ? 'digital_output' : 'analog_output' +} + +/** + * Converts persisted PDOs to runtime PDO format. + * Entries with index "0x0000" are treated as padding. + */ +function convertPdos(pdos: PersistedPdo[]): RuntimePdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): RuntimePdoEntry => ({ + index: entry.index, + subindex: hexToInt(entry.subIndex), + bit_length: entry.bitLen, + name: entry.name, + data_type: entry.index === '0x0000' ? 'PAD' : entry.dataType, + }), + ), + })) +} + +/** + * Builds runtime channels by joining channelInfo with channelMappings. + */ +function buildChannels( + channelInfo: PersistedChannelInfo[], + channelMappings: { channelId: string; iecLocation: string }[], +): RuntimeChannel[] { + const mappingMap = new Map(channelMappings.map((m) => [m.channelId, m.iecLocation])) + + return channelInfo.map((ch, index) => ({ + index, + name: ch.name, + type: deriveChannelType(ch.direction, ch.bitLen), + bit_length: ch.bitLen, + iec_location: mappingMap.get(ch.channelId) ?? '', + pdo_index: ch.pdoIndex, + pdo_entry_index: ch.entryIndex, + pdo_entry_subindex: hexToInt(ch.entrySubIndex), + })) +} + +/** + * Converts SDOConfigurationEntry[] to RuntimeSdoConfig[] for the runtime plugin. + */ +function buildSdoConfigurations(entries: SDOConfigurationEntry[] | undefined): RuntimeSdoConfig[] { + if (!entries || entries.length === 0) return [] + + return entries.map( + (entry): RuntimeSdoConfig => ({ + index: entry.index, + subindex: entry.subIndex, + value: parseNumericValue(entry.value), + data_type: entry.dataType, + bit_length: entry.bitLength, + name: entry.name, + comment: `Startup SDO: ${entry.objectName}`, + }), + ) +} + +/** + * Builds a runtime slave from a configured device. + */ +function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSlave { + const position = device.position ?? index + const channels = device.channelInfo ? buildChannels(device.channelInfo, device.channelMappings) : [] + const rxPdos = device.rxPdos ? convertPdos(device.rxPdos) : [] + const txPdos = device.txPdos ? convertPdos(device.txPdos) : [] + + const cfg = device.config + + return { + position, + name: device.name, + type: device.slaveType ?? 'coupler', + vendor_id: device.vendorId, + product_code: device.productCode, + revision: device.revisionNo, + config: { + startup_checks: { + check_vendor_id: cfg.startupChecks.checkVendorId, + check_product_code: cfg.startupChecks.checkProductCode, + }, + addressing: { + ethercat_address: cfg.addressing.ethercatAddress, + }, + timeouts: { + sdo_timeout_ms: cfg.timeouts.sdoTimeoutMs, + init_to_preop_timeout_ms: cfg.timeouts.initToPreOpTimeoutMs, + safeop_to_op_timeout_ms: cfg.timeouts.safeOpToOpTimeoutMs, + }, + watchdog: { + sm_watchdog_enabled: cfg.watchdog.smWatchdogEnabled, + sm_watchdog_ms: cfg.watchdog.smWatchdogMs, + pdi_watchdog_enabled: cfg.watchdog.pdiWatchdogEnabled, + pdi_watchdog_ms: cfg.watchdog.pdiWatchdogMs, + }, + distributed_clocks: { + enabled: cfg.distributedClocks.dcEnabled, + sync_unit_cycle_us: cfg.distributedClocks.dcSyncUnitCycleUs, + sync0_enabled: cfg.distributedClocks.dcSync0Enabled, + sync0_cycle_us: cfg.distributedClocks.dcSync0CycleUs, + sync0_shift_us: cfg.distributedClocks.dcSync0ShiftUs, + sync1_enabled: cfg.distributedClocks.dcSync1Enabled, + sync1_cycle_us: cfg.distributedClocks.dcSync1CycleUs, + sync1_shift_us: cfg.distributedClocks.dcSync1ShiftUs, + }, + }, + channels, + sdo_configurations: buildSdoConfigurations(device.sdoConfigurations), + rx_pdos: rxPdos, + tx_pdos: txPdos, + } +} + +/** + * Generates the EtherCAT plugin configuration JSON from the project's remote devices. + * Produces the exact contract expected by the OpenPLC runtime EtherCAT plugin. + * + * Output format: array root `[{ name, protocol: "ETHERCAT", config: { master, slaves[], diagnostics } }]` + * + * @param remoteDevices - Array of PLCRemoteDevice from the project data + * @returns The EtherCAT configuration as a JSON string, or null if no devices are configured + */ +export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefined): string | null => { + if (!remoteDevices || remoteDevices.length === 0) { + return null + } + + const ethercatRemoteDevices = remoteDevices.filter( + (device) => device.protocol === 'ethercat' && device.ethercatConfig, + ) + + if (ethercatRemoteDevices.length === 0) { + return null + } + + // Build one root entry per EtherCAT remote device + const rootEntries: RuntimeRootEntry[] = [] + + for (const remoteDevice of ethercatRemoteDevices) { + const devices = (remoteDevice.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] + const slaves = devices.map((device, i) => buildSlave(device, i)) + + if (slaves.length === 0) continue + + rootEntries.push({ + name: remoteDevice.name || 'ethercat_master', + protocol: 'ETHERCAT', + config: { + master: { + interface: remoteDevice.ethercatConfig?.masterConfig?.networkInterface || 'eth0', + cycle_time_us: remoteDevice.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000, + watchdog_timeout_cycles: remoteDevice.ethercatConfig?.masterConfig?.watchdogTimeoutCycles ?? 3, + }, + slaves, + diagnostics: { + log_connections: true, + log_data_access: false, + log_errors: true, + max_log_entries: 10000, + status_update_interval_ms: 500, + }, + }, + }) + } + + if (rootEntries.length === 0) { + return null + } + + return JSON.stringify(rootEntries, null, 2) +} diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts new file mode 100644 index 000000000..69b2d8d97 --- /dev/null +++ b/src/backend/shared/ethercat/index.ts @@ -0,0 +1,7 @@ +export { createDefaultSlaveConfig, DEFAULT_SLAVE_CONFIG } from './device-config-defaults' +export { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from './device-matcher' +export { buildChannelInfo, deriveSlaveType, persistPdos } from './enrich-device-data' +export { esiTypeToIecType, pdoToChannels } from './esi-parser' +export { parseESIDeviceFull, parseESILight } from './esi-parser-main' +export { generateEthercatConfig } from './generate-ethercat-config' +export { extractDefaultSdoConfigurations } from './sdo-config-defaults' diff --git a/src/backend/shared/ethercat/sdo-config-defaults.ts b/src/backend/shared/ethercat/sdo-config-defaults.ts new file mode 100644 index 000000000..25c7dfc7a --- /dev/null +++ b/src/backend/shared/ethercat/sdo-config-defaults.ts @@ -0,0 +1,84 @@ +/** + * SDO Configuration Defaults Extraction + * + * Extracts configurable SDO parameters from CoE Object Dictionary entries. + * Filters to only include user-configurable (RW) parameters in the + * manufacturer-specific and profile-specific ranges. + */ + +import type { ESICoEObject, SDOConfigurationEntry } from '@root/types/ethercat/esi-types' + +/** + * Parse a hex index string to a number for range comparison. + */ +function hexToNumber(hex: string): number { + return parseInt(hex.replace('0x', ''), 16) || 0 +} + +/** + * Check if an object index is in a configurable range. + * Excludes: + * 0x0000-0x0FFF: Data types (internal) + * 0x1000-0x1FFF: Communication / identity parameters (managed by master) + */ +function isConfigurableRange(index: string): boolean { + const num = hexToNumber(index) + return num >= 0x2000 +} + +/** + * Extract default SDO configurations from CoE Object Dictionary. + * + * Returns one SDOConfigurationEntry per RW parameter found in the + * configurable ranges (0x2000+). For complex objects, each RW sub-item + * (except subIndex 0 which is the max subindex counter) gets its own entry. + */ +export function extractDefaultSdoConfigurations(coeObjects: ESICoEObject[]): SDOConfigurationEntry[] { + const entries: SDOConfigurationEntry[] = [] + + for (const obj of coeObjects) { + if (!isConfigurableRange(obj.index)) continue + + if (obj.subItems && obj.subItems.length > 0) { + // Complex object: iterate sub-items + for (const sub of obj.subItems) { + const subIdx = parseInt(sub.subIndex, 10) + // Skip subIndex 0 (max subindex counter) + if (subIdx === 0) continue + // Only include RW sub-items with a defined default value + if (sub.access !== 'RW') continue + if (sub.defaultValue === undefined || sub.defaultValue === null) continue + const defaultValue = sub.defaultValue + + entries.push({ + index: obj.index, + subIndex: subIdx, + value: defaultValue, + defaultValue, + dataType: sub.type, + bitLength: sub.bitSize, + name: sub.name, + objectName: obj.name, + }) + } + } else { + // Simple object: use object-level values + if (obj.access !== 'RW') continue + if (obj.defaultValue === undefined || obj.defaultValue === null) continue + const defaultValue = obj.defaultValue + + entries.push({ + index: obj.index, + subIndex: 0, + value: defaultValue, + defaultValue, + dataType: obj.type, + bitLength: obj.bitSize, + name: obj.name, + objectName: obj.name, + }) + } + } + + return entries +} diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 04117924f..a63da68f5 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -387,6 +387,24 @@ export interface PLCRemoteDevice { name: string protocol: RemoteDeviceProtocol modbusTcpConfig?: ModbusRemoteTcpConfig + ethercatConfig?: { + masterConfig?: { + networkInterface: string + cycleTimeUs: number + watchdogTimeoutCycles?: number + } + devices: Array<{ + id: string + name: string + channelMappings: Array<{ + channelId: string + iecLocation: string + userEdited: boolean + alias?: string + }> + [key: string]: unknown + }> + } } // --------------------------------------------------------------------------- diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index 3dd2a49d5..9449aa75b 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -610,10 +610,134 @@ type ModbusTcpConfig = z.infer const PLCRemoteDeviceProtocolSchema = z.enum(['modbus-tcp', 'ethernet-ip', 'ethercat', 'profinet']) type PLCRemoteDeviceProtocol = z.infer +// ---- EtherCAT Configuration Schemas ---- + +const EtherCATChannelMappingSchema = z.object({ + channelId: z.string(), + iecLocation: z.string(), + userEdited: z.boolean(), + alias: z.string().optional(), +}) + +const ESIDeviceRefSchema = z.object({ + repositoryItemId: z.string(), + deviceIndex: z.number(), +}) + +const EtherCATStartupChecksSchema = z.object({ + checkVendorId: z.boolean(), + checkProductCode: z.boolean(), +}) + +const EtherCATAddressingSchema = z.object({ + ethercatAddress: z.number().int().min(0).max(65535), +}) + +const EtherCATTimeoutsSchema = z.object({ + sdoTimeoutMs: z.number().int().min(0), + initToPreOpTimeoutMs: z.number().int().min(0), + safeOpToOpTimeoutMs: z.number().int().min(0), +}) + +const EtherCATWatchdogSchema = z.object({ + smWatchdogEnabled: z.boolean(), + smWatchdogMs: z.number().int().min(0), + pdiWatchdogEnabled: z.boolean(), + pdiWatchdogMs: z.number().int().min(0), +}) + +const EtherCATDistributedClocksSchema = z.object({ + dcEnabled: z.boolean(), + dcSyncUnitCycleUs: z.number().int().min(0), + dcSync0Enabled: z.boolean(), + dcSync0CycleUs: z.number().int().min(0), + dcSync0ShiftUs: z.number().int().min(0), + dcSync1Enabled: z.boolean(), + dcSync1CycleUs: z.number().int().min(0), + dcSync1ShiftUs: z.number().int().min(0), +}) + +const EtherCATSlaveConfigSchema = z.object({ + startupChecks: EtherCATStartupChecksSchema, + addressing: EtherCATAddressingSchema, + timeouts: EtherCATTimeoutsSchema, + watchdog: EtherCATWatchdogSchema, + distributedClocks: EtherCATDistributedClocksSchema, +}) + +const PersistedPdoEntrySchema = z.object({ + index: z.string(), + subIndex: z.string(), + bitLen: z.number(), + name: z.string(), + dataType: z.string(), +}) + +const PersistedPdoSchema = z.object({ + index: z.string(), + name: z.string(), + entries: z.array(PersistedPdoEntrySchema), +}) + +const PersistedChannelInfoSchema = z.object({ + channelId: z.string(), + name: z.string(), + direction: z.enum(['input', 'output']), + pdoIndex: z.string(), + entryIndex: z.string(), + entrySubIndex: z.string(), + dataType: z.string(), + bitLen: z.number(), + iecType: z.string(), +}) + +const SDOConfigurationEntrySchema = z.object({ + index: z.string(), + subIndex: z.number(), + value: z.string(), + defaultValue: z.string(), + dataType: z.string(), + bitLength: z.number(), + name: z.string(), + objectName: z.string(), +}) + +const ConfiguredEtherCATDeviceSchema = z.object({ + id: z.string(), + position: z.number().optional(), + name: z.string(), + esiDeviceRef: ESIDeviceRefSchema, + vendorId: z.string(), + productCode: z.string(), + revisionNo: z.string(), + addedFrom: z.enum(['repository', 'scan']), + config: EtherCATSlaveConfigSchema, + channelMappings: z.array(EtherCATChannelMappingSchema), + channelInfo: z.array(PersistedChannelInfoSchema).optional(), + rxPdos: z.array(PersistedPdoSchema).optional(), + txPdos: z.array(PersistedPdoSchema).optional(), + slaveType: z.string().optional(), + sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), +}) + +const EtherCATMasterConfigSchema = z.object({ + networkInterface: z.string(), + cycleTimeUs: z.number().int().min(100).max(100000), + watchdogTimeoutCycles: z.number().int().min(1).max(100).optional(), +}) +type EtherCATMasterConfig = z.infer + +const EthercatConfigSchema = z.object({ + masterConfig: EtherCATMasterConfigSchema.optional(), + devices: z.array(ConfiguredEtherCATDeviceSchema), +}) +type EthercatConfig = z.infer + const PLCRemoteDeviceSchema = z.object({ name: z.string(), protocol: PLCRemoteDeviceProtocolSchema, modbusTcpConfig: ModbusTcpConfigSchema.optional(), + ethercatConfig: EthercatConfigSchema.optional(), }) type PLCRemoteDevice = z.infer @@ -683,6 +807,9 @@ type PLCProject = z.infer export { baseTypeSchema, bodySchema, + ConfiguredEtherCATDeviceSchema, + EthercatConfigSchema, + EtherCATMasterConfigSchema, ModbusErrorHandlingSchema, ModbusFunctionCodeSchema, ModbusIOGroupSchema, @@ -737,11 +864,14 @@ export { S7CommSlaveConfigSchema, S7CommSystemAreaSchema, S7CommSystemAreasSchema, + SDOConfigurationEntrySchema, } export type { BaseType, BodySchema, + EthercatConfig, + EtherCATMasterConfig, ModbusErrorHandling, ModbusFunctionCode, ModbusIOGroup, diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts new file mode 100644 index 000000000..4ec1f0f30 --- /dev/null +++ b/src/types/ethercat/esi-types.ts @@ -0,0 +1,626 @@ +/** + * EtherCAT Slave Information (ESI) Types + * + * Types for parsing and representing ESI XML files following ETG.2000 specification. + * ESI files describe EtherCAT slave device properties, PDO mappings, and communication settings. + */ + +// ===================== VENDOR ===================== + +/** + * Vendor information from ESI file + */ +export interface ESIVendor { + /** Vendor ID (hex format, e.g., "0x0002" for Beckhoff) */ + id: string + /** Vendor name */ + name: string +} + +// ===================== DEVICE INFO ===================== + +/** + * Device type information + */ +export interface ESIDeviceType { + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** Type name/description */ + name: string +} + +/** + * Sync Manager configuration + */ +export interface ESISyncManager { + /** SM index (0-3 typically) */ + index: number + /** Start address */ + startAddress: string + /** Control byte */ + controlByte: string + /** Default size */ + defaultSize: number + /** Enable flag */ + enable: boolean + /** SM type: Mailbox Out, Mailbox In, Process Data Out, Process Data In */ + type: 'MbxOut' | 'MbxIn' | 'Outputs' | 'Inputs' +} + +/** + * FMMU (Fieldbus Memory Management Unit) configuration + */ +export interface ESIFMMU { + /** FMMU type: Outputs, Inputs, MbxState */ + type: 'Outputs' | 'Inputs' | 'MbxState' +} + +// ===================== PDO ENTRIES ===================== + +/** + * EtherCAT data types used in PDO entries + * Common types: BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, + * REAL, LREAL, STRING, BYTE, WORD, DWORD, BIT, BIT2-BIT7 + * Using string to allow vendor-specific custom types + */ +export type ESIDataType = string + +/** + * PDO Entry - represents a single variable in a PDO + */ +export interface ESIPdoEntry { + /** Entry index (hex, e.g., "#x6000") */ + index: string + /** Entry subindex (hex, e.g., "#x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name/identifier */ + name: string + /** Data type */ + dataType: ESIDataType + /** Optional: Comment/description */ + comment?: string +} + +/** + * Process Data Object - TxPdo (slave to master) or RxPdo (master to slave) + */ +export interface ESIPdo { + /** PDO index (hex, e.g., "#x1600" for RxPdo, "#x1A00" for TxPdo) */ + index: string + /** PDO name */ + name: string + /** Whether this PDO is fixed (cannot be modified) */ + fixed: boolean + /** Whether this PDO is mandatory */ + mandatory: boolean + /** SM index this PDO is assigned to */ + smIndex?: number + /** List of entries in this PDO */ + entries: ESIPdoEntry[] +} + +// ===================== COE (CANopen over EtherCAT) ===================== + +/** + * CoE Object Dictionary entry + */ +export interface ESICoEObject { + /** Object index (hex) */ + index: string + /** Object name */ + name: string + /** Object type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** PDO mapping allowed */ + pdoMapping: boolean + /** Object category: M=Mandatory, O=Optional, C=Conditional */ + category?: 'M' | 'O' | 'C' + /** PDO mapping direction: R=RxPDO, T=TxPDO, RT=both */ + pdoMappingDirection?: 'R' | 'T' | 'RT' + /** Default value */ + defaultValue?: string + /** Subindexes for complex objects */ + subItems?: ESICoESubItem[] +} + +/** + * CoE Object subitem (for array/record types) + */ +export interface ESICoESubItem { + /** Subindex */ + subIndex: string + /** Name */ + name: string + /** Data type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** Whether this sub-item can be PDO-mapped */ + pdoMapping?: boolean + /** Default value */ + defaultValue?: string +} + +// ===================== SDO CONFIGURATION ===================== + +/** + * SDO (Service Data Object) configuration entry for startup parameters. + * Each entry represents a single parameter to be written to the slave at startup. + */ +export interface SDOConfigurationEntry { + /** Object index (hex, e.g., "0x8000") */ + index: string + /** Subindex: 0 for simple objects, 1+ for sub-items */ + subIndex: number + /** Value configured by the user */ + value: string + /** Default value from the ESI */ + defaultValue: string + /** Data type (e.g., "UINT16", "BOOL") */ + dataType: string + /** Bit length of the parameter */ + bitLength: number + /** Parameter name */ + name: string + /** Parent object name */ + objectName: string +} + +// ===================== DEVICE ENRICHMENT ===================== + +/** + * Data extracted from a full ESIDevice for persistence into ConfiguredEtherCATDevice. + * Returned by enrichDeviceData() and used by device configuration components. + */ +export type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== DEVICE ===================== + +/** + * Complete ESI Device representation + */ +export interface ESIDevice { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** FMMU configurations */ + fmmu: ESIFMMU[] + /** Sync Manager configurations */ + syncManagers: ESISyncManager[] + /** RxPDOs (master to slave) */ + rxPdo: ESIPdo[] + /** TxPDOs (slave to master) */ + txPdo: ESIPdo[] + /** CoE objects (optional) */ + coeObjects?: ESICoEObject[] + /** Device image URL (optional) */ + imageUrl?: string + /** Additional description */ + description?: string +} + +// ===================== GROUP ===================== + +/** + * Device group/category + */ +export interface ESIGroup { + /** Group type identifier */ + type: string + /** Group name */ + name: string + /** Group image URL (optional) */ + imageUrl?: string + /** Group description */ + description?: string +} + +// ===================== COMPLETE ESI FILE ===================== + +/** + * Complete ESI file representation + */ +export interface ESIFile { + /** Vendor information */ + vendor: ESIVendor + /** Device groups */ + groups: ESIGroup[] + /** Devices in the file */ + devices: ESIDevice[] + /** Original filename */ + filename?: string + /** File version info */ + version?: string + /** Creation/modification info */ + infoData?: { + version?: string + creationDate?: string + modificationDate?: string + vendorUrl?: string + } +} + +// ===================== PARSED CHANNEL (for UI) ===================== + +/** + * Represents a channel that can be mapped to a located variable + * This is a flattened view of PDO entries for easier UI handling + */ +export interface ESIChannel { + /** Unique identifier for this channel */ + id: string + /** PDO type: input (TxPdo) or output (RxPdo) */ + direction: 'input' | 'output' + /** Parent PDO index */ + pdoIndex: string + /** Parent PDO name */ + pdoName: string + /** Entry index */ + entryIndex: string + /** Entry subindex */ + entrySubIndex: string + /** Channel name */ + name: string + /** Data type */ + dataType: ESIDataType + /** Bit length */ + bitLen: number + /** Bit offset within the PDO */ + bitOffset: number + /** Byte offset (calculated) */ + byteOffset: number + /** IEC 61131-3 compatible type */ + iecType: string + /** Whether this channel is selected for mapping */ + selected?: boolean + /** Mapped variable name (if assigned) */ + mappedVariable?: string +} + +// ===================== PERSISTED PDO/CHANNEL DATA ===================== + +/** + * Persisted PDO entry - stored in project.json for runtime config generation. + * Includes padding entries (index "0x0000") for complete PDO layout. + */ +export interface PersistedPdoEntry { + /** Entry index (hex, e.g., "0x6000") */ + index: string + /** Entry subindex (hex, e.g., "0x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name */ + name: string + /** Data type (e.g., "BOOL", "INT16", "BIT" for padding) */ + dataType: string +} + +/** + * Persisted PDO - stored in project.json for runtime config generation. + */ +export interface PersistedPdo { + /** PDO index (hex, e.g., "0x1A00") */ + index: string + /** PDO name */ + name: string + /** PDO entries including padding */ + entries: PersistedPdoEntry[] +} + +/** + * Persisted channel info with full metadata from ESI. + * Enriches the minimal channelId stored in EtherCATChannelMapping. + */ +export interface PersistedChannelInfo { + /** Unique channel ID matching ESIChannel.id format */ + channelId: string + /** Channel display name from ESI */ + name: string + /** Channel direction */ + direction: 'input' | 'output' + /** Parent PDO index (hex) */ + pdoIndex: string + /** Entry index (hex) */ + entryIndex: string + /** Entry subindex (hex) */ + entrySubIndex: string + /** ESI data type */ + dataType: string + /** Bit length */ + bitLen: number + /** IEC 61131-3 compatible type */ + iecType: string +} + +// ===================== CHANNEL MAPPING ===================== + +/** + * Mapping of an ESI channel to an IEC 61131-3 located variable address + */ +export interface EtherCATChannelMapping { + /** Matches ESIChannel.id */ + channelId: string + /** IEC 61131-3 located variable address (e.g., "%IX0.0", "%QW2") */ + iecLocation: string + /** True if the user manually edited this address */ + userEdited: boolean + /** User-editable alias for this channel mapping */ + alias?: string +} + +// ===================== PARSE RESULT ===================== + +/** + * Result of parsing an ESI file + */ +export interface ESIParseResult { + success: boolean + data?: ESIFile + error?: string + warnings?: string[] +} + +// ===================== DEVICE SUMMARY (lightweight) ===================== + +/** + * Lightweight device metadata without PDOs/SM/FMMU. + * Used for repository listing and device matching without full parsing. + */ +export interface ESIDeviceSummary { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** Pre-computed count of non-padding TxPDO entries */ + inputChannelCount: number + /** Pre-computed count of non-padding RxPDO entries */ + outputChannelCount: number + /** Pre-computed total input bytes */ + totalInputBytes: number + /** Pre-computed total output bytes */ + totalOutputBytes: number + /** Additional description */ + description?: string +} + +// ===================== REPOSITORY ===================== + +/** + * Item in the ESI repository (a loaded ESI file) + */ +export interface ESIRepositoryItem { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Devices contained in this file */ + devices: ESIDevice[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +/** + * Lightweight repository item with device summaries instead of full ESIDevice objects. + * Used for UI display and matching without loading full PDO data. + */ +export interface ESIRepositoryItemLight { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Lightweight device summaries */ + devices: ESIDeviceSummary[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +// ===================== CONFIGURED DEVICES ===================== + +/** + * Reference to an ESI device in the repository + */ +export interface ESIDeviceRef { + /** ID of the repository item containing the device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number +} + +/** + * A configured EtherCAT device in the project + */ +export interface ConfiguredEtherCATDevice { + /** Unique identifier */ + id: string + /** Position in the EtherCAT network (from scan or manual assignment) */ + position?: number + /** User-editable name for this device */ + name: string + /** Reference to the ESI device definition */ + esiDeviceRef: ESIDeviceRef + /** Vendor ID (hex format) */ + vendorId: string + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** How this device was added */ + addedFrom: 'repository' | 'scan' + /** Per-slave configuration settings */ + config: EtherCATSlaveConfig + /** Channel-to-located-variable mappings */ + channelMappings: EtherCATChannelMapping[] + /** Enriched channel metadata from ESI (persisted for runtime config generation) */ + channelInfo?: PersistedChannelInfo[] + /** RxPDOs with full layout including padding (persisted for runtime config generation) */ + rxPdos?: PersistedPdo[] + /** TxPDOs with full layout including padding (persisted for runtime config generation) */ + txPdos?: PersistedPdo[] + /** Slave device type classification (e.g., "digital_input", "coupler") */ + slaveType?: string + /** SDO startup parameters extracted from CoE Object Dictionary */ + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== PER-SLAVE CONFIGURATION ===================== + +/** + * Startup identity checks for an EtherCAT slave. + * When enabled, the master verifies the slave's identity during startup. + */ +export interface EtherCATStartupChecks { + /** Verify slave vendor ID matches ESI definition */ + checkVendorId: boolean + /** Verify slave product code matches ESI definition */ + checkProductCode: boolean +} + +/** + * Addressing configuration for an EtherCAT slave. + */ +export interface EtherCATAddressing { + /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ + ethercatAddress: number +} + +/** + * Timeout settings for an EtherCAT slave. + */ +export interface EtherCATTimeouts { + /** SDO (Service Data Object) operation timeout in milliseconds */ + sdoTimeoutMs: number + /** Init to Pre-Operational state transition timeout in milliseconds */ + initToPreOpTimeoutMs: number + /** Pre-Op to Safe-Op and Safe-Op to Operational transition timeout in milliseconds */ + safeOpToOpTimeoutMs: number +} + +/** + * Watchdog settings for an EtherCAT slave. + */ +export interface EtherCATWatchdog { + /** Enable Sync Manager watchdog */ + smWatchdogEnabled: boolean + /** Sync Manager watchdog time in milliseconds */ + smWatchdogMs: number + /** Enable Process Data Interface (PDI) watchdog */ + pdiWatchdogEnabled: boolean + /** PDI watchdog time in milliseconds */ + pdiWatchdogMs: number +} + +/** + * Distributed Clocks (DC) settings for an EtherCAT slave. + * DC provides synchronized timing across all slaves in the network. + */ +export interface EtherCATDistributedClocks { + /** Enable Distributed Clocks for this slave */ + dcEnabled: boolean + /** Base sync unit cycle time in microseconds. 0 = use master cycle time */ + dcSyncUnitCycleUs: number + /** Enable SYNC0 pulse generation */ + dcSync0Enabled: boolean + /** SYNC0 cycle time in microseconds. 0 = use master cycle time */ + dcSync0CycleUs: number + /** SYNC0 shift/offset time in microseconds */ + dcSync0ShiftUs: number + /** Enable SYNC1 pulse generation */ + dcSync1Enabled: boolean + /** SYNC1 cycle time in microseconds. 0 = use master cycle time */ + dcSync1CycleUs: number + /** SYNC1 shift/offset time in microseconds */ + dcSync1ShiftUs: number +} + +/** + * Complete per-slave configuration for a configured EtherCAT device. + */ +export interface EtherCATSlaveConfig { + /** Identity verification during startup */ + startupChecks: EtherCATStartupChecks + /** Network addressing */ + addressing: EtherCATAddressing + /** Communication timeouts */ + timeouts: EtherCATTimeouts + /** Watchdog settings */ + watchdog: EtherCATWatchdog + /** Distributed Clocks (DC) settings */ + distributedClocks: EtherCATDistributedClocks +} + +// ===================== DEVICE MATCHING ===================== + +/** + * Quality of match between a scanned device and ESI device + */ +export type DeviceMatchQuality = 'exact' | 'partial' | 'none' + +/** + * A potential match for a scanned device + */ +export interface DeviceMatch { + /** ID of the repository item containing the matched device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number + /** Quality of the match */ + matchQuality: DeviceMatchQuality + /** The matched ESI device (lightweight summary) */ + esiDevice: ESIDeviceSummary +} + +/** + * A scanned device with its potential matches from the repository + */ +export interface ScannedDeviceMatch { + /** The scanned device from network discovery */ + device: { + position: number + name: string + vendor_id: number + product_code: number + revision: number + serial_number: number + state: string + input_bytes: number + output_bytes: number + } + /** List of potential matches from the repository */ + matches: DeviceMatch[] + /** The match selected by the user for addition */ + selectedMatch?: ESIDeviceRef +} diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts new file mode 100644 index 000000000..bd395e7fd --- /dev/null +++ b/src/types/ethercat/index.ts @@ -0,0 +1,313 @@ +/** + * EtherCAT Discovery Service Types + * + * Types for communication with the OpenPLC Runtime EtherCAT discovery endpoints. + * Based on the runtime's /api/discovery/* REST API. + */ + +// Re-export ESI types +export * from './esi-types' + +// ===================== ENUMS ===================== + +/** + * Status codes returned by the discovery service + */ +export type EtherCATDiscoveryStatus = + | 'success' + | 'error' + | 'timeout' + | 'permission_denied' + | 'interface_not_found' + | 'not_available' + +/** + * EtherCAT slave states as reported by the discovery service + */ +export type EtherCATSlaveState = 'NONE' | 'INIT' | 'PRE-OP' | 'BOOT' | 'SAFE-OP' | 'OP' | 'UNKNOWN' + +// ===================== NETWORK INTERFACES ===================== + +/** + * Represents a network interface available for EtherCAT communication + */ +export interface NetworkInterface { + /** Interface name (e.g., "eth0", "enp3s0") */ + name: string + /** Human-readable description of the interface */ + description: string +} + +/** + * Response from GET /api/discovery/interfaces + */ +export interface NetworkInterfacesResponse { + status: 'success' | 'error' + interfaces: NetworkInterface[] + message?: string +} + +// ===================== ETHERCAT DEVICE ===================== + +/** + * Represents an EtherCAT slave device discovered on the network + */ +export interface EtherCATDevice { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name (e.g., "EK1100", "EL1008") */ + name: string + /** Vendor ID (e.g., 2 for Beckhoff) */ + vendor_id: number + /** Product code identifying the device type */ + product_code: number + /** Hardware revision number */ + revision: number + /** Serial number (0 if not available) */ + serial_number: number + /** Configured station address */ + config_address: number + /** Alias address (0 if not set) */ + alias: number + /** Current EtherCAT state */ + state: EtherCATSlaveState + /** AL (Application Layer) status code */ + al_status_code: number + /** Whether the device supports CoE (CANopen over EtherCAT) */ + has_coe: boolean + /** Number of input bytes */ + input_bytes: number + /** Number of output bytes */ + output_bytes: number +} + +// ===================== SERVICE STATUS ===================== + +/** + * Response from GET /api/discovery/ethercat/status + */ +export interface EtherCATServiceStatusResponse { + /** Whether the EtherCAT discovery service is available */ + available: boolean + /** Status message */ + message: string +} + +// ===================== SCAN ===================== + +/** + * Request body for POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanRequest { + /** Network interface to scan (e.g., "eth0") */ + interface: string + /** Scan timeout in milliseconds (default: 5000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanResponse { + status: EtherCATDiscoveryStatus + /** List of discovered EtherCAT devices */ + devices: EtherCATDevice[] + /** Human-readable result message */ + message: string + /** Time taken to complete the scan in milliseconds */ + scan_time_ms: number + /** Interface that was scanned */ + interface: string +} + +// ===================== CONNECTION TEST ===================== + +/** + * Request body for POST /api/discovery/ethercat/test + */ +export interface EtherCATTestRequest { + /** Network interface to use */ + interface: string + /** Position of the slave to test (1-indexed) */ + position: number + /** Connection test timeout in milliseconds (default: 3000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/test + */ +export interface EtherCATTestResponse { + status: EtherCATDiscoveryStatus + /** Whether the connection was successful */ + connected: boolean + /** Device information if connected */ + device?: EtherCATDevice + /** Human-readable result message */ + message: string + /** Response time in milliseconds */ + response_time_ms: number +} + +// ===================== VALIDATION ===================== + +/** + * PDO mapping entry for validation + */ +export interface PDOMappingEntry { + /** PDO address */ + address: string + /** Optional: data type */ + type?: string + /** Optional: bit offset */ + bit_offset?: number +} + +/** + * Slave configuration entry for validation requests. + * Not to be confused with EtherCATSlaveConfig from esi-types.ts (per-slave runtime config). + */ +export interface EtherCATValidationSlaveEntry { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Vendor ID */ + vendor_id?: number + /** Product code */ + product_code?: number + /** PDO mapping configuration */ + pdo_mapping?: Record +} + +/** + * Request body for POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateRequest { + /** Network interface to use */ + interface: string + /** List of slave configurations */ + slaves: EtherCATValidationSlaveEntry[] + /** Cycle time in milliseconds */ + cycle_time_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateResponse { + /** Whether the configuration is valid */ + valid: boolean + /** List of validation errors (configuration is invalid if non-empty) */ + errors: string[] + /** List of warnings (configuration is valid but may have issues) */ + warnings: string[] +} + +// ===================== IPC RESPONSE WRAPPERS ===================== + +/** + * Generic IPC response wrapper for EtherCAT operations + */ +export interface EtherCATIPCResponse { + success: boolean + data?: T + error?: string +} + +/** + * IPC response for listing network interfaces + */ +export type ListInterfacesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for checking service status + */ +export type ServiceStatusIPCResponse = EtherCATIPCResponse + +/** + * IPC response for scanning EtherCAT devices + */ +export type ScanDevicesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for testing connection to a device + */ +export type TestConnectionIPCResponse = EtherCATIPCResponse + +/** + * IPC response for validating configuration + */ +export type ValidateConfigIPCResponse = EtherCATIPCResponse + +// ===================== RUNTIME STATUS MONITORING ===================== + +/** + * EtherCAT plugin state machine states as reported by the runtime + */ +export type EtherCATPluginState = + | 'IDLE' + | 'SCANNING' + | 'CONFIGURING' + | 'TRANSITIONING' + | 'OPERATIONAL' + | 'RECOVERING' + | 'ERROR' + | 'STOPPED' + +/** + * Per-slave status snapshot from the runtime + */ +export interface EtherCATSlaveStatus { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name */ + name: string + /** Current EtherCAT AL state (e.g., "OP", "SAFE-OP", "INIT") */ + state: string + /** AL status code (0 = no error) */ + al_status_code: number + /** Cumulative error count for this slave */ + error_count: number + /** Whether the slave has an error condition */ + has_error: boolean +} + +/** + * Cycle performance metrics from the EtherCAT thread + */ +export interface EtherCATCycleMetrics { + /** Total cycles executed since last reset */ + cycle_count: number + /** Total WKC errors since last reset */ + wkc_error_count: number + /** Average cycle time in microseconds */ + avg_cycle_us: number + /** Maximum cycle time in microseconds */ + max_cycle_us: number + /** Maximum process data exchange time in microseconds */ + max_exchange_us: number + /** Current consecutive WKC error count */ + consecutive_wkc_errors: number + /** Number of recovery attempts since last successful recovery */ + recovery_attempts: number +} + +/** + * Response from GET /api/discovery/ethercat/runtime-status + */ +export interface EtherCATRuntimeStatusResponse { + /** Current plugin state */ + plugin_state: EtherCATPluginState + /** Number of configured slaves */ + slave_count: number + /** Expected working counter value */ + expected_wkc: number + /** Per-slave status array */ + slaves: EtherCATSlaveStatus[] + /** Cycle performance metrics */ + metrics: EtherCATCycleMetrics +} + +/** + * IPC response for getting runtime status + */ +export type RuntimeStatusIPCResponse = EtherCATIPCResponse From 03e9bdc5bf5ad581514bb8bbb1972283a8f565bb Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 9 Apr 2026 19:31:29 -0400 Subject: [PATCH 02/30] feat: extend RuntimePort with EtherCAT discovery and create EsiPort Add EtherCAT runtime discovery methods to RuntimePort (scan, test, validate, status, interfaces). Create EsiPort interface for ESI repository operations (load, parse, save, delete, migrate). Wire EsiPort into PlatformPorts. Add hasEthercat capability flag and useEsi() convenience hook. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend/editor/ethercat/esi-service.ts | 550 ++++++++++++++++++ src/backend/editor/ethercat/index.ts | 2 + src/main/modules/ipc/main.ts | 366 ++++++++++++ src/main/modules/ipc/renderer.ts | 128 +++- src/middleware/adapters/editor/esi-adapter.ts | 115 ++++ .../adapters/editor/runtime-adapter.ts | 56 ++ src/middleware/editor-platform.ts | 3 + src/middleware/shared/ports/esi-port.ts | 53 ++ .../shared/ports/platform-capabilities.ts | 11 + src/middleware/shared/ports/runtime-port.ts | 37 ++ .../shared/providers/platform-context.tsx | 4 + src/middleware/shared/providers/types.ts | 2 + 12 files changed, 1326 insertions(+), 1 deletion(-) create mode 100644 src/backend/editor/ethercat/esi-service.ts create mode 100644 src/backend/editor/ethercat/index.ts create mode 100644 src/middleware/adapters/editor/esi-adapter.ts create mode 100644 src/middleware/shared/ports/esi-port.ts diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts new file mode 100644 index 000000000..a9705db96 --- /dev/null +++ b/src/backend/editor/ethercat/esi-service.ts @@ -0,0 +1,550 @@ +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 '../../shared/ethercat/esi-parser-main' +import { fileOrDirectoryExists } from '../utils' + +/** + * ESI Repository Index stored in devices/esi/repository.json + * Version 2 includes device summaries inline for instant loading. + */ +interface ESIRepositoryIndex { + version: number + items: Array<{ + id: string + filename: string + vendorId: string + vendorName: string + deviceCount: number + loadedAt: number + warnings?: string[] + devices?: ESIDeviceSummary[] + }> +} + +/** + * Response type for ESI service operations + */ +interface ESIServiceResponse { + success: boolean + error?: string +} + +/** + * ESI Service - Handles persistence of ESI XML files in the project + * + * ESI files are stored in the project's devices/esi/ directory. + * Each XML file is saved with its UUID as filename. + * A repository.json index file tracks all loaded ESI files. + */ +class ESIService { + private readonly ESI_DIR = 'devices/esi' + private readonly REPOSITORY_FILE = 'repository.json' + private indexLock: Promise = Promise.resolve() + + /** + * Serialize access to the repository index to prevent race conditions. + */ + private withIndexLock(fn: () => Promise): Promise { + const current = this.indexLock + let resolve: () => void + this.indexLock = new Promise((r) => (resolve = r)) + return current.then(fn).finally(() => resolve!()) + } + + /** + * Get the ESI directory path for a project + */ + private getEsiDir(projectPath: string): string { + const basePath = basename(projectPath) === 'project.json' ? dirname(projectPath) : projectPath + return join(basePath, this.ESI_DIR) + } + + /** + * Get the repository index file path + */ + private getRepositoryPath(projectPath: string): string { + return join(this.getEsiDir(projectPath), this.REPOSITORY_FILE) + } + + private static readonly UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + /** + * Get the path for an ESI XML file (validates itemId is a UUID to prevent path traversal) + */ + private getXmlPath(projectPath: string, itemId: string): string { + if (!ESIService.UUID_REGEX.test(itemId)) { + throw new Error(`Invalid item ID: ${itemId}`) + } + return join(this.getEsiDir(projectPath), `${itemId}.xml`) + } + + /** + * Ensure the ESI directory exists + */ + private async ensureEsiDir(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) { + await promises.mkdir(esiDir, { recursive: true }) + } + } + + /** + * Load the ESI repository index from disk + */ + async loadRepositoryIndex(projectPath: string): Promise { + const repoPath = this.getRepositoryPath(projectPath) + + try { + const content = await promises.readFile(repoPath, 'utf-8') + const index = JSON.parse(content) as ESIRepositoryIndex + return index + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + // Re-throw for corrupted/unreadable files so callers don't silently overwrite + throw error + } + } + + /** + * Save the ESI repository index to disk (v1 format for backward compat) + */ + async saveRepositoryIndex(projectPath: string, items: ESIRepositoryItem[]): Promise { + try { + await this.ensureEsiDir(projectPath) + + const index: ESIRepositoryIndex = { + version: 1, + items: items.map((item) => ({ + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + })), + } + + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify(index, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI repository index:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save repository index', + } + } + } + + /** + * Save the ESI repository index to disk in v2 format with device summaries + */ + async saveRepositoryIndexV2(projectPath: string, items: ESIRepositoryItemLight[]): Promise { + try { + await this.ensureEsiDir(projectPath) + + const index: ESIRepositoryIndex = { + version: 2, + items: items.map((item) => ({ + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + devices: item.devices, + })), + } + + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify(index, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI repository index v2:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save repository index', + } + } + } + + /** + * Save an ESI XML file to disk + */ + async saveXmlFile(projectPath: string, itemId: string, xmlContent: string): Promise { + try { + await this.ensureEsiDir(projectPath) + + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.writeFile(xmlPath, xmlContent, 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save XML file', + } + } + } + + /** + * Load an ESI XML file from disk + */ + async loadXmlFile( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + const content = await promises.readFile(xmlPath, 'utf-8') + return { success: true, content } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: 'XML file not found' } + } + console.error('Error loading ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load XML file', + } + } + } + + /** + * Delete an ESI XML file from disk + */ + async deleteXmlFile(projectPath: string, itemId: string): Promise { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.unlink(xmlPath) + return { success: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: true } // already deleted + } + console.error('Error deleting ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete XML file', + } + } + } + + /** + * Save a complete ESI repository item (XML + update index) + */ + async saveRepositoryItem( + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + _existingItems: ESIRepositoryItem[], + ): Promise { + return this.withIndexLock(async () => { + // Save the XML file + const xmlResult = await this.saveXmlFile(projectPath, item.id, xmlContent) + if (!xmlResult.success) { + return xmlResult + } + + // Read current index from disk instead of trusting caller snapshot + const currentIndex = await this.loadRepositoryIndex(projectPath) + const currentItems = currentIndex?.items ?? [] + const updatedItems = [ + ...currentItems.filter((i) => i.id !== item.id), + { + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: item.loadedAt, + warnings: item.warnings, + }, + ] + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile( + repoPath, + JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), + 'utf-8', + ) + return { success: true } + }) + } + + /** + * Delete a repository item (XML + update index) + */ + async deleteRepositoryItem( + projectPath: string, + itemId: string, + _existingItems: ESIRepositoryItem[], + ): Promise { + return this.withIndexLock(async () => { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Read current index from disk instead of trusting caller snapshot + const currentIndex = await this.loadRepositoryIndex(projectPath) + const currentItems = currentIndex?.items ?? [] + const updatedItems = currentItems.filter((i) => i.id !== itemId) + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile( + repoPath, + JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), + 'utf-8', + ) + return { success: true } + }) + } + + /** + * Delete a repository item and update the v2 index (no re-parsing) + */ + async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { + return this.withIndexLock(async () => { + try { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Update the v2 index without the deleted item + const currentItems = await this.loadLightItemsFromIndex(projectPath) + const updatedItems = currentItems.filter((i) => i.id !== itemId) + return this.saveRepositoryIndexV2(projectPath, updatedItems) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete repository item', + } + } + }) + } + + /** + * Load light items from the v2 repository index + */ + private async loadLightItemsFromIndex(projectPath: string): Promise { + const index = await this.loadRepositoryIndex(projectPath) + if (!index || index.version !== 2) return [] + return index.items + .filter((i) => i.devices) + .map((i) => ({ + id: i.id, + filename: i.filename, + vendor: { id: i.vendorId, name: i.vendorName }, + devices: i.devices || [], + loadedAt: i.loadedAt, + warnings: i.warnings, + })) + } + + /** + * Load repository as lightweight items (v2 instant, v1 needs migration) + */ + async loadRepositoryLight( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> { + try { + const index = await this.loadRepositoryIndex(projectPath) + + if (!index) { + return { success: true, items: [] } + } + + // V2 index has device summaries inline + if (index.version === 2 && index.items.length > 0 && index.items[0].devices) { + const items = await this.loadLightItemsFromIndex(projectPath) + return { success: true, items } + } + + // V1 index needs migration + if (index.items.length > 0) { + return { success: true, needsMigration: true } + } + + return { success: true, items: [] } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load repository', + } + } + } + + /** + * Migrate a v1 repository to v2 by re-parsing all XML files with parseESILight + */ + async migrateRepositoryToV2( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> { + return this.withIndexLock(async () => { + try { + const index = await this.loadRepositoryIndex(projectPath) + if (!index) { + return { success: true, items: [] } + } + + const items: ESIRepositoryItemLight[] = [] + + for (const indexItem of index.items) { + try { + const xmlResult = await this.loadXmlFile(projectPath, indexItem.id) + if (xmlResult.success && xmlResult.content) { + const parseResult = parseESILight(xmlResult.content, indexItem.filename) + if (parseResult.success && parseResult.vendor && parseResult.devices) { + items.push({ + id: indexItem.id, + filename: indexItem.filename, + vendor: parseResult.vendor, + devices: parseResult.devices, + loadedAt: indexItem.loadedAt, + warnings: parseResult.warnings || indexItem.warnings, + }) + } + } + } catch (err) { + console.error(`Failed to migrate ESI file ${indexItem.filename}:`, err) + } + } + + // Save as v2 + const saveResult = await this.saveRepositoryIndexV2(projectPath, items) + if (!saveResult.success) { + return { success: false, error: saveResult.error || 'Failed to save migrated index' } + } + + return { success: true, items } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate repository', + } + } + }) + } + + /** + * Parse and save a single ESI file. Returns the saved item on success. + * Called once per file from the renderer's sequential upload loop. + */ + async parseAndSaveFile( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> { + // Parse outside the lock (CPU-bound, no index access) + const parseResult = parseESILight(content, filename) + if (!parseResult.success || !parseResult.vendor || !parseResult.devices) { + return { success: false, error: parseResult.error || 'Parse failed' } + } + + return this.withIndexLock(async () => { + try { + // Check for duplicate + const existingIndex = await this.loadRepositoryIndex(projectPath) + const existingFilenames = new Set(existingIndex?.items.map((i) => i.filename) ?? []) + if (existingFilenames.has(filename)) { + return { success: true } // skip duplicate silently + } + + // Save XML to disk + const itemId = uuidv4() + const saveResult = await this.saveXmlFile(projectPath, itemId, content) + if (!saveResult.success) { + return { success: false, error: saveResult.error ?? 'Failed to save XML file' } + } + + const item: ESIRepositoryItemLight = { + id: itemId, + filename, + vendor: parseResult.vendor!, + devices: parseResult.devices!, + loadedAt: Date.now(), + warnings: parseResult.warnings, + } + + // Append to v2 index + const currentItems = await this.loadLightItemsFromIndex(projectPath) + await this.saveRepositoryIndexV2(projectPath, [...currentItems, item]) + + return { success: true, item } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + }) + } + + /** + * Clear the entire ESI repository: delete all XML files and reset the index. + */ + async clearRepository(projectPath: string): Promise { + return this.withIndexLock(async () => { + try { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) return { success: true } + + // Delete only XML files, not the index + const entries = await promises.readdir(esiDir) + const xmlFiles = entries.filter((entry) => entry.endsWith('.xml')) + await Promise.all(xmlFiles.map((entry) => promises.unlink(join(esiDir, entry)))) + + // Write empty v2 index + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify({ version: 2, items: [] }, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear repository', + } + } + }) + } + + /** + * Check if ESI directory exists for a project + */ + hasEsiDirectory(projectPath: string): boolean { + return fileOrDirectoryExists(this.getEsiDir(projectPath)) + } + + /** + * Get list of XML files in the ESI directory + */ + async listXmlFiles(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + + if (!fileOrDirectoryExists(esiDir)) { + return [] + } + + try { + const entries = await promises.readdir(esiDir) + return entries.filter((entry) => entry.endsWith('.xml')) + } catch { + return [] + } + } +} + +export { ESIService } +export type { ESIRepositoryIndex, ESIServiceResponse } diff --git a/src/backend/editor/ethercat/index.ts b/src/backend/editor/ethercat/index.ts new file mode 100644 index 000000000..2a452f194 --- /dev/null +++ b/src/backend/editor/ethercat/index.ts @@ -0,0 +1,2 @@ +export type { ESIRepositoryIndex, ESIServiceResponse } from './esi-service' +export { ESIService } from './esi-service' diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 70dc8d958..9a25da24d 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,5 +1,19 @@ +import { ESIService } from '@root/backend/editor/ethercat' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' import { getErrorMessage } from '@root/frontend/utils/get-error-message' +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' +import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { PLCProjectData } from '@root/types/PLC/open-plc' @@ -48,6 +62,9 @@ class MainProcessBridge implements MainIpcModule { private fileWatchers: Map = new Map() // avr8js ATmega2560 emulator instance for the built-in simulator private simulatorModule = new SimulatorModule() + // VPP package manager for board package operations + // ESI repository service for EtherCAT device descriptions + private esiService = new ESIService() constructor({ ipcMain, @@ -296,6 +313,89 @@ class MainProcessBridge implements MainIpcModule { } } + /** + * Wrap a service call with standardized error handling. + */ + private async wrapServiceCall(fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * 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, @@ -525,6 +625,8 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('hardware:refresh-communication-ports', this.handleHardwareRefreshCommunicationPorts) this.registerHandle('hardware:refresh-available-boards', this.handleHardwareRefreshAvailableBoards) + // ===================== PACKAGE MANAGER ===================== + // ===================== UTILITIES ===================== this.registerHandle('util:get-preview-image', this.handleUtilGetPreviewImage) this.ipcMain.on('util:log', this.handleUtilLog) @@ -550,6 +652,28 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('runtime:clear-credentials', this.handleRuntimeClearCredentials) this.registerHandle('runtime:get-serial-ports', this.handleRuntimeGetSerialPorts) + // ===================== ETHERCAT DISCOVERY ===================== + this.registerHandle('ethercat:get-interfaces', this.handleEtherCATGetInterfaces) + this.registerHandle('ethercat:get-status', this.handleEtherCATGetStatus) + this.registerHandle('ethercat:scan', this.handleEtherCATScan) + this.registerHandle('ethercat:test', this.handleEtherCATTest) + this.registerHandle('ethercat:validate', this.handleEtherCATValidate) + this.registerHandle('ethercat:get-runtime-status', this.handleEtherCATGetRuntimeStatus) + + // ===================== ESI REPOSITORY ===================== + this.registerHandle('esi:load-repository-index', this.handleESILoadRepositoryIndex) + this.registerHandle('esi:save-repository-index', this.handleESISaveRepositoryIndex) + this.registerHandle('esi:save-xml-file', this.handleESISaveXmlFile) + this.registerHandle('esi:load-xml-file', this.handleESILoadXmlFile) + this.registerHandle('esi:delete-xml-file', this.handleESIDeleteXmlFile) + this.registerHandle('esi:save-repository-item', this.handleESISaveRepositoryItem) + this.registerHandle('esi:delete-repository-item', this.handleESIDeleteRepositoryItem) + this.registerHandle('esi:parse-and-save-file', this.handleESIParseAndSaveFile) + this.registerHandle('esi:clear-repository', this.handleESIClearRepository) + this.registerHandle('esi:load-device-full', this.handleESILoadDeviceFull) + this.registerHandle('esi:load-repository-light', this.handleESILoadRepositoryLight) + this.registerHandle('esi:migrate-repository', this.handleESIMigrateRepository) + // ===================== SIMULATOR ===================== this.registerHandle('simulator:load-firmware', this.handleSimulatorLoadFirmware) this.registerHandle('simulator:stop', this.handleSimulatorStop) @@ -1355,6 +1479,248 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== ETHERCAT DISCOVERY HANDLERS ===================== + + 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) } + } + } + + 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) => JSON.parse(data) as EtherCATServiceStatusResponse, + ) + 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) } + } + } + + handleEtherCATScan = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'scan', + params: { interface: scanRequest.interface, timeout_ms: scanRequest.timeout_ms }, + }) + const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return { + status: (pluginResponse.status as string) ?? 'success', + devices: (pluginResponse.devices as EtherCATScanResponse['devices']) ?? [], + message: (pluginResponse.message as string) ?? '', + scan_time_ms: 0, + interface: scanRequest.interface, + } as EtherCATScanResponse + }, + scanTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATTest = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { + try { + const postData = JSON.stringify(testRequest) + const testTimeout = (testRequest.timeout_ms || 3000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/test', + postData, + (data: string) => JSON.parse(data) as EtherCATTestResponse, + testTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATValidate = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => { + try { + const postData = JSON.stringify(validateRequest) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/validate', + postData, + (data: string) => JSON.parse(data) as EtherCATValidateResponse, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATGetRuntimeStatus = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'status', + }) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return pluginResponse as unknown as EtherCATRuntimeStatusResponse + }, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + // ===================== ESI REPOSITORY HANDLERS ===================== + + handleESILoadRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(async () => { + const index = await this.esiService.loadRepositoryIndex(projectPath) + return { success: true as const, data: index } + }) + + handleESISaveRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string, items: ESIRepositoryItem[]) => + this.wrapServiceCall(() => this.esiService.saveRepositoryIndex(projectPath, items)) + + handleESISaveXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string, xmlContent: string) => + this.wrapServiceCall(() => this.esiService.saveXmlFile(projectPath, itemId, xmlContent)) + + handleESILoadXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.loadXmlFile(projectPath, itemId)) + + handleESIDeleteXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.deleteRepositoryItemV2(projectPath, itemId)) + + handleESISaveRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ) => this.wrapServiceCall(() => this.esiService.saveRepositoryItem(projectPath, item, xmlContent, existingItems)) + + handleESIDeleteRepositoryItem = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + existingItems: ESIRepositoryItem[], + ) => this.wrapServiceCall(() => this.esiService.deleteRepositoryItem(projectPath, itemId, existingItems)) + + handleESIParseAndSaveFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + filename: string, + content: string, + ) => this.wrapServiceCall(() => this.esiService.parseAndSaveFile(projectPath, filename, content)) + + handleESIClearRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.clearRepository(projectPath)) + + handleESILoadDeviceFull = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + deviceIndex: number, + ) => + this.wrapServiceCall(async () => { + const xmlResult = await this.esiService.loadXmlFile(projectPath, itemId) + if (!xmlResult.success || !xmlResult.content) { + return { success: false as const, error: xmlResult.error || 'XML file not found' } + } + return parseESIDeviceFull(xmlResult.content, deviceIndex) + }) + + handleESILoadRepositoryLight = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.loadRepositoryLight(projectPath)) + + handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + handleSimulatorLoadFirmware = async ( _event: IpcMainInvokeEvent, hexPath: string, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 04da0fa1f..1c1ed3f0c 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,16 @@ -import type { PLCProjectData } from '@root/middleware/shared/ports/types' +import type { PLCProjectData } from \'@root/middleware/shared/ports/types\' +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' +import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { RuntimeLogEntry } from '@root/types/PLC/runtime-logs' @@ -355,6 +367,120 @@ const rendererProcessBridge = { return () => ipcRenderer.removeListener('runtime:token-refreshed', callback) }, + // ===================== ETHERCAT DISCOVERY METHODS ===================== + etherCATGetInterfaces: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => + ipcRenderer.invoke('ethercat:get-interfaces', ipAddress, jwtToken), + + etherCATGetStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-status', ipAddress, jwtToken), + + etherCATScan: ( + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => + ipcRenderer.invoke('ethercat:scan', ipAddress, jwtToken, scanRequest), + + etherCATTest: ( + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => + ipcRenderer.invoke('ethercat:test', ipAddress, jwtToken, testRequest), + + etherCATValidate: ( + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => + ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + + etherCATGetRuntimeStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress, jwtToken), + + // ===================== ESI REPOSITORY METHODS ===================== + esiLoadRepositoryIndex: ( + projectPath: string, + ): Promise<{ + success: boolean + data?: { version: number; items: Array> } + error?: string + }> => ipcRenderer.invoke('esi:load-repository-index', projectPath), + + esiSaveRepositoryIndex: ( + projectPath: string, + items: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-repository-index', projectPath, items), + + esiSaveXmlFile: ( + projectPath: string, + itemId: string, + xmlContent: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-xml-file', projectPath, itemId, xmlContent), + + esiLoadXmlFile: ( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> => + ipcRenderer.invoke('esi:load-xml-file', projectPath, itemId), + + esiDeleteXmlFile: (projectPath: string, itemId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:delete-xml-file', projectPath, itemId), + + esiSaveRepositoryItem: ( + projectPath: string, + item: ESIRepositoryItem, + xmlContent: string, + existingItems: ESIRepositoryItem[], + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-repository-item', projectPath, item, xmlContent, existingItems), + + 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 ===================== + esiParseAndSaveFile: ( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> => + ipcRenderer.invoke('esi:parse-and-save-file', projectPath, filename, content), + + esiClearRepository: (projectPath: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:clear-repository', projectPath), + + esiLoadDeviceFull: ( + projectPath: string, + itemId: string, + deviceIndex: number, + ): Promise<{ success: boolean; device?: ESIDevice; error?: string }> => + ipcRenderer.invoke('esi:load-device-full', projectPath, itemId, deviceIndex), + + esiLoadRepositoryLight: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> => + ipcRenderer.invoke('esi:load-repository-light', projectPath), + + esiMigrateRepository: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> => + ipcRenderer.invoke('esi:migrate-repository', projectPath), + // ===================== SIMULATOR METHODS ===================== simulatorLoadFirmware: (hexPath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('simulator:load-firmware', hexPath), diff --git a/src/middleware/adapters/editor/esi-adapter.ts b/src/middleware/adapters/editor/esi-adapter.ts new file mode 100644 index 000000000..38fe5314a --- /dev/null +++ b/src/middleware/adapters/editor/esi-adapter.ts @@ -0,0 +1,115 @@ +/** + * Editor EsiPort adapter — delegates to Electron IPC bridge. + * + * Communicates with the main process ESIService for ESI repository + * management. The adapter injects the project path from a callback + * so UI components never pass it explicitly. + * + * IPC channels: + * esi:load-repository-light (invoke) + * esi:migrate-repository (invoke) + * esi:parse-and-save-file (invoke) + * esi:delete-xml-file (invoke) + * esi:clear-repository (invoke) + * esi:load-device-full (invoke) + */ + +import type { EsiPort } from '../../shared/ports/esi-port' +import type { Result } from '../../shared/ports/types' + +export function createEditorEsiAdapter(getProjectPath: () => string): EsiPort { + function requireProjectPath(): string { + const path = getProjectPath() + if (!path) throw new Error('No project path available') + return path + } + + return { + async loadRepositoryLight() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiLoadRepositoryLight(projectPath) + if (result.success) { + return { success: true, items: result.items ?? [], needsMigration: result.needsMigration } as Result<{ + items: typeof result.items extends undefined ? never : NonNullable + needsMigration?: boolean + }> + } + return { success: false, error: result.error ?? 'Failed to load repository' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async migrateRepository() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiMigrateRepository(projectPath) + if (result.success) { + return { success: true, items: result.items ?? [] } as Result<{ + items: NonNullable + }> + } + return { success: false, error: result.error ?? 'Migration failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async parseAndSaveFile(filename, content) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiParseAndSaveFile(projectPath, filename, content) + if (result.success && result.item) { + return { success: true, item: result.item } as Result<{ item: NonNullable }> + } + if (result.success) { + // Duplicate file — silently skip + return { success: true, item: undefined } as unknown as Result<{ + item: NonNullable + }> + } + return { success: false, error: result.error ?? 'Parse failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async deleteRepositoryItem(itemId) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiDeleteXmlFile(projectPath, itemId) + return result.success + ? ({ success: true } as Result) + : { success: false, error: result.error ?? 'Delete failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async clearRepository() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiClearRepository(projectPath) + return result.success + ? ({ success: true } as Result) + : { success: false, error: result.error ?? 'Clear failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async loadDeviceFull(itemId, deviceIndex) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiLoadDeviceFull(projectPath, itemId, deviceIndex) + if (result.success && result.device) { + return { success: true, device: result.device } as Result<{ device: NonNullable }> + } + return { success: false, error: result.error ?? 'Device not found' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + } +} diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index 08ac0ff3d..ad58893e6 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -135,5 +135,61 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP } return window.bridge.onRuntimeTokenRefreshed(handler) }, + + // --- EtherCAT Discovery --- + + async getNetworkInterfaces() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetInterfaces(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async getEthercatServiceStatus() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetStatus(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async scanEthercatDevices(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATScan(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async testEthercatConnection(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATTest(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async validateEthercatConfig(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATValidate(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async getEthercatRuntimeStatus() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetRuntimeStatus(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, } } diff --git a/src/middleware/editor-platform.ts b/src/middleware/editor-platform.ts index feb611760..d60ebafc1 100644 --- a/src/middleware/editor-platform.ts +++ b/src/middleware/editor-platform.ts @@ -13,10 +13,12 @@ * */ +import { openPLCStoreBase } from '../frontend/store' import { createEditorAcceleratorAdapter } from './adapters/editor/accelerator-adapter' import { createEditorCompilerAdapter } from './adapters/editor/compiler-adapter' import { createEditorDebuggerAdapter } from './adapters/editor/debugger-adapter' import { createEditorDeviceAdapter } from './adapters/editor/device-adapter' +import { createEditorEsiAdapter } from './adapters/editor/esi-adapter' import { createEditorOrchestratorAdapter } from './adapters/editor/orchestrator-adapter' import { createEditorProjectAdapter } from './adapters/editor/project-adapter' import { createEditorRuntimeAdapter } from './adapters/editor/runtime-adapter' @@ -52,5 +54,6 @@ export const editorPorts: PlatformPorts = { window: createEditorWindowAdapter(), accelerator: createEditorAcceleratorAdapter(), theme: createEditorThemeAdapter(), + esi: createEditorEsiAdapter(() => openPLCStoreBase.getState().project.meta.path), capabilities: { ...EDITOR_CAPABILITIES, isDevMode: process.env.NODE_ENV === 'development' }, } diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts new file mode 100644 index 000000000..6d4f06cd3 --- /dev/null +++ b/src/middleware/shared/ports/esi-port.ts @@ -0,0 +1,53 @@ +/** + * EsiPort — Abstracts ESI (EtherCAT Slave Information) repository management. + * + * The ESI repository stores parsed EtherCAT device description files (XML) + * for use in device configuration. The persistence mechanism differs between + * platforms: + * + * Editor adapter: Delegates to main process ESIService which reads/writes + * XML files and a JSON index in the project's devices/esi/ directory. + * Web adapter: Will delegate to a backend API for ESI file management. + * + * All parsing and processing happens in the shared backend + * (src/backend/shared/ethercat/). The port only handles persistence I/O. + */ + +import type { ESIDevice, ESIRepositoryItemLight } from '../../../types/ethercat/esi-types' +import type { Result } from './types' + +export interface EsiPort { + /** + * Load the ESI repository as lightweight items (instant from v2 cached index). + * Returns needsMigration=true if the repository is in v1 format. + */ + loadRepositoryLight(): Promise> + + /** + * Migrate a v1 repository to v2 format with device summaries. + * Returns the updated list of lightweight items. + */ + migrateRepository(): Promise> + + /** + * Parse an ESI XML file and save it to the repository. + * The filename and raw XML content are provided; parsing happens on the backend. + */ + parseAndSaveFile(filename: string, content: string): Promise> + + /** + * Delete a single repository item and its associated XML file. + */ + deleteRepositoryItem(itemId: string): Promise + + /** + * Clear the entire ESI repository (bulk delete all files + reset index). + */ + clearRepository(): Promise + + /** + * Load a full ESI device on-demand (with PDOs, sync managers, FMMU, CoE). + * This is called when the user selects a device for detailed configuration. + */ + loadDeviceFull(itemId: string, deviceIndex: number): Promise> +} diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index 06c9160b0..d9e22396d 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -84,6 +84,15 @@ export interface PlatformCapabilities { */ hasDirectProgramUpload: boolean + // --- Packages --- + + /** True if the app supports installing/managing VPP board packages. */ + + // --- EtherCAT --- + + /** True if the app supports EtherCAT device configuration and ESI repository. */ + hasEthercat: boolean + // --- Environment --- /** True when running in a development build (Vite DEV / webpack development mode). */ @@ -113,6 +122,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasAIAssistant: false, hasProxiedRuntimeConnection: false, hasDirectProgramUpload: false, + hasEthercat: true, isDevMode: false, } @@ -135,5 +145,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasAIAssistant: true, hasProxiedRuntimeConnection: true, hasDirectProgramUpload: true, + hasEthercat: false, isDevMode: false, } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index fe432af85..1b62a0328 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -37,6 +37,17 @@ * - runtimeLogout() */ +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '../../../types/ethercat' import type { PlcStatus, RuntimeLogEntry, SerialPort, TimingStats, Unsubscribe } from './types' export interface LoginParams { @@ -138,4 +149,30 @@ export interface RuntimePort { * Returns unsubscribe function. */ onTokenRefreshed?(callback: (newToken: string) => void): Unsubscribe + + // --- EtherCAT Discovery (runtime device commands) --- + + /** Get network interfaces available on the runtime device. */ + getNetworkInterfaces?(): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> + + /** Check if the EtherCAT service is available on the runtime. */ + getEthercatServiceStatus?(): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> + + /** Scan for EtherCAT devices on a network interface. */ + scanEthercatDevices?( + request: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> + + /** Test connection to a specific EtherCAT slave. */ + testEthercatConnection?( + request: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> + + /** Validate an EtherCAT configuration against the runtime. */ + validateEthercatConfig?( + request: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> + + /** Get EtherCAT runtime status (plugin state, slave status, cycle metrics). */ + getEthercatRuntimeStatus?(): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> } diff --git a/src/middleware/shared/providers/platform-context.tsx b/src/middleware/shared/providers/platform-context.tsx index 51446b8a2..ffabd5716 100644 --- a/src/middleware/shared/providers/platform-context.tsx +++ b/src/middleware/shared/providers/platform-context.tsx @@ -101,3 +101,7 @@ export function useCapabilities() { export function useAI() { return usePlatform().ai } + +export function useEsi() { + return usePlatform().esi +} diff --git a/src/middleware/shared/providers/types.ts b/src/middleware/shared/providers/types.ts index ada544f2e..b28e9973c 100644 --- a/src/middleware/shared/providers/types.ts +++ b/src/middleware/shared/providers/types.ts @@ -8,6 +8,7 @@ import type { AIPort } from '../ports/ai-port' import type { CompilerPort } from '../ports/compiler-port' import type { DebuggerPort } from '../ports/debugger-port' import type { DevicePort } from '../ports/device-port' +import type { EsiPort } from '../ports/esi-port' import type { OrchestratorPort } from '../ports/orchestrator-port' import type { PlatformCapabilities } from '../ports/platform-capabilities' import type { ProjectPort } from '../ports/project-port' @@ -30,5 +31,6 @@ export interface PlatformPorts { accelerator: AcceleratorPort theme: ThemePort capabilities: PlatformCapabilities + esi?: EsiPort ai?: AIPort } From 5906b459c980415d2ee3af8bb78cc00d4272a1ac Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 9 Apr 2026 19:40:54 -0400 Subject: [PATCH 03/30] feat: add EtherCAT store actions, selectors, and device config hook Add updateEthercatConfig action to project slice. Include EtherCAT channel mappings in IEC address collection to prevent conflicts with Modbus. Extend remote device IO point selector to collect EtherCAT channel aliases. Port use-device-configuration hook using EsiPort instead of window.bridge for on-demand device loading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hooks/use-device-configuration.ts | 132 ++++++++++++++++++ src/frontend/hooks/use-store-selectors.ts | 43 ++++-- src/frontend/store/slices/project/slice.ts | 33 +++++ src/frontend/store/slices/project/types.ts | 1 + 4 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 src/frontend/hooks/use-device-configuration.ts diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts new file mode 100644 index 000000000..b2a611079 --- /dev/null +++ b/src/frontend/hooks/use-device-configuration.ts @@ -0,0 +1,132 @@ +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, +} from '@root/types/ethercat/esi-types' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { EsiPort } from '../../middleware/shared/ports/esi-port' + +type UseDeviceConfigurationParams = { + device: ConfiguredEtherCATDevice + esiPort: EsiPort + 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, + esiPort, + 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 esiPort.loadDeviceFull( + 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('error' in result ? result.error : 'Failed to load device data') + } + } catch (error) { + setChannelLoadError(String(error)) + } finally { + setIsLoadingChannels(false) + } + } + + void loadFullDevice() + }, [enabled, esiPort, 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/frontend/hooks/use-store-selectors.ts b/src/frontend/hooks/use-store-selectors.ts index 03b7c5e65..f7a20b80b 100644 --- a/src/frontend/hooks/use-store-selectors.ts +++ b/src/frontend/hooks/use-store-selectors.ts @@ -97,18 +97,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/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 7ea804c84..8cb736d66 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -1,3 +1,4 @@ +import type { EthercatConfig } from '@root/types/PLC/open-plc' import { produce } from 'immer' import { StateCreator } from 'zustand' @@ -1064,6 +1065,16 @@ const createProjectSlice: StateCreator = (se usedAddresses.add(p.iecLocation) } } + // Include EtherCAT channel mappings from all remote devices + for (const rd of slice.project.data.remoteDevices ?? []) { + if (rd.ethercatConfig?.devices) { + for (const dev of rd.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + usedAddresses.add(mapping.iecLocation) + } + } + } + } const ioPoints = generateIOPoints(group.functionCode, group.length, group.name, usedAddresses) device.modbusTcpConfig.ioGroups.push({ ...group, ioPoints }) @@ -1105,6 +1116,28 @@ const createProjectSlice: StateCreator = (se ) return ok() }, + updateEthercatConfig: (deviceName: string, ethercatConfig: EthercatConfig | Record) => { + let response = ok() + setState( + produce((slice: ProjectSlice) => { + if (!slice.project.data.remoteDevices) { + response = { ok: false, message: 'No remote devices found' } + return + } + const device = slice.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 as typeof device.ethercatConfig + }), + ) + return response + }, }, }) diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 06f761483..105dfbc25 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -239,6 +239,7 @@ export type ProjectActions = { ) => ProjectResponse deleteIOGroup: (deviceName: string, groupId: string) => ProjectResponse updateIOPointAlias: (deviceName: string, groupId: string, pointId: string, alias: string) => ProjectResponse + updateEthercatConfig: (deviceName: string, ethercatConfig: Record) => ProjectResponse } // --------------------------------------------------------------------------- From 83bf21c315e38d5d9740fa037767da74af76eb0c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 07:44:52 -0400 Subject: [PATCH 04/30] feat: port all EtherCAT UI components to shared frontend Port 21 EtherCAT editor components from src/renderer/ to src/frontend/. Replace all window.bridge calls with useRuntime() and useEsi() hooks. Update import paths for shared backend utils and frontend assets. Route EtherCAT protocol to dedicated EtherCATEditor in workspace screen. Enable EtherCAT option in create-element card. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../create-element/element-card/index.tsx | 2 +- .../components/channel-mapping-table.tsx | 232 +++++++ .../components/configured-device-row.tsx | 242 +++++++ .../components/configured-devices.tsx | 160 +++++ .../components/device-browser-modal.tsx | 274 ++++++++ .../components/device-configuration-form.tsx | 422 ++++++++++++ .../components/device-detail-panel.tsx | 219 +++++++ .../ethercat/components/device-scan-table.tsx | 127 ++++ .../ethercat/components/devices-tab.tsx | 253 ++++++++ .../ethercat/components/diagnostics-tab.tsx | 179 ++++++ .../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 | 222 +++++++ .../ethercat/components/esi-repository.tsx | 153 +++++ .../device/ethercat/components/esi-upload.tsx | 223 +++++++ .../components/global-settings-tab.tsx | 155 +++++ .../components/interface-selector.tsx | 94 +++ .../components/runtime-status-panel.tsx | 309 +++++++++ .../components/sdo-parameters-table.tsx | 389 +++++++++++ .../editor/device/ethercat/index.tsx | 608 ++++++++++++++++++ .../hooks/use-device-configuration.ts | 11 +- src/frontend/screens/workspace-screen.tsx | 8 +- 24 files changed, 4899 insertions(+), 7 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index 934974f7c..6c3b9c402 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -61,7 +61,7 @@ const ServerProtocolSources = [ const RemoteDeviceProtocolSources = [ { value: 'modbus-tcp', label: 'Modbus', disabled: false }, { value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true }, - { value: 'ethercat', label: 'EtherCAT', disabled: true }, + { value: 'ethercat', label: 'EtherCAT', disabled: false }, { value: 'profinet', label: 'PROFINET', disabled: true }, ] as const diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx new file mode 100644 index 000000000..f35f5c1d2 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -0,0 +1,232 @@ +import { cn } from '@root/frontend/utils/cn' +import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +type ChannelMappingTableProps = { + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onAliasChange: (channelId: string, newAlias: string) => void +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * Alias cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const AliasCell = React.memo( + ({ + channelId, + alias, + onAliasChange, + }: { + channelId: string + alias: string + onAliasChange: (channelId: string, newAlias: string) => void + }) => { + const [localAlias, setLocalAlias] = useState(alias) + + useEffect(() => { + setLocalAlias(alias) + }, [alias]) + + const handleBlur = useCallback(() => { + if (localAlias !== alias) { + onAliasChange(channelId, localAlias) + } + }, [channelId, localAlias, alias, onAliasChange]) + + return ( + setLocalAlias(e.target.value)} + onBlur={handleBlur} + placeholder='Alias' + className='h-[24px] w-full rounded border border-neutral-300 bg-white px-1.5 font-mono text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + /> + ) + }, +) + +/** + * Channel Mapping Table Component + * + * Displays channels with auto-generated IEC 61131-3 located variable addresses (read-only) + * and editable alias column. + */ +const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappingTableProps) => { + const [filterDirection, setFilterDirection] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + // Build a lookup map from channelId to mapping + const mappingMap = useMemo(() => { + const map = new Map() + for (const m of mappings) { + map.set(m.channelId, m) + } + return map + }, [mappings]) + + // Build a 1-based per-direction index for each channel (stable regardless of filtering) + const channelIndexMap = useMemo(() => { + const map = new Map() + let inputIdx = 0 + let outputIdx = 0 + for (const ch of channels) { + if (ch.direction === 'input') { + inputIdx++ + map.set(ch.id, inputIdx) + } else { + outputIdx++ + map.set(ch.id, outputIdx) + } + } + return map + }, [channels]) + + // Filter channels based on direction and search + const filteredChannels = useMemo(() => { + return channels.filter((channel) => { + if (filterDirection !== 'all' && channel.direction !== filterDirection) { + return false + } + + if (searchTerm) { + const search = searchTerm.toLowerCase() + const mapping = mappingMap.get(channel.id) + return ( + channel.iecType.toLowerCase().includes(search) || + (mapping?.iecLocation.toLowerCase().includes(search) ?? false) || + (mapping?.alias?.toLowerCase().includes(search) ?? false) + ) + } + + return true + }) + }, [channels, filterDirection, searchTerm, mappingMap]) + + const inputCount = channels.filter((c) => c.direction === 'input').length + const outputCount = channels.filter((c) => c.direction === 'output').length + + return ( +
+ {/* Filters */} +
+ {/* Direction Filter */} +
+ + + +
+ + {/* Search */} + setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredChannels.length === 0 ? ( + + + + ) : ( + filteredChannels.map((channel) => { + const mapping = mappingMap.get(channel.id) + return ( + + + + + + + + ) + }) + )} + +
+ # + + Dir + + IEC Type + + Address + Alias
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ {channelIndexMap.get(channel.id) ?? ''} + + + {channel.direction === 'input' ? 'IN' : 'OUT'} + + + {channel.iecType} + + {mapping?.iecLocation ?? ''} + + +
+
+
+ ) +} + +export { ChannelMappingTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx new file mode 100644 index 000000000..52df7067a --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -0,0 +1,242 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { useMemo } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type ConfiguredDeviceRowProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + isExpanded: boolean + onToggleExpand: () => void + isSelected: boolean + onSelect: () => void + onUpdateDevice: (config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +const ConfiguredDeviceRow = ({ + device, + repository, + isExpanded, + onToggleExpand, + isSelected, + onSelect, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDeviceRowProps) => { + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const ioSummary = esiDevice ? `${esiDevice.inputChannelCount} / ${esiDevice.outputChannelCount}` : '-' + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + enabled: isExpanded, + }) + + return ( + <> + {/* Main row */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect() + } + }} + className={cn( + 'cursor-pointer border-b border-neutral-200 dark:border-neutral-800', + isSelected && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + + {device.name} + {esiDevice?.name || 'Unknown'} + + {device.position !== undefined ? device.position : '-'} + + {ioSummary} + + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + + + + + {/* Expanded details */} + {isExpanded && ( + <> + {/* Device Info Section */} + + + +
+
+ Device Info +
+
+
+ Vendor:{' '} + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID:{' '} + {device.vendorId} +
+
+ Product Code:{' '} + {device.productCode} +
+
+ Revision:{' '} + {device.revisionNo} +
+
+ ESI File:{' '} + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group:{' '} + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels:{' '} + {esiDevice.inputChannelCount} +
+
+ Output Channels:{' '} + {esiDevice.outputChannelCount} +
+ + )} +
+
+ + + + {/* Configuration Section */} + + + +
+
+ Configuration +
+ +
+ + + + {/* Startup Parameters (SDO) Section */} + + + +
+
+ Startup Parameters (SDO) +
+ +
+ + + + {/* Channel Mappings Section */} + + + +
+
+ Channel Mappings +
+ +
+ + + + )} + + ) +} + +export { ConfiguredDeviceRow } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx new file mode 100644 index 000000000..c97a7ee71 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -0,0 +1,160 @@ +import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import TableActions from '@root/frontend/components/_atoms/table-actions' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { useCallback, useEffect, useState } from 'react' + +import { ConfiguredDeviceRow } from './configured-device-row' + +type ConfiguredDevicesProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onAddDevice: () => void + onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +/** + * Configured Devices Component + * + * Displays the list of configured EtherCAT devices with add/remove functionality. + */ +const ConfiguredDevices = ({ + devices, + repository, + onAddDevice, + onRemoveDevice, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDevicesProps) => { + const [expandedDevices, setExpandedDevices] = useState>(new Set()) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + // Clear stale selection when device list changes externally + useEffect(() => { + if (selectedDeviceId && !devices.some((d) => d.id === selectedDeviceId)) { + setSelectedDeviceId(null) + } + }, [devices, selectedDeviceId]) + + const handleToggleExpand = useCallback((deviceId: string) => { + setExpandedDevices((prev) => { + const next = new Set(prev) + if (next.has(deviceId)) { + next.delete(deviceId) + } else { + next.add(deviceId) + } + return next + }) + }, []) + + const handleRemoveSelected = useCallback(() => { + if (selectedDeviceId) { + onRemoveDevice(selectedDeviceId) + setSelectedDeviceId(null) + } + }, [selectedDeviceId, onRemoveDevice]) + + return ( +
+ {/* Header with actions */} +
+

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

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

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

+
+ ) : ( +
+ {groupedDevices.map((group) => ( +
+ {/* Vendor header */} + + + {/* Devices */} + {effectiveExpandedVendors.has(group.vendorId) && ( +
+ {group.devices.map(({ repoItem, device, deviceIndex }) => { + const isSelected = + selectedRef?.repositoryItemId === repoItem.id && selectedRef?.deviceIndex === deviceIndex + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} + +export { DeviceBrowserModal } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx new file mode 100644 index 000000000..6a353070c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx @@ -0,0 +1,422 @@ +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import { cn } from '@root/frontend/utils/cn' +import type { + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx new file mode 100644 index 000000000..bb4e1869a --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx @@ -0,0 +1,219 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { useMemo, useState } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type DeviceDetailPanelProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + projectPath: string + usedAddresses: Set + onUpdateDevice: (config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +const DeviceDetailPanel = ({ + device, + repository, + projectPath, + usedAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, +}: DeviceDetailPanelProps) => { + const [activeTab, setActiveTab] = useState('info') + + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + }) + + return ( +
+ {/* Device header */} +
+

{device.name}

+

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

+
+ + {/* Sub-tabs */} + setActiveTab(v as DeviceDetailTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + {/* Device Info Tab */} + +
+
+
+ Vendor + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID + {device.vendorId} +
+
+ Product Code + {device.productCode} +
+
+ Revision + {device.revisionNo} +
+
+ ESI File + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels + {esiDevice.inputChannelCount} +
+
+ Output Channels + {esiDevice.outputChannelCount} +
+ + )} +
+ Source + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + +
+
+
+
+ + {/* Configuration Tab */} + +
+
+ +
+
+
+ + {/* Startup Parameters Tab */} + +
+ +
+
+ + {/* Channel Mappings Tab */} + +
+ +
+
+
+
+ ) +} + +export { DeviceDetailPanel } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx new file mode 100644 index 000000000..9d0914a61 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx @@ -0,0 +1,127 @@ +import { cn } from '@root/frontend/utils/cn' +import type { EtherCATDevice } from '@root/types/ethercat' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx new file mode 100644 index 000000000..402cd7f7a --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -0,0 +1,253 @@ +import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import TableActions from '@root/frontend/components/_atoms/table-actions' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { useCallback, useMemo, useState } from 'react' + +import { DeviceBrowserModal } from './device-browser-modal' +import { DeviceDetailPanel } from './device-detail-panel' +import { ESIRepository } from './esi-repository' + +type DevicesTabProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onRepositoryChange: (items: ESIRepositoryItemLight[]) => void + projectPath: string + isLoadingRepository: boolean + repositoryError: string | null + onRetryRepository: () => void + usedAddresses: Set + onAddDeviceFromBrowser: ( + ref: ESIDeviceRef, + device: ESIDeviceSummary, + repoItem: ESIRepositoryItemLight, + ) => void | Promise + onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void +} + +const DevicesTab = ({ + devices, + repository, + onRepositoryChange, + projectPath, + isLoadingRepository, + repositoryError, + onRetryRepository, + usedAddresses, + onAddDeviceFromBrowser, + onRemoveDevice, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, +}: DevicesTabProps) => { + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + const [showRepository, setShowRepository] = useState(false) + + const selectedDevice = useMemo(() => { + return devices.find((d) => d.id === selectedDeviceId) ?? null + }, [devices, selectedDeviceId]) + + // Auto-select first device if current selection is invalid + const effectiveSelectedId = selectedDevice ? selectedDeviceId : devices.length > 0 ? devices[0].id : null + + const effectiveDevice = useMemo(() => { + return devices.find((d) => d.id === effectiveSelectedId) ?? null + }, [devices, effectiveSelectedId]) + + const handleRemoveSelected = useCallback(() => { + if (effectiveSelectedId) { + onRemoveDevice(effectiveSelectedId) + setSelectedDeviceId(null) + } + }, [effectiveSelectedId, onRemoveDevice]) + + return ( +
+ {/* Repository Section (collapsible) */} +
+ + + {showRepository && ( +
+ {repositoryError && ( +
+

Failed to load repository: {repositoryError}

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

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

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

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

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

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

+
+ )} +
+
+ + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={(...args) => void onAddDeviceFromBrowser(...args)} + repository={repository} + /> +
+ ) +} + +export { DevicesTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx new file mode 100644 index 000000000..fab46d97d --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -0,0 +1,179 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { cn } from '@root/frontend/utils/cn' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { ScannedDeviceMatch } from '@root/types/ethercat/esi-types' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx new file mode 100644 index 000000000..e43caf37e --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -0,0 +1,184 @@ +import { getBestMatchQuality } from '@root/backend/shared/ethercat/device-matcher' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { cn } from '@root/frontend/utils/cn' +import type { DeviceMatchQuality, ScannedDeviceMatch } from '@root/types/ethercat/esi-types' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx new file mode 100644 index 000000000..306aa23f7 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx @@ -0,0 +1,234 @@ +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { cn } from '@root/frontend/utils/cn' +import type { ESIChannel } from '@root/types/ethercat/esi-types' +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx new file mode 100644 index 000000000..e586ca237 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx @@ -0,0 +1,165 @@ +import { getDeviceSummary } from '@root/backend/shared/ethercat/esi-parser' +import { cn } from '@root/frontend/utils/cn' +import type { ESIDevice, ESIFile } from '@root/types/ethercat/esi-types' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx new file mode 100644 index 000000000..a91b91180 --- /dev/null +++ b/src/frontend/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/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx new file mode 100644 index 000000000..7ee75384f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -0,0 +1,222 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { cn } from '@root/frontend/utils/cn' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx new file mode 100644 index 000000000..25fc2f5f3 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -0,0 +1,153 @@ +import { useEsi } from '@root/middleware/shared/providers/platform-context' +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 esi = useEsi() + 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 esi!.deleteRepositoryItem(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 esi!.clearRepository() + if (result.success) { + onRepositoryChange([]) + setUploadErrors([]) + } else { + console.error('Failed to clear ESI repository:', result.error) + } + } catch (err) { + console.error('Error clearing ESI repository:', err) + } finally { + setIsSaving(false) + } + }, [onRepositoryChange, projectPath]) + + const [errorsExpanded, setErrorsExpanded] = useState(false) + + const isProcessing = isLoading || isSaving + + return ( +
+ {/* Upload Area */} + + + {/* Error Summary (collapsible) */} + {uploadErrors.length > 0 && ( +
+
+ + +
+ {errorsExpanded && ( +
+ {uploadErrors.map((error, index) => ( +
+ {error.filename || 'File'}: {error.error} +
+ ))} +
+ )} +
+ )} + + {/* Repository Table */} +
+ +
+
+ ) +} + +export { ESIRepository } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx new file mode 100644 index 000000000..a0e1232fa --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -0,0 +1,223 @@ +import { cn } from '@root/frontend/utils/cn' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +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 esi = useEsi() + const [isDragging, setIsDragging] = useState(false) + const [parseProgress, setParseProgress] = useState({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 0, + }) + const fileInputRef = useRef(null) + + const processFiles = useCallback( + async (files: FileList) => { + const xmlFiles = Array.from(files).filter((file) => file.name.toLowerCase().endsWith('.xml')) + + if (xmlFiles.length === 0) { + onFilesLoaded(repository, [{ filename: '', error: 'No XML files found. Please upload .xml ESI files.' }]) + return + } + + setParseProgress({ + active: true, + currentFile: xmlFiles[0].name, + currentFileIndex: 0, + totalFiles: xmlFiles.length, + percentage: 0, + }) + + const newItems: ESIRepositoryItemLight[] = [] + const errors: Array<{ filename: string; error: string }> = [] + + const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB + + // Process files one at a time to avoid memory issues + for (let i = 0; i < xmlFiles.length; i++) { + const file = xmlFiles[i] + + setParseProgress({ + active: true, + currentFile: file.name, + currentFileIndex: i, + totalFiles: xmlFiles.length, + percentage: Math.round((i / xmlFiles.length) * 100), + }) + + if (file.size > MAX_FILE_SIZE) { + errors.push({ + filename: file.name, + error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 100MB.`, + }) + continue + } + + try { + const text = await file.text() + const result = await esi!.parseAndSaveFile(file.name, text) + + if (result.success && result.item) { + newItems.push(result.item) + } else if ('error' in result ? result.error : 'Parse failed') { + errors.push({ filename: file.name, error: 'error' in result ? result.error : 'Parse failed' }) + } + } catch (err) { + errors.push({ filename: file.name, error: err instanceof Error ? err.message : String(err) }) + } + } + + setParseProgress({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 100, + }) + + onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) + }, + [onFilesLoaded, repository, projectPath], + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = e.dataTransfer.files + if (files.length > 0) { + void processFiles(files) + } + }, + [processFiles], + ) + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + void processFiles(files) + } + // Reset input so same files can be selected again + e.target.value = '' + }, + [processFiles], + ) + + const handleClick = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const isProcessing = isLoading || parseProgress.active + + return ( +
+ + + {/* Drop zone */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + aria-label='Upload ESI XML files. Click or drag and drop.' + className={cn( + 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors', + isDragging + ? 'bg-brand/10 dark:bg-brand/20 border-brand' + : 'border-neutral-300 hover:border-brand hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800/50', + isProcessing && 'pointer-events-none opacity-50', + )} + > + + + {parseProgress.active ? ( +
+ +
+ ) : ( +
+ + + + + Drop ESI files here or browse + + + Supports multiple .xml ESI files (ETG.2000) + + {repository.length > 0 && ( + + {repository.length} file(s) currently loaded + + )} +
+ )} +
+
+ ) +} + +export { ESIUpload } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx new file mode 100644 index 000000000..f96d53605 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -0,0 +1,155 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' +import { cn } from '@root/frontend/utils/cn' +import type { NetworkInterface } from '@root/types/ethercat' +import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx new file mode 100644 index 000000000..c27a8d67d --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx @@ -0,0 +1,94 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { Label } from '@root/frontend/components/_atoms/label' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' +import { cn } from '@root/frontend/utils/cn' +import type { NetworkInterface } from '@root/types/ethercat' + +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/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx new file mode 100644 index 000000000..1809c5bd5 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -0,0 +1,309 @@ +import { cn } from '@root/frontend/utils/cn' +import { useRuntime } from '@root/middleware/shared/providers/platform-context' +import type { + EtherCATCycleMetrics, + EtherCATPluginState, + EtherCATRuntimeStatusResponse, + EtherCATSlaveStatus, +} from '@root/types/ethercat' +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 runtime.getEthercatRuntimeStatus!() + if (result.success && result.data) { + setStatus(result.data) + setError(null) + setPluginNotActive(false) + } else { + const rawError = result.error ?? 'Failed to get status' + const cleanMessage = extractErrorMessage(rawError) + if (isPluginNotActiveError(cleanMessage)) { + setPluginNotActive(true) + setError(null) + setStatus(null) + } else { + setPluginNotActive(false) + setError(cleanMessage) + } + } + } catch (err) { + setError(String(err)) + setPluginNotActive(false) + } finally { + setIsLoading(false) + } + }, [isConnected, ipAddress, jwtToken]) + + // Start polling when connected + useEffect(() => { + if (!isConnected) { + setStatus(null) + setError(null) + setPluginNotActive(false) + return + } + + void fetchStatus() + + intervalRef.current = setInterval(() => { + void fetchStatus() + }, POLL_INTERVAL_MS) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [isConnected, fetchStatus]) + + if (!isConnected) { + return ( +
+ + Not connected to runtime +
+ ) + } + + if (pluginNotActive) { + return ( +
+ + + EtherCAT plugin not active - start PLC with EtherCAT configuration + +
+ ) + } + + if (error && !status) { + return ( +
+
+ + {error} +
+ +
+ ) + } + + if (!status) { + return ( +
+ + {isLoading ? 'Loading status...' : 'Waiting for status...'} + +
+ ) + } + + const pluginState = status.plugin_state + const stateColor = stateColorMap[pluginState] ?? 'gray' + const metrics: EtherCATCycleMetrics = status.metrics + + return ( +
+ {/* Plugin state header */} +
+
+ + {pluginState} + + ({status.slave_count} slave{status.slave_count !== 1 ? 's' : ''}, WKC={status.expected_wkc}) + +
+ +
+ + {/* Cycle metrics */} + {(pluginState === 'OPERATIONAL' || pluginState === 'RECOVERING' || pluginState === 'ERROR') && ( +
+ + + + + + {metrics.recovery_attempts > 0 && } +
+ )} + + {/* Slave table */} + {status.slaves.length > 0 && ( +
+ + + + + + + + + + + + {status.slaves.map((slave) => ( + + + + + + + + ))} + +
PosNameStateAL StatusErrors
{slave.position}{slave.name} + + + {slave.al_status_code === 0 + ? '-' + : `0x${slave.al_status_code.toString(16).toUpperCase().padStart(4, '0')}`} + + {slave.error_count > 0 ? ( + {slave.error_count} + ) : ( + '0' + )} +
+
+ )} +
+ ) +} + +export { RuntimeStatusPanel } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx new file mode 100644 index 000000000..ceb5d1384 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -0,0 +1,389 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { cn } from '@root/frontend/utils/cn' +import type { SDOConfigurationEntry } from '@root/types/ethercat/esi-types' +import { useCallback, useEffect, useMemo, useState } from 'react' + +type SdoParametersTableProps = { + sdoConfigurations: SDOConfigurationEntry[] + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +/** + * A group of SDO entries sharing the same parent object (index + objectName). + */ +interface SdoObjectGroup { + index: string + objectName: string + entries: SDOConfigurationEntry[] +} + +/** + * Get numeric range for a data type. + */ +function getDataTypeRange(dataType: string, bitLength: number): { min: number; max: number } | null { + const upper = dataType.toUpperCase() + + if (upper === 'BOOL') return { min: 0, max: 1 } + if (upper === 'USINT' || upper === 'UINT8') return { min: 0, max: 255 } + if (upper === 'SINT' || upper === 'INT8') return { min: -128, max: 127 } + if (upper === 'UINT' || upper === 'UINT16') return { min: 0, max: 65535 } + if (upper === 'INT' || upper === 'INT16') return { min: -32768, max: 32767 } + if (upper === 'UDINT' || upper === 'UINT32') return { min: 0, max: 4294967295 } + if (upper === 'DINT' || upper === 'INT32') return { min: -2147483648, max: 2147483647 } + if (upper === 'REAL' || upper === 'REAL32' || upper === 'FLOAT') return { min: -3.4028235e38, max: 3.4028235e38 } + if (upper === 'LREAL' || upper === 'REAL64' || upper === 'DOUBLE') + return { min: -1.7976931348623157e308, max: 1.7976931348623157e308 } + + // Fallback based on bit length for unsigned + if (bitLength > 0 && bitLength <= 32) { + return { min: 0, max: Math.pow(2, bitLength) - 1 } + } + + return null +} + +/** + * Check if the data type is boolean. + */ +function isBoolType(dataType: string): boolean { + return dataType.toUpperCase() === 'BOOL' +} + +/** + * Value cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const ValueCell = ({ + entry, + onValueChange, +}: { + entry: SDOConfigurationEntry + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + const [localValue, setLocalValue] = useState(entry.value) + + useEffect(() => { + setLocalValue(entry.value) + }, [entry.value]) + + const handleBlur = useCallback(() => { + if (localValue !== entry.value) { + const range = getDataTypeRange(entry.dataType, entry.bitLength) + if (range && localValue !== '') { + const num = Number(localValue) + if (!isNaN(num)) { + const clamped = Math.max(range.min, Math.min(range.max, num)) + const clampedStr = String(clamped) + setLocalValue(clampedStr) + onValueChange(entry.index, entry.subIndex, clampedStr) + return + } + } + onValueChange(entry.index, entry.subIndex, localValue) + } + }, [entry.index, entry.subIndex, entry.value, entry.dataType, entry.bitLength, localValue, onValueChange]) + + if (isBoolType(entry.dataType)) { + return ( + { + const newVal = e.target.checked ? '1' : '0' + setLocalValue(newVal) + onValueChange(entry.index, entry.subIndex, newVal) + }} + className='h-4 w-4 accent-brand' + /> + ) + } + + const range = getDataTypeRange(entry.dataType, entry.bitLength) + const isFloat = ['REAL', 'REAL32', 'FLOAT', 'LREAL', 'REAL64', 'DOUBLE'].includes(entry.dataType.toUpperCase()) + const isNumeric = range !== null + + return ( + setLocalValue(e.target.value)} + onBlur={handleBlur} + min={isNumeric ? range?.min : undefined} + max={isNumeric ? range?.max : undefined} + className='h-[24px] w-full max-w-[120px] rounded border border-neutral-300 bg-white px-1.5 font-mono text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + /> + ) +} + +/** + * SDO Parameters Table Component + * + * Displays configurable SDO startup parameters grouped by parent CoE object. + * Each object is a collapsible section header; sub-items are shown as editable rows beneath. + */ +const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: SdoParametersTableProps) => { + const [searchTerm, setSearchTerm] = useState('') + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + + // Group entries by parent object index + const groups = useMemo(() => { + const map = new Map() + for (const entry of sdoConfigurations) { + const existing = map.get(entry.index) + if (existing) { + existing.entries.push(entry) + } else { + map.set(entry.index, { index: entry.index, objectName: entry.objectName, entries: [entry] }) + } + } + return Array.from(map.values()) + }, [sdoConfigurations]) + + // Filter groups and entries by search term + const filteredGroups = useMemo(() => { + if (!searchTerm) return groups + + const search = searchTerm.toLowerCase() + const result: SdoObjectGroup[] = [] + + for (const group of groups) { + // If group name/index matches, include entire group + if (group.objectName.toLowerCase().includes(search) || group.index.toLowerCase().includes(search)) { + result.push(group) + continue + } + + // Otherwise filter entries within the group + const matchedEntries = group.entries.filter( + (entry) => + entry.name.toLowerCase().includes(search) || + entry.dataType.toLowerCase().includes(search) || + entry.index.toLowerCase().includes(search), + ) + + if (matchedEntries.length > 0) { + result.push({ ...group, entries: matchedEntries }) + } + } + + return result + }, [groups, searchTerm]) + + const handleToggleGroup = useCallback((index: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + }, []) + + const handleExpandAll = useCallback(() => { + setCollapsedGroups(new Set()) + }, []) + + const handleCollapseAll = useCallback(() => { + setCollapsedGroups(new Set(groups.map((g) => g.index))) + }, [groups]) + + const handleValueChange = useCallback( + (index: string, subIndex: number, value: string) => { + const updated = sdoConfigurations.map((entry) => + entry.index === index && entry.subIndex === subIndex ? { ...entry, value } : entry, + ) + onUpdateSdoConfigurations(updated) + }, + [sdoConfigurations, onUpdateSdoConfigurations], + ) + + const handleResetAll = useCallback(() => { + const reset = sdoConfigurations.map((entry) => ({ ...entry, value: entry.defaultValue })) + onUpdateSdoConfigurations(reset) + }, [sdoConfigurations, onUpdateSdoConfigurations]) + + const hasModifiedValues = sdoConfigurations.some((entry) => entry.value !== entry.defaultValue) + const totalEntries = sdoConfigurations.length + + return ( +
+ {/* Toolbar */} +
+ setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> + +
+ + +
+ + {filteredGroups.length} object(s), {totalEntries} parameter(s) + {hasModifiedValues && ' — modified values highlighted'} + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {filteredGroups.length === 0 ? ( + + + + ) : ( + filteredGroups.map((group) => { + const isCollapsed = collapsedGroups.has(group.index) + const groupHasModified = group.entries.some((e) => e.value !== e.defaultValue) + + return ( + handleToggleGroup(group.index)} + onValueChange={handleValueChange} + /> + ) + }) + )} + +
+ Sub + + Name + + Type + + Bits + + Default + + Value +
+ {sdoConfigurations.length === 0 + ? 'No configurable SDO parameters found' + : 'No parameters match the current filter'} +
+
+
+ ) +} + +/** + * Renders a group header row + its sub-item rows (when expanded). + * Extracted as a component to avoid key issues with fragments in map. + */ +const GroupRows = ({ + group, + isCollapsed, + hasModified, + onToggle, + onValueChange, +}: { + group: SdoObjectGroup + isCollapsed: boolean + hasModified: boolean + onToggle: () => void + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + return ( + <> + {/* Group header row */} + + + + + +
+ {group.index} + {group.objectName} + + ({group.entries.length} param{group.entries.length !== 1 && 's'}) + + {hasModified && ( + + modified + + )} +
+ + + + {/* Sub-item rows */} + {!isCollapsed && + group.entries.map((entry) => { + const isModified = entry.value !== entry.defaultValue + return ( + + + {entry.subIndex} + + {entry.name} + + {entry.dataType} + {entry.bitLength} + + {entry.defaultValue || '-'} + + + + + + ) + })} + + ) +} + +export { SdoParametersTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx new file mode 100644 index 000000000..3bea69360 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -0,0 +1,608 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' +import { + countMatchedDevices, + getBestMatchQuality, + matchDevicesToRepository, +} from '@root/backend/shared/ethercat/device-matcher' +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' +import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + ScannedDeviceMatch, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { DevicesTab } from './components/devices-tab' +import { DiagnosticsTab } from './components/diagnostics-tab' +import { GlobalSettingsTab } from './components/global-settings-tab' + +type EditorTab = 'global-settings' | 'diagnostics' | 'devices' + +const TabItem = ({ + value, + label, + isActive, + badge, +}: { + value: string + label: string + isActive: boolean + badge?: React.ReactNode +}) => ( + + {label} + {badge} + +) + +/** + * EtherCAT Device Editor + * + * Three-tab layout: + * - Global Settings: Master configuration (network interface, cycle time, watchdog) + * - Diagnostics: Runtime status monitoring and device discovery/scanning + * - Devices: ESI repository management and configured device editing + */ +const EtherCATEditor = () => { + const { editor, runtimeConnection, project, projectActions } = useOpenPLCStore() + const runtime = useRuntime() + const esi = useEsi() + + const deviceName = editor.type === 'plc-remote-device' ? editor.meta.name : '' + const projectPath = project.meta.path + + // Runtime connection state + const { connectionStatus, ipAddress } = runtimeConnection + const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null + + // Tab state + const [activeTab, setActiveTab] = useState('devices') + + // Repository state + const [repository, setRepository] = useState([]) + const [isLoadingRepository, setIsLoadingRepository] = useState(false) + const [repositoryError, setRepositoryError] = useState(null) + const [repositoryLoadRetry, setRepositoryLoadRetry] = useState(0) + const repositoryLoadedRef = useRef(false) + + // Configured devices from Zustand store + const remoteDevice = useMemo(() => { + return project.data.remoteDevices?.find((d) => d.name === deviceName) + }, [project.data.remoteDevices, deviceName]) + + const configuredDevices = useMemo(() => { + return (remoteDevice?.ethercatConfig?.devices ?? []) as unknown as ConfiguredEtherCATDevice[] + }, [remoteDevice]) + + // Collect all IEC addresses used across all remote devices (Modbus + EtherCAT) + const usedAddresses = useMemo(() => { + const addresses = new Set() + const allRemoteDevices = project.data.remoteDevices || [] + + for (const rd of allRemoteDevices) { + if (rd.modbusTcpConfig?.ioGroups) { + for (const group of rd.modbusTcpConfig.ioGroups) { + for (const point of group.ioPoints ?? []) { + addresses.add(point.iecLocation) + } + } + } + if (rd.ethercatConfig?.devices) { + for (const dev of rd.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + addresses.add(mapping.iecLocation) + } + } + } + } + return addresses + }, [project.data.remoteDevices]) + + const masterConfig = useMemo(() => { + return ( + remoteDevice?.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + } + ) + }, [remoteDevice]) + + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(deviceName, { masterConfig, devices }) + }, + [deviceName, projectActions, masterConfig], + ) + + const handleUpdateMasterConfig = useCallback( + (updates: Partial) => { + const newMasterConfig = { ...masterConfig, ...updates } + projectActions.updateEthercatConfig(deviceName, { + masterConfig: newMasterConfig, + devices: configuredDevices, + }) + }, + [deviceName, projectActions, masterConfig, configuredDevices], + ) + + // Network interfaces state + const [interfaces, setInterfaces] = useState([]) + const [selectedInterface, setSelectedInterface] = useState('') + const [isLoadingInterfaces, setIsLoadingInterfaces] = useState(false) + const [interfaceError, setInterfaceError] = useState(null) + + // EtherCAT service status + const [serviceAvailable, setServiceAvailable] = useState(null) + const [serviceMessage, setServiceMessage] = useState('') + + // Scan state + const [scannedDevices, setScannedDevices] = useState([]) + const [isScanning, setIsScanning] = useState(false) + const [scanError, setScanError] = useState(null) + const [scanMessage, setScanMessage] = useState('') + const [scanTimeMs, setScanTimeMs] = useState(null) + + // Discovery selection state + const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + + // Matched devices + const deviceMatches = useMemo(() => { + return matchDevicesToRepository(scannedDevices, repository) + }, [scannedDevices, repository]) + + const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) + + // Check EtherCAT service status + const checkServiceStatus = useCallback(async () => { + if (!isConnectedToRuntime) { + setServiceAvailable(null) + setServiceMessage('') + return + } + + try { + const result = await runtime.getEthercatServiceStatus!() + if (result.success && result.data) { + setServiceAvailable(result.data.available) + setServiceMessage(result.data.message) + } else { + setServiceAvailable(false) + setServiceMessage(result.success ? 'No data' : (result.error ?? 'Failed to check service status')) + } + } catch (error) { + setServiceAvailable(false) + setServiceMessage(String(error)) + } + }, [isConnectedToRuntime]) + + // Fetch network interfaces from runtime + const fetchInterfaces = useCallback(async () => { + if (!isConnectedToRuntime) { + setInterfaces([]) + setInterfaceError('Not connected to runtime') + return + } + + setIsLoadingInterfaces(true) + setInterfaceError(null) + + try { + const result = await runtime.getNetworkInterfaces!() + if (result.success && result.data) { + const fetchedInterfaces = result.data + setInterfaces(fetchedInterfaces) + const names = new Set(fetchedInterfaces.map((i) => i.name)) + if (fetchedInterfaces.length > 0) { + setSelectedInterface((prev) => (prev && names.has(prev) ? prev : fetchedInterfaces[0].name)) + } else { + setSelectedInterface('') + } + } else { + setInterfaces([]) + setInterfaceError(result.success ? 'No data' : (result.error ?? 'Failed to load interfaces')) + } + } catch (error) { + setInterfaces([]) + setInterfaceError(String(error)) + } finally { + setIsLoadingInterfaces(false) + } + }, [isConnectedToRuntime]) + + // Scan for EtherCAT devices + const scanDevices = useCallback(async () => { + if (!isConnectedToRuntime || !selectedInterface) { + setScanError('Please select a network interface') + return + } + + setIsScanning(true) + setScanError(null) + setScanMessage('') + setScannedDevices([]) + setSelectedScannedDevices(new Set()) + setScanTimeMs(null) + + try { + const result = await runtime.scanEthercatDevices!({ + interface: selectedInterface, + timeout_ms: 5000, + }) + + if (result.success && result.data) { + setScannedDevices(result.data.devices) + setScanMessage(result.data.message) + setScanTimeMs(result.data.scan_time_ms) + + if (result.data.status !== 'success') { + setScanError(`Scan completed with status: ${result.data.status}`) + } + } else { + setScanError(result.success ? 'Scan failed' : (result.error ?? 'Scan failed')) + } + } catch (error) { + setScanError(String(error)) + } finally { + setIsScanning(false) + } + }, [isConnectedToRuntime, selectedInterface]) + + // Reset repository loaded flag when project changes + useEffect(() => { + repositoryLoadedRef.current = false + }, [projectPath]) + + // Load ESI repository + useEffect(() => { + let cancelled = false + + const loadRepository = async () => { + if (!projectPath || repositoryLoadedRef.current) return + + setIsLoadingRepository(true) + setRepositoryError(null) + + try { + const result = await esi!.loadRepositoryLight() + if (cancelled) return + + if (result.success && result.items) { + setRepository(result.items) + repositoryLoadedRef.current = true + } else if ('needsMigration' in result && result.needsMigration) { + // One-time migration from v1 to v2 + const migrationResult = await esi!.migrateRepository() + if (cancelled) return + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) + repositoryLoadedRef.current = true + } else { + setRepositoryError(!migrationResult.success ? migrationResult.error : 'Failed to migrate repository') + } + } else if (!result.success) { + setRepositoryError(result.error) + } else { + repositoryLoadedRef.current = true + } + } catch (error) { + if (cancelled) return + console.error('Failed to load ESI repository:', error) + setRepositoryError(String(error)) + } finally { + if (!cancelled) setIsLoadingRepository(false) + } + } + + void loadRepository() + return () => { + cancelled = true + } + }, [projectPath, repositoryLoadRetry]) + + // Check service status and fetch interfaces when runtime connection changes + useEffect(() => { + if (isConnectedToRuntime) { + void checkServiceStatus() + void fetchInterfaces() + } else { + setServiceAvailable(null) + setInterfaces([]) + setScannedDevices([]) + setSelectedInterface('') + } + }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) + + // Initialize ethercatConfig in store if missing + useEffect(() => { + if (remoteDevice && !remoteDevice.ethercatConfig) { + projectActions.updateEthercatConfig(deviceName, { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: [], + }) + } + }, [remoteDevice, deviceName, projectActions]) + + // Discovery handlers + const handleSelectScannedDevice = useCallback((position: number, selected: boolean) => { + setSelectedScannedDevices((prev) => { + const next = new Set(prev) + if (selected) { + next.add(position) + } else { + next.delete(position) + } + return next + }) + }, []) + + const handleSelectAllScanned = useCallback( + (selected: boolean) => { + if (selected) { + const selectable = deviceMatches + .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') + .map((dm) => dm.device.position) + setSelectedScannedDevices(new Set(selectable)) + } else { + setSelectedScannedDevices(new Set()) + } + }, + [deviceMatches], + ) + + const handleAddSelectedFromScan = useCallback(async () => { + const newDevices: ConfiguredEtherCATDevice[] = [] + const existingPositions = new Set(configuredDevices.map((d) => d.position)) + + for (const position of selectedScannedDevices) { + // Skip devices already configured at this position + if (existingPositions.has(position)) continue + const match = deviceMatches.find((dm) => dm.device.position === position) + if (!match || match.matches.length === 0) continue + + // Use the best match (first one, which is sorted by quality) + const bestMatch = match.matches[0] + const repoItem = repository.find((r) => r.id === bestMatch.repositoryItemId) + if (!repoItem) continue + + let enriched = {} + const result = await esi!.loadDeviceFull(bestMatch.repositoryItemId, bestMatch.deviceIndex) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device) + } + + newDevices.push({ + id: uuidv4(), + position: match.device.position, + name: match.device.name, + esiDeviceRef: { + repositoryItemId: bestMatch.repositoryItemId, + deviceIndex: bestMatch.deviceIndex, + }, + vendorId: repoItem.vendor.id, + productCode: bestMatch.esiDevice.type.productCode, + revisionNo: bestMatch.esiDevice.type.revisionNo, + addedFrom: 'scan', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + }) + } + + if (newDevices.length > 0) { + syncDevicesToStore([...configuredDevices, ...newDevices]) + setSelectedScannedDevices(new Set()) + setActiveTab('devices') + } + }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) + + // Device management handlers + const handleAddDeviceFromBrowser = useCallback( + async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + let enriched = {} + const result = await esi!.loadDeviceFull(ref.repositoryItemId, ref.deviceIndex) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device) + } + + const nextPosition = + configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? -1)) + 1 : 0 + + const newDevice: ConfiguredEtherCATDevice = { + id: uuidv4(), + position: nextPosition, + name: device.name, + esiDeviceRef: ref, + vendorId: repoItem.vendor.id, + productCode: device.type.productCode, + revisionNo: device.type.revisionNo, + addedFrom: 'repository', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + } + syncDevicesToStore([...configuredDevices, newDevice]) + }, + [configuredDevices, syncDevicesToStore, projectPath], + ) + + const handleRemoveDevice = useCallback( + (deviceId: string) => { + syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateDevice = useCallback( + (deviceId: string, config: EtherCATSlaveConfig) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateChannelMappings = useCallback( + (deviceId: string, channelMappings: EtherCATChannelMapping[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleEnrichDevice = useCallback( + (deviceId: string, data: Partial) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleUpdateSdoConfigurations = useCallback( + (deviceId: string, sdoConfigurations: SDOConfigurationEntry[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) + }, + [configuredDevices, syncDevicesToStore], + ) + + const handleRetryRepository = useCallback(() => { + setRepositoryError(null) + repositoryLoadedRef.current = false + setRepositoryLoadRetry((c) => c + 1) + }, []) + + return ( +
+ {/* Header */} +
+

EtherCAT Device: {deviceName}

+

Protocol: EtherCAT

+
+ + {/* Tabs */} + setActiveTab(v as EditorTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + 0 ? ( + + {scannedDevices.length} + + ) : undefined + } + /> + 0 ? ( + + {configuredDevices.length} + + ) : undefined + } + /> + + + {/* Global Settings Tab */} + + void fetchInterfaces()} + /> + + + {/* Diagnostics Tab */} + + void fetchInterfaces()} + isScanning={isScanning} + scanError={scanError} + scanTimeMs={scanTimeMs} + scanMessage={scanMessage} + scannedDevices={scannedDevices} + onScan={() => void scanDevices()} + deviceMatches={deviceMatches} + matchCounts={matchCounts} + selectedScannedDevices={selectedScannedDevices} + onSelectScannedDevice={handleSelectScannedDevice} + onSelectAllScanned={handleSelectAllScanned} + onAddSelectedFromScan={() => void handleAddSelectedFromScan()} + /> + + + {/* Devices Tab */} + + + + +
+ ) +} + +export { EtherCATEditor } diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts index b2a611079..05e955fc8 100644 --- a/src/frontend/hooks/use-device-configuration.ts +++ b/src/frontend/hooks/use-device-configuration.ts @@ -11,11 +11,11 @@ import type { } from '@root/types/ethercat/esi-types' import { useCallback, useEffect, useRef, useState } from 'react' -import type { EsiPort } from '../../middleware/shared/ports/esi-port' +import { useEsi } from '../../middleware/shared/providers/platform-context' type UseDeviceConfigurationParams = { device: ConfiguredEtherCATDevice - esiPort: EsiPort + projectPath: string externalAddresses: Set onUpdateDevice: (config: EtherCATSlaveConfig) => void onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void @@ -34,13 +34,14 @@ type UseDeviceConfigurationResult = { export function useDeviceConfiguration({ device, - esiPort, + projectPath, externalAddresses, onUpdateDevice, onUpdateChannelMappings, onEnrichDevice, enabled = true, }: UseDeviceConfigurationParams): UseDeviceConfigurationResult { + const esiPort = useEsi() const [channels, setChannels] = useState([]) const [coeObjects, setCoeObjects] = useState(undefined) const [isLoadingChannels, setIsLoadingChannels] = useState(false) @@ -63,7 +64,7 @@ export function useDeviceConfiguration({ setChannelLoadError(null) try { - const result = await esiPort.loadDeviceFull( + const result = await esiPort!.loadDeviceFull( device.esiDeviceRef.repositoryItemId, device.esiDeviceRef.deviceIndex, ) @@ -101,7 +102,7 @@ export function useDeviceConfiguration({ } void loadFullDevice() - }, [enabled, esiPort, device.esiDeviceRef.repositoryItemId, device.esiDeviceRef.deviceIndex]) + }, [enabled, esiPort, projectPath, device.esiDeviceRef.repositoryItemId, device.esiDeviceRef.deviceIndex]) const handleAliasChange = useCallback( (channelId: string, alias: string) => { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 0ae52fd0d..aa60e31db 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -7,6 +7,7 @@ import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' 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 { MonacoEditor } from '../components/_features/[workspace]/editor/monaco' @@ -334,7 +335,12 @@ const WorkspaceScreen = () => { <> {editor['type'] === 'plc-resource' && } {editor['type'] === 'plc-device' && } - {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-server' && editor.meta.protocol === 'modbus-tcp' && ( )} From 9b27556589666bd87a62ccd4af6b72b4755b1857 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 07:46:22 -0400 Subject: [PATCH 05/30] feat: integrate EtherCAT config generation into v4 compilation Import generateEthercatConfig from shared backend and wire it into the Runtime v4 compilation pipeline. Generates conf/ethercat.json alongside existing Modbus, S7Comm, and OPC-UA configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/compiler/compiler-module.ts | 104 +++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 9c19d9a5a..0c05b7aeb 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { promisify } from 'node:util' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import { generateEthercatConfig } from '@root/backend/shared/ethercat/generate-ethercat-config' import { type CppPouData as CppPouDataCode, generateCBlocksCode, @@ -33,6 +34,7 @@ import JSZip from 'jszip' import { CreateXMLFile } from '../utils' import type { ArduinoCoreControl, HalsFile } from './types' import { FormatMacAddress } from './utils/formatters' +import { copyPluginSource, generateVppPluginConfig, resolveVppDevice } from './utils/vpp-plugin-export' interface MethodsResult { success: boolean @@ -183,7 +185,27 @@ class CompilerModule { async #getBoardRuntime(board: string) { const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) - return halsFileContent[board]['compiler'] + if (halsFileContent[board]) { + return halsFileContent[board]['compiler'] + } + + // Fallback: check installed VPP packages for the board + try { + const installed = packageManager.listInstalled() + for (const pkg of installed) { + const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + for (const device of manifest.devices) { + if (device.name === board) { + return device.target.type === 'runtime-v4' ? 'openplc-compiler' : 'arduino-cli' + } + } + } + } catch { + // ignore package manager errors + } + + throw new Error(`Board "${board}" not found in hals.json or installed VPP packages`) } #executeXml2st(args: string[]) { @@ -1328,6 +1350,65 @@ class CompilerModule { } } + async handleGenerateEthercatConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + 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') + } + } + + /** + * Export VPP plugin source code and configuration for runtime-v4 VPP boards. + * Copies the plugin source directory into the build folder and generates + * the plugin config JSON from the project's vendor screen data. + * Skips silently if the board is not a VPP board. + */ + async handleVppPluginExport( + sourceTargetFolderPath: string, + projectPath: string, + boardTarget: string, + handleOutputData: HandleOutputDataCallback, + ): Promise { + const vppInfo = resolveVppDevice(boardTarget) + if (!vppInfo) return // Not a VPP board + + const { device, packagePath } = vppInfo + + if (device.hal.type !== 'runtime-v4-plugin' || !device.hal.pluginEntry) return + + handleOutputData('Exporting VPP plugin source code...', 'info') + + // Copy plugin source files and compute checksum + const checksum = await copyPluginSource(packagePath, device.hal.pluginEntry, sourceTargetFolderPath) + handleOutputData(`VPP plugin source exported (checksum: ${checksum.substring(0, 12)}...)`, 'info') + + // Read device configuration to get vendor screen data + const devicesConfigPath = join(projectPath, 'devices', 'configuration.json') + const deviceConfig = + await CompilerModule.readJSONFile(devicesConfigPath) + + // Generate plugin config from vendor screen data + const moduleDefinitions = device.moduleSystem?.modules ?? [] + const pluginConfig = generateVppPluginConfig(deviceConfig, moduleDefinitions) + + // Write plugin config to conf directory + const confDir = join(sourceTargetFolderPath, 'conf') + await mkdir(confDir, { recursive: true }) + await writeFile(join(confDir, 'synergy.json'), JSON.stringify(pluginConfig, null, 2), 'utf-8') + handleOutputData('Generated conf/synergy.json from backplane configuration', 'info') + } + async embedCBlocksInProgramSt( sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, @@ -1429,7 +1510,9 @@ class CompilerModule { }) // --- Check for unsupported features on non-v4 targets --- - const isRuntimeV4 = boardTarget === 'OpenPLC Runtime v4' + // VPP boards with runtime-v4 target type use openplc-compiler and are also v4-capable + const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' + const isRuntimeV4 = boardRuntime === 'openplc-compiler' && !isRuntimeV3 const hasServers = projectData.servers && projectData.servers.length > 0 const hasRemoteDevices = projectData.remoteDevices && projectData.remoteDevices.length > 0 @@ -1714,8 +1797,6 @@ class CompilerModule { } try { - const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' - let fileBuffer: Buffer let filename: string let contentType: string @@ -1762,6 +1843,21 @@ 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 }) + }) + + // Export VPP plugin source and config if this is a VPP board + await this.handleVppPluginExport( + sourceTargetFolderPath, + normalizedProjectPath, + boardTarget, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + ) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compressing source files for OpenPLC Runtime v4...', From f1a15b9be81b348ea955f063a6b42c97fc294709 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 12:23:34 -0400 Subject: [PATCH 06/30] fix: remove VPP code remnants and fix corrupted import in renderer Remove VPP-specific imports, methods, and compilation export that leaked into the EtherCAT branch during cherry-pick conflict resolution. Fix corrupted escaped quotes in renderer.ts line 1. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/compiler/compiler-module.ts | 52 ------------------- src/main/modules/ipc/renderer.ts | 2 +- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 0c05b7aeb..9a254f1df 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -34,7 +34,6 @@ import JSZip from 'jszip' import { CreateXMLFile } from '../utils' import type { ArduinoCoreControl, HalsFile } from './types' import { FormatMacAddress } from './utils/formatters' -import { copyPluginSource, generateVppPluginConfig, resolveVppDevice } from './utils/vpp-plugin-export' interface MethodsResult { success: boolean @@ -1368,47 +1367,6 @@ class CompilerModule { } } - /** - * Export VPP plugin source code and configuration for runtime-v4 VPP boards. - * Copies the plugin source directory into the build folder and generates - * the plugin config JSON from the project's vendor screen data. - * Skips silently if the board is not a VPP board. - */ - async handleVppPluginExport( - sourceTargetFolderPath: string, - projectPath: string, - boardTarget: string, - handleOutputData: HandleOutputDataCallback, - ): Promise { - const vppInfo = resolveVppDevice(boardTarget) - if (!vppInfo) return // Not a VPP board - - const { device, packagePath } = vppInfo - - if (device.hal.type !== 'runtime-v4-plugin' || !device.hal.pluginEntry) return - - handleOutputData('Exporting VPP plugin source code...', 'info') - - // Copy plugin source files and compute checksum - const checksum = await copyPluginSource(packagePath, device.hal.pluginEntry, sourceTargetFolderPath) - handleOutputData(`VPP plugin source exported (checksum: ${checksum.substring(0, 12)}...)`, 'info') - - // Read device configuration to get vendor screen data - const devicesConfigPath = join(projectPath, 'devices', 'configuration.json') - const deviceConfig = - await CompilerModule.readJSONFile(devicesConfigPath) - - // Generate plugin config from vendor screen data - const moduleDefinitions = device.moduleSystem?.modules ?? [] - const pluginConfig = generateVppPluginConfig(deviceConfig, moduleDefinitions) - - // Write plugin config to conf directory - const confDir = join(sourceTargetFolderPath, 'conf') - await mkdir(confDir, { recursive: true }) - await writeFile(join(confDir, 'synergy.json'), JSON.stringify(pluginConfig, null, 2), 'utf-8') - handleOutputData('Generated conf/synergy.json from backplane configuration', 'info') - } - async embedCBlocksInProgramSt( sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, @@ -1848,16 +1806,6 @@ class CompilerModule { _mainProcessPort.postMessage({ logLevel, message: data }) }) - // Export VPP plugin source and config if this is a VPP board - await this.handleVppPluginExport( - sourceTargetFolderPath, - normalizedProjectPath, - boardTarget, - (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - ) - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compressing source files for OpenPLC Runtime v4...', diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 1c1ed3f0c..78a906290 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,4 @@ -import type { PLCProjectData } from \'@root/middleware/shared/ports/types\' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, From 6213a2f6ba393f10bc6578835a3b7be393cb0878 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 12:36:56 -0400 Subject: [PATCH 07/30] feat: auto-create system task when EtherCAT device is added Port feat/ethercat-task-selection changes to the refactored architecture: - Add isSystemTask/associatedDevice fields to PLCTask schema - Add enabled field to EtherCATMasterConfig schema - Create ethercat-task-helpers in shared backend (task naming, cycle time conversion) - Auto-create/delete/rename system tasks when EtherCAT devices are managed in the project slice - Sync cycle time changes to system task interval - Preserve system tasks during task list updates - Prevent editing/deleting/reordering system tasks in task table and task editor UI - Add task_name and enabled filter to EtherCAT config generator - Add Enable Plugin toggle to global settings tab Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shared/ethercat/ethercat-task-helpers.ts | 19 +++++ .../ethercat/generate-ethercat-config.ts | 24 ++++-- src/backend/shared/ethercat/index.ts | 1 + .../components/global-settings-tab.tsx | 25 ++++++ .../_molecules/task-table/index.tsx | 8 +- .../_organisms/task-editor/index.tsx | 14 +++- src/frontend/store/slices/project/slice.ts | 83 ++++++++++++++++++- src/middleware/shared/ports/types.ts | 2 + src/types/PLC/open-plc.ts | 3 + src/types/PLC/units/task.ts | 2 + 10 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 src/backend/shared/ethercat/ethercat-task-helpers.ts diff --git a/src/backend/shared/ethercat/ethercat-task-helpers.ts b/src/backend/shared/ethercat/ethercat-task-helpers.ts new file mode 100644 index 000000000..abc42cc5e --- /dev/null +++ b/src/backend/shared/ethercat/ethercat-task-helpers.ts @@ -0,0 +1,19 @@ +/** + * Generates a system task name from an EtherCAT device name. + * Example: "Master1" -> "EtherCAT_Master1" + */ +export function ethercatTaskName(deviceName: string): string { + return `EtherCAT_${deviceName}` +} + +/** + * Converts a cycle time in microseconds to an IEC 61131-3 time interval string. + * Examples: 1000 -> "T#1ms", 500 -> "T#500us", 20000 -> "T#20ms" + */ +export function cycleTimeUsToIecInterval(cycleTimeUs: number): string { + if (cycleTimeUs <= 0) return 'T#1ms' + if (cycleTimeUs % 1000 === 0) { + return `T#${cycleTimeUs / 1000}ms` + } + return `T#${cycleTimeUs}us` +} diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts index 4341ce39b..0b99e9cdd 100644 --- a/src/backend/shared/ethercat/generate-ethercat-config.ts +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -6,6 +6,8 @@ import type { } from '@root/types/ethercat/esi-types' import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' +import { ethercatTaskName } from './ethercat-task-helpers' + // Runtime JSON interfaces (snake_case for plugin consumption) interface RuntimePdoEntry { @@ -92,6 +94,8 @@ interface RuntimeMaster { interface: string cycle_time_us: number watchdog_timeout_cycles: number + task_name?: string + task_cycle_time_us?: number } interface RuntimeDiagnostics { @@ -282,7 +286,8 @@ export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefi } const ethercatRemoteDevices = remoteDevices.filter( - (device) => device.protocol === 'ethercat' && device.ethercatConfig, + (device) => + device.protocol === 'ethercat' && device.ethercatConfig && (device.ethercatConfig.masterConfig?.enabled ?? true), ) if (ethercatRemoteDevices.length === 0) { @@ -298,15 +303,22 @@ export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefi if (slaves.length === 0) continue + const cycleTimeUs = remoteDevice.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskName = ethercatTaskName(remoteDevice.name) + + const master: RuntimeMaster = { + interface: remoteDevice.ethercatConfig?.masterConfig?.networkInterface || 'eth0', + cycle_time_us: cycleTimeUs, + watchdog_timeout_cycles: remoteDevice.ethercatConfig?.masterConfig?.watchdogTimeoutCycles ?? 3, + task_name: taskName, + task_cycle_time_us: cycleTimeUs, + } + rootEntries.push({ name: remoteDevice.name || 'ethercat_master', protocol: 'ETHERCAT', config: { - master: { - interface: remoteDevice.ethercatConfig?.masterConfig?.networkInterface || 'eth0', - cycle_time_us: remoteDevice.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000, - watchdog_timeout_cycles: remoteDevice.ethercatConfig?.masterConfig?.watchdogTimeoutCycles ?? 3, - }, + master, slaves, diagnostics: { log_connections: true, diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts index 69b2d8d97..3d72dd8ea 100644 --- a/src/backend/shared/ethercat/index.ts +++ b/src/backend/shared/ethercat/index.ts @@ -1,4 +1,5 @@ export { createDefaultSlaveConfig, DEFAULT_SLAVE_CONFIG } from './device-config-defaults' +export { cycleTimeUsToIecInterval, ethercatTaskName } from './ethercat-task-helpers' export { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from './device-matcher' export { buildChannelInfo, deriveSlaveType, persistPdos } from './enrich-device-data' export { esiTypeToIecType, pdoToChannels } from './esi-parser' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx index f96d53605..c9c801f2a 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -27,6 +27,31 @@ const GlobalSettingsTab = ({ }: GlobalSettingsTabProps) => { return (
+ {/* Enable Plugin */} +
+

Enable Plugin

+
+
+ {/* Network Interface */}

diff --git a/src/frontend/components/_molecules/task-table/index.tsx b/src/frontend/components/_molecules/task-table/index.tsx index 23237074f..c9e16e070 100644 --- a/src/frontend/components/_molecules/task-table/index.tsx +++ b/src/frontend/components/_molecules/task-table/index.tsx @@ -15,7 +15,7 @@ const columns = [ size: 150, minSize: 100, maxSize: 150, - cell: EditableNameCell, + cell: (props) => EditableNameCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('triggering', { header: 'Triggering', @@ -23,7 +23,7 @@ const columns = [ size: 468, minSize: 150, maxSize: 468, - cell: SelectableTriggerCell, + cell: (props) => SelectableTriggerCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('interval', { header: 'Interval', @@ -31,7 +31,7 @@ const columns = [ minSize: 150, maxSize: 468, enableResizing: true, - cell: SelectableIntervalCell, + cell: (props) => SelectableIntervalCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('priority', { header: 'Priority', @@ -39,7 +39,7 @@ const columns = [ size: 468, minSize: 150, maxSize: 468, - cell: EditablePriorityCell, + cell: (props) => EditablePriorityCell({ ...props, editable: !props.row.original.isSystemTask }), }), ] diff --git a/src/frontend/components/_organisms/task-editor/index.tsx b/src/frontend/components/_organisms/task-editor/index.tsx index c58ab8c1b..8e86af03d 100644 --- a/src/frontend/components/_organisms/task-editor/index.tsx +++ b/src/frontend/components/_organisms/task-editor/index.tsx @@ -249,6 +249,9 @@ const TaskEditor = () => { } } + const selectedRowIndex = 'selectedRow' in editorTasks ? parseInt(editorTasks.selectedRow) : -1 + const isSelectedTaskSystem = selectedRowIndex >= 0 && taskData[selectedRowIndex]?.isSystemTask === true + const isInstanceInCode = 'instance' in editor && editor.instance?.display === 'code' if (isInstanceInCode) return null @@ -279,7 +282,10 @@ const TaskEditor = () => { { ariaLabel: 'Remove Tasks table row button', onClick: handleDeleteTask, - disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED, + disabled: + isDebuggerVisible || + parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || + isSelectedTaskSystem, icon: , id: 'remove-task-button', }, @@ -289,7 +295,8 @@ const TaskEditor = () => { disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || - parseInt(editorTasks.selectedRow) === 0, + parseInt(editorTasks.selectedRow) === 0 || + isSelectedTaskSystem, icon: , id: 'move-task-up-button', }, @@ -299,7 +306,8 @@ const TaskEditor = () => { disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || - parseInt(editorTasks.selectedRow) === taskData.length - 1, + parseInt(editorTasks.selectedRow) === taskData.length - 1 || + isSelectedTaskSystem, icon: , id: 'move-task-down-button', }, diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 8cb736d66..489d8b6f1 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -1,3 +1,4 @@ +import { cycleTimeUsToIecInterval, ethercatTaskName } from '@root/backend/shared/ethercat/ethercat-task-helpers' import type { EthercatConfig } from '@root/types/PLC/open-plc' import { produce } from 'immer' import { StateCreator } from 'zustand' @@ -192,6 +193,25 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { slice.project = state + + // Migration: ensure system tasks exist for all EtherCAT devices + const ethercatDevices = (slice.project.data.remoteDevices ?? []).filter((d) => d.protocol === 'ethercat') + for (const device of ethercatDevices) { + const existingTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === device.name, + ) + if (!existingTask) { + const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + slice.project.data.configurations.resource.tasks.unshift({ + name: ethercatTaskName(device.name), + triggering: 'Cyclic' as const, + interval: cycleTimeUsToIecInterval(cycleTimeUs), + priority: 0, + isSystemTask: true, + associatedDevice: device.name, + }) + } + } }), ) }, @@ -599,26 +619,34 @@ const createProjectSlice: StateCreator = (se setTasks: ({ tasks }) => { setState( produce((slice: ProjectSlice) => { - slice.project.data.configurations.resource.tasks = tasks + // Preserve system tasks (auto-created for EtherCAT devices) + const systemTasks = slice.project.data.configurations.resource.tasks.filter((t) => t.isSystemTask) + slice.project.data.configurations.resource.tasks = [...systemTasks, ...tasks.filter((t) => !t.isSystemTask)] }), ) return ok() }, updateTask: (dto) => { + let response = ok() setState( produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks + if (tasks[dto.rowId]?.isSystemTask) { + response = { ok: false, title: 'System task', message: 'System tasks cannot be modified' } + return + } if (dto.rowId >= 0 && dto.rowId < tasks.length) { tasks[dto.rowId] = { ...tasks[dto.rowId], ...dto.data } } }), ) - return ok() + return response }, deleteTask: ({ rowId }) => { setState( produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks + if (tasks[rowId]?.isSystemTask) return if (rowId >= 0 && rowId < tasks.length) tasks.splice(rowId, 1) }), ) @@ -628,6 +656,7 @@ const createProjectSlice: StateCreator = (se produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks if (rowId < 0 || rowId >= tasks.length) return + if (tasks[rowId].isSystemTask) return const [item] = tasks.splice(rowId, 1) tasks.splice(newIndex, 0, item) }), @@ -1012,6 +1041,19 @@ const createProjectSlice: StateCreator = (se device.modbusTcpConfig = { host: '127.0.0.1', port: 502, slaveId: 1, timeout: 1000, ioGroups: [] } } slice.project.data.remoteDevices.push(device) + + // Auto-create system task for EtherCAT devices + if (device.protocol === 'ethercat') { + const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + slice.project.data.configurations.resource.tasks.unshift({ + name: ethercatTaskName(device.name), + triggering: 'Cyclic' as const, + interval: cycleTimeUsToIecInterval(cycleTimeUs), + priority: 0, + isSystemTask: true, + associatedDevice: device.name, + }) + } }), ) return ok() @@ -1020,8 +1062,19 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { if (!slice.project.data.remoteDevices) return + const deviceToDelete = slice.project.data.remoteDevices.find((d) => d.name === name) slice.pendingDeletions.push(`devices/remote/${name}.json`) slice.project.data.remoteDevices = slice.project.data.remoteDevices.filter((d) => d.name !== name) + + // Remove associated system task for EtherCAT devices + if (deviceToDelete?.protocol === 'ethercat') { + const taskIndex = slice.project.data.configurations.resource.tasks.findIndex( + (t) => t.isSystemTask && t.associatedDevice === name, + ) + if (taskIndex !== -1) { + slice.project.data.configurations.resource.tasks.splice(taskIndex, 1) + } + } }), ) return ok() @@ -1034,7 +1087,20 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { const device = slice.project.data.remoteDevices?.find((d) => d.name === name) - if (device) device.name = newName + if (!device) return + + // Update associated system task name for EtherCAT devices + if (device.protocol === 'ethercat') { + const systemTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === name, + ) + if (systemTask) { + systemTask.name = ethercatTaskName(newName) + systemTask.associatedDevice = newName + } + } + + device.name = newName }), ) return ok() @@ -1134,6 +1200,17 @@ const createProjectSlice: StateCreator = (se return } device.ethercatConfig = ethercatConfig as typeof device.ethercatConfig + + // Sync cycle time to the associated system task interval + const cycleTimeUs = (ethercatConfig as EthercatConfig).masterConfig?.cycleTimeUs + if (cycleTimeUs !== undefined) { + const systemTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === deviceName, + ) + if (systemTask) { + systemTask.interval = cycleTimeUsToIecInterval(cycleTimeUs) + } + } }), ) return response diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index a63da68f5..985edc8e1 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -57,6 +57,8 @@ export interface PLCTask { triggering: 'Cyclic' | 'Interrupt' interval: string priority: number + isSystemTask?: boolean + associatedDevice?: string } export interface PLCInstance { diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index 9449aa75b..434dcb309 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -166,6 +166,8 @@ const PLCTaskSchema = z.object({ triggering: z.enum(['Cyclic', 'Interrupt']), interval: z.string(), // TODO: Must have a regex validation for this. Probably a new modal must be created to handle this. priority: z.number(), // TODO: implement this validation. This must be a positive integer from 0 to 100 + isSystemTask: z.boolean().optional(), + associatedDevice: z.string().optional(), }) type PLCTask = z.infer @@ -721,6 +723,7 @@ const ConfiguredEtherCATDeviceSchema = z.object({ }) const EtherCATMasterConfigSchema = z.object({ + enabled: z.boolean().optional(), networkInterface: z.string(), cycleTimeUs: z.number().int().min(100).max(100000), watchdogTimeoutCycles: z.number().int().min(1).max(100).optional(), diff --git a/src/types/PLC/units/task.ts b/src/types/PLC/units/task.ts index 98ffdac2a..1473eda03 100644 --- a/src/types/PLC/units/task.ts +++ b/src/types/PLC/units/task.ts @@ -6,6 +6,8 @@ const PLCTaskSchema = z.object({ triggering: z.enum(['CYCLIC', 'INTERRUPT']), interval: z.string(), // TODO: Must have a regex validation for this. Probably a new modal must be created to handle this. priority: z.number(), // TODO: implement this validation. This must be a positive integer from 0 to 100 + isSystemTask: z.boolean().optional(), + associatedDevice: z.string().optional(), }) type PLCTask = z.infer From 67d0dd11f62bf287a39202c029269afc07b2532a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 13:25:07 -0400 Subject: [PATCH 08/30] fix: remove circular store import from editor-platform Replace direct openPLCStoreBase import in editor-platform.ts with a setProjectPath() setter pattern matching the existing setRuntimeIpAddress approach. The store import was causing a circular dependency that resulted in a blank screen on startup. App.tsx now syncs the project path to the platform adapter via useEffect. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 8 +++++++- src/middleware/editor-platform.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3068e21b4..5efc70709 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,7 @@ import { import { StartScreen } from './frontend/screens/start-screen' import { WorkspaceScreen } from './frontend/screens/workspace-screen' import { openPLCStoreBase, useOpenPLCStore } from './frontend/store' -import { editorPorts, setRuntimeIpAddress } from './middleware/editor-platform' +import { editorPorts, setProjectPath, setRuntimeIpAddress } from './middleware/editor-platform' import { PlatformProvider } from './middleware/shared/providers' // Initialize system libraries at module load time (before first render) @@ -66,6 +66,12 @@ export default function App() { setRuntimeIpAddress(runtimeIpAddress) }, [runtimeIpAddress]) + // Sync project path to the platform adapter so the ESI port can access it + const projectPath = useOpenPLCStore((state) => state.project.meta.path) + useEffect(() => { + setProjectPath(projectPath) + }, [projectPath]) + return ( {path === '' ? : } diff --git a/src/middleware/editor-platform.ts b/src/middleware/editor-platform.ts index d60ebafc1..04f8efa39 100644 --- a/src/middleware/editor-platform.ts +++ b/src/middleware/editor-platform.ts @@ -13,7 +13,6 @@ * */ -import { openPLCStoreBase } from '../frontend/store' import { createEditorAcceleratorAdapter } from './adapters/editor/accelerator-adapter' import { createEditorCompilerAdapter } from './adapters/editor/compiler-adapter' import { createEditorDebuggerAdapter } from './adapters/editor/debugger-adapter' @@ -34,11 +33,16 @@ import type { PlatformPorts } from './shared/providers/types' * Set by the store/UI when the user configures or connects to a device. */ let _runtimeIpAddress = '' +let _projectPath = '' export function setRuntimeIpAddress(ip: string): void { _runtimeIpAddress = ip } +export function setProjectPath(path: string): void { + _projectPath = path +} + /** * Editor platform ports — all port interfaces wired to Electron IPC bridge. */ @@ -54,6 +58,6 @@ export const editorPorts: PlatformPorts = { window: createEditorWindowAdapter(), accelerator: createEditorAcceleratorAdapter(), theme: createEditorThemeAdapter(), - esi: createEditorEsiAdapter(() => openPLCStoreBase.getState().project.meta.path), + esi: createEditorEsiAdapter(() => _projectPath), capabilities: { ...EDITOR_CAPABILITIES, isDevMode: process.env.NODE_ENV === 'development' }, } From e3d500e4aea2d9bdd57fefffbf89e2e4a26c72cf Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 14:48:15 -0400 Subject: [PATCH 09/30] feat: port remaining ethercat-task-selection commits (13 commits) Port all additional work from feat/ethercat-task-selection including: - Restructured EtherCAT editor with tab layout (Network, Devices, Repository, Diagnostics, Advanced) - New EtherCATDeviceEditor for individual slave device editing - Side-by-side scan/configured tables with device tree navigation - Offline device management and improved network interface handling - Standardized device names and hex formatting - plc-ethercat-device editor model and tab type for slave devices - ethercatDeviceActions in shared slice (delete/rename slaves) - Enable plugin filter in config generator - Default task priority changed from 0 to 1 - Error propagation fix in IPC POST request handler Note: ProjectTreeExpandableLeaf (device tree in explorer) deferred to follow-up due to type system differences between architectures. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ethercat-architecture.md | 565 ++++++++++++++++++ .../editor/compiler/compiler-module.ts | 12 +- .../ethercat/generate-ethercat-config.ts | 2 +- .../ethercat/components/advanced-tab.tsx | 94 +++ .../ethercat/components/devices-tab.tsx | 223 +++---- .../ethercat/components/diagnostics-tab.tsx | 15 +- .../components/discovered-device-table.tsx | 95 +-- .../components/interface-selector.tsx | 242 ++++++-- .../ethercat/components/repository-tab.tsx | 47 ++ .../components/runtime-status-panel.tsx | 55 +- .../ethercat/components/scan-bus-tab.tsx | 276 +++++++++ .../ethercat/ethercat-device-editor.tsx | 356 +++++++++++ .../editor/device/ethercat/index.tsx | 248 ++++---- .../hooks/use-device-configuration.ts | 21 +- src/frontend/screens/workspace-screen.tsx | 3 +- src/frontend/store/slices/editor/types.ts | 8 + src/frontend/store/slices/project/slice.ts | 4 +- src/frontend/store/slices/shared/slice.ts | 57 +- src/frontend/store/slices/shared/types.ts | 6 + src/frontend/store/slices/shared/utils.ts | 7 + src/frontend/store/slices/tabs/types.ts | 1 + src/frontend/store/slices/tabs/utils.ts | 8 + src/frontend/store/slices/workspace/types.ts | 1 + .../parse-resource-string-to-configuration.ts | 2 +- src/main/modules/ipc/main.ts | 8 +- src/types/ethercat/index.ts | 28 +- 26 files changed, 1925 insertions(+), 459 deletions(-) create mode 100644 docs/ethercat-architecture.md create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx diff --git a/docs/ethercat-architecture.md b/docs/ethercat-architecture.md new file mode 100644 index 000000000..e9e66e4e2 --- /dev/null +++ b/docs/ethercat-architecture.md @@ -0,0 +1,565 @@ +# EtherCAT Architecture — OpenPLC Editor + +Complete architectural reference of the EtherCAT functionality, from device creation to runtime JSON generation. + +--- + +## 1. Overview + +The EtherCAT system spans both Electron processes: + +``` +Main Process (Node.js) Renderer Process (React) +├── ESI Service (parse, store, query) ├── Zustand Store (project state) +├── IPC Handlers (scan, status, ESI) ├── EtherCAT Editor (bus-level UI) +├── Compiler Module (JSON generation) ├── Device Editor (per-slave UI) +└── Runtime HTTP Client (scan/status) └── Utilities (matching, config gen) +``` + +**Key data flow:** +``` +ESI XML upload → Parse (main) → Repository (disk) + ↓ +Network scan → Match against repo → Add to project → Configure → Generate JSON → Runtime +``` + +--- + +## 2. Type System + +### Core Types (`src/types/ethercat/esi-types.ts`) + +| Type | Purpose | +|------|---------| +| `ESIDevice` | Full parsed ESI device (PDOs, SyncManagers, CoE, FMMUs) | +| `ESIDeviceSummary` | Lightweight summary for repository listing (channel counts, I/O bytes) | +| `ESIChannel` | UI-friendly flattened PDO entry (name, direction, bitLen, offset) | +| `ConfiguredEtherCATDevice` | A device added to the project (config, mappings, persisted PDOs) | +| `EtherCATSlaveConfig` | Per-slave settings (addressing, timeouts, watchdog, DC) | +| `PersistedChannelInfo` / `PersistedPdo` | Runtime-serializable channel/PDO metadata | +| `SDOConfigurationEntry` | CoE SDO startup parameter | +| `ESIRepositoryItemLight` | Repository entry with vendor info and device summaries | +| `ESIDeviceRef` | Pointer to a device in the repository (`repositoryItemId` + `deviceIndex`) | +| `ScannedDeviceMatch` | A scanned device paired with its ESI matches (exact/partial/none) | + +### Discovery Types (`src/types/ethercat/index.ts`) + +| Type | Purpose | +|------|---------| +| `EtherCATDevice` | Network-discovered device (position, vendor_id, product_code, state) | +| `EtherCATScanResponse` | Scan result (status, devices[], scan_time_ms) | +| `NetworkInterface` | Adapter for scanning (name, description) | +| `EtherCATRuntimeStatusResponse` | Runtime state machine (masters/slaves with states, WKC) | + +### Project Persistence (`src/types/PLC/open-plc.ts`) + +```typescript +PLCRemoteDevice { + name: string + protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' + ethercatConfig?: EthercatConfig +} + +EthercatConfig { + masterConfig?: EtherCATMasterConfig // interface, cycleTimeUs, watchdogTimeoutCycles + devices: ConfiguredEtherCATDevice[] +} +``` + +Stored in `project.json` under `data.remoteDevices[]`. + +--- + +## 3. ESI Repository System + +### Storage Layout + +``` +/devices/esi/ +├── repository.json # v2 index with device summaries +├── .xml # Individual ESI XML files +├── .xml +└── ... +``` + +### ESI Service (`src/main/services/esi-service/index.ts`) + +Runs in the **main process**. Key methods: + +| Method | Purpose | +|--------|---------| +| `parseAndSaveFile(projectPath, filename, content)` | Parse XML, save with UUID, append to v2 index | +| `loadRepositoryLight(projectPath)` | Fast load from v2 index (pre-computed summaries) | +| `loadDeviceFull(projectPath, itemId, deviceIndex)` | On-demand full parse of a specific device | +| `deleteRepositoryItemV2(projectPath, itemId)` | Remove XML + update index | +| `clearRepository(projectPath)` | Delete all ESI files and index | +| `migrateRepositoryToV2(projectPath)` | Convert v1 (metadata-only) → v2 (with summaries) | + +### ESI Parser (`src/main/services/esi-service/esi-parser-main.ts`) + +Two parsing modes: + +1. **Light** — `parseESILight(xmlString, filename?)` → `ESIDeviceSummary[]` + - Counts PDO entries without building full structures + - Returns: `inputChannelCount`, `outputChannelCount`, `totalInputBytes`, `totalOutputBytes` + - Used for repository listing + +2. **Full** — `parseESIDeviceFull(xmlString, deviceIndex)` → `ESIDevice` + - On-demand when configuring a specific device + - Returns complete: FMMUs, SyncManagers, RxPDOs, TxPDOs, CoE Objects + +Parser details: +- Uses `fast-xml-parser` +- Handles localized text (prefers English LcId 1033) +- Normalizes hex formats (`#x`, `0x`) +- CoE Dictionary: DataTypes + Objects with PDO mapping metadata + +### IPC Bridge for ESI + +``` +Renderer Main +window.bridge.esiParseAndSaveFile() → handleESIParseAndSaveFile() +window.bridge.esiLoadRepositoryLight() → handleESILoadRepositoryLight() +window.bridge.esiLoadDeviceFull() → handleESILoadDeviceFull() +window.bridge.esiDeleteXmlFile() → handleESIDeleteXmlFile() +window.bridge.esiClearRepository() → handleESIClearRepository() +window.bridge.esiMigrateRepository() → handleESIMigrateRepository() +``` + +Defined in `src/main/modules/ipc/main.ts` (handlers) and `src/main/modules/ipc/renderer.ts` (invocations). + +--- + +## 4. Remote Device (Bus) Creation + +### Store Action (`src/renderer/store/slices/project/slice.ts`) + +`createRemoteDevice(device: PLCRemoteDevice)`: +1. Validates name doesn't conflict with POUs/datatypes +2. Pushes to `project.data.remoteDevices[]` +3. **Auto-creates a system task** for EtherCAT: + +```typescript +{ + name: "EtherCAT_", // ethercatTaskName() + triggering: 'Cyclic', + interval: "T#1ms", // cycleTimeUsToIecInterval(1000) + priority: 1, + isSystemTask: true, + associatedDevice: deviceName, +} +``` + +Related actions: +- `deleteRemoteDevice(name)` — removes device + associated system task +- `updateRemoteDeviceName(oldName, newName)` — renames both device and task +- `updateEthercatConfig(deviceName, ethercatConfig)` — updates config and **syncs cycle time to task interval** + +### Task Helpers (`src/utils/ethercat/ethercat-task-helpers.ts`) + +| Function | Output | +|----------|--------| +| `ethercatTaskName("Master1")` | `"EtherCAT_Master1"` | +| `cycleTimeUsToIecInterval(1000)` | `"T#1ms"` | +| `cycleTimeUsToIecInterval(500)` | `"T#500us"` | + +--- + +## 5. Device Scanning (Online) + +### IPC Handlers (`src/main/modules/ipc/main.ts`) + +| Handler | Runtime Endpoint | Purpose | +|---------|-----------------|---------| +| `handleEtherCATGetStatus` | `GET /api/discovery/ethercat/status` | Service availability | +| `handleEtherCATGetInterfaces` | `POST /api/plugin-command` | List network adapters | +| `handleEtherCATScan` | `POST /api/plugin-command` | Discover devices on interface | +| `handleEtherCATTest` | `POST /api/discovery/ethercat/test` | Test connection | +| `handleEtherCATValidate` | `POST /api/discovery/ethercat/validate` | Validate configuration | +| `handleEtherCATGetRuntimeStatus` | Runtime API | Master/slave state machine | + +Scan request payload: +```json +{ + "plugin": "ethercat", + "command": "scan", + "params": { "interface": "eth0", "timeout_ms": 5000 } +} +``` + +Scan response: +```typescript +{ + status: 'success' | 'error', + devices: EtherCATDevice[], // { position, name, vendor_id, product_code, revision, state, input_bytes, output_bytes } + message: string, + scan_time_ms: number, +} +``` + +### Renderer IPC (`src/main/modules/ipc/renderer.ts`) + +```typescript +window.bridge.etherCATGetStatus(ipAddress, jwtToken) +window.bridge.etherCATGetInterfaces(ipAddress, jwtToken) +window.bridge.etherCATScan(ipAddress, jwtToken, { interface, timeout_ms }) +``` + +--- + +## 6. Device Matching + +**File:** `src/utils/ethercat/device-matcher.ts` + +`matchDevicesToRepository(scannedDevices, repository)` → `ScannedDeviceMatch[]` + +Match quality levels: +| Quality | Criteria | +|---------|----------| +| **exact** | Vendor ID + Product Code + Revision all match | +| **partial** | Vendor ID + Product Code match (revision differs) | +| **none** | No match found | + +Output per scanned device: +```typescript +{ + device: EtherCATDevice, // from scan + matches: DeviceMatch[], // sorted by quality (best first) +} +``` + +Helper: `countMatchedDevices(matches)` → `{ total, exact, partial, none }` + +--- + +## 7. Adding a Device to the Project + +Two paths: + +### A. From Scan (online) + +1. User selects matched devices in the scan table +2. `handleAddSelectedFromScan()` in `EtherCATEditor`: + - Loads full ESI data via `esiLoadDeviceFull()` + - Enriches with `enrichDeviceData()` + - Creates `ConfiguredEtherCATDevice` with `addedFrom: 'scan'` + - Calls `syncDevicesToStore()` + +### B. From Repository (offline) + +1. User clicks `+` button → `DeviceBrowserModal` opens +2. Selects a device from the ESI repository +3. `handleAddDeviceFromBrowser()` in `EtherCATEditor`: + - Loads full ESI data via `esiLoadDeviceFull()` + - Enriches with `enrichDeviceData()` + - Creates `ConfiguredEtherCATDevice` with `addedFrom: 'repository'` + - Assigns next available position + - Calls `syncDevicesToStore()` + +### Device Enrichment (`src/utils/ethercat/enrich-device-data.ts`) + +`enrichDeviceData(esiDevice)` extracts fields spread into `ConfiguredEtherCATDevice`: + +| Field | Source | +|-------|--------| +| `channelInfo: PersistedChannelInfo[]` | `buildChannelInfo()` — full channel metadata with IEC types | +| `rxPdos, txPdos: PersistedPdo[]` | `persistPdos()` — PDOs with padding preserved | +| `slaveType: string` | `deriveSlaveType()` — heuristic: `digital_input`, `analog_output`, `coupler`, etc. | +| `sdoConfigurations: SDOConfigurationEntry[]` | `extractDefaultSdoConfigurations()` — RW objects in 0x2000+ range | + +### Default Slave Config (`src/utils/ethercat/device-config-defaults.ts`) + +`createDefaultSlaveConfig()` returns: +```typescript +{ + startupChecks: { checkVendorId: true, checkProductCode: true }, + addressing: { ethercatAddress: 0 }, // 0 = auto from position + timeouts: { sdoTimeoutMs: 1000, initToPreOpTimeoutMs: 3000, safeOpToOpTimeoutMs: 10000 }, + watchdog: { smWatchdogEnabled: true, smWatchdogMs: 100, pdiWatchdogEnabled: false, ... }, + distributedClocks: { dcEnabled: false, dcSync0Enabled: false, ... }, +} +``` + +--- + +## 8. Device Configuration (Per-Slave) + +### Editor Components + +``` +src/renderer/components/_features/[workspace]/editor/device/ethercat/ +├── index.tsx # Bus-level editor (3 tabs: Network, Repository, Advanced) +├── ethercat-device-editor.tsx # Per-device editor (4 tabs below) +└── components/ + ├── scan-bus-tab.tsx # Network scan + configured devices list + ├── repository-tab.tsx # ESI file management + ├── advanced-tab.tsx # Master config (cycle time, watchdog) + ├── device-configuration-form.tsx # Slave config forms (addressing, DC, watchdog) + ├── channel-mapping-table.tsx # IEC variable mapping table + ├── sdo-parameters-table.tsx # CoE SDO configuration table + ├── device-browser-modal.tsx # Modal for browsing ESI repo to add devices + ├── interface-selector.tsx # Network interface dropdown + └── discovered-device-table.tsx # Scan results table +``` + +### Per-Device Editor Tabs + +1. **Device Info** — vendor, product code, revision, source, channel counts +2. **Configuration** — `DeviceConfigurationForm` with sections: + - Addressing (EtherCAT address) + - Timeouts (SDO, init→preop, safeop→op) + - Watchdog (SM watchdog, PDI watchdog) + - Distributed Clocks (DC sync0/sync1) +3. **Startup Parameters** — `SdoParametersSection`: editable CoE SDO entries +4. **Channel Mappings** — `ChannelMappingsSection`: IEC 61131-3 located variable assignments + +### Channel Mapping Utilities (`src/utils/ethercat/esi-parser.ts`) + +| Function | Purpose | +|----------|---------| +| `pdoToChannels(device)` | Flatten PDOs → `ESIChannel[]` (skips padding `0x0000`) | +| `generateIecLocation(channel, offset?)` | Generate `%IX0.0`, `%QW2`, etc. | +| `generateDefaultChannelMappings(channels, usedAddresses?)` | Auto-generate non-conflicting IEC addresses | +| `esiTypeToIecType(dataType, bitLen)` | Map ESI types → IEC types (BOOL, BYTE, WORD, ...) | + +IEC location format: +``` +%. + direction: I (input) or Q (output) + size: X (bit), B (byte), W (word), D (dword), L (lword) + Example: BOOL input at byte 0, bit 2 → %IX0.2 +``` + +### SDO Extraction (`src/utils/ethercat/sdo-config-defaults.ts`) + +`extractDefaultSdoConfigurations(coeObjects)` → `SDOConfigurationEntry[]` + +- Filters CoE objects in range 0x2000+ (user-configurable) +- Excludes system objects (0x0000–0x1FFF) +- For complex objects: extracts RW sub-items with default values + +### Device Configuration Hook (`src/renderer/hooks/use-device-configuration.ts`) + +`useDeviceConfiguration({ device, projectPath, ... })` provides: +- Lazy-loads full ESI device on first render +- Manages channel list and CoE objects +- Handles alias changes in channel mappings +- Provides `updateConfig()` for slave config sections + +--- + +## 9. State Management + +### Zustand Store Slices + +The EtherCAT state lives primarily in the **Project Slice** (`src/renderer/store/slices/project/slice.ts`): + +``` +project.data.remoteDevices[] → PLCRemoteDevice[] + └── ethercatConfig + ├── masterConfig: { networkInterface, cycleTimeUs, watchdogTimeoutCycles } + └── devices: ConfiguredEtherCATDevice[] +``` + +Key actions on the project slice: + +| Action | Purpose | +|--------|---------| +| `createRemoteDevice()` | Add bus + auto-create system task | +| `deleteRemoteDevice()` | Remove bus + delete system task | +| `updateEthercatConfig()` | Update master config and/or device list, sync task interval | +| `updateRemoteDeviceName()` | Rename bus + associated task | + +The **Editor Slice** manages the active editor model: +- Bus editor: `type: 'plc-remote-device'`, `meta: { name, protocol: 'ethercat' }` +- Device editor: `type: 'plc-ethercat-device'`, `meta: { name, busName, deviceId }` + +### EtherCAT Editor State (`src/renderer/components/.../ethercat/index.tsx`) + +Local component state in `EtherCATEditor`: + +``` +Repository: ESIRepositoryItemLight[] (loaded from ESI service) +Interfaces: NetworkInterface[] (fetched from runtime) +Scan: scannedDevices[], deviceMatches[], selectedScannedDevices +Service: serviceAvailable, serviceMessage +``` + +Store-derived: +``` +configuredDevices = remoteDevice.ethercatConfig.devices +masterConfig = remoteDevice.ethercatConfig.masterConfig +``` + +--- + +## 10. JSON Configuration Generation for Runtime + +### Generator (`src/utils/ethercat/generate-ethercat-config.ts`) + +`generateEthercatConfig(remoteDevices[])` → JSON string or `null` + +Iterates all remote devices with `protocol === 'ethercat'`, produces: + +```typescript +interface RuntimeRootEntry { + name: string + protocol: "ETHERCAT" + config: { + master: { + interface: string // "eth0" + cycle_time_us: number + watchdog_timeout_cycles: number + task_name?: string // "EtherCAT_Master1" + task_cycle_time_us?: number + } + slaves: RuntimeSlave[] + diagnostics: { + log_connections: true + log_data_access: false + log_errors: true + max_log_entries: 10000 + status_update_interval_ms: 500 + } + } +} +``` + +Each slave: +```typescript +interface RuntimeSlave { + position: number + name: string + type: string // "digital_input", "analog_output", etc. + vendor_id: string // "0x0002" + product_code: string + revision: string + config: { + startup_checks: { ... } + addressing: { ethercat_address: number } + timeouts: { ... } + watchdog: { ... } + distributed_clocks: { ... } + } + channels: RuntimeChannel[] // { index, name, type, bit_length, iec_location, pdo refs } + sdo_configurations: RuntimeSdoConfig[] + rx_pdos: RuntimePdo[] // Full PDO layout with padding + tx_pdos: RuntimePdo[] +} +``` + +Channel type derivation: `deriveChannelType(direction, bitLen)` → `"digital_input"` | `"analog_input"` | `"digital_output"` | `"analog_output"` + +SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF`), float, negative. + +### Compiler Integration (`src/main/modules/compiler/compiler-module.ts`) + +`handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, handleOutputData)`: + +1. Calls `generateEthercatConfig(projectData.remoteDevices)` +2. Creates `conf/` directory in firmware build folder +3. Writes `conf/ethercat.json` +4. Part of the larger build pipeline alongside `modbus-master.json`, `s7comm.json`, `opcua.json` + +--- + +## 11. End-to-End Flow + +``` +1. CREATE BUS + UI: Add Remote Device → protocol: ethercat + Store: createRemoteDevice() → remoteDevices[] + system task + +2. UPLOAD ESI FILES + UI: Repository tab → drag & drop XML + IPC: esiParseAndSaveFile() → main process → parseESILight() + Disk: devices/esi/.xml + repository.json + +3. SCAN NETWORK (online) or ADD MANUALLY (offline) + Online: + IPC: etherCATScan() → runtime HTTP → EtherCATScanResponse + Match: matchDevicesToRepository() → exact/partial/none + Select: user picks matched devices → handleAddSelectedFromScan() + Offline: + UI: + button → DeviceBrowserModal → select from repository + Handler: handleAddDeviceFromBrowser() + +4. ENRICH & STORE + IPC: esiLoadDeviceFull() → parseESIDeviceFull() + Util: enrichDeviceData() → channelInfo, PDOs, slaveType, SDOs + Store: updateEthercatConfig() → devices[] + sync task interval + +5. CONFIGURE DEVICE + UI: Click device in tree → EtherCATDeviceEditor + Tabs: Config (addressing, DC, watchdog), SDO params, Channel mappings + Store: updateEthercatConfig() on each change + +6. BUILD FIRMWARE + Compiler: generateEthercatConfig(remoteDevices) + Output: conf/ethercat.json + Runtime: loads JSON → initializes master/slaves → starts cyclic task +``` + +--- + +## 12. File Reference + +### Types +| File | Contents | +|------|----------| +| `src/types/ethercat/esi-types.ts` | ESIDevice, ConfiguredEtherCATDevice, channels, PDOs, SDOs | +| `src/types/ethercat/index.ts` | EtherCATDevice, scan/status responses, NetworkInterface | +| `src/types/PLC/open-plc.ts` | Zod schemas: EthercatConfig, EtherCATMasterConfig, PLCRemoteDevice | + +### Main Process +| File | Contents | +|------|----------| +| `src/main/services/esi-service/index.ts` | ESI file persistence and repository management | +| `src/main/services/esi-service/esi-parser-main.ts` | XML parsing (light and full modes) | +| `src/main/modules/ipc/main.ts` | IPC handlers for scan, status, ESI operations | +| `src/main/modules/ipc/renderer.ts` | Renderer-side IPC bridge (`window.bridge.*`) | +| `src/main/modules/compiler/compiler-module.ts` | Firmware build: writes `conf/ethercat.json` | + +### Renderer — Utilities +| File | Contents | +|------|----------| +| `src/utils/ethercat/device-matcher.ts` | Match scanned devices against ESI repository | +| `src/utils/ethercat/enrich-device-data.ts` | Extract persistable data from full ESI device | +| `src/utils/ethercat/esi-parser.ts` | pdoToChannels, generateIecLocation, default mappings | +| `src/utils/ethercat/device-config-defaults.ts` | DEFAULT_SLAVE_CONFIG | +| `src/utils/ethercat/sdo-config-defaults.ts` | Extract default SDO configurations from CoE | +| `src/utils/ethercat/ethercat-task-helpers.ts` | Task naming, cycle time conversion | +| `src/utils/ethercat/generate-ethercat-config.ts` | Generate runtime JSON from project state | + +### Renderer — Components +| File | Contents | +|------|----------| +| `src/renderer/components/.../ethercat/index.tsx` | Bus-level editor (Network, Repository, Advanced tabs) | +| `src/renderer/components/.../ethercat/ethercat-device-editor.tsx` | Per-device editor (Info, Config, SDO, Channels tabs) | +| `src/renderer/components/.../ethercat/components/scan-bus-tab.tsx` | Scan UI + configured devices list with +/- | +| `src/renderer/components/.../ethercat/components/device-browser-modal.tsx` | Browse ESI repo to add devices | +| `src/renderer/components/.../ethercat/components/device-configuration-form.tsx` | Slave config forms | +| `src/renderer/components/.../ethercat/components/channel-mapping-table.tsx` | IEC variable mapping | +| `src/renderer/components/.../ethercat/components/sdo-parameters-table.tsx` | CoE SDO editing | + +### Renderer — Store +| File | Contents | +|------|----------| +| `src/renderer/store/slices/project/slice.ts` | createRemoteDevice, updateEthercatConfig, delete, rename | +| `src/renderer/store/slices/editor/types.ts` | Editor model schema (plc-ethercat-device variant) | +| `src/renderer/store/slices/tabs/utils.ts` | CreateEtherCATDeviceEditor | +| `src/renderer/store/slices/shared/index.ts` | openFile, closeFile, forceCloseFile, deleteEthercatDevice | +| `src/renderer/hooks/use-device-configuration.ts` | Lazy-load full device, manage channels/SDOs | + +--- + +## 13. Constraints & Notes + +1. **ESI parsing is CPU-bound** — runs in main process. Sequential uploads recommended for UI responsiveness. +2. **Cycle time ↔ task sync** — `updateEthercatConfig()` auto-updates the associated system task interval. +3. **Address uniqueness** — IEC addresses must be unique across all remote devices (Modbus + EtherCAT). `usedAddresses` is tracked when generating mappings. +4. **CoE SDO range** — only objects in 0x2000+ are user-configurable. System objects (0x0000–0x1FFF) are runtime-managed. +5. **PDO padding** — entries with `index: "0x0000"` are padding: excluded from channel lists but preserved in persisted PDOs for correct byte offsets. +6. **System tasks** — auto-created on device creation, auto-deleted on removal. Marked with `isSystemTask: true`. +7. **Repository v2 migration** — old v1 (metadata-only) auto-migrates to v2 (with device summaries) on first load. +8. **Editor caching** — editor models are cached by `meta.name`. When removing a device, its tab/editor must be explicitly closed to avoid stale `deviceId` references on re-add. diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 9a254f1df..21b2bb336 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -188,18 +188,8 @@ class CompilerModule { return halsFileContent[board]['compiler'] } - // Fallback: check installed VPP packages for the board + // Board not found in hals.json try { - const installed = packageManager.listInstalled() - for (const pkg of installed) { - const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - for (const device of manifest.devices) { - if (device.name === board) { - return device.target.type === 'runtime-v4' ? 'openplc-compiler' : 'arduino-cli' - } - } - } } catch { // ignore package manager errors } diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts index 0b99e9cdd..53e49e3f8 100644 --- a/src/backend/shared/ethercat/generate-ethercat-config.ts +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -220,7 +220,7 @@ function buildSdoConfigurations(entries: SDOConfigurationEntry[] | undefined): R * Builds a runtime slave from a configured device. */ function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSlave { - const position = device.position ?? index + const position = device.position ?? index + 1 const channels = device.channelInfo ? buildChannels(device.channelInfo, device.channelMappings) : [] const rxPdos = device.rxPdos ? convertPdos(device.rxPdos) : [] const txPdos = device.txPdos ? convertPdos(device.txPdos) : [] diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx new file mode 100644 index 000000000..9ad2513ce --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx @@ -0,0 +1,94 @@ +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/frontend/utils/cn' + +type AdvancedTabProps = { + masterConfig: EtherCATMasterConfig + onUpdateMasterConfig: (updates: Partial) => 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 AdvancedTab = ({ masterConfig, onUpdateMasterConfig }: AdvancedTabProps) => { + return ( +
+ {/* Enable Plugin */} +
+

Enable Bus

+
+
+ + {/* 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 { AdvancedTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx index 402cd7f7a..0ebdf4c4b 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -1,21 +1,16 @@ import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' import TableActions from '@root/frontend/components/_atoms/table-actions' -import { cn } from '@root/frontend/utils/cn' import type { ConfiguredEtherCATDevice, - EnrichDeviceData, ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight, - EtherCATChannelMapping, - EtherCATSlaveConfig, - SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' +import { cn } from '@root/frontend/utils/cn' import { useCallback, useMemo, useState } from 'react' import { DeviceBrowserModal } from './device-browser-modal' -import { DeviceDetailPanel } from './device-detail-panel' import { ESIRepository } from './esi-repository' type DevicesTabProps = { @@ -26,17 +21,14 @@ type DevicesTabProps = { 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 + /** Called when user double-clicks a device to open its editor */ + onOpenDevice: (deviceId: string, deviceName: string) => void } const DevicesTab = ({ @@ -47,13 +39,9 @@ const DevicesTab = ({ isLoadingRepository, repositoryError, onRetryRepository, - usedAddresses, onAddDeviceFromBrowser, onRemoveDevice, - onUpdateDevice, - onUpdateChannelMappings, - onEnrichDevice, - onUpdateSdoConfigurations, + onOpenDevice, }: DevicesTabProps) => { const [selectedDeviceId, setSelectedDeviceId] = useState(null) const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) @@ -63,13 +51,8 @@ const DevicesTab = ({ 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) @@ -123,120 +106,102 @@ const DevicesTab = ({ )}
- {/* 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 ( - - ) - }) - )} -
+ {/* Configured Devices 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', + }} + />
- {/* 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 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 ( + + ) + }) )}
+ + {/* Hint */} + {devices.length > 0 && ( +
+

+ Double-click a device to open its configuration +

+
+ )}
{/* Device Browser Modal */} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx index fab46d97d..6a5e5b751 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -1,7 +1,7 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' -import { cn } from '@root/frontend/utils/cn' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { ScannedDeviceMatch } from '@root/types/ethercat/esi-types' +import { cn } from '@root/frontend/utils/cn' import { DiscoveredDeviceTable } from './discovered-device-table' import { InterfaceSelector } from './interface-selector' @@ -11,6 +11,8 @@ type DiagnosticsTabProps = { isConnectedToRuntime: boolean ipAddress: string | null jwtToken: string | null + /** Master name to filter status from multi-master response */ + masterName?: string // Service status serviceAvailable: boolean | null serviceMessage: string @@ -20,7 +22,6 @@ type DiagnosticsTabProps = { onSelectInterface: (value: string) => void isLoadingInterfaces: boolean interfaceError: string | null - onRefreshInterfaces: () => void // Scan isScanning: boolean scanError: string | null @@ -42,6 +43,7 @@ const DiagnosticsTab = ({ isConnectedToRuntime, ipAddress, jwtToken, + masterName, serviceAvailable, serviceMessage, interfaces, @@ -49,7 +51,6 @@ const DiagnosticsTab = ({ onSelectInterface, isLoadingInterfaces, interfaceError, - onRefreshInterfaces, isScanning, scanError, scanTimeMs, @@ -66,7 +67,12 @@ const DiagnosticsTab = ({
{/* Runtime Status */} {isConnectedToRuntime && ipAddress && jwtToken && ( - + )} {/* Not connected state */} @@ -104,7 +110,6 @@ const DiagnosticsTab = ({ onSelectInterface={onSelectInterface} isLoading={isLoadingInterfaces} error={interfaceError} - onRefresh={onRefreshInterfaces} />
+
+
+
+ + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={onAddDeviceFromBrowser} + repository={repository} + /> +
+ ) +} + +export { ScanBusTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx new file mode 100644 index 000000000..3cf84a144 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -0,0 +1,356 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { useOpenPLCStore } from '@root/frontend/store' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/types/ethercat/esi-types' +import { cn } from '@root/frontend/utils/cn' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + ChannelMappingsSection, + DeviceConfigurationForm, + SdoParametersSection, +} from './components/device-configuration-form' + +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +/** + * Standalone full-page editor for a single EtherCAT slave device. + * + * Opened when the user clicks on a device child node in the project tree. + * Reads `busName` and `deviceId` from the editor meta and looks up the + * device from the Zustand store. + */ +const EtherCATDeviceEditor = () => { + const { editor, project, projectActions, workspaceActions } = useOpenPLCStore() + const esi = useEsi() + + const busName = editor.type === 'plc-ethercat-device' ? editor.meta.busName : '' + const deviceId = editor.type === 'plc-ethercat-device' ? editor.meta.deviceId : '' + const projectPath = project.meta.path + + const [activeTab, setActiveTab] = useState('info') + + // Repository state + const [repository, setRepository] = useState([]) + const repositoryLoadedRef = useRef(false) + + // Look up the remote device (bus) and the specific configured device + const remoteDevice = useMemo(() => { + return project.data.remoteDevices?.find((d) => d.name === busName) + }, [project.data.remoteDevices, busName]) + + const configuredDevices = useMemo(() => { + return (remoteDevice?.ethercatConfig?.devices ?? []) as unknown as ConfiguredEtherCATDevice[] + }, [remoteDevice]) + + const device = useMemo(() => { + return configuredDevices.find((d) => d.id === deviceId) ?? null + }, [configuredDevices, deviceId]) + + const masterConfig = useMemo(() => { + return ( + remoteDevice?.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + } + ) + }, [remoteDevice]) + + // Collect all IEC addresses used across all remote devices + const usedAddresses = useMemo(() => { + const addresses = new Set() + const allRemoteDevices = project.data.remoteDevices || [] + + for (const rd of allRemoteDevices) { + if (rd.modbusTcpConfig?.ioGroups) { + for (const group of rd.modbusTcpConfig.ioGroups) { + for (const point of group.ioPoints ?? []) { + addresses.add(point.iecLocation) + } + } + } + if (rd.ethercatConfig?.devices) { + for (const dev of rd.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + addresses.add(mapping.iecLocation) + } + } + } + } + return addresses + }, [project.data.remoteDevices]) + + // Exclude the current device's own addresses from the "external" set + const externalAddresses = useMemo(() => { + if (!device) return usedAddresses + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device]) + + // Sync helpers + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(busName, { masterConfig, devices }) + workspaceActions.setEditingState('unsaved') + }, + [busName, projectActions, masterConfig], + ) + + const handleUpdateDevice = useCallback( + (config: EtherCATSlaveConfig) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleUpdateChannelMappings = useCallback( + (channelMappings: EtherCATChannelMapping[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleEnrichDevice = useCallback( + (data: EnrichDeviceData) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleUpdateSdoConfigurations = useCallback( + (sdoConfigurations: SDOConfigurationEntry[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + // Load ESI repository + useEffect(() => { + let cancelled = false + + const loadRepository = async () => { + if (!projectPath || repositoryLoadedRef.current) return + + try { + const result = await esi!.loadRepositoryLight() + if (cancelled) return + + if (result.success && result.items) { + setRepository(result.items) + repositoryLoadedRef.current = true + } else if ('needsMigration' in result && result.needsMigration) { + const migrationResult = await esi!.migrateRepository() + if (cancelled) return + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) + repositoryLoadedRef.current = true + } + } else { + repositoryLoadedRef.current = true + } + } catch (error) { + if (cancelled) return + console.error('Failed to load ESI repository:', error) + } + } + + void loadRepository() + return () => { + cancelled = true + } + }, [projectPath]) + + // Reset repository loaded flag when project changes + useEffect(() => { + repositoryLoadedRef.current = false + }, [projectPath]) + + // Resolve ESI device summary and repo item for info display + const esiDevice = useMemo(() => { + if (!device) return null + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device]) + + const repoItem = useMemo(() => { + if (!device) return null + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) ?? null + }, [repository, device]) + + // Use the device configuration hook for channels, CoE objects, etc. + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device: device as ConfiguredEtherCATDevice, + projectPath, + externalAddresses, + onUpdateDevice: handleUpdateDevice, + onUpdateChannelMappings: handleUpdateChannelMappings, + onEnrichDevice: handleEnrichDevice, + enabled: device !== null, + }) + + // Fallback when device is not found + if (!device) { + return ( +
+
+

Device not found

+

+ The EtherCAT device could not be found. It may have been removed from the bus configuration. +

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

{device.name}

+

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

+
+ + {/* Tabs */} + setActiveTab(v as DeviceDetailTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + {/* Device Info Tab */} + +
+
+
+ Vendor + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID + {device.vendorId} +
+
+ Product Code + {device.productCode} +
+
+ Revision + {device.revisionNo} +
+
+ ESI File + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels + {esiDevice.inputChannelCount} +
+
+ Output Channels + {esiDevice.outputChannelCount} +
+ + )} +
+
+
+ + {/* Configuration Tab */} + +
+
+ +
+
+
+ + {/* Startup Parameters Tab */} + +
+ +
+
+ + {/* Channel Mappings Tab */} + +
+ +
+
+
+
+ ) +} + +export { EtherCATDeviceEditor } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 3bea69360..021ba53be 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,13 +1,5 @@ import * as Tabs from '@radix-ui/react-tabs' -import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' -import { - countMatchedDevices, - getBestMatchQuality, - matchDevicesToRepository, -} from '@root/backend/shared/ethercat/device-matcher' -import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' import { useOpenPLCStore } from '@root/frontend/store' -import { cn } from '@root/frontend/utils/cn' import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { @@ -15,20 +7,21 @@ import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight, - EtherCATChannelMapping, - EtherCATSlaveConfig, ScannedDeviceMatch, - SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/frontend/utils/cn' +import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' +import { getBestMatchQuality, matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' -import { DevicesTab } from './components/devices-tab' -import { DiagnosticsTab } from './components/diagnostics-tab' -import { GlobalSettingsTab } from './components/global-settings-tab' +import { AdvancedTab } from './components/advanced-tab' +import { RepositoryTab } from './components/repository-tab' +import { ScanBusTab } from './components/scan-bus-tab' -type EditorTab = 'global-settings' | 'diagnostics' | 'devices' +type EditorTab = 'scan-bus' | 'repository' | 'advanced' const TabItem = ({ value, @@ -58,15 +51,26 @@ const TabItem = ({ ) /** - * EtherCAT Device Editor + * EtherCAT Bus Editor * * Three-tab layout: - * - Global Settings: Master configuration (network interface, cycle time, watchdog) - * - Diagnostics: Runtime status monitoring and device discovery/scanning - * - Devices: ESI repository management and configured device editing + * - Scan Bus: Network interface selection and device discovery/scanning + * - Repository: ESI file repository management + * - Advanced: Master configuration (enable plugin, cycle time, watchdog) + * + * Individual device configuration (I/O mapping, SDO, etc.) is handled by + * EtherCATDeviceEditor, opened from the project tree. */ const EtherCATEditor = () => { - const { editor, runtimeConnection, project, projectActions } = useOpenPLCStore() + const { + editor, + runtimeConnection, + project, + projectActions, + workspaceActions, + sharedWorkspaceActions, + editorActions, + } = useOpenPLCStore() const runtime = useRuntime() const esi = useEsi() @@ -78,7 +82,7 @@ const EtherCATEditor = () => { const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null // Tab state - const [activeTab, setActiveTab] = useState('devices') + const [activeTab, setActiveTab] = useState('scan-bus') // Repository state const [repository, setRepository] = useState([]) @@ -96,30 +100,6 @@ const EtherCATEditor = () => { return (remoteDevice?.ethercatConfig?.devices ?? []) as unknown as ConfiguredEtherCATDevice[] }, [remoteDevice]) - // Collect all IEC addresses used across all remote devices (Modbus + EtherCAT) - const usedAddresses = useMemo(() => { - const addresses = new Set() - const allRemoteDevices = project.data.remoteDevices || [] - - for (const rd of allRemoteDevices) { - if (rd.modbusTcpConfig?.ioGroups) { - for (const group of rd.modbusTcpConfig.ioGroups) { - for (const point of group.ioPoints ?? []) { - addresses.add(point.iecLocation) - } - } - } - if (rd.ethercatConfig?.devices) { - for (const dev of rd.ethercatConfig.devices) { - for (const mapping of dev.channelMappings) { - addresses.add(mapping.iecLocation) - } - } - } - } - return addresses - }, [project.data.remoteDevices]) - const masterConfig = useMemo(() => { return ( remoteDevice?.ethercatConfig?.masterConfig ?? { @@ -133,8 +113,9 @@ const EtherCATEditor = () => { const syncDevicesToStore = useCallback( (devices: ConfiguredEtherCATDevice[]) => { projectActions.updateEthercatConfig(deviceName, { masterConfig, devices }) + workspaceActions.setEditingState('unsaved') }, - [deviceName, projectActions, masterConfig], + [deviceName, projectActions, masterConfig, workspaceActions], ) const handleUpdateMasterConfig = useCallback( @@ -144,13 +125,28 @@ const EtherCATEditor = () => { masterConfig: newMasterConfig, devices: configuredDevices, }) + workspaceActions.setEditingState('unsaved') }, - [deviceName, projectActions, masterConfig, configuredDevices], + [deviceName, projectActions, masterConfig, configuredDevices, workspaceActions], ) // Network interfaces state const [interfaces, setInterfaces] = useState([]) - const [selectedInterface, setSelectedInterface] = useState('') + const [selectedInterface, _setSelectedInterface] = useState(masterConfig.networkInterface || '') + const setSelectedInterface = useCallback( + (value: string) => { + _setSelectedInterface(value) + handleUpdateMasterConfig({ networkInterface: value }) + }, + [handleUpdateMasterConfig], + ) + // Sync local state when masterConfig loads/changes (e.g. after project open) + useEffect(() => { + if (masterConfig.networkInterface) { + _setSelectedInterface(masterConfig.networkInterface) + } + }, [masterConfig.networkInterface]) + const [isLoadingInterfaces, setIsLoadingInterfaces] = useState(false) const [interfaceError, setInterfaceError] = useState(null) @@ -173,8 +169,6 @@ const EtherCATEditor = () => { return matchDevicesToRepository(scannedDevices, repository) }, [scannedDevices, repository]) - const matchCounts = useMemo(() => countMatchedDevices(deviceMatches), [deviceMatches]) - // Check EtherCAT service status const checkServiceStatus = useCallback(async () => { if (!isConnectedToRuntime) { @@ -190,7 +184,7 @@ const EtherCATEditor = () => { setServiceMessage(result.data.message) } else { setServiceAvailable(false) - setServiceMessage(result.success ? 'No data' : (result.error ?? 'Failed to check service status')) + setServiceMessage(!result.success ? (result.error ?? 'Failed') : 'Failed to check service status') } } catch (error) { setServiceAvailable(false) @@ -216,13 +210,17 @@ const EtherCATEditor = () => { setInterfaces(fetchedInterfaces) const names = new Set(fetchedInterfaces.map((i) => i.name)) if (fetchedInterfaces.length > 0) { - setSelectedInterface((prev) => (prev && names.has(prev) ? prev : fetchedInterfaces[0].name)) + _setSelectedInterface((prev) => { + const next = prev && names.has(prev) ? prev : fetchedInterfaces[0].name + handleUpdateMasterConfig({ networkInterface: next }) + return next + }) } else { setSelectedInterface('') } } else { setInterfaces([]) - setInterfaceError(result.success ? 'No data' : (result.error ?? 'Failed to load interfaces')) + setInterfaceError(!result.success ? (result.error ?? 'Failed') : 'Failed to fetch interfaces') } } catch (error) { setInterfaces([]) @@ -261,7 +259,7 @@ const EtherCATEditor = () => { setScanError(`Scan completed with status: ${result.data.status}`) } } else { - setScanError(result.success ? 'Scan failed' : (result.error ?? 'Scan failed')) + setScanError(!result.success ? (result.error ?? 'Failed') : 'Scan failed') } } catch (error) { setScanError(String(error)) @@ -331,7 +329,6 @@ const EtherCATEditor = () => { setServiceAvailable(null) setInterfaces([]) setScannedDevices([]) - setSelectedInterface('') } }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) @@ -388,7 +385,10 @@ const EtherCATEditor = () => { if (!repoItem) continue let enriched = {} - const result = await esi!.loadDeviceFull(bestMatch.repositoryItemId, bestMatch.deviceIndex) + const result = await esi!.loadDeviceFull( + bestMatch.repositoryItemId, + bestMatch.deviceIndex, + ) if (result.success && result.device) { enriched = enrichDeviceData(result.device) } @@ -396,7 +396,7 @@ const EtherCATEditor = () => { newDevices.push({ id: uuidv4(), position: match.device.position, - name: match.device.name, + name: bestMatch.esiDevice.name || match.device.name, esiDeviceRef: { repositoryItemId: bestMatch.repositoryItemId, deviceIndex: bestMatch.deviceIndex, @@ -414,11 +414,15 @@ const EtherCATEditor = () => { if (newDevices.length > 0) { syncDevicesToStore([...configuredDevices, ...newDevices]) setSelectedScannedDevices(new Set()) - setActiveTab('devices') } }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) - // Device management handlers + const handleRetryRepository = useCallback(() => { + setRepositoryError(null) + repositoryLoadedRef.current = false + setRepositoryLoadRetry((c) => c + 1) + }, []) + const handleAddDeviceFromBrowser = useCallback( async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { let enriched = {} @@ -428,7 +432,7 @@ const EtherCATEditor = () => { } const nextPosition = - configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? -1)) + 1 : 0 + configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 const newDevice: ConfiguredEtherCATDevice = { id: uuidv4(), @@ -443,6 +447,7 @@ const EtherCATEditor = () => { channelMappings: [], ...enriched, } + syncDevicesToStore([...configuredDevices, newDevice]) }, [configuredDevices, syncDevicesToStore, projectPath], @@ -450,51 +455,28 @@ const EtherCATEditor = () => { const handleRemoveDevice = useCallback( (deviceId: string) => { + const device = configuredDevices.find((d) => d.id === deviceId) + if (device) { + // Remove cached editor model to avoid stale deviceId on re-add + editorActions.removeModel(device.name) + // Close the device tab only if it's open (without switching away from current tab) + const { tabs, tabsActions } = useOpenPLCStore.getState() + const hasTab = tabs.some((t) => t.name === device.name) + if (hasTab) { + tabsActions.removeTab(device.name) + } + } syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) }, - [configuredDevices, syncDevicesToStore], - ) - - const handleUpdateDevice = useCallback( - (deviceId: string, config: EtherCATSlaveConfig) => { - syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) - }, - [configuredDevices, syncDevicesToStore], + [configuredDevices, syncDevicesToStore, sharedWorkspaceActions, editorActions], ) - const handleUpdateChannelMappings = useCallback( - (deviceId: string, channelMappings: EtherCATChannelMapping[]) => { - syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) - }, - [configuredDevices, syncDevicesToStore], - ) - - const handleEnrichDevice = useCallback( - (deviceId: string, data: Partial) => { - syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) - }, - [configuredDevices, syncDevicesToStore], - ) - - const handleUpdateSdoConfigurations = useCallback( - (deviceId: string, sdoConfigurations: SDOConfigurationEntry[]) => { - syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) - }, - [configuredDevices, syncDevicesToStore], - ) - - const handleRetryRepository = useCallback(() => { - setRepositoryError(null) - repositoryLoadedRef.current = false - setRepositoryLoadRetry((c) => c + 1) - }, []) - return (
{/* Header */}
-

EtherCAT Device: {deviceName}

-

Protocol: EtherCAT

+

EtherCAT Bus: {deviceName}

+

EtherCAT Master Configuration

{/* Tabs */} @@ -504,11 +486,10 @@ const EtherCATEditor = () => { className='flex min-h-0 flex-1 flex-col overflow-hidden' > - 0 ? ( @@ -518,43 +499,27 @@ const EtherCATEditor = () => { } /> 0 ? ( - - {configuredDevices.length} + repository.length > 0 ? ( + + {repository.length} ) : undefined } /> + - {/* Global Settings Tab */} + {/* Scan Bus Tab */} - void fetchInterfaces()} - /> - - - {/* Diagnostics Tab */} - - { onSelectInterface={setSelectedInterface} isLoadingInterfaces={isLoadingInterfaces} interfaceError={interfaceError} - onRefreshInterfaces={() => void fetchInterfaces()} isScanning={isScanning} scanError={scanError} scanTimeMs={scanTimeMs} @@ -570,39 +534,43 @@ const EtherCATEditor = () => { scannedDevices={scannedDevices} onScan={() => void scanDevices()} deviceMatches={deviceMatches} - matchCounts={matchCounts} selectedScannedDevices={selectedScannedDevices} onSelectScannedDevice={handleSelectScannedDevice} onSelectAllScanned={handleSelectAllScanned} onAddSelectedFromScan={() => void handleAddSelectedFromScan()} + configuredDevices={configuredDevices} + repository={repository} + onAddDeviceFromBrowser={(...args) => void handleAddDeviceFromBrowser(...args)} + onRemoveDevice={handleRemoveDevice} /> - {/* Devices Tab */} + {/* Repository Tab */} - + + {/* Advanced Tab */} + + +
) } +export { EtherCATDeviceEditor } from './ethercat-device-editor' export { EtherCATEditor } diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts index 05e955fc8..88d34c336 100644 --- a/src/frontend/hooks/use-device-configuration.ts +++ b/src/frontend/hooks/use-device-configuration.ts @@ -1,6 +1,3 @@ -import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' -import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' -import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' import type { ConfiguredEtherCATDevice, EnrichDeviceData, @@ -9,10 +6,12 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, } from '@root/types/ethercat/esi-types' +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' +import { useEsi } from '@root/middleware/shared/providers/platform-context' import { useCallback, useEffect, useRef, useState } from 'react' -import { useEsi } from '../../middleware/shared/providers/platform-context' - type UseDeviceConfigurationParams = { device: ConfiguredEtherCATDevice projectPath: string @@ -57,7 +56,7 @@ export function useDeviceConfiguration({ onEnrichDeviceRef.current = onEnrichDevice useEffect(() => { - if (!enabled || fullDeviceLoadedRef.current) return + if (!enabled || !device || fullDeviceLoadedRef.current) return const loadFullDevice = async () => { setIsLoadingChannels(true) @@ -92,7 +91,7 @@ export function useDeviceConfiguration({ }) } } else { - setChannelLoadError('error' in result ? result.error : 'Failed to load device data') + setChannelLoadError(!result.success ? (result.error ?? 'Failed') : 'Failed to load device data') } } catch (error) { setChannelLoadError(String(error)) @@ -102,24 +101,26 @@ export function useDeviceConfiguration({ } void loadFullDevice() - }, [enabled, esiPort, projectPath, device.esiDeviceRef.repositoryItemId, device.esiDeviceRef.deviceIndex]) + }, [enabled, projectPath, device?.esiDeviceRef?.repositoryItemId, device?.esiDeviceRef?.deviceIndex]) const handleAliasChange = useCallback( (channelId: string, alias: string) => { + if (!device) return const updated = device.channelMappings.map((m) => (m.channelId === channelId ? { ...m, alias } : m)) onUpdateChannelMappingsRef.current(updated) }, - [device.channelMappings], + [device?.channelMappings], ) const updateConfig = useCallback( (section: K, updates: Partial) => { + if (!device) return onUpdateDeviceRef.current({ ...device.config, [section]: { ...device.config[section], ...updates }, }) }, - [device.config], + [device?.config], ) return { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index aa60e31db..d6df09e0e 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -7,7 +7,7 @@ import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' -import { EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' +import { EtherCATDeviceEditor, 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 { MonacoEditor } from '../components/_features/[workspace]/editor/monaco' @@ -341,6 +341,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-remote-device' && editor.meta.protocol !== 'ethercat' && ( )} + {editor['type'] === 'plc-ethercat-device' && } {editor['type'] === 'plc-server' && editor.meta.protocol === 'modbus-tcp' && ( )} diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 6c22dc9b2..40b544fb9 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -150,6 +150,14 @@ export type EditorModel = EditorModelBase & protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' } } + | { + type: 'plc-ethercat-device' + meta: { + name: string + busName: string + deviceId: string + } + } ) // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 489d8b6f1..b35feb333 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -206,7 +206,7 @@ const createProjectSlice: StateCreator = (se name: ethercatTaskName(device.name), triggering: 'Cyclic' as const, interval: cycleTimeUsToIecInterval(cycleTimeUs), - priority: 0, + priority: 1, isSystemTask: true, associatedDevice: device.name, }) @@ -1049,7 +1049,7 @@ const createProjectSlice: StateCreator = (se name: ethercatTaskName(device.name), triggering: 'Cyclic' as const, interval: cycleTimeUsToIecInterval(cycleTimeUs), - priority: 0, + priority: 1, isSystemTask: true, associatedDevice: device.name, }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 8dffd4255..3bc3aa879 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -11,7 +11,7 @@ import type { FileSliceDataObject } from '../file' import type { HistorySnapshot } from '../history' import type { LadderFlowType } from '../ladder' import type { TabsProps } from '../tabs' -import { CreateEditorObjectFromTab, CreateRemoteDeviceEditor, CreateServerEditor } from '../tabs/utils' +import { CreateEditorObjectFromTab, CreateEtherCATDeviceEditor, CreateRemoteDeviceEditor, CreateServerEditor } from '../tabs/utils' import type { SharedRootState, SharedSlice } from './types' import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' @@ -274,6 +274,61 @@ const createSharedSlice: StateCreator = (s renameElement(getState(), oldName, newName, (o, n) => getState().projectActions.updateRemoteDeviceName(o, n)), }, + ethercatDeviceActions: { + delete: (busName, deviceId) => { + const state = getState() + const remoteDevice = state.project.data.remoteDevices?.find((d) => d.name === busName) + if (!remoteDevice) return { ok: false, message: 'Bus not found' } + + const device = remoteDevice.ethercatConfig?.devices?.find((d) => d.id === deviceId) + if (!device) return { ok: false, message: 'EtherCAT device not found' } + + const deviceName = device.name + state.projectActions.updateEthercatConfig(busName, { + masterConfig: remoteDevice.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + }, + devices: (remoteDevice.ethercatConfig?.devices ?? []).filter((d) => d.id !== deviceId), + }) + state.editorActions.removeModel(deviceName) + state.tabsActions.removeTab(deviceName) + + const currentEditor = state.editor + if (currentEditor.type !== 'available' && currentEditor.meta.name === deviceName) { + state.editorActions.clearEditor() + } + + return { ok: true } + }, + + rename: (busName, deviceId, newName) => { + const state = getState() + const remoteDevice = state.project.data.remoteDevices?.find((d) => d.name === busName) + if (!remoteDevice) return { ok: false, message: 'Bus not found' } + + const devices = remoteDevice.ethercatConfig?.devices ?? [] + const device = devices.find((d) => d.id === deviceId) + if (!device) return { ok: false, message: 'EtherCAT device not found' } + + const oldName = device.name + const updatedDevices = devices.map((d) => (d.id === deviceId ? { ...d, name: newName } : d)) + state.projectActions.updateEthercatConfig(busName, { + masterConfig: remoteDevice.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + }, + devices: updatedDevices, + }) + state.editorActions.updateEditorName(oldName, newName) + state.tabsActions.updateTabName(oldName, newName) + + return { ok: true } + }, + }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState: (name) => { const { file } = getState().fileActions.getFile({ name }) diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index eda1c47b6..7e6ceb572 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -105,6 +105,11 @@ export type RemoteDeviceActions = { rename: (oldName: string, newName: string) => SharedResponse } +export type EtherCATDeviceActions = { + delete: (busName: string, deviceId: string) => SharedResponse + rename: (busName: string, deviceId: string, newName: string) => SharedResponse +} + export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void markSaved: (pouName: string) => void @@ -149,6 +154,7 @@ export type SharedSlice = { datatypeActions: DatatypeActions serverActions: ServerActions remoteDeviceActions: RemoteDeviceActions + ethercatDeviceActions: EtherCATDeviceActions snapshotActions: SnapshotActions sharedWorkspaceActions: SharedWorkspaceActions } diff --git a/src/frontend/store/slices/shared/utils.ts b/src/frontend/store/slices/shared/utils.ts index 4127e177a..66cbdc094 100644 --- a/src/frontend/store/slices/shared/utils.ts +++ b/src/frontend/store/slices/shared/utils.ts @@ -159,6 +159,13 @@ export function createEditorObjectForRemoteDevice( } } +export function createEditorObjectForEtherCATDevice(name: string, busName: string, deviceId: string): EditorModel { + return { + type: 'plc-ethercat-device', + meta: { name, busName, deviceId }, + } +} + export function createTabObject( name: string, pouType: 'program' | 'function' | 'function-block', diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index ec2ce28d8..a63a30702 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -14,6 +14,7 @@ export type TabsProps = { | { type: 'device'; derivation: 'configuration' | 'pin-mapping' | 'orchestrators' } | { type: 'server'; protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua' } | { type: 'remote-device'; protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' } + | { type: 'ethercat-device'; busName: string; deviceId: string } configuration?: Record } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index d03c612e9..dec71f363 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -99,6 +99,11 @@ const CreateRemoteDeviceEditor = ( meta: { name, protocol }, }) +const CreateEtherCATDeviceEditor = (name: string, busName: string, deviceId: string): EditorModel => ({ + type: 'plc-ethercat-device', + meta: { name, busName, deviceId }, +}) + const CreateServerEditor = ( name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua', @@ -124,6 +129,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateDeviceEditor(name, elementType.derivation) case 'remote-device': return CreateRemoteDeviceEditor(name, elementType.protocol) + case 'ethercat-device': + return CreateEtherCATDeviceEditor(name, elementType.busName, elementType.deviceId) case 'server': return CreateServerEditor(name, elementType.protocol) } @@ -133,6 +140,7 @@ export { CreateDeviceEditor, CreateEditorModelObject, CreateEditorObjectFromTab, + CreateEtherCATDeviceEditor, CreatePLCGraphicalObject, CreatePLCTextualObject, CreateRemoteDeviceEditor, diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index fd3c70f65..3f5cbb98c 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -34,6 +34,7 @@ export type WorkspaceProjectTreeLeafType = | 'resource' | 'server' | 'remote-device' + | 'ethercat-device' | null // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/parse-resource-string-to-configuration.ts b/src/frontend/utils/parse-resource-string-to-configuration.ts index 1bee818d7..28e7d3553 100644 --- a/src/frontend/utils/parse-resource-string-to-configuration.ts +++ b/src/frontend/utils/parse-resource-string-to-configuration.ts @@ -108,7 +108,7 @@ export function parseResourceStringToConfiguration(configString: string): { name, triggering: DEFAULT_TRIGGERING, interval: '', - priority: 0, + priority: 1, } if (params) { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 9a25da24d..d2306aed4 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -258,8 +258,8 @@ class MainProcessBridge implements MainIpcModule { if (responseParser) { try { return { success: true, data: responseParser(data) } - } catch { - return { success: false, error: 'Invalid response format' } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Invalid response format' } } } return { success: true } @@ -359,8 +359,8 @@ class MainProcessBridge implements MainIpcModule { if (res.statusCode === 200) { try { resolve({ success: true, data: responseParser(data) }) - } catch { - resolve({ success: false, error: 'Invalid response format' }) + } catch (err) { + resolve({ success: false, error: err instanceof Error ? err.message : 'Invalid response format' }) } } else { resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts index bd395e7fd..b5b63354f 100644 --- a/src/types/ethercat/index.ts +++ b/src/types/ethercat/index.ts @@ -292,9 +292,11 @@ export interface EtherCATCycleMetrics { } /** - * Response from GET /api/discovery/ethercat/runtime-status + * Per-master status snapshot (used in multi-master responses) */ -export interface EtherCATRuntimeStatusResponse { +export interface EtherCATMasterStatus { + /** Master name from configuration */ + name: string /** Current plugin state */ plugin_state: EtherCATPluginState /** Number of configured slaves */ @@ -307,6 +309,28 @@ export interface EtherCATRuntimeStatusResponse { metrics: EtherCATCycleMetrics } +/** + * Response from GET /api/discovery/ethercat/runtime-status + * + * The runtime returns a "masters" array for multi-master setups. + * For backward compatibility with single-master, flat fields are + * also included at root level when there is exactly one master. + */ +export interface EtherCATRuntimeStatusResponse { + /** Per-master status array (always present in multi-master runtime) */ + masters?: EtherCATMasterStatus[] + /** Current plugin state (backward compat: only when single master) */ + plugin_state?: EtherCATPluginState + /** Number of configured slaves (backward compat) */ + slave_count?: number + /** Expected working counter value (backward compat) */ + expected_wkc?: number + /** Per-slave status array (backward compat) */ + slaves?: EtherCATSlaveStatus[] + /** Cycle performance metrics (backward compat) */ + metrics?: EtherCATCycleMetrics +} + /** * IPC response for getting runtime status */ From dfd45b79b7344d0575e8574f746a4a0b7efaf2a6 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Apr 2026 14:55:32 -0400 Subject: [PATCH 10/30] feat: add ProjectTreeExpandableLeaf for EtherCAT bus device tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add expandable leaf component to project tree that supports hierarchical navigation — EtherCAT buses expand to show their configured slave devices as child leaves. Each slave device leaf supports rename, delete, and opens the dedicated EtherCATDeviceEditor. Changes: - Add ProjectTreeExpandableLeaf component with collapse/expand, rename, delete, and context menu support - Add ethercatDevice leafLang and LeafSources entry - Add busName/deviceId props to ProjectTreeLeaf for ethercat slaves - Add ethercat device rename/delete handling in ProjectTreeLeaf - Update explorer to render EtherCAT buses as expandable leaves with slave devices as children Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_molecules/project-tree/index.tsx | 224 +++++++++++++++++- .../_organisms/explorer/project.tsx | 71 ++++-- 2 files changed, 269 insertions(+), 26 deletions(-) diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 0e6136a57..36b41cc24 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -238,6 +238,189 @@ const ProjectTreeNestedBranch = ({ nestedBranchTarget, children, ...res }: IProj ) } +type IProjectTreeExpandableLeafProps = ComponentPropsWithoutRef<'li'> & { + leafLang: IProjectTreeLeafProps['leafLang'] + leafType: WorkspaceProjectTreeLeafType + label?: string + children?: ReactNode +} + +const ProjectTreeExpandableLeaf = ({ + leafLang, + leafType, + label, + children, + onClick: handleLeafClick, + ...res +}: IProjectTreeExpandableLeafProps) => { + const { + editor: { + meta: { name }, + }, + workspace: { selectedProjectTreeLeaf, isDebuggerVisible }, + workspaceActions: { setSelectedProjectTreeLeaf }, + remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice }, + fileActions: { getFile }, + } = useOpenPLCStore() + + const [isExpanded, setIsExpanded] = useState(true) + const [isEditing, setIsEditing] = useState(false) + const [newLabel, setNewLabel] = useState(label || '') + const [isPopoverOpen, setPopoverOpen] = useState(false) + const inputNameRef = useRef(null) + + const { LeafIcon } = LeafSources[leafLang] + const { file: associatedFile } = getFile({ name: label || '' }) + const handleLabel = useCallback((l: string | undefined) => unsavedLabel(l, associatedFile), [associatedFile]) + + const handleLeafSelection = () => { + if (!label) return + const { label: currentLabel } = selectedProjectTreeLeaf + if (label === currentLabel) return + setSelectedProjectTreeLeaf({ label, type: leafType }) + } + + const handleRenameFile = async (renamed: string) => { + setIsEditing(false) + if (!renamed || !label) return + const res = await renameRemoteDevice(label, renamed) + if (!res.ok) setNewLabel(label || '') + } + + const handleDeleteFile = () => { + if (label) deleteRemoteDeviceRequest(label) + } + + useEffect(() => { + if (isEditing && inputNameRef.current) { + inputNameRef.current.focus() + inputNameRef.current.select() + } + }, [inputNameRef, isEditing]) + + const popoverOptions = useMemo( + () => [ + { + name: 'Rename', + onClick: () => setIsEditing(true), + icon: , + }, + { + name: 'Delete', + onClick: () => handleDeleteFile(), + icon: , + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [label], + ) + + return ( +
  • +
    + { + e.stopPropagation() + setIsExpanded(!isExpanded) + }} + /> +
    { + handleLeafSelection() + if (label === name) return + if (handleLeafClick) handleLeafClick(e as unknown as React.MouseEvent) + }} + > + + {isEditing ? ( + setNewLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleRenameFile(newLabel.trim() || '') + if (e.key === 'Escape') setIsEditing(false) + }} + onBlur={() => void handleRenameFile(newLabel || '')} + className='w-full border-0 bg-transparent px-1 text-xs text-neutral-850 focus:outline-none dark:text-neutral-300' + /> + ) : ( + !isDebuggerVisible && setIsEditing(true)} + > + {handleLabel(label) || ''} + + )} +
    + + + e.stopPropagation()} + > + + + + e.stopPropagation()} + > + {popoverOptions.map((option, index) => ( +
    { + option.onClick() + setPopoverOpen(false) + }} + > + {option.icon} +

    {option.name}

    +
    + ))} +
    +
    +
    +
    + + {children && isExpanded &&
      {children}
    } +
  • + ) +} + type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { leafLang: | 'il' @@ -256,8 +439,11 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'devOrchestrators' | 'server' | 'remoteDevice' + | 'ethercatDevice' leafType: WorkspaceProjectTreeLeafType label?: string + busName?: string + deviceId?: string } const LeafSources = { @@ -277,8 +463,17 @@ const LeafSources = { devOrchestrators: { LeafIcon: OrchestratorIcon }, server: { LeafIcon: ServerIcon }, remoteDevice: { LeafIcon: RemoteDeviceIcon }, + ethercatDevice: { LeafIcon: DeviceTransferIcon }, } -const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, ...res }: IProjectTreeLeafProps) => { +const ProjectTreeLeaf = ({ + leafLang, + leafType, + label, + busName, + deviceId, + onClick: handleLeafClick, + ...res +}: IProjectTreeLeafProps) => { const { editor: { meta: { name }, @@ -289,6 +484,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, datatypeActions: { deleteRequest: deleteDatatypeRequest, rename: renameDatatype, duplicate: duplicateDatatype }, serverActions: { deleteRequest: deleteServerRequest, rename: renameServer }, remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice }, + ethercatDeviceActions: { delete: deleteEthercatDevice, rename: renameEthercatDevice }, fileActions: { getFile }, } = useOpenPLCStore() @@ -302,6 +498,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, const isDatatype = useMemo(() => leafLang === 'arr' || leafLang === 'enum' || leafLang === 'str', [leafLang]) const isServer = useMemo(() => leafLang === 'server', [leafLang]) const isRemoteDevice = useMemo(() => leafLang === 'remoteDevice', [leafLang]) + const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice', [leafLang]) const { LeafIcon } = LeafSources[leafLang] const { file: associatedFile } = getFile({ name: label || '' }) @@ -323,10 +520,10 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, setSelectedProjectTreeLeaf({ label, type: leafType }) } - const handleRenameFile = (newLabel: string) => { + const handleRenameFile = async (newLabel: string) => { setIsEditing(false) - if (!isAPou && !isDatatype && !isServer && !isRemoteDevice) { + if (!isAPou && !isDatatype && !isServer && !isRemoteDevice && !isEthercatDevice) { toast({ title: 'Error', description: 'Only POU, datatype, server, or remote device files can be renamed.', @@ -375,6 +572,14 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, } return } + + if (isEthercatDevice && busName && deviceId) { + const res = await renameEthercatDevice(busName, deviceId, newLabel) + if (!res.ok) { + setNewLabel(label || '') + } + return + } } const handleDuplicateFile = () => { @@ -414,7 +619,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, } const handleDeleteFile = () => { - if (!isAPou && !isDatatype && !isServer && !isRemoteDevice) { + if (!isAPou && !isDatatype && !isServer && !isRemoteDevice && !isEthercatDevice) { toast({ title: 'Error', description: 'Only POU, datatype, server, or remote device files can be deleted.', @@ -452,11 +657,10 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, return } - toast({ - title: 'Error', - description: 'Only POU, datatype, server, or remote device files can be deleted.', - variant: 'fail', - }) + if (isEthercatDevice && busName && deviceId) { + deleteEthercatDevice(busName, deviceId) + return + } } const handleLabel = useCallback((label: string | undefined) => unsavedLabel(label, associatedFile), [associatedFile]) @@ -590,4 +794,4 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, ) } -export { ProjectTreeBranch, ProjectTreeLeaf, ProjectTreeNestedBranch, ProjectTreeRoot } +export { ProjectTreeBranch, ProjectTreeExpandableLeaf, ProjectTreeLeaf, ProjectTreeNestedBranch, ProjectTreeRoot } diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index b4603f8d6..fe81b9943 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -7,7 +7,12 @@ import { extractSearchQuery } from '../../../store/slices/search/utils' import type { TabsProps } from '../../../store/slices/tabs' import { CreateEditorObjectFromTab } from '../../../store/slices/tabs/utils' import { CreatePLCElement } from '../../_features/[workspace]/create-element' -import { ProjectTreeBranch, ProjectTreeLeaf, ProjectTreeRoot } from '../../_molecules/project-tree' +import { + ProjectTreeBranch, + ProjectTreeExpandableLeaf, + ProjectTreeLeaf, + ProjectTreeRoot, +} from '../../_molecules/project-tree' type PouLeafLang = 'il' | 'st' | 'ld' | 'sfc' | 'fbd' | 'python' | 'cpp' @@ -299,21 +304,55 @@ const Project = () => { {[...(remoteDevices || [])] .sort((a, b) => a.name.localeCompare(b.name)) - .map((device) => ( - - handleCreateTab({ - name: device.name, - path: `/device/remote/${device.name}`, - elementType: { type: 'remote-device', protocol: device.protocol }, - }) - } - /> - ))} + .map((device) => + device.protocol === 'ethercat' ? ( + + handleCreateTab({ + name: device.name, + path: `/devices/remote/${device.name}.json`, + elementType: { type: 'remote-device', protocol: device.protocol }, + }) + } + > + {device.ethercatConfig?.devices?.map((child) => ( + + handleCreateTab({ + name: child.name, + path: `/devices/remote/${device.name}/devices/${child.id}`, + elementType: { type: 'ethercat-device', busName: device.name, deviceId: child.id }, + }) + } + /> + ))} + + ) : ( + + handleCreateTab({ + name: device.name, + path: `/device/remote/${device.name}`, + elementType: { type: 'remote-device', protocol: device.protocol }, + }) + } + /> + ), + )}

    From a24a6b73565ba7826d1691781f6220c2caf92033 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 13 Apr 2026 10:34:50 +0200 Subject: [PATCH 11/30] feat: port remaining ethercat-task-selection commits (6 commits) Ports the 6 unpushed commits from feat/ethercat-task-selection: - d784f3e3 refactor: replace colored IN/OUT badges with plain text in channel tables - 969ecd8f refactor: update channel filter buttons to match console tab style - 98e870ec feat: add task priority setting to EtherCAT advanced config - 4213c4d3 fix: prevent duplicating system task properties when creating new tasks (also applied to the ROWS_NOT_SELECTED path, which copies from task in this branch) - 5a635e33 feat: sort tasks by priority in ST/XML generation and rename tab to Bus - a1df55a1 feat: add breadcrumb trails for remote devices, servers, and EtherCAT slaves Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ethercat/components/advanced-tab.tsx | 23 ++++++++++ .../components/channel-mapping-table.tsx | 31 +++++-------- .../components/esi-channels-table.tsx | 31 +++++-------- .../editor/device/ethercat/index.tsx | 2 +- .../_molecules/breadcrumbs/index.tsx | 45 ++++++++++++++++++- .../_organisms/task-editor/index.tsx | 5 ++- src/frontend/store/slices/project/slice.ts | 13 ++++-- .../xml-generator/codesys/instances-xml.ts | 4 +- .../xml-generator/old-editor/instances-xml.ts | 4 +- .../parse-resource-configuration-to-string.ts | 4 +- src/types/PLC/open-plc.ts | 1 + 11 files changed, 112 insertions(+), 51 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx index 9ad2513ce..9cb5e28b6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx @@ -63,6 +63,29 @@ const AdvancedTab = ({ masterConfig, onUpdateMasterConfig }: AdvancedTabProps) =
    + {/* Task Priority */} +
    +

    Task Priority

    +
    + onUpdateMasterConfig({ taskPriority: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 1) onUpdateMasterConfig({ taskPriority: 1 }) + else if (val > 31) onUpdateMasterConfig({ taskPriority: 31 }) + }} + min={1} + max={31} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + Priority of the EtherCAT cyclic task (1 - 31) + +
    +
    + {/* Watchdog Timeout */}

    diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx index f35f5c1d2..93d956869 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -113,14 +113,14 @@ const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappi {/* Filters */}
    {/* Direction Filter */} -
    +
    )} - {/* Add selected button */} - {selectedScannedDevices.size > 0 && ( -
    - -
    - )} - {/* Side-by-side: Scanned Devices (left) + Configured Devices (right) */}
    {/* Scanned Devices — left */}
    -
    +

    Scanned Devices

    +
    Date: Mon, 13 Apr 2026 13:49:03 +0200 Subject: [PATCH 15/30] fix: populate EtherCAT channelMappings at device add-time Adding an EtherCAT slave (via scan or repository) was leaving channelMappings as an empty array. The compiler reads that field to assign IEC locations (%QX0.0, %IX0.0, ...) when generating the runtime config; without entries, every channel was emitted with an empty iec_location, so outputs never bound to PLC variables and the slave appeared inert until the user opened its editor page (where a useEffect in use-device-configuration was lazily filling the mappings). Move the population to the add path: - enrichDeviceData(device, usedAddresses?) now also returns channelMappings, generated via generateDefaultChannelMappings against the project-wide set of already-used IEC addresses. - New util collectUsedIecAddresses gathers IEC locations from every remote device's Modbus I/O points and EtherCAT channel mappings. ethercat-device-editor.tsx and the add handlers both consume it. - handleAddSelectedFromScan accumulates freshly assigned addresses between iterations so devices added in the same batch don't collide. The lazy-init in use-device-configuration is left in place as a fallback for projects saved before this fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ethercat/collect-used-iec-addresses.ts | 43 +++++++++++++++++++ .../shared/ethercat/enrich-device-data.ts | 16 ++++++- src/backend/shared/ethercat/index.ts | 1 + .../ethercat/ethercat-device-editor.tsx | 27 +++--------- .../editor/device/ethercat/index.tsx | 26 ++++++++--- 5 files changed, 83 insertions(+), 30 deletions(-) create mode 100644 src/backend/shared/ethercat/collect-used-iec-addresses.ts diff --git a/src/backend/shared/ethercat/collect-used-iec-addresses.ts b/src/backend/shared/ethercat/collect-used-iec-addresses.ts new file mode 100644 index 000000000..f06de5353 --- /dev/null +++ b/src/backend/shared/ethercat/collect-used-iec-addresses.ts @@ -0,0 +1,43 @@ +/** + * Collect every IEC address (e.g. `%QX0.0`, `%IW2`) currently in use across + * all remote devices in the project — Modbus TCP I/O points and EtherCAT + * channel mappings. Used to seed `generateDefaultChannelMappings` so newly + * added devices receive non-conflicting IEC locations. + * + * Typed structurally to accept both the schema-derived `PLCRemoteDevice` + * type and the store's slightly looser inferred type without coupling. + */ + +type RemoteDeviceForAddressCollection = { + modbusTcpConfig?: { + ioGroups?: Array<{ + ioPoints?: Array<{ iecLocation: string }> + }> + } + ethercatConfig?: { + devices?: Array<{ + channelMappings?: Array<{ iecLocation: string }> + }> + } +} + +export function collectUsedIecAddresses( + remoteDevices: RemoteDeviceForAddressCollection[] | undefined, +): Set { + const addresses = new Set() + if (!remoteDevices) return addresses + + for (const rd of remoteDevices) { + for (const group of rd.modbusTcpConfig?.ioGroups ?? []) { + for (const point of group.ioPoints ?? []) { + addresses.add(point.iecLocation) + } + } + for (const dev of rd.ethercatConfig?.devices ?? []) { + for (const mapping of dev.channelMappings ?? []) { + addresses.add(mapping.iecLocation) + } + } + } + return addresses +} diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts index 9bf2e347d..73b893961 100644 --- a/src/backend/shared/ethercat/enrich-device-data.ts +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -8,13 +8,14 @@ import type { ESIDevice, ESIPdo, + EtherCATChannelMapping, PersistedChannelInfo, PersistedPdo, PersistedPdoEntry, SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' -import { esiTypeToIecType, pdoToChannels } from './esi-parser' +import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' import { extractDefaultSdoConfigurations } from './sdo-config-defaults' /** @@ -95,13 +96,23 @@ export function deriveSlaveType(device: ESIDevice): string { /** * Enrich device data by extracting all persistable info from a full ESIDevice. * Returns fields to spread into ConfiguredEtherCATDevice. + * + * `usedAddresses` is the set of IEC addresses already taken by other devices + * in the project; the generated `channelMappings` will avoid them. Pass an + * up-to-date set when adding a device so its outputs/inputs receive valid, + * non-conflicting IEC locations from the start (otherwise the runtime can't + * bind them and the slave appears inert until the editor page is opened). */ -export function enrichDeviceData(device: ESIDevice): { +export function enrichDeviceData( + device: ESIDevice, + usedAddresses?: Set, +): { channelInfo: PersistedChannelInfo[] rxPdos: PersistedPdo[] txPdos: PersistedPdo[] slaveType: string sdoConfigurations?: SDOConfigurationEntry[] + channelMappings: EtherCATChannelMapping[] } { return { channelInfo: buildChannelInfo(device), @@ -109,5 +120,6 @@ export function enrichDeviceData(device: ESIDevice): { txPdos: persistPdos(device.txPdo), slaveType: deriveSlaveType(device), sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, + channelMappings: generateDefaultChannelMappings(pdoToChannels(device), usedAddresses), } } diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts index 3d72dd8ea..b47844856 100644 --- a/src/backend/shared/ethercat/index.ts +++ b/src/backend/shared/ethercat/index.ts @@ -1,6 +1,7 @@ export { createDefaultSlaveConfig, DEFAULT_SLAVE_CONFIG } from './device-config-defaults' export { cycleTimeUsToIecInterval, ethercatTaskName } from './ethercat-task-helpers' export { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from './device-matcher' +export { collectUsedIecAddresses } from './collect-used-iec-addresses' export { buildChannelInfo, deriveSlaveType, persistPdos } from './enrich-device-data' export { esiTypeToIecType, pdoToChannels } from './esi-parser' export { parseESIDeviceFull, parseESILight } from './esi-parser-main' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 9efee1630..96feca966 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -1,4 +1,5 @@ import * as Tabs from '@radix-ui/react-tabs' +import { collectUsedIecAddresses } from '@root/backend/shared/ethercat' import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' import { useOpenPLCStore } from '@root/frontend/store' import { useEsi } from '@root/middleware/shared/providers/platform-context' @@ -83,28 +84,10 @@ const EtherCATDeviceEditor = () => { }, [remoteDevice]) // Collect all IEC addresses used across all remote devices - const usedAddresses = useMemo(() => { - const addresses = new Set() - const allRemoteDevices = project.data.remoteDevices || [] - - for (const rd of allRemoteDevices) { - if (rd.modbusTcpConfig?.ioGroups) { - for (const group of rd.modbusTcpConfig.ioGroups) { - for (const point of group.ioPoints ?? []) { - addresses.add(point.iecLocation) - } - } - } - if (rd.ethercatConfig?.devices) { - for (const dev of rd.ethercatConfig.devices) { - for (const mapping of dev.channelMappings) { - addresses.add(mapping.iecLocation) - } - } - } - } - return addresses - }, [project.data.remoteDevices]) + const usedAddresses = useMemo( + () => collectUsedIecAddresses(project.data.remoteDevices), + [project.data.remoteDevices], + ) // Exclude the current device's own addresses from the "external" set const externalAddresses = useMemo(() => { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 7e6e2a885..b17ad0e60 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -11,6 +11,7 @@ import type { } from '@root/types/ethercat/esi-types' import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' import { cn } from '@root/frontend/utils/cn' +import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-used-iec-addresses' import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' import { getBestMatchQuality, matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' @@ -372,6 +373,7 @@ const EtherCATEditor = () => { const handleAddSelectedFromScan = useCallback(async () => { const newDevices: ConfiguredEtherCATDevice[] = [] const existingPositions = new Set(configuredDevices.map((d) => d.position)) + const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) for (const position of selectedScannedDevices) { // Skip devices already configured at this position @@ -384,13 +386,16 @@ const EtherCATEditor = () => { const repoItem = repository.find((r) => r.id === bestMatch.repositoryItemId) if (!repoItem) continue - let enriched = {} + let enriched: Partial = { channelMappings: [] } const result = await esi!.loadDeviceFull( bestMatch.repositoryItemId, bestMatch.deviceIndex, ) if (result.success && result.device) { - enriched = enrichDeviceData(result.device) + enriched = enrichDeviceData(result.device, usedAddresses) + // Reserve the freshly assigned addresses so the next device in the + // batch doesn't collide with them. + for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) } newDevices.push({ @@ -415,7 +420,15 @@ const EtherCATEditor = () => { syncDevicesToStore([...configuredDevices, ...newDevices]) setSelectedScannedDevices(new Set()) } - }, [selectedScannedDevices, deviceMatches, repository, configuredDevices, syncDevicesToStore, projectPath]) + }, [ + selectedScannedDevices, + deviceMatches, + repository, + configuredDevices, + syncDevicesToStore, + projectPath, + project.data.remoteDevices, + ]) const handleRetryRepository = useCallback(() => { setRepositoryError(null) @@ -425,10 +438,11 @@ const EtherCATEditor = () => { const handleAddDeviceFromBrowser = useCallback( async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { - let enriched = {} + let enriched: Partial = { channelMappings: [] } const result = await esi!.loadDeviceFull(ref.repositoryItemId, ref.deviceIndex) if (result.success && result.device) { - enriched = enrichDeviceData(result.device) + const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) + enriched = enrichDeviceData(result.device, usedAddresses) } const nextPosition = @@ -450,7 +464,7 @@ const EtherCATEditor = () => { syncDevicesToStore([...configuredDevices, newDevice]) }, - [configuredDevices, syncDevicesToStore, projectPath], + [configuredDevices, syncDevicesToStore, projectPath, project.data.remoteDevices], ) const handleRemoveDevice = useCallback( From abfb9dd295c78e6e59e58f60464a741630169929 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 13 Apr 2026 14:59:17 +0200 Subject: [PATCH 16/30] fix: close EtherCAT slave tabs without phantom save prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slave tabs (elementType.type === 'ethercat-device') were never registered in the files store, so closeFile's getSavedState lookup defaulted to false and triggered the "Unsaved changes — save?" modal on every close, even when nothing had been edited. Worse, clicking "Save" in that modal called executeSaveFile, which also failed on the missing files entry, so the tab refused to close ("Don't Save" worked because its handler bypassed the result). Treat ethercat-device tabs as views over the parent remote device's data: they own no file and have no per-tab dirty state. Short-circuit closeFile to forceCloseFile for that tab type. Real edits to slave config still dirty the parent bus tab via syncDevicesToStore, so the project-level save flow is unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/frontend/store/slices/shared/slice.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 3bc3aa879..809f67677 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -347,6 +347,17 @@ const createSharedSlice: StateCreator = (s }, closeFile: (name) => { + // EtherCAT slave tabs are views over data owned by the parent remote + // device — they have no entry in the `files` store and no per-tab + // dirty state. `getSavedState` would default to `false` and trigger + // a phantom "save changes?" prompt, so close them directly. Any real + // edits to slave config already dirty the parent bus tab through + // syncDevicesToStore -> setEditingState('unsaved'). + const tab = getState().tabs.find((t) => t.name === name) + if (tab?.elementType.type === 'ethercat-device') { + return getState().sharedWorkspaceActions.forceCloseFile(name) + } + // Check if file has unsaved changes const isSaved = getState().fileActions.getSavedState({ name }) From dd77cc9509a8c566184f2152c56cbcfc91ff3af4 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 13 Apr 2026 18:03:15 +0200 Subject: [PATCH 17/30] fix: update PLC type imports after move to backend/shared Upstream commit 1554301d moved the shared PLC type schemas from src/types/PLC/ to src/backend/shared/types/PLC/ (and deleted src/types/PLC/runtime-logs.ts in favor of the existing definition in middleware/shared/ports/types.ts) but did not update every importer. Eight files were left referencing the old paths, breaking tsc with 13 "Cannot find module" / cascading implicit-any errors. Repoint each importer to the new location: - @root/types/PLC/devices -> @root/backend/shared/types/PLC/devices - @root/types/PLC/open-plc -> @root/backend/shared/types/PLC/open-plc - @root/types/PLC/runtime-logs -> @root/middleware/shared/ports Also re-sorts adjacent imports in the touched files via eslint --fix to satisfy simple-import-sort. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend/editor/compiler/compiler-module.ts | 4 ++-- .../shared/ethercat/generate-ethercat-config.ts | 2 +- .../device/ethercat/components/advanced-tab.tsx | 2 +- .../ethercat/components/global-settings-tab.tsx | 2 +- .../[workspace]/editor/device/ethercat/index.tsx | 12 ++++++------ src/frontend/store/slices/project/slice.ts | 2 +- src/main/modules/ipc/main.ts | 4 ++-- src/main/modules/ipc/renderer.ts | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 21b2bb336..e78f3bd38 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -10,6 +10,8 @@ import { promisify } from 'node:util' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' import { generateEthercatConfig } from '@root/backend/shared/ethercat/generate-ethercat-config' +import type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices' +import type { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { type CppPouData as CppPouDataCode, generateCBlocksCode, @@ -25,8 +27,6 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { generateModbusSlaveConfig } from '@root/frontend/utils/modbus/generate-modbus-slave-config' import { generateOpcUaConfig, OpcUaConfigError } from '@root/frontend/utils/opcua' import { generateS7CommConfig } from '@root/frontend/utils/s7comm' -import type { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' -import type { PLCProjectData } from '@root/types/PLC/open-plc' import { app as electronApp, dialog } from 'electron' import type { MessagePortMain } from 'electron/main' import JSZip from 'jszip' diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts index 53e49e3f8..28fc45728 100644 --- a/src/backend/shared/ethercat/generate-ethercat-config.ts +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -1,10 +1,10 @@ +import type { PLCRemoteDevice } from '@root/backend/shared/types/PLC/open-plc' import type { ConfiguredEtherCATDevice, PersistedChannelInfo, PersistedPdo, SDOConfigurationEntry, } from '@root/types/ethercat/esi-types' -import type { PLCRemoteDevice } from '@root/types/PLC/open-plc' import { ethercatTaskName } from './ethercat-task-helpers' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx index 9cb5e28b6..083fc2380 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx @@ -1,5 +1,5 @@ +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { InputWithRef } from '@root/frontend/components/_atoms/input' -import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' import { cn } from '@root/frontend/utils/cn' type AdvancedTabProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx index c9c801f2a..b2dc6f239 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -1,9 +1,9 @@ +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { InputWithRef } from '@root/frontend/components/_atoms/input' import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' import { cn } from '@root/frontend/utils/cn' import type { NetworkInterface } from '@root/types/ethercat' -import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' type GlobalSettingsTabProps = { masterConfig: EtherCATMasterConfig diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index b17ad0e60..12e249bf6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,5 +1,11 @@ import * as Tabs from '@radix-ui/react-tabs' +import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-used-iec-addresses' +import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' +import { getBestMatchQuality, matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { @@ -9,12 +15,6 @@ import type { ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/types/ethercat/esi-types' -import type { EtherCATMasterConfig } from '@root/types/PLC/open-plc' -import { cn } from '@root/frontend/utils/cn' -import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-used-iec-addresses' -import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' -import { getBestMatchQuality, matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' -import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 8a76234a6..21988d7a9 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -1,5 +1,5 @@ import { cycleTimeUsToIecInterval, ethercatTaskName } from '@root/backend/shared/ethercat/ethercat-task-helpers' -import type { EthercatConfig } from '@root/types/PLC/open-plc' +import type { EthercatConfig } from '@root/backend/shared/types/PLC/open-plc' import { produce } from 'immer' import { StateCreator } from 'zustand' diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index d2306aed4..c8a321817 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,7 +1,9 @@ import { ESIService } from '@root/backend/editor/ethercat' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' +import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' +import { RuntimeLogEntry } from '@root/middleware/shared/ports' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -16,8 +18,6 @@ import type { 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 { PLCProjectData } from '@root/types/PLC/open-plc' -import { RuntimeLogEntry } from '@root/types/PLC/runtime-logs' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' import { app, nativeTheme, shell } from 'electron' import { readFile, realpathSync, stat, statSync, unwatchFile, watchFile } from 'fs' diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 78a906290..5a78a751e 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,3 +1,4 @@ +import { RuntimeLogEntry } from '@root/middleware/shared/ports' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { EtherCATRuntimeStatusResponse, @@ -13,7 +14,6 @@ import type { 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 { RuntimeLogEntry } from '@root/types/PLC/runtime-logs' import { ipcRenderer, IpcRendererEvent } from 'electron' type IpcRendererCallbacks = (_event: IpcRendererEvent, ...args: unknown[]) => void From 23cd396b7c3e60e1c12ea69f504ed75a5ba476c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 16 Apr 2026 12:44:59 +0200 Subject: [PATCH 18/30] fix: move ESI types to shared ports surface and fix formatting Moves the canonical ESI type definitions from src/types/ethercat/ to src/middleware/shared/ports/esi-types.ts so ports can import them without violating the architecture layer rule (ports -> [utils, ports]). The original file at src/types/ethercat/esi-types.ts becomes a re-export barrel so the 30+ existing consumers continue working without import changes. Also fixes Prettier formatting on 6 EtherCAT files flagged by CI. Co-authored-by: Claude Opus 4.6 (1M context) --- .../ethercat/collect-used-iec-addresses.ts | 4 +- .../ethercat/components/advanced-tab.tsx | 2 +- .../components/global-settings-tab.tsx | 2 +- .../ethercat/ethercat-device-editor.tsx | 5 +- .../editor/device/ethercat/index.tsx | 5 +- src/frontend/store/slices/shared/slice.ts | 7 +- src/middleware/shared/ports/esi-port.ts | 2 +- src/middleware/shared/ports/esi-types.ts | 626 +++++++++++++++++ src/types/ethercat/esi-types.ts | 630 +----------------- 9 files changed, 645 insertions(+), 638 deletions(-) create mode 100644 src/middleware/shared/ports/esi-types.ts diff --git a/src/backend/shared/ethercat/collect-used-iec-addresses.ts b/src/backend/shared/ethercat/collect-used-iec-addresses.ts index f06de5353..0145b16b6 100644 --- a/src/backend/shared/ethercat/collect-used-iec-addresses.ts +++ b/src/backend/shared/ethercat/collect-used-iec-addresses.ts @@ -21,9 +21,7 @@ type RemoteDeviceForAddressCollection = { } } -export function collectUsedIecAddresses( - remoteDevices: RemoteDeviceForAddressCollection[] | undefined, -): Set { +export function collectUsedIecAddresses(remoteDevices: RemoteDeviceForAddressCollection[] | undefined): Set { const addresses = new Set() if (!remoteDevices) return addresses diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx index 083fc2380..2b2709faa 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx @@ -33,7 +33,7 @@ const AdvancedTab = ({ masterConfig, onUpdateMasterConfig }: AdvancedTabProps) = /> - {masterConfig.enabled ?? true ? 'EtherCAT bus will start when PLC runs' : 'EtherCAT bus is disabled'} + {(masterConfig.enabled ?? true) ? 'EtherCAT bus will start when PLC runs' : 'EtherCAT bus is disabled'}
    diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx index b2dc6f239..785edc15c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -47,7 +47,7 @@ const GlobalSettingsTab = ({ /> - {masterConfig.enabled ?? true ? 'Plugin will start when PLC runs' : 'Plugin is disabled'} + {(masterConfig.enabled ?? true) ? 'Plugin will start when PLC runs' : 'Plugin is disabled'}
    diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 96feca966..db67456d5 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -84,10 +84,7 @@ const EtherCATDeviceEditor = () => { }, [remoteDevice]) // Collect all IEC addresses used across all remote devices - const usedAddresses = useMemo( - () => collectUsedIecAddresses(project.data.remoteDevices), - [project.data.remoteDevices], - ) + const usedAddresses = useMemo(() => collectUsedIecAddresses(project.data.remoteDevices), [project.data.remoteDevices]) // Exclude the current device's own addresses from the "external" set const externalAddresses = useMemo(() => { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 12e249bf6..21eb63112 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -387,10 +387,7 @@ const EtherCATEditor = () => { if (!repoItem) continue let enriched: Partial = { channelMappings: [] } - const result = await esi!.loadDeviceFull( - bestMatch.repositoryItemId, - bestMatch.deviceIndex, - ) + const result = await esi!.loadDeviceFull(bestMatch.repositoryItemId, bestMatch.deviceIndex) if (result.success && result.device) { enriched = enrichDeviceData(result.device, usedAddresses) // Reserve the freshly assigned addresses so the next device in the diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 5ab53033d..e362abaa8 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -11,7 +11,12 @@ import type { FileSliceDataObject } from '../file' import type { HistorySnapshot } from '../history' import type { LadderFlowType } from '../ladder' import type { TabsProps } from '../tabs' -import { CreateEditorObjectFromTab, CreateEtherCATDeviceEditor, CreateRemoteDeviceEditor, CreateServerEditor } from '../tabs/utils' +import { + CreateEditorObjectFromTab, + CreateEtherCATDeviceEditor, + CreateRemoteDeviceEditor, + CreateServerEditor, +} from '../tabs/utils' import type { SharedRootState, SharedSlice } from './types' import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts index 6d4f06cd3..0665b4020 100644 --- a/src/middleware/shared/ports/esi-port.ts +++ b/src/middleware/shared/ports/esi-port.ts @@ -13,7 +13,7 @@ * (src/backend/shared/ethercat/). The port only handles persistence I/O. */ -import type { ESIDevice, ESIRepositoryItemLight } from '../../../types/ethercat/esi-types' +import type { ESIDevice, ESIRepositoryItemLight } from './esi-types' import type { Result } from './types' export interface EsiPort { diff --git a/src/middleware/shared/ports/esi-types.ts b/src/middleware/shared/ports/esi-types.ts new file mode 100644 index 000000000..4ec1f0f30 --- /dev/null +++ b/src/middleware/shared/ports/esi-types.ts @@ -0,0 +1,626 @@ +/** + * EtherCAT Slave Information (ESI) Types + * + * Types for parsing and representing ESI XML files following ETG.2000 specification. + * ESI files describe EtherCAT slave device properties, PDO mappings, and communication settings. + */ + +// ===================== VENDOR ===================== + +/** + * Vendor information from ESI file + */ +export interface ESIVendor { + /** Vendor ID (hex format, e.g., "0x0002" for Beckhoff) */ + id: string + /** Vendor name */ + name: string +} + +// ===================== DEVICE INFO ===================== + +/** + * Device type information + */ +export interface ESIDeviceType { + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** Type name/description */ + name: string +} + +/** + * Sync Manager configuration + */ +export interface ESISyncManager { + /** SM index (0-3 typically) */ + index: number + /** Start address */ + startAddress: string + /** Control byte */ + controlByte: string + /** Default size */ + defaultSize: number + /** Enable flag */ + enable: boolean + /** SM type: Mailbox Out, Mailbox In, Process Data Out, Process Data In */ + type: 'MbxOut' | 'MbxIn' | 'Outputs' | 'Inputs' +} + +/** + * FMMU (Fieldbus Memory Management Unit) configuration + */ +export interface ESIFMMU { + /** FMMU type: Outputs, Inputs, MbxState */ + type: 'Outputs' | 'Inputs' | 'MbxState' +} + +// ===================== PDO ENTRIES ===================== + +/** + * EtherCAT data types used in PDO entries + * Common types: BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, + * REAL, LREAL, STRING, BYTE, WORD, DWORD, BIT, BIT2-BIT7 + * Using string to allow vendor-specific custom types + */ +export type ESIDataType = string + +/** + * PDO Entry - represents a single variable in a PDO + */ +export interface ESIPdoEntry { + /** Entry index (hex, e.g., "#x6000") */ + index: string + /** Entry subindex (hex, e.g., "#x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name/identifier */ + name: string + /** Data type */ + dataType: ESIDataType + /** Optional: Comment/description */ + comment?: string +} + +/** + * Process Data Object - TxPdo (slave to master) or RxPdo (master to slave) + */ +export interface ESIPdo { + /** PDO index (hex, e.g., "#x1600" for RxPdo, "#x1A00" for TxPdo) */ + index: string + /** PDO name */ + name: string + /** Whether this PDO is fixed (cannot be modified) */ + fixed: boolean + /** Whether this PDO is mandatory */ + mandatory: boolean + /** SM index this PDO is assigned to */ + smIndex?: number + /** List of entries in this PDO */ + entries: ESIPdoEntry[] +} + +// ===================== COE (CANopen over EtherCAT) ===================== + +/** + * CoE Object Dictionary entry + */ +export interface ESICoEObject { + /** Object index (hex) */ + index: string + /** Object name */ + name: string + /** Object type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** PDO mapping allowed */ + pdoMapping: boolean + /** Object category: M=Mandatory, O=Optional, C=Conditional */ + category?: 'M' | 'O' | 'C' + /** PDO mapping direction: R=RxPDO, T=TxPDO, RT=both */ + pdoMappingDirection?: 'R' | 'T' | 'RT' + /** Default value */ + defaultValue?: string + /** Subindexes for complex objects */ + subItems?: ESICoESubItem[] +} + +/** + * CoE Object subitem (for array/record types) + */ +export interface ESICoESubItem { + /** Subindex */ + subIndex: string + /** Name */ + name: string + /** Data type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** Whether this sub-item can be PDO-mapped */ + pdoMapping?: boolean + /** Default value */ + defaultValue?: string +} + +// ===================== SDO CONFIGURATION ===================== + +/** + * SDO (Service Data Object) configuration entry for startup parameters. + * Each entry represents a single parameter to be written to the slave at startup. + */ +export interface SDOConfigurationEntry { + /** Object index (hex, e.g., "0x8000") */ + index: string + /** Subindex: 0 for simple objects, 1+ for sub-items */ + subIndex: number + /** Value configured by the user */ + value: string + /** Default value from the ESI */ + defaultValue: string + /** Data type (e.g., "UINT16", "BOOL") */ + dataType: string + /** Bit length of the parameter */ + bitLength: number + /** Parameter name */ + name: string + /** Parent object name */ + objectName: string +} + +// ===================== DEVICE ENRICHMENT ===================== + +/** + * Data extracted from a full ESIDevice for persistence into ConfiguredEtherCATDevice. + * Returned by enrichDeviceData() and used by device configuration components. + */ +export type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== DEVICE ===================== + +/** + * Complete ESI Device representation + */ +export interface ESIDevice { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** FMMU configurations */ + fmmu: ESIFMMU[] + /** Sync Manager configurations */ + syncManagers: ESISyncManager[] + /** RxPDOs (master to slave) */ + rxPdo: ESIPdo[] + /** TxPDOs (slave to master) */ + txPdo: ESIPdo[] + /** CoE objects (optional) */ + coeObjects?: ESICoEObject[] + /** Device image URL (optional) */ + imageUrl?: string + /** Additional description */ + description?: string +} + +// ===================== GROUP ===================== + +/** + * Device group/category + */ +export interface ESIGroup { + /** Group type identifier */ + type: string + /** Group name */ + name: string + /** Group image URL (optional) */ + imageUrl?: string + /** Group description */ + description?: string +} + +// ===================== COMPLETE ESI FILE ===================== + +/** + * Complete ESI file representation + */ +export interface ESIFile { + /** Vendor information */ + vendor: ESIVendor + /** Device groups */ + groups: ESIGroup[] + /** Devices in the file */ + devices: ESIDevice[] + /** Original filename */ + filename?: string + /** File version info */ + version?: string + /** Creation/modification info */ + infoData?: { + version?: string + creationDate?: string + modificationDate?: string + vendorUrl?: string + } +} + +// ===================== PARSED CHANNEL (for UI) ===================== + +/** + * Represents a channel that can be mapped to a located variable + * This is a flattened view of PDO entries for easier UI handling + */ +export interface ESIChannel { + /** Unique identifier for this channel */ + id: string + /** PDO type: input (TxPdo) or output (RxPdo) */ + direction: 'input' | 'output' + /** Parent PDO index */ + pdoIndex: string + /** Parent PDO name */ + pdoName: string + /** Entry index */ + entryIndex: string + /** Entry subindex */ + entrySubIndex: string + /** Channel name */ + name: string + /** Data type */ + dataType: ESIDataType + /** Bit length */ + bitLen: number + /** Bit offset within the PDO */ + bitOffset: number + /** Byte offset (calculated) */ + byteOffset: number + /** IEC 61131-3 compatible type */ + iecType: string + /** Whether this channel is selected for mapping */ + selected?: boolean + /** Mapped variable name (if assigned) */ + mappedVariable?: string +} + +// ===================== PERSISTED PDO/CHANNEL DATA ===================== + +/** + * Persisted PDO entry - stored in project.json for runtime config generation. + * Includes padding entries (index "0x0000") for complete PDO layout. + */ +export interface PersistedPdoEntry { + /** Entry index (hex, e.g., "0x6000") */ + index: string + /** Entry subindex (hex, e.g., "0x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name */ + name: string + /** Data type (e.g., "BOOL", "INT16", "BIT" for padding) */ + dataType: string +} + +/** + * Persisted PDO - stored in project.json for runtime config generation. + */ +export interface PersistedPdo { + /** PDO index (hex, e.g., "0x1A00") */ + index: string + /** PDO name */ + name: string + /** PDO entries including padding */ + entries: PersistedPdoEntry[] +} + +/** + * Persisted channel info with full metadata from ESI. + * Enriches the minimal channelId stored in EtherCATChannelMapping. + */ +export interface PersistedChannelInfo { + /** Unique channel ID matching ESIChannel.id format */ + channelId: string + /** Channel display name from ESI */ + name: string + /** Channel direction */ + direction: 'input' | 'output' + /** Parent PDO index (hex) */ + pdoIndex: string + /** Entry index (hex) */ + entryIndex: string + /** Entry subindex (hex) */ + entrySubIndex: string + /** ESI data type */ + dataType: string + /** Bit length */ + bitLen: number + /** IEC 61131-3 compatible type */ + iecType: string +} + +// ===================== CHANNEL MAPPING ===================== + +/** + * Mapping of an ESI channel to an IEC 61131-3 located variable address + */ +export interface EtherCATChannelMapping { + /** Matches ESIChannel.id */ + channelId: string + /** IEC 61131-3 located variable address (e.g., "%IX0.0", "%QW2") */ + iecLocation: string + /** True if the user manually edited this address */ + userEdited: boolean + /** User-editable alias for this channel mapping */ + alias?: string +} + +// ===================== PARSE RESULT ===================== + +/** + * Result of parsing an ESI file + */ +export interface ESIParseResult { + success: boolean + data?: ESIFile + error?: string + warnings?: string[] +} + +// ===================== DEVICE SUMMARY (lightweight) ===================== + +/** + * Lightweight device metadata without PDOs/SM/FMMU. + * Used for repository listing and device matching without full parsing. + */ +export interface ESIDeviceSummary { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** Pre-computed count of non-padding TxPDO entries */ + inputChannelCount: number + /** Pre-computed count of non-padding RxPDO entries */ + outputChannelCount: number + /** Pre-computed total input bytes */ + totalInputBytes: number + /** Pre-computed total output bytes */ + totalOutputBytes: number + /** Additional description */ + description?: string +} + +// ===================== REPOSITORY ===================== + +/** + * Item in the ESI repository (a loaded ESI file) + */ +export interface ESIRepositoryItem { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Devices contained in this file */ + devices: ESIDevice[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +/** + * Lightweight repository item with device summaries instead of full ESIDevice objects. + * Used for UI display and matching without loading full PDO data. + */ +export interface ESIRepositoryItemLight { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Lightweight device summaries */ + devices: ESIDeviceSummary[] + /** Timestamp when this file was loaded */ + loadedAt: number + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +// ===================== CONFIGURED DEVICES ===================== + +/** + * Reference to an ESI device in the repository + */ +export interface ESIDeviceRef { + /** ID of the repository item containing the device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number +} + +/** + * A configured EtherCAT device in the project + */ +export interface ConfiguredEtherCATDevice { + /** Unique identifier */ + id: string + /** Position in the EtherCAT network (from scan or manual assignment) */ + position?: number + /** User-editable name for this device */ + name: string + /** Reference to the ESI device definition */ + esiDeviceRef: ESIDeviceRef + /** Vendor ID (hex format) */ + vendorId: string + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** How this device was added */ + addedFrom: 'repository' | 'scan' + /** Per-slave configuration settings */ + config: EtherCATSlaveConfig + /** Channel-to-located-variable mappings */ + channelMappings: EtherCATChannelMapping[] + /** Enriched channel metadata from ESI (persisted for runtime config generation) */ + channelInfo?: PersistedChannelInfo[] + /** RxPDOs with full layout including padding (persisted for runtime config generation) */ + rxPdos?: PersistedPdo[] + /** TxPDOs with full layout including padding (persisted for runtime config generation) */ + txPdos?: PersistedPdo[] + /** Slave device type classification (e.g., "digital_input", "coupler") */ + slaveType?: string + /** SDO startup parameters extracted from CoE Object Dictionary */ + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== PER-SLAVE CONFIGURATION ===================== + +/** + * Startup identity checks for an EtherCAT slave. + * When enabled, the master verifies the slave's identity during startup. + */ +export interface EtherCATStartupChecks { + /** Verify slave vendor ID matches ESI definition */ + checkVendorId: boolean + /** Verify slave product code matches ESI definition */ + checkProductCode: boolean +} + +/** + * Addressing configuration for an EtherCAT slave. + */ +export interface EtherCATAddressing { + /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ + ethercatAddress: number +} + +/** + * Timeout settings for an EtherCAT slave. + */ +export interface EtherCATTimeouts { + /** SDO (Service Data Object) operation timeout in milliseconds */ + sdoTimeoutMs: number + /** Init to Pre-Operational state transition timeout in milliseconds */ + initToPreOpTimeoutMs: number + /** Pre-Op to Safe-Op and Safe-Op to Operational transition timeout in milliseconds */ + safeOpToOpTimeoutMs: number +} + +/** + * Watchdog settings for an EtherCAT slave. + */ +export interface EtherCATWatchdog { + /** Enable Sync Manager watchdog */ + smWatchdogEnabled: boolean + /** Sync Manager watchdog time in milliseconds */ + smWatchdogMs: number + /** Enable Process Data Interface (PDI) watchdog */ + pdiWatchdogEnabled: boolean + /** PDI watchdog time in milliseconds */ + pdiWatchdogMs: number +} + +/** + * Distributed Clocks (DC) settings for an EtherCAT slave. + * DC provides synchronized timing across all slaves in the network. + */ +export interface EtherCATDistributedClocks { + /** Enable Distributed Clocks for this slave */ + dcEnabled: boolean + /** Base sync unit cycle time in microseconds. 0 = use master cycle time */ + dcSyncUnitCycleUs: number + /** Enable SYNC0 pulse generation */ + dcSync0Enabled: boolean + /** SYNC0 cycle time in microseconds. 0 = use master cycle time */ + dcSync0CycleUs: number + /** SYNC0 shift/offset time in microseconds */ + dcSync0ShiftUs: number + /** Enable SYNC1 pulse generation */ + dcSync1Enabled: boolean + /** SYNC1 cycle time in microseconds. 0 = use master cycle time */ + dcSync1CycleUs: number + /** SYNC1 shift/offset time in microseconds */ + dcSync1ShiftUs: number +} + +/** + * Complete per-slave configuration for a configured EtherCAT device. + */ +export interface EtherCATSlaveConfig { + /** Identity verification during startup */ + startupChecks: EtherCATStartupChecks + /** Network addressing */ + addressing: EtherCATAddressing + /** Communication timeouts */ + timeouts: EtherCATTimeouts + /** Watchdog settings */ + watchdog: EtherCATWatchdog + /** Distributed Clocks (DC) settings */ + distributedClocks: EtherCATDistributedClocks +} + +// ===================== DEVICE MATCHING ===================== + +/** + * Quality of match between a scanned device and ESI device + */ +export type DeviceMatchQuality = 'exact' | 'partial' | 'none' + +/** + * A potential match for a scanned device + */ +export interface DeviceMatch { + /** ID of the repository item containing the matched device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number + /** Quality of the match */ + matchQuality: DeviceMatchQuality + /** The matched ESI device (lightweight summary) */ + esiDevice: ESIDeviceSummary +} + +/** + * A scanned device with its potential matches from the repository + */ +export interface ScannedDeviceMatch { + /** The scanned device from network discovery */ + device: { + position: number + name: string + vendor_id: number + product_code: number + revision: number + serial_number: number + state: string + input_bytes: number + output_bytes: number + } + /** List of potential matches from the repository */ + matches: DeviceMatch[] + /** The match selected by the user for addition */ + selectedMatch?: ESIDeviceRef +} diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts index 4ec1f0f30..9bd74d94a 100644 --- a/src/types/ethercat/esi-types.ts +++ b/src/types/ethercat/esi-types.ts @@ -1,626 +1,10 @@ /** - * EtherCAT Slave Information (ESI) Types + * EtherCAT Slave Information (ESI) Types — re-export barrel. * - * Types for parsing and representing ESI XML files following ETG.2000 specification. - * ESI files describe EtherCAT slave device properties, PDO mappings, and communication settings. - */ - -// ===================== VENDOR ===================== - -/** - * Vendor information from ESI file - */ -export interface ESIVendor { - /** Vendor ID (hex format, e.g., "0x0002" for Beckhoff) */ - id: string - /** Vendor name */ - name: string -} - -// ===================== DEVICE INFO ===================== - -/** - * Device type information - */ -export interface ESIDeviceType { - /** Product code (hex format) */ - productCode: string - /** Revision number (hex format) */ - revisionNo: string - /** Type name/description */ - name: string -} - -/** - * Sync Manager configuration - */ -export interface ESISyncManager { - /** SM index (0-3 typically) */ - index: number - /** Start address */ - startAddress: string - /** Control byte */ - controlByte: string - /** Default size */ - defaultSize: number - /** Enable flag */ - enable: boolean - /** SM type: Mailbox Out, Mailbox In, Process Data Out, Process Data In */ - type: 'MbxOut' | 'MbxIn' | 'Outputs' | 'Inputs' -} - -/** - * FMMU (Fieldbus Memory Management Unit) configuration - */ -export interface ESIFMMU { - /** FMMU type: Outputs, Inputs, MbxState */ - type: 'Outputs' | 'Inputs' | 'MbxState' -} - -// ===================== PDO ENTRIES ===================== - -/** - * EtherCAT data types used in PDO entries - * Common types: BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, - * REAL, LREAL, STRING, BYTE, WORD, DWORD, BIT, BIT2-BIT7 - * Using string to allow vendor-specific custom types - */ -export type ESIDataType = string - -/** - * PDO Entry - represents a single variable in a PDO - */ -export interface ESIPdoEntry { - /** Entry index (hex, e.g., "#x6000") */ - index: string - /** Entry subindex (hex, e.g., "#x01") */ - subIndex: string - /** Bit length of the data */ - bitLen: number - /** Entry name/identifier */ - name: string - /** Data type */ - dataType: ESIDataType - /** Optional: Comment/description */ - comment?: string -} - -/** - * Process Data Object - TxPdo (slave to master) or RxPdo (master to slave) - */ -export interface ESIPdo { - /** PDO index (hex, e.g., "#x1600" for RxPdo, "#x1A00" for TxPdo) */ - index: string - /** PDO name */ - name: string - /** Whether this PDO is fixed (cannot be modified) */ - fixed: boolean - /** Whether this PDO is mandatory */ - mandatory: boolean - /** SM index this PDO is assigned to */ - smIndex?: number - /** List of entries in this PDO */ - entries: ESIPdoEntry[] -} - -// ===================== COE (CANopen over EtherCAT) ===================== - -/** - * CoE Object Dictionary entry - */ -export interface ESICoEObject { - /** Object index (hex) */ - index: string - /** Object name */ - name: string - /** Object type */ - type: string - /** Bit size */ - bitSize: number - /** Access rights */ - access: 'RO' | 'RW' | 'WO' - /** PDO mapping allowed */ - pdoMapping: boolean - /** Object category: M=Mandatory, O=Optional, C=Conditional */ - category?: 'M' | 'O' | 'C' - /** PDO mapping direction: R=RxPDO, T=TxPDO, RT=both */ - pdoMappingDirection?: 'R' | 'T' | 'RT' - /** Default value */ - defaultValue?: string - /** Subindexes for complex objects */ - subItems?: ESICoESubItem[] -} - -/** - * CoE Object subitem (for array/record types) - */ -export interface ESICoESubItem { - /** Subindex */ - subIndex: string - /** Name */ - name: string - /** Data type */ - type: string - /** Bit size */ - bitSize: number - /** Access rights */ - access: 'RO' | 'RW' | 'WO' - /** Whether this sub-item can be PDO-mapped */ - pdoMapping?: boolean - /** Default value */ - defaultValue?: string -} - -// ===================== SDO CONFIGURATION ===================== - -/** - * SDO (Service Data Object) configuration entry for startup parameters. - * Each entry represents a single parameter to be written to the slave at startup. - */ -export interface SDOConfigurationEntry { - /** Object index (hex, e.g., "0x8000") */ - index: string - /** Subindex: 0 for simple objects, 1+ for sub-items */ - subIndex: number - /** Value configured by the user */ - value: string - /** Default value from the ESI */ - defaultValue: string - /** Data type (e.g., "UINT16", "BOOL") */ - dataType: string - /** Bit length of the parameter */ - bitLength: number - /** Parameter name */ - name: string - /** Parent object name */ - objectName: string -} - -// ===================== DEVICE ENRICHMENT ===================== - -/** - * Data extracted from a full ESIDevice for persistence into ConfiguredEtherCATDevice. - * Returned by enrichDeviceData() and used by device configuration components. - */ -export type EnrichDeviceData = { - channelInfo?: PersistedChannelInfo[] - rxPdos?: PersistedPdo[] - txPdos?: PersistedPdo[] - slaveType?: string - sdoConfigurations?: SDOConfigurationEntry[] -} - -// ===================== DEVICE ===================== - -/** - * Complete ESI Device representation - */ -export interface ESIDevice { - /** Device type information */ - type: ESIDeviceType - /** Device name */ - name: string - /** Group name (category) */ - groupName?: string - /** Physics type (e.g., "YY") */ - physics?: string - /** FMMU configurations */ - fmmu: ESIFMMU[] - /** Sync Manager configurations */ - syncManagers: ESISyncManager[] - /** RxPDOs (master to slave) */ - rxPdo: ESIPdo[] - /** TxPDOs (slave to master) */ - txPdo: ESIPdo[] - /** CoE objects (optional) */ - coeObjects?: ESICoEObject[] - /** Device image URL (optional) */ - imageUrl?: string - /** Additional description */ - description?: string -} - -// ===================== GROUP ===================== - -/** - * Device group/category - */ -export interface ESIGroup { - /** Group type identifier */ - type: string - /** Group name */ - name: string - /** Group image URL (optional) */ - imageUrl?: string - /** Group description */ - description?: string -} - -// ===================== COMPLETE ESI FILE ===================== - -/** - * Complete ESI file representation - */ -export interface ESIFile { - /** Vendor information */ - vendor: ESIVendor - /** Device groups */ - groups: ESIGroup[] - /** Devices in the file */ - devices: ESIDevice[] - /** Original filename */ - filename?: string - /** File version info */ - version?: string - /** Creation/modification info */ - infoData?: { - version?: string - creationDate?: string - modificationDate?: string - vendorUrl?: string - } -} - -// ===================== PARSED CHANNEL (for UI) ===================== - -/** - * Represents a channel that can be mapped to a located variable - * This is a flattened view of PDO entries for easier UI handling - */ -export interface ESIChannel { - /** Unique identifier for this channel */ - id: string - /** PDO type: input (TxPdo) or output (RxPdo) */ - direction: 'input' | 'output' - /** Parent PDO index */ - pdoIndex: string - /** Parent PDO name */ - pdoName: string - /** Entry index */ - entryIndex: string - /** Entry subindex */ - entrySubIndex: string - /** Channel name */ - name: string - /** Data type */ - dataType: ESIDataType - /** Bit length */ - bitLen: number - /** Bit offset within the PDO */ - bitOffset: number - /** Byte offset (calculated) */ - byteOffset: number - /** IEC 61131-3 compatible type */ - iecType: string - /** Whether this channel is selected for mapping */ - selected?: boolean - /** Mapped variable name (if assigned) */ - mappedVariable?: string -} - -// ===================== PERSISTED PDO/CHANNEL DATA ===================== - -/** - * Persisted PDO entry - stored in project.json for runtime config generation. - * Includes padding entries (index "0x0000") for complete PDO layout. - */ -export interface PersistedPdoEntry { - /** Entry index (hex, e.g., "0x6000") */ - index: string - /** Entry subindex (hex, e.g., "0x01") */ - subIndex: string - /** Bit length of the data */ - bitLen: number - /** Entry name */ - name: string - /** Data type (e.g., "BOOL", "INT16", "BIT" for padding) */ - dataType: string -} - -/** - * Persisted PDO - stored in project.json for runtime config generation. - */ -export interface PersistedPdo { - /** PDO index (hex, e.g., "0x1A00") */ - index: string - /** PDO name */ - name: string - /** PDO entries including padding */ - entries: PersistedPdoEntry[] -} - -/** - * Persisted channel info with full metadata from ESI. - * Enriches the minimal channelId stored in EtherCATChannelMapping. - */ -export interface PersistedChannelInfo { - /** Unique channel ID matching ESIChannel.id format */ - channelId: string - /** Channel display name from ESI */ - name: string - /** Channel direction */ - direction: 'input' | 'output' - /** Parent PDO index (hex) */ - pdoIndex: string - /** Entry index (hex) */ - entryIndex: string - /** Entry subindex (hex) */ - entrySubIndex: string - /** ESI data type */ - dataType: string - /** Bit length */ - bitLen: number - /** IEC 61131-3 compatible type */ - iecType: string -} - -// ===================== CHANNEL MAPPING ===================== - -/** - * Mapping of an ESI channel to an IEC 61131-3 located variable address - */ -export interface EtherCATChannelMapping { - /** Matches ESIChannel.id */ - channelId: string - /** IEC 61131-3 located variable address (e.g., "%IX0.0", "%QW2") */ - iecLocation: string - /** True if the user manually edited this address */ - userEdited: boolean - /** User-editable alias for this channel mapping */ - alias?: string -} - -// ===================== PARSE RESULT ===================== - -/** - * Result of parsing an ESI file - */ -export interface ESIParseResult { - success: boolean - data?: ESIFile - error?: string - warnings?: string[] -} - -// ===================== DEVICE SUMMARY (lightweight) ===================== - -/** - * Lightweight device metadata without PDOs/SM/FMMU. - * Used for repository listing and device matching without full parsing. - */ -export interface ESIDeviceSummary { - /** Device type information */ - type: ESIDeviceType - /** Device name */ - name: string - /** Group name (category) */ - groupName?: string - /** Physics type (e.g., "YY") */ - physics?: string - /** Pre-computed count of non-padding TxPDO entries */ - inputChannelCount: number - /** Pre-computed count of non-padding RxPDO entries */ - outputChannelCount: number - /** Pre-computed total input bytes */ - totalInputBytes: number - /** Pre-computed total output bytes */ - totalOutputBytes: number - /** Additional description */ - description?: string -} - -// ===================== REPOSITORY ===================== - -/** - * Item in the ESI repository (a loaded ESI file) - */ -export interface ESIRepositoryItem { - /** Unique identifier for this repository item */ - id: string - /** Original filename */ - filename: string - /** Vendor information */ - vendor: ESIVendor - /** Devices contained in this file */ - devices: ESIDevice[] - /** Timestamp when this file was loaded */ - loadedAt: number - /** Parsing warnings (non-fatal issues) */ - warnings?: string[] -} - -/** - * Lightweight repository item with device summaries instead of full ESIDevice objects. - * Used for UI display and matching without loading full PDO data. - */ -export interface ESIRepositoryItemLight { - /** Unique identifier for this repository item */ - id: string - /** Original filename */ - filename: string - /** Vendor information */ - vendor: ESIVendor - /** Lightweight device summaries */ - devices: ESIDeviceSummary[] - /** Timestamp when this file was loaded */ - loadedAt: number - /** Parsing warnings (non-fatal issues) */ - warnings?: string[] -} - -// ===================== CONFIGURED DEVICES ===================== - -/** - * Reference to an ESI device in the repository - */ -export interface ESIDeviceRef { - /** ID of the repository item containing the device */ - repositoryItemId: string - /** Index of the device within the repository item */ - deviceIndex: number -} - -/** - * A configured EtherCAT device in the project - */ -export interface ConfiguredEtherCATDevice { - /** Unique identifier */ - id: string - /** Position in the EtherCAT network (from scan or manual assignment) */ - position?: number - /** User-editable name for this device */ - name: string - /** Reference to the ESI device definition */ - esiDeviceRef: ESIDeviceRef - /** Vendor ID (hex format) */ - vendorId: string - /** Product code (hex format) */ - productCode: string - /** Revision number (hex format) */ - revisionNo: string - /** How this device was added */ - addedFrom: 'repository' | 'scan' - /** Per-slave configuration settings */ - config: EtherCATSlaveConfig - /** Channel-to-located-variable mappings */ - channelMappings: EtherCATChannelMapping[] - /** Enriched channel metadata from ESI (persisted for runtime config generation) */ - channelInfo?: PersistedChannelInfo[] - /** RxPDOs with full layout including padding (persisted for runtime config generation) */ - rxPdos?: PersistedPdo[] - /** TxPDOs with full layout including padding (persisted for runtime config generation) */ - txPdos?: PersistedPdo[] - /** Slave device type classification (e.g., "digital_input", "coupler") */ - slaveType?: string - /** SDO startup parameters extracted from CoE Object Dictionary */ - sdoConfigurations?: SDOConfigurationEntry[] -} - -// ===================== PER-SLAVE CONFIGURATION ===================== - -/** - * Startup identity checks for an EtherCAT slave. - * When enabled, the master verifies the slave's identity during startup. - */ -export interface EtherCATStartupChecks { - /** Verify slave vendor ID matches ESI definition */ - checkVendorId: boolean - /** Verify slave product code matches ESI definition */ - checkProductCode: boolean -} - -/** - * Addressing configuration for an EtherCAT slave. - */ -export interface EtherCATAddressing { - /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ - ethercatAddress: number -} - -/** - * Timeout settings for an EtherCAT slave. - */ -export interface EtherCATTimeouts { - /** SDO (Service Data Object) operation timeout in milliseconds */ - sdoTimeoutMs: number - /** Init to Pre-Operational state transition timeout in milliseconds */ - initToPreOpTimeoutMs: number - /** Pre-Op to Safe-Op and Safe-Op to Operational transition timeout in milliseconds */ - safeOpToOpTimeoutMs: number -} - -/** - * Watchdog settings for an EtherCAT slave. - */ -export interface EtherCATWatchdog { - /** Enable Sync Manager watchdog */ - smWatchdogEnabled: boolean - /** Sync Manager watchdog time in milliseconds */ - smWatchdogMs: number - /** Enable Process Data Interface (PDI) watchdog */ - pdiWatchdogEnabled: boolean - /** PDI watchdog time in milliseconds */ - pdiWatchdogMs: number -} - -/** - * Distributed Clocks (DC) settings for an EtherCAT slave. - * DC provides synchronized timing across all slaves in the network. - */ -export interface EtherCATDistributedClocks { - /** Enable Distributed Clocks for this slave */ - dcEnabled: boolean - /** Base sync unit cycle time in microseconds. 0 = use master cycle time */ - dcSyncUnitCycleUs: number - /** Enable SYNC0 pulse generation */ - dcSync0Enabled: boolean - /** SYNC0 cycle time in microseconds. 0 = use master cycle time */ - dcSync0CycleUs: number - /** SYNC0 shift/offset time in microseconds */ - dcSync0ShiftUs: number - /** Enable SYNC1 pulse generation */ - dcSync1Enabled: boolean - /** SYNC1 cycle time in microseconds. 0 = use master cycle time */ - dcSync1CycleUs: number - /** SYNC1 shift/offset time in microseconds */ - dcSync1ShiftUs: number -} - -/** - * Complete per-slave configuration for a configured EtherCAT device. - */ -export interface EtherCATSlaveConfig { - /** Identity verification during startup */ - startupChecks: EtherCATStartupChecks - /** Network addressing */ - addressing: EtherCATAddressing - /** Communication timeouts */ - timeouts: EtherCATTimeouts - /** Watchdog settings */ - watchdog: EtherCATWatchdog - /** Distributed Clocks (DC) settings */ - distributedClocks: EtherCATDistributedClocks -} - -// ===================== DEVICE MATCHING ===================== - -/** - * Quality of match between a scanned device and ESI device - */ -export type DeviceMatchQuality = 'exact' | 'partial' | 'none' - -/** - * A potential match for a scanned device - */ -export interface DeviceMatch { - /** ID of the repository item containing the matched device */ - repositoryItemId: string - /** Index of the device within the repository item */ - deviceIndex: number - /** Quality of the match */ - matchQuality: DeviceMatchQuality - /** The matched ESI device (lightweight summary) */ - esiDevice: ESIDeviceSummary -} - -/** - * A scanned device with its potential matches from the repository + * Canonical definitions live in src/middleware/shared/ports/esi-types.ts + * (a shared surface synced between openplc-editor and openplc-web). + * + * This file re-exports everything so existing consumers that import from + * `@root/types/ethercat/esi-types` continue working without changes. */ -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 -} +export * from '../../middleware/shared/ports/esi-types' From 69ea68fb6bb134f4c903e3e7a9e6a21534ca2555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 16 Apr 2026 13:20:39 +0200 Subject: [PATCH 19/30] fix: move ESI types to ports surface and update all imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves architecture validation failures by ensuring ESI types live exclusively in src/middleware/shared/ports/esi-types.ts (the ports layer) and all 30 consumers import from there directly. The previous approach (re-export barrel in types/ethercat/) violated two architecture rules simultaneously: ports->types and types->ports were both forbidden. This commit eliminates the barrel entirely — types/ethercat/index.ts no longer re-exports ESI types, and all imports now use @root/middleware/shared/ports/esi-types. Also runs lint:fix to resolve import sorting issues in synced files. Co-authored-by: Claude Opus 4.6 (1M context) --- src/backend/editor/ethercat/esi-service.ts | 2 +- src/backend/shared/ethercat/device-config-defaults.ts | 2 +- src/backend/shared/ethercat/device-matcher.ts | 2 +- src/backend/shared/ethercat/enrich-device-data.ts | 2 +- src/backend/shared/ethercat/esi-parser-main.ts | 2 +- src/backend/shared/ethercat/esi-parser.ts | 2 +- .../shared/ethercat/generate-ethercat-config.ts | 2 +- src/backend/shared/ethercat/sdo-config-defaults.ts | 2 +- .../ethercat/components/channel-mapping-table.tsx | 2 +- .../ethercat/components/configured-device-row.tsx | 2 +- .../device/ethercat/components/configured-devices.tsx | 2 +- .../ethercat/components/device-browser-modal.tsx | 2 +- .../ethercat/components/device-configuration-form.tsx | 2 +- .../device/ethercat/components/device-detail-panel.tsx | 2 +- .../editor/device/ethercat/components/devices-tab.tsx | 2 +- .../device/ethercat/components/diagnostics-tab.tsx | 2 +- .../ethercat/components/discovered-device-table.tsx | 2 +- .../device/ethercat/components/esi-channels-table.tsx | 2 +- .../device/ethercat/components/esi-device-info.tsx | 2 +- .../ethercat/components/esi-repository-table.tsx | 2 +- .../device/ethercat/components/esi-repository.tsx | 2 +- .../editor/device/ethercat/components/esi-upload.tsx | 2 +- .../device/ethercat/components/repository-tab.tsx | 2 +- .../editor/device/ethercat/components/scan-bus-tab.tsx | 2 +- .../ethercat/components/sdo-parameters-table.tsx | 2 +- .../editor/device/ethercat/ethercat-device-editor.tsx | 2 +- .../[workspace]/editor/device/ethercat/index.tsx | 2 +- src/frontend/hooks/use-device-configuration.ts | 2 +- src/main/modules/ipc/main.ts | 2 +- src/main/modules/ipc/renderer.ts | 2 +- src/types/ethercat/esi-types.ts | 10 ---------- src/types/ethercat/index.ts | 5 +++-- 32 files changed, 33 insertions(+), 42 deletions(-) delete mode 100644 src/types/ethercat/esi-types.ts diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts index a9705db96..1a4cdaa24 100644 --- a/src/backend/editor/ethercat/esi-service.ts +++ b/src/backend/editor/ethercat/esi-service.ts @@ -1,4 +1,4 @@ -import type { ESIDeviceSummary, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIDeviceSummary, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { promises } from 'fs' import { basename, dirname, join } from 'path' import { v4 as uuidv4 } from 'uuid' diff --git a/src/backend/shared/ethercat/device-config-defaults.ts b/src/backend/shared/ethercat/device-config-defaults.ts index d1c1d6403..483e7f8f7 100644 --- a/src/backend/shared/ethercat/device-config-defaults.ts +++ b/src/backend/shared/ethercat/device-config-defaults.ts @@ -1,4 +1,4 @@ -import type { EtherCATSlaveConfig } from '@root/types/ethercat/esi-types' +import type { EtherCATSlaveConfig } from '@root/middleware/shared/ports/esi-types' /** * Default per-slave configuration for a newly added EtherCAT device. diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts index cb267d051..7739b811a 100644 --- a/src/backend/shared/ethercat/device-matcher.ts +++ b/src/backend/shared/ethercat/device-matcher.ts @@ -10,7 +10,7 @@ import type { DeviceMatchQuality, ESIRepositoryItemLight, ScannedDeviceMatch, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' /** * Parse a hex string to a number for comparison diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts index 73b893961..7d04d2eab 100644 --- a/src/backend/shared/ethercat/enrich-device-data.ts +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -13,7 +13,7 @@ import type { PersistedPdo, PersistedPdoEntry, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' import { extractDefaultSdoConfigurations } from './sdo-config-defaults' diff --git a/src/backend/shared/ethercat/esi-parser-main.ts b/src/backend/shared/ethercat/esi-parser-main.ts index e8c741afd..2ee57c19e 100644 --- a/src/backend/shared/ethercat/esi-parser-main.ts +++ b/src/backend/shared/ethercat/esi-parser-main.ts @@ -19,7 +19,7 @@ import type { ESIPdoEntry, ESISyncManager, ESIVendor, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { XMLParser } from 'fast-xml-parser' // ===================== SHARED HELPERS ===================== diff --git a/src/backend/shared/ethercat/esi-parser.ts b/src/backend/shared/ethercat/esi-parser.ts index 983146817..61da25461 100644 --- a/src/backend/shared/ethercat/esi-parser.ts +++ b/src/backend/shared/ethercat/esi-parser.ts @@ -11,7 +11,7 @@ * in src/main/services/esi-service/esi-parser-main.ts. */ -import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' +import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/middleware/shared/ports/esi-types' /** * Map ESI data type to IEC 61131-3 type diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts index 28fc45728..482a43530 100644 --- a/src/backend/shared/ethercat/generate-ethercat-config.ts +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -4,7 +4,7 @@ import type { PersistedChannelInfo, PersistedPdo, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { ethercatTaskName } from './ethercat-task-helpers' diff --git a/src/backend/shared/ethercat/sdo-config-defaults.ts b/src/backend/shared/ethercat/sdo-config-defaults.ts index 25c7dfc7a..9b3ab6646 100644 --- a/src/backend/shared/ethercat/sdo-config-defaults.ts +++ b/src/backend/shared/ethercat/sdo-config-defaults.ts @@ -6,7 +6,7 @@ * manufacturer-specific and profile-specific ranges. */ -import type { ESICoEObject, SDOConfigurationEntry } from '@root/types/ethercat/esi-types' +import type { ESICoEObject, SDOConfigurationEntry } from '@root/middleware/shared/ports/esi-types' /** * Parse a hex index string to a number for range comparison. diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx index 93d956869..348201276 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -1,5 +1,5 @@ import { cn } from '@root/frontend/utils/cn' -import type { ESIChannel, EtherCATChannelMapping } from '@root/types/ethercat/esi-types' +import type { ESIChannel, EtherCATChannelMapping } from '@root/middleware/shared/ports/esi-types' import React, { useCallback, useEffect, useMemo, useState } from 'react' type ChannelMappingTableProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx index 52df7067a..2dab3f5c6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -9,7 +9,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { useMemo } from 'react' import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx index c97a7ee71..fe17634d2 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -8,7 +8,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { useCallback, useEffect, useState } from 'react' import { ConfiguredDeviceRow } from './configured-device-row' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx index 9a49cbb7d..9345febe0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx @@ -1,6 +1,6 @@ import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/frontend/components/_molecules/modal' import { cn } from '@root/frontend/utils/cn' -import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { useCallback, useMemo, useState } from 'react' type DeviceBrowserModalProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx index 6a353070c..5c9a09140 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx @@ -9,7 +9,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { ChannelMappingTable } from './channel-mapping-table' import { SdoParametersTable } from './sdo-parameters-table' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx index bb4e1869a..de7d1d093 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx @@ -9,7 +9,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { useMemo, useState } from 'react' import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx index 0ebdf4c4b..545dfe1e9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -6,7 +6,7 @@ import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' import { useCallback, useMemo, useState } from 'react' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx index 6a5e5b751..0a94116b1 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -1,6 +1,6 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' -import type { ScannedDeviceMatch } from '@root/types/ethercat/esi-types' +import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' import { DiscoveredDeviceTable } from './discovered-device-table' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx index 224e764d6..fd78d8e99 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -1,5 +1,5 @@ import { Checkbox } from '@root/frontend/components/_atoms/checkbox' -import type { ScannedDeviceMatch } from '@root/types/ethercat/esi-types' +import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' import { getBestMatchQuality } from '@root/backend/shared/ethercat/device-matcher' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx index a56018365..7206b5a38 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx @@ -1,6 +1,6 @@ import { Checkbox } from '@root/frontend/components/_atoms/checkbox' import { cn } from '@root/frontend/utils/cn' -import type { ESIChannel } from '@root/types/ethercat/esi-types' +import type { ESIChannel } from '@root/middleware/shared/ports/esi-types' import { useMemo, useState } from 'react' type ESIChannelsTableProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx index e586ca237..7cbceef8c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx @@ -1,6 +1,6 @@ import { getDeviceSummary } from '@root/backend/shared/ethercat/esi-parser' import { cn } from '@root/frontend/utils/cn' -import type { ESIDevice, ESIFile } from '@root/types/ethercat/esi-types' +import type { ESIDevice, ESIFile } from '@root/middleware/shared/ports/esi-types' type ESIDeviceInfoProps = { esiFile: ESIFile diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx index 7ee75384f..712904fd2 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -1,6 +1,6 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { cn } from '@root/frontend/utils/cn' -import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { useCallback, useState } from 'react' type ESIRepositoryTableProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx index 25fc2f5f3..412c826fb 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -1,5 +1,5 @@ import { useEsi } from '@root/middleware/shared/providers/platform-context' -import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { useCallback, useState } from 'react' import { ESIRepositoryTable } from './esi-repository-table' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx index a0e1232fa..39affe9dd 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -1,6 +1,6 @@ import { cn } from '@root/frontend/utils/cn' import { useEsi } from '@root/middleware/shared/providers/platform-context' -import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { useCallback, useRef, useState } from 'react' import { ESIParseProgress } from './esi-parse-progress' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx index a38bef8e2..2935f06c0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx @@ -1,4 +1,4 @@ -import type { ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { ESIRepository } from './esi-repository' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx index 73495df44..9b6b4fcfa 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx @@ -9,7 +9,7 @@ import type { ESIDeviceSummary, ESIRepositoryItemLight, ScannedDeviceMatch, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' import { useState } from 'react' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx index ceb5d1384..c249e488b 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -1,6 +1,6 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { cn } from '@root/frontend/utils/cn' -import type { SDOConfigurationEntry } from '@root/types/ethercat/esi-types' +import type { SDOConfigurationEntry } from '@root/middleware/shared/ports/esi-types' import { useCallback, useEffect, useMemo, useState } from 'react' type SdoParametersTableProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index db67456d5..0ca65cbc9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -11,7 +11,7 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, SDOConfigurationEntry, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 21eb63112..997fe5613 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -14,7 +14,7 @@ import type { ESIDeviceSummary, ESIRepositoryItemLight, ScannedDeviceMatch, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts index 88d34c336..5517a1c9e 100644 --- a/src/frontend/hooks/use-device-configuration.ts +++ b/src/frontend/hooks/use-device-configuration.ts @@ -5,7 +5,7 @@ import type { ESICoEObject, EtherCATChannelMapping, EtherCATSlaveConfig, -} from '@root/types/ethercat/esi-types' +} from '@root/middleware/shared/ports/esi-types' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index a51a17f06..a9fcfe319 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -15,7 +15,7 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' -import type { ESIRepositoryItem } from '@root/types/ethercat/esi-types' +import type { ESIRepositoryItem } from '@root/middleware/shared/ports/esi-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 5ee804d45..314983630 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -11,7 +11,7 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' -import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/types/ethercat/esi-types' +import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { ipcRenderer, IpcRendererEvent } from 'electron' diff --git a/src/types/ethercat/esi-types.ts b/src/types/ethercat/esi-types.ts deleted file mode 100644 index 9bd74d94a..000000000 --- a/src/types/ethercat/esi-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * EtherCAT Slave Information (ESI) Types — re-export barrel. - * - * Canonical definitions live in src/middleware/shared/ports/esi-types.ts - * (a shared surface synced between openplc-editor and openplc-web). - * - * This file re-exports everything so existing consumers that import from - * `@root/types/ethercat/esi-types` continue working without changes. - */ -export * from '../../middleware/shared/ports/esi-types' diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts index b5b63354f..2fc16e149 100644 --- a/src/types/ethercat/index.ts +++ b/src/types/ethercat/index.ts @@ -5,8 +5,9 @@ * Based on the runtime's /api/discovery/* REST API. */ -// Re-export ESI types -export * from './esi-types' +// NOTE: ESI types (ESIDevice, ESIRepositoryItemLight, etc.) live in +// src/middleware/shared/ports/esi-types.ts (shared surface). Import them +// from '@root/middleware/shared/ports/esi-types' directly. // ===================== ENUMS ===================== From b0939c12dc213e15ec146331b2b09f2f4354c7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 16 Apr 2026 16:26:47 +0200 Subject: [PATCH 20/30] fix: resolve all CI lint, format, and architecture errors - Remove unused import CreateEtherCATDeviceEditor (shared/slice.ts) - Remove await on sync store actions renameRemoteDevice and renameEthercatDevice (project-tree/index.tsx) - Remove dead empty try/catch block (compiler-module.ts) - Run prettier on all src files Co-authored-by: Claude Opus 4.6 (1M context) --- src/backend/editor/compiler/compiler-module.ts | 6 +----- src/backend/editor/ethercat/esi-service.ts | 6 +++++- src/backend/shared/ethercat/device-matcher.ts | 2 +- src/backend/shared/ethercat/esi-parser.ts | 8 +++++++- src/backend/shared/ethercat/index.ts | 4 ++-- .../editor/device/ethercat/components/devices-tab.tsx | 2 +- .../editor/device/ethercat/components/diagnostics-tab.tsx | 4 ++-- .../ethercat/components/discovered-device-table.tsx | 4 ++-- .../editor/device/ethercat/components/esi-repository.tsx | 2 +- .../editor/device/ethercat/components/esi-upload.tsx | 2 +- .../device/ethercat/components/interface-selector.tsx | 2 +- .../device/ethercat/components/runtime-status-panel.tsx | 2 +- .../editor/device/ethercat/components/scan-bus-tab.tsx | 4 ++-- .../editor/device/ethercat/ethercat-device-editor.tsx | 4 ++-- .../[workspace]/editor/device/ethercat/index.tsx | 4 ++-- src/frontend/components/_molecules/project-tree/index.tsx | 4 ++-- src/frontend/hooks/use-device-configuration.ts | 6 +++--- src/frontend/store/slices/shared/slice.ts | 7 +------ src/main/modules/ipc/main.ts | 2 +- src/main/modules/ipc/renderer.ts | 2 +- 20 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index e78f3bd38..c9d0c2acb 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -188,11 +188,7 @@ class CompilerModule { return halsFileContent[board]['compiler'] } - // Board not found in hals.json - try { - } catch { - // ignore package manager errors - } + // Board not found in hals.json or installed VPP packages throw new Error(`Board "${board}" not found in hals.json or installed VPP packages`) } diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts index 1a4cdaa24..7349fa265 100644 --- a/src/backend/editor/ethercat/esi-service.ts +++ b/src/backend/editor/ethercat/esi-service.ts @@ -1,4 +1,8 @@ -import type { ESIDeviceSummary, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import type { + ESIDeviceSummary, + ESIRepositoryItem, + ESIRepositoryItemLight, +} from '@root/middleware/shared/ports/esi-types' import { promises } from 'fs' import { basename, dirname, join } from 'path' import { v4 as uuidv4 } from 'uuid' diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts index 7739b811a..377759039 100644 --- a/src/backend/shared/ethercat/device-matcher.ts +++ b/src/backend/shared/ethercat/device-matcher.ts @@ -4,13 +4,13 @@ * 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/middleware/shared/ports/esi-types' +import type { EtherCATDevice } from '@root/types/ethercat' /** * Parse a hex string to a number for comparison diff --git a/src/backend/shared/ethercat/esi-parser.ts b/src/backend/shared/ethercat/esi-parser.ts index 61da25461..6e460a59f 100644 --- a/src/backend/shared/ethercat/esi-parser.ts +++ b/src/backend/shared/ethercat/esi-parser.ts @@ -11,7 +11,13 @@ * in src/main/services/esi-service/esi-parser-main.ts. */ -import type { ESIChannel, ESIDataType, ESIDevice, ESIPdo, EtherCATChannelMapping } from '@root/middleware/shared/ports/esi-types' +import type { + ESIChannel, + ESIDataType, + ESIDevice, + ESIPdo, + EtherCATChannelMapping, +} from '@root/middleware/shared/ports/esi-types' /** * Map ESI data type to IEC 61131-3 type diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts index b47844856..b89a1087e 100644 --- a/src/backend/shared/ethercat/index.ts +++ b/src/backend/shared/ethercat/index.ts @@ -1,9 +1,9 @@ +export { collectUsedIecAddresses } from './collect-used-iec-addresses' export { createDefaultSlaveConfig, DEFAULT_SLAVE_CONFIG } from './device-config-defaults' -export { cycleTimeUsToIecInterval, ethercatTaskName } from './ethercat-task-helpers' export { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from './device-matcher' -export { collectUsedIecAddresses } from './collect-used-iec-addresses' export { buildChannelInfo, deriveSlaveType, persistPdos } from './enrich-device-data' export { esiTypeToIecType, pdoToChannels } from './esi-parser' export { parseESIDeviceFull, parseESILight } from './esi-parser-main' +export { cycleTimeUsToIecInterval, ethercatTaskName } from './ethercat-task-helpers' export { generateEthercatConfig } from './generate-ethercat-config' export { extractDefaultSdoConfigurations } from './sdo-config-defaults' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx index 545dfe1e9..2982b9681 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -1,13 +1,13 @@ import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' import TableActions from '@root/frontend/components/_atoms/table-actions' +import { cn } from '@root/frontend/utils/cn' import type { ConfiguredEtherCATDevice, ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight, } from '@root/middleware/shared/ports/esi-types' -import { cn } from '@root/frontend/utils/cn' import { useCallback, useMemo, useState } from 'react' import { DeviceBrowserModal } from './device-browser-modal' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx index 0a94116b1..d3ab20416 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -1,7 +1,7 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' -import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' +import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import { DiscoveredDeviceTable } from './discovered-device-table' import { InterfaceSelector } from './interface-selector' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx index fd78d8e99..7d0d2b337 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -1,7 +1,7 @@ +import { getBestMatchQuality } from '@root/backend/shared/ethercat/device-matcher' import { Checkbox } from '@root/frontend/components/_atoms/checkbox' -import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' import { cn } from '@root/frontend/utils/cn' -import { getBestMatchQuality } from '@root/backend/shared/ethercat/device-matcher' +import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' type DiscoveredDeviceTableProps = { deviceMatches: ScannedDeviceMatch[] diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx index 412c826fb..85f3ae3bf 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -1,5 +1,5 @@ -import { useEsi } from '@root/middleware/shared/providers/platform-context' import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' import { useCallback, useState } from 'react' import { ESIRepositoryTable } from './esi-repository-table' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx index 39affe9dd..66d844b49 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -1,6 +1,6 @@ import { cn } from '@root/frontend/utils/cn' -import { useEsi } from '@root/middleware/shared/providers/platform-context' import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' import { useCallback, useRef, useState } from 'react' import { ESIParseProgress } from './esi-parse-progress' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx index d3375c8e3..065dcf497 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx @@ -3,8 +3,8 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' import { InputWithRef } from '@root/frontend/components/_atoms/input' import { Label } from '@root/frontend/components/_atoms/label' -import type { NetworkInterface } from '@root/types/ethercat' import { cn } from '@root/frontend/utils/cn' +import type { NetworkInterface } from '@root/types/ethercat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' type InterfaceSelectorProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx index 64b30d4ba..3b58c1066 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -1,3 +1,4 @@ +import { cn } from '@root/frontend/utils/cn' import { useRuntime } from '@root/middleware/shared/providers/platform-context' import type { EtherCATCycleMetrics, @@ -6,7 +7,6 @@ import type { EtherCATRuntimeStatusResponse, EtherCATSlaveStatus, } from '@root/types/ethercat' -import { cn } from '@root/frontend/utils/cn' import { useCallback, useEffect, useRef, useState } from 'react' const POLL_INTERVAL_MS = 2000 diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx index 9b6b4fcfa..f2a6500a9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx @@ -2,7 +2,7 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' import TableActions from '@root/frontend/components/_atoms/table-actions' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import { cn } from '@root/frontend/utils/cn' import type { ConfiguredEtherCATDevice, ESIDeviceRef, @@ -10,7 +10,7 @@ import type { ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/middleware/shared/ports/esi-types' -import { cn } from '@root/frontend/utils/cn' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import { useState } from 'react' import { DeviceBrowserModal } from './device-browser-modal' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 0ca65cbc9..46c9cf5ce 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -2,7 +2,7 @@ import * as Tabs from '@radix-ui/react-tabs' import { collectUsedIecAddresses } from '@root/backend/shared/ethercat' import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' import { useOpenPLCStore } from '@root/frontend/store' -import { useEsi } from '@root/middleware/shared/providers/platform-context' +import { cn } from '@root/frontend/utils/cn' import type { ConfiguredEtherCATDevice, EnrichDeviceData, @@ -12,7 +12,7 @@ import type { EtherCATSlaveConfig, SDOConfigurationEntry, } from '@root/middleware/shared/ports/esi-types' -import { cn } from '@root/frontend/utils/cn' +import { useEsi } from '@root/middleware/shared/providers/platform-context' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 997fe5613..3f179478e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -6,8 +6,6 @@ import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-da import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' -import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import type { ConfiguredEtherCATDevice, ESIDeviceRef, @@ -15,6 +13,8 @@ import type { ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/middleware/shared/ports/esi-types' +import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 36b41cc24..6f7e76d86 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -283,7 +283,7 @@ const ProjectTreeExpandableLeaf = ({ const handleRenameFile = async (renamed: string) => { setIsEditing(false) if (!renamed || !label) return - const res = await renameRemoteDevice(label, renamed) + const res = renameRemoteDevice(label, renamed) if (!res.ok) setNewLabel(label || '') } @@ -574,7 +574,7 @@ const ProjectTreeLeaf = ({ } if (isEthercatDevice && busName && deviceId) { - const res = await renameEthercatDevice(busName, deviceId, newLabel) + const res = renameEthercatDevice(busName, deviceId, newLabel) if (!res.ok) { setNewLabel(label || '') } diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts index 5517a1c9e..223e21187 100644 --- a/src/frontend/hooks/use-device-configuration.ts +++ b/src/frontend/hooks/use-device-configuration.ts @@ -1,3 +1,6 @@ +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' import type { ConfiguredEtherCATDevice, EnrichDeviceData, @@ -6,9 +9,6 @@ import type { EtherCATChannelMapping, EtherCATSlaveConfig, } from '@root/middleware/shared/ports/esi-types' -import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' -import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' -import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' import { useEsi } from '@root/middleware/shared/providers/platform-context' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e362abaa8..275c421fc 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -11,12 +11,7 @@ import type { FileSliceDataObject } from '../file' import type { HistorySnapshot } from '../history' import type { LadderFlowType } from '../ladder' import type { TabsProps } from '../tabs' -import { - CreateEditorObjectFromTab, - CreateEtherCATDeviceEditor, - CreateRemoteDeviceEditor, - CreateServerEditor, -} from '../tabs/utils' +import { CreateEditorObjectFromTab, CreateRemoteDeviceEditor, CreateServerEditor } from '../tabs/utils' import type { SharedRootState, SharedSlice } from './types' import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index a9fcfe319..3b8fac932 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -4,6 +4,7 @@ import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-mai import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { ESIRepositoryItem } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -15,7 +16,6 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' -import type { ESIRepositoryItem } from '@root/middleware/shared/ports/esi-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 314983630..02582e0bb 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,5 @@ import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { EtherCATRuntimeStatusResponse, @@ -11,7 +12,6 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/types/ethercat' -import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { ipcRenderer, IpcRendererEvent } from 'electron' From b6bb9c813b4fc261492bf0611946232c5b9cd285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 16 Apr 2026 17:16:49 +0200 Subject: [PATCH 21/30] fix: align fast-xml-parser version with openplc-web (^5.6.0) Co-authored-by: Claude Opus 4.6 (1M context) --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ea2ac952..e0b94399b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", - "fast-xml-parser": "^5.5.11", + "fast-xml-parser": "^5.6.0", "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", diff --git a/package.json b/package.json index 2e53c983e..7d3c473d2 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", - "fast-xml-parser": "^5.5.11", + "fast-xml-parser": "^5.6.0", "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", From 7f9b72a4d1c0af4373d7edf4696417b281ac22a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 17 Apr 2026 01:43:25 +0200 Subject: [PATCH 22/30] fix: register EtherCAT slave devices as first-class file entries EtherCAT slave device tabs were treated as "views" with no file store entry, causing Ctrl+S to fail with "File not found" and close to skip the save-changes prompt. This commit promotes slaves to first-class items following the same pattern as programs, servers, and remote devices: - Add 'ethercat-device' to FileSliceType - Register file entries on project load (filePath = parent bus name) - Register file entry when adding a device from the ESI repository - Add save handler that serializes the parent bus file (slaves live inside ethercatConfig.devices[] of the bus JSON) - syncDevicesToStore now calls handleFileAndWorkspaceSavedState for proper per-file dirty tracking - Remove force-close special case (normal flow works with file entry) Co-authored-by: Claude Opus 4.6 (1M context) --- .../device/ethercat/ethercat-device-editor.tsx | 12 ++++++++++-- .../editor/device/ethercat/index.tsx | 6 +++++- src/frontend/services/save-actions.ts | 10 ++++++++++ src/frontend/store/slices/file/types.ts | 1 + src/frontend/store/slices/shared/slice.ts | 17 ++++++----------- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 46c9cf5ce..cdddc89c9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -97,12 +97,20 @@ const EtherCATDeviceEditor = () => { }, [usedAddresses, device]) // Sync helpers + const deviceName = device?.name ?? '' + const syncDevicesToStore = useCallback( (devices: ConfiguredEtherCATDevice[]) => { projectActions.updateEthercatConfig(busName, { masterConfig, devices }) - workspaceActions.setEditingState('unsaved') + // Mark the slave file dirty (same pattern as other file types) + const { sharedWorkspaceActions } = useOpenPLCStore.getState() + if (deviceName) { + sharedWorkspaceActions.handleFileAndWorkspaceSavedState(deviceName) + } else { + workspaceActions.setEditingState('unsaved') + } }, - [busName, projectActions, masterConfig], + [busName, projectActions, masterConfig, deviceName], ) const handleUpdateDevice = useCallback( diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 3f179478e..a6b377de6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -460,8 +460,12 @@ const EtherCATEditor = () => { } syncDevicesToStore([...configuredDevices, newDevice]) + + // Register file entry for the new slave so Ctrl+S and dirty tracking work + const { fileActions } = useOpenPLCStore.getState() + fileActions.addFile({ name: newDevice.name, type: 'ethercat-device', filePath: deviceName }) }, - [configuredDevices, syncDevicesToStore, projectPath, project.data.remoteDevices], + [configuredDevices, syncDevicesToStore, deviceName, project.data.remoteDevices], ) const handleRemoveDevice = useCallback( diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index eba9000aa..5efee21dd 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -266,6 +266,16 @@ export async function executeSaveFile(fileName: string, projectPort: ProjectPort JSON.stringify(device, null, 2), ) if (!res.success) return fail(res.error ?? 'Save failed') + } else if (file.type === 'ethercat-device') { + // Slave devices live inside the parent bus file. filePath holds the bus name. + const busName = file.filePath + const bus = project.data.remoteDevices?.find((d) => d.name === busName) + if (!bus) return fail(`Parent bus "${busName}" not found for device "${fileName}".`) + const res = await projectPort.saveFile( + joinPath(projectPath, 'devices/remote', `${busName}.json`), + JSON.stringify(bus, null, 2), + ) + if (!res.success) return fail(res.error ?? 'Save failed') } else { // data-type, resource: live in project.json const debugVariables = collectDebugVariables( diff --git a/src/frontend/store/slices/file/types.ts b/src/frontend/store/slices/file/types.ts index 1fd8d416e..ad838665b 100644 --- a/src/frontend/store/slices/file/types.ts +++ b/src/frontend/store/slices/file/types.ts @@ -7,6 +7,7 @@ export type FileSliceType = | 'resource' | 'server' | 'remote-device' + | 'ethercat-device' | null export type FileSliceData = { diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 275c421fc..0d1ef9ce8 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -359,17 +359,6 @@ const createSharedSlice: StateCreator = (s }, closeFile: (name) => { - // EtherCAT slave tabs are views over data owned by the parent remote - // device — they have no entry in the `files` store and no per-tab - // dirty state. `getSavedState` would default to `false` and trigger - // a phantom "save changes?" prompt, so close them directly. Any real - // edits to slave config already dirty the parent bus tab through - // syncDevicesToStore -> setEditingState('unsaved'). - const tab = getState().tabs.find((t) => t.name === name) - if (tab?.elementType.type === 'ethercat-device') { - return getState().sharedWorkspaceActions.forceCloseFile(name) - } - // Check if file has unsaved changes const isSaved = getState().fileActions.getSavedState({ name }) @@ -613,6 +602,12 @@ const createSharedSlice: StateCreator = (s if (remoteDevices) { remoteDevices.forEach((d) => { files[d.name] = { type: 'remote-device', filePath: d.name, saved: true } + // Register file entries for EtherCAT slave devices (children of the bus) + if (d.protocol === 'ethercat' && d.ethercatConfig?.devices) { + for (const slave of d.ethercatConfig.devices) { + files[slave.name] = { type: 'ethercat-device', filePath: d.name, saved: true } + } + } }) } files['Resource'] = { type: 'resource', filePath: 'Resource', saved: true } From 8446a1fb4d96a7b4a8861dc2dcf3ee4684f8150c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 17 Apr 2026 17:47:28 +0200 Subject: [PATCH 23/30] refactor(esi): adopt shared loadedAt: string contract Sync the shared ESIRepositoryItem{Light} port types with openplc-web (byte-identical in src/middleware/shared/ports/esi-types.ts). loadedAt is now an ISO 8601 UTC string matching the autonomy-edge backend. esi-service.ts keeps the in-disk format (repository.json) on Unix ms for backward compatibility with existing project files. Conversion happens at every border: - isoToMs() / msToIso() helpers added. - saveRepositoryIndex / saveRepositoryIndexV2 / saveRepositoryItem convert incoming item.loadedAt (string) to number when writing. - loadLightItemsFromIndex and the v1->v2 migration path convert outgoing indexItem.loadedAt (number) to ISO string. - parseAndSaveFile stamps new items with new Date().toISOString(). Type check clean. --- src/backend/editor/ethercat/esi-service.ts | 37 ++++++++++++++++++---- src/middleware/shared/ports/esi-types.ts | 8 ++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts index 7349fa265..bca244189 100644 --- a/src/backend/editor/ethercat/esi-service.ts +++ b/src/backend/editor/ethercat/esi-service.ts @@ -13,6 +13,11 @@ import { fileOrDirectoryExists } from '../utils' /** * ESI Repository Index stored in devices/esi/repository.json * Version 2 includes device summaries inline for instant loading. + * + * The in-disk format keeps loadedAt as Unix ms (number) for backward + * compatibility with existing project files. The shared + * ESIRepositoryItem{Light} types expose loadedAt as an ISO 8601 string + * (matching the web backend); conversion happens at every border. */ interface ESIRepositoryIndex { version: number @@ -28,6 +33,26 @@ interface ESIRepositoryIndex { }> } +/** + * Convert a loadedAt value coming from the shared types (string, ISO 8601) + * into the Unix ms format persisted on disk. Tolerates numeric inputs + * during the transition so nothing breaks if an older caller still passes + * a number. + */ +function isoToMs(value: string | number): number { + if (typeof value === 'number') return value + const ms = new Date(value).getTime() + return Number.isFinite(ms) ? ms : Date.now() +} + +/** + * Convert a loadedAt value from the in-disk format (Unix ms) into the + * ISO 8601 string exposed through the shared types. + */ +function msToIso(value: number): string { + return new Date(value).toISOString() +} + /** * Response type for ESI service operations */ @@ -129,7 +154,7 @@ class ESIService { vendorId: item.vendor.id, vendorName: item.vendor.name, deviceCount: item.devices.length, - loadedAt: item.loadedAt, + loadedAt: isoToMs(item.loadedAt), warnings: item.warnings, })), } @@ -162,7 +187,7 @@ class ESIService { vendorId: item.vendor.id, vendorName: item.vendor.name, deviceCount: item.devices.length, - loadedAt: item.loadedAt, + loadedAt: isoToMs(item.loadedAt), warnings: item.warnings, devices: item.devices, })), @@ -271,7 +296,7 @@ class ESIService { vendorId: item.vendor.id, vendorName: item.vendor.name, deviceCount: item.devices.length, - loadedAt: item.loadedAt, + loadedAt: isoToMs(item.loadedAt), warnings: item.warnings, }, ] @@ -352,7 +377,7 @@ class ESIService { filename: i.filename, vendor: { id: i.vendorId, name: i.vendorName }, devices: i.devices || [], - loadedAt: i.loadedAt, + loadedAt: msToIso(i.loadedAt), warnings: i.warnings, })) } @@ -416,7 +441,7 @@ class ESIService { filename: indexItem.filename, vendor: parseResult.vendor, devices: parseResult.devices, - loadedAt: indexItem.loadedAt, + loadedAt: msToIso(indexItem.loadedAt), warnings: parseResult.warnings || indexItem.warnings, }) } @@ -478,7 +503,7 @@ class ESIService { filename, vendor: parseResult.vendor!, devices: parseResult.devices!, - loadedAt: Date.now(), + loadedAt: new Date().toISOString(), warnings: parseResult.warnings, } diff --git a/src/middleware/shared/ports/esi-types.ts b/src/middleware/shared/ports/esi-types.ts index 4ec1f0f30..1f2f0f13a 100644 --- a/src/middleware/shared/ports/esi-types.ts +++ b/src/middleware/shared/ports/esi-types.ts @@ -423,8 +423,8 @@ export interface ESIRepositoryItem { vendor: ESIVendor /** Devices contained in this file */ devices: ESIDevice[] - /** Timestamp when this file was loaded */ - loadedAt: number + /** ISO 8601 UTC timestamp when this file was loaded */ + loadedAt: string /** Parsing warnings (non-fatal issues) */ warnings?: string[] } @@ -442,8 +442,8 @@ export interface ESIRepositoryItemLight { vendor: ESIVendor /** Lightweight device summaries */ devices: ESIDeviceSummary[] - /** Timestamp when this file was loaded */ - loadedAt: number + /** ISO 8601 UTC timestamp when this file was loaded */ + loadedAt: string /** Parsing warnings (non-fatal issues) */ warnings?: string[] } From 44c35157734e263022aff149b021b99f28426e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 17 Apr 2026 20:13:01 +0200 Subject: [PATCH 24/30] fix(ethercat): cascade delete of bus children Deleting a remote device bus only removed the bus itself; EtherCAT children in its ethercatConfig.devices list survived as orphan state: the tabs stayed open, editor models lingered, and file entries held their save-state. Fix: - remoteDeviceActions.delete iterates the bus' children first and routes each through ethercatDeviceActions.delete (the existing single-child cleanup action) before handing the bus to deleteElement. Child ids are snapshotted because ethercatDeviceActions.delete mutates the array via updateEthercatConfig. - ethercatDeviceActions.delete now also drops the child's file entry, which was already being registered at project load (src/frontend/store/slices/shared/slice.ts) but never cleaned up on removal. This fixes the orphan file both for direct deletes and for the new cascade. Covered by shared-slice.test.ts > remoteDeviceActions > delete > 'cascades to EtherCAT children so their tabs, editors and files are removed'. --- .../store/__tests__/shared-slice.test.ts | 37 +++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 22 ++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 565eda096..d4dd285b2 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -15,6 +15,7 @@ import { createSearchSlice } from '../slices/search/slice' import { createSharedSlice } from '../slices/shared/slice' import type { SharedRootState } from '../slices/shared/types' import { createTabsSlice } from '../slices/tabs/slice' +import { createVersionControlSlice } from '../slices/version-control/slice' import { createWorkspaceSlice } from '../slices/workspace/slice' function makeStore() { @@ -32,6 +33,7 @@ function makeStore() { ...createFBDFlowSlice(...args), ...createLadderFlowSlice(...args), ...createHistorySlice(...args), + ...createVersionControlSlice(...args), ...createSharedSlice(...args), })) } @@ -705,6 +707,41 @@ describe('createSharedSlice', () => { store.getState().remoteDeviceActions.delete('Device1') expect(store.getState().editor.meta.name).toBe('Device2') }) + + it('cascades to EtherCAT children so their tabs, editors and files are removed', () => { + // Set up an EtherCAT bus with two configured slave devices. + store.getState().projectActions.createRemoteDevice({ + data: { name: 'eth', protocol: 'ethercat' }, + }) + store.getState().projectActions.updateEthercatConfig('eth', { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: [ + { id: 'slave-1', name: 'EK1100' }, + { id: 'slave-2', name: 'EL1008' }, + ] as never, + }) + // Register renderer-side state the UI would have created for each child. + for (const child of ['EK1100', 'EL1008']) { + store + .getState() + .editorActions.addModel({ type: 'plc-remote-device', meta: { name: child, protocol: 'ethercat' } }) + store.getState().fileActions.addFile({ name: child, type: 'remote-device', filePath: child }) + store.getState().tabsActions.updateTabs({ + name: child, + elementType: { type: 'remote-device', protocol: 'ethercat' }, + }) + } + + expect(store.getState().tabs.map((t) => t.name)).toEqual(expect.arrayContaining(['EK1100', 'EL1008'])) + + store.getState().remoteDeviceActions.delete('eth') + + const state = store.getState() + expect(state.project.data.remoteDevices?.some((d) => d.name === 'eth')).toBe(false) + expect(state.files['EK1100']).toBeUndefined() + expect(state.files['EL1008']).toBeUndefined() + expect(state.tabs.some((t) => t.name === 'EK1100' || t.name === 'EL1008')).toBe(false) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 0d1ef9ce8..6d37dbd9a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -280,7 +280,22 @@ const createSharedSlice: StateCreator = (s getState().modalActions.openModal('confirm-delete-element', { name, elementType: 'remote-device' }) }, - delete: (name) => deleteElement(getState(), name, (n) => getState().projectActions.deleteRemoteDevice(n)), + delete: (name) => { + // Cascade: purge EtherCAT children first so their tabs, editor + // models, and file entries are cleaned up before the bus vanishes + // from the tree. Without this step the children survive the + // parent delete as orphan state. + const state = getState() + const bus = state.project.data.remoteDevices?.find((d) => d.name === name) + const children = bus?.protocol === 'ethercat' ? (bus.ethercatConfig?.devices ?? []) : [] + // Snapshot ids — ethercatDeviceActions.delete mutates the same + // array via updateEthercatConfig, so iterating the live array + // would skip every second child. + for (const childId of children.map((d) => d.id)) { + state.ethercatDeviceActions.delete(name, childId) + } + return deleteElement(getState(), name, (n) => getState().projectActions.deleteRemoteDevice(n)) + }, rename: (oldName, newName) => renameElement(getState(), oldName, newName, (o, n) => getState().projectActions.updateRemoteDeviceName(o, n)), @@ -306,6 +321,11 @@ const createSharedSlice: StateCreator = (s }) state.editorActions.removeModel(deviceName) state.tabsActions.removeTab(deviceName) + // EtherCAT children are registered in the file slice on project + // load (see register files for save-state tracking). Drop the + // entry here so it doesn't linger when the child is removed + // directly or via a bus cascade. + state.fileActions.removeFile({ name: deviceName }) const currentEditor = state.editor if (currentEditor.type !== 'available' && currentEditor.meta.name === deviceName) { From 11e1261ef386ad646ba695cec953cfe3017b8d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 20 Apr 2026 10:10:31 +0200 Subject: [PATCH 25/30] fix(ethercat): address CodeRabbit review on PR #731 Batch of fixes and refactors surfaced by CodeRabbit on the ESI feature PR. Grouped here because they all share the EtherCAT surface and the same risk (incorrect data/UX on ESI repository and runtime flows): - esi-service: drop dead v1 writers; enforce v2 "all items have devices" check so partial migrations don't silently drop rows; back up and recover from a corrupted repository.json instead of throwing; make duplicate handling explicit via a `duplicate` flag. - compiler-module: generate Runtime v4 conf/* (ethercat.json, modbus, s7comm, opcua) before the compile-only early return so compile-only flows produce the same artifacts as uploads. - device-matcher: reject NaN IDs instead of coercing to 0 and falsely matching unrelated devices. - esi-parser: detect IEC address overlaps across widths (%IX/%IB/%IW). - use-device-configuration: forward externalAddresses into enrichDeviceData so back-filled channel mappings don't collide. - project/shared slices: honor saved masterConfig.taskPriority on system task creation; rekey files[] on EtherCAT slave rename. - runtime-status-panel: narrow isPluginNotActiveError so timeouts / connection refused surface as real errors. - main.ts makeRuntimeApiPostRequest: propagate HTTP status so 401/403 triggers token refresh without brittle message parsing. - global-settings-tab: local draft state for numeric fields to keep NaN/out-of-range values out of masterConfig. - ethercat-device-editor: reload repository when projectPath changes. - device-scan-table / discovered-device-table: keyboard accessibility (role=button, tabIndex, Enter/Space, focus-visible ring). - ports: extend masterConfig with enabled/taskPriority; make parseAndSaveFile contract explicit about duplicates; use @root alias in runtime-port. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.ts | 69 +++--- src/backend/editor/ethercat/esi-service.ts | 196 ++++++------------ src/backend/shared/ethercat/device-matcher.ts | 14 +- src/backend/shared/ethercat/esi-parser.ts | 60 +++++- .../ethercat/components/device-scan-table.tsx | 10 + .../components/discovered-device-table.tsx | 13 ++ .../device/ethercat/components/esi-upload.tsx | 7 +- .../components/global-settings-tab.tsx | 56 ++++- .../components/runtime-status-panel.tsx | 14 +- .../ethercat/ethercat-device-editor.tsx | 14 +- .../hooks/use-device-configuration.ts | 2 +- src/frontend/store/slices/project/slice.ts | 6 +- src/frontend/store/slices/shared/slice.ts | 9 +- src/main/modules/ipc/main.ts | 46 ++-- src/main/modules/ipc/renderer.ts | 26 +-- src/middleware/adapters/editor/esi-adapter.ts | 9 +- src/middleware/shared/ports/esi-port.ts | 9 +- src/middleware/shared/ports/runtime-port.ts | 3 +- src/middleware/shared/ports/types.ts | 2 + 19 files changed, 314 insertions(+), 251 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index c9d0c2acb..94bd4c7ea 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1710,6 +1710,43 @@ class CompilerModule { message: 'Source files generated successfully at: ' + sourceTargetFolderPath, }) + // Generate Runtime v4 conf/* files for BOTH compile-only and upload flows. + // Without this, compile-only never produces ethercat.json (and other configs), + // so users who only want the generated sources miss runtime configuration. + if (isRuntimeV4) { + try { + await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error generating Runtime v4 configs: ${error instanceof Error ? error.message : String(error)}`, + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + } + if (compileOnly) { _mainProcessPort.postMessage({ logLevel: 'info', @@ -1762,36 +1799,8 @@ class CompilerModule { filename = 'program.st' contentType = 'text/plain' } else { - // Clean conf folder from previous compilations to avoid stale config files - await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate Modbus Slave config for Runtime v4 - await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate Modbus Master config for Runtime v4 - await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate S7Comm config for Runtime v4 - await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate OPC-UA config for Runtime v4 - await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate EtherCAT config for Runtime v4 - await this.handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - + // Runtime v4 conf/* files were already generated above, before the + // compile-only early return, so compile-only flows also get them. _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compressing source files for OpenPLC Runtime v4...', diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts index bca244189..0abb79d26 100644 --- a/src/backend/editor/ethercat/esi-service.ts +++ b/src/backend/editor/ethercat/esi-service.ts @@ -1,8 +1,4 @@ -import type { - ESIDeviceSummary, - ESIRepositoryItem, - ESIRepositoryItemLight, -} from '@root/middleware/shared/ports/esi-types' +import type { ESIDeviceSummary, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import { promises } from 'fs' import { basename, dirname, join } from 'path' import { v4 as uuidv4 } from 'uuid' @@ -126,49 +122,39 @@ class ESIService { async loadRepositoryIndex(projectPath: string): Promise { const repoPath = this.getRepositoryPath(projectPath) + let content: string try { - const content = await promises.readFile(repoPath, 'utf-8') - const index = JSON.parse(content) as ESIRepositoryIndex - return index + content = await promises.readFile(repoPath, 'utf-8') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null } - // Re-throw for corrupted/unreadable files so callers don't silently overwrite + // Re-throw I/O errors (permission, locked file, ...) so callers don't + // silently overwrite what is probably a working file. throw error } - } - /** - * Save the ESI repository index to disk (v1 format for backward compat) - */ - async saveRepositoryIndex(projectPath: string, items: ESIRepositoryItem[]): Promise { try { - await this.ensureEsiDir(projectPath) - - const index: ESIRepositoryIndex = { - version: 1, - items: items.map((item) => ({ - id: item.id, - filename: item.filename, - vendorId: item.vendor.id, - vendorName: item.vendor.name, - deviceCount: item.devices.length, - loadedAt: isoToMs(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', + return JSON.parse(content) as ESIRepositoryIndex + } catch (parseError) { + // Corrupted JSON: rename the bad file to `.corrupt-.bak` and + // treat the repository as empty. This gives callers a clear recovery + // path (re-parse the XMLs on disk) instead of being stuck with a fatal + // throw on every load, while preserving the original file for forensics. + const backupPath = `${repoPath}.corrupt-${Date.now()}.bak` + try { + await promises.rename(repoPath, backupPath) + console.warn( + `[ESIService] Corrupted repository index at ${repoPath} — backed up to ${backupPath} and treating repository as empty.`, + parseError, + ) + } catch (renameError) { + console.error( + `[ESIService] Corrupted repository index at ${repoPath} and backup rename failed. Leaving file in place.`, + { parseError, renameError }, + ) } + return null } } @@ -269,76 +255,6 @@ class ESIService { } } - /** - * Save a complete ESI repository item (XML + update index) - */ - async saveRepositoryItem( - projectPath: string, - item: ESIRepositoryItem, - xmlContent: string, - _existingItems: ESIRepositoryItem[], - ): Promise { - return this.withIndexLock(async () => { - // Save the XML file - const xmlResult = await this.saveXmlFile(projectPath, item.id, xmlContent) - if (!xmlResult.success) { - return xmlResult - } - - // Read current index from disk instead of trusting caller snapshot - const currentIndex = await this.loadRepositoryIndex(projectPath) - const currentItems = currentIndex?.items ?? [] - const updatedItems = [ - ...currentItems.filter((i) => i.id !== item.id), - { - id: item.id, - filename: item.filename, - vendorId: item.vendor.id, - vendorName: item.vendor.name, - deviceCount: item.devices.length, - loadedAt: isoToMs(item.loadedAt), - warnings: item.warnings, - }, - ] - const repoPath = this.getRepositoryPath(projectPath) - await promises.writeFile( - repoPath, - JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), - 'utf-8', - ) - return { success: true } - }) - } - - /** - * Delete a repository item (XML + update index) - */ - async deleteRepositoryItem( - projectPath: string, - itemId: string, - _existingItems: ESIRepositoryItem[], - ): Promise { - return this.withIndexLock(async () => { - // Delete the XML file - const deleteResult = await this.deleteXmlFile(projectPath, itemId) - if (!deleteResult.success) { - return deleteResult - } - - // Read current index from disk instead of trusting caller snapshot - const currentIndex = await this.loadRepositoryIndex(projectPath) - const currentItems = currentIndex?.items ?? [] - const updatedItems = currentItems.filter((i) => i.id !== itemId) - const repoPath = this.getRepositoryPath(projectPath) - await promises.writeFile( - repoPath, - JSON.stringify({ version: currentIndex?.version ?? 1, items: updatedItems }, null, 2), - 'utf-8', - ) - return { success: true } - }) - } - /** * Delete a repository item and update the v2 index (no re-parsing) */ @@ -365,25 +281,32 @@ class ESIService { } /** - * Load light items from the v2 repository index + * Load light items from the v2 repository index. + * + * Assumes the caller has already confirmed the index is fully v2-shaped + * (all items carry `devices`). Items missing `devices` are treated as a + * schema violation — they were already rejected by `loadRepositoryLight`. */ 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: msToIso(i.loadedAt), - warnings: i.warnings, - })) + return index.items.map((i) => ({ + id: i.id, + filename: i.filename, + vendor: { id: i.vendorId, name: i.vendorName }, + devices: i.devices ?? [], + loadedAt: msToIso(i.loadedAt), + warnings: i.warnings, + })) } /** - * Load repository as lightweight items (v2 instant, v1 needs migration) + * Load repository as lightweight items (v2 instant, v1 needs migration). + * + * A v2 index is only considered valid when ALL items carry a `devices` + * array. If any item is missing it (partial write, manual edit, external + * corruption), we flag the whole index as needing re-migration so the + * user's view never silently drops items. */ async loadRepositoryLight( projectPath: string, @@ -395,18 +318,20 @@ class ESIService { 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 } + if (index.items.length === 0) { + return { success: true, items: [] } } - // V1 index needs migration - if (index.items.length > 0) { - return { success: true, needsMigration: true } + // V2 index is valid only when every item has inline device summaries. + // Any item missing `devices` means we either have a v1 index or a + // partially-migrated v2 — in both cases, trigger migration. + const allItemsHaveDevices = index.version === 2 && index.items.every((i) => i.devices !== undefined) + if (allItemsHaveDevices) { + const items = await this.loadLightItemsFromIndex(projectPath) + return { success: true, items } } - return { success: true, items: [] } + return { success: true, needsMigration: true } } catch (error) { return { success: false, @@ -470,12 +395,20 @@ class ESIService { /** * Parse and save a single ESI file. Returns the saved item on success. * Called once per file from the renderer's sequential upload loop. + * + * ## Duplicate handling + * When `filename` already exists in the repository index, we return + * `{ success: true, duplicate: true }` WITHOUT `item`. The adapter (and + * ultimately the UI) treats this as a no-op rather than an error — uploading + * the same file again is not a failure, it's just nothing to do. Callers + * that need to distinguish "added" from "skipped" must check `duplicate` or + * the presence of `item`. */ async parseAndSaveFile( projectPath: string, filename: string, content: string, - ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> { + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; duplicate?: boolean; error?: string }> { // Parse outside the lock (CPU-bound, no index access) const parseResult = parseESILight(content, filename) if (!parseResult.success || !parseResult.vendor || !parseResult.devices) { @@ -488,7 +421,10 @@ class ESIService { 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 + // Skip duplicate: success with an explicit `duplicate` flag so the + // caller can tell this apart from a real add without inferring it + // from a missing `item`. + return { success: true, duplicate: true } } // Save XML to disk diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts index 377759039..1c048f068 100644 --- a/src/backend/shared/ethercat/device-matcher.ts +++ b/src/backend/shared/ethercat/device-matcher.ts @@ -13,12 +13,14 @@ import type { import type { EtherCATDevice } from '@root/types/ethercat' /** - * Parse a hex string to a number for comparison - * Handles formats: "0x1234", "#x1234", "1234" + * Parse a hex string to a number for comparison. + * Handles formats: "0x1234", "#x1234", "1234". + * Returns NaN for unparseable input so callers can explicitly reject invalid IDs + * instead of silently coercing them to 0 and producing false matches. */ function parseHexToNumber(hexString: string): number { const cleaned = hexString.replace(/#x/gi, '0x') - return Number(cleaned) || 0 + return Number(cleaned) } /** @@ -36,6 +38,12 @@ function getMatchQuality( const esiProductNum = parseHexToNumber(esiProductCode) const esiRevisionNum = parseHexToNumber(esiRevisionNo) + // Reject ESI entries whose numeric IDs failed to parse — without this, + // unparseable strings used to collapse to 0 and falsely match real devices. + if (Number.isNaN(esiVendorNum) || Number.isNaN(esiProductNum) || Number.isNaN(esiRevisionNum)) { + return 'none' + } + // Check vendor ID first - must match for any level of match if (scannedVendorId !== esiVendorNum) { return 'none' diff --git a/src/backend/shared/ethercat/esi-parser.ts b/src/backend/shared/ethercat/esi-parser.ts index 6e460a59f..7592e26d6 100644 --- a/src/backend/shared/ethercat/esi-parser.ts +++ b/src/backend/shared/ethercat/esi-parser.ts @@ -182,6 +182,42 @@ export function generateIecLocation(channel: ESIChannel, globalBitOffset?: numbe } } +/** + * A direction-tagged bit range for conflict detection on IEC locations. + * Used to catch overlaps across different access widths (e.g. `%IX0.0`, + * `%IB0`, and `%IW0` all touch byte 0 but compare unequal as strings). + */ +type IecBitRange = { direction: 'I' | 'Q'; startBit: number; endBit: number } + +const IEC_WIDTH_BITS: Record = { + X: 1, + B: 8, + W: 16, + D: 32, + L: 64, +} + +/** + * Parse an IEC location string (`%IX0.0`, `%IB0`, `%QW2`, ...) into a bit range. + * Returns `null` for unrecognized formats so callers can skip them instead of + * treating them as false matches. + */ +export function parseIecLocationToBitRange(location: string): IecBitRange | null { + const match = /^%([IQ])([XBWDL])(\d+)(?:\.(\d+))?$/.exec(location) + if (!match) return null + const [, dir, kind, byteStr, bitStr] = match + const width = IEC_WIDTH_BITS[kind] + if (width === undefined) return null + const byte = Number.parseInt(byteStr, 10) + const bit = bitStr !== undefined ? Number.parseInt(bitStr, 10) : 0 + const startBit = byte * 8 + bit + return { direction: dir as 'I' | 'Q', startBit, endBit: startBit + width - 1 } +} + +function bitRangesOverlap(a: IecBitRange, b: IecBitRange): boolean { + return a.direction === b.direction && a.startBit <= b.endBit && b.startBit <= a.endBit +} + /** * Get the size in bits for a channel based on its IEC type. */ @@ -222,7 +258,22 @@ export function generateDefaultChannelMappings( channels: ESIChannel[], usedAddresses?: Set, ): EtherCATChannelMapping[] { - const used = new Set(usedAddresses) + // Normalize existing addresses to bit ranges so conflict detection catches + // overlaps across widths (e.g. `%IB0` vs `%IW0` vs `%IX0.0` all overlap byte 0). + const usedRanges: IecBitRange[] = [] + if (usedAddresses) { + for (const addr of usedAddresses) { + const range = parseIecLocationToBitRange(addr) + if (range) usedRanges.push(range) + } + } + + const conflicts = (candidate: string): boolean => { + const range = parseIecLocationToBitRange(candidate) + if (!range) return false + return usedRanges.some((r) => bitRangesOverlap(range, r)) + } + const inputChannels = channels.filter((c) => c.direction === 'input') const outputChannels = channels.filter((c) => c.direction === 'output') @@ -241,8 +292,8 @@ export function generateDefaultChannelMappings( let candidate = generateIecLocation(channel, currentBitOffset) - // Find a non-conflicting address - while (used.has(candidate)) { + // Find a non-conflicting address (checks bit-range overlap, not string equality) + while (conflicts(candidate)) { currentBitOffset += bitSize // Re-align if needed if (bitSize > 1 && currentBitOffset % 8 !== 0) { @@ -251,7 +302,8 @@ export function generateDefaultChannelMappings( candidate = generateIecLocation(channel, currentBitOffset) } - used.add(candidate) + const candidateRange = parseIecLocationToBitRange(candidate) + if (candidateRange) usedRanges.push(candidateRange) mappings.push({ channelId: channel.id, iecLocation: candidate, diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx index 9d0914a61..d69b200e6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx @@ -80,10 +80,20 @@ const DeviceScanTable = ({ devices, selectedPosition, onSelectDevice, isScanning devices.map((device) => ( onSelectDevice(device.position)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + 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', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1', selectedPosition === device.position && 'bg-brand/10 dark:bg-brand/20', )} > diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx index 7d0d2b337..5d74b82ad 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -71,10 +71,23 @@ const DiscoveredDeviceTable = ({ return ( isSelectable && onSelectDevice(dm.device.position, !isSelected)} + onKeyDown={(e) => { + if (!isSelectable) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + 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', + isSelectable && + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1', isSelected && 'bg-brand/10 dark:bg-brand/20', !isSelectable && 'opacity-60', )} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx index 66d844b49..18133386a 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -85,8 +85,11 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath } if (result.success && result.item) { newItems.push(result.item) - } else if ('error' in result ? result.error : 'Parse failed') { - errors.push({ filename: file.name, error: 'error' in result ? result.error : 'Parse failed' }) + } else if (result.success) { + // Duplicate content already in the repository — skip silently. + // See EsiPort.parseAndSaveFile for the duplicate-handling contract. + } else { + errors.push({ filename: file.name, error: result.error ?? 'Parse failed' }) } } catch (err) { errors.push({ filename: file.name, error: err instanceof Error ? err.message : String(err) }) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx index 785edc15c..9716381b2 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -4,6 +4,7 @@ import { InputWithRef } from '@root/frontend/components/_atoms/input' import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' import { cn } from '@root/frontend/utils/cn' import type { NetworkInterface } from '@root/types/ethercat' +import { useEffect, useState } from 'react' type GlobalSettingsTabProps = { masterConfig: EtherCATMasterConfig @@ -25,6 +26,29 @@ const GlobalSettingsTab = ({ isLoadingInterfaces, onRefreshInterfaces, }: GlobalSettingsTabProps) => { + // Local draft state so the user's in-progress keystrokes (including + // temporarily empty or out-of-range values) render without writing + // NaN / invalid numbers into the persisted masterConfig. Only finite, + // in-range values propagate to the store on change; onBlur clamps any + // remaining bad draft back into range. + const [cycleTimeDraft, setCycleTimeDraft] = useState(String(masterConfig.cycleTimeUs)) + const [watchdogDraft, setWatchdogDraft] = useState(String(masterConfig.watchdogTimeoutCycles ?? 3)) + + useEffect(() => { + setCycleTimeDraft(String(masterConfig.cycleTimeUs)) + }, [masterConfig.cycleTimeUs]) + + useEffect(() => { + setWatchdogDraft(String(masterConfig.watchdogTimeoutCycles ?? 3)) + }, [masterConfig.watchdogTimeoutCycles]) + + const commitIfValid = (raw: string, min: number, max: number, commit: (value: number) => void) => { + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed >= min && parsed <= max) { + commit(parsed) + } + } + return (
    {/* Enable Plugin */} @@ -132,12 +156,20 @@ const GlobalSettingsTab = ({
    onUpdateMasterConfig({ cycleTimeUs: Number(e.target.value) })} + value={cycleTimeDraft} + onChange={(e) => { + setCycleTimeDraft(e.target.value) + commitIfValid(e.target.value, 100, 100000, (v) => onUpdateMasterConfig({ cycleTimeUs: v })) + }} onBlur={(e) => { const val = Number(e.target.value) - if (!val || val < 100) onUpdateMasterConfig({ cycleTimeUs: 100 }) - else if (val > 100000) onUpdateMasterConfig({ cycleTimeUs: 100000 }) + if (!Number.isFinite(val) || val < 100) { + onUpdateMasterConfig({ cycleTimeUs: 100 }) + setCycleTimeDraft('100') + } else if (val > 100000) { + onUpdateMasterConfig({ cycleTimeUs: 100000 }) + setCycleTimeDraft('100000') + } }} min={100} max={100000} @@ -157,12 +189,20 @@ const GlobalSettingsTab = ({
    onUpdateMasterConfig({ watchdogTimeoutCycles: Number(e.target.value) })} + value={watchdogDraft} + onChange={(e) => { + setWatchdogDraft(e.target.value) + commitIfValid(e.target.value, 1, 100, (v) => onUpdateMasterConfig({ watchdogTimeoutCycles: v })) + }} onBlur={(e) => { const val = Number(e.target.value) - if (!val || val < 1) onUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) - else if (val > 100) onUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) + if (!Number.isFinite(val) || val < 1) { + onUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) + setWatchdogDraft('1') + } else if (val > 100) { + onUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) + setWatchdogDraft('100') + } }} min={1} max={100} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx index 3b58c1066..4b3162a05 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -126,15 +126,21 @@ function extractErrorMessage(rawError: string): string { return rawError } +/** + * Detect only errors that indicate the EtherCAT plugin endpoint is missing or + * disabled on the runtime (HTTP 404, HTML error page, or explicit not-loaded + * messages). Network/connectivity failures like timeouts and "connection + * refused" are NOT plugin-state issues — they must surface as real errors so + * users can diagnose their connection instead of seeing a misleading + * "plugin not active" banner. + */ 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('plugin not active') || + lower.includes('plugin not found') || lower.includes(' { [configuredDevices, deviceId, syncDevicesToStore], ) - // Load ESI repository + // Load ESI repository. Resets and reloads whenever `projectPath` changes + // so switching projects picks up the new project's repository instead of + // serving the prior one from the stale "already loaded" flag. useEffect(() => { let cancelled = false + repositoryLoadedRef.current = false const loadRepository = async () => { - if (!projectPath || repositoryLoadedRef.current) return + if (!projectPath) return try { const result = await esi!.loadRepositoryLight() @@ -175,12 +178,7 @@ const EtherCATDeviceEditor = () => { return () => { cancelled = true } - }, [projectPath]) - - // Reset repository loaded flag when project changes - useEffect(() => { - repositoryLoadedRef.current = false - }, [projectPath]) + }, [projectPath, esi]) // Resolve ESI device summary and repo item for info display const esiDevice = useMemo(() => { diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts index 223e21187..72b49239c 100644 --- a/src/frontend/hooks/use-device-configuration.ts +++ b/src/frontend/hooks/use-device-configuration.ts @@ -79,7 +79,7 @@ export function useDeviceConfiguration({ } if (!device.channelInfo || !device.rxPdos || !device.txPdos) { - const { sdoConfigurations, ...rest } = enrichDeviceData(result.device) + const { sdoConfigurations, ...rest } = enrichDeviceData(result.device, externalAddresses) onEnrichDeviceRef.current(device.sdoConfigurations !== undefined ? rest : { ...rest, sdoConfigurations }) } else if (device.sdoConfigurations === undefined && result.device.coeObjects?.length) { onEnrichDeviceRef.current({ diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 21988d7a9..f1ff583e6 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -202,11 +202,12 @@ const createProjectSlice: StateCreator = (se ) if (!existingTask) { const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskPriority = device.ethercatConfig?.masterConfig?.taskPriority ?? 1 slice.project.data.configurations.resource.tasks.unshift({ name: ethercatTaskName(device.name), triggering: 'Cyclic' as const, interval: cycleTimeUsToIecInterval(cycleTimeUs), - priority: 1, + priority: taskPriority, isSystemTask: true, associatedDevice: device.name, }) @@ -1045,11 +1046,12 @@ const createProjectSlice: StateCreator = (se // Auto-create system task for EtherCAT devices if (device.protocol === 'ethercat') { const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskPriority = device.ethercatConfig?.masterConfig?.taskPriority ?? 1 slice.project.data.configurations.resource.tasks.unshift({ name: ethercatTaskName(device.name), triggering: 'Cyclic' as const, interval: cycleTimeUsToIecInterval(cycleTimeUs), - priority: 1, + priority: taskPriority, isSystemTask: true, associatedDevice: device.name, }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 6d37dbd9a..e7b98103a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -356,6 +356,9 @@ const createSharedSlice: StateCreator = (s }) state.editorActions.updateEditorName(oldName, newName) state.tabsActions.updateTabName(oldName, newName) + // Rekey the file slice entry so save-state tracking follows the rename + // instead of orphaning the old name when the slave is first-class. + state.fileActions.updateFile({ name: oldName, newName }) return { ok: true } }, @@ -622,7 +625,11 @@ const createSharedSlice: StateCreator = (s if (remoteDevices) { remoteDevices.forEach((d) => { files[d.name] = { type: 'remote-device', filePath: d.name, saved: true } - // Register file entries for EtherCAT slave devices (children of the bus) + // Register file entries for EtherCAT slave devices (children of the bus). + // Keyed by slave.name to match how the rest of the file registry, tabs, + // and editor models identify slaves. Rename flows in + // `ethercatDeviceActions.rename` call `fileActions.updateFile({ name, newName })` + // to rekey this entry so it never orphans. if (d.protocol === 'ethercat' && d.ethercatConfig?.devices) { for (const slave of d.ethercatConfig.devices) { files[slave.name] = { type: 'ethercat-device', filePath: d.name, saved: true } diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3b8fac932..a78482e48 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -4,7 +4,6 @@ import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-mai import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' -import type { ESIRepositoryItem } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -335,7 +334,11 @@ class MainProcessBridge implements MainIpcModule { 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 }> => { + type PostResult = + | { success: true; data: T } + | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => { return new Promise((resolve) => { const req = https.request( { @@ -363,7 +366,13 @@ class MainProcessBridge implements MainIpcModule { resolve({ success: false, error: err instanceof Error ? err.message : 'Invalid response format' }) } } else { - resolve({ success: false, error: data || `Unexpected status: ${res.statusCode}` }) + // Propagate HTTP status so the caller can detect 401/403 for + // token-refresh without relying on brittle message parsing. + resolve({ + success: false, + error: data || `Unexpected status: ${res.statusCode}`, + statusCode: res.statusCode, + }) } }) }, @@ -380,19 +389,23 @@ class MainProcessBridge implements MainIpcModule { }) } + const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => + r.success ? r : { success: false, error: r.error } + return doRequest(jwtToken).then((result) => { - if (!result.success && this.isTokenExpiredError(undefined, result.error)) { + const statusCode = !result.success ? result.statusCode : undefined + if (!result.success && this.isTokenExpiredError(statusCode, 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 doRequest(refreshResult.accessToken).then(stripStatus) } return { success: false as const, error: `Token refresh failed: ${refreshResult.error || 'Unknown error'}` } }) } - return result + return stripStatus(result) }) } @@ -663,12 +676,9 @@ class MainProcessBridge implements MainIpcModule { // ===================== ESI REPOSITORY ===================== this.registerHandle('esi:load-repository-index', this.handleESILoadRepositoryIndex) - this.registerHandle('esi:save-repository-index', this.handleESISaveRepositoryIndex) this.registerHandle('esi:save-xml-file', this.handleESISaveXmlFile) this.registerHandle('esi:load-xml-file', this.handleESILoadXmlFile) this.registerHandle('esi:delete-xml-file', this.handleESIDeleteXmlFile) - this.registerHandle('esi:save-repository-item', this.handleESISaveRepositoryItem) - this.registerHandle('esi:delete-repository-item', this.handleESIDeleteRepositoryItem) this.registerHandle('esi:parse-and-save-file', this.handleESIParseAndSaveFile) this.registerHandle('esi:clear-repository', this.handleESIClearRepository) this.registerHandle('esi:load-device-full', this.handleESILoadDeviceFull) @@ -1679,9 +1689,6 @@ class MainProcessBridge implements MainIpcModule { return { success: true as const, data: index } }) - handleESISaveRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string, items: ESIRepositoryItem[]) => - this.wrapServiceCall(() => this.esiService.saveRepositoryIndex(projectPath, items)) - handleESISaveXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string, xmlContent: string) => this.wrapServiceCall(() => this.esiService.saveXmlFile(projectPath, itemId, xmlContent)) @@ -1691,21 +1698,6 @@ class MainProcessBridge implements MainIpcModule { handleESIDeleteXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => this.wrapServiceCall(() => this.esiService.deleteRepositoryItemV2(projectPath, itemId)) - handleESISaveRepositoryItem = async ( - _event: IpcMainInvokeEvent, - projectPath: string, - item: ESIRepositoryItem, - xmlContent: string, - existingItems: ESIRepositoryItem[], - ) => this.wrapServiceCall(() => this.esiService.saveRepositoryItem(projectPath, item, xmlContent, existingItems)) - - handleESIDeleteRepositoryItem = async ( - _event: IpcMainInvokeEvent, - projectPath: string, - itemId: string, - existingItems: ESIRepositoryItem[], - ) => this.wrapServiceCall(() => this.esiService.deleteRepositoryItem(projectPath, itemId, existingItems)) - handleESIParseAndSaveFile = async ( _event: IpcMainInvokeEvent, projectPath: string, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 02582e0bb..4af2651b9 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,5 +1,5 @@ import { RuntimeLogEntry } from '@root/middleware/shared/ports' -import type { ESIDevice, ESIRepositoryItem, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { EtherCATRuntimeStatusResponse, @@ -418,12 +418,6 @@ const rendererProcessBridge = { error?: string }> => ipcRenderer.invoke('esi:load-repository-index', projectPath), - esiSaveRepositoryIndex: ( - projectPath: string, - items: ESIRepositoryItem[], - ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('esi:save-repository-index', projectPath, items), - esiSaveXmlFile: ( projectPath: string, itemId: string, @@ -440,27 +434,11 @@ const rendererProcessBridge = { esiDeleteXmlFile: (projectPath: string, itemId: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('esi:delete-xml-file', projectPath, itemId), - esiSaveRepositoryItem: ( - projectPath: string, - item: ESIRepositoryItem, - xmlContent: string, - existingItems: ESIRepositoryItem[], - ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('esi:save-repository-item', projectPath, item, xmlContent, existingItems), - - 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 ===================== esiParseAndSaveFile: ( projectPath: string, filename: string, content: string, - ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; error?: string }> => + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; duplicate?: boolean; error?: string }> => ipcRenderer.invoke('esi:parse-and-save-file', projectPath, filename, content), esiClearRepository: (projectPath: string): Promise<{ success: boolean; error?: string }> => diff --git a/src/middleware/adapters/editor/esi-adapter.ts b/src/middleware/adapters/editor/esi-adapter.ts index 38fe5314a..fb5bcda3c 100644 --- a/src/middleware/adapters/editor/esi-adapter.ts +++ b/src/middleware/adapters/editor/esi-adapter.ts @@ -61,13 +61,12 @@ export function createEditorEsiAdapter(getProjectPath: () => string): EsiPort { const projectPath = requireProjectPath() const result = await window.bridge.esiParseAndSaveFile(projectPath, filename, content) if (result.success && result.item) { - return { success: true, item: result.item } as Result<{ item: NonNullable }> + return { success: true, item: result.item } } if (result.success) { - // Duplicate file — silently skip - return { success: true, item: undefined } as unknown as Result<{ - item: NonNullable - }> + // Duplicate file — surface it explicitly so callers can distinguish + // a successful add from a silent skip instead of squinting at `!item`. + return { success: true, duplicate: true } } return { success: false, error: result.error ?? 'Parse failed' } } catch (err) { diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts index 0665b4020..1461f4530 100644 --- a/src/middleware/shared/ports/esi-port.ts +++ b/src/middleware/shared/ports/esi-port.ts @@ -32,8 +32,15 @@ export interface EsiPort { /** * Parse an ESI XML file and save it to the repository. * The filename and raw XML content are provided; parsing happens on the backend. + * + * On success, `item` is present when the file was newly added, and omitted + * with `duplicate: true` when the file's content hash already exists in the + * repository — letting callers distinguish a real add from a silent skip. */ - parseAndSaveFile(filename: string, content: string): Promise> + parseAndSaveFile( + filename: string, + content: string, + ): Promise> /** * Delete a single repository item and its associated XML file. diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 1b62a0328..04d2fb5cc 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -47,7 +47,8 @@ import type { EtherCATValidateRequest, EtherCATValidateResponse, NetworkInterface, -} from '../../../types/ethercat' +} from '@root/types/ethercat' + import type { PlcStatus, RuntimeLogEntry, SerialPort, TimingStats, Unsubscribe } from './types' export interface LoginParams { diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 985edc8e1..404123765 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -391,9 +391,11 @@ export interface PLCRemoteDevice { modbusTcpConfig?: ModbusRemoteTcpConfig ethercatConfig?: { masterConfig?: { + enabled?: boolean networkInterface: string cycleTimeUs: number watchdogTimeoutCycles?: number + taskPriority?: number } devices: Array<{ id: string From c85b290d028f8751b39388ca6b2948e0bcaa834a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 20 Apr 2026 10:38:01 +0200 Subject: [PATCH 26/30] style: apply prettier to ipc main.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps CI Format Check green after the CodeRabbit batch fixes — the PostResult type alias was split across lines instead of prettier's single-line format. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/modules/ipc/main.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index a78482e48..3d3a33f10 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -334,9 +334,7 @@ class MainProcessBridge implements MainIpcModule { responseParser: (data: string) => T, timeoutMs?: number, ): Promise<{ success: true; data: T } | { success: false; error: string }> { - type PostResult = - | { success: true; data: T } - | { success: false; error: string; statusCode?: number } + type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } const doRequest = (token: string): Promise => { return new Promise((resolve) => { From 435aae47093666123dff493415ecc9f0919d3ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 20 Apr 2026 11:31:58 +0200 Subject: [PATCH 27/30] chore: add sync-shared-surfaces Claude Code skill Project-level playbook for mirroring shared-surface files between openplc-editor and openplc-web. Uses scripts/compare-surfaces.py (same source of truth as the ci-sync workflow) and guards for Windows CRLF drift by checking git ls-tree blob hashes before treating a mismatch as real work. Invoke with: /sync-shared-surfaces [--dry-run] [--direction=editor-to-web|web-to-editor] [--no-commit] Requires a one-time Claude Code restart for the skills directory watcher to pick up the new skill. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/sync-shared-surfaces/SKILL.md | 217 +++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 .claude/skills/sync-shared-surfaces/SKILL.md diff --git a/.claude/skills/sync-shared-surfaces/SKILL.md b/.claude/skills/sync-shared-surfaces/SKILL.md new file mode 100644 index 000000000..3672a10f4 --- /dev/null +++ b/.claude/skills/sync-shared-surfaces/SKILL.md @@ -0,0 +1,217 @@ +--- +name: sync-shared-surfaces +description: Mirror shared-surface files between openplc-editor and openplc-web to resolve Shared Surface Sync CI failures. Use when the `sync / Shared Surface Sync` job fails with `hash_mismatch`, `only_in_editor`, or `only_in_web` diffs, or proactively before pushing a feature branch that touches `src/frontend/`, `src/middleware/shared/`, `src/backend/shared/`, or `src/__architecture__/`. +argument-hint: "[--direction=editor-to-web|web-to-editor] [--dry-run] [--no-commit]" +allowed-tools: Bash Read Edit Write Grep Glob +--- + +# Sync Shared Surfaces (openplc-editor ↔ openplc-web) + +Playbook for keeping the shared surfaces of `openplc-editor` and `openplc-web` +byte-identical, which is what the `ci-sync.yml` workflow enforces via +`scripts/compare-surfaces.py`. The same four surface roots are the source of +truth — any change here, on either repo, must land on the other before CI can +go green. + +## What counts as a shared surface + +Defined in `scripts/compare-surfaces.py` (`SURFACES` constant): + +- `src/frontend/` +- `src/middleware/shared/` +- `src/backend/shared/` +- `src/__architecture__/` + +Adapters (`middleware/adapters/editor`, `middleware/adapters/web`), backend +process code (`src/main/`, `src/backend/editor/`, `src/backend/web/`), and +platform-specific configs are NOT shared and must NOT be copied across. + +## Preflight + +Run these checks in order. If any fails, STOP and ask the user how to proceed +rather than guessing. + +1. **Locate the sibling repo.** Assume `openplc-web` is a sibling of the + current `openplc-editor` working directory: + - Editor root: current `pwd` + - Web root: `../openplc-web` (resolve to absolute path) + + If the sibling does not exist, ask the user for the path. Do NOT clone. + +2. **Confirm both repos are on matching feature branches.** Run `git -C + rev-parse --abbrev-ref HEAD` on both. If they differ, surface the mismatch + and ask the user whether to proceed (sometimes one side is on `development` + while the feature branch is staged locally — that's a red flag). + +3. **Refresh remotes.** Run `git -C fetch origin ` on both. + If either working tree is behind its `origin/`, ask the user + whether to `git pull --rebase` before syncing — out-of-date local state + is the #1 cause of "I synced and it's still failing." + +4. **Verify clean working trees.** Run `git -C status --short` on both. + If there are unrelated staged/unstaged changes, stop and ask the user — + never bundle unrelated work into a sync commit. + +## Detect diffs + +Run `scripts/compare-surfaces.py` from the editor repo against the web repo's +`src/` directory: + +```bash +python3 scripts/compare-surfaces.py \ + --web-root "/src" \ + --editor-root "/src" +``` + +The script prints a JSON object to stdout. Parse it and group the diffs by +`reason`: + +- `only_in_editor` — file exists in editor but not web → default direction + editor→web +- `only_in_web` — inverse → default web→editor +- `hash_mismatch` — both sides have it but bytes differ → needs a direction + decision + +**Line-ending caveat (Windows):** `compare-surfaces.py` hashes raw bytes, so +files with CRLF in a Windows working tree may hash-mismatch against an LF +copy even when the git index is identical. Before treating a +`hash_mismatch` as real, confirm it's not just line endings by comparing +`git ls-tree` blob hashes: + +```bash +git -C ls-tree origin/ -- +git -C ls-tree origin/ -- +``` + +If those blob hashes match, the git index is already in sync and no action +is needed — report it but don't copy. + +## Present the plan + +Before touching any file, show the user a summary: + +``` +Editor branch: feat/ethercat-esi-backend (HEAD abc1234) +Web branch: feat/ethercat-esi-backend (HEAD def5678) + +Proposed sync: + editor → web (N files) + - src/frontend/... + - src/backend/shared/... + web → editor (M files) + - src/middleware/shared/... + needs decision (K files) + - src/frontend/store/__tests__/shared-slice.test.ts + Last touched in editor by commit () + Last touched in web by commit () +``` + +For every `hash_mismatch`, fetch `git log -1 --oneline -- ` on both +sides and include the last-touching commit in the display. This is the +single most important signal for picking a direction: the newer commit +usually wins, unless the older one was the shared-surface migration and the +newer one is a local fix that needs to propagate. + +Ask the user to confirm (or redirect) before applying. Do NOT batch-apply +without confirmation unless `--direction=...` was passed explicitly. + +## Apply the sync + +For each approved diff, copy the file in the chosen direction using `cp` via +Bash. Preserve the relative path exactly — use absolute source and +destination paths to avoid cwd surprises. + +```bash +cp "/" "/" +``` + +If the file's parent directory doesn't exist on the target (new +`only_in_editor` file), create it first with `mkdir -p`. + +## Validate the target + +After all copies are done, in the target repo: + +1. `npx tsc --noEmit` — type check must pass. +2. `npx prettier --check ` — formatting must pass. +3. Re-run `compare-surfaces.py` and confirm `total_diffs` dropped as + expected. Remaining diffs should only be the ones the user explicitly + chose to skip, or line-ending-only diffs already verified via + `git ls-tree`. + +If type-check fails on the target, STOP. A type error usually means the +synced file references something that doesn't exist on the target repo (web +adapters, editor-only ports, etc.). Do NOT silently edit the synced file to +"fix" the error — that defeats the point of byte-identical shared surfaces. +Instead, report the error and ask the user: it may mean the target repo is +missing a prerequisite change, or the file being synced shouldn't actually +be shared. + +## Commit + +Unless `--no-commit` was passed: + +1. Run `git -C status --short` and `git -C diff --stat` — + show the user what will land in the commit. +2. Build a commit message of the form: + + ``` + sync: mirror shared surfaces from openplc- + + Synced file(s) from openplc- @: + - + - + ... + + Co-Authored-By: Claude Opus 4.7 (1M context) + ``` + +3. Ask the user to confirm before committing. After commit, show the + resulting `git log --oneline -3` on the target so they can verify. + +**Never push.** Pushing is the user's call — surface the commit and stop. + +## Arguments + +Parse `$ARGUMENTS`: + +- `--direction=editor-to-web` — skip the per-diff prompt; every diff (including + `hash_mismatch`) flows editor→web. Useful when the editor is the canonical + source for a CodeRabbit batch, ESI feature work, etc. +- `--direction=web-to-editor` — inverse. +- `--dry-run` — do the preflight, detect, and print the plan, but stop before + copying. Always run this first when the surface count is > 20 or the user + hasn't run a sync recently. +- `--no-commit` — apply the sync and validate, but leave the target repo + with unstaged changes for the user to commit manually. + +If no `--direction` is given, require interactive confirmation for every +diff. If no `--dry-run`, still treat the first preview pass as a plan — do +not copy before the user confirms. + +## When to pick a different tool + +- **A handful of known files, direction obvious:** just `cp` by hand and + commit — faster than walking the full playbook. +- **Merge conflict in a shared file after a pull:** resolve the conflict + normally; this skill is for silent drift, not merge state. +- **Shared dependency/version drift (`package.json`):** different problem — + use `scripts/compare-dependencies.py` and align `package.json` manually + (the `Shared Dependencies Sync` job is separate from this one). +- **Tooling config drift (`.prettierrc`, `eslint.config.*`, `tsconfig*`):** + use `scripts/compare-tooling.py`; same pattern but different roots. + +## Edge cases worth calling out + +- **Test files with adapter-specific mocks.** Tests under `src/frontend/store/ + __tests__/` are shared surface, so they must be byte-identical. If a test + legitimately needs to differ per platform, move the platform-specific + parts into an adapter test (`middleware/adapters/*/_tests__/`), not into + the shared test. +- **`src/types/` is NOT in the shared surface list.** Changes there don't + trip the sync CI, so this skill won't touch them — but they may still need + manual mirroring for type consistency. +- **CI compares against a web open PR as a fallback.** The sync job will + pass with a warning if the editor's surfaces match an *open* web PR + targeting the same base. Ideal flow: land the editor changes, sync to + web, push both branches before merging either. From 8edf1cfb0c19ac0ccdcb7ddea3f08ca9b5a213c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 20 Apr 2026 22:32:28 +0200 Subject: [PATCH 28/30] =?UTF-8?q?fix(ethercat):=20address=20Jo=C3=A3o's=20?= =?UTF-8?q?review=20comments=20on=20PR=20#731?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch of fixes from @JoaoGSP's inline review: - esi-service: write-then-rename repository index; delete orphan XML when index write fails (atomicity gap on parseAndSaveFile) - esi-port: docstring now matches code — dedup is filename-based - esi-upload: fix useCallback deps; drop unused projectPath prop - esi-repository: stop forwarding projectPath to ESIUpload - ipc/main handleEtherCATGetStatus: shape-guard JSON before cast - ipc/main handleEtherCATScan: use pluginResponse.scan_time_ms instead of hardcoded 0 - ipc/main: drop misleading/empty VPP comments - ipc/renderer: import type for RuntimeLogEntry Web-side sync: Autonomy-Logic/openplc-web#371 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/editor/ethercat/esi-service.ts | 13 ++++++++++--- .../ethercat/components/esi-repository.tsx | 7 +------ .../device/ethercat/components/esi-upload.tsx | 5 ++--- src/main/modules/ipc/main.ts | 18 +++++++++++++----- src/main/modules/ipc/renderer.ts | 2 +- src/middleware/shared/ports/esi-port.ts | 7 +++++-- 6 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts index 0abb79d26..8540d7259 100644 --- a/src/backend/editor/ethercat/esi-service.ts +++ b/src/backend/editor/ethercat/esi-service.ts @@ -180,7 +180,9 @@ class ESIService { } const repoPath = this.getRepositoryPath(projectPath) - await promises.writeFile(repoPath, JSON.stringify(index, null, 2), 'utf-8') + const tmpPath = `${repoPath}.tmp` + await promises.writeFile(tmpPath, JSON.stringify(index, null, 2), 'utf-8') + await promises.rename(tmpPath, repoPath) return { success: true } } catch (error) { @@ -443,9 +445,14 @@ class ESIService { warnings: parseResult.warnings, } - // Append to v2 index + // Append to v2 index. If the index write fails, delete the XML we just + // wrote so we don't leave an orphan that's unreferenced by the index. const currentItems = await this.loadLightItemsFromIndex(projectPath) - await this.saveRepositoryIndexV2(projectPath, [...currentItems, item]) + const indexResult = await this.saveRepositoryIndexV2(projectPath, [...currentItems, item]) + if (!indexResult.success) { + await this.deleteXmlFile(projectPath, itemId) + return { success: false, error: indexResult.error ?? 'Failed to update repository index' } + } return { success: true, item } } catch (error) { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx index 85f3ae3bf..4cb7a0c5a 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -81,12 +81,7 @@ const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading return (
    {/* Upload Area */} - + {/* Error Summary (collapsible) */} {uploadErrors.length > 0 && ( diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx index 18133386a..42cd63c03 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -17,7 +17,6 @@ type ESIUploadProps = { onFilesLoaded: (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => void repository: ESIRepositoryItemLight[] isLoading?: boolean - projectPath: string } /** @@ -26,7 +25,7 @@ type ESIUploadProps = { * 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 ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadProps) => { const esi = useEsi() const [isDragging, setIsDragging] = useState(false) const [parseProgress, setParseProgress] = useState({ @@ -105,7 +104,7 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false, projectPath } onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) }, - [onFilesLoaded, repository, projectPath], + [onFilesLoaded, repository, esi], ) const handleDragOver = useCallback((e: React.DragEvent) => { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3d3a33f10..12330d41f 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -61,7 +61,6 @@ class MainProcessBridge implements MainIpcModule { private fileWatchers: Map = new Map() // avr8js ATmega2560 emulator instance for the built-in simulator private simulatorModule = new SimulatorModule() - // VPP package manager for board package operations // ESI repository service for EtherCAT device descriptions private esiService = new ESIService() @@ -637,8 +636,6 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('hardware:refresh-communication-ports', this.handleHardwareRefreshCommunicationPorts) this.registerHandle('hardware:refresh-available-boards', this.handleHardwareRefreshAvailableBoards) - // ===================== PACKAGE MANAGER ===================== - // ===================== UTILITIES ===================== this.registerHandle('util:get-preview-image', this.handleUtilGetPreviewImage) this.ipcMain.on('util:log', this.handleUtilLog) @@ -1539,7 +1536,18 @@ class MainProcessBridge implements MainIpcModule { ipAddress, jwtToken, '/api/discovery/ethercat/status', - (data: string) => JSON.parse(data) as EtherCATServiceStatusResponse, + (data: string) => { + const parsed = JSON.parse(data) as unknown + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as { available?: unknown }).available !== 'boolean' || + typeof (parsed as { message?: unknown }).message !== 'string' + ) { + throw new Error('EtherCAT status response did not match expected shape') + } + return parsed as EtherCATServiceStatusResponse + }, ) if (result.success && result.data) { return { success: true, data: result.data } @@ -1577,7 +1585,7 @@ class MainProcessBridge implements MainIpcModule { status: (pluginResponse.status as string) ?? 'success', devices: (pluginResponse.devices as EtherCATScanResponse['devices']) ?? [], message: (pluginResponse.message as string) ?? '', - scan_time_ms: 0, + scan_time_ms: (pluginResponse.scan_time_ms as number) ?? 0, interface: scanRequest.interface, } as EtherCATScanResponse }, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 4af2651b9..42702770c 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,4 @@ -import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { RuntimeLogEntry } from '@root/middleware/shared/ports' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts index 1461f4530..240e73fc7 100644 --- a/src/middleware/shared/ports/esi-port.ts +++ b/src/middleware/shared/ports/esi-port.ts @@ -34,8 +34,11 @@ export interface EsiPort { * The filename and raw XML content are provided; parsing happens on the backend. * * On success, `item` is present when the file was newly added, and omitted - * with `duplicate: true` when the file's content hash already exists in the - * repository — letting callers distinguish a real add from a silent skip. + * with `duplicate: true` when a repository entry with the same filename + * already exists — letting callers distinguish a real add from a silent skip. + * Dedup is filename-based: reimporting the same bytes under a different name + * will add a new entry, and replacing a file's contents without renaming it + * is reported as a duplicate. */ parseAndSaveFile( filename: string, From bcae26f1f0e9f6f92cc117ab216db2f2049400b5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Apr 2026 16:43:13 -0400 Subject: [PATCH 29/30] docs: add Phase 4 debugger plan (scalable per-leaf addressing) New debugger design based on discussion: - Full leaf-level expansion, stored as {void* ptr, type_tag} entries in PROGMEM (Flash). Zero SRAM cost. - Multiple debug arrays (8000 entries each) to work around AVR's 32767-byte single-object limit that causes the current failure at ~33K leaves. - Protocol addressing: (array_idx: u8, elem_idx: u16). Wire format keeps FCs 0x41-0x45 with updated PDU layout. - Agnostic dispatch: type-tag indexed TypeOps table in the STruC++ runtime header, with templated force_impl / unforce_impl / read_impl per IEC elementary type. No per-project debug logic -- only the compiler-emitted pointer tables are project-specific. - debug-map.json replaces debug.c on the editor side. Upper-layer changes are minimal: composite-key tree, polling cadence, forcing UI, and Zustand shapes stay the same; only the address type changes from number to {arrayIdx, elemIdx}. - Two-stage rollout: Phase 4a polling only, Phase 4b adds subscribe (0x46) + unsolicited stream (0x48) for low-latency embedded. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/strucpp-migration/04-debugger.md | 441 ++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 docs/strucpp-migration/04-debugger.md diff --git a/docs/strucpp-migration/04-debugger.md b/docs/strucpp-migration/04-debugger.md new file mode 100644 index 000000000..40035b44a --- /dev/null +++ b/docs/strucpp-migration/04-debugger.md @@ -0,0 +1,441 @@ +# Phase 4: Debugger + +## Status: Design locked, ready to implement + +## Goals + +- Replace the flat `debug_vars[]` mechanism (MatIEC + xml2st) with a scalable scheme built + on STruC++'s `IECVar` forcing API. +- Work well for small embedded targets (~50 variables on Arduino Mega) **and** large + Linux projects (50K+ variables on Runtime v4). +- Minimize rewrite of the editor's upper layers — the composite-key tree, polling loop, + forcing UI, and Zustand store should remain effectively unchanged. +- Full leaf-level addressability: every array element and every struct/FB field is + independently readable and forceable. + +## Why the current design fails + +The MatIEC pipeline generates `debug.c` containing a single `debug_vars[]` array with one +entry per **leaf** (arrays and structs expanded element-by-element). Real-world failure +mode observed on a user project: + +``` +src/debug.c:32926:1: error: size of array is too large +``` + +That project had ~33,000 leaves. AVR GCC's `size_t` is 16-bit, so a single object cannot +exceed 32,767 bytes — with a multi-field struct per entry the ceiling is hit well before +that element count. Moving metadata to Flash is necessary but not sufficient: the +*single-array* constraint itself is the blocker. + +## Design summary + +1. **Every leaf is expanded.** No runtime walking of composite variables — each array + element and struct/FB field gets its own table entry. +2. **Multiple debug arrays.** The compiler emits the table as a set of arrays, each capped + at 8,000 entries (safe margin under AVR's 32,767-byte object limit, assuming 4 B/entry). + On Linux the same split is used for consistency. +3. **Compact per-entry format.** `struct Entry { void* ptr; uint8_t type_tag; uint8_t _pad; }` + = 4 bytes. Entry table lives in Flash (`PROGMEM` on AVR, `.rodata` on Linux). Zero SRAM + cost. +4. **Agnostic runtime dispatcher.** Part of the STruC++ runtime headers — not per-project + generated code. A `TypeOps` table indexed by `type_tag` holds function pointers to + `force_impl`, `unforce_impl`, `read_impl` instantiated for each IEC elementary + type. Covers all leaves because STruC++ wraps every leaf as `IECVar` where T is an + elementary type. +5. **Protocol addressing: `(array_idx: u8, elem_idx: u16)`.** 256 arrays × 65K entries = + 16M addressable leaves. More than enough for any realistic project. +6. **Two-stage rollout:** polling-based first (Modbus FCs 0x41–0x45 with the new PDU + layout), subscription/stream later (0x46–0x48). Subscription is orthogonal to addressing + and can ship as a follow-up without breaking the base protocol. + +--- + +## What gets generated vs. what ships in the runtime + +### Per-project (emitted by `strucpp-compiler.ts`) + +**`generated_debug.cpp`** — pointer tables only: + +```cpp +#include "generated.hpp" +#include "strucpp/debug_dispatch.hpp" + +using namespace strucpp::debug; + +// Each array caps at ~8000 entries to stay under AVR's 32767-byte single-object limit. +const Entry debug_arr_0[] PROGMEM = { + { (void*)&g_config.INSTANCE0.blink, TAG_BOOL }, + { (void*)&g_config.INSTANCE0.counter, TAG_INT }, + { (void*)&g_config.INSTANCE0.speeds[0], TAG_INT }, + { (void*)&g_config.INSTANCE0.speeds[1], TAG_INT }, + // ... +}; +const Entry debug_arr_1[] PROGMEM = { /* next batch */ }; + +const Entry* const debug_arrays[] PROGMEM = { debug_arr_0, debug_arr_1 }; +const uint16_t debug_array_counts[] PROGMEM = { 8000, 5231 }; +const uint8_t debug_array_count = 2; +``` + +**`debug-map.json`** — editor-only manifest: + +```json +{ + "version": 2, + "md5": "", + "typeTags": { + "BOOL": 0, "SINT": 1, "INT": 2, "DINT": 3, "LINT": 4, + "REAL": 5, "LREAL": 6, "STRING": 7, "TIME": 8 + }, + "arrays": [ + { "index": 0, "count": 8000 }, + { "index": 1, "count": 5231 } + ], + "leaves": [ + { "arrayIdx": 0, "elemIdx": 0, "path": "INSTANCE0.blink", "type": "BOOL", "size": 1 }, + { "arrayIdx": 0, "elemIdx": 1, "path": "INSTANCE0.counter", "type": "INT", "size": 2 }, + { "arrayIdx": 0, "elemIdx": 2, "path": "INSTANCE0.speeds[0]", "type": "INT", "size": 2 }, + { "arrayIdx": 0, "elemIdx": 3, "path": "INSTANCE0.speeds[1]", "type": "INT", "size": 2 } + ] +} +``` + +Packing rule: emit leaves in declaration order, flush to a new debug array when the +current one reaches 8,000 entries **or** at a program boundary (whichever comes first). +Program-boundary flush isolates per-program churn from downstream arrays. + +### Shared across all projects (STruC++ runtime headers) + +**`strucpp/debug_dispatch.hpp`** — the generic handler, unchanged across projects: + +```cpp +namespace strucpp::debug { + +enum TypeTag : uint8_t { + TAG_BOOL = 0, TAG_SINT, TAG_USINT, TAG_INT, TAG_UINT, + TAG_DINT, TAG_UDINT, TAG_LINT, TAG_ULINT, + TAG_REAL, TAG_LREAL, + TAG_BYTE, TAG_WORD, TAG_DWORD, TAG_LWORD, + TAG_TIME, TAG_DATE, TAG_TOD, TAG_DT, + TAG_STRING, TAG_WSTRING, + TAG__COUNT +}; + +struct Entry { void* ptr; uint8_t tag; uint8_t _pad; }; + +template +static void force_impl(void* p, const uint8_t* bytes) { + T v; memcpy(&v, bytes, sizeof(T)); + static_cast*>(p)->force(v); +} +template +static void unforce_impl(void* p) { + static_cast*>(p)->unforce(); +} +template +static void read_impl(const void* p, uint8_t* dest) { + T v = static_cast*>(p)->get(); + memcpy(dest, &v, sizeof(T)); +} + +struct TypeOps { + void (*force) (void*, const uint8_t*); + void (*unforce)(void*); + void (*read) (const void*, uint8_t*); + uint8_t size; +}; + +constexpr TypeOps type_ops[TAG__COUNT] = { + /*BOOL */ { force_impl, unforce_impl, read_impl, 1 }, + /*SINT */ { force_impl, unforce_impl, read_impl, 1 }, + /*... */ + /*STRING*/ { /* special-case: variable-length */ nullptr, nullptr, nullptr, 0 }, +}; + +inline Entry read_entry(uint8_t arr, uint16_t elem); // PROGMEM-aware accessor + +inline void handle_set(uint8_t arr, uint16_t elem, bool forcing, const uint8_t* bytes) { + auto e = read_entry(arr, elem); + if (forcing) type_ops[e.tag].force(e.ptr, bytes); + else type_ops[e.tag].unforce(e.ptr); +} + +inline void handle_read(uint8_t arr, uint16_t elem, uint8_t* dest) { + auto e = read_entry(arr, elem); + type_ops[e.tag].read(e.ptr, dest); +} + +} // namespace strucpp::debug +``` + +STRING/WSTRING need special handling (variable length). Wire format: +`{uint16_t length, bytes[length]}`. Implementation reads/writes into `IECString` via +a specialization rather than the generic `read_impl` template. + +### PROGMEM access on ATmega2560 + +Debug tables will cross the 64 KB Flash boundary for large projects. The `read_entry()` +accessor uses `pgm_read_*_far()` on AVR, a plain array access everywhere else: + +```cpp +inline Entry read_entry(uint8_t arr, uint16_t elem) { +#ifdef __AVR__ + uint32_t base = pgm_get_far_address(debug_arrays); + const Entry* table = (const Entry*)pgm_read_word_far(base + arr * sizeof(void*)); + Entry e; + // read 4 bytes from PROGMEM, handling far address construction + uint32_t entry_addr = pgm_get_far_address(*table) + elem * sizeof(Entry); + e.ptr = (void*)pgm_read_word_far(entry_addr); + e.tag = pgm_read_byte_far(entry_addr + 2); + return e; +#else + return debug_arrays[arr][elem]; +#endif +} +``` + +Slight perf cost (~4 cycles extra per lookup) — irrelevant at debugger polling cadence. + +--- + +## Wire protocol + +Function codes keep the 0x41–0x45 numbering (same Modbus dispatcher structure), but the +addressing fields change from `u16 flat_index` to `(u8 array_idx, u16 elem_idx)`. + +| FC | Name | Request payload | Response payload | +|------|---------------------|--------------------------------------------------------|--------------------------------------------------| +| 0x41 | DEBUG_INFO | (empty) | `[array_count:u8, (elem_count:u16)×array_count]` | +| 0x42 | DEBUG_SET | `arr:u8, elem:u16, force:u8, len:u16, value...` | `status:u8` | +| 0x43 | DEBUG_GET_RANGE | `arr:u8, start_elem:u16, end_elem:u16` | `status:u8, last_elem:u16, tick:u32, size:u16, data...` | +| 0x44 | DEBUG_GET_LIST | `count:u16, (arr:u8, elem:u16)×count` | `status:u8, last_idx:u16, tick:u32, size:u16, data...` | +| 0x45 | DEBUG_GET_MD5 | `endian_check:u16` | `status:u8, md5:ascii, endian_echo:u16` | + +Phase 4b additions (subscribe/stream): + +| FC | Name | Request payload | Response payload | +|------|---------------------|--------------------------------------------------------|--------------------------------------------------| +| 0x46 | WATCH_SUBSCRIBE | `interval_ms:u16, count:u16, (arr:u8, elem:u16)×count` | `handle:u8, status:u8` | +| 0x47 | WATCH_UNSUBSCRIBE | `handle:u8` | `status:u8` | +| 0x48 | STREAM (unsolicited)| — | `handle:u8, tick:u32, size:u16, data...` | + +Notes: + +- Endianness: data payloads are native byte order of the target (no swap on the wire). + Editor probes with FC 0x45 and byte-swaps locally if target differs. Matches current + behavior. +- Chunking: same as today — if a GET_RANGE / GET_LIST response exceeds the Modbus frame + limit, the target returns what fits and reports `last_idx`. Editor retries from there. +- Per-array addressing implies that `DEBUG_GET_RANGE` operates within a single debug + array. Cross-array batch reads use `DEBUG_GET_LIST`. +- Unsolicited STREAM frames break strict Modbus master/slave semantics but are safe in + practice because OpenPLC targets use Modbus RTU over USB-CDC (full-duplex) or TCP. + RS-485 half-duplex users should stay on polling mode. + +--- + +## Implementation plan + +### 4.1 STruC++ runtime — debug dispatch headers *(ships in strucpp@≥0.3.0)* + +- `src/runtime/include/debug_dispatch.hpp` — `TypeTag` enum, `Entry` struct, `TypeOps` table, + templated `force_impl` / `unforce_impl` / `read_impl`, STRING/WSTRING specializations, + PROGMEM-aware `read_entry()`. +- `src/runtime/include/debug_handler.hpp` — protocol-level helpers: + `handle_info()`, `handle_set()`, `handle_get_range()`, `handle_get_list()`, + `handle_get_md5()` — frame-agnostic (take input/output buffers, return bytes written). +- Unit tests in the STruC++ repo covering the templated helpers with a mocked Entry table. + +**Exit criteria:** STruC++ runtime exports a single `strucpp::debug::handle_*` API that the +editor's Arduino sketch and the Runtime v4 `.so` can both call. No per-project code here. + +### 4.2 Editor — code generator for `generated_debug.cpp` + `debug-map.json` + +Location: `src/backend/shared/utils/PLC/generate-debug-table.ts` + +Inputs: +- `CompileResult` from the STruC++ compile wrapper (AST + symbol tables + project model). +- The `program.st` source (for MD5). + +Outputs (written to the board's `src/` directory alongside `generated.cpp`): +- `generated_debug.cpp` (Flash-resident pointer tables, packing rule described above). +- `debug-map.json` (editor-side path→address manifest). + +Core algorithm: + +``` +for each program instance in projectModel: + for each variable in program.vars (in declaration order): + walk(variable, path = "INSTANCE0.varName"): + if leaf (elementary type): + emit entry { &path, tag(type) } + elif array: + for i in dimensions: + walk(element, path = "${path}[${i}]") + elif struct/FB: + for field in fields: + walk(field, path = "${path}.${field.name}") + flush to new debug array // program-boundary flush +``` + +Cap per array at 8,000 entries; new array also starts when an element would push the +byte count past 32,000 (safety margin vs. AVR's 32,767 limit). + +### 4.3 Editor — compiler-module wiring + +`src/backend/editor/compiler/compiler-module.ts`: +- After `handleCompileSTtoCpp()` produces `generated.cpp/.hpp`, call + `generateDebugTable(compileResult)` to emit the two new files. +- Add `generated_debug.cpp` to the list of sources arduino-cli compiles (lives in the + `src/` library directory, picked up automatically). +- No changes to the Arduino sketch itself — it doesn't need to know about debug tables. + +### 4.4 Embedded — ModbusSlave integration + +`resources/sources/StrucppBaremetal/ModbusSlave.cpp`: +- Replace the existing 0x41–0x45 handler bodies (which call the MatIEC-era + `get_var_count()` / `get_var_addr()` / `set_trace()` weak externs) with direct calls + into `strucpp::debug::handle_*`. +- Drop the weak-extern declarations. +- Behavior change for FC 0x41: returns `{array_count, (elem_count)×N}` instead of a + single `var_count`. PDU grows slightly but remains under the Modbus frame limit even + for 256 arrays (≤513 bytes — chunk if exceeded, though realistic projects have ≤10 + arrays). + +### 4.5 Editor — frontend and middleware changes + +These are the **only** editor-side changes (kept minimal on purpose): + +**`src/middleware/shared/ports/debugger-port.ts`:** +```ts +export interface DebugAddr { arrayIdx: number; elemIdx: number } + +getVariablesList(refs: DebugAddr[]): Promise +setVariable(ref: DebugAddr, force: boolean, valueBuffer?: Uint8Array): Promise +readDebugMap(projectPath: string, boardTarget: string): Promise +``` + +**`src/frontend/utils/debug-parser.ts`:** Add `parseDebugMapV2()` alongside existing +`parseDebugFile()`. Both paths exist only long enough to flip users over — v1 (MatIEC) is +removed once Phase 4 ships. + +**`src/frontend/utils/debugger-session.ts` (`buildVariableIndexMap`):** The composite key +continues to be `pouName:varName.field[idx]`. The value stored in +`workspace.debugVariableIndexes` changes from `number` to `DebugAddr`. Everything +downstream that reads this map updates accordingly (a small, mechanical edit — the polling +loop, the force handler, the tree builder all go through this map). + +**`src/frontend/hooks/useDebugPolling.ts`:** No structural change — same batching logic, +same 50/200 ms cadences, same round-robin. Just passes `DebugAddr[]` through to the port +instead of `number[]`. + +**`src/frontend/hooks/useDebugSession.ts`:** Reads `debug-map.json` at session start +(via `debuggerPort.readDebugMap()`) instead of `debug.c`. The rest of the lifecycle +(MD5 verify, build tree, start polling) is unchanged. + +**Tree builder (`debug-tree-builder.ts` / `debug-tree-traversal.ts`):** Today it expands +arrays eagerly using the flat index. Under v2 it keeps doing the same expansion — the +source of truth moves from `debug.c` to `debug-map.json`, but the UI shape and composite +keys stay identical. A later optimization could lazy-load array children on expand, but +it's not needed for correctness and is out of scope for this phase. + +**IPC adapter (`debugger-adapter.ts`):** Serializes `DebugAddr` pairs over the bridge. +Trivial. + +### 4.6 Subscribe / stream (Phase 4b — follow-up) + +Deferred until Phase 4a is stable and the basic polling path is validated end-to-end. + +Target side: +- `strucpp::debug::handle_subscribe()` stores `{handle, interval_ms, address_list[]}` in + a fixed-size table (max ~4 subscriptions). On each scan cycle, if `tick * scan_ms >= + next_emit_time`, assemble a 0x48 frame and hand it to `ModbusSlave.sendUnsolicited()`. +- `ModbusSlave` gets a new `sendUnsolicited(frame, len)` method. Must not interleave with + a request currently being served — use a tiny flag. + +Editor side: +- `debuggerPort.subscribe(addrs, intervalMs) -> handle` and a handler in `useDebugPolling` + that, when subscriptions are active, stops polling those addresses and listens for + STREAM frames instead. + +### 4.7 Runtime v4 integration + +`src/backend/shared/utils/PLC/generate-v4-compat.ts` already exports C-linkage shims for +the Runtime v4 `.so`. Add: + +```c +extern "C" uint8_t strucpp_debug_array_count(); +extern "C" uint16_t strucpp_debug_elem_count(uint8_t arr); +extern "C" void strucpp_debug_set (uint8_t arr, uint16_t elem, bool force, + const uint8_t* bytes, uint16_t len); +extern "C" void strucpp_debug_read(uint8_t arr, uint16_t elem, + uint8_t* dest, uint16_t* size_out); +``` + +These are thin wrappers over `strucpp::debug::handle_*`. The runtime's existing +`debug_handler.c` is replaced by a much smaller shim that parses Modbus PDUs and calls +these exports. + +--- + +## Scalability summary + +| Project size | Debug arrays | Flash (table) | SRAM | +|--------------|--------------|---------------|------| +| 50 leaves | 1 | 200 B | 0 B | +| 500 leaves | 1 | 2 KB | 0 B | +| 3,500 leaves | 1 | 14 KB | 0 B | +| 20,000 leaves| 3 | 80 KB | 0 B | +| 50,000 leaves| 7 | 200 KB | 0 B | + +(AVR Mega has 256 KB Flash — fits projects up to ~50K leaves *in theory*; realistic +embedded deployments top out well below that.) + +--- + +## Testing strategy + +1. **Unit tests (STruC++ repo):** `debug_dispatch.hpp` force/read cycle for each + `TypeTag`, using a synthetic Entry table and a mock IECVar. +2. **Compile test:** run `generate-debug-table.ts` against a project with mixed scalars, + arrays, structs, FBs → verify `generated_debug.cpp` compiles on AVR (arduino-cli + `arduino:avr:mega`) and `debug-map.json` shape matches. +3. **Size regression:** the 35K-leaf user project that currently fails MatIEC must + compile cleanly with the new pipeline. +4. **End-to-end (Arduino Mega):** reuse the Chris Demo blink project. Force `blink := + TRUE` from the editor, verify PB7 stays HIGH across scan cycles. Unforce, verify + oscillation resumes. Validate in the avr8js simulator first, then hardware. +5. **End-to-end (Runtime v4):** same blink project compiled as `.so`, dlopen'd by the + runtime, debugger connects via WebSocket, force/read verified. +6. **Regression on existing hierarchical UI:** the composite-key tree, force badges, + graph/plot, and per-FB instance switching must render identically. + +--- + +## Out of scope for Phase 4 + +- Breakpoints / step / continue. STruC++ doesn't have source-level step support at this + point; separate initiative. +- On-demand / lazy array expansion in the UI. Full eager expansion is kept for + compatibility; an optimization pass can add lazy loading later if large-array + rendering becomes a bottleneck. +- Per-variable rate limiting on subscriptions. A single interval per subscription handle + is sufficient for Phase 4b. + +--- + +## Open items to confirm during implementation + +1. **`g_config` static address stability on AVR.** The `&g_config.INSTANCE0.counter` + expressions in `generated_debug.cpp` must be constant expressions for PROGMEM + initializers. If the AVR linker ever decides `g_config` needs dynamic construction + (it shouldn't — it's a static POD-constructible instance), we'd need to populate the + table at runtime in `setup()`. Verify with a test compile. +2. **String length encoding.** Fixed-size `IECString` has a runtime length field plus + up to N bytes of data. Wire encoding for Phase 4a: `{u16 len, bytes[len]}`. Confirm + that forcing a longer value than N is rejected cleanly (return an error status in FC + 0x42). +3. **MD5 scope.** Current MatIEC MD5 covers `program.st`. Under v2 the debug-map layout + depends on STruC++ version too — consider hashing `{program.st, strucpp_version}` so + that a runtime library bump invalidates stale editor state automatically. From 7aee1b9a707cb96897bf8bd4306ed73fe6bf3a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 22 Apr 2026 16:59:20 +0200 Subject: [PATCH 30/30] chore: untrack sync-shared-surfaces Claude Code skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills de desenvolvimento local do Claude Code não devem viver no repo — mantidas apenas na máquina do desenvolvedor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/sync-shared-surfaces/SKILL.md | 217 ------------------- 1 file changed, 217 deletions(-) delete mode 100644 .claude/skills/sync-shared-surfaces/SKILL.md diff --git a/.claude/skills/sync-shared-surfaces/SKILL.md b/.claude/skills/sync-shared-surfaces/SKILL.md deleted file mode 100644 index 3672a10f4..000000000 --- a/.claude/skills/sync-shared-surfaces/SKILL.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -name: sync-shared-surfaces -description: Mirror shared-surface files between openplc-editor and openplc-web to resolve Shared Surface Sync CI failures. Use when the `sync / Shared Surface Sync` job fails with `hash_mismatch`, `only_in_editor`, or `only_in_web` diffs, or proactively before pushing a feature branch that touches `src/frontend/`, `src/middleware/shared/`, `src/backend/shared/`, or `src/__architecture__/`. -argument-hint: "[--direction=editor-to-web|web-to-editor] [--dry-run] [--no-commit]" -allowed-tools: Bash Read Edit Write Grep Glob ---- - -# Sync Shared Surfaces (openplc-editor ↔ openplc-web) - -Playbook for keeping the shared surfaces of `openplc-editor` and `openplc-web` -byte-identical, which is what the `ci-sync.yml` workflow enforces via -`scripts/compare-surfaces.py`. The same four surface roots are the source of -truth — any change here, on either repo, must land on the other before CI can -go green. - -## What counts as a shared surface - -Defined in `scripts/compare-surfaces.py` (`SURFACES` constant): - -- `src/frontend/` -- `src/middleware/shared/` -- `src/backend/shared/` -- `src/__architecture__/` - -Adapters (`middleware/adapters/editor`, `middleware/adapters/web`), backend -process code (`src/main/`, `src/backend/editor/`, `src/backend/web/`), and -platform-specific configs are NOT shared and must NOT be copied across. - -## Preflight - -Run these checks in order. If any fails, STOP and ask the user how to proceed -rather than guessing. - -1. **Locate the sibling repo.** Assume `openplc-web` is a sibling of the - current `openplc-editor` working directory: - - Editor root: current `pwd` - - Web root: `../openplc-web` (resolve to absolute path) - - If the sibling does not exist, ask the user for the path. Do NOT clone. - -2. **Confirm both repos are on matching feature branches.** Run `git -C - rev-parse --abbrev-ref HEAD` on both. If they differ, surface the mismatch - and ask the user whether to proceed (sometimes one side is on `development` - while the feature branch is staged locally — that's a red flag). - -3. **Refresh remotes.** Run `git -C fetch origin ` on both. - If either working tree is behind its `origin/`, ask the user - whether to `git pull --rebase` before syncing — out-of-date local state - is the #1 cause of "I synced and it's still failing." - -4. **Verify clean working trees.** Run `git -C status --short` on both. - If there are unrelated staged/unstaged changes, stop and ask the user — - never bundle unrelated work into a sync commit. - -## Detect diffs - -Run `scripts/compare-surfaces.py` from the editor repo against the web repo's -`src/` directory: - -```bash -python3 scripts/compare-surfaces.py \ - --web-root "/src" \ - --editor-root "/src" -``` - -The script prints a JSON object to stdout. Parse it and group the diffs by -`reason`: - -- `only_in_editor` — file exists in editor but not web → default direction - editor→web -- `only_in_web` — inverse → default web→editor -- `hash_mismatch` — both sides have it but bytes differ → needs a direction - decision - -**Line-ending caveat (Windows):** `compare-surfaces.py` hashes raw bytes, so -files with CRLF in a Windows working tree may hash-mismatch against an LF -copy even when the git index is identical. Before treating a -`hash_mismatch` as real, confirm it's not just line endings by comparing -`git ls-tree` blob hashes: - -```bash -git -C ls-tree origin/ -- -git -C ls-tree origin/ -- -``` - -If those blob hashes match, the git index is already in sync and no action -is needed — report it but don't copy. - -## Present the plan - -Before touching any file, show the user a summary: - -``` -Editor branch: feat/ethercat-esi-backend (HEAD abc1234) -Web branch: feat/ethercat-esi-backend (HEAD def5678) - -Proposed sync: - editor → web (N files) - - src/frontend/... - - src/backend/shared/... - web → editor (M files) - - src/middleware/shared/... - needs decision (K files) - - src/frontend/store/__tests__/shared-slice.test.ts - Last touched in editor by commit () - Last touched in web by commit () -``` - -For every `hash_mismatch`, fetch `git log -1 --oneline -- ` on both -sides and include the last-touching commit in the display. This is the -single most important signal for picking a direction: the newer commit -usually wins, unless the older one was the shared-surface migration and the -newer one is a local fix that needs to propagate. - -Ask the user to confirm (or redirect) before applying. Do NOT batch-apply -without confirmation unless `--direction=...` was passed explicitly. - -## Apply the sync - -For each approved diff, copy the file in the chosen direction using `cp` via -Bash. Preserve the relative path exactly — use absolute source and -destination paths to avoid cwd surprises. - -```bash -cp "/" "/" -``` - -If the file's parent directory doesn't exist on the target (new -`only_in_editor` file), create it first with `mkdir -p`. - -## Validate the target - -After all copies are done, in the target repo: - -1. `npx tsc --noEmit` — type check must pass. -2. `npx prettier --check ` — formatting must pass. -3. Re-run `compare-surfaces.py` and confirm `total_diffs` dropped as - expected. Remaining diffs should only be the ones the user explicitly - chose to skip, or line-ending-only diffs already verified via - `git ls-tree`. - -If type-check fails on the target, STOP. A type error usually means the -synced file references something that doesn't exist on the target repo (web -adapters, editor-only ports, etc.). Do NOT silently edit the synced file to -"fix" the error — that defeats the point of byte-identical shared surfaces. -Instead, report the error and ask the user: it may mean the target repo is -missing a prerequisite change, or the file being synced shouldn't actually -be shared. - -## Commit - -Unless `--no-commit` was passed: - -1. Run `git -C status --short` and `git -C diff --stat` — - show the user what will land in the commit. -2. Build a commit message of the form: - - ``` - sync: mirror shared surfaces from openplc- - - Synced file(s) from openplc- @: - - - - - ... - - Co-Authored-By: Claude Opus 4.7 (1M context) - ``` - -3. Ask the user to confirm before committing. After commit, show the - resulting `git log --oneline -3` on the target so they can verify. - -**Never push.** Pushing is the user's call — surface the commit and stop. - -## Arguments - -Parse `$ARGUMENTS`: - -- `--direction=editor-to-web` — skip the per-diff prompt; every diff (including - `hash_mismatch`) flows editor→web. Useful when the editor is the canonical - source for a CodeRabbit batch, ESI feature work, etc. -- `--direction=web-to-editor` — inverse. -- `--dry-run` — do the preflight, detect, and print the plan, but stop before - copying. Always run this first when the surface count is > 20 or the user - hasn't run a sync recently. -- `--no-commit` — apply the sync and validate, but leave the target repo - with unstaged changes for the user to commit manually. - -If no `--direction` is given, require interactive confirmation for every -diff. If no `--dry-run`, still treat the first preview pass as a plan — do -not copy before the user confirms. - -## When to pick a different tool - -- **A handful of known files, direction obvious:** just `cp` by hand and - commit — faster than walking the full playbook. -- **Merge conflict in a shared file after a pull:** resolve the conflict - normally; this skill is for silent drift, not merge state. -- **Shared dependency/version drift (`package.json`):** different problem — - use `scripts/compare-dependencies.py` and align `package.json` manually - (the `Shared Dependencies Sync` job is separate from this one). -- **Tooling config drift (`.prettierrc`, `eslint.config.*`, `tsconfig*`):** - use `scripts/compare-tooling.py`; same pattern but different roots. - -## Edge cases worth calling out - -- **Test files with adapter-specific mocks.** Tests under `src/frontend/store/ - __tests__/` are shared surface, so they must be byte-identical. If a test - legitimately needs to differ per platform, move the platform-specific - parts into an adapter test (`middleware/adapters/*/_tests__/`), not into - the shared test. -- **`src/types/` is NOT in the shared surface list.** Changes there don't - trip the sync CI, so this skill won't touch them — but they may still need - manual mirroring for type consistency. -- **CI compares against a web open PR as a fallback.** The sync job will - pass with a warning if the editor's surfaces match an *open* web PR - targeting the same base. Ideal flow: land the editor changes, sync to - web, push both branches before merging either.