Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2fc42e7
sync(ethercat): mirror openplc-web feat/ethercat-web-adapter
marconetsf Apr 27, 2026
476d8cb
sync(ethercat): mirror Configuration screen stats from openplc-web
marconetsf Apr 27, 2026
42032c0
sync(arch): route EtherCATRuntimeStatusResponse through the ports layer
marconetsf Apr 27, 2026
2fb6ee0
sync(ethercat): apply prettier on board.tsx
marconetsf Apr 27, 2026
5b3959d
Merge branch 'development' into feat/ethercat-web-adapter
marconetsf Apr 30, 2026
5e0930a
refactor(ethercat): mirror PR feedback fixes from openplc-web
marconetsf Apr 30, 2026
78ce5d0
fix(ethercat): mirror tsc + prettier follow-up from openplc-web
marconetsf Apr 30, 2026
f64cc6f
refactor(ethercat): mirror second round of PR feedback from openplc-web
marconetsf Apr 30, 2026
12bea51
refactor(ethercat): mirror a11y flip + legacy removal from openplc-web
marconetsf Apr 30, 2026
1616198
feat(ethercat): surface CoE startup SDOs from <DefaultData>-style ESIs
marconetsf May 4, 2026
f8fa19a
style(ethercat): apply prettier to SDO parser and decoder
marconetsf May 4, 2026
1bf19bf
Merge branch 'development' into feat/ethercat-web-adapter
marconetsf May 4, 2026
09467d3
fix(ethercat): drop blank SDO entries from runtime config
marconetsf May 4, 2026
e0a5e09
style(ethercat): fix prettier indent on .map() close paren
marconetsf May 4, 2026
7e9cbaf
refactor(ethercat): move discovery types to middleware/shared/ports
marconetsf May 5, 2026
b8aeca7
style(ethercat): sort imports after types path swap
marconetsf May 5, 2026
070ca65
style(ipc): sort imports in renderer.ts after types path swap
marconetsf May 5, 2026
eb677c2
Merge branch 'development' into feat/ethercat-web-adapter
marconetsf May 5, 2026
8d39e11
feat(ethercat): validate generated config before compilation [DOPE-280]
marconetsf May 6, 2026
2567189
Merge branch 'development' into feat/ethercat-web-adapter
marconetsf May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/backend/editor/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { promisify } from 'node:util'

import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config'
import { generateEthercatConfig } from '@root/backend/shared/ethercat/generate-ethercat-config'
import { validateEthercatConfig } from '@root/backend/shared/ethercat/validate-ethercat-config'
import type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices'
import type { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc'
import {
Expand Down Expand Up @@ -1342,6 +1343,11 @@ class CompilerModule {
): Promise<void> {
const ethercatConfig = generateEthercatConfig(projectData.remoteDevices)

const ethercatErrors = validateEthercatConfig(ethercatConfig)
if (ethercatErrors.length > 0) {
throw new Error(`EtherCAT configuration is invalid: ${ethercatErrors.join('; ')}`)
}

if (ethercatConfig) {
const confFolderPath = join(sourceTargetFolderPath, 'conf')
await mkdir(confFolderPath, { recursive: true })
Expand Down
125 changes: 125 additions & 0 deletions src/backend/shared/ethercat/__tests__/validate-ethercat-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { validateEthercatConfig } from '../validate-ethercat-config'

const makeMaster = (name: string, networkInterface: string) => ({
name,
protocol: 'ETHERCAT',
config: {
master: {
interface: networkInterface,
cycle_time_us: 1000,
watchdog_timeout_cycles: 3,
},
slaves: [],
diagnostics: {
log_connections: true,
log_data_access: false,
log_errors: true,
max_log_entries: 10000,
status_update_interval_ms: 500,
},
},
})

const toJson = (entries: unknown[]) => JSON.stringify(entries, null, 2)

describe('validateEthercatConfig', () => {
describe('no-op cases', () => {
it('returns no errors when configJson is null (no EtherCAT masters generated)', () => {
expect(validateEthercatConfig(null)).toEqual([])
})

it('returns no errors when configJson is an empty string', () => {
expect(validateEthercatConfig('')).toEqual([])
})

it('returns no errors for an empty entries array', () => {
expect(validateEthercatConfig(toJson([]))).toEqual([])
})
})

describe('happy path', () => {
it('returns no errors for a single master', () => {
expect(validateEthercatConfig(toJson([makeMaster('master_a', 'eth0')]))).toEqual([])
})

it('returns no errors for multiple masters with distinct interfaces', () => {
const json = toJson([
makeMaster('master_a', 'eth0'),
makeMaster('master_b', 'eth1'),
makeMaster('master_c', 'enp3s0'),
])
expect(validateEthercatConfig(json)).toEqual([])
})
})

describe('unique-interface validation', () => {
it('returns an error when two masters share the same interface', () => {
const json = toJson([makeMaster('master_a', 'eth0'), makeMaster('master_b', 'eth0')])
const errors = validateEthercatConfig(json)
expect(errors).toHaveLength(1)
expect(errors[0]).toContain("'eth0'")
expect(errors[0]).toContain('master_a')
expect(errors[0]).toContain('master_b')
})

it('reports each duplicate group once when three masters share an interface', () => {
const json = toJson([
makeMaster('master_a', 'eth0'),
makeMaster('master_b', 'eth0'),
makeMaster('master_c', 'eth0'),
])
const errors = validateEthercatConfig(json)
expect(errors).toHaveLength(1)
expect(errors[0]).toContain('master_a')
expect(errors[0]).toContain('master_b')
expect(errors[0]).toContain('master_c')
})

it('reports multiple duplicate groups separately', () => {
const json = toJson([
makeMaster('master_a', 'eth0'),
makeMaster('master_b', 'eth0'),
makeMaster('master_c', 'eth1'),
makeMaster('master_d', 'eth1'),
makeMaster('master_e', 'eth2'),
])
const errors = validateEthercatConfig(json)
expect(errors).toHaveLength(2)
const joined = errors.join(' | ')
expect(joined).toContain("'eth0'")
expect(joined).toContain("'eth1'")
expect(joined).not.toContain("'eth2'")
})

it('does not flag a unique interface that appears alongside duplicates', () => {
const json = toJson([
makeMaster('master_a', 'eth0'),
makeMaster('master_b', 'eth0'),
makeMaster('master_c', 'eth1'),
])
const errors = validateEthercatConfig(json)
expect(errors).toHaveLength(1)
expect(errors[0]).not.toContain("'eth1'")
})

it('uses a placeholder name for unnamed masters', () => {
const json = toJson([makeMaster('', 'eth0'), makeMaster('', 'eth0')])
const errors = validateEthercatConfig(json)
expect(errors[0]).toContain('<unnamed master>')
})
})

describe('malformed input', () => {
it('returns an error when the JSON is unparseable', () => {
const errors = validateEthercatConfig('{not json')
expect(errors).toHaveLength(1)
expect(errors[0]).toContain('Failed to parse')
})

it('returns an error when the parsed value is not an array', () => {
const errors = validateEthercatConfig('{"foo": "bar"}')
expect(errors).toHaveLength(1)
expect(errors[0]).toContain('not an array')
})
})
})
2 changes: 1 addition & 1 deletion src/backend/shared/ethercat/device-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ESIRepositoryItemLight,
ScannedDeviceMatch,
} from '@root/middleware/shared/ports/esi-types'
import type { EtherCATDevice } from '@root/types/ethercat'
import type { EtherCATDevice } from '@root/middleware/shared/ports/ethercat-types'

/**
* Parse a hex string to a number for comparison.
Expand Down
15 changes: 10 additions & 5 deletions src/backend/shared/ethercat/esi-parser-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,10 @@ function parseCoEDictionary(deviceEl: Record<string, unknown>): ESICoEObject[] |
if (pdoMappingStr) siPdoMapping = pdoMappingStr.toLowerCase() !== 'false' && pdoMappingStr !== '0'
}

const siDefaultValue = getTextValue(si['DefaultValue']) || undefined
// ETG.2000 allows either <DefaultValue> (formatted text, often CODESYS-generated)
// or <DefaultData> (hex string of LE wire bytes, Beckhoff-generated). Read both
// so vendor-mixed repositories don't drop SDO defaults silently.
const siDefaultValue = getTextValue(si['DefaultValue']) || getTextValue(si['DefaultData']) || undefined

subItems.push({
subIdx: siSubIdx,
Expand Down Expand Up @@ -450,8 +453,8 @@ function parseCoEDictionary(deviceEl: Record<string, unknown>): ESICoEObject[] |
}
}

// Parse default value from object-level Info
let defaultValue = getTextValue(objEl['DefaultValue']) || undefined
// Parse default value from object-level Info (DefaultValue or DefaultData)
let defaultValue = getTextValue(objEl['DefaultValue']) || getTextValue(objEl['DefaultData']) || undefined

// Resolve DataType to build sub-items for complex objects
const dtInfo = typeName ? dataTypeMap.get(typeName) : undefined
Expand All @@ -466,7 +469,9 @@ function parseCoEDictionary(deviceEl: Record<string, unknown>): ESICoEObject[] |
for (const isi of infoSubItems) {
const isiName = getTextValue(isi['Name'])
const isiInfo = isi['Info'] as Record<string, unknown> | undefined
const isiDefaultValue = isiInfo ? getTextValue(isiInfo['DefaultValue']) || undefined : undefined
const isiDefaultValue = isiInfo
? getTextValue(isiInfo['DefaultValue']) || getTextValue(isiInfo['DefaultData']) || undefined
: undefined
if (isiName) {
overrideMap.set(isiName, { defaultValue: isiDefaultValue })
}
Expand All @@ -492,7 +497,7 @@ function parseCoEDictionary(deviceEl: Record<string, unknown>): ESICoEObject[] |
if (!subItems && !defaultValue) {
const infoEl = objEl['Info'] as Record<string, unknown> | undefined
if (infoEl) {
defaultValue = getTextValue(infoEl['DefaultValue']) || undefined
defaultValue = getTextValue(infoEl['DefaultValue']) || getTextValue(infoEl['DefaultData']) || undefined
}
}

Expand Down
40 changes: 28 additions & 12 deletions src/backend/shared/ethercat/generate-ethercat-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,23 @@ function hexToInt(hex: string): number {

/**
* Parses a user-entered value string into a numeric value.
* Handles decimal ("100"), hex ("0xFF", "#xFF"), float ("3.14"), and negative ("-50").
* Handles:
* - Decimal ("100"), hex ("0xFF", "#xFF"), float ("3.14"), negative ("-50")
* - BOOL strings ("TRUE"/"FALSE", case-insensitive) -> 1/0. Without this
* branch the decoder's BOOL output collapses to 0 because Number("TRUE")
* is NaN.
* Returns 0 for empty or unparseable strings.
*/
function parseNumericValue(str: string): number {
if (!str || str.trim() === '') return 0

const trimmed = str.trim()

// BOOL literals (decoder output for BOOL defaults uses these)
const lower = trimmed.toLowerCase()
if (lower === 'true') return 1
if (lower === 'false') return 0

// Handle hex prefixes: "0x" / "0X" / "#x" / "#X"
if (/^(0x|#x)/i.test(trimmed)) {
const hexStr = trimmed.replace(/^#x/i, '0x')
Expand Down Expand Up @@ -199,21 +208,28 @@ function buildChannels(

/**
* Converts SDOConfigurationEntry[] to RuntimeSdoConfig[] for the runtime plugin.
*
* Entries the operator left blank (empty value) are dropped: the ESI may
* declare an RW SDO without a vendor default expecting the operator to
* supply one. If they did not, we must not silently send 0 -- the slave's
* own internal default applies instead.
*/
function buildSdoConfigurations(entries: SDOConfigurationEntry[] | undefined): RuntimeSdoConfig[] {
if (!entries || entries.length === 0) return []

return entries.map(
(entry): RuntimeSdoConfig => ({
index: entry.index,
subindex: entry.subIndex,
value: parseNumericValue(entry.value),
data_type: entry.dataType,
bit_length: entry.bitLength,
name: entry.name,
comment: `Startup SDO: ${entry.objectName}`,
}),
)
return entries
.filter((entry) => entry.value !== undefined && entry.value !== null && entry.value.trim() !== '')
.map(
(entry): RuntimeSdoConfig => ({
index: entry.index,
subindex: entry.subIndex,
value: parseNumericValue(entry.value),
data_type: entry.dataType,
bit_length: entry.bitLength,
name: entry.name,
comment: `Startup SDO: ${entry.objectName}`,
}),
)
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/backend/shared/ethercat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { parseESIDeviceFull, parseESILight } from './esi-parser-main'
export { cycleTimeUsToIecInterval, ethercatTaskName } from './ethercat-task-helpers'
export { generateEthercatConfig } from './generate-ethercat-config'
export { extractDefaultSdoConfigurations } from './sdo-config-defaults'
export { validateEthercatConfig } from './validate-ethercat-config'
Loading
Loading