From ed0fe8cac6b8e286e9b467446e5c39d25175cfc4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 6 Jul 2026 02:51:48 -0400 Subject: [PATCH 1/8] feat(softmotion): CiA 402 SoftMotion axis support (mirror of openplc-web) Byte-identical mirror of the shared SoftMotion surface from openplc-web (PR to be released together): - cia402.ts: recognize CiA 402 drives + resolve objects to IEC addresses - generate-softmotion.ts: compile-time AXIS_REF_SM3 globals + PDO scalars + per-scan SM_Drive_GenericDS402 bridge + VAR_EXTERNAL injection - enrichDeviceData tags recognized drives; open-plc/esi-types carry the Cia402AxisConfig; preprocessPous runs the codegen in every compile path - shared tests (cia402, generate-softmotion, preprocess-pous) pass under the editor's jest - binary-versions: strucpp v0.5.13 -> v0.5.14 (ships the SM3 library + FB inout copy-back + composite shared globals) The editor loads plcopen-softmotion.stlib via its bundled-libs directory scan (no bundled-stlibs.ts change needed). Requires strucpp release v0.5.14 (STruCpp PR #194). Co-Authored-By: Claude Opus 4.8 (1M context) --- binary-versions.json | 2 +- .../shared/ethercat/__tests__/cia402.test.ts | 93 ++ .../__tests__/fixtures/cia402-servo-esi.xml | 1398 +++++++++++++++++ .../__tests__/generate-softmotion.test.ts | 249 +++ src/backend/shared/ethercat/cia402.ts | 134 ++ .../shared/ethercat/enrich-device-data.ts | 5 + .../shared/ethercat/generate-softmotion.ts | 231 +++ src/backend/shared/types/PLC/open-plc.ts | 17 + .../PLC/__tests__/preprocess-pous.test.ts | 47 + .../shared/utils/PLC/preprocess-pous.ts | 9 + src/middleware/shared/ports/esi-types.ts | 20 + 11 files changed, 2204 insertions(+), 1 deletion(-) create mode 100644 src/backend/shared/ethercat/__tests__/cia402.test.ts create mode 100644 src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml create mode 100644 src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts create mode 100644 src/backend/shared/ethercat/cia402.ts create mode 100644 src/backend/shared/ethercat/generate-softmotion.ts diff --git a/binary-versions.json b/binary-versions.json index a0d6978b0..6dd46a282 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.13", + "version": "v0.5.14", "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..d1f7c3d35 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -0,0 +1,249 @@ +// 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, + SM3_BRIDGE_INSTANCE_NAME, + SM3_BRIDGE_POU_NAME, + sanitizeAxisName, +} 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('is a no-op when there are no CiA 402 axes', () => { + const project = makeProject([]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + 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('leaves non-program POUs untouched', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous.push({ + name: 'helper', + pouType: 'function-block', + interface: { variables: [] }, + body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, + }) + const out = generateSoftMotionArtifacts(project) + const helper = out.pous.find((p) => p.name === 'helper')! + expect(helper.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..06568034e --- /dev/null +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -0,0 +1,231 @@ +// 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 +} + +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 +} + +/** + * 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 that reference it, so + // `MC_*(Axis := X_Axis)` resolves without the user declaring the global. + const patchedPous = project.pous.map((pou) => { + if (pou.pouType !== 'program') return pou + const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) + const toAdd = axes + .filter((a) => !declared.has(a.axisName.toUpperCase()) && bodyReferences(pou.body.value, a.axisName)) + .map((a) => external(a.axisName, 'AXIS_REF_SM3', 'derived')) + if (toAdd.length === 0) return pou + return { + ...pou, + interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, + } + }) + + 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/middleware/shared/ports/esi-types.ts b/src/middleware/shared/ports/esi-types.ts index fbc4f01e7..4de4fc9aa 100644 --- a/src/middleware/shared/ports/esi-types.ts +++ b/src/middleware/shared/ports/esi-types.ts @@ -493,6 +493,26 @@ export interface ConfiguredEtherCATDevice { slaveType?: string /** SDO startup parameters extracted from CoE Object Dictionary */ sdoConfigurations?: SDOConfigurationEntry[] + /** CiA 402 SoftMotion axis configuration (present when recognized as a drive) */ + cia402?: Cia402AxisConfig +} + +/** + * Per-axis CiA 402 SoftMotion configuration persisted on a recognized drive. + * Mirrors the AXIS_REF_SM3 scaling fields; increments-per-unit is derived as + * scaleFactor * scaleNum / scaleDenom. When `enabled`, the compile step + * generates the AXIS_REF_SM3 global, located PDO scalars, and the per-scan + * SM_Drive_GenericDS402 bridge for this device. + */ +export interface Cia402AxisConfig { + /** TRUE = treat this EtherCAT device as a SoftMotion axis. */ + enabled: boolean + /** iRatioTechUnitsNum (CODESYS param 1052). */ + scaleNum: number + /** dwRatioTechUnitsDenom (CODESYS param 1051). */ + scaleDenom: number + /** fScalefactor (CODESYS param 1054) — increments per user unit. */ + scaleFactor: number } // ===================== PER-SLAVE CONFIGURATION ===================== From 6a28927e0ea776b53ce575dd9be7d06386321f49 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 9 Jul 2026 22:14:51 -0400 Subject: [PATCH 2/8] feat(softmotion): device-tree icon + CiA 402 axis config screen (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of the openplc-web SoftMotion axis UI: - SoftMotion icon (teal rotary-motion tile) marking a recognized CiA 402 SoftMotion drive in the project tree. - Cia402AxisTab: CODESYS-style axis config — enable toggle, increments↔units scaling, the CiA 402 object→IEC-address mapping table, and a real-time feedback panel. The device name is the axis name used in MC_*(Axis := …). - project tree + explorer wire the `softMotionDrive` leaf lang when cia402.enabled; the EtherCAT device editor gains a "SoftMotion Axis" tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../assets/icons/interface/SoftMotion.tsx | 46 ++++ .../ethercat/components/cia402-axis-tab.tsx | 198 ++++++++++++++++++ .../ethercat/ethercat-device-editor.tsx | 35 +++- .../_molecules/project-tree/index.tsx | 5 + .../_organisms/explorer/project.tsx | 2 +- 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/frontend/assets/icons/interface/SoftMotion.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx 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..ee3fe65e4 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx @@ -0,0 +1,198 @@ +import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +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 ( +
+ {/* Enable */} + + +

+ Referenced in application code as{' '} + + {device.name} + {' '} + — e.g. MC_Power(Axis := {device.name}, Enable := TRUE). +

+ + {/* 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..016abdd97 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 }) => ( ) => { + syncDevicesToStore( + configuredDevices.map((d) => { + if (d.id !== deviceId) return d + const base: Cia402AxisConfig = d.cia402 ?? { + enabled: false, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, + } + return { ...d, cia402: { ...base, ...patch } } + }), + ) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + // 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. @@ -283,11 +303,24 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: > + {device.cia402 && } + {/* SoftMotion Axis (CiA 402) Tab */} + {device.cia402 && ( + +
+ +
+
+ )} + {/* Device Info Tab */} & { | 'remoteDevice' | 'vendorScreen' | 'ethercatDevice' + | 'softMotionDrive' | 'libraryManifest' leafType: WorkspaceProjectTreeLeafType label?: string @@ -486,6 +488,9 @@ const LeafSources = { remoteDevice: { LeafIcon: RemoteDeviceIcon }, vendorScreen: { LeafIcon: ConfigIcon }, ethercatDevice: { LeafIcon: DeviceTransferIcon }, + // A recognized CiA 402 SoftMotion drive gets a distinct rotary-axis icon so + // it reads as an axis (usable in MC_* blocks), not a plain EtherCAT slave. + softMotionDrive: { LeafIcon: SoftMotionIcon }, // Library manifest gets its own document-with-bookmark icon so // the explorer leaf, the workspace tab, and the breadcrumb all // render the same glyph — the manifest is the user's entry point diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index 0a2b6cb42..f25786f77 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -445,7 +445,7 @@ const Project = () => { {device.ethercatConfig?.devices?.map((child) => ( Date: Thu, 9 Jul 2026 23:41:40 -0400 Subject: [PATCH 3/8] fix(softmotion): axis rename, valid names, simplified axis tab, hide channel mappings (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of the openplc-web SoftMotion UX fixes: 1. Rename works for SoftMotion drives (isEthercatDevice now covers the softMotionDrive leaf lang). 2. Drive names are enforced to valid IEC identifiers — sanitized at add-time, validated on rename (isValidIecIdentifier), since the name is the axis variable in generated code. 3. Channel Mappings tab hidden for SoftMotion drives (PDO mappings are internal); editor defaults to the SoftMotion Axis tab. 4. SoftMotion Axis tab simplified (dropped the enable toggle and the application-code reference blurb). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 30 ++++++++---- .../shared/ethercat/generate-softmotion.ts | 10 ++++ .../ethercat/components/cia402-axis-tab.tsx | 15 ------ .../ethercat/ethercat-device-editor.tsx | 48 ++++++++++++------- .../editor/device/ethercat/index.tsx | 10 +++- .../_molecules/project-tree/index.tsx | 4 +- .../store/__tests__/shared-slice.test.ts | 12 +++++ src/frontend/store/slices/shared/slice.ts | 10 ++++ 8 files changed, 95 insertions(+), 44 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index d1f7c3d35..a218ca9e9 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -9,6 +9,7 @@ import { enrichDeviceData } from '../enrich-device-data' import { parseESIDeviceFull } from '../esi-parser-main' import { generateSoftMotionArtifacts, + isValidIecIdentifier, SM3_BRIDGE_INSTANCE_NAME, SM3_BRIDGE_POU_NAME, sanitizeAxisName, @@ -50,9 +51,7 @@ function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { globalVariables: [], }, }, - remoteDevices: [ - { name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }, - ], + remoteDevices: [{ name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }], } } @@ -63,6 +62,15 @@ describe('generateSoftMotionArtifacts', () => { 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) @@ -133,7 +141,13 @@ describe('generateSoftMotionArtifacts', () => { 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: '' }, + { + 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);' } @@ -214,9 +228,7 @@ describe('generateSoftMotionArtifacts', () => { a.id = 'a' b.id = 'b' const out = generateSoftMotionArtifacts(makeProject([a, b])) - const axisGlobals = out.configurations.resource.globalVariables.filter( - (g) => g.name === 'X_Axis', - ) + const axisGlobals = out.configurations.resource.globalVariables.filter((g) => g.name === 'X_Axis') expect(axisGlobals).toHaveLength(1) }) @@ -241,9 +253,7 @@ describe('generateSoftMotionArtifacts', () => { 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, - ) + expect(out.configurations.resource.instances[0].task).toBe(out.configurations.resource.tasks[0].name) }) }) }) diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index 06568034e..fd2d550b9 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -69,6 +69,16 @@ export function sanitizeAxisName(name: string): string { 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}` } 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 index ee3fe65e4..6d5827e21 100644 --- 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 @@ -1,5 +1,4 @@ import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' -import { Checkbox } from '@root/frontend/components/_atoms/checkbox' import { InputWithRef } from '@root/frontend/components/_atoms/input' import type { Cia402AxisConfig, ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' import { useMemo } from 'react' @@ -77,20 +76,6 @@ export const Cia402AxisTab = ({ device, onUpdate }: Cia402AxisTabProps) => { return (
- {/* Enable */} - - -

- Referenced in application code as{' '} - - {device.name} - {' '} - — e.g. MC_Power(Axis := {device.name}, Enable := TRUE). -

- {/* Scaling */}
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 016abdd97..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 @@ -104,6 +104,17 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: ) }, [remoteDevice]) + // A recognized CiA 402 SoftMotion drive: its PDO channel mappings are + // generated and consumed internally by the drive bridge, so the raw + // Channel Mappings tab is hidden and the SoftMotion Axis tab leads. + const isSoftMotion = !!device?.cia402?.enabled + + // Channel Mappings is hidden for SoftMotion drives; if it was the active + // tab (the default), fall through to the SoftMotion Axis tab. + useEffect(() => { + if (isSoftMotion && activeTab === 'channel-mappings') setActiveTab('axis') + }, [isSoftMotion, activeTab]) + // Pool of every claim from producers active on the current target. // EtherCAT is sharing the image table with VPP and Modbus TCP on // Runtime v4, so all three feed into the pool — but capability @@ -302,7 +313,9 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: className='flex min-h-0 flex-1 flex-col overflow-hidden' > - + {!isSoftMotion && ( + + )} {device.cia402 && } @@ -398,21 +411,24 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }:
- {/* Channel Mappings Tab */} - -
- -
-
+ {/* Channel Mappings Tab — hidden for SoftMotion drives (their PDO + mappings are generated and consumed internally by the bridge). */} + {!isSoftMotion && ( + +
+ +
+
+ )}
) 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 91a4b4a6f..1b6e71a49 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -2,6 +2,7 @@ import * as Tabs from '@radix-ui/react-tabs' 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 { sanitizeAxisName } from '@root/backend/shared/ethercat/generate-softmotion' 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' @@ -450,7 +451,9 @@ const EtherCATEditor = () => { for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) } - const baseName = getShortDeviceName(bestMatch.esiDevice) + // SoftMotion drive names become axis variable names — keep them valid. + const rawName = getShortDeviceName(bestMatch.esiDevice) + const baseName = enriched.cia402?.enabled ? sanitizeAxisName(rawName) : rawName const uniqueName = generateUniqueSlaveName(baseName, takenNames) takenNames.add(uniqueName) @@ -523,7 +526,10 @@ const EtherCATEditor = () => { const nextPosition = configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 - const baseName = getShortDeviceName(device) + // A SoftMotion drive's name becomes the axis variable name in generated + // code, so it must be a valid IEC identifier from the start. + const rawName = getShortDeviceName(device) + const baseName = enriched.cia402?.enabled ? sanitizeAxisName(rawName) : rawName const uniqueName = generateUniqueSlaveName(baseName, collectAllSlaveNames(project.data.remoteDevices)) const newDevice: ConfiguredEtherCATDevice = { diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 91297cdea..66e99ce5a 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -533,7 +533,9 @@ const ProjectTreeLeaf = ({ 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]) + // A SoftMotion drive is an EtherCAT child device too (cia402.enabled) — it + // shares every EtherCAT device action (rename/delete), just a distinct icon. + const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice' || leafLang === 'softMotionDrive', [leafLang]) const { LeafIcon } = LeafSources[leafLang] const { file: associatedFile } = getFile({ name: label || '' }) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index b8d013888..3a44478e8 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -932,6 +932,18 @@ describe('createSharedSlice', () => { const result = store.getState().ethercatDeviceActions.rename('bus1', 'missing-slave', 'X') expect(result).toEqual({ ok: false, message: 'EtherCAT device not found' }) }) + + it('rejects an invalid IEC identifier for a SoftMotion (CiA 402) drive', () => { + addEthercatBus('bus3', [ + { id: 'axis-1', name: 'X_Axis', cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 } }, + ] as never) + const bad = store.getState().ethercatDeviceActions.rename('bus3', 'axis-1', 'ASDA-A2-E') + expect(bad.ok).toBe(false) + expect(bad.message).toContain('valid axis name') + // A valid identifier is accepted. + const good = store.getState().ethercatDeviceActions.rename('bus3', 'axis-1', 'Y_Axis') + expect(good.ok).toBe(true) + }) }) }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e21eea76c..488f1ae21 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 { isValidIecIdentifier } from '../../../../backend/shared/ethercat/generate-softmotion' 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' @@ -393,6 +394,15 @@ const createSharedSlice: StateCreator = (s if (!device) return { ok: false, message: 'EtherCAT device not found' } const oldName = device.name + // A SoftMotion drive's name IS the axis variable name emitted into + // generated code, so it must be a valid IEC identifier (no spaces, + // hyphens, or leading digits). + if (device.cia402?.enabled && !isValidIecIdentifier(newName)) { + return { + ok: false, + message: `"${newName}" is not a valid axis name. Use letters, digits, and underscores, starting with a letter or underscore.`, + } + } // 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. From c2aa65eadc9304d4c3b699451cd6b9fe1ad385df Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Jul 2026 08:35:18 -0400 Subject: [PATCH 4/8] feat(softmotion): ST LSP recognizes generated axis globals (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror. The LSP now synthesizes a `VAR_GLOBAL : AXIS_REF_SM3` document and injects the same axis VAR_EXTERNAL the compiler generates (via the shared injectAxisExternals, now covering function blocks too), so editor code that names a SoftMotion axis — `MC_Power(Axis := X_Axis)` — resolves instead of flagging the axis as undeclared. Also fixes the compile gap where axis references inside function blocks didn't get their VAR_EXTERNAL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 72 ++++++++++++++++- .../shared/ethercat/generate-softmotion.ts | 79 +++++++++++++++---- src/frontend/services/st-lsp/project-sync.ts | 69 ++++++++++++++-- src/frontend/services/st-lsp/types.ts | 9 +++ 4 files changed, 204 insertions(+), 25 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index a218ca9e9..b1aad441a 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -9,10 +9,13 @@ 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') @@ -76,6 +79,52 @@ describe('generateSoftMotionArtifacts', () => { 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 VAR_GLOBAL of type AXIS_REF_SM3', () => { + const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) + expect(st).toContain('VAR_GLOBAL') + expect(st).toContain('X_Axis : AXIS_REF_SM3;') + expect(st).toContain('END_VAR') + expect(st).toContain('CONFIGURATION') + }) + }) + it('is a no-op when the CiA 402 device is disabled', () => { const dev = makeDevice('X_Axis') dev.cia402 = { ...dev.cia402!, enabled: false } @@ -165,17 +214,32 @@ describe('generateSoftMotionArtifacts', () => { expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) }) - it('leaves non-program POUs untouched', () => { + it('injects the axis external into a function block that references it', () => { const project = makeProject([makeDevice('X_Axis')]) project.pous.push({ - name: 'helper', + name: 'MotionFB', pouType: 'function-block', interface: { variables: [] }, body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, }) const out = generateSoftMotionArtifacts(project) - const helper = out.pous.find((p) => p.name === 'helper')! - expect(helper.interface!.variables.some((v) => v.name === 'X_Axis')).toBe(false) + 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)', () => { diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index fd2d550b9..cec4ae9c9 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -148,6 +148,66 @@ function collectAxes(project: PLCProjectData): AxisPlan[] { 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. + * + * Only the axis references are declared (not the located PDO scalar globals), + * because 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. + */ +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 [ + 'CONFIGURATION __softmotion_axes__', + 'VAR_GLOBAL', + decls, + 'END_VAR', + 'RESOURCE __softmotion_res__ ON PLC', + 'END_RESOURCE', + 'END_CONFIGURATION', + '', + ].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. @@ -199,20 +259,11 @@ export function generateSoftMotionArtifacts(project: PLCProjectData): PLCProject documentation: 'Auto-generated SoftMotion drive bridge — do not edit; regenerated each compile.', } - // Inject a VAR_EXTERNAL for each axis into user programs that reference it, so - // `MC_*(Axis := X_Axis)` resolves without the user declaring the global. - const patchedPous = project.pous.map((pou) => { - if (pou.pouType !== 'program') return pou - const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) - const toAdd = axes - .filter((a) => !declared.has(a.axisName.toUpperCase()) && bodyReferences(pou.body.value, a.axisName)) - .map((a) => external(a.axisName, 'AXIS_REF_SM3', 'derived')) - if (toAdd.length === 0) return pou - return { - ...pou, - interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, - } - }) + // 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 diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index 6ac456e87..4ed1ac1e5 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -25,12 +25,17 @@ * `refreshStlibs()` on the service. */ -import type { PLCDataType, PLCPou } from '../../../middleware/shared/ports/types' +import { + injectAxisExternals, + serializeSoftMotionAxisGlobalsToST, + softMotionAxisNames, +} from '../../../backend/shared/ethercat/generate-softmotion' +import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' import { serializePouSignatureToSTWithBodyOffset } from '../../utils/PLC/pou-signature-serializer' import { deleteBodyLineOffset, setBodyLineOffset } from '../lsp-shared/body-offsets' -import { DATA_TYPES_URI, pouUri, type StLspService, stubUri } from './types' +import { DATA_TYPES_URI, pouUri, SOFTMOTION_GLOBALS_URI, type StLspService, stubUri } from './types' /** * Determines whether a POU's source goes through the live-body @@ -102,20 +107,55 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { snapshot.contentByUri.set(DATA_TYPES_URI, nextText) } + // Single fixed-URI document carrying `VAR_GLOBAL : AXIS_REF_SM3` for + // every recognized CiA 402 drive, so editor code that names an axis resolves + // it (the LSP analyses every open document together, and AXIS_REF_SM3 comes + // from the bundled stlib). Mirrors reconcileDataTypes. + function reconcileSoftMotionGlobals(remoteDevices: PLCRemoteDevice[] | undefined): void { + if (disposed) return + const nextText = serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never) + const previousText = snapshot.contentByUri.get(SOFTMOTION_GLOBALS_URI) + if (nextText.length === 0) { + if (previousText !== undefined) { + service.closeDocument(SOFTMOTION_GLOBALS_URI) + snapshot.contentByUri.delete(SOFTMOTION_GLOBALS_URI) + } + return + } + if (previousText === undefined) { + service.openDocument(SOFTMOTION_GLOBALS_URI, nextText) + } else if (previousText !== nextText) { + snapshot.version += 1 + service.changeDocument(SOFTMOTION_GLOBALS_URI, nextText, snapshot.version) + } + snapshot.contentByUri.set(SOFTMOTION_GLOBALS_URI, nextText) + } + function reconcile(pous: PLCPou[]): void { if (disposed) return const seenNames = new Set() const seenUris = new Set() - // The data-types document survives every POU reconcile — mark its - // URI as seen so the catch-all cleanup loop below doesn't close it. + // The data-types and SoftMotion-globals documents survive every POU + // reconcile — mark their URIs as seen so the catch-all cleanup loop below + // doesn't drop them. seenUris.add(DATA_TYPES_URI) + seenUris.add(SOFTMOTION_GLOBALS_URI) + + // Inject the same axis VAR_EXTERNALs the compiler generates, so a POU that + // names an axis (`MC_*(Axis := X_Axis)`) resolves it in the editor exactly + // as it will at compile time. Derived from the live remote-device set. + const axisNames = softMotionAxisNames({ + remoteDevices: openPLCStoreBase.getState().project.data.remoteDevices, + } as never) for (const pou of pous) { seenNames.add(pou.name) const nextUri = uriForPou(pou) const previousUri = snapshot.uriByName.get(pou.name) - const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou) + const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset( + injectAxisExternals(pou, axisNames), + ) // POU name unchanged but URI switched (body language change). // Send didClose for the previous URI before didOpen on the new. @@ -175,17 +215,31 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { (state) => state.project.data.dataTypes, (dataTypes) => reconcileDataTypes(dataTypes), ) + // SoftMotion axis globals derive from the EtherCAT remote devices — adding, + // renaming, or enabling a CiA 402 drive must refresh the synthesized globals + // doc so editor code resolves the new axis without a POU edit. + const unsubscribeRemoteDevices = openPLCStoreBase.subscribe( + (state) => state.project.data.remoteDevices, + (remoteDevices) => { + reconcileSoftMotionGlobals(remoteDevices) + // Axis set changed → re-publish POUs so their injected VAR_EXTERNALs + // pick up added/removed/renamed axes without waiting on a POU edit. + reconcile(openPLCStoreBase.getState().project.data.pous) + }, + ) // Initial reconcile against whatever is already in the store. The - // data types load first so any POU that references them resolves - // on the first didOpen, not on a follow-up didChange. + // data types and axis globals load first so any POU that references + // them resolves on the first didOpen, not on a follow-up didChange. reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) return { resync() { if (disposed) return reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) }, forceResync() { @@ -204,6 +258,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { disposed = true unsubscribePous() unsubscribeDataTypes() + unsubscribeRemoteDevices() // Close every doc we'd opened so the worker stays consistent // if the service is restarted in the same session. for (const uri of snapshot.contentByUri.keys()) { diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index a5a6e721c..f8454fa30 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -40,6 +40,15 @@ export const POUVARS_URI_AUTHORITY = 'pouvars' */ export const DATA_TYPES_URI = 'inmemory://datatypes/__project__.st' +/** + * URI for the synthesized SoftMotion axis globals document — a + * `VAR_GLOBAL : AXIS_REF_SM3` per recognized CiA 402 drive, so editor + * code referencing an axis (`MC_Power(Axis := X_Axis)`) resolves against the + * same public axis the compiler generates, without the user declaring it. + * Single fixed URI per session; the axis set is project-global. + */ +export const SOFTMOTION_GLOBALS_URI = 'inmemory://softmotion/__axes__.st' + /** Public service the rest of the renderer talks to. */ export interface StLspService { /** From 5a6c023f4ec28e64d05847df7af2e0977887d3ef Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Jul 2026 12:42:10 -0400 Subject: [PATCH 5/8] refactor(softmotion): axis as ambient global for the LSP; no VAR_EXTERNAL injection (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror. The LSP now surfaces each axis as a bare top-level VAR_GLOBAL block (ambient global — resolves with no VAR_EXTERNAL), POUs are serialised verbatim (no injected declarations shifting line numbers, so go-to-definition stays correct), and go-to-definition on an axis redirects to the owning drive's device editor. VAR_EXTERNAL injection remains compile-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 17 ++++- .../shared/ethercat/generate-softmotion.ts | 28 ++++---- .../goto-definition-redirect.test.ts | 69 +++++++++++++++++++ .../st-lsp/goto-definition-redirect.ts | 61 +++++++++++++++- src/frontend/services/st-lsp/project-sync.ts | 28 ++------ 5 files changed, 164 insertions(+), 39 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index b1aad441a..3bfbe0d2d 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -116,12 +116,23 @@ describe('generateSoftMotionArtifacts', () => { expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') }) - it('declares each axis as a VAR_GLOBAL of type AXIS_REF_SM3', () => { + 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('VAR_GLOBAL') 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).toContain('CONFIGURATION') + 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]) }) }) diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index cec4ae9c9..3e87b5ca6 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -186,26 +186,26 @@ export function injectAxisExternals(pou: PLCPou, axisNames: string[]): PLCPou { * against the same public axis the compiler generates — without the user * declaring anything. Returns '' when the project has no axes. * - * Only the axis references are declared (not the located PDO scalar globals), - * because 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. + * 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 [ - 'CONFIGURATION __softmotion_axes__', - 'VAR_GLOBAL', - decls, - 'END_VAR', - 'RESOURCE __softmotion_res__ ON PLC', - 'END_RESOURCE', - 'END_CONFIGURATION', - '', - ].join('\n') + return ['VAR_GLOBAL', decls, 'END_VAR', ''].join('\n') } /** diff --git a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts index b53e0f14d..cc960ef63 100644 --- a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts +++ b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts @@ -35,6 +35,7 @@ function setProjectPous(pous: PLCPou[]) { ...s.project.data, pous, dataTypes: [], + remoteDevices: [], }, }, editor: { type: 'available', meta: { name: 'available' } }, @@ -186,4 +187,72 @@ describe('redirectDefinitionToStore', () => { expect(state.editor.variable.display).toBe('table') } }) + + it('redirects a SoftMotion axis global to the owning drive editor', () => { + // Minimal CiA 402 drive: controlWord (0x6040 out) + statusWord (0x6041 in) + // mapped, so collectAxes/softMotionAxisNames recognise it as an axis. + openPLCStoreBase.setState((s) => ({ + ...s, + project: { + ...s.project, + data: { + ...s.project.data, + pous: [], + dataTypes: [], + remoteDevices: [ + { + name: 'eth', + protocol: 'ethercat', + ethercatConfig: { + devices: [ + { + id: 'd1', + name: 'My_Axis', + cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 }, + channelInfo: [ + { channelId: 'c1', entryIndex: '0x6040', direction: 'output', iecType: 'UINT' }, + { channelId: 'c2', entryIndex: '0x6041', direction: 'input', iecType: 'UINT' }, + ], + channelMappings: [ + { channelId: 'c1', iecLocation: '%QW0', alias: '' }, + { channelId: 'c2', iecLocation: '%IW0', alias: '' }, + ], + }, + ], + }, + }, + ], + } as never, + }, + editor: { type: 'available', meta: { name: 'available' } }, + editors: [], + tabs: [], + selectedTab: null, + })) + + // Line 0 of the globals doc is `VAR_GLOBAL`; line 1 is the first axis. + const handled = redirectDefinitionToStore({ + uri: 'inmemory://softmotion/__axes__.st', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 9 } }, + }) + + expect(handled).toBe(true) + const state = openPLCStoreBase.getState() + expect(state.editor.type).toBe('plc-ethercat-device') + expect(state.editor.meta.name).toBe('My_Axis') + if (state.editor.type === 'plc-ethercat-device') { + expect(state.editor.meta.busName).toBe('eth') + expect(state.editor.meta.deviceId).toBe('d1') + } + }) + + it('returns false for a SoftMotion globals line with no matching axis', () => { + setProjectPous([]) + expect( + redirectDefinitionToStore({ + uri: 'inmemory://softmotion/__axes__.st', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } }, + }), + ).toBe(false) + }) }) diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index 01d0f65c6..c3c3a3e91 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -35,13 +35,14 @@ import type { Location, LocationLink } from 'vscode-languageserver-protocol' +import { sanitizeAxisName, softMotionAxisNames } from '../../../backend/shared/ethercat/generate-softmotion' import type { PLCDataType } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' import { serializeDataTypesToLines } from '../../utils/PLC/data-type-serializer' import { getBodyLineOffset } from '../lsp-shared/body-offsets' import { normaliseLocation, routeToPou, routeToPouBody, routeToPouPreamble } from '../lsp-shared/definition-redirect' -import { DATA_TYPES_URI, parsePouUri } from './types' +import { DATA_TYPES_URI, parsePouUri, SOFTMOTION_GLOBALS_URI } from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -102,9 +103,67 @@ function openDataTypeEditor(dataType: PLCDataType): boolean { return true } +/** + * Open the EtherCAT device (drive) editor for `deviceId` on `busName`, mirroring + * the project-tree click path. Used to redirect go-to-definition on a SoftMotion + * axis to its drive configuration screen instead of the synthesised globals doc. + */ +function openDeviceEditor(name: string, busName: string, deviceId: string): boolean { + const tabProps: Parameters[0] = { + name, + path: `/devices/remote/${busName}/devices/${deviceId}`, + elementType: { type: 'ethercat-device', busName, deviceId }, + } + const { + editorActions: { setEditor, addModel, getEditorFromEditors }, + tabsActions: { updateTabs, setSelectedTab }, + } = openPLCStoreBase.getState() + updateTabs(tabProps) + const existing = getEditorFromEditors(name) + if (existing) { + addModel(existing) + setEditor(existing) + } else { + const model = CreateEditorObjectFromTab(tabProps) + addModel(model) + setEditor(model) + } + setSelectedTab(name) + return true +} + +/** + * Redirect a go-to-definition landing in the synthesised SoftMotion axis-globals + * document to the drive that owns the axis. The document is a bare `VAR_GLOBAL` + * block: line 0 is `VAR_GLOBAL`, line N (1-based) is axis N-1 — the same order + * as `softMotionAxisNames`. Returns false when the line doesn't map to an axis + * or no drive matches (caller falls back to Monaco's default). + */ +function redirectSoftMotionAxis(lspLine: number): boolean { + if (lspLine < 1) return false + const data = openPLCStoreBase.getState().project.data + const axisName = softMotionAxisNames(data)[lspLine - 1] + if (!axisName) return false + for (const rd of data.remoteDevices ?? []) { + if (rd.protocol !== 'ethercat') continue + for (const dev of rd.ethercatConfig?.devices ?? []) { + if (sanitizeAxisName(dev.name) === axisName) { + return openDeviceEditor(dev.name, rd.name, dev.id) + } + } + } + return false +} + export function redirectDefinitionToStore(loc: Location | LocationLink): boolean { const target = normaliseLocation(loc) + // SoftMotion axis globals doc → open the owning drive's config screen rather + // than the synthesised (non-editable) global declaration. + if (target.uri === SOFTMOTION_GLOBALS_URI) { + return redirectSoftMotionAxis(target.lineLsp) + } + // Datatypes URI → open the matching data-type editor tab. The LSP // emits this URI for every reference into the synthesised // TYPE…END_TYPE block (enum members, struct fields, array element diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index 4ed1ac1e5..d259308e0 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -25,11 +25,7 @@ * `refreshStlibs()` on the service. */ -import { - injectAxisExternals, - serializeSoftMotionAxisGlobalsToST, - softMotionAxisNames, -} from '../../../backend/shared/ethercat/generate-softmotion' +import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion' import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' @@ -142,20 +138,15 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { seenUris.add(DATA_TYPES_URI) seenUris.add(SOFTMOTION_GLOBALS_URI) - // Inject the same axis VAR_EXTERNALs the compiler generates, so a POU that - // names an axis (`MC_*(Axis := X_Axis)`) resolves it in the editor exactly - // as it will at compile time. Derived from the live remote-device set. - const axisNames = softMotionAxisNames({ - remoteDevices: openPLCStoreBase.getState().project.data.remoteDevices, - } as never) - for (const pou of pous) { seenNames.add(pou.name) const nextUri = uriForPou(pou) const previousUri = snapshot.uriByName.get(pou.name) - const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset( - injectAxisExternals(pou, axisNames), - ) + // POUs are serialised verbatim — no axis VAR_EXTERNAL injection here. + // Axes are surfaced as ambient globals (SOFTMOTION_GLOBALS_URI), so the + // editor documents keep the exact line layout the user wrote and + // go-to-definition line mapping stays correct. + const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou) // POU name unchanged but URI switched (body language change). // Send didClose for the previous URI before didOpen on the new. @@ -220,12 +211,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { // doc so editor code resolves the new axis without a POU edit. const unsubscribeRemoteDevices = openPLCStoreBase.subscribe( (state) => state.project.data.remoteDevices, - (remoteDevices) => { - reconcileSoftMotionGlobals(remoteDevices) - // Axis set changed → re-publish POUs so their injected VAR_EXTERNALs - // pick up added/removed/renamed axes without waiting on a POU edit. - reconcile(openPLCStoreBase.getState().project.data.pous) - }, + (remoteDevices) => reconcileSoftMotionGlobals(remoteDevices), ) // Initial reconcile against whatever is already in the store. The From 26d4894c364cb61afa7373b155e73358e9898371 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Jul 2026 14:13:53 -0400 Subject: [PATCH 6/8] fix(lsp): sync resource globals so user VAR_EXTERNAL declarations resolve (mirror of openplc-web) Byte-identical mirror. The LSP now sends the project's configuration-level globals as a CONFIGURATION VAR_GLOBAL doc (the level the compiler emits at, and the only form strucpp matches a VAR_EXTERNAL against), so a POU's VAR_EXTERNAL no longer falsely errors. Extracted a shared reconcileSyntheticDoc engine used by all three synthesized docs (data types, resource globals, softmotion axes), and go-to-definition on a user global opens the Resource editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../goto-definition-redirect.test.ts | 12 +++ .../st-lsp/goto-definition-redirect.ts | 37 +++++++- src/frontend/services/st-lsp/project-sync.ts | 90 +++++++++++-------- src/frontend/services/st-lsp/types.ts | 8 ++ .../resource-globals-serializer.test.ts | 38 ++++++++ .../utils/PLC/resource-globals-serializer.ts | 39 ++++++++ 6 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts create mode 100644 src/frontend/utils/PLC/resource-globals-serializer.ts diff --git a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts index cc960ef63..4d06cbd12 100644 --- a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts +++ b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts @@ -246,6 +246,18 @@ describe('redirectDefinitionToStore', () => { } }) + it('redirects a resource-global to the Resource editor', () => { + setProjectPous([]) + const handled = redirectDefinitionToStore({ + uri: 'inmemory://globals/__resource__.st', + range: { start: { line: 2, character: 4 }, end: { line: 2, character: 15 } }, + }) + expect(handled).toBe(true) + const state = openPLCStoreBase.getState() + expect(state.editor.type).toBe('plc-resource') + expect(state.editor.meta.name).toBe('Resource') + }) + it('returns false for a SoftMotion globals line with no matching axis', () => { setProjectPous([]) expect( diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index c3c3a3e91..5e7364940 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -42,7 +42,7 @@ import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' import { serializeDataTypesToLines } from '../../utils/PLC/data-type-serializer' import { getBodyLineOffset } from '../lsp-shared/body-offsets' import { normaliseLocation, routeToPou, routeToPouBody, routeToPouPreamble } from '../lsp-shared/definition-redirect' -import { DATA_TYPES_URI, parsePouUri, SOFTMOTION_GLOBALS_URI } from './types' +import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -155,9 +155,44 @@ function redirectSoftMotionAxis(lspLine: number): boolean { return false } +/** + * Open the Resource editor (where configuration-level globals are declared), + * mirroring the project-tree click path. Used to redirect go-to-definition on a + * user global to the globals table instead of the synthesised globals doc. + */ +function openResourceEditor(): boolean { + const tabProps: Parameters[0] = { + name: 'Resource', + path: '/data/configuration/resource', + elementType: { type: 'resource' }, + } + const { + editorActions: { setEditor, addModel, getEditorFromEditors }, + tabsActions: { updateTabs, setSelectedTab }, + } = openPLCStoreBase.getState() + updateTabs(tabProps) + const existing = getEditorFromEditors('Resource') + if (existing) { + addModel(existing) + setEditor(existing) + } else { + const model = CreateEditorObjectFromTab(tabProps) + addModel(model) + setEditor(model) + } + setSelectedTab('Resource') + return true +} + export function redirectDefinitionToStore(loc: Location | LocationLink): boolean { const target = normaliseLocation(loc) + // Resource-globals doc → open the Resource editor (globals table) rather than + // the synthesised (non-editable) CONFIGURATION declaration. + if (target.uri === RESOURCE_GLOBALS_URI) { + return openResourceEditor() + } + // SoftMotion axis globals doc → open the owning drive's config screen rather // than the synthesised (non-editable) global declaration. if (target.uri === SOFTMOTION_GLOBALS_URI) { diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index d259308e0..dd7e1e692 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -26,12 +26,20 @@ */ import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion' -import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCRemoteDevice, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' import { serializePouSignatureToSTWithBodyOffset } from '../../utils/PLC/pou-signature-serializer' +import { serializeResourceGlobalsToST } from '../../utils/PLC/resource-globals-serializer' import { deleteBodyLineOffset, setBodyLineOffset } from '../lsp-shared/body-offsets' -import { DATA_TYPES_URI, pouUri, SOFTMOTION_GLOBALS_URI, type StLspService, stubUri } from './types' +import { + DATA_TYPES_URI, + pouUri, + RESOURCE_GLOBALS_URI, + SOFTMOTION_GLOBALS_URI, + type StLspService, + stubUri, +} from './types' /** * Determines whether a POU's source goes through the live-body @@ -79,52 +87,46 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { const snapshot = emptySnapshot() let disposed = false - function reconcileDataTypes(dataTypes: PLCDataType[]): void { + // Reconcile a single fixed-URI synthesized document (data types, resource + // globals, softmotion axes …) against the worker: open on first non-empty + // text, didChange on a text change, didClose when it becomes empty. Centralised + // so every synthesized doc shares one diff engine instead of copy-pasting it. + function reconcileSyntheticDoc(uri: string, nextText: string): void { if (disposed) return - // Single fixed-URI document that carries the whole TYPE block. - // Empty `dataTypes` (or types that all serialise to nothing) → - // close any previously-open document so strucpp doesn't keep a - // stale set around. - const nextText = serializeDataTypesToST(dataTypes) - const previousText = snapshot.contentByUri.get(DATA_TYPES_URI) + const previousText = snapshot.contentByUri.get(uri) if (nextText.length === 0) { if (previousText !== undefined) { - service.closeDocument(DATA_TYPES_URI) - snapshot.contentByUri.delete(DATA_TYPES_URI) + service.closeDocument(uri) + snapshot.contentByUri.delete(uri) } return } if (previousText === undefined) { - service.openDocument(DATA_TYPES_URI, nextText) + service.openDocument(uri, nextText) } else if (previousText !== nextText) { snapshot.version += 1 - service.changeDocument(DATA_TYPES_URI, nextText, snapshot.version) + service.changeDocument(uri, nextText, snapshot.version) } - snapshot.contentByUri.set(DATA_TYPES_URI, nextText) + snapshot.contentByUri.set(uri, nextText) + } + + // The whole `TYPE … END_TYPE` block, so any POU that references a user data + // type resolves it. + function reconcileDataTypes(dataTypes: PLCDataType[]): void { + reconcileSyntheticDoc(DATA_TYPES_URI, serializeDataTypesToST(dataTypes)) + } + + // The project's configuration-level `VAR_GLOBAL`s wrapped in a CONFIGURATION, + // so a POU's `VAR_EXTERNAL` resolves against a matching global. + function reconcileResourceGlobals(globals: PLCVariable[]): void { + reconcileSyntheticDoc(RESOURCE_GLOBALS_URI, serializeResourceGlobalsToST(globals)) } - // Single fixed-URI document carrying `VAR_GLOBAL : AXIS_REF_SM3` for - // every recognized CiA 402 drive, so editor code that names an axis resolves - // it (the LSP analyses every open document together, and AXIS_REF_SM3 comes - // from the bundled stlib). Mirrors reconcileDataTypes. + // A `VAR_GLOBAL : AXIS_REF_SM3` per recognized CiA 402 drive, so editor + // code that names an axis resolves it (AXIS_REF_SM3 comes from the bundled + // stlib the LSP already ingests). function reconcileSoftMotionGlobals(remoteDevices: PLCRemoteDevice[] | undefined): void { - if (disposed) return - const nextText = serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never) - const previousText = snapshot.contentByUri.get(SOFTMOTION_GLOBALS_URI) - if (nextText.length === 0) { - if (previousText !== undefined) { - service.closeDocument(SOFTMOTION_GLOBALS_URI) - snapshot.contentByUri.delete(SOFTMOTION_GLOBALS_URI) - } - return - } - if (previousText === undefined) { - service.openDocument(SOFTMOTION_GLOBALS_URI, nextText) - } else if (previousText !== nextText) { - snapshot.version += 1 - service.changeDocument(SOFTMOTION_GLOBALS_URI, nextText, snapshot.version) - } - snapshot.contentByUri.set(SOFTMOTION_GLOBALS_URI, nextText) + reconcileSyntheticDoc(SOFTMOTION_GLOBALS_URI, serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never)) } function reconcile(pous: PLCPou[]): void { @@ -132,10 +134,11 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { const seenNames = new Set() const seenUris = new Set() - // The data-types and SoftMotion-globals documents survive every POU - // reconcile — mark their URIs as seen so the catch-all cleanup loop below - // doesn't drop them. + // The synthesized documents (data types, resource globals, SoftMotion axes) + // survive every POU reconcile — mark their URIs as seen so the catch-all + // cleanup loop below doesn't drop them. seenUris.add(DATA_TYPES_URI) + seenUris.add(RESOURCE_GLOBALS_URI) seenUris.add(SOFTMOTION_GLOBALS_URI) for (const pou of pous) { @@ -206,6 +209,12 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { (state) => state.project.data.dataTypes, (dataTypes) => reconcileDataTypes(dataTypes), ) + // Resource globals live under the configuration and change independently of + // POUs, so a POU's VAR_EXTERNAL resolves without waiting on a POU edit. + const unsubscribeResourceGlobals = openPLCStoreBase.subscribe( + (state) => state.project.data.configurations.resource.globalVariables, + (globals) => reconcileResourceGlobals(globals), + ) // SoftMotion axis globals derive from the EtherCAT remote devices — adding, // renaming, or enabling a CiA 402 drive must refresh the synthesized globals // doc so editor code resolves the new axis without a POU edit. @@ -215,9 +224,10 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { ) // Initial reconcile against whatever is already in the store. The - // data types and axis globals load first so any POU that references + // synthesized globals/types load first so any POU that references // them resolves on the first didOpen, not on a follow-up didChange. reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileResourceGlobals(openPLCStoreBase.getState().project.data.configurations.resource.globalVariables) reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) @@ -225,6 +235,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { resync() { if (disposed) return reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileResourceGlobals(openPLCStoreBase.getState().project.data.configurations.resource.globalVariables) reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) }, @@ -244,6 +255,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { disposed = true unsubscribePous() unsubscribeDataTypes() + unsubscribeResourceGlobals() unsubscribeRemoteDevices() // Close every doc we'd opened so the worker stays consistent // if the service is restarted in the same session. diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index f8454fa30..ee506b6d5 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -49,6 +49,14 @@ export const DATA_TYPES_URI = 'inmemory://datatypes/__project__.st' */ export const SOFTMOTION_GLOBALS_URI = 'inmemory://softmotion/__axes__.st' +/** + * URI for the synthesized resource-globals document — the project's + * configuration-level `VAR_GLOBAL`s wrapped in a `CONFIGURATION` block, so a + * POU's `VAR_EXTERNAL` resolves against a matching global (strucpp requires a + * configuration-scoped global for that). Single fixed URI per session. + */ +export const RESOURCE_GLOBALS_URI = 'inmemory://globals/__resource__.st' + /** Public service the rest of the renderer talks to. */ export interface StLspService { /** diff --git a/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts b/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts new file mode 100644 index 000000000..d7bc53a2c --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts @@ -0,0 +1,38 @@ +import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { serializeResourceGlobalsToST } from '../resource-globals-serializer' + +function global(name: string, typeValue: string, extra: Partial = {}): PLCVariable { + return { name, type: { definition: 'base-type', value: typeValue }, location: '', documentation: '', ...extra } +} + +describe('serializeResourceGlobalsToST', () => { + it('returns empty string when there are no globals', () => { + expect(serializeResourceGlobalsToST([])).toBe('') + }) + + it('wraps the globals in a CONFIGURATION so VAR_EXTERNAL resolves', () => { + const st = serializeResourceGlobalsToST([global('test_global', 'DINT')]) + expect(st).toContain('CONFIGURATION') + expect(st).toContain('VAR_GLOBAL') + expect(st).toContain('test_global : DINT;') + expect(st).toContain('END_VAR') + expect(st).toContain('RESOURCE') + expect(st).toContain('END_CONFIGURATION') + // Globals must be at the CONFIGURATION level, before the RESOURCE block. + expect(st.indexOf('VAR_GLOBAL')).toBeLessThan(st.indexOf('RESOURCE')) + }) + + it('emits a single VAR_GLOBAL block even if a stored global carries a stray class', () => { + const st = serializeResourceGlobalsToST([global('a', 'INT', { class: 'external' }), global('b', 'BOOL')]) + // Both land in one VAR_GLOBAL block; no VAR_EXTERNAL leaks in. + expect(st).not.toContain('VAR_EXTERNAL') + expect((st.match(/VAR_GLOBAL/g) ?? []).length).toBe(1) + expect(st).toContain('a : INT;') + expect(st).toContain('b : BOOL;') + }) + + it('preserves location and initial value', () => { + const st = serializeResourceGlobalsToST([global('counter', 'INT', { location: '%MW0', initialValue: '5' })]) + expect(st).toContain('counter : INT AT %MW0 := 5;') + }) +}) diff --git a/src/frontend/utils/PLC/resource-globals-serializer.ts b/src/frontend/utils/PLC/resource-globals-serializer.ts new file mode 100644 index 000000000..f6b6c39eb --- /dev/null +++ b/src/frontend/utils/PLC/resource-globals-serializer.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +import type { PLCVariable } from '../../../middleware/shared/ports/types' +import { generateIecVariablesToString } from '../generate-iec-variables-to-string' + +const GLOBALS_CONFIG_NAME = '__globals_cfg__' +const GLOBALS_RESOURCE_NAME = '__globals_res__' + +/** + * Serialize the project's resource-level global variables as a standalone + * `CONFIGURATION` document for the language server, so a POU's `VAR_EXTERNAL` + * resolves against a matching `VAR_GLOBAL` instead of being flagged as having no + * global declaration. + * + * Two things pin the shape: + * - strucpp only matches a `VAR_EXTERNAL` against a global declared inside a + * `CONFIGURATION` — a bare top-level `VAR_GLOBAL` block does NOT satisfy it. + * - the compiler itself emits user globals at the `CONFIGURATION` level (see + * `st-transpiler/emit/configuration.ts`), so validating against this shape + * matches exactly what gets generated. + * + * The `VAR_GLOBAL` block is produced by `generateIecVariablesToString` — the + * same formatter the variables editor uses — so declarations stay in lockstep + * with how variables render everywhere else. Returns '' when there are none. + */ +export function serializeResourceGlobalsToST(globals: PLCVariable[]): string { + if (!globals || globals.length === 0) return '' + // Force the global class so the shared formatter always emits a single + // VAR_GLOBAL block, regardless of any stray class on the stored variable. + const varBlock = generateIecVariablesToString(globals.map((g) => ({ ...g, class: 'global' }))) + return [ + `CONFIGURATION ${GLOBALS_CONFIG_NAME}`, + varBlock, + ` RESOURCE ${GLOBALS_RESOURCE_NAME} ON PLC`, + ' END_RESOURCE', + 'END_CONFIGURATION', + '', + ].join('\n') +} From cc34abe43e17bdf7c6eadfc14c964b66da0a7f39 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 16 Jul 2026 10:55:02 -0400 Subject: [PATCH 7/8] fix(debugger): resolve VAR_EXTERNAL globals to one canonical watch An FB/program VAR_EXTERNAL is a reference to a single CONFIGURATION VAR_GLOBAL, but the debugger keyed each reference per-POU/instance. A global watched from a function block therefore showed `-` (no value) and appeared as duplicate rows (e.g. `test_global` + `main.MANUAL_OVERRIDE0.test_global`). Give every external reference one canonical, POU-independent identity (`Config0:`, displayed `Config0.`) across all four paths that treated externals per-POU: - debug tree: surface FB VAR_EXTERNAL members (findFunctionBlockExternalVariables) and resolve them (plus program externals) to the canonical global key; drop the now-redundant external special-case in buildDebugTree. - poller: poll external watches by the canonical key, instance-independent. - watch panel (allDebugVariables): canonical key/display + dedup by key. - store: sync the debug (watch) flag across the global definition and every VAR_EXTERNAL reference so the debug icon toggles everywhere at once. Externals now resolve to the shared global's value and dedup to a single `Config0.` entry regardless of where they are referenced. Validated in-browser (simulator) + web vitest / editor jest (544 each), 100% coverage on gated files. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/screens/workspace-screen.tsx | 110 ++++++++++-------- .../store/__tests__/project-slice.test.ts | 52 +++++++++ src/frontend/store/slices/project/slice.ts | 35 ++++++ .../__tests__/debug-polling-filter.test.ts | 23 ++++ .../__tests__/debug-tree-builder.test.ts | 6 +- .../__tests__/debug-tree-traversal.test.ts | 28 ++++- .../__tests__/debug-variable-finder.test.ts | 13 +++ .../utils/__tests__/debugger-session.test.ts | 20 ++-- .../utils/__tests__/pou-helpers.test.ts | 49 +++++++- src/frontend/utils/debug-polling-filter.ts | 16 ++- src/frontend/utils/debug-tree-builder.ts | 40 +------ src/frontend/utils/debug-tree-traversal.ts | 29 ++++- src/frontend/utils/debug-variable-finder.ts | 19 +++ src/frontend/utils/pou-helpers.ts | 30 ++++- 14 files changed, 372 insertions(+), 98 deletions(-) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index ae2fda3fe..158f57d0f 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -55,6 +55,7 @@ import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' +import { buildGlobalCompositeKey, GLOBAL_CONFIG_NAME } from '../utils/debug-variable-finder' import { toast } from '../utils/toast' const WorkspaceScreen = () => { @@ -153,59 +154,74 @@ const WorkspaceScreen = () => { useRuntimePolling() // Build debug variables from POUs with debug=true - const allDebugVariables = useMemo( - () => - pous.flatMap((pou) => { - const variables = pou.interface?.variables ?? [] - return variables - .filter((v) => v.debug === true) - .map((v) => { - let typeValue = '' - if (v.type.definition === 'base-type') { - typeValue = v.type.value - } else if (v.type.definition === 'user-data-type') { - typeValue = v.type.value - } else if (v.type.definition === 'array') { - typeValue = v.type.value - } else if (v.type.definition === 'derived') { - typeValue = v.type.value - } - + const allDebugVariables = useMemo(() => { + const rows = pous.flatMap((pou) => { + const variables = pou.interface?.variables ?? [] + return variables + .filter((v) => v.debug === true) + .map((v) => { + let typeValue = '' + if (v.type.definition === 'base-type') { + typeValue = v.type.value + } else if (v.type.definition === 'user-data-type') { + typeValue = v.type.value + } else if (v.type.definition === 'array') { + typeValue = v.type.value + } else if (v.type.definition === 'derived') { + typeValue = v.type.value + } + + let compositeKey: string + let displayName: string + let rowPouName = pou.name + if (v.class === 'external') { + // A VAR_EXTERNAL points at one shared global. Give it the canonical, + // POU/instance-independent identity used by the debug tree + poller so + // it resolves the global's value and every reference collapses to a + // single `Config0.` watch (deduped below). + compositeKey = buildGlobalCompositeKey(v.name) + displayName = `${GLOBAL_CONFIG_NAME}.${v.name}` + rowPouName = GLOBAL_CONFIG_NAME + } else if (pou.pouType === 'function-block') { // For function block POUs, transform the key to use instance context - let compositeKey: string - let displayName: string - if (pou.pouType === 'function-block') { - const fbTypeKey = pou.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - const instances = fbDebugInstances.get(fbTypeKey) ?? [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - - if (selectedInstance) { - compositeKey = `${selectedInstance.programName}:${selectedInstance.fbVariableName}.${v.name}` - displayName = `${selectedInstance.programName}.${selectedInstance.fbVariableName}.${v.name}` - } else { - compositeKey = `${pou.name}:${v.name}` - displayName = v.name - } + const fbTypeKey = pou.name.toUpperCase() + const selectedKey = fbSelectedInstance.get(fbTypeKey) + const instances = fbDebugInstances.get(fbTypeKey) ?? [] + const selectedInstance = instances.find((inst) => inst.key === selectedKey) + + if (selectedInstance) { + compositeKey = `${selectedInstance.programName}:${selectedInstance.fbVariableName}.${v.name}` + displayName = `${selectedInstance.programName}.${selectedInstance.fbVariableName}.${v.name}` } else { compositeKey = `${pou.name}:${v.name}` displayName = v.name } + } else { + compositeKey = `${pou.name}:${v.name}` + displayName = v.name + } + + const variableValue = debugBoolValues.get(compositeKey) ?? debugNonBoolValues.get(compositeKey) + const displayValue = variableValue !== undefined ? variableValue : '-' + + return { + pouName: rowPouName, + name: displayName, + type: typeValue, + value: displayValue, + compositeKey, + } + }) + }) - const variableValue = debugBoolValues.get(compositeKey) ?? debugNonBoolValues.get(compositeKey) - const displayValue = variableValue !== undefined ? variableValue : '-' - - return { - pouName: pou.name, - name: displayName, - type: typeValue, - value: displayValue, - compositeKey, - } - }) - }), - [pous, debugBoolValues, debugNonBoolValues, fbSelectedInstance, fbDebugInstances], - ) + // Collapse duplicates by composite key — a global referenced via VAR_EXTERNAL + // from several POUs (and mirrored by the debug-flag sync) yields one row. + const byKey = new Map() + for (const row of rows) { + if (!byKey.has(row.compositeKey)) byKey.set(row.compositeKey, row) + } + return Array.from(byKey.values()) + }, [pous, debugBoolValues, debugNonBoolValues, fbSelectedInstance, fbDebugInstances]) // Deduplicate names with POU prefix when conflicts exist const debugVariables = useMemo(() => { diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index be7f4e262..045a13ee2 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -842,6 +842,58 @@ describe('createProjectSlice', () => { expect(result.ok).toBe(false) }) + it('syncs a VAR_EXTERNAL debug toggle to the global and every other reference', () => { + // One global, referenced via VAR_EXTERNAL from a program and an FB. + seedGlobals(store, [makeVariable('test_global', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('test_global', 'external')])) + seedPou(store, makePou('Mover', 'function-block', [makeVariable('test_global', 'external')])) + + store.getState().projectActions.updateVariable({ + scope: 'local', + associatedPou: 'Main', + variableId: 'test_global', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.configurations.resource.globalVariables[0].debug).toBe(true) + for (const pou of st.data.pous) { + expect(pou.interface?.variables[0].debug).toBe(true) + } + }) + + it('syncs a global debug toggle down to every VAR_EXTERNAL reference (case-insensitive)', () => { + seedGlobals(store, [makeVariable('Test_Global', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('TEST_GLOBAL', 'external')])) + + store.getState().projectActions.updateVariable({ + scope: 'global', + variableId: 'Test_Global', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.configurations.resource.globalVariables[0].debug).toBe(true) + expect(st.data.pous[0].interface?.variables[0].debug).toBe(true) + }) + + it('does not propagate a local (non-external) debug toggle to a same-named global', () => { + seedGlobals(store, [makeVariable('x', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('x', 'local')])) + + store.getState().projectActions.updateVariable({ + scope: 'local', + associatedPou: 'Main', + variableId: 'x', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.pous[0].interface?.variables[0].debug).toBe(true) + // The unrelated same-named global must stay untouched. + expect(st.data.configurations.resource.globalVariables[0].debug).toBeFalsy() + }) + it('stores the location binding verbatim (alias name or literal) and never auto-adopts', () => { // Single-field model: `location` is the binding. A manual literal is // stored verbatim; an alias name is stored verbatim (NOT auto-resolved diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 67c1949aa..a6c93f670 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -369,6 +369,31 @@ function applyVppEntries( }) } +/** + * Mirror a shared global's debug (watch) flag across every reference to it. + * + * A VAR_EXTERNAL is a pointer to one CONFIGURATION VAR_GLOBAL, so "watch this + * global in the debugger" is a property of the global, not of any single + * reference. Toggling the debug icon on the global's own definition or on any + * POU's VAR_EXTERNAL therefore updates them all in lockstep — so every table + * shows the same icon state and the global polls/displays as one entity + * (matching the canonical Config0: key used by the debug tree and poller). + * + * Mutates the produce draft in place. Case-insensitive on the global name, + * consistent with global-variable lookup elsewhere in this slice. + */ +function syncGlobalDebugFlag(slice: ProjectSlice, globalName: string, debug: boolean): void { + const lower = globalName.toLowerCase() + for (const g of slice.project.data.configurations.resource.globalVariables) { + if (g.name.toLowerCase() === lower) g.debug = debug + } + for (const pou of slice.project.data.pous) { + for (const v of pou.interface?.variables ?? []) { + if (v.class === 'external' && v.name.toLowerCase() === lower) v.debug = debug + } + } +} + const reconcileVariablesText = ( pouName: string | undefined, getState: ProjectGetState, @@ -772,6 +797,16 @@ const createProjectSlice: StateCreator = ...(validationResponse.data ? validationResponse.data : {}), } response.data = variables[found.index] + + // A shared global's watch flag belongs to the global, not to any one + // reference — keep the global's definition and every VAR_EXTERNAL to it + // in sync so the debug icon toggles everywhere at once. + if (updates.debug !== undefined) { + const target = variables[found.index] + if (scope === 'global' || target.class === 'external') { + syncGlobalDebugFlag(slice, target.name, updates.debug) + } + } }), ) if (scope === 'local' && response.ok) regenerateVariablesText(associatedPou, getState) diff --git a/src/frontend/utils/__tests__/debug-polling-filter.test.ts b/src/frontend/utils/__tests__/debug-polling-filter.test.ts index bf3e58c99..8e7d1eedb 100644 --- a/src/frontend/utils/__tests__/debug-polling-filter.test.ts +++ b/src/frontend/utils/__tests__/debug-polling-filter.test.ts @@ -202,6 +202,29 @@ describe('buildActiveIndexSet', () => { const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) }) + + it('polls a program VAR_EXTERNAL watch by its canonical global key', () => { + const pou = makePou('Main', 'program', [makeVariable('SHARED', 'external', true)]) + // The debug tree exposes the shared global under Config0:SHARED. + const allLeaves = new Map([[42, [{ compositeKey: 'Config0:SHARED', type: 'INT' }]]]) + const state = makeState({ pous: [pou] }) + + const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) + expect(activeIndexes).toEqual([42]) + }) + + it('polls an FB VAR_EXTERNAL watch even with no instance selected (globals are instance-independent)', () => { + const fbPou = makePou('MyFB', 'function-block', [ + makeVariable('SHARED', 'external', true), + makeVariable('OUT', 'output', true), + ]) + const allLeaves = new Map([[7, [{ compositeKey: 'Config0:SHARED', type: 'INT' }]]]) + const state = makeState({ pous: [fbPou] }) + + // OUT (local) is skipped for want of an instance; the external still polls. + const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) + expect(activeIndexes).toEqual([7]) + }) }) describe('forced variables', () => { diff --git a/src/frontend/utils/__tests__/debug-tree-builder.test.ts b/src/frontend/utils/__tests__/debug-tree-builder.test.ts index f2ba59f0b..e24edd52b 100644 --- a/src/frontend/utils/__tests__/debug-tree-builder.test.ts +++ b/src/frontend/utils/__tests__/debug-tree-builder.test.ts @@ -121,15 +121,17 @@ describe('buildDebugTree', () => { }) describe('external variables', () => { - it('builds a leaf node for an external base-type variable using prefix', () => { + it('builds a leaf node for an external base-type variable with a canonical global key', () => { const variable = makeBaseVariable('GLOBAL_FLAG', 'BOOL', 'external') const debugVars = [makeDebugVar('GLOBAL_FLAG', 'BOOL_ENUM', 5)] const projectData = { dataTypes: [], pous: [] } const node = buildDebugTree(variable, 'Main', INSTANCE_NAME, debugVars, projectData, SYSTEM_LIBS) + // fullPath resolves to the shared global; compositeKey is POU-independent + // (Config0:*) so the same global watched from any POU dedups to one entry. expect(node.fullPath).toBe('GLOBAL_FLAG') - expect(node.compositeKey).toBe('Main:GLOBAL_FLAG') + expect(node.compositeKey).toBe('Config0:GLOBAL_FLAG') expect(node.debugIndex).toBe(5) expect(node.isComplex).toBe(false) }) diff --git a/src/frontend/utils/__tests__/debug-tree-traversal.test.ts b/src/frontend/utils/__tests__/debug-tree-traversal.test.ts index 4a63d00b9..37c86601a 100644 --- a/src/frontend/utils/__tests__/debug-tree-traversal.test.ts +++ b/src/frontend/utils/__tests__/debug-tree-traversal.test.ts @@ -156,7 +156,7 @@ describe('traverseVariable', () => { }) describe('external variables', () => { - it('uses prefix for external base-type variables', () => { + it('resolves external base-type variables to the global path + canonical key', () => { const variable = makeBaseVariable('GLOBAL_FLAG', 'BOOL', 'external') const debugVars = [makeDebugVar('GLOBAL_FLAG', 'BOOL_ENUM', 5)] const ctx = makeContext({ debugVariables: debugVars }) @@ -165,6 +165,32 @@ describe('traverseVariable', () => { expect(result.fullPath).toBe('GLOBAL_FLAG') expect(result.debugIndex).toBe(5) + // POU-independent key so every reference dedups to one watch. + expect(result.compositeKey).toBe('Config0:GLOBAL_FLAG') + }) + + it('surfaces a function block VAR_EXTERNAL as a canonical global child', () => { + // A user FB that references a global via VAR_EXTERNAL. The global lives at + // its bare name; the FB member must resolve there (not to an instance + // path) so it shows the shared value and dedups across every reference. + const customFb = makePou('MyFb', 'function-block', [ + makeBaseVariable('LOCAL_X', 'INT', 'local'), + makeBaseVariable('SHARED', 'INT', 'external'), + ]) + const variable = makeDerivedVariable('inst', 'MyFb') + const debugVars = [makeDebugVar('INSTANCE0.INST.LOCAL_X', 'INT_ENUM', 3), makeDebugVar('SHARED', 'INT_ENUM', 42)] + const ctx = makeContext({ debugVariables: debugVars, projectPous: [customFb] }) + + const result = traverseVariable(variable, ctx, simpleVisitor) + + const shared = result.children!.find((c) => c.name === 'SHARED') + expect(shared).toBeDefined() + expect(shared!.fullPath).toBe('SHARED') + expect(shared!.compositeKey).toBe('Config0:SHARED') + expect(shared!.debugIndex).toBe(42) + // Local member keeps its instance-scoped path. + const local = result.children!.find((c) => c.name === 'LOCAL_X') + expect(local!.compositeKey).toBe('Main:inst.LOCAL_X') }) }) diff --git a/src/frontend/utils/__tests__/debug-variable-finder.test.ts b/src/frontend/utils/__tests__/debug-variable-finder.test.ts index c521c6c6c..73ad91803 100644 --- a/src/frontend/utils/__tests__/debug-variable-finder.test.ts +++ b/src/frontend/utils/__tests__/debug-variable-finder.test.ts @@ -3,7 +3,9 @@ import { appendToDebugPath, buildDebugPath, buildDebugPathPrefix, + buildGlobalCompositeKey, buildGlobalDebugPath, + GLOBAL_CONFIG_NAME, findDebugVariable, findDebugVariableForField, findDebugVariableWithFallback, @@ -89,6 +91,17 @@ describe('buildDebugPath', () => { }) }) +describe('buildGlobalCompositeKey', () => { + it('qualifies the global with the config name, preserving declared case', () => { + expect(buildGlobalCompositeKey('test_global')).toBe(`${GLOBAL_CONFIG_NAME}:test_global`) + }) + + it('produces the same key regardless of the referencing POU (dedup)', () => { + // Every reference to a global maps here, so all references collapse to one key. + expect(buildGlobalCompositeKey('MY_GLOBAL')).toBe(buildGlobalCompositeKey('MY_GLOBAL')) + }) +}) + describe('buildGlobalDebugPath', () => { it('returns uppercased name', () => { expect(buildGlobalDebugPath('MY_GLOBAL')).toBe('MY_GLOBAL') diff --git a/src/frontend/utils/__tests__/debugger-session.test.ts b/src/frontend/utils/__tests__/debugger-session.test.ts index 2463f564f..bd88262c2 100644 --- a/src/frontend/utils/__tests__/debugger-session.test.ts +++ b/src/frontend/utils/__tests__/debugger-session.test.ts @@ -315,16 +315,20 @@ describe('deriveVariableIndexMap', () => { const { indexMap } = derive([pou], instances, map) - expect(indexMap.get('Main:START_PB')).toBe(addr(0, 0)) + // Externals resolve under the canonical, POU-independent global key. + expect(indexMap.get('Config0:START_PB')).toBe(addr(0, 0)) expect(indexMap.get('Main:LOCAL_X')).toBe(addr(0, 1)) - // The instance-prefixed path must NOT resolve the global. + // Neither the instance-prefixed nor the program-scoped path resolves the + // global — every reference dedups to the one canonical key. expect(indexMap.has('INSTANCE0.START_PB')).toBe(false) + expect(indexMap.has('Main:START_PB')).toBe(false) }) - it('maps a global shared by two programs to the same index under both keys', () => { + it('maps a global shared by two programs to one canonical global key', () => { // The regression this whole refactor targets: a VAR_GLOBAL referenced (as - // VAR_EXTERNAL) by two programs must resolve to ONE address under BOTH - // composite keys, so force + value display work on every referencing POU. + // VAR_EXTERNAL) by two programs must resolve to ONE address so force + value + // display work on every referencing POU. Both references now collapse onto + // the single canonical `Config0:*` key. const main = makePou('Main', 'program', [makeBaseVariable('START_PB', 'BOOL', 'external')]) const another = makePou('Another', 'program', [makeBaseVariable('START_PB', 'BOOL', 'external')]) const instances = [makeInstance('INSTANCE0', 'Main'), makeInstance('INSTANCE1', 'Another')] @@ -332,8 +336,10 @@ describe('deriveVariableIndexMap', () => { const { indexMap } = derive([main, another], instances, map) - expect(indexMap.get('Main:START_PB')).toBe(addr(0, 0)) - expect(indexMap.get('Another:START_PB')).toBe(addr(0, 0)) + expect(indexMap.get('Config0:START_PB')).toBe(addr(0, 0)) + // Per-program keys no longer exist — that collapse IS the dedup. + expect(indexMap.has('Main:START_PB')).toBe(false) + expect(indexMap.has('Another:START_PB')).toBe(false) }) it('falls back to raw debug path for unmatched leaves (nested fields)', () => { diff --git a/src/frontend/utils/__tests__/pou-helpers.test.ts b/src/frontend/utils/__tests__/pou-helpers.test.ts index 5b7a879b0..a33829b8b 100644 --- a/src/frontend/utils/__tests__/pou-helpers.test.ts +++ b/src/frontend/utils/__tests__/pou-helpers.test.ts @@ -1,6 +1,7 @@ -import type { PLCDataType, PLCPou } from '../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { + findFunctionBlockExternalVariables, findFunctionBlockVariables, findLeafVariables, findStructureVariables, @@ -205,6 +206,52 @@ describe('findFunctionBlockVariables', () => { }) }) +// --------------------------------------------------------------------------- +// findFunctionBlockExternalVariables +// --------------------------------------------------------------------------- + +describe('findFunctionBlockExternalVariables', () => { + const makeVar = (name: string, cls: PLCVariable['class']): PLCVariable => ({ + name, + class: cls, + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + }) + + const fbWith = (vars: PLCVariable[]): PLCPou => ({ + name: 'MyFB', + pouType: 'function-block', + interface: { variables: vars }, + body: { language: 'st', value: '' }, + }) + + it('returns only the VAR_EXTERNAL members of a user FB', () => { + const fb = fbWith([ + makeVar('IN', 'input'), + makeVar('G1', 'external'), + makeVar('S', 'local'), + makeVar('G2', 'external'), + ]) + const names = findFunctionBlockExternalVariables('MyFB', [fb]).map((v) => v.name) + expect(names).toEqual(['G1', 'G2']) + }) + + it('returns [] for a user FB with no externals', () => { + const fb = fbWith([makeVar('IN', 'input'), makeVar('S', 'local')]) + expect(findFunctionBlockExternalVariables('MyFB', [fb])).toEqual([]) + }) + + it('returns [] for an unknown FB type', () => { + expect(findFunctionBlockExternalVariables('NoSuchFB', [])).toEqual([]) + }) + + it('returns [] for a library FB (black box — not searched in project POUs)', () => { + // SR is a system-library FB; its externals are not project-visible. + expect(findFunctionBlockExternalVariables('SR', [])).toEqual([]) + }) +}) + // --------------------------------------------------------------------------- // isFunctionBlockType // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/debug-polling-filter.ts b/src/frontend/utils/debug-polling-filter.ts index 71ccdaaa0..7192cc4fe 100644 --- a/src/frontend/utils/debug-polling-filter.ts +++ b/src/frontend/utils/debug-polling-filter.ts @@ -17,6 +17,7 @@ */ import type { FbInstanceInfo, PLCPou } from '../../middleware/shared/ports/types' +import { buildGlobalCompositeKey } from './debug-variable-finder' /** * Minimal state shape required by the debug polling filter. @@ -93,14 +94,25 @@ export function buildActiveIndexSet( const watched = variables.filter((v) => v.debug === true) if (watched.length === 0) continue + // VAR_EXTERNAL watches resolve to a shared global by its canonical, + // POU-independent key (Config0:) — matching the debug tree — so a + // global watched from a program body, a function block, or any nesting polls + // the one global address regardless of POU kind or selected FB instance. + for (const v of watched) { + if (v.class === 'external') activeKeys.add(buildGlobalCompositeKey(v.name)) + } + + const localWatched = watched.filter((v) => v.class !== 'external') + if (localWatched.length === 0) continue + if (pou.pouType === 'function-block') { const resolved = resolveFbInstance(pou.name, fbSelectedInstance, fbDebugInstances) if (!resolved) continue - for (const v of watched) { + for (const v of localWatched) { activeKeys.add(`${resolved.programName}:${resolved.fbVariableName}.${v.name}`) } } else { - for (const v of watched) { + for (const v of localWatched) { activeKeys.add(`${pou.name}:${v.name}`) } } diff --git a/src/frontend/utils/debug-tree-builder.ts b/src/frontend/utils/debug-tree-builder.ts index 67be33a35..3b80180c3 100644 --- a/src/frontend/utils/debug-tree-builder.ts +++ b/src/frontend/utils/debug-tree-builder.ts @@ -10,7 +10,7 @@ import type { DebugTreeNode, PLCPou, PLCVariable } from '../../middleware/shared import type { DebugVariableEntry } from './debug-parser' import type { DebugNodeVisitor, TraversalContext } from './debug-tree-traversal' import { traverseVariable } from './debug-tree-traversal' -import { buildGlobalDebugPath, buildVariableDebugPath } from './debug-variable-finder' +import { buildVariableDebugPath } from './debug-variable-finder' /** * Project data shape expected by the debug tree builder. @@ -135,39 +135,11 @@ export function buildDebugTree( projectData: DebugProjectData, systemLibraries: SystemLibrary[], ): DebugTreeNode { - // Handle external variables specially - they use global path - // For external variables, we need to adjust the traversal - if (variable.class === 'external') { - // External variables use CONFIG0__ prefix instead of RES0__INSTANCE - // Create a modified variable traversal for external variables - const fullPath = buildGlobalDebugPath(variable.name) - const compositeKey = `${pouName}:${variable.name}` - - if (variable.type.definition === 'base-type') { - const debugVar = debugVariables.find((dv) => dv.name === fullPath) - const node: DebugTreeNode = { - name: variable.name, - fullPath, - compositeKey, - type: variable.type.value.toUpperCase(), - isComplex: false, - debugIndex: debugVar?.index, - } - - /* istanbul ignore next -- dev-only: guarded by DEBUG_TREE_LOGGING constant */ - if (DEBUG_TREE_LOGGING) { - console.groupCollapsed(`Debug Tree for ${variable.name} (external)`) - logDebugTree(node) - console.groupEnd() - } - - return node - } - - // For complex external variables, use the standard traversal - // but we need to handle this specially since external vars use CONFIG0__ prefix - // The shared traversal handles external class automatically - } + // External (VAR_EXTERNAL) variables are handled uniformly by the shared + // traversal: it resolves the `external` class to the global address + // (buildVariableDebugPath) and the canonical `Config0:` composite key, + // so a global watched from a program body, a function block, or any nesting + // dedups to one entry. No special-casing needed here. // Create traversal context const context: TraversalContext = { diff --git a/src/frontend/utils/debug-tree-traversal.ts b/src/frontend/utils/debug-tree-traversal.ts index 3b26d5f1f..6014b07bc 100644 --- a/src/frontend/utils/debug-tree-traversal.ts +++ b/src/frontend/utils/debug-tree-traversal.ts @@ -9,8 +9,18 @@ import type { SystemLibrary } from '../../middleware/shared/ports/library-types' import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' import type { DebugVariableEntry } from './debug-parser' -import { buildVariableDebugPath, findDebugVariable, findDebugVariableForField } from './debug-variable-finder' -import { findFunctionBlockVariables, findStructureVariables, normalizeTypeString } from './pou-helpers' +import { + buildGlobalCompositeKey, + buildVariableDebugPath, + findDebugVariable, + findDebugVariableForField, +} from './debug-variable-finder' +import { + findFunctionBlockExternalVariables, + findFunctionBlockVariables, + findStructureVariables, + normalizeTypeString, +} from './pou-helpers' /** * Pick the right type name for a leaf. The debug-map records the IEC base @@ -247,6 +257,16 @@ function traverseNestedNode( } } + // VAR_EXTERNAL members reference config-scoped globals, not instance state, + // so findFunctionBlockVariables omits them. Surface them here so a global can + // be watched from inside any FB: traverseVariable resolves the `external` + // class to the shared global's address and the canonical `Config0:` + // key, so this node dedups with the same global watched from the program body + // or any other FB — regardless of how deep this instance is nested. + for (const ext of findFunctionBlockExternalVariables(typeName, projectPous)) { + children.push(traverseVariable(ext, context, visitor)) + } + return visitor.visitComplex(name, fullPath, compositeKey, typeName, children) } else if (typeDefinition === 'user-data-type') { // Structure type — STruC++ emits struct fields as `PARENT.FIELD` @@ -398,7 +418,10 @@ function traverseNestedNode( */ export function traverseVariable(variable: PLCVariable, context: TraversalContext, visitor: DebugNodeVisitor): T { const { debugVariables, projectPous, pouName, instanceName, systemLibraries } = context - const compositeKey = `${pouName}:${variable.name}` + // A VAR_EXTERNAL reference gets a canonical, POU-independent key so every + // reference to the same global dedups to one watch displayed as `Config0.`. + const compositeKey = + variable.class === 'external' ? buildGlobalCompositeKey(variable.name) : `${pouName}:${variable.name}` // Build the base path (single rule — see buildVariableDebugPath) const fullPath = buildVariableDebugPath(variable.class === 'external', instanceName, variable.name) diff --git a/src/frontend/utils/debug-variable-finder.ts b/src/frontend/utils/debug-variable-finder.ts index 1228fb326..70a319226 100644 --- a/src/frontend/utils/debug-variable-finder.ts +++ b/src/frontend/utils/debug-variable-finder.ts @@ -94,6 +94,25 @@ export function buildGlobalDebugPath(variablePath: string): string { return variablePath.toUpperCase() } +/** + * The configuration name STruC++ emits for the generated CONFIGURATION/RESOURCE + * (hardcoded in parse-resource-configuration-to-string.ts). Used only to label + * global (VAR_EXTERNAL) watches in the debugger so they read as `Config0.`. + */ +export const GLOBAL_CONFIG_NAME = 'Config0' + +/** + * Canonical composite key for a global referenced through VAR_EXTERNAL. It is + * deliberately independent of the referencing POU or FB instance, so every + * reference to the same global — a program body, any function block, any depth + * of nesting — dedups to a single debugger watch that displays as + * `Config0.`. The value still resolves via buildGlobalDebugPath (the bare + * uppercase name in debug-map.json); this only governs identity/display. + */ +export function buildGlobalCompositeKey(variableName: string): string { + return `${GLOBAL_CONFIG_NAME}:${variableName}` +} + /** * The single path rule for a program variable: a shared global (VAR_EXTERNAL → * CONFIGURATION VAR_GLOBAL) addresses by its bare name; everything else is diff --git a/src/frontend/utils/pou-helpers.ts b/src/frontend/utils/pou-helpers.ts index c1969088f..9e2aebe29 100644 --- a/src/frontend/utils/pou-helpers.ts +++ b/src/frontend/utils/pou-helpers.ts @@ -4,7 +4,7 @@ */ import type { SystemLibrary } from '../../middleware/shared/ports/library-types' -import type { PLCDataType, PLCPou } from '../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' /** * Variable definition from a POU or library FB. @@ -135,6 +135,34 @@ export const findFunctionBlockVariables = ( return null } +/** + * Return the VAR_EXTERNAL members of a user-defined function block. + * + * findFunctionBlockVariables deliberately drops externals (they point at + * config-scoped globals, not instance state, and must not appear in the OPC-UA + * picker as instance members). The debugger, however, wants to surface them so + * a global can be watched from inside any FB — resolved to the shared global's + * address, not a per-instance copy. This companion returns exactly those + * members so the debug tree can emit canonical global nodes for them. + * + * Library FBs are black boxes (their externals aren't in debug-map.json), so + * only project POUs are searched; returns [] when the FB is a library type, + * unknown, or declares no externals. + * + * Returns full PLCVariable objects (not the narrowed PouVariable view) so the + * debug traversal can hand them straight to traverseVariable. + */ +export const findFunctionBlockExternalVariables = (typeName: string, projectPous: PLCPou[]): PLCVariable[] => { + const typeNameUpper = typeName.toUpperCase() + const customFB = projectPous.find( + (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + ) + if (customFB && customFB.pouType === 'function-block') { + return (customFB.interface?.variables ?? []).filter((v) => v.class === 'external') + } + return [] +} + /** * Check if a type name is a function block (library or project). */ From 431c7bf116e4f4120ffe58a3848aa8da64882e96 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 16 Jul 2026 11:37:09 -0400 Subject: [PATCH 8/8] chore(release): bump strucpp to v0.6.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- binary-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/binary-versions.json b/binary-versions.json index 6dd46a282..3991c80f2 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.14", + "version": "v0.6.0", "repository": "Autonomy-Logic/STruCpp" } }