-
+ {/* 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 09860c1e2..66e99ce5a 100644
--- a/src/frontend/components/_molecules/project-tree/index.tsx
+++ b/src/frontend/components/_molecules/project-tree/index.tsx
@@ -9,6 +9,7 @@ import { DeviceTransferIcon } from '../../../assets/icons/interface/DeviceTransf
import { DuplicateIcon } from '../../../assets/icons/interface/Duplicate'
import { MoreOptionsIcon } from '../../../assets/icons/interface/MoreOptions'
import { PencilIcon } from '../../../assets/icons/interface/Pencil'
+import { SoftMotionIcon } from '../../../assets/icons/interface/SoftMotion'
import { ArrayIcon } from '../../../assets/icons/project/Array'
import { CppIcon } from '../../../assets/icons/project/Cpp'
import { DataTypeIcon } from '../../../assets/icons/project/DataType'
@@ -460,6 +461,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & {
| '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
@@ -528,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/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) => (
{
@@ -151,59 +152,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/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts
index b53e0f14d..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
@@ -35,6 +35,7 @@ function setProjectPous(pous: PLCPou[]) {
...s.project.data,
pous,
dataTypes: [],
+ remoteDevices: [],
},
},
editor: { type: 'available', meta: { name: 'available' } },
@@ -186,4 +187,84 @@ 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('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(
+ 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..5e7364940 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, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types'
/**
* Map an LSP line in the synthesised datatypes document to the
@@ -102,9 +103,102 @@ 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
+}
+
+/**
+ * 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) {
+ 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 6ac456e87..dd7e1e692 100644
--- a/src/frontend/services/st-lsp/project-sync.ts
+++ b/src/frontend/services/st-lsp/project-sync.ts
@@ -25,12 +25,21 @@
* `refreshStlibs()` on the service.
*/
-import type { PLCDataType, PLCPou } from '../../../middleware/shared/ports/types'
+import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion'
+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, 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
@@ -78,28 +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))
+ }
+
+ // 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 {
+ reconcileSyntheticDoc(SOFTMOTION_GLOBALS_URI, serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never))
}
function reconcile(pous: PLCPou[]): void {
@@ -107,14 +134,21 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle {
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 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) {
seenNames.add(pou.name)
const nextUri = uriForPou(pou)
const previousUri = snapshot.uriByName.get(pou.name)
+ // 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).
@@ -175,17 +209,34 @@ 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.
+ const unsubscribeRemoteDevices = openPLCStoreBase.subscribe(
+ (state) => state.project.data.remoteDevices,
+ (remoteDevices) => reconcileSoftMotionGlobals(remoteDevices),
+ )
// 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.
+ // 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)
return {
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)
},
forceResync() {
@@ -204,6 +255,8 @@ 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.
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..ee506b6d5 100644
--- a/src/frontend/services/st-lsp/types.ts
+++ b/src/frontend/services/st-lsp/types.ts
@@ -40,6 +40,23 @@ 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'
+
+/**
+ * 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/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/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts
index e0ced0349..ea20dba41 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/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/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts
index 175936271..43d03155c 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.
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')
+}
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).
*/
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 =====================