diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 94bd4c7ea..08af2d45f 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -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 { @@ -1342,6 +1343,11 @@ class CompilerModule { ): Promise { 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 }) diff --git a/src/backend/shared/ethercat/__tests__/validate-ethercat-config.test.ts b/src/backend/shared/ethercat/__tests__/validate-ethercat-config.test.ts new file mode 100644 index 000000000..8dda1c5a7 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/validate-ethercat-config.test.ts @@ -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('') + }) + }) + + 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') + }) + }) +}) diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts index 1c048f068..425225172 100644 --- a/src/backend/shared/ethercat/device-matcher.ts +++ b/src/backend/shared/ethercat/device-matcher.ts @@ -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. diff --git a/src/backend/shared/ethercat/esi-parser-main.ts b/src/backend/shared/ethercat/esi-parser-main.ts index 2ee57c19e..de92d0019 100644 --- a/src/backend/shared/ethercat/esi-parser-main.ts +++ b/src/backend/shared/ethercat/esi-parser-main.ts @@ -383,7 +383,10 @@ function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | if (pdoMappingStr) siPdoMapping = pdoMappingStr.toLowerCase() !== 'false' && pdoMappingStr !== '0' } - const siDefaultValue = getTextValue(si['DefaultValue']) || undefined + // ETG.2000 allows either (formatted text, often CODESYS-generated) + // or (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, @@ -450,8 +453,8 @@ function parseCoEDictionary(deviceEl: Record): 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 @@ -466,7 +469,9 @@ function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | for (const isi of infoSubItems) { const isiName = getTextValue(isi['Name']) const isiInfo = isi['Info'] as Record | 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 }) } @@ -492,7 +497,7 @@ function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | if (!subItems && !defaultValue) { const infoEl = objEl['Info'] as Record | undefined if (infoEl) { - defaultValue = getTextValue(infoEl['DefaultValue']) || undefined + defaultValue = getTextValue(infoEl['DefaultValue']) || getTextValue(infoEl['DefaultData']) || undefined } } diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts index 482a43530..7ee5585ed 100644 --- a/src/backend/shared/ethercat/generate-ethercat-config.ts +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -127,7 +127,11 @@ 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 { @@ -135,6 +139,11 @@ function parseNumericValue(str: string): number { 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') @@ -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}`, + }), + ) } /** diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts index b89a1087e..f5aeaf4c6 100644 --- a/src/backend/shared/ethercat/index.ts +++ b/src/backend/shared/ethercat/index.ts @@ -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' diff --git a/src/backend/shared/ethercat/sdo-config-defaults.ts b/src/backend/shared/ethercat/sdo-config-defaults.ts index 9b3ab6646..f12c6d015 100644 --- a/src/backend/shared/ethercat/sdo-config-defaults.ts +++ b/src/backend/shared/ethercat/sdo-config-defaults.ts @@ -3,7 +3,13 @@ * * Extracts configurable SDO parameters from CoE Object Dictionary entries. * Filters to only include user-configurable (RW) parameters in the - * manufacturer-specific and profile-specific ranges. + * manufacturer-specific and profile-specific ranges, excluding objects that + * are mapped to PDOs (those are streamed cyclically, not configured at startup). + * + * ESI contains hex-encoded little-endian wire bytes. This module + * decodes them into type-aware display strings (decimal for integers, 0xNN + * for bitmask types, TRUE/FALSE for BOOL, etc.) so the UI shows operator- + * friendly values instead of raw hex blobs. */ import type { ESICoEObject, SDOConfigurationEntry } from '@root/middleware/shared/ports/esi-types' @@ -26,35 +32,123 @@ function isConfigurableRange(index: string): boolean { return num >= 0x2000 } +/** + * Decode an ESI hex string into a type-aware display value. + * + * is hex-encoded little-endian wire bytes per ETG.2000. + * Examples (UINT16): + * "0100" -> 1 + * "E803" -> 1000 + * "0080" -> -32768 (when interpreted as INT16) + * + * Display conventions follow what TwinCAT/CODESYS show by default: + * - BOOL -> "TRUE" / "FALSE" + * - BYTE / WORD / DWORD / LWORD -> "0xNN" (bitmask types are read as hex) + * - SINT/INT/DINT/LINT -> signed decimal + * - USINT/UINT/UDINT/ULINT -> unsigned decimal + * - REAL / LREAL -> decimal (DataView IEEE-754 LE) + * + * Unknown types or invalid input fall through to the raw hex string so + * the operator at least sees what came from the ESI rather than nothing. + */ +function decodeEsiDefaultData(hex: string | undefined, dataType: string | undefined, bitSize: number): string { + if (!hex) return '' + const clean = hex.replace(/\s+/g, '').replace(/^0x/i, '') + if (clean.length === 0) return '' + // Defensive: pad to even length so each byte parses cleanly + const padded = clean.length % 2 === 0 ? clean : '0' + clean + if (!/^[0-9a-fA-F]+$/.test(padded)) return hex + + const bytes: number[] = [] + for (let i = 0; i < padded.length; i += 2) { + bytes.push(parseInt(padded.substring(i, i + 2), 16)) + } + + const dt = (dataType ?? '').toUpperCase() + + if (dt === 'BOOL') { + return bytes[0] !== 0 ? 'TRUE' : 'FALSE' + } + + // Bitmask types: print as 0x... padded to the declared width. + if (dt === 'BYTE' || dt === 'WORD' || dt === 'DWORD' || dt === 'LWORD') { + const msbFirst = [...bytes].reverse() + const hexStr = msbFirst + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + const expectedDigits = Math.max(1, Math.ceil(bitSize / 4)) + return '0x' + hexStr.padStart(expectedDigits, '0') + } + + if (dt === 'REAL') { + if (bytes.length < 4) return hex + const buf = new ArrayBuffer(4) + const view = new DataView(buf) + for (let i = 0; i < 4; i++) view.setUint8(i, bytes[i] ?? 0) + return view.getFloat32(0, true).toString() + } + if (dt === 'LREAL') { + if (bytes.length < 8) return hex + const buf = new ArrayBuffer(8) + const view = new DataView(buf) + for (let i = 0; i < 8; i++) view.setUint8(i, bytes[i] ?? 0) + return view.getFloat64(0, true).toString() + } + + // Integer path -- BigInt avoids precision loss on 64-bit unsigned types. + let val = 0n + for (let i = bytes.length - 1; i >= 0; i--) { + val = (val << 8n) | BigInt(bytes[i]) + } + + const isSigned = dt === 'SINT' || dt === 'INT' || dt === 'DINT' || dt === 'LINT' + if (isSigned && bitSize > 0) { + const signBit = 1n << BigInt(bitSize - 1) + if (val & signBit) { + val = val - (1n << BigInt(bitSize)) + } + } + + return val.toString() +} + /** * Extract default SDO configurations from CoE Object Dictionary. * * Returns one SDOConfigurationEntry per RW parameter found in the - * configurable ranges (0x2000+). For complex objects, each RW sub-item - * (except subIndex 0 which is the max subindex counter) gets its own entry. + * configurable ranges (0x2000+) that is NOT mapped to a PDO. PDO-mapped + * objects (0x6000-0x6FFF inputs, 0x7000-0x7FFF outputs) are excluded here + * because their values are driven cyclically by the master, not configured + * via SDO at startup. For complex objects, each RW sub-item (except + * subIndex 0 which is the max-subindex counter) gets its own entry, with + * its decoded from LE wire bytes into a display value. */ export function extractDefaultSdoConfigurations(coeObjects: ESICoEObject[]): SDOConfigurationEntry[] { const entries: SDOConfigurationEntry[] = [] for (const obj of coeObjects) { if (!isConfigurableRange(obj.index)) continue + // PDO-mapped objects are streamed cyclically, not configured via SDO. + if (obj.pdoMapping) continue if (obj.subItems && obj.subItems.length > 0) { - // Complex object: iterate sub-items for (const sub of obj.subItems) { const subIdx = parseInt(sub.subIndex, 10) - // Skip subIndex 0 (max subindex counter) if (subIdx === 0) continue - // Only include RW sub-items with a defined default value if (sub.access !== 'RW') continue - if (sub.defaultValue === undefined || sub.defaultValue === null) continue - const defaultValue = sub.defaultValue + // Vendor may omit / on RW entries that + // require explicit operator input (motor torque limits, IDs, etc). + // Surface them with an empty default so they are visible and + // configurable in the UI; the exporter skips entries the operator + // never fills in. + const decoded = decodeEsiDefaultData(sub.defaultValue, sub.type, sub.bitSize) entries.push({ index: obj.index, subIndex: subIdx, - value: defaultValue, - defaultValue, + value: decoded, + defaultValue: decoded, dataType: sub.type, bitLength: sub.bitSize, name: sub.name, @@ -62,16 +156,14 @@ export function extractDefaultSdoConfigurations(coeObjects: ESICoEObject[]): SDO }) } } else { - // Simple object: use object-level values if (obj.access !== 'RW') continue - if (obj.defaultValue === undefined || obj.defaultValue === null) continue - const defaultValue = obj.defaultValue + const decoded = decodeEsiDefaultData(obj.defaultValue, obj.type, obj.bitSize) entries.push({ index: obj.index, subIndex: 0, - value: defaultValue, - defaultValue, + value: decoded, + defaultValue: decoded, dataType: obj.type, bitLength: obj.bitSize, name: obj.name, diff --git a/src/backend/shared/ethercat/validate-ethercat-config.ts b/src/backend/shared/ethercat/validate-ethercat-config.ts new file mode 100644 index 000000000..8ef572d93 --- /dev/null +++ b/src/backend/shared/ethercat/validate-ethercat-config.ts @@ -0,0 +1,77 @@ +/** + * Subset of the runtime EtherCAT root-entry shape that the validator cares + * about. The full shape is defined locally inside `generate-ethercat-config.ts` + * — keeping a minimal mirror here avoids coupling the validator to fields it + * doesn't use, while still benefiting from TypeScript when iterating entries. + */ +type EthercatRootEntry = { + name: string + config: { + master: { + interface: string + } + } +} + +/** + * Each EtherCAT master must own its network interface — having two masters + * bound to the same NIC produces undefined behavior at the runtime (both + * masters race to drive the same socket). + */ +const validateUniqueMasterInterfaces = (entries: EthercatRootEntry[]): string[] => { + const errors: string[] = [] + const interfaceToMasters = new Map() + + for (const entry of entries) { + const iface = entry.config?.master?.interface + if (!iface) continue + const name = entry.name || '' + const masters = interfaceToMasters.get(iface) ?? [] + masters.push(name) + interfaceToMasters.set(iface, masters) + } + + for (const [iface, masters] of interfaceToMasters) { + if (masters.length > 1) { + errors.push(`Network interface '${iface}' is shared by multiple masters: ${masters.join(', ')}`) + } + } + return errors +} + +/** + * Run all internal validations on the EtherCAT runtime configuration JSON + * produced by `generateEthercatConfig`. + * + * Validating the generator's output (instead of its input) keeps this gate + * honest: whatever lands in `ethercat.json` is exactly what we check, with + * no risk of drifting from the generator's filter rules. + * + * Returns the list of validation errors. An empty list means "deploy is + * safe"; a non-empty list means the caller must abort and is responsible + * for surfacing the messages to the user (each platform routes errors + * differently — Vite progress, Electron output panel, etc.). + */ +export const validateEthercatConfig = (configJson: string | null): string[] => { + if (!configJson) return [] + + let entries: EthercatRootEntry[] + try { + entries = JSON.parse(configJson) as EthercatRootEntry[] + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + return [`Failed to parse generated EtherCAT config: ${detail}`] + } + + if (!Array.isArray(entries)) { + return ['Generated EtherCAT config is not an array'] + } + + const errors: string[] = [] + errors.push(...validateUniqueMasterInterfaces(entries)) + // Future internal validations append their errors here. Keeping them + // additive lets the user see every problem in a single pass instead of + // one-error-at-a-time. + + return errors +} diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index feae4a5de..8d372ef1d 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -17,12 +17,14 @@ import { isSimulatorTarget, validateRuntimeVersion, } from '../../../../../../utils/device' +import { normalizeEthercatStatus } from '../../../../../../utils/ethercat-status' import { Checkbox } from '../../../../../_atoms/checkbox' import { Label } from '../../../../../_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' import TableActions from '../../../../../_atoms/table-actions' import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../../../../../_molecules/modal' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' +import { EthercatStatsSection } from '../ethercat/components/ethercat-stats-section' import { PinMappingTable } from './components/pin-mapping-table' const Board = memo(function () { @@ -67,6 +69,10 @@ const Board = memo(function () { const setIncludeTimingStatsInPolling = useOpenPLCStore( (state): ((include: boolean) => void) => state.deviceActions.setIncludeTimingStatsInPolling, ) + const ethercatStatus = useOpenPLCStore((state) => state.runtimeConnection.ethercatStatus) + const setIncludeEthercatStatsInPolling = useOpenPLCStore( + (state): ((include: boolean) => void) => state.deviceActions.setIncludeEthercatStatsInPolling, + ) const [isPressed, setIsPressed] = useState(false) const [previewImage, setPreviewImage] = useState('') @@ -316,6 +322,17 @@ const Board = memo(function () { } }, [setIncludeTimingStatsInPolling]) + // Only runtime targets expose the EtherCAT endpoint; skip the poll otherwise. + useEffect(() => { + if (!isOpenPLCRuntimeTarget(currentBoardInfo)) return + setIncludeEthercatStatsInPolling(true) + return () => { + setIncludeEthercatStatsInPolling(false) + } + }, [setIncludeEthercatStatsInPolling, currentBoardInfo]) + + const ethercatMasters = useMemo(() => normalizeEthercatStatus(ethercatStatus), [ethercatStatus]) + return ( {!isSimulatorTarget(currentBoardInfo) && ( @@ -517,64 +534,67 @@ const Board = memo(function () {
)} {isSimulatorTarget(currentBoardInfo) ? null : isOpenPLCRuntimeTarget(currentBoardInfo) ? ( - connectionStatus === 'connected' && - timingStats && - timingStats.scan_count > 0 && ( -
-

- Scan Cycle Statistics -

-
-
- Scan Count - - {timingStats.scan_count.toLocaleString()} - -
-
- Overruns - {timingStats.overruns} -
- {timingStats.scan_time_avg !== null && ( -
- Scan Time (avg) - - {timingStats.scan_time_avg} us - - {timingStats.scan_time_min !== null && timingStats.scan_time_max !== null && ( - - min: {timingStats.scan_time_min} / max: {timingStats.scan_time_max} - - )} -
- )} - {timingStats.cycle_time_avg !== null && ( + <> + {connectionStatus === 'connected' && timingStats && timingStats.scan_count > 0 && ( +
+

+ Scan Cycle Statistics +

+
- Cycle Time (avg) + Scan Count - {timingStats.cycle_time_avg} us + {timingStats.scan_count.toLocaleString()} - {timingStats.cycle_time_min !== null && timingStats.cycle_time_max !== null && ( - - min: {timingStats.cycle_time_min} / max: {timingStats.cycle_time_max} - - )}
- )} - {timingStats.cycle_latency_avg !== null && (
- Cycle Latency (avg) - - {timingStats.cycle_latency_avg} us - + Overruns + {timingStats.overruns}
- )} + {timingStats.scan_time_avg !== null && ( +
+ Scan Time (avg) + + {timingStats.scan_time_avg} us + + {timingStats.scan_time_min !== null && timingStats.scan_time_max !== null && ( + + min: {timingStats.scan_time_min} / max: {timingStats.scan_time_max} + + )} +
+ )} + {timingStats.cycle_time_avg !== null && ( +
+ Cycle Time (avg) + + {timingStats.cycle_time_avg} us + + {timingStats.cycle_time_min !== null && timingStats.cycle_time_max !== null && ( + + min: {timingStats.cycle_time_min} / max: {timingStats.cycle_time_max} + + )} +
+ )} + {timingStats.cycle_latency_avg !== null && ( +
+ Cycle Latency (avg) + + {timingStats.cycle_latency_avg} us + +
+ )} +
-
- ) + )} + {connectionStatus === 'connected' && ( + + )} + ) : (
diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx index d69b200e6..15d342d06 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx @@ -1,5 +1,5 @@ import { cn } from '@root/frontend/utils/cn' -import type { EtherCATDevice } from '@root/types/ethercat' +import type { EtherCATDevice } from '@root/middleware/shared/ports/ethercat-types' type DeviceScanTableProps = { devices: EtherCATDevice[] diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx index d3ab20416..977939138 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -1,7 +1,7 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { cn } from '@root/frontend/utils/cn' import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { EtherCATDevice, NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { DiscoveredDeviceTable } from './discovered-device-table' import { InterfaceSelector } from './interface-selector' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx index 5d74b82ad..c597a5d59 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -23,11 +23,12 @@ const DiscoveredDeviceTable = ({ onSelectAll, isScanning, }: DiscoveredDeviceTableProps) => { - // Calculate selection state - const selectableDevices = deviceMatches.filter((dm) => getBestMatchQuality(dm.matches) !== 'none') - const allSelected = - selectableDevices.length > 0 && selectableDevices.every((dm) => selectedDevices.has(dm.device.position)) - const someSelected = selectableDevices.some((dm) => selectedDevices.has(dm.device.position)) + // All scanned devices are selectable. Devices without a repository match get + // a visual "No XML" hint — they're still selectable, but on "Add Selected" + // the parent opens a modal explaining they can't be added until the ESI XML + // is imported in the Repository tab. + const allSelected = deviceMatches.length > 0 && deviceMatches.every((dm) => selectedDevices.has(dm.device.position)) + const someSelected = deviceMatches.some((dm) => selectedDevices.has(dm.device.position)) const handleSelectAll = () => { onSelectAll(!allSelected) @@ -42,7 +43,7 @@ const DiscoveredDeviceTable = ({ Pos @@ -65,31 +66,17 @@ const DiscoveredDeviceTable = ({ const bestQuality = getBestMatchQuality(dm.matches) const bestMatch = dm.matches.length > 0 ? dm.matches[0] : null const displayName = bestMatch?.esiDevice?.name || dm.device.name - const isSelectable = bestQuality !== 'none' + const hasNoXml = bestQuality === 'none' const isSelected = selectedDevices.has(dm.device.position) return ( isSelectable && onSelectDevice(dm.device.position, !isSelected)} - onKeyDown={(e) => { - if (!isSelectable) return - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onSelectDevice(dm.device.position, !isSelected) - } - }} + onClick={() => onSelectDevice(dm.device.position, !isSelected)} className={cn( - 'border-b border-neutral-200 transition-colors dark:border-neutral-800', - isSelectable && 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/50', - isSelectable && - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1', + 'cursor-pointer border-b border-neutral-200 transition-colors dark:border-neutral-800', + 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50', isSelected && 'bg-brand/10 dark:bg-brand/20', - !isSelectable && 'opacity-60', )} > @@ -97,7 +84,7 @@ const DiscoveredDeviceTable = ({ checked={isSelected} onCheckedChange={(checked) => onSelectDevice(dm.device.position, !!checked)} onClick={(e) => e.stopPropagation()} - disabled={!isSelectable} + aria-label={`Select device at position ${dm.device.position}`} /> @@ -105,9 +92,19 @@ const DiscoveredDeviceTable = ({ - {displayName} + + {displayName} + {hasNoXml && ( + + No XML + + )} + 0x{dm.device.vendor_id.toString(16).padStart(4, '0').toUpperCase()} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/ethercat-stats-section.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/ethercat-stats-section.tsx new file mode 100644 index 000000000..b1839e3c6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/ethercat-stats-section.tsx @@ -0,0 +1,111 @@ +import { cn } from '@root/frontend/utils/cn' +import type { EtherCATMasterStatus } from '@root/middleware/shared/ports/ethercat-types' + +type EthercatStatsSectionProps = { + masters: EtherCATMasterStatus[] + /** + * Outer wrapper className. Defaults to 'flex flex-col gap-4'. Pass a full + * replacement when call sites need a different layout (e.g. board.tsx adds + * w-full). + */ + className?: string + /** + * Replacement className for the cards grid. Defaults to a 2/3/4-column + * responsive grid that matches the board.tsx layout. + */ + cardsClassName?: string + /** + * When true, sets each per-master wrapper's id to the computed sectionId so + * Table-of-Contents anchors / scroll-to-id work. Used by board.tsx; the + * orchestrators list does not need it. + */ + withSectionId?: boolean +} + +const DEFAULT_SECTION_CLASSNAME = 'flex flex-col gap-4' +const DEFAULT_CARDS_CLASSNAME = 'grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4' + +// Bus names are runtime-supplied and may carry spaces / special chars. +const slugifyBusName = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +const cardClassName = + 'flex flex-col gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-900' +const cardLabelClassName = 'text-xs text-neutral-500 dark:text-neutral-400' +const cardValueClassName = 'text-lg font-semibold text-neutral-900 dark:text-white' + +const EthercatStatsSection = ({ + masters, + className, + cardsClassName, + withSectionId = false, +}: EthercatStatsSectionProps) => { + return ( + <> + {masters.map((master, idx) => { + // Positional fallback when the runtime config didn't name the bus. + const busLabel = master.name || `Bus ${idx + 1}` + // idx in the id keeps it unique when two buses share a name. + const slug = master.name ? slugifyBusName(master.name) : '' + const sectionId = slug ? `ethercat-stats-${slug}-${idx}` : `ethercat-stats-${idx}` + return ( +
+

+ EtherCAT Statistics{' '} + — {busLabel} +

+
+
+ Master State + {master.plugin_state} +
+
+ Slave Count + {master.slave_count} +
+
+ Cycle Count + {master.metrics.cycle_count.toLocaleString()} +
+
+ WKC Errors + {master.metrics.wkc_error_count.toLocaleString()} + {master.metrics.consecutive_wkc_errors > 0 && ( + consecutive: {master.metrics.consecutive_wkc_errors} + )} +
+
+ Cycle Time (avg) + + {master.metrics.avg_cycle_us} us + + max: {master.metrics.max_cycle_us} us +
+
+ Max Exchange Time + + {master.metrics.max_exchange_us} us + +
+ {master.metrics.recovery_attempts > 0 && ( +
+ Recovery Attempts + {master.metrics.recovery_attempts} +
+ )} +
+
+ ) + })} + + ) +} + +export { EthercatStatsSection } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx index 9716381b2..f198c11e4 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -3,7 +3,7 @@ import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' import { InputWithRef } from '@root/frontend/components/_atoms/input' import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' import { cn } from '@root/frontend/utils/cn' -import type { NetworkInterface } from '@root/types/ethercat' +import type { NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { useEffect, useState } from 'react' type GlobalSettingsTabProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx index 065dcf497..4eca1445d 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx @@ -4,7 +4,7 @@ import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' import { InputWithRef } from '@root/frontend/components/_atoms/input' import { Label } from '@root/frontend/components/_atoms/label' import { cn } from '@root/frontend/utils/cn' -import type { NetworkInterface } from '@root/types/ethercat' +import type { NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' type InterfaceSelectorProps = { diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx index 4b3162a05..8c09c0aa8 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -1,12 +1,11 @@ import { cn } from '@root/frontend/utils/cn' -import { useRuntime } from '@root/middleware/shared/providers/platform-context' import type { - EtherCATCycleMetrics, EtherCATMasterStatus, EtherCATPluginState, EtherCATRuntimeStatusResponse, EtherCATSlaveStatus, -} from '@root/types/ethercat' +} from '@root/middleware/shared/ports/ethercat-types' +import { useRuntime } from '@root/middleware/shared/providers/platform-context' import { useCallback, useEffect, useRef, useState } from 'react' const POLL_INTERVAL_MS = 2000 @@ -61,57 +60,28 @@ function SlaveStateCell({ status }: { status: EtherCATSlaveStatus }) { ) } -function MetricCard({ label, value, unit }: { label: string; value: string | number; unit?: string }) { - return ( -
- {label} - - {value} - {unit && {unit}} - -
- ) -} - interface RuntimeStatusPanelProps { ipAddress: string | null jwtToken: string | null isConnected: boolean - /** Master name to filter from multi-master response. If omitted, uses first master or flat fields. */ + /** Master name to filter from the response. If omitted, uses the first master. */ masterName?: string } /** - * Resolve the status for a specific master from the runtime response. - * Tries the multi-master "masters" array first, then falls back to flat fields. + * Pick a master from the runtime response — by name when supplied, otherwise + * the first one. Returns null when the runtime hasn't reported any. */ function resolveMasterStatus( response: EtherCATRuntimeStatusResponse, masterName?: string, ): EtherCATMasterStatus | null { - // Try multi-master array first - if (response.masters && response.masters.length > 0) { - if (masterName) { - const match = response.masters.find((m) => m.name === masterName) - if (match) return match - } - // Fallback: first master in array - return response.masters[0] - } - - // Fallback: flat fields (single-master backward compat) - if (response.plugin_state && response.slaves && response.metrics) { - return { - name: masterName ?? 'default', - plugin_state: response.plugin_state, - slave_count: response.slave_count ?? 0, - expected_wkc: response.expected_wkc ?? 0, - slaves: response.slaves, - metrics: response.metrics, - } + if (!response.masters || response.masters.length === 0) return null + if (masterName) { + const match = response.masters.find((m) => m.name === masterName) + if (match) return match } - - return null + return response.masters[0] } function extractErrorMessage(rawError: string): string { @@ -262,7 +232,6 @@ function RuntimeStatusPanel({ ipAddress, jwtToken, isConnected, masterName }: Ru const pluginState = masterStatus.plugin_state const stateColor = stateColorMap[pluginState] ?? 'gray' - const metrics: EtherCATCycleMetrics = masterStatus.metrics return (
@@ -290,17 +259,12 @@ function RuntimeStatusPanel({ ipAddress, jwtToken, isConnected, masterName }: Ru
- {/* Cycle metrics */} - {(pluginState === 'OPERATIONAL' || pluginState === 'RECOVERING' || pluginState === 'ERROR') && ( -
- - - - - - {metrics.recovery_attempts > 0 && } -
- )} + {/* + * Cycle metrics moved to the Device → Configuration screen + * (`board.tsx`), which now hosts both the PLC scan-cycle stats and + * the EtherCAT cycle stats in a unified card grid. This panel keeps + * the per-slave diagnostics that only make sense in the bus context. + */} {/* Slave table */} {masterStatus.slaves.length > 0 && ( diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx index f2a6500a9..0ccd9a85c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx @@ -10,7 +10,7 @@ import type { ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/middleware/shared/ports/esi-types' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import type { EtherCATDevice, NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { useState } from 'react' import { DeviceBrowserModal } from './device-browser-modal' diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx index c249e488b..b643fbeb7 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -50,7 +50,31 @@ function isBoolType(dataType: string): boolean { } /** - * Value cell with local state to avoid re-rendering the entire table on every keystroke. + * Parse a user-entered value the same way the runtime exporter will. + * Accepts decimal, hex (0x.../#x...), TRUE/FALSE. Returns NaN for unparseable input. + */ +function parseUserInput(input: string): number { + const trimmed = input.trim() + if (trimmed === '') return NaN + const lower = trimmed.toLowerCase() + if (lower === 'true') return 1 + if (lower === 'false') return 0 + if (/^(0x|#x)/i.test(trimmed)) { + return Number(trimmed.replace(/^#x/i, '0x')) + } + return Number(trimmed) +} + +/** + * Value cell with local state and range validation. + * + * On blur, parses the input the same way the runtime config exporter does, + * then validates against the type's range. Invalid input is NOT silently + * clamped: the cell shows a red border + tooltip with the valid range so + * the operator notices and decides explicitly. The raw user string is + * still propagated upward so the project file reflects what was typed -- + * the runtime's strict_sdo will reject the SDO write at startup if the + * operator ignores the warning. */ const ValueCell = ({ entry, @@ -65,22 +89,28 @@ const ValueCell = ({ setLocalValue(entry.value) }, [entry.value]) + const range = getDataTypeRange(entry.dataType, entry.bitLength) + + const validation = useMemo<{ valid: boolean; message?: string }>(() => { + if (localValue.trim() === '') return { valid: true } + const num = parseUserInput(localValue) + if (isNaN(num)) { + return { valid: false, message: `Cannot parse "${localValue}" as ${entry.dataType}` } + } + if (range && (num < range.min || num > range.max)) { + return { + valid: false, + message: `${num} out of range for ${entry.dataType} (allowed: ${range.min} .. ${range.max})`, + } + } + return { valid: true } + }, [localValue, entry.dataType, range]) + const handleBlur = useCallback(() => { if (localValue !== entry.value) { - const range = getDataTypeRange(entry.dataType, entry.bitLength) - if (range && localValue !== '') { - const num = Number(localValue) - if (!isNaN(num)) { - const clamped = Math.max(range.min, Math.min(range.max, num)) - const clampedStr = String(clamped) - setLocalValue(clampedStr) - onValueChange(entry.index, entry.subIndex, clampedStr) - return - } - } onValueChange(entry.index, entry.subIndex, localValue) } - }, [entry.index, entry.subIndex, entry.value, entry.dataType, entry.bitLength, localValue, onValueChange]) + }, [entry.index, entry.subIndex, entry.value, localValue, onValueChange]) if (isBoolType(entry.dataType)) { return ( @@ -97,7 +127,6 @@ const ValueCell = ({ ) } - const range = getDataTypeRange(entry.dataType, entry.bitLength) const isFloat = ['REAL', 'REAL32', 'FLOAT', 'LREAL', 'REAL64', 'DOUBLE'].includes(entry.dataType.toUpperCase()) const isNumeric = range !== null @@ -108,9 +137,14 @@ const ValueCell = ({ value={localValue} onChange={(e) => setLocalValue(e.target.value)} onBlur={handleBlur} - min={isNumeric ? range?.min : undefined} - max={isNumeric ? range?.max : undefined} - className='h-[24px] w-full max-w-[120px] rounded border border-neutral-300 bg-white px-1.5 font-mono text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + title={validation.valid ? undefined : validation.message} + aria-invalid={!validation.valid} + className={cn( + 'h-[24px] w-full max-w-[120px] rounded border bg-white px-1.5 font-mono text-xs outline-none dark:bg-neutral-950', + validation.valid + ? 'border-neutral-300 text-neutral-700 focus:border-brand dark:border-neutral-700 dark:text-neutral-300' + : 'border-red-500 text-red-600 focus:border-red-500 dark:text-red-400', + )} /> ) } 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 b5de3d12d..05155f4bd 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 @@ -54,7 +54,7 @@ const EtherCATDeviceEditor = () => { const deviceId = editor.type === 'plc-ethercat-device' ? editor.meta.deviceId : '' const projectPath = project.meta.path - const [activeTab, setActiveTab] = useState('info') + const [activeTab, setActiveTab] = useState('channel-mappings') // Repository state const [repository, setRepository] = useState([]) @@ -233,10 +233,10 @@ const EtherCATDeviceEditor = () => { className='flex min-h-0 flex-1 flex-col overflow-hidden' > + - {/* Device Info Tab */} 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 3e5031602..2d2234a89 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -1,9 +1,10 @@ import * as Tabs from '@radix-ui/react-tabs' import { collectUsedIecAddresses } from '@root/backend/shared/ethercat/collect-used-iec-addresses' import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' -import { getBestMatchQuality, matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' +import { matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' +import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' import type { @@ -13,8 +14,8 @@ import type { ESIRepositoryItemLight, ScannedDeviceMatch, } from '@root/middleware/shared/ports/esi-types' +import type { EtherCATDevice, NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' -import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' @@ -79,8 +80,8 @@ const EtherCATEditor = () => { const projectPath = project.meta.path // Runtime connection state - const { connectionStatus, ipAddress } = runtimeConnection - const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null + const { connectionStatus } = runtimeConnection + const isConnectedToRuntime = connectionStatus === 'connected' // Tab state const [activeTab, setActiveTab] = useState('scan-bus') @@ -165,6 +166,10 @@ const EtherCATEditor = () => { // Discovery selection state const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + // Devices the user tried to add but couldn't (no ESI XML in repository). + // When non-empty the warning modal is open. + const [unmatchedAddAttempt, setUnmatchedAddAttempt] = useState([]) + // Matched devices const deviceMatches = useMemo(() => { return matchDevicesToRepository(scannedDevices, repository) @@ -359,10 +364,9 @@ const EtherCATEditor = () => { const handleSelectAllScanned = useCallback( (selected: boolean) => { if (selected) { - const selectable = deviceMatches - .filter((dm) => getBestMatchQuality(dm.matches) !== 'none') - .map((dm) => dm.device.position) - setSelectedScannedDevices(new Set(selectable)) + // All scanned devices are selectable; unmatched-XML ones are filtered + // out at add-time with a modal warning instead of silently skipped. + setSelectedScannedDevices(new Set(deviceMatches.map((dm) => dm.device.position))) } else { setSelectedScannedDevices(new Set()) } @@ -372,6 +376,7 @@ const EtherCATEditor = () => { const handleAddSelectedFromScan = useCallback(async () => { const newDevices: ConfiguredEtherCATDevice[] = [] + const unmatched: ScannedDeviceMatch['device'][] = [] const existingPositions = new Set(configuredDevices.map((d) => d.position)) const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) @@ -379,7 +384,13 @@ const EtherCATEditor = () => { // Skip devices already configured at this position if (existingPositions.has(position)) continue const match = deviceMatches.find((dm) => dm.device.position === position) - if (!match || match.matches.length === 0) continue + if (!match) continue + + // No ESI XML in repository → collect for the warning modal and skip add. + if (match.matches.length === 0) { + unmatched.push(match.device) + continue + } // Use the best match (first one, which is sorted by quality) const bestMatch = match.matches[0] @@ -415,7 +426,24 @@ const EtherCATEditor = () => { if (newDevices.length > 0) { syncDevicesToStore([...configuredDevices, ...newDevices]) - setSelectedScannedDevices(new Set()) + } + + // Keep unmatched selected so the user can see which ones failed; clear + // the successfully-added ones from the selection. We compute the new set + // as `prev minus added` rather than rebuilding from `unmatched` so that + // selections of already-configured positions (silently skipped above) + // and any state changes that happened while the loop ran are preserved. + if (newDevices.length > 0) { + setSelectedScannedDevices((prev) => { + const next = new Set(prev) + // position is optional in the device type but always set when added here. + for (const d of newDevices) if (d.position !== undefined) next.delete(d.position) + return next + }) + } + + if (unmatched.length > 0) { + setUnmatchedAddAttempt(unmatched) } }, [ selectedScannedDevices, @@ -501,30 +529,8 @@ const EtherCATEditor = () => { className='flex min-h-0 flex-1 flex-col overflow-hidden' > - 0 ? ( - - {scannedDevices.length} - - ) : undefined - } - /> - 0 ? ( - - {repository.length} - - ) : undefined - } - /> + + @@ -583,6 +589,90 @@ const EtherCATEditor = () => { + + {/* Warning when the user tries to add scanned devices that have no ESI XML */} + 0} + onOpenChange={(open) => { + if (!open) setUnmatchedAddAttempt([]) + }} + > + setUnmatchedAddAttempt([])} + className='!inset-x-0 !bottom-auto !top-1/2 !h-auto max-h-[80vh] w-[540px] !-translate-y-1/2 p-6' + > + Missing ESI XML for some devices +

+ {unmatchedAddAttempt.length === 1 + ? 'The device below could not be added because its ESI XML is not in the repository.' + : `${unmatchedAddAttempt.length} devices could not be added because their ESI XML is not in the repository.`} +

+ +
+ + + + + + + + + + + {unmatchedAddAttempt.map((d) => ( + + + + + + + ))} + +
+ Pos + + Name + + Vendor + + Product +
{d.position}{d.name} + 0x{d.vendor_id.toString(16).padStart(4, '0').toUpperCase()} + + 0x{d.product_code.toString(16).padStart(8, '0').toUpperCase()} +
+
+ +
+

How to fix

+
    +
  1. Download the ESI XML for each device from the manufacturer's website.
  2. +
  3. + Open the Repository tab and import the XML files there. +
  4. +
  5. Re-run the scan and click "Add Selected" again.
  6. +
+
+ +
+ + +
+
+
) } diff --git a/src/frontend/components/_features/[workspace]/editor/device/orchestrators/orchestrators-list.tsx b/src/frontend/components/_features/[workspace]/editor/device/orchestrators/orchestrators-list.tsx index 3bde74e1e..24964238a 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/orchestrators/orchestrators-list.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/orchestrators/orchestrators-list.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { OrchestratorInfo } from '../../../../../../../middleware/shared/ports/orchestrator-port' import { useOrchestrator, useRuntime } from '../../../../../../../middleware/shared/providers' @@ -7,15 +7,17 @@ import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' import { WarningIcon } from '../../../../../../assets/icons/interface/Warning' import { useOpenPLCStore } from '../../../../../../store' import { cn } from '../../../../../../utils/cn' +import { normalizeEthercatStatus } from '../../../../../../utils/ethercat-status' import { getErrorMessage } from '../../../../../../utils/get-error-message' import { Modal, ModalContent, ModalTitle } from '../../../../../_molecules/modal' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' +import { EthercatStatsSection } from '../ethercat/components/ethercat-stats-section' // Note: Status and timing stats polling is handled globally by useRuntimePolling hook. // This component sets includeTimingStatsInPolling=true on mount to request timing stats. const SIMULATOR_BOARD_NAME = 'OpenPLC Simulator' -const RUNTIME_BOARD_NAME = 'OpenPLC Runtime v3' +const RUNTIME_BOARD_NAME = 'OpenPLC Runtime v4' /** * Returns the appropriate status badge styling based on status value @@ -302,6 +304,21 @@ const OrchestratorsList = () => { } }, [deviceActions]) + // Same pattern for EtherCAT runtime status. Only fetched while this screen + // is mounted, so non-EtherCAT setups don't pay for the extra round-trip on + // every poll. + useEffect(() => { + deviceActions.setIncludeEthercatStatsInPolling(true) + return () => { + deviceActions.setIncludeEthercatStatsInPolling(false) + } + }, [deviceActions]) + + const ethercatMasters = useMemo( + () => normalizeEthercatStatus(runtimeConnection.ethercatStatus), + [runtimeConnection.ethercatStatus], + ) + // Handle device switch confirmation const handleConfirmDeviceSwitch = useCallback(async () => { if (!pendingDeviceSwitch) return @@ -642,6 +659,11 @@ const OrchestratorsList = () => {
)}
+ + )} diff --git a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts new file mode 100644 index 000000000..2249c911a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts @@ -0,0 +1,148 @@ +import { renderHook } from '@testing-library/react' + +const storeMocks = vi.hoisted(() => { + const setPlcRuntimeStatus = vi.fn() + const setTimingStats = vi.fn() + const setEthercatStatus = vi.fn() + const setRuntimeJwtToken = vi.fn() + const setRuntimeConnectionStatus = vi.fn() + const openModal = vi.fn() + const setPlcLogsVisible = vi.fn() + const setPlcLogs = vi.fn() + const appendPlcLogs = vi.fn() + const setPlcLogsLastId = vi.fn() + const clearPlcLogs = vi.fn() + + const state: Record = { + runtimeConnection: { + connectionStatus: 'connected', + jwtToken: 'tok', + includeTimingStatsInPolling: false, + includeEthercatStatsInPolling: false, + plcStatus: null, + ethercatStatus: null, + }, + workspace: { plcLogs: '', plcLogsLastId: null }, + deviceActions: { + setPlcRuntimeStatus, + setTimingStats, + setEthercatStatus, + setRuntimeJwtToken, + setRuntimeConnectionStatus, + }, + modalActions: { openModal }, + workspaceActions: { setPlcLogsVisible, setPlcLogs, appendPlcLogs, setPlcLogsLastId, clearPlcLogs }, + } + + type Selector = (s: typeof state) => T + const useOpenPLCStore = ((selector?: Selector) => (selector ? selector(state) : state)) as ReturnType< + typeof vi.fn + > & { getState: () => typeof state } + useOpenPLCStore.getState = () => state + + return { + state, + useOpenPLCStore, + setPlcRuntimeStatus, + setTimingStats, + setEthercatStatus, + openModal, + setPlcLogs, + appendPlcLogs, + clearPlcLogs, + setPlcLogsVisible, + } +}) + +vi.mock('../../store', () => ({ + useOpenPLCStore: storeMocks.useOpenPLCStore, +})) + +const runtimeMocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + getLogs: vi.fn(), + getEthercatRuntimeStatus: undefined as undefined | ReturnType, +})) + +vi.mock('../../../middleware/shared/providers', () => ({ + useRuntime: () => runtimeMocks, +})) + +import { useRuntimePolling } from '../use-runtime-polling' + +const flushAll = async () => { + // Two ticks: poll schedules a Promise.all; status/logs/ethercat resolve, then + // the consumer's downstream `.then` chain runs. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('useRuntimePolling — EtherCAT branches', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: status/logs succeed, ethercat off, no method on runtime. + runtimeMocks.getStatus.mockResolvedValue({ success: true, status: 'RUNNING' }) + runtimeMocks.getLogs.mockResolvedValue({ success: true, logs: [] }) + runtimeMocks.getEthercatRuntimeStatus = undefined + Object.assign(storeMocks.state.runtimeConnection as object, { + connectionStatus: 'connected', + jwtToken: 'tok', + includeTimingStatsInPolling: false, + includeEthercatStatsInPolling: false, + }) + }) + + it('clears stored ethercat status when the polling flag is off', async () => { + Object.assign(storeMocks.state.runtimeConnection as object, { includeEthercatStatsInPolling: false }) + runtimeMocks.getEthercatRuntimeStatus = vi.fn().mockResolvedValue({ success: true, data: { masters: [] } }) + + renderHook(() => useRuntimePolling()) + await flushAll() + + // setEthercatStatus(null) is the soft-clear when the flag is off. + expect(storeMocks.setEthercatStatus).toHaveBeenCalledWith(null) + // The optional method is gated by the flag too — it shouldn't even be invoked. + expect(runtimeMocks.getEthercatRuntimeStatus).not.toHaveBeenCalled() + }) + + it('skips cleanly when the optional getEthercatRuntimeStatus method is not on the runtime', async () => { + Object.assign(storeMocks.state.runtimeConnection as object, { includeEthercatStatsInPolling: true }) + runtimeMocks.getEthercatRuntimeStatus = undefined + + renderHook(() => useRuntimePolling()) + await flushAll() + + // No data write — the soft-fail branch keeps whatever was in the store. + expect(storeMocks.setEthercatStatus).not.toHaveBeenCalled() + // status path still ran successfully so the rest of the cycle isn't disturbed. + expect(storeMocks.setPlcRuntimeStatus).toHaveBeenCalledWith('RUNNING') + }) + + it('writes the runtime payload into the store on a successful ethercat poll', async () => { + Object.assign(storeMocks.state.runtimeConnection as object, { includeEthercatStatsInPolling: true }) + const payload = { masters: [{ name: 'BusA', plugin_state: 'OPERATIONAL' }] } + runtimeMocks.getEthercatRuntimeStatus = vi.fn().mockResolvedValue({ success: true, data: payload }) + + renderHook(() => useRuntimePolling()) + await flushAll() + + expect(runtimeMocks.getEthercatRuntimeStatus).toHaveBeenCalledTimes(1) + expect(storeMocks.setEthercatStatus).toHaveBeenCalledWith(payload) + }) + + it('does not tear down the connection on a transient ethercat rejection', async () => { + Object.assign(storeMocks.state.runtimeConnection as object, { includeEthercatStatsInPolling: true }) + runtimeMocks.getEthercatRuntimeStatus = vi.fn().mockRejectedValue(new Error('boom')) + + renderHook(() => useRuntimePolling()) + await flushAll() + + // status path still wrote — meaning Promise.all didn't reject. + expect(storeMocks.setPlcRuntimeStatus).toHaveBeenCalledWith('RUNNING') + // Soft-fail keeps prior data; setEthercatStatus is not called with anything. + expect(storeMocks.setEthercatStatus).not.toHaveBeenCalled() + // No connection-lost modal opened. + expect(storeMocks.openModal).not.toHaveBeenCalled() + }) +}) diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index dcad0d03b..26fab3186 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -23,6 +23,7 @@ export const useRuntimePolling = () => { const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) const setTimingStats = useOpenPLCStore((state) => state.deviceActions.setTimingStats) + const setEthercatStatus = useOpenPLCStore((state) => state.deviceActions.setEthercatStatus) const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const pollIntervalRef = useRef | null>(null) @@ -36,6 +37,7 @@ export const useRuntimePolling = () => { deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) deviceActions.setTimingStats(null) + deviceActions.setEthercatStatus(null) }, []) const handleConnectionLost = useCallback(() => { @@ -55,7 +57,12 @@ export const useRuntimePolling = () => { const currentState = useOpenPLCStore.getState() const { - runtimeConnection: { connectionStatus: curStatus, jwtToken: curToken, includeTimingStatsInPolling }, + runtimeConnection: { + connectionStatus: curStatus, + jwtToken: curToken, + includeTimingStatsInPolling, + includeEthercatStatsInPolling, + }, workspace: { plcLogsLastId, plcLogs }, workspaceActions, } = currentState @@ -78,9 +85,23 @@ export const useRuntimePolling = () => { const isV4 = Array.isArray(plcLogs) || plcLogs === '' const minId = isV4 && plcLogsLastId !== null ? plcLogsLastId + 1 : undefined - const [statusResult, logsResult] = await Promise.all([ + // EtherCAT runtime status piggybacks on the same polling cycle as + // status/logs so the Configuration screen's stats stay fresh without + // running a second timer. Only requested when the consumer opted in + // via setIncludeEthercatStatsInPolling — otherwise we skip the call + // entirely and clear any stale data left in the store. + // A rejected ethercat call is swallowed to null so a single transient + // ethercat error doesn't reject the whole Promise.all and tear down a + // healthy runtime connection via handlePollFailure(). + const ethercatPromise = + includeEthercatStatsInPolling && runtime.getEthercatRuntimeStatus + ? runtime.getEthercatRuntimeStatus().catch(() => null) + : Promise.resolve(null) + + const [statusResult, logsResult, ethercatResult] = await Promise.all([ runtime.getStatus(includeTimingStatsInPolling), runtime.getLogs(minId), + ethercatPromise, ]) // Process status @@ -101,6 +122,16 @@ export const useRuntimePolling = () => { } else if (!includeTimingStatsInPolling) { setTimingStats(null) } + + if (includeEthercatStatsInPolling) { + if (ethercatResult && ethercatResult.success && ethercatResult.data) { + setEthercatStatus(ethercatResult.data) + } + // Soft-fail: keep previous data on transient errors so the UI + // doesn't flicker between populated and empty between polls. + } else { + setEthercatStatus(null) + } } else { handlePollFailure() return @@ -138,7 +169,7 @@ export const useRuntimePolling = () => { } finally { isPollingRef.current = false } - }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats]) + }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) useEffect(() => { const { workspaceActions } = useOpenPLCStore.getState() diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 5941491a8..3859637eb 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -1,5 +1,6 @@ import { createStore } from 'zustand/vanilla' +import type { EtherCATRuntimeStatusResponse } from '../../../middleware/shared/ports/runtime-port' import type { BoardInfo, CommunicationPort, DevicePin, TimingStats } from '../../../middleware/shared/ports/types' import { createDeviceSlice, DeviceSlice } from '../slices/device' import { defaultDeviceConfiguration } from '../slices/device/data/types' @@ -38,6 +39,29 @@ function makeTimingStats(overrides?: Partial): TimingStats { } } +function makeEthercatStatus(overrides?: Partial): EtherCATRuntimeStatusResponse { + return { + masters: overrides?.masters ?? [ + { + name: 'master0', + plugin_state: 'OPERATIONAL', + slave_count: 3, + expected_wkc: 6, + slaves: [], + metrics: { + cycle_count: 1000, + wkc_error_count: 0, + avg_cycle_us: 800, + max_cycle_us: 1200, + max_exchange_us: 600, + consecutive_wkc_errors: 0, + recovery_attempts: 0, + }, + }, + ], + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -89,6 +113,8 @@ describe('createDeviceSlice', () => { expect(rc.storedCredentials).toBeNull() expect(rc.timingStats).toBeNull() expect(rc.includeTimingStatsInPolling).toBe(false) + expect(rc.ethercatStatus).toBeNull() + expect(rc.includeEthercatStatsInPolling).toBe(false) }) it('exposes deviceActions object', () => { @@ -292,6 +318,8 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.setStoredCredentials({ username: 'u', password: 'p' }) store.getState().deviceActions.setTimingStats(makeTimingStats()) store.getState().deviceActions.setIncludeTimingStatsInPolling(true) + store.getState().deviceActions.setEthercatStatus(makeEthercatStatus()) + store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) store.getState().deviceActions.clearDeviceDefinitions() const rc = store.getState().runtimeConnection @@ -303,6 +331,8 @@ describe('createDeviceSlice', () => { expect(rc.storedCredentials).toBeNull() expect(rc.timingStats).toBeNull() expect(rc.includeTimingStatsInPolling).toBe(false) + expect(rc.ethercatStatus).toBeNull() + expect(rc.includeEthercatStatsInPolling).toBe(false) }) }) @@ -1143,6 +1173,43 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setEthercatStatus + // ----------------------------------------------------------------------- + describe('setEthercatStatus', () => { + it('sets ethercat status', () => { + const store = makeStore() + const status = makeEthercatStatus() + store.getState().deviceActions.setEthercatStatus(status) + expect(store.getState().runtimeConnection.ethercatStatus).toEqual(status) + }) + + it('clears ethercat status', () => { + const store = makeStore() + store.getState().deviceActions.setEthercatStatus(makeEthercatStatus()) + store.getState().deviceActions.setEthercatStatus(null) + expect(store.getState().runtimeConnection.ethercatStatus).toBeNull() + }) + }) + + // ----------------------------------------------------------------------- + // setIncludeEthercatStatsInPolling + // ----------------------------------------------------------------------- + describe('setIncludeEthercatStatsInPolling', () => { + it('enables ethercat stats in polling', () => { + const store = makeStore() + store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) + expect(store.getState().runtimeConnection.includeEthercatStatsInPolling).toBe(true) + }) + + it('disables ethercat stats in polling', () => { + const store = makeStore() + store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) + store.getState().deviceActions.setIncludeEthercatStatsInPolling(false) + expect(store.getState().runtimeConnection.includeEthercatStatsInPolling).toBe(false) + }) + }) + // ----------------------------------------------------------------------- // setTemporaryDhcpIp // ----------------------------------------------------------------------- @@ -1181,6 +1248,8 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.setStoredCredentials({ username: 'u', password: 'p' }) store.getState().deviceActions.setTimingStats(makeTimingStats()) store.getState().deviceActions.setIncludeTimingStatsInPolling(true) + store.getState().deviceActions.setEthercatStatus(makeEthercatStatus()) + store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) store.getState().deviceActions.clearRuntimeConnection() const rc = store.getState().runtimeConnection @@ -1192,6 +1261,8 @@ describe('createDeviceSlice', () => { expect(rc.storedCredentials).toBeNull() expect(rc.timingStats).toBeNull() expect(rc.includeTimingStatsInPolling).toBe(false) + expect(rc.ethercatStatus).toBeNull() + expect(rc.includeEthercatStatsInPolling).toBe(false) }) it('does not affect device definitions', () => { diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index e78582538..0b9302044 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -39,6 +39,8 @@ const createDeviceSlice: StateCreator = (setSt storedCredentials: null, timingStats: null, includeTimingStatsInPolling: false, + ethercatStatus: null, + includeEthercatStatsInPolling: false, }, deviceActions: { @@ -86,6 +88,8 @@ const createDeviceSlice: StateCreator = (setSt runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null runtimeConnection.includeTimingStatsInPolling = false + runtimeConnection.ethercatStatus = null + runtimeConnection.includeEthercatStatsInPolling = false }), ) }, @@ -473,6 +477,20 @@ const createDeviceSlice: StateCreator = (setSt }), ) }, + setEthercatStatus: (status): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.ethercatStatus = status + }), + ) + }, + setIncludeEthercatStatsInPolling: (include): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.includeEthercatStatsInPolling = include + }), + ) + }, setTemporaryDhcpIp: (ipAddress): void => { setState( produce(({ deviceDefinitions }: DeviceSlice) => { @@ -491,6 +509,8 @@ const createDeviceSlice: StateCreator = (setSt runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null runtimeConnection.includeTimingStatsInPolling = false + runtimeConnection.ethercatStatus = null + runtimeConnection.includeEthercatStatsInPolling = false }), ) }, diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index f45594245..28d2a5b66 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -1,3 +1,4 @@ +import type { EtherCATRuntimeStatusResponse } from '../../../../middleware/shared/ports/ethercat-types' import type { BoardInfo, CommunicationPort, @@ -55,6 +56,8 @@ export type RuntimeConnection = { storedCredentials: StoredCredentials | null timingStats: TimingStats | null includeTimingStatsInPolling: boolean + ethercatStatus: EtherCATRuntimeStatusResponse | null + includeEthercatStatsInPolling: boolean } // --------------------------------------------------------------------------- @@ -135,6 +138,8 @@ export type DeviceActions = { setStoredCredentials: (credentials: StoredCredentials | null) => void setTimingStats: (stats: TimingStats | null) => void setIncludeTimingStatsInPolling: (include: boolean) => void + setEthercatStatus: (status: EtherCATRuntimeStatusResponse | null) => void + setIncludeEthercatStatsInPolling: (include: boolean) => void setTemporaryDhcpIp: (ipAddress?: string) => void clearRuntimeConnection: () => void } diff --git a/src/frontend/utils/__tests__/ethercat-status.test.ts b/src/frontend/utils/__tests__/ethercat-status.test.ts new file mode 100644 index 000000000..4e4e54c4f --- /dev/null +++ b/src/frontend/utils/__tests__/ethercat-status.test.ts @@ -0,0 +1,36 @@ +import type { EtherCATMasterStatus, EtherCATRuntimeStatusResponse } from '@root/middleware/shared/ports/ethercat-types' + +import { normalizeEthercatStatus } from '../ethercat-status' + +const baseMetrics: EtherCATMasterStatus['metrics'] = { + cycle_count: 100, + wkc_error_count: 0, + avg_cycle_us: 250, + max_cycle_us: 400, + max_exchange_us: 120, + consecutive_wkc_errors: 0, + recovery_attempts: 0, +} + +describe('normalizeEthercatStatus', () => { + it('returns an empty array for null', () => { + expect(normalizeEthercatStatus(null)).toEqual([]) + }) + + it('returns an empty array for undefined', () => { + expect(normalizeEthercatStatus(undefined)).toEqual([]) + }) + + it('returns the masters array verbatim when populated', () => { + const masters: EtherCATMasterStatus[] = [ + { name: 'BusA', plugin_state: 'OPERATIONAL', slave_count: 2, expected_wkc: 4, slaves: [], metrics: baseMetrics }, + { name: 'BusB', plugin_state: 'PRE-OP', slave_count: 1, expected_wkc: 2, slaves: [], metrics: baseMetrics }, + ] + const status: EtherCATRuntimeStatusResponse = { masters } + expect(normalizeEthercatStatus(status)).toBe(masters) + }) + + it('returns an empty array when masters is empty', () => { + expect(normalizeEthercatStatus({ masters: [] })).toEqual([]) + }) +}) diff --git a/src/frontend/utils/ethercat-status.ts b/src/frontend/utils/ethercat-status.ts new file mode 100644 index 000000000..dccdc5b33 --- /dev/null +++ b/src/frontend/utils/ethercat-status.ts @@ -0,0 +1,12 @@ +import type { EtherCATMasterStatus, EtherCATRuntimeStatusResponse } from '@root/middleware/shared/ports/ethercat-types' + +/** + * Return the masters array from a runtime status response, or an empty list + * when the runtime hasn't reported any. Lives as a helper so call sites can + * treat the response uniformly without re-implementing the null guard. + */ +export function normalizeEthercatStatus( + status: EtherCATRuntimeStatusResponse | null | undefined, +): EtherCATMasterStatus[] { + return status?.masters ?? [] +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 12330d41f..7b131778a 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -14,7 +14,7 @@ import type { EtherCATValidateRequest, EtherCATValidateResponse, NetworkInterface, -} from '@root/types/ethercat' +} from '@root/middleware/shared/ports/ethercat-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 42702770c..6a0bec706 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,6 +1,5 @@ import type { RuntimeLogEntry } from '@root/middleware/shared/ports' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' -import type { PLCProjectData } from '@root/middleware/shared/ports/types' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -11,7 +10,8 @@ import type { EtherCATValidateRequest, EtherCATValidateResponse, NetworkInterface, -} from '@root/types/ethercat' +} from '@root/middleware/shared/ports/ethercat-types' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { ipcRenderer, IpcRendererEvent } from 'electron' diff --git a/src/types/ethercat/index.ts b/src/middleware/shared/ports/ethercat-types.ts similarity index 92% rename from src/types/ethercat/index.ts rename to src/middleware/shared/ports/ethercat-types.ts index 2fc16e149..0b1d6f392 100644 --- a/src/types/ethercat/index.ts +++ b/src/middleware/shared/ports/ethercat-types.ts @@ -313,23 +313,11 @@ export interface EtherCATMasterStatus { /** * Response from GET /api/discovery/ethercat/runtime-status * - * The runtime returns a "masters" array for multi-master setups. - * For backward compatibility with single-master, flat fields are - * also included at root level when there is exactly one master. + * The runtime returns a "masters" array entry per configured EtherCAT bus. */ export interface EtherCATRuntimeStatusResponse { - /** Per-master status array (always present in multi-master runtime) */ - masters?: EtherCATMasterStatus[] - /** Current plugin state (backward compat: only when single master) */ - plugin_state?: EtherCATPluginState - /** Number of configured slaves (backward compat) */ - slave_count?: number - /** Expected working counter value (backward compat) */ - expected_wkc?: number - /** Per-slave status array (backward compat) */ - slaves?: EtherCATSlaveStatus[] - /** Cycle performance metrics (backward compat) */ - metrics?: EtherCATCycleMetrics + /** Per-master status array (one entry per configured EtherCAT bus) */ + masters: EtherCATMasterStatus[] } /** diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 04d2fb5cc..b4e833c52 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -47,8 +47,7 @@ import type { EtherCATValidateRequest, EtherCATValidateResponse, NetworkInterface, -} from '@root/types/ethercat' - +} from './ethercat-types' import type { PlcStatus, RuntimeLogEntry, SerialPort, TimingStats, Unsubscribe } from './types' export interface LoginParams {