From 60b71d639bfcce6baf7007a3720355c6babbc73b Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 11 May 2026 10:33:44 +0200 Subject: [PATCH 1/4] fix(ethercat): prevent slave name collisions across masters [DOPE-281] - Source slave name from (short, e.g. "EL1809") instead of the long descriptor. - Auto-suffix _NN at creation when the base collides with any existing slave in any master. - Reject rename to a name already taken by another slave, matching the pattern used by POU/datatype/master renames. - Re-add the long ESI descriptor as a subtitle below the slave header now that the title shows the short form. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/unique-slave-name.test.ts | 59 ++++++++ .../shared/ethercat/unique-slave-name.ts | 44 ++++++ .../ethercat/ethercat-device-editor.tsx | 3 + .../editor/device/ethercat/index.tsx | 19 ++- .../store/__tests__/shared-slice.test.ts | 126 ++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 7 + 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts create mode 100644 src/backend/shared/ethercat/unique-slave-name.ts diff --git a/src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts b/src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts new file mode 100644 index 000000000..70122fe78 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts @@ -0,0 +1,59 @@ +import { collectAllSlaveNames, generateUniqueSlaveName } from '../unique-slave-name' + +describe('collectAllSlaveNames', () => { + it('returns empty set when remoteDevices is undefined', () => { + expect(collectAllSlaveNames(undefined)).toEqual(new Set()) + }) + + it('returns empty set when there are no remote devices', () => { + expect(collectAllSlaveNames([])).toEqual(new Set()) + }) + + it('returns empty set when remote devices have no ethercat config', () => { + expect(collectAllSlaveNames([{}, { ethercatConfig: undefined }])).toEqual(new Set()) + }) + + it('returns empty set when ethercat config has no devices array', () => { + expect(collectAllSlaveNames([{ ethercatConfig: {} }])).toEqual(new Set()) + }) + + it('collects names from a single master', () => { + const result = collectAllSlaveNames([ + { ethercatConfig: { devices: [{ name: 'EL1809' }, { name: 'EL2008' }] } }, + ]) + expect(result).toEqual(new Set(['EL1809', 'EL2008'])) + }) + + it('collects names across multiple masters and deduplicates', () => { + const result = collectAllSlaveNames([ + { ethercatConfig: { devices: [{ name: 'EL1809' }, { name: 'EL2008' }] } }, + { ethercatConfig: { devices: [{ name: 'EL1809' }, { name: 'EL3104' }] } }, + ]) + expect(result).toEqual(new Set(['EL1809', 'EL2008', 'EL3104'])) + }) +}) + +describe('generateUniqueSlaveName', () => { + it('returns base when not taken', () => { + expect(generateUniqueSlaveName('EL1809', [])).toBe('EL1809') + expect(generateUniqueSlaveName('EL1809', ['EL2008'])).toBe('EL1809') + }) + + it('returns _01 suffix on first collision', () => { + expect(generateUniqueSlaveName('EL1809', ['EL1809'])).toBe('EL1809_01') + }) + + it('skips taken suffixes and picks the next free one', () => { + expect(generateUniqueSlaveName('EL1809', ['EL1809', 'EL1809_01', 'EL1809_02'])).toBe('EL1809_03') + }) + + it('pads single digits to two digits and widens past 99', () => { + const taken = new Set(['EL1809']) + for (let i = 1; i <= 99; i++) taken.add(`EL1809_${String(i).padStart(2, '0')}`) + expect(generateUniqueSlaveName('EL1809', taken)).toBe('EL1809_100') + }) + + it('accepts a Set directly as the existing argument', () => { + expect(generateUniqueSlaveName('EL1809', new Set(['EL1809']))).toBe('EL1809_01') + }) +}) diff --git a/src/backend/shared/ethercat/unique-slave-name.ts b/src/backend/shared/ethercat/unique-slave-name.ts new file mode 100644 index 000000000..cf14680cf --- /dev/null +++ b/src/backend/shared/ethercat/unique-slave-name.ts @@ -0,0 +1,44 @@ +/** + * Helpers to ensure EtherCAT slave names are unique within a project. + * + * Slaves with the same name across different masters used to collide in the + * UI's name-keyed slices (tabs/editor/file). Dedup happens at creation time + * by appending `_01`, `_02`, … to the base name when it clashes with any + * existing slave in any master. + */ + +type RemoteDeviceForNameCollection = { + ethercatConfig?: { + devices?: Array<{ name: string }> + } +} + +/** + * Collect every EtherCAT slave name currently configured across all masters. + */ +export function collectAllSlaveNames(remoteDevices: RemoteDeviceForNameCollection[] | undefined): Set { + const names = new Set() + if (!remoteDevices) return names + + for (const rd of remoteDevices) { + for (const dev of rd.ethercatConfig?.devices ?? []) { + names.add(dev.name) + } + } + return names +} + +/** + * Return `base` if unused, otherwise the first `${base}_NN` (two-digit padded) + * not present in `existing`. Pad widens past 99 automatically. + */ +export function generateUniqueSlaveName(base: string, existing: Iterable): string { + const taken = new Set(existing) + let candidate = base + let i = 0 + while (taken.has(candidate)) { + i++ + candidate = `${base}_${String(i).padStart(2, '0')}` + } + return candidate +} 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 05155f4bd..dcbd6caf7 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 @@ -224,6 +224,9 @@ const EtherCATDeviceEditor = () => { {/* Header */}

{device.name}

+ {esiDevice?.name && esiDevice.name !== device.name && ( +

{esiDevice.name}

+ )}
{/* Tabs */} 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 2d2234a89..e5ebc40b0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -3,6 +3,7 @@ import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-u import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' import { matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { collectAllSlaveNames, generateUniqueSlaveName } from '@root/backend/shared/ethercat/unique-slave-name' import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' @@ -379,6 +380,9 @@ const EtherCATEditor = () => { const unmatched: ScannedDeviceMatch['device'][] = [] const existingPositions = new Set(configuredDevices.map((d) => d.position)) const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) + // Names already taken across every master — extended as we add each + // device so the batch can't collide with itself either. + const takenNames = collectAllSlaveNames(project.data.remoteDevices) for (const position of selectedScannedDevices) { // Skip devices already configured at this position @@ -406,10 +410,17 @@ const EtherCATEditor = () => { for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) } + // Prefer the short product code from (e.g. "EL1809") over the + // long localized name from — the long form is + // verbose and identical for any two units of the same model. + const baseName = bestMatch.esiDevice.type.name || bestMatch.esiDevice.name || match.device.name + const uniqueName = generateUniqueSlaveName(baseName, takenNames) + takenNames.add(uniqueName) + newDevices.push({ id: uuidv4(), position: match.device.position, - name: bestMatch.esiDevice.name || match.device.name, + name: uniqueName, esiDeviceRef: { repositoryItemId: bestMatch.repositoryItemId, deviceIndex: bestMatch.deviceIndex, @@ -473,10 +484,14 @@ const EtherCATEditor = () => { const nextPosition = configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 + // Prefer the short product code from over the long localized name. + const baseName = device.type.name || device.name + const uniqueName = generateUniqueSlaveName(baseName, collectAllSlaveNames(project.data.remoteDevices)) + const newDevice: ConfiguredEtherCATDevice = { id: uuidv4(), position: nextPosition, - name: device.name, + name: uniqueName, esiDeviceRef: ref, vendorId: repoItem.vendor.id, productCode: device.type.productCode, diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index d4dd285b2..6769e929a 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -773,6 +773,132 @@ describe('createSharedSlice', () => { }) }) + // ========================================================================= + // ethercatDeviceActions + // ========================================================================= + describe('ethercatDeviceActions', () => { + function addEthercatBus(name: string, slaves: Array<{ id: string; name: string }>) { + store.getState().projectActions.createRemoteDevice({ + data: { name, protocol: 'ethercat' }, + }) + store.getState().projectActions.updateEthercatConfig(name, { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: slaves as never, + }) + for (const slave of slaves) { + store + .getState() + .editorActions.addModel({ type: 'plc-remote-device', meta: { name: slave.name, protocol: 'ethercat' } }) + store.getState().fileActions.addFile({ name: slave.name, type: 'ethercat-device', filePath: name }) + store.getState().tabsActions.updateTabs({ + name: slave.name, + elementType: { type: 'ethercat-device', busName: name, deviceId: slave.id }, + }) + } + } + + // ----------------------------------------------------------------------- + // delete + // ----------------------------------------------------------------------- + describe('delete', () => { + beforeEach(() => { + addEthercatBus('bus1', [{ id: 'slave-1', name: 'EK1100' }]) + }) + + it('removes the slave from project, files, tabs and editor', () => { + store.getState().editorActions.setEditor({ + type: 'plc-remote-device', + meta: { name: 'EK1100', protocol: 'ethercat' }, + }) + + const result = store.getState().ethercatDeviceActions.delete('bus1', 'slave-1') + expect(result).toEqual({ ok: true }) + + const state = store.getState() + const bus = state.project.data.remoteDevices?.find((d) => d.name === 'bus1') + expect(bus?.ethercatConfig?.devices).toHaveLength(0) + expect(state.files['EK1100']).toBeUndefined() + expect(state.tabs.some((t) => t.name === 'EK1100')).toBe(false) + expect(state.editor.type).toBe('available') + }) + + it('returns error when the bus does not exist', () => { + const result = store.getState().ethercatDeviceActions.delete('missing-bus', 'slave-1') + expect(result).toEqual({ ok: false, message: 'Bus not found' }) + }) + + it('returns error when the slave id does not exist', () => { + const result = store.getState().ethercatDeviceActions.delete('bus1', 'missing-slave') + expect(result).toEqual({ ok: false, message: 'EtherCAT device not found' }) + }) + + it('does not clear the editor when a different slave is active', () => { + store.getState().editorActions.setEditor({ + type: 'plc-remote-device', + meta: { name: 'other-device', protocol: 'ethercat' }, + }) + store.getState().ethercatDeviceActions.delete('bus1', 'slave-1') + expect(store.getState().editor.meta.name).toBe('other-device') + }) + }) + + // ----------------------------------------------------------------------- + // rename + // ----------------------------------------------------------------------- + describe('rename', () => { + beforeEach(() => { + addEthercatBus('bus1', [ + { id: 'slave-1', name: 'EK1100' }, + { id: 'slave-2', name: 'EL1809' }, + ]) + addEthercatBus('bus2', [{ id: 'slave-3', name: 'EL1809_01' }]) + }) + + it('renames the slave across project, files and tabs', () => { + const result = store.getState().ethercatDeviceActions.rename('bus1', 'slave-1', 'EK1100-renamed') + expect(result).toEqual({ ok: true }) + + const state = store.getState() + const bus = state.project.data.remoteDevices?.find((d) => d.name === 'bus1') + const slave = bus?.ethercatConfig?.devices?.find((d) => d.id === 'slave-1') + expect(slave?.name).toBe('EK1100-renamed') + expect(state.files['EK1100-renamed']).toBeDefined() + expect(state.files['EK1100']).toBeUndefined() + expect(state.tabs.some((t) => t.name === 'EK1100-renamed')).toBe(true) + }) + + it('rejects renaming to a name already used by another slave in the same bus', () => { + const result = store.getState().ethercatDeviceActions.rename('bus1', 'slave-1', 'EL1809') + expect(result.ok).toBe(false) + expect(result.message).toContain('EL1809') + const state = store.getState() + const bus = state.project.data.remoteDevices?.find((d) => d.name === 'bus1') + expect(bus?.ethercatConfig?.devices?.find((d) => d.id === 'slave-1')?.name).toBe('EK1100') + }) + + it('rejects renaming to a name already used by a slave on a different bus', () => { + const result = store.getState().ethercatDeviceActions.rename('bus1', 'slave-2', 'EL1809_01') + expect(result.ok).toBe(false) + expect(result.message).toContain('EL1809_01') + }) + + it('allows renaming to the same name (no-op)', () => { + const result = store.getState().ethercatDeviceActions.rename('bus1', 'slave-1', 'EK1100') + expect(result).toEqual({ ok: true }) + }) + + it('returns error when the bus does not exist', () => { + const result = store.getState().ethercatDeviceActions.rename('missing-bus', 'slave-1', 'X') + expect(result).toEqual({ ok: false, message: 'Bus not found' }) + }) + + it('returns error when the slave id does not exist', () => { + const result = store.getState().ethercatDeviceActions.rename('bus1', 'missing-slave', 'X') + expect(result).toEqual({ ok: false, message: 'EtherCAT device not found' }) + }) + }) + }) + // ========================================================================= // snapshotActions // ========================================================================= diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e7b98103a..f1a9b765f 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,6 +1,7 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' +import { collectAllSlaveNames } from '../../../../backend/shared/ethercat/unique-slave-name' import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' @@ -345,6 +346,12 @@ const createSharedSlice: StateCreator = (s if (!device) return { ok: false, message: 'EtherCAT device not found' } const oldName = device.name + // Reject if another slave (any master) already owns the target name. + // Same-name rename is a no-op the UI short-circuits before us, but we + // still allow it here so the action stays idempotent. + if (newName !== oldName && collectAllSlaveNames(state.project.data.remoteDevices).has(newName)) { + return { ok: false, message: `An EtherCAT slave named "${newName}" already exists in this project` } + } const updatedDevices = devices.map((d) => (d.id === deviceId ? { ...d, name: newName } : d)) state.projectActions.updateEthercatConfig(busName, { masterConfig: remoteDevice.ethercatConfig?.masterConfig ?? { From 018f7fed905677b7ba3d4d19e6d7f910fa3f3936 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 11 May 2026 10:58:20 +0200 Subject: [PATCH 2/4] fix(ethercat): move slave-name dedup helper to frontend/utils [DOPE-281] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper was misplaced under backend/shared/ethercat/, which made the store import violate the layer rule "Store must not import from Backend Shared". Move it to frontend/utils/ alongside next-name.ts and ethercat-status.ts — the conventional location for pure helpers shared between store and components. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_features/[workspace]/editor/device/ethercat/index.tsx | 2 +- src/frontend/store/slices/shared/slice.ts | 2 +- .../utils}/__tests__/unique-slave-name.test.ts | 0 .../shared/ethercat => frontend/utils}/unique-slave-name.ts | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename src/{backend/shared/ethercat => frontend/utils}/__tests__/unique-slave-name.test.ts (100%) rename src/{backend/shared/ethercat => frontend/utils}/unique-slave-name.ts (100%) 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 e5ebc40b0..6fbfdd169 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -3,11 +3,11 @@ import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-u import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' import { matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' -import { collectAllSlaveNames, generateUniqueSlaveName } from '@root/backend/shared/ethercat/unique-slave-name' import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' +import { collectAllSlaveNames, generateUniqueSlaveName } from '@root/frontend/utils/unique-slave-name' import type { ConfiguredEtherCATDevice, ESIDeviceRef, diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 942749670..c640a8fff 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,12 +1,12 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import { collectAllSlaveNames } from '../../../../backend/shared/ethercat/unique-slave-name' import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' import { toast } from '../../../utils/toast' +import { collectAllSlaveNames } from '../../../utils/unique-slave-name' import type { FBDFlowType } from '../fbd' import type { FileSliceDataObject } from '../file' import type { HistorySnapshot } from '../history' diff --git a/src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts b/src/frontend/utils/__tests__/unique-slave-name.test.ts similarity index 100% rename from src/backend/shared/ethercat/__tests__/unique-slave-name.test.ts rename to src/frontend/utils/__tests__/unique-slave-name.test.ts diff --git a/src/backend/shared/ethercat/unique-slave-name.ts b/src/frontend/utils/unique-slave-name.ts similarity index 100% rename from src/backend/shared/ethercat/unique-slave-name.ts rename to src/frontend/utils/unique-slave-name.ts From 189c134a94b4e882a59aac2e577a44b5d4d341e6 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 11 May 2026 11:04:16 +0200 Subject: [PATCH 3/4] style(ethercat): apply prettier to unique-slave-name test Inline a single-element array literal that exceeded the explicit-wrap heuristic but fits within the 120-char width. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/frontend/utils/__tests__/unique-slave-name.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/frontend/utils/__tests__/unique-slave-name.test.ts b/src/frontend/utils/__tests__/unique-slave-name.test.ts index 70122fe78..b338f1dde 100644 --- a/src/frontend/utils/__tests__/unique-slave-name.test.ts +++ b/src/frontend/utils/__tests__/unique-slave-name.test.ts @@ -18,9 +18,7 @@ describe('collectAllSlaveNames', () => { }) it('collects names from a single master', () => { - const result = collectAllSlaveNames([ - { ethercatConfig: { devices: [{ name: 'EL1809' }, { name: 'EL2008' }] } }, - ]) + const result = collectAllSlaveNames([{ ethercatConfig: { devices: [{ name: 'EL1809' }, { name: 'EL2008' }] } }]) expect(result).toEqual(new Set(['EL1809', 'EL2008'])) }) From 7679a9f50137bb891a2764f583ef0bd26f03d6c0 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 18 May 2026 09:41:14 +0200 Subject: [PATCH 4/4] fix(ethercat): harden short device name fallback [DOPE-281] The previous chain assumed always carried text, but the ESI schema only requires its ProductCode/RevisionNo attributes. Vendors emitting self-closing were silently dropping to the long localized , defeating the readability win of short product codes. Introduce getShortDeviceName with a tiered fallback that validates shape, extracts SKU-shaped tokens from , and falls back to the canonical (productCode, revisionNo) identity when nothing readable is available. Also clarify the unique-slave-name padStart docstring and document the rename guard as the sole rejecting enforcement point for slave-name uniqueness across masters. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/device/ethercat/index.tsx | 9 +- src/frontend/store/slices/shared/slice.ts | 7 +- .../utils/__tests__/short-device-name.test.ts | 102 ++++++++++++++++++ src/frontend/utils/short-device-name.ts | 46 ++++++++ src/frontend/utils/unique-slave-name.ts | 3 +- 5 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 src/frontend/utils/__tests__/short-device-name.test.ts create mode 100644 src/frontend/utils/short-device-name.ts 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 6fbfdd169..2e25f5e1c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -7,6 +7,7 @@ import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-p import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' +import { getShortDeviceName } from '@root/frontend/utils/short-device-name' import { collectAllSlaveNames, generateUniqueSlaveName } from '@root/frontend/utils/unique-slave-name' import type { ConfiguredEtherCATDevice, @@ -410,10 +411,7 @@ const EtherCATEditor = () => { for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) } - // Prefer the short product code from (e.g. "EL1809") over the - // long localized name from — the long form is - // verbose and identical for any two units of the same model. - const baseName = bestMatch.esiDevice.type.name || bestMatch.esiDevice.name || match.device.name + const baseName = getShortDeviceName(bestMatch.esiDevice) const uniqueName = generateUniqueSlaveName(baseName, takenNames) takenNames.add(uniqueName) @@ -484,8 +482,7 @@ const EtherCATEditor = () => { const nextPosition = configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 - // Prefer the short product code from over the long localized name. - const baseName = device.type.name || device.name + const baseName = getShortDeviceName(device) const uniqueName = generateUniqueSlaveName(baseName, collectAllSlaveNames(project.data.remoteDevices)) const newDevice: ConfiguredEtherCATDevice = { diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index c640a8fff..e86e7bdb2 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -346,9 +346,10 @@ const createSharedSlice: StateCreator = (s if (!device) return { ok: false, message: 'EtherCAT device not found' } const oldName = device.name - // Reject if another slave (any master) already owns the target name. - // Same-name rename is a no-op the UI short-circuits before us, but we - // still allow it here so the action stays idempotent. + // Only *rejecting* enforcement of slave-name uniqueness — scan-bus add + // auto-suffixes instead. Tabs/editor/file slices are name-keyed and break + // silently on duplicates, so new write paths must replicate one strategy. + // Same-name rename is allowed (the action stays idempotent). if (newName !== oldName && collectAllSlaveNames(state.project.data.remoteDevices).has(newName)) { return { ok: false, message: `An EtherCAT slave named "${newName}" already exists in this project` } } diff --git a/src/frontend/utils/__tests__/short-device-name.test.ts b/src/frontend/utils/__tests__/short-device-name.test.ts new file mode 100644 index 000000000..9dc8fec67 --- /dev/null +++ b/src/frontend/utils/__tests__/short-device-name.test.ts @@ -0,0 +1,102 @@ +import { getShortDeviceName } from '../short-device-name' + +const make = ( + overrides: Partial<{ typeName: string; name: string; productCode: string; revisionNo: string }> = {}, +) => ({ + type: { + name: overrides.typeName ?? '', + productCode: overrides.productCode ?? '0x07212C52', + revisionNo: overrides.revisionNo ?? '0x00110000', + }, + name: overrides.name ?? '', +}) + +describe('getShortDeviceName', () => { + describe('P1: text as short code', () => { + it('returns text when it looks like an SKU', () => { + expect(getShortDeviceName(make({ typeName: 'EL1809' }))).toBe('EL1809') + }) + + it('accepts SKUs with hyphens up to 24 chars', () => { + expect(getShortDeviceName(make({ typeName: 'EL2521-0124-0010' }))).toBe('EL2521-0124-0010') + }) + + it('trims surrounding whitespace before evaluating', () => { + expect(getShortDeviceName(make({ typeName: ' EL1809 ' }))).toBe('EL1809') + }) + + it('falls through to P2 when text contains internal whitespace', () => { + // P1 rejects (whitespace), P2 takes first token "EK1100" — SKU-shaped, returned + expect(getShortDeviceName(make({ typeName: 'Generic Coupler', name: 'EK1100 EtherCAT Coupler' }))).toBe('EK1100') + }) + + it('falls through to P3 when both P1 and P2 reject but text exists', () => { + // P1 rejects (whitespace), P2 rejects ("Generic" has no digit), P3 returns text + expect(getShortDeviceName(make({ typeName: 'Generic Coupler', name: 'Generic Coupler description' }))).toBe( + 'Generic Coupler', + ) + }) + + it('falls through when text is longer than 24 chars', () => { + expect( + getShortDeviceName(make({ typeName: 'ExtraLongDescriptiveTypeName123', name: 'EL1809 2Ch. Digital Input' })), + ).toBe('EL1809') + }) + }) + + describe('P2: first token of as SKU', () => { + it('extracts SKU when it leads the long name', () => { + expect(getShortDeviceName(make({ name: 'EL1809 2Ch. Digital Input 24V, 3ms' }))).toBe('EL1809') + }) + + it('handles comma and semicolon separators', () => { + expect(getShortDeviceName(make({ name: 'EK1100,EtherCAT Coupler' }))).toBe('EK1100') + }) + + it('rejects digit-leading tokens like "2-Channel"', () => { + // P2 rejects, P3 unavailable, P4 returns the long name as-is + expect(getShortDeviceName(make({ name: '2-Channel Digital Input' }))).toBe('2-Channel Digital Input') + }) + + it('rejects pure-letter tokens like "EtherCAT"', () => { + expect(getShortDeviceName(make({ name: 'EtherCAT Generic Slave' }))).toBe('EtherCAT Generic Slave') + }) + + it('rejects tokens shorter than 3 chars even if SKU-shaped', () => { + expect(getShortDeviceName(make({ name: 'A1 short token here' }))).toBe('A1 short token here') + }) + + it('rejects tokens longer than 24 chars', () => { + const longToken = 'X' + '1'.repeat(24) + expect(getShortDeviceName(make({ name: `${longToken} description` }))).toBe(`${longToken} description`) + }) + + it('accepts SKUs with mixed digits and hyphens (e.g. Omron R88D-1SN02H-ECT)', () => { + expect(getShortDeviceName(make({ name: 'R88D-1SN02H-ECT Servo Drive' }))).toBe('R88D-1SN02H-ECT') + }) + }) + + describe('P3: text as last readable fallback', () => { + it('returns text when it has whitespace and P2 finds nothing usable', () => { + expect(getShortDeviceName(make({ typeName: 'Generic Coupler' }))).toBe('Generic Coupler') + }) + }) + + describe('P4: long name as-is', () => { + it('returns the long name when both P1 and P2 reject', () => { + expect(getShortDeviceName(make({ name: 'Generic EtherCAT Slave' }))).toBe('Generic EtherCAT Slave') + }) + }) + + describe('P5: canonical identity fallback', () => { + it('returns Device_{productCode}_{revisionNo} when no name is available', () => { + expect(getShortDeviceName(make({ productCode: '0x07212C52', revisionNo: '0x00110000' }))).toBe( + 'Device_0x07212C52_0x00110000', + ) + }) + + it('treats whitespace-only names as empty', () => { + expect(getShortDeviceName(make({ typeName: ' ', name: ' ' }))).toBe('Device_0x07212C52_0x00110000') + }) + }) +}) diff --git a/src/frontend/utils/short-device-name.ts b/src/frontend/utils/short-device-name.ts new file mode 100644 index 000000000..20ba5bc14 --- /dev/null +++ b/src/frontend/utils/short-device-name.ts @@ -0,0 +1,46 @@ +import type { ESIDeviceSummary } from '@root/middleware/shared/ports/esi-types' + +type ShortNameInput = Pick + +// SKU-shaped tokens: leading letter, ≥1 digit, only [A-Z0-9_-]. +// Rejects descriptive tokens that happen to contain a digit +// (e.g. "2-Channel", "24V", "2Ch."), which are common when a vendor +// puts the model code *after* the description in . +const SKU_TOKEN = /^[A-Z][A-Z0-9_-]*\d[A-Z0-9_-]*$/i + +/** + * Pick a short, human-readable device name from an ESI device summary. + * + * The ESI schema doesn't require to carry text content (only the + * ProductCode/RevisionNo attributes), so vendors that emit a self-closing + * leave us without a short code. This walks a fallback chain so + * the UI always renders something sensible, and worst-case falls back to + * the canonical (ProductCode, RevisionNo) identity that is unique by + * construction. + */ +export function getShortDeviceName(esiDevice: ShortNameInput): string { + const typeText = esiDevice.type.name.trim() + const longName = esiDevice.name.trim() + + // P1: text if it already looks like a short code. + if (typeText && typeText.length <= 24 && !/\s/.test(typeText)) { + return typeText + } + + // P2: first token of if it matches an SKU shape. + if (longName) { + const firstToken = longName.split(/[\s,;]/)[0] + if (firstToken.length >= 3 && firstToken.length <= 24 && SKU_TOKEN.test(firstToken)) { + return firstToken + } + } + + // P3: text even if it's longer than a typical SKU. + if (typeText) return typeText + + // P4: full long name as-is. + if (longName) return longName + + // P5: canonical ETG identity — always unique, always deterministic. + return `Device_${esiDevice.type.productCode}_${esiDevice.type.revisionNo}` +} diff --git a/src/frontend/utils/unique-slave-name.ts b/src/frontend/utils/unique-slave-name.ts index cf14680cf..64634d194 100644 --- a/src/frontend/utils/unique-slave-name.ts +++ b/src/frontend/utils/unique-slave-name.ts @@ -30,7 +30,8 @@ export function collectAllSlaveNames(remoteDevices: RemoteDeviceForNameCollectio /** * Return `base` if unused, otherwise the first `${base}_NN` (two-digit padded) - * not present in `existing`. Pad widens past 99 automatically. + * not present in `existing`. Two-digit pad doesn't truncate, so 3+ digit + * indices pass through unchanged. */ export function generateUniqueSlaveName(base: string, existing: Iterable): string { const taken = new Set(existing)