diff --git a/binary-versions.json b/binary-versions.json index a0d6978b0..3991c80f2 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.13", + "version": "v0.6.0", "repository": "Autonomy-Logic/STruCpp" } } diff --git a/src/backend/shared/ethercat/__tests__/cia402.test.ts b/src/backend/shared/ethercat/__tests__/cia402.test.ts new file mode 100644 index 000000000..fceccc2c8 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/cia402.test.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + + +import { + CIA402_OBJECTS, + DEFAULT_CIA402_AXIS_CONFIG, + isCia402Drive, + normalizeObjectIndex, + resolveCia402Objects, +} from '../cia402' +import { enrichDeviceData } from '../enrich-device-data' +import { parseESIDeviceFull } from '../esi-parser-main' + +const ESI_XML = readFileSync( + resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), + 'utf-8', +) + +function loadDevice() { + const result = parseESIDeviceFull(ESI_XML, 0) + expect(result.success).toBe(true) + expect(result.device).toBeDefined() + return result.device! +} + +describe('cia402 recognition', () => { + it('normalizes object indices in every ESI notation', () => { + expect(normalizeObjectIndex('#x6040')).toBe(0x6040) + expect(normalizeObjectIndex('0x6040')).toBe(0x6040) + expect(normalizeObjectIndex('6040')).toBe(0x6040) + expect(normalizeObjectIndex(0x6040)).toBe(0x6040) + expect(normalizeObjectIndex('bogus')).toBe(-1) + }) + + it('recognizes a real CiA 402 servo ESI as a SoftMotion drive', () => { + const device = loadDevice() + expect(isCia402Drive(device)).toBe(true) + }) + + it('does not recognize a device lacking the mandatory objects', () => { + const device = loadDevice() + // strip Controlword (0x6040) from the RxPDOs → no longer a valid axis + const stripped = { + ...device, + rxPdo: device.rxPdo.map((p) => ({ + ...p, + entries: p.entries.filter((e) => normalizeObjectIndex(e.index) !== 0x6040), + })), + } + expect(isCia402Drive(stripped)).toBe(false) + }) + + it('resolves CiA 402 objects to editor-allocated IEC addresses', () => { + const device = loadDevice() + const enriched = enrichDeviceData(device) + const resolved = resolveCia402Objects(enriched.channelInfo, enriched.channelMappings) + + const byRole = Object.fromEntries(resolved.map((r) => [r.role, r])) + // The real fixture maps: 0x6040 Controlword, 0x607A Target position (out); + // 0x6041 Statusword, 0x6064 Position actual (in). + expect(byRole.controlWord).toBeDefined() + expect(byRole.statusWord).toBeDefined() + expect(byRole.targetPosition).toBeDefined() + expect(byRole.positionActual).toBeDefined() + + // Controlword is a 16-bit output → %Q word address, WORD/UINT type. + expect(byRole.controlWord.iecLocation).toMatch(/^%Q/) + expect(byRole.statusWord.iecLocation).toMatch(/^%I/) + // Every resolved object carries a concrete address + IEC type. + for (const r of resolved) { + expect(r.iecLocation).toMatch(/^%[IQ]/) + expect(r.iecType).toMatch(/\S/) + } + }) + + it('omits CiA 402 objects that have no channel mapping', () => { + const device = loadDevice() + const enriched = enrichDeviceData(device) + // Channel info present, but no address mappings → nothing resolves. + expect(resolveCia402Objects(enriched.channelInfo, [])).toEqual([]) + }) + + it('defines the single-axis CiA 402 object set including mandatory objects', () => { + // Controlword + Statusword must be in the object table. + const indices = CIA402_OBJECTS.map((o) => o.index) + expect(indices).toContain(0x6040) + expect(indices).toContain(0x6041) + expect(DEFAULT_CIA402_AXIS_CONFIG.enabled).toBe(true) + expect(DEFAULT_CIA402_AXIS_CONFIG.scaleFactor).toBe(1) + }) +}) diff --git a/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml b/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml new file mode 100644 index 000000000..d30146a95 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml @@ -0,0 +1,1398 @@ + + + + #x00000B95 + oss + + + + + groupType + groupName + + + + + cia402_id + cia402_drive_name + groupType + + 402 + 2 + + + + DT1018 + 144 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Vendor ID + UDINT + 32 + 16 + + ro + + + + 2 + Product Code + UDINT + 32 + 48 + + ro + + + + 3 + Revision Number + UDINT + 32 + 80 + + ro + + + + 4 + Serial Number + UDINT + 32 + 112 + + ro + + + + + DT10F1 + 64 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Local Error Reaction + UDINT + 32 + 16 + + rw + + + + 2 + SyncErrorCounterLimit + UINT + 16 + 48 + + rw + + + + + DT1600 + 80 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Control Word + UDINT + 32 + 16 + + ro + + + + 2 + Target position + UDINT + 32 + 48 + + ro + + + + + DT1A00 + 80 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Status Word + UDINT + 32 + 16 + + ro + + + + 2 + Position actual + UDINT + 32 + 48 + + ro + + + + + DT1C00ARR + USINT + 32 + + 1 + 4 + + + + DT1C00 + 48 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C00ARR + 32 + 16 + + ro + + + + + DT1C12ARR + UINT + 16 + + 1 + 1 + + + + DT1C12 + 32 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C12ARR + 16 + 16 + + ro + + + + + DT1C13ARR + UINT + 16 + + 1 + 1 + + + + DT1C13 + 32 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C13ARR + 16 + 16 + + ro + + + + + DT1C32 + 488 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Sync mode + UINT + 16 + 16 + + rw + + + + 2 + CycleTime + UDINT + 32 + 32 + + ro + + + + 3 + ShiftTime + UDINT + 32 + 64 + + ro + + + + 4 + Sync modes supported + UINT + 16 + 96 + + ro + + + + 5 + Minimum Cycle Time + UDINT + 32 + 112 + + ro + + + + 6 + Calc and Copy Time + UDINT + 32 + 144 + + ro + + + + 7 + Minimum Delay Time + UDINT + 32 + 176 + + ro + + + + 8 + GetCycleTime + UINT + 16 + 208 + + rw + + + + 9 + DelayTime + UDINT + 32 + 224 + + ro + + + + 10 + Sync0CycleTime + UDINT + 32 + 256 + + ro + + + + 11 + SM event missed counter + UINT + 16 + 288 + + ro + + + + 12 + CycleTimeTooSmallCnt + UINT + 16 + 304 + + ro + + + + 13 + Shift too short counter + UINT + 16 + 320 + + ro + + + + 14 + RxPDOToggleFailed + UINT + 16 + 336 + + ro + + + + 15 + Minimum Cycle Distance + UDINT + 32 + 352 + + ro + + + + 16 + Maximum Cycle Distance + UDINT + 32 + 384 + + ro + + + + 17 + Minimum SM Sync Distance + UDINT + 32 + 416 + + ro + + + + 18 + Maximum SM Sync Distance + UDINT + 32 + 448 + + ro + + + + 32 + SyncError + BOOL + 1 + 480 + + ro + + + + + DT1C33 + 488 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Sync mode + UINT + 16 + 16 + + rw + + + + 2 + CycleTime + UDINT + 32 + 32 + + ro + + + + 3 + ShiftTime + UDINT + 32 + 64 + + ro + + + + 4 + Sync modes supported + UINT + 16 + 96 + + ro + + + + 5 + Minimum Cycle Time + UDINT + 32 + 112 + + ro + + + + 6 + Calc and Copy Time + UDINT + 32 + 144 + + ro + + + + 7 + Minimum Delay Time + UDINT + 32 + 176 + + ro + + + + 8 + GetCycleTime + UINT + 16 + 208 + + rw + + + + 9 + DelayTime + UDINT + 32 + 224 + + ro + + + + 10 + Sync0CycleTime + UDINT + 32 + 256 + + ro + + + + 11 + SM event missed counter + UINT + 16 + 288 + + ro + + + + 12 + CycleTimeTooSmallCnt + UINT + 16 + 304 + + ro + + + + 13 + Shift too short counter + UINT + 16 + 320 + + ro + + + + 14 + RxPDOToggleFailed + UINT + 16 + 336 + + ro + + + + 15 + Minimum Cycle Distance + UDINT + 32 + 352 + + ro + + + + 16 + Maximum Cycle Distance + UDINT + 32 + 384 + + ro + + + + 17 + Minimum SM Sync Distance + UDINT + 32 + 416 + + ro + + + + 18 + Maximum SM Sync Distance + UDINT + 32 + 448 + + ro + + + + 32 + SyncError + BOOL + 1 + 480 + + ro + + + + + SINT + 8 + + + BOOL + 1 + + + STRING(3) + 24 + + + UDINT + 32 + + + UINT + 16 + + + USINT + 8 + + + STRING(9) + 72 + + + + + #x1000 + Device Type + UDINT + 32 + + #x00020192 + + + ro + m + + + + #x1001 + Error register + USINT + 8 + + 0 + + + ro + + + + #x1008 + Device Name + STRING(9) + 72 + + cia402_id + + + ro + + + + #x1009 + Hardware Version + STRING(3) + 24 + + 1.0 + + + ro + o + + + + #x100A + Software Version + STRING(3) + 24 + + 1.0 + + + ro + + + + #x1018 + Identity Object + DT1018 + 144 + + + Max SubIndex + + 4 + + + + Vendor ID + + #x00000B95 + + + + Product Code + + #x00020192 + + + + Revision Number + + 42 + + + + Serial Number + + #x00000000 + + + + + ro + + + + #x10F1 + ErrorSettings + DT10F1 + 64 + + + Max SubIndex + + 2 + + + + Local Error Reaction + + 0 + + + + SyncErrorCounterLimit + + 200 + + + + + ro + + + + #x1600 + Control Position + DT1600 + 80 + + + Max SubIndex + + 2 + + + + Control Word + + #x60400010 + + + + Target position + + #x607A0020 + + + + + ro + + + + #x1A00 + Status Position + DT1A00 + 80 + + + Max SubIndex + + 2 + + + + Status Word + + #x60410010 + + + + Position actual + + #x60640020 + + + + + ro + + + + #x1C00 + Sync Manager Communication Type + DT1C00 + 48 + + + Max SubIndex + + 4 + + + + Communications Type SM0 + + 1 + + + + Communications Type SM1 + + 2 + + + + Communications Type SM2 + + 3 + + + + Communications Type SM3 + + 4 + + + + + ro + + + + #x1C12 + Sync Manager 2 PDO Assignment + DT1C12 + 32 + + + Max SubIndex + + 1 + + + + PDO Mapping + + #x1600 + + + + + ro + + + + #x1C13 + Sync Manager 3 PDO Assignment + DT1C13 + 32 + + + Max SubIndex + + 1 + + + + PDO Mapping + + #x1A00 + + + + + ro + + + + #x1C32 + Sync Manager 2 Parameters + DT1C32 + 488 + + + Max SubIndex + + 32 + + + + Sync mode + + 1 + + + + CycleTime + + 0 + + + + ShiftTime + + 0 + + + + Sync modes supported + + 6 + + + + Minimum Cycle Time + + 125000 + + + + Calc and Copy Time + + 0 + + + + Minimum Delay Time + + 0 + + + + GetCycleTime + + 0 + + + + DelayTime + + 0 + + + + Sync0CycleTime + + 0 + + + + SM event missed counter + + 0 + + + + CycleTimeTooSmallCnt + + 0 + + + + Shift too short counter + + 0 + + + + RxPDOToggleFailed + + 0 + + + + Minimum Cycle Distance + + 0 + + + + Maximum Cycle Distance + + 0 + + + + Minimum SM Sync Distance + + 0 + + + + Maximum SM Sync Distance + + 0 + + + + SyncError + + 0 + + + + + ro + + + + #x1C33 + Sync Manager 3 Parameters + DT1C33 + 488 + + + Max SubIndex + + 32 + + + + Sync mode + + 1 + + + + CycleTime + + 0 + + + + ShiftTime + + 0 + + + + Sync modes supported + + 6 + + + + Minimum Cycle Time + + 125000 + + + + Calc and Copy Time + + 0 + + + + Minimum Delay Time + + 0 + + + + GetCycleTime + + 0 + + + + DelayTime + + 0 + + + + Sync0CycleTime + + 0 + + + + SM event missed counter + + 0 + + + + CycleTimeTooSmallCnt + + 0 + + + + Shift too short counter + + 0 + + + + RxPDOToggleFailed + + 0 + + + + Minimum Cycle Distance + + 0 + + + + Maximum Cycle Distance + + 0 + + + + Minimum SM Sync Distance + + 0 + + + + Maximum SM Sync Distance + + 0 + + + + SyncError + + 0 + + + + + ro + + + + #x6040 + Control Word + UINT + 16 + + 0 + + + ro + R + + + + #x6041 + Status Word + UINT + 16 + + 0 + + + ro + T + + + + #x6060 + Modes of operation + SINT + 8 + + 8 + + + rw + + + + #x6061 + Mode of operation display + SINT + 8 + + 8 + + + ro + + + + #x6064 + Position actual + UDINT + 32 + + 0 + + + ro + T + + + + #x607A + Target position + UDINT + 32 + + 0 + + + ro + R + + + + #x6502 + Supported drive modes + UDINT + 32 + + 128 + + + ro + + + + + + Outputs + Inputs + MBoxState + MBoxOut + MBoxIn + Outputs + Inputs + + #x1600 + Control Position + + #x6040 + #x0 + 16 + Control Word + UINT + + + #x607a + #x0 + 32 + Target position + UDINT + + + + #x1A00 + Status Position + + #x6041 + #x0 + 16 + Status Word + UINT + + + #x6064 + #x0 + 32 + Position actual + UDINT + + + + + + + 2048 + 800603440A000000 + 0010000200120002 + + + + + \ No newline at end of file diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts new file mode 100644 index 000000000..3bfbe0d2d --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' + +import { enrichDeviceData } from '../enrich-device-data' +import { parseESIDeviceFull } from '../esi-parser-main' +import { + generateSoftMotionArtifacts, + injectAxisExternals, + isValidIecIdentifier, + serializeSoftMotionAxisGlobalsToST, + SM3_BRIDGE_INSTANCE_NAME, + SM3_BRIDGE_POU_NAME, + sanitizeAxisName, + softMotionAxisNames, +} from '../generate-softmotion' + +const ESI_XML = readFileSync(resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), 'utf-8') + +function makeDevice(name: string): ConfiguredEtherCATDevice { + const parsed = parseESIDeviceFull(ESI_XML, 0) + const enriched = enrichDeviceData(parsed.device!) + return { + id: 'dev-1', + name, + esiDeviceRef: { repositoryItemId: 'repo-1', deviceIndex: 0 }, + vendorId: '0x0', + productCode: '0x0', + revisionNo: '0x0', + addedFrom: 'repository', + config: {} as ConfiguredEtherCATDevice['config'], + ...enriched, + } +} + +function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { + return { + dataTypes: [], + pous: [ + { + name: 'main', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'st', value: '' }, + }, + ], + configurations: { + resource: { + tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 1 }], + instances: [{ name: 'instance0', task: 'task0', program: 'main' }], + globalVariables: [], + }, + }, + remoteDevices: [{ name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }], + } +} + +describe('generateSoftMotionArtifacts', () => { + it('sanitizes device names into valid IEC identifiers', () => { + expect(sanitizeAxisName('X_Axis')).toBe('X_Axis') + expect(sanitizeAxisName('My Axis 01')).toBe('My_Axis_01') + expect(sanitizeAxisName('9drive')).toBe('_9drive') + }) + + it('validates IEC identifiers', () => { + expect(isValidIecIdentifier('X_Axis')).toBe(true) + expect(isValidIecIdentifier('_axis1')).toBe(true) + expect(isValidIecIdentifier('ASDA-A2-E')).toBe(false) + expect(isValidIecIdentifier('My Axis')).toBe(false) + expect(isValidIecIdentifier('9drive')).toBe(false) + expect(isValidIecIdentifier('')).toBe(false) + }) + + it('is a no-op when there are no CiA 402 axes', () => { + const project = makeProject([]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + describe('softMotionAxisNames', () => { + it('returns sanitized names of enabled axes', () => { + expect(softMotionAxisNames(makeProject([makeDevice('My Axis')]))).toEqual(['My_Axis']) + }) + it('returns [] when there are no axes', () => { + expect(softMotionAxisNames(makeProject([]))).toEqual([]) + }) + }) + + describe('injectAxisExternals', () => { + const prog = (value: string) => + ({ name: 'p', pouType: 'program', interface: { variables: [] }, body: { language: 'st', value } }) as never + + it('adds the external to a program that references the axis', () => { + const out = injectAxisExternals(prog('pwr(Axis := Ax);'), ['Ax']) + expect(out.interface!.variables.some((v) => v.name === 'Ax' && v.class === 'external')).toBe(true) + }) + it('leaves a POU that does not reference any axis unchanged', () => { + const pou = prog('y := 1;') + expect(injectAxisExternals(pou, ['Ax'])).toBe(pou) + }) + it('skips a function POU', () => { + const fn = { + name: 'f', + pouType: 'function', + interface: { variables: [] }, + body: { language: 'st', value: 'x := Ax;' }, + } as never + expect(injectAxisExternals(fn, ['Ax'])).toBe(fn) + }) + }) + + describe('serializeSoftMotionAxisGlobalsToST', () => { + it('returns empty string when there are no axes', () => { + expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') + }) + + it('declares each axis as a bare top-level VAR_GLOBAL of type AXIS_REF_SM3', () => { + const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) + expect(st).toContain('X_Axis : AXIS_REF_SM3;') + // Bare top-level block (ambient global) — NOT wrapped in a CONFIGURATION, + // so the axis resolves without VAR_EXTERNAL. + expect(st.startsWith('VAR_GLOBAL')).toBe(true) + expect(st).toContain('END_VAR') + expect(st).not.toContain('CONFIGURATION') + }) + + it('lists axes in softMotionAxisNames order (line N+1 = axis N)', () => { + const project = makeProject([makeDevice('X_Axis')]) + const st = serializeSoftMotionAxisGlobalsToST(project) + const names = softMotionAxisNames(project) + const lines = st.split('\n') + // line 0 = VAR_GLOBAL, line 1 = first axis + expect(lines[1]).toContain(names[0]) + }) + }) + + it('is a no-op when the CiA 402 device is disabled', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { ...dev.cia402!, enabled: false } + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('generates the AXIS_REF_SM3 global named after the device', () => { + const project = makeProject([makeDevice('X_Axis')]) + const out = generateSoftMotionArtifacts(project) + const globals = out.configurations.resource.globalVariables + const axis = globals.find((g) => g.name === 'X_Axis') + expect(axis).toBeDefined() + expect(axis!.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + expect(axis!.location).toBe('') + }) + + it('generates located scalar globals bound to the drive PDO addresses', () => { + const project = makeProject([makeDevice('X_Axis')]) + const globals = generateSoftMotionArtifacts(project).configurations.resource.globalVariables + const ctrl = globals.find((g) => g.name === 'X_Axis_controlWord') + const status = globals.find((g) => g.name === 'X_Axis_statusWord') + const target = globals.find((g) => g.name === 'X_Axis_targetPosition') + expect(ctrl?.type.value).toBe('uint') + expect(ctrl?.location).toMatch(/^%Q/) + expect(status?.type.value).toBe('uint') + expect(status?.location).toMatch(/^%I/) + expect(target?.type.value).toBe('dint') // forced to DINT for bridge compatibility + expect(target?.location).toMatch(/^%Q/) + }) + + it('generates a bridge program with an SM_Drive instance and pin bindings', () => { + const project = makeProject([makeDevice('X_Axis')]) + const out = generateSoftMotionArtifacts(project) + const bridge = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME) + expect(bridge).toBeDefined() + expect(bridge!.pouType).toBe('program') + const fbVar = bridge!.interface!.variables.find((v) => v.name === 'X_Axis_drive') + expect(fbVar!.type).toEqual({ definition: 'derived', value: 'SM_Drive_GenericDS402' }) + const body = bridge!.body.value as string + // input pins bound with :=, output pins captured with => + expect(body).toContain('Axis := X_Axis') + expect(body).toContain('wStatusWord := X_Axis_statusWord') + expect(body).toContain('diActualPosition := X_Axis_positionActual') + expect(body).toContain('wControlWord => X_Axis_controlWord') + expect(body).toContain('diTargetPosition => X_Axis_targetPosition') + // scaling applied + expect(body).toContain('X_Axis.fScalefactor :=') + }) + + it('injects VAR_EXTERNAL for the axis into a user program that references it', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous[0].body = { language: 'st', value: 'pwr(Axis := X_Axis, Enable := TRUE);' } + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + const ext = main.interface!.variables.find((v) => v.name === 'X_Axis') + expect(ext).toBeDefined() + expect(ext!.class).toBe('external') + expect(ext!.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + }) + + it('does not double-declare an axis the user already declared', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous[0].interface = { + variables: [ + { + name: 'X_Axis', + class: 'external', + type: { definition: 'derived', value: 'AXIS_REF_SM3' }, + location: '', + documentation: '', + }, + ], + } + project.pous[0].body = { language: 'st', value: 'pwr(Axis := X_Axis);' } + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.filter((v) => v.name === 'X_Axis')).toHaveLength(1) + }) + + it('detects axis references in graphical (non-string) POU bodies', () => { + const project = makeProject([makeDevice('X_Axis')]) + // A function POU is left untouched; a graphical program referencing the axis gets the external. + project.pous[0].body = { language: 'fbd', value: { rung: { nodes: [{ variable: 'X_Axis' }] } } } as never + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) + }) + + it('injects the axis external into a function block that references it', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous.push({ + name: 'MotionFB', + pouType: 'function-block', + interface: { variables: [] }, + body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, + }) + const out = generateSoftMotionArtifacts(project) + const fb = out.pous.find((p) => p.name === 'MotionFB')! + const ext = fb.interface!.variables.find((v) => v.name === 'X_Axis') + expect(ext?.class).toBe('external') + expect(ext?.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + }) + + it('leaves functions untouched (they cannot hold VAR_EXTERNAL)', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous.push({ + name: 'helperFn', + pouType: 'function', + interface: { variables: [] }, + body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, + }) + const out = generateSoftMotionArtifacts(project) + const fn = out.pous.find((p) => p.name === 'helperFn')! + expect(fn.interface!.variables.some((v) => v.name === 'X_Axis')).toBe(false) + }) + + it('runs the bridge first each scan (instance unshifted to the front)', () => { + const project = makeProject([makeDevice('X_Axis')]) + const instances = generateSoftMotionArtifacts(project).configurations.resource.instances + expect(instances[0].name).toBe(SM3_BRIDGE_INSTANCE_NAME) + expect(instances[0].program).toBe(SM3_BRIDGE_POU_NAME) + expect(instances[0].task).toBe('task0') + expect(instances.map((i) => i.name)).toContain('instance0') + }) + + it('honors configured scaling in the generated bridge', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1000 } + const out = generateSoftMotionArtifacts(makeProject([dev])) + const body = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME)!.body.value as string + expect(body).toContain('X_Axis.fScalefactor := 1000.0;') + }) + + describe('edge cases', () => { + it('ignores non-ethercat remote devices', () => { + const project = makeProject([]) + project.remoteDevices = [{ name: 'mb', protocol: 'modbus-tcp' }] + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('handles a remote device with no ethercatConfig', () => { + const project = makeProject([]) + project.remoteDevices = [{ name: 'ec', protocol: 'ethercat' }] + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('skips an enabled device whose mandatory objects are unmapped', () => { + const dev = makeDevice('X_Axis') + dev.channelMappings = [] // nothing resolves -> no controlWord/statusWord + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('skips a device whose channelInfo is absent', () => { + const dev = makeDevice('X_Axis') + dev.channelInfo = undefined + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('deduplicates axes that sanitize to the same identifier', () => { + const a = makeDevice('X Axis') + const b = makeDevice('X_Axis') // both -> X_Axis + a.id = 'a' + b.id = 'b' + const out = generateSoftMotionArtifacts(makeProject([a, b])) + const axisGlobals = out.configurations.resource.globalVariables.filter((g) => g.name === 'X_Axis') + expect(axisGlobals).toHaveLength(1) + }) + + it('handles a project with no remoteDevices field', () => { + const project = makeProject([]) + delete project.remoteDevices + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('emits fractional scale factors verbatim', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 0.5 } + const out = generateSoftMotionArtifacts(makeProject([dev])) + const body = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME)!.body.value as string + expect(body).toContain('X_Axis.fScalefactor := 0.5;') + }) + + it('creates a fallback cyclic task when the resource has none', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.configurations.resource.tasks = [] + project.configurations.resource.instances = [] + const out = generateSoftMotionArtifacts(project) + expect(out.configurations.resource.tasks).toHaveLength(1) + expect(out.configurations.resource.tasks[0].triggering).toBe('Cyclic') + expect(out.configurations.resource.instances[0].task).toBe(out.configurations.resource.tasks[0].name) + }) + }) +}) diff --git a/src/backend/shared/ethercat/cia402.ts b/src/backend/shared/ethercat/cia402.ts new file mode 100644 index 000000000..b360d47e0 --- /dev/null +++ b/src/backend/shared/ethercat/cia402.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * CiA 402 (DS402) SoftMotion axis recognition & mapping. + * + * Detects whether an EtherCAT device is a CiA 402 servo drive and resolves its + * standard CiA 402 objects (Controlword, Statusword, Target/Actual position, + * …) to the editor-allocated IEC located addresses. This is the bridge between + * a generic EtherCAT slave and the strucpp AXIS_REF_SM3 / MC_* motion library: + * a recognized drive becomes a SoftMotion axis whose name is used directly in + * MC_*(Axis := ) calls, with the glue generated at compile time + * (see generate-softmotion.ts). + */ + +import type { + Cia402AxisConfig, + ESIDevice, + ESIPdo, + EtherCATChannelMapping, + PersistedChannelInfo, +} from '@root/middleware/shared/ports/esi-types' + +export type { Cia402AxisConfig } + +/** A CiA 402 object role used by the AXIS_REF_SM3 drive bridge. */ +export type Cia402Role = + | 'controlWord' + | 'modesOfOperation' + | 'targetPosition' + | 'profileVelocity' + | 'targetVelocity' + | 'targetTorque' + | 'statusWord' + | 'modesDisplay' + | 'positionActual' + | 'velocityActual' + | 'torqueActual' + +interface Cia402ObjectDef { + role: Cia402Role + index: number + direction: 'output' | 'input' +} + +/** + * The single-axis CiA 402 object dictionary entries the drive bridge maps. + * Controlword/Statusword are mandatory; the rest are optional and only wired + * when the drive exposes them as PDOs. + */ +export const CIA402_OBJECTS: readonly Cia402ObjectDef[] = [ + { role: 'controlWord', index: 0x6040, direction: 'output' }, + { role: 'modesOfOperation', index: 0x6060, direction: 'output' }, + { role: 'targetPosition', index: 0x607a, direction: 'output' }, + { role: 'profileVelocity', index: 0x6081, direction: 'output' }, + { role: 'targetVelocity', index: 0x60ff, direction: 'output' }, + { role: 'targetTorque', index: 0x6071, direction: 'output' }, + { role: 'statusWord', index: 0x6041, direction: 'input' }, + { role: 'modesDisplay', index: 0x6061, direction: 'input' }, + { role: 'positionActual', index: 0x6064, direction: 'input' }, + { role: 'velocityActual', index: 0x606c, direction: 'input' }, + { role: 'torqueActual', index: 0x6077, direction: 'input' }, +] + +/** The CiA 402 objects that MUST be present for a device to be an axis. */ +const MANDATORY_INDICES = [0x6040, 0x6041] as const + +/** + * Default axis config for a newly-recognized drive (1:1 scaling). See the + * Cia402AxisConfig type in the esi-types ports module. + */ +export const DEFAULT_CIA402_AXIS_CONFIG: Cia402AxisConfig = { + enabled: true, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, +} + +/** Parse a hex object index in any ESI form (`#x6040`, `0x6040`, `6040`, `24640`). */ +export function normalizeObjectIndex(raw: string | number): number { + if (typeof raw === 'number') return raw + const s = raw.trim().replace(/^#x/i, '').replace(/^0x/i, '') + // ESI indices are hex; a bare token like "6040" is hex, not decimal. Reject + // anything that isn't a pure hex string (parseInt would leniently read a + // leading hex prefix like "b" out of "bogus"). + if (!/^[0-9a-f]+$/i.test(s)) return -1 + return parseInt(s, 16) +} + +function pdosContainIndex(pdos: ESIPdo[], index: number): boolean { + return pdos.some((pdo) => pdo.entries.some((e) => normalizeObjectIndex(e.index) === index)) +} + +/** + * True when the device exposes the mandatory CiA 402 objects as PDOs + * (Controlword out, Statusword in) — i.e. it can be driven as a SoftMotion axis. + */ +export function isCia402Drive(device: ESIDevice): boolean { + return ( + pdosContainIndex(device.rxPdo, MANDATORY_INDICES[0]) && + pdosContainIndex(device.txPdo, MANDATORY_INDICES[1]) + ) +} + +/** A resolved CiA 402 object: its role, IEC located address, and IEC type. */ +export interface ResolvedCia402Object { + role: Cia402Role + index: number + iecLocation: string + iecType: string +} + +/** + * Resolve the CiA 402 objects present on a device to their editor-allocated IEC + * located addresses, by joining channel metadata (which carries the object + * index) with the channel mappings (which carry the allocated address) on + * `channelId`. Objects without a mapping (unassigned) are omitted. + */ +export function resolveCia402Objects( + channelInfo: PersistedChannelInfo[], + mappings: EtherCATChannelMapping[], +): ResolvedCia402Object[] { + const locationByChannel = new Map(mappings.map((m) => [m.channelId, m.iecLocation])) + const resolved: ResolvedCia402Object[] = [] + for (const def of CIA402_OBJECTS) { + const info = channelInfo.find( + (c) => normalizeObjectIndex(c.entryIndex) === def.index && c.direction === def.direction, + ) + if (!info) continue + const iecLocation = locationByChannel.get(info.channelId) + if (!iecLocation) continue + resolved.push({ role: def.role, index: def.index, iecLocation, iecType: info.iecType }) + } + return resolved +} diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts index 7d04d2eab..7926238f0 100644 --- a/src/backend/shared/ethercat/enrich-device-data.ts +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -15,6 +15,7 @@ import type { SDOConfigurationEntry, } from '@root/middleware/shared/ports/esi-types' +import { type Cia402AxisConfig, DEFAULT_CIA402_AXIS_CONFIG, isCia402Drive } from './cia402' import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' import { extractDefaultSdoConfigurations } from './sdo-config-defaults' @@ -113,6 +114,7 @@ export function enrichDeviceData( slaveType: string sdoConfigurations?: SDOConfigurationEntry[] channelMappings: EtherCATChannelMapping[] + cia402?: Cia402AxisConfig } { return { channelInfo: buildChannelInfo(device), @@ -121,5 +123,8 @@ export function enrichDeviceData( slaveType: deriveSlaveType(device), sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, channelMappings: generateDefaultChannelMappings(pdoToChannels(device), usedAddresses), + // A CiA 402 servo is auto-recognized as a SoftMotion axis; the user can + // disable/tune it in the device's Axis configuration. + cia402: isCia402Drive(device) ? { ...DEFAULT_CIA402_AXIS_CONFIG } : undefined, } } diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts new file mode 100644 index 000000000..3e87b5ca6 --- /dev/null +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Compile-time SoftMotion code generation. + * + * Turns each CiA 402 EtherCAT drive (recognized by cia402.ts, opted-in via its + * Cia402AxisConfig) into the ST glue that lets CODESYS-style application code + * run unmodified: + * + * - an AXIS_REF_SM3 global named after the device (so `MC_Power(Axis := X_Axis)` + * resolves directly to the drive), + * - a located scalar global per mapped CiA 402 PDO object, bound to the + * editor-allocated %I/%Q address, + * - a per-scan `__sm3_bridge` PROGRAM (run first each cycle) that calls + * SM_Drive_GenericDS402 to marshal the PDO image <-> the axis and apply the + * configured scaling. + * + * The user never maps an address: the EtherCAT device's name IS the axis name. + * Called from preprocessPous so every compile path (build/download/deploy/debug) + * gets the generated artifacts. Pure: returns a new PLCProjectData, never + * mutates the input. + */ + +import type { PLCInstance, PLCPou, PLCProjectData, PLCTask, PLCVariable } from '@root/middleware/shared/ports/types' + +import type { Cia402Role } from './cia402' +import { resolveCia402Objects } from './cia402' + +export const SM3_BRIDGE_POU_NAME = '__sm3_bridge' +export const SM3_BRIDGE_INSTANCE_NAME = '__sm3_bridge_inst' +const SM3_FALLBACK_TASK: PLCTask = { + name: '__sm3_task', + triggering: 'Cyclic', + interval: 'T#10ms', + priority: 0, +} + +/** + * Maps a CiA 402 object role to the SM_Drive_GenericDS402 pin it binds and the + * IEC type the located scalar is declared with. The scalar type is fixed to the + * bridge pin's type (not the ESI-declared type) so the generated FB call is + * always type-correct — the PDO byte width is identical either way (a 32-bit + * position reads the same 4 bytes as DINT or UDINT at the same %QD address). + * `pinKind` decides `:=` (FB input, drive feedback) vs `=>` (FB output, command). + */ +interface RoleBinding { + pin: string + pinKind: 'input' | 'output' + iecType: string +} +const ROLE_BINDINGS: Record = { + controlWord: { pin: 'wControlWord', pinKind: 'output', iecType: 'UINT' }, + modesOfOperation: { pin: 'siModes', pinKind: 'output', iecType: 'SINT' }, + targetPosition: { pin: 'diTargetPosition', pinKind: 'output', iecType: 'DINT' }, + profileVelocity: { pin: 'udiProfileVelocity', pinKind: 'output', iecType: 'UDINT' }, + targetVelocity: { pin: 'diTargetVelocity', pinKind: 'output', iecType: 'DINT' }, + targetTorque: { pin: 'iTargetTorque', pinKind: 'output', iecType: 'INT' }, + statusWord: { pin: 'wStatusWord', pinKind: 'input', iecType: 'UINT' }, + modesDisplay: { pin: 'siModesDisplay', pinKind: 'input', iecType: 'SINT' }, + positionActual: { pin: 'diActualPosition', pinKind: 'input', iecType: 'DINT' }, + velocityActual: { pin: 'diActualVelocity', pinKind: 'input', iecType: 'DINT' }, + torqueActual: { pin: 'iActualTorque', pinKind: 'input', iecType: 'INT' }, +} + +/** Sanitize a device name into a valid IEC 61131-3 identifier. */ +export function sanitizeAxisName(name: string): string { + let s = name.replace(/[^A-Za-z0-9_]/g, '_') + if (!/^[A-Za-z_]/.test(s)) s = `_${s}` + return s +} + +/** + * True when `name` is already a valid IEC 61131-3 identifier — a letter or + * underscore followed by letters, digits, or underscores. A SoftMotion drive's + * name IS the axis variable name used in `MC_*(Axis := )`, so it must + * satisfy this (no spaces, hyphens, or leading digits). + */ +export function isValidIecIdentifier(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) +} + +function lrealLiteral(n: number): string { + return Number.isInteger(n) ? `${n}.0` : `${n}` +} + +function global(name: string, typeValue: string, definition: 'base-type' | 'derived', location: string): PLCVariable { + return { name, type: { definition, value: typeValue }, location, documentation: '' } +} + +/** A VAR_EXTERNAL declaration referencing a configuration global. */ +function external(name: string, typeValue: string, definition: 'base-type' | 'derived'): PLCVariable { + return { name, class: 'external', type: { definition, value: typeValue }, location: '', documentation: '' } +} + +/** A POU-local variable (e.g. the bridge FB instance). */ +function local(name: string, typeValue: string, definition: 'base-type' | 'derived'): PLCVariable { + return { name, class: 'local', type: { definition, value: typeValue }, location: '', documentation: '' } +} + +/** True when a POU body (ST text or a serialized graphical body) references `identifier`. */ +function bodyReferences(bodyValue: unknown, identifier: string): boolean { + const text = typeof bodyValue === 'string' ? bodyValue : JSON.stringify(bodyValue ?? '') + return new RegExp(`\\b${identifier}\\b`).test(text) +} + +interface AxisPlan { + axisName: string + scaleNum: number + scaleDenom: number + scaleFactor: number + objects: { role: Cia402Role; scalarName: string; iecLocation: string; binding: RoleBinding }[] +} + +/** Collect every opted-in, resolvable CiA 402 axis in the project. */ +function collectAxes(project: PLCProjectData): AxisPlan[] { + const plans: AxisPlan[] = [] + const seen = new Set() + for (const rd of project.remoteDevices ?? []) { + if (rd.protocol !== 'ethercat') continue + for (const dev of rd.ethercatConfig?.devices ?? []) { + if (!dev.cia402?.enabled) continue + const resolved = resolveCia402Objects(dev.channelInfo ?? [], dev.channelMappings) + const hasControl = resolved.some((o) => o.role === 'controlWord') + const hasStatus = resolved.some((o) => o.role === 'statusWord') + // A drive can only be an axis if both mandatory objects are mapped. + if (!hasControl || !hasStatus) continue + + const axisName = sanitizeAxisName(dev.name) + // Skip a duplicate sanitized name to avoid emitting two globals with the + // same identifier (later devices lose — surfaced by compile if referenced). + if (seen.has(axisName.toUpperCase())) continue + seen.add(axisName.toUpperCase()) + + plans.push({ + axisName, + scaleNum: dev.cia402.scaleNum, + scaleDenom: dev.cia402.scaleDenom, + scaleFactor: dev.cia402.scaleFactor, + objects: resolved.map((o) => ({ + role: o.role, + scalarName: `${axisName}_${o.role}`, + iecLocation: o.iecLocation, + binding: ROLE_BINDINGS[o.role], + })), + }) + } + } + return plans +} + +/** Sanitized names of every enabled, resolvable CiA 402 axis in the project. */ +export function softMotionAxisNames(project: PLCProjectData): string[] { + return collectAxes(project).map((a) => a.axisName) +} + +/** POU types that may access a SoftMotion axis global via VAR_EXTERNAL. Functions + * are stateless and can't hold VAR_EXTERNAL, so they're excluded. */ +const AXIS_EXTERNAL_POU_TYPES = new Set(['program', 'function-block']) + +/** + * Inject a `VAR_EXTERNAL : AXIS_REF_SM3` into `pou` for every axis in + * `axisNames` its body references but hasn't already declared — so + * `MC_*(Axis := )` resolves without the user declaring the global. + * Returns the POU unchanged when nothing applies. Programs and function blocks + * only: strucpp requires a VAR_EXTERNAL to touch a global, and both POU kinds + * support it (a function can't). Shared by the compiler and the language server + * so the editor sees exactly what the compiler generates. + */ +export function injectAxisExternals(pou: PLCPou, axisNames: string[]): PLCPou { + if (!AXIS_EXTERNAL_POU_TYPES.has(pou.pouType)) return pou + const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) + const toAdd = axisNames + .filter((name) => !declared.has(name.toUpperCase()) && bodyReferences(pou.body.value, name)) + .map((name) => external(name, 'AXIS_REF_SM3', 'derived')) + if (toAdd.length === 0) return pou + return { + ...pou, + interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, + } +} + +/** + * Serialize the SoftMotion axis globals as a standalone ST configuration for the + * language server. Each CiA 402 drive becomes a `VAR_GLOBAL : AXIS_REF_SM3` + * so editor code referencing the axis (e.g. `MC_Power(Axis := X_Axis)`) resolves + * against the same public axis the compiler generates — without the user + * declaring anything. Returns '' when the project has no axes. + * + * Emitted as a **bare top-level `VAR_GLOBAL` block** (not wrapped in a + * CONFIGURATION): strucpp registers top-level global blocks into the ambient + * global scope, so a POU can reference the axis directly — no `VAR_EXTERNAL` + * needed, which is what keeps the editor documents byte-for-byte what the user + * wrote (no injected declarations shifting line numbers). Only the axis + * references are declared (not the located PDO scalar globals) — those are + * internal to the generated drive bridge and never named in user code. + * `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion stlib the LSP + * already ingests. + * + * The declaration order matches `softMotionAxisNames`, so line N+1 of this + * document (line 0 is `VAR_GLOBAL`) is axis N — the go-to-definition redirect + * relies on that to map a click back to its drive. + */ +export function serializeSoftMotionAxisGlobalsToST(project: PLCProjectData): string { + const axes = collectAxes(project) + if (axes.length === 0) return '' + + const decls = axes.map((a) => ` ${a.axisName} : AXIS_REF_SM3;`).join('\n') + return ['VAR_GLOBAL', decls, 'END_VAR', ''].join('\n') +} + +/** + * Inject generated SoftMotion globals + the per-scan bridge program for every + * CiA 402 axis in the project. No-op (returns the input) when there are none. + */ +export function generateSoftMotionArtifacts(project: PLCProjectData): PLCProjectData { + const axes = collectAxes(project) + if (axes.length === 0) return project + + const newGlobals: PLCVariable[] = [] + const bridgeVars: PLCVariable[] = [] + const bodyLines: string[] = [] + + for (const axis of axes) { + // AXIS_REF_SM3 instance (the name used in MC_*(Axis := ...)) — a config + // global; the bridge reaches it via VAR_EXTERNAL. + newGlobals.push(global(axis.axisName, 'AXIS_REF_SM3', 'derived', '')) + bridgeVars.push(external(axis.axisName, 'AXIS_REF_SM3', 'derived')) + + bodyLines.push(`(* ---- SoftMotion axis ${axis.axisName} ---- *)`) + // Apply configured scaling each scan (device config is authoritative). + bodyLines.push(`${axis.axisName}.iRatioTechUnitsNum := DINT#${Math.trunc(axis.scaleNum)};`) + bodyLines.push(`${axis.axisName}.dwRatioTechUnitsDenom := DWORD#${Math.trunc(axis.scaleDenom)};`) + bodyLines.push(`${axis.axisName}.fScalefactor := ${lrealLiteral(axis.scaleFactor)};`) + + const inBinds: string[] = [] + const outBinds: string[] = [] + for (const obj of axis.objects) { + const iecType = obj.binding.iecType.toLowerCase() + // located scalar global bound to the drive PDO address... + newGlobals.push(global(obj.scalarName, iecType, 'base-type', obj.iecLocation)) + // ...and the bridge's VAR_EXTERNAL view of it. + bridgeVars.push(external(obj.scalarName, iecType, 'base-type')) + if (obj.binding.pinKind === 'input') inBinds.push(`${obj.binding.pin} := ${obj.scalarName}`) + else outBinds.push(`${obj.binding.pin} => ${obj.scalarName}`) + } + + const fbInstance = `${axis.axisName}_drive` + bridgeVars.push(local(fbInstance, 'SM_Drive_GenericDS402', 'derived')) + const binds = [`Axis := ${axis.axisName}`, ...inBinds, 'bOnline := TRUE', ...outBinds] + bodyLines.push(`${fbInstance}(`) + bodyLines.push(`\t${binds.join(',\n\t')});`) + } + + const bridgePou: PLCPou = { + name: SM3_BRIDGE_POU_NAME, + pouType: 'program', + interface: { variables: bridgeVars }, + body: { language: 'st', value: bodyLines.join('\n') }, + documentation: 'Auto-generated SoftMotion drive bridge — do not edit; regenerated each compile.', + } + + // Inject a VAR_EXTERNAL for each axis into user programs and function blocks + // that reference it, so `MC_*(Axis := X_Axis)` resolves without the user + // declaring the global. + const axisNames = axes.map((a) => a.axisName) + const patchedPous = project.pous.map((pou) => injectAxisExternals(pou, axisNames)) + + const resource = project.configurations.resource + // Ensure a task exists to run the bridge, then attach the bridge instance at + // the FRONT of the instance list so it runs before user POUs each scan + // (fresh PDO feedback in, commands out). + const tasks = resource.tasks.length > 0 ? resource.tasks : [SM3_FALLBACK_TASK] + const bridgeInstance: PLCInstance = { + name: SM3_BRIDGE_INSTANCE_NAME, + task: tasks[0].name, + program: SM3_BRIDGE_POU_NAME, + } + + return { + ...project, + pous: [...patchedPous, bridgePou], + configurations: { + ...project.configurations, + resource: { + ...resource, + tasks, + globalVariables: [...resource.globalVariables, ...newGlobals], + instances: [bridgeInstance, ...resource.instances], + }, + }, + } +} diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 4a5fa1b57..31c808344 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -705,6 +705,21 @@ const SDOConfigurationEntrySchema = z.object({ objectName: z.string(), }) +/** + * CiA 402 SoftMotion axis configuration on a recognized EtherCAT drive. When + * present and enabled, the device is treated as a SoftMotion axis: at compile + * time the editor generates an AXIS_REF_SM3 global (named after the device), + * located scalar globals bound to the drive's CiA 402 PDO addresses, and a + * per-scan SM_Drive_GenericDS402 bridge. Scaling mirrors the AXIS_REF_SM3 + * fields (increments per unit = scaleFactor * scaleNum / scaleDenom). + */ +const Cia402AxisConfigSchema = z.object({ + enabled: z.boolean(), + scaleNum: z.number(), + scaleDenom: z.number(), + scaleFactor: z.number(), +}) + const ConfiguredEtherCATDeviceSchema = z.object({ id: z.string(), position: z.number().optional(), @@ -721,6 +736,8 @@ const ConfiguredEtherCATDeviceSchema = z.object({ txPdos: z.array(PersistedPdoSchema).optional(), slaveType: z.string().optional(), sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), + /** Present when this drive is a CiA 402 SoftMotion axis (see schema above). */ + cia402: Cia402AxisConfigSchema.optional(), }) const EtherCATMasterConfigSchema = z.object({ diff --git a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts index 109380992..7fab4705b 100644 --- a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts +++ b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts @@ -310,4 +310,51 @@ describe('preprocessPous — mixed', () => { const { projectData } = preprocessPous(project, false, logger.log) expect(projectData.originalCppPous).toBeUndefined() }) + + it('generates SoftMotion axis artifacts for a CiA 402 EtherCAT drive', () => { + const project: PLCProjectData = { + ...makeProjectData([makeStPou('main', 'pwr(Axis := X_Axis, Enable := TRUE);')]), + remoteDevices: [ + { + name: 'ethercat-bus', + protocol: 'ethercat', + ethercatConfig: { + devices: [ + { + id: 'd1', + name: 'X_Axis', + esiDeviceRef: { repositoryItemId: 'r', deviceIndex: 0 }, + vendorId: '0x0', + productCode: '0x0', + revisionNo: '0x0', + addedFrom: 'repository', + config: {}, + cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 }, + channelInfo: [ + { channelId: 'c1', name: 'Controlword', direction: 'output', pdoIndex: '0x1600', entryIndex: '0x6040', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, + { channelId: 'c2', name: 'Statusword', direction: 'input', pdoIndex: '0x1A00', entryIndex: '0x6041', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, + ], + channelMappings: [ + { channelId: 'c1', iecLocation: '%QW0' }, + { channelId: 'c2', iecLocation: '%IW0' }, + ], + }, + ], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + } + const logger = collectLog() + const { projectData } = preprocessPous(project, false, logger.log) + // Bridge program + axis global generated; VAR_EXTERNAL injected into main. + expect(projectData.pous.some((p) => p.name === '__sm3_bridge')).toBe(true) + expect( + projectData.configurations.resource.globalVariables.some( + (g) => g.name === 'X_Axis' && g.type.value === 'AXIS_REF_SM3', + ), + ).toBe(true) + const main = projectData.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) + }) }) diff --git a/src/backend/shared/utils/PLC/preprocess-pous.ts b/src/backend/shared/utils/PLC/preprocess-pous.ts index bced661cf..3e2760274 100644 --- a/src/backend/shared/utils/PLC/preprocess-pous.ts +++ b/src/backend/shared/utils/PLC/preprocess-pous.ts @@ -5,6 +5,7 @@ import { addPythonLocalVariables } from '../../../../frontend/utils/python/addPy import { generateSTCode } from '../../../../frontend/utils/python/generateSTCode' import { injectPythonCode } from '../../../../frontend/utils/python/injectPythonCode' import type { PLCPou, PLCProjectData, PLCVariable } from '../../../../middleware/shared/ports/types' +import { generateSoftMotionArtifacts } from '../../ethercat/generate-softmotion' type CppPouData = { name: string @@ -174,6 +175,14 @@ function preprocessPous(projectData: PLCProjectData, isSimulator: boolean, log: log('info', `Successfully processed ${cppPous.length} C/C++ POU(s)`) } + // --- SoftMotion: generate AXIS_REF_SM3 globals + PDO scalars + drive bridge + // for CiA 402 EtherCAT axes (no-op when the project has none). --- + const withMotion = generateSoftMotionArtifacts(processedProjectData) as ProjectDataWithCpp + if (withMotion !== processedProjectData) { + processedProjectData = withMotion + log('info', 'Generated SoftMotion axis bindings for CiA 402 drive(s)') + } + return { projectData: processedProjectData as ProjectDataWithCpp, validationFailed: false } } diff --git a/src/frontend/assets/icons/interface/SoftMotion.tsx b/src/frontend/assets/icons/interface/SoftMotion.tsx new file mode 100644 index 000000000..074af025c --- /dev/null +++ b/src/frontend/assets/icons/interface/SoftMotion.tsx @@ -0,0 +1,46 @@ +import { ComponentProps } from 'react' + +import { cn } from '../../../utils/cn' + +type ISoftMotionIconProps = ComponentProps<'svg'> & { + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-8 h-8', + lg: 'w-12 h-12', +} + +/** + * SoftMotion (CiA 402 servo axis) icon — a rounded device tile with a rotary + * motion glyph (a circular arrow around a hub), in teal to distinguish a + * recognized SoftMotion drive from a plain EtherCAT slave in the project tree. + */ +export const SoftMotionIcon = (props: ISoftMotionIconProps) => { + const { className, size = 'sm', ...res } = props + return ( + + + {/* rotary arc suggesting axis rotation */} + + {/* arrowhead closing the arc */} + + {/* motor hub */} + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx new file mode 100644 index 000000000..6d5827e21 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx @@ -0,0 +1,183 @@ +import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import type { Cia402AxisConfig, ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import { useMemo } from 'react' + +const inputClassName = + 'h-[26px] w-28 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' + +/** Human labels for the CiA 402 object roles, in a sensible display order. */ +const ROLE_LABELS: Array<{ role: Cia402Role; label: string }> = [ + { role: 'controlWord', label: 'Control Word (0x6040)' }, + { role: 'statusWord', label: 'Status Word (0x6041)' }, + { role: 'modesOfOperation', label: 'Modes of Operation (0x6060)' }, + { role: 'modesDisplay', label: 'Modes Display (0x6061)' }, + { role: 'targetPosition', label: 'Target Position (0x607A)' }, + { role: 'positionActual', label: 'Position Actual (0x6064)' }, + { role: 'profileVelocity', label: 'Profile Velocity (0x6081)' }, + { role: 'targetVelocity', label: 'Target Velocity (0x60FF)' }, + { role: 'velocityActual', label: 'Velocity Actual (0x606C)' }, + { role: 'targetTorque', label: 'Target Torque (0x6071)' }, + { role: 'torqueActual', label: 'Torque Actual (0x6077)' }, +] + +/** Feedback signals shown in the live-values panel (drive → controller). */ +const FEEDBACK_SIGNALS: Array<{ role: Cia402Role; label: string; unit: string }> = [ + { role: 'positionActual', label: 'Actual Position', unit: 'u' }, + { role: 'velocityActual', label: 'Actual Velocity', unit: 'u/s' }, + { role: 'torqueActual', label: 'Actual Torque', unit: '' }, + { role: 'statusWord', label: 'Status Word', unit: '' }, +] + +function parseFloatInput(value: string): number | undefined { + const n = Number(value) + return Number.isFinite(n) ? n : undefined +} + +function parseIntInput(value: string, min: number): number | undefined { + const n = parseInt(value, 10) + return Number.isNaN(n) || n < min ? undefined : n +} + +export type Cia402AxisTabProps = { + device: ConfiguredEtherCATDevice + /** Merge-updates the device's CiA 402 axis config in the store. */ + onUpdate: (patch: Partial) => void +} + +/** + * SoftMotion axis (CiA 402) configuration + live-feedback screen — the OpenPLC + * analogue of the CODESYS CiA 402 device editor. Lets the user tune the + * increments↔units scaling used by SM_Drive_GenericDS402, shows how the drive's + * CiA 402 objects map to IEC located addresses, and (when a PLC is connected) + * displays real-time axis feedback. The device name is the axis name used in + * MC_*(Axis := ). + */ +export const Cia402AxisTab = ({ device, onUpdate }: Cia402AxisTabProps) => { + const cia402: Cia402AxisConfig = device.cia402 ?? { + enabled: false, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, + } + + const resolved = useMemo( + () => resolveCia402Objects(device.channelInfo ?? [], device.channelMappings), + [device.channelInfo, device.channelMappings], + ) + const locationByRole = useMemo(() => { + const m = new Map() + for (const o of resolved) m.set(o.role, { iecLocation: o.iecLocation, iecType: o.iecType }) + return m + }, [resolved]) + + const denom = cia402.scaleDenom === 0 ? 1 : cia402.scaleDenom + const incPerUnit = cia402.scaleFactor * (cia402.scaleNum / denom) + + return ( +
+ {/* Scaling */} +
+
+ Scaling (increments ↔ technical units) +
+
+ + + +
+ Increments per unit + + {Number.isFinite(incPerUnit) ? incPerUnit : '—'} + +
+
+
+ + {/* CiA 402 object → IEC address mapping */} +
+
CiA 402 Object Mapping
+
+ + + + + + + + + + {ROLE_LABELS.map(({ role, label }) => { + const m = locationByRole.get(role) + return ( + + + + + + ) + })} + +
ObjectIEC AddressType
{label}{m?.iecLocation ?? '—'}{m?.iecType ?? '—'}
+
+
+ + {/* Real-time feedback */} +
+
Real-time Feedback
+
+ {FEEDBACK_SIGNALS.map(({ role, label, unit }) => { + const mapped = locationByRole.has(role) + return ( +
+ {label} + + {mapped ? `— ${unit}` : 'n/a'} + +
+ ) + })} +
+

+ Live values appear here when connected to a running PLC and monitoring is active. +

+
+
+ ) +} 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 2877a856e..f4e411d25 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 @@ -3,6 +3,7 @@ import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configur import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' import type { + Cia402AxisConfig, ConfiguredEtherCATDevice, EnrichDeviceData, ESIDeviceSummary, @@ -16,13 +17,14 @@ import { buildAddressPool } from '@root/middleware/shared/utils/iec-address' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Cia402AxisTab } from './components/cia402-axis-tab' import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection, } from './components/device-configuration-form' -type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' | 'axis' const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => (