From 2fc42e76f29589ecfb17ea012fc17bc672255b25 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 27 Apr 2026 13:22:32 +0200 Subject: [PATCH 01/16] sync(ethercat): mirror openplc-web feat/ethercat-web-adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches the editor up to the web's branch on the four shared surfaces. The bulk of this is the new EtherCAT statistics card grid on the Device → Orchestrators view; the rest is shared fixes that hadn't been mirrored yet. Surface changes (byte-identical to openplc-web): - frontend/components/_features/[workspace]/editor/device/orchestrators/ orchestrators-list.tsx: enables the new ethercat polling flag on mount, normalises the runtime's two response shapes (multi-master `masters[]` and the flat single-master legacy form) into one array, and renders one `EtherCAT Statistics — ` card grid per master alongside the existing Scan Cycle Statistics. Cards: Master State, Slave Count, Cycle Count, WKC Errors (with consecutive subtitle), Cycle Time avg (with max subtitle), Max Exchange Time, Recovery Attempts (when > 0). - frontend/store/slices/device/{types,slice}.ts and frontend/store/__tests__/device-slice.test.ts: new `ethercatStatus` + `includeEthercatStatsInPolling` fields and matching setters; both cleared on disconnect alongside the existing timing-stats fields. Tests cover the new setters, default initial state, and disconnect cleanup. Slice keeps 100% coverage. - frontend/hooks/use-runtime-polling.ts: when the consumer opts in via the new flag, `getEthercatRuntimeStatus()` rides on the same 2s poll cycle as the PLC status/logs pair (single Promise.all). Soft-fails on transient errors so the UI doesn't flicker between populated and empty. - frontend/components/_features/[workspace]/editor/device/ethercat/ components/runtime-status-panel.tsx: removed the duplicated cycle-metric cards (and the local `MetricCard` helper) so cycle stats live in one place. Panel now focuses on master-state header + per-slave diagnostics table. - frontend/components/_features/[workspace]/editor/device/ethercat/ components/discovered-device-table.tsx: "No XML" badge palette switched from yellow (warning) to neutral gray to match the rest of the table chrome. Tooltip remains the load-bearing signal. - frontend/components/_features/[workspace]/editor/device/ethercat/ index.tsx: narrows `unmatched`/`unmatchedAddAttempt` to `ScannedDeviceMatch['device'][]` to match the post-merge widening of EtherCATDevice; drops the unused `getBestMatchQuality` import; minor prettier reformat. - frontend/components/_features/[workspace]/editor/device/ethercat/ ethercat-device-editor.tsx: promotes "Channel Mappings" to the first tab and the default active tab. Editor-side runtime adapter (not shared) already implements `getEthercatRuntimeStatus()`, so the new polling path works without further code changes on the desktop side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/discovered-device-table.tsx | 48 +++--- .../components/runtime-status-panel.tsx | 31 +--- .../ethercat/ethercat-device-editor.tsx | 4 +- .../editor/device/ethercat/index.tsx | 146 ++++++++++++++---- .../orchestrators/orchestrators-list.tsx | 120 +++++++++++++- src/frontend/hooks/use-runtime-polling.ts | 34 +++- .../store/__tests__/device-slice.test.ts | 71 +++++++++ src/frontend/store/slices/device/slice.ts | 20 +++ src/frontend/store/slices/device/types.ts | 5 + 9 files changed, 393 insertions(+), 86 deletions(-) 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..8e515fce5 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,27 @@ 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)} + role='button' + tabIndex={0} + aria-pressed={isSelected} + onClick={() => onSelectDevice(dm.device.position, !isSelected)} onKeyDown={(e) => { - if (!isSelectable) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() 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', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1', isSelected && 'bg-brand/10 dark:bg-brand/20', - !isSelectable && 'opacity-60', )} > @@ -97,7 +94,6 @@ const DiscoveredDeviceTable = ({ checked={isSelected} onCheckedChange={(checked) => onSelectDevice(dm.device.position, !!checked)} onClick={(e) => e.stopPropagation()} - disabled={!isSelectable} /> @@ -105,9 +101,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/runtime-status-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx index 4b3162a05..a6aac7078 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,7 +1,6 @@ import { cn } from '@root/frontend/utils/cn' import { useRuntime } from '@root/middleware/shared/providers/platform-context' import type { - EtherCATCycleMetrics, EtherCATMasterStatus, EtherCATPluginState, EtherCATRuntimeStatusResponse, @@ -61,18 +60,6 @@ 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 @@ -262,7 +249,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 +276,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/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..a1617ed41 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 { @@ -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,14 @@ 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. + setSelectedScannedDevices(new Set(unmatched.map((d) => d.position))) + + if (unmatched.length > 0) { + setUnmatchedAddAttempt(unmatched) } }, [ selectedScannedDevices, @@ -501,30 +519,8 @@ const EtherCATEditor = () => { className='flex min-h-0 flex-1 flex-col overflow-hidden' > - 0 ? ( - - {scannedDevices.length} - - ) : undefined - } - /> - 0 ? ( - - {repository.length} - - ) : undefined - } - /> + + @@ -583,6 +579,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..95fde159c 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' @@ -15,7 +15,7 @@ import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-edi // 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 +302,45 @@ 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]) + + // Normalise the runtime's two response shapes into a single array. Modern + // runtimes ship `masters[]` (one entry per configured EtherCAT bus); older + // ones inline the fields for a single master at the response root. Either + // way we render one stats section per master. + const ethercatMasters = useMemo(() => { + const status = runtimeConnection.ethercatStatus + if (!status) return [] + if (status.masters && status.masters.length > 0) return status.masters + if (status.plugin_state === undefined) return [] + return [ + { + name: '', + plugin_state: status.plugin_state, + slave_count: status.slave_count ?? 0, + expected_wkc: status.expected_wkc ?? 0, + slaves: status.slaves ?? [], + metrics: status.metrics ?? { + cycle_count: 0, + wkc_error_count: 0, + avg_cycle_us: 0, + max_cycle_us: 0, + max_exchange_us: 0, + consecutive_wkc_errors: 0, + recovery_attempts: 0, + }, + }, + ] + }, [runtimeConnection.ethercatStatus]) + // Handle device switch confirmation const handleConfirmDeviceSwitch = useCallback(async () => { if (!pendingDeviceSwitch) return @@ -642,6 +681,83 @@ const OrchestratorsList = () => { )} + + {ethercatMasters.map((master, idx) => { + // Project supports more than one EtherCAT bus per device; surface + // the bus name in the section header so users can tell which set + // of stats they're looking at. Fall back to a positional label + // for the single-master legacy response shape (no `name`). + const busLabel = master.name || `Bus ${idx + 1}` + const sectionId = master.name ? `ethercat-stats-${master.name}` : `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} + +
+ )} +
+
+ ) + })} )} diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index dcad0d03b..4a6b2ee8f 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,20 @@ 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. + const ethercatPromise = + includeEthercatStatsInPolling && runtime.getEthercatRuntimeStatus + ? runtime.getEthercatRuntimeStatus() + : Promise.resolve(null) + + const [statusResult, logsResult, ethercatResult] = await Promise.all([ runtime.getStatus(includeTimingStatsInPolling), runtime.getLogs(minId), + ethercatPromise, ]) // Process status @@ -101,6 +119,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 +166,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..1f97faec5 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -1,6 +1,7 @@ import { createStore } from 'zustand/vanilla' import type { BoardInfo, CommunicationPort, DevicePin, TimingStats } from '../../../middleware/shared/ports/types' +import type { EtherCATRuntimeStatusResponse } from '../../../types/ethercat' import { createDeviceSlice, DeviceSlice } from '../slices/device' import { defaultDeviceConfiguration } from '../slices/device/data/types' import * as pinsValidation from '../slices/device/validation/pins' @@ -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..e5c7991a0 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -6,6 +6,7 @@ import type { PlcStatus, TimingStats, } from '../../../../middleware/shared/ports/types' +import type { EtherCATRuntimeStatusResponse } from '../../../../types/ethercat' // --------------------------------------------------------------------------- // Device available options @@ -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 } From 476d8cbbde67a7a8ca406ff6ab9b127ee6b46a58 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 27 Apr 2026 13:36:40 +0200 Subject: [PATCH 02/16] sync(ethercat): mirror Configuration screen stats from openplc-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the EtherCAT card grid that openplc-web added to `board.tsx` (the Device → Configuration screen) on its `feat/ethercat-web-adapter` branch. Desktop users hit Configuration as part of their normal flow when picking a device and connecting; this is where they expect the EtherCAT stats to live, alongside the existing Scan Cycle Statistics. Surface change byte-identical to web. Same render shape as orchestrators-list: - enables `includeEthercatStatsInPolling` on mount, clears on unmount; - normalises `masters[]` and the flat single-master legacy form into one array via a `useMemo`; - renders one `EtherCAT Statistics — ` card grid per master inside the existing `isOpenPLCRuntimeTarget` branch, after the Scan Cycle Statistics block. Editor's runtime adapter (not shared) already implements `getEthercatRuntimeStatus()`, so the new polling path works without further code changes on the desktop side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/device/configuration/board.tsx | 127 +++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) 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..00c4986d9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -67,6 +67,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 +320,44 @@ const Board = memo(function () { } }, [setIncludeTimingStatsInPolling]) + // 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(() => { + setIncludeEthercatStatsInPolling(true) + return () => { + setIncludeEthercatStatsInPolling(false) + } + }, [setIncludeEthercatStatsInPolling]) + + // Normalise the runtime's two response shapes into a single array. Modern + // runtimes ship `masters[]` (one entry per configured EtherCAT bus); older + // ones inline the fields for a single master at the response root. Either + // way we render one stats section per master. + const ethercatMasters = useMemo(() => { + if (!ethercatStatus) return [] + if (ethercatStatus.masters && ethercatStatus.masters.length > 0) return ethercatStatus.masters + if (ethercatStatus.plugin_state === undefined) return [] + return [ + { + name: '', + plugin_state: ethercatStatus.plugin_state, + slave_count: ethercatStatus.slave_count ?? 0, + expected_wkc: ethercatStatus.expected_wkc ?? 0, + slaves: ethercatStatus.slaves ?? [], + metrics: ethercatStatus.metrics ?? { + cycle_count: 0, + wkc_error_count: 0, + avg_cycle_us: 0, + max_cycle_us: 0, + max_exchange_us: 0, + consecutive_wkc_errors: 0, + recovery_attempts: 0, + }, + }, + ] + }, [ethercatStatus]) + return ( {!isSimulatorTarget(currentBoardInfo) && ( @@ -517,9 +559,8 @@ const Board = memo(function () {
)} {isSimulatorTarget(currentBoardInfo) ? null : isOpenPLCRuntimeTarget(currentBoardInfo) ? ( - connectionStatus === 'connected' && - timingStats && - timingStats.scan_count > 0 && ( + <> + {connectionStatus === 'connected' && timingStats && timingStats.scan_count > 0 && (

- ) + )} + {connectionStatus === 'connected' && + ethercatMasters.map((master, idx) => { + // Project supports more than one EtherCAT bus per device; surface + // the bus name in the section header so users can tell which set + // of stats they're looking at. Fall back to a positional label + // for the single-master legacy response shape (no `name`). + const busLabel = master.name || `Bus ${idx + 1}` + const sectionId = master.name ? `ethercat-stats-${master.name}` : `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} + +
+ )} +
+
+ ) + })} + ) : (
From 42032c09c7284dc68834a8f8b4ba7fc10ea2fe68 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 27 Apr 2026 13:49:12 +0200 Subject: [PATCH 03/16] sync(arch): route EtherCATRuntimeStatusResponse through the ports layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors openplc-web's fix that re-exports `EtherCATRuntimeStatusResponse` from `middleware/shared/ports/runtime-port.ts` so the device store slice can import it without crossing the Store → Types layer boundary that the architecture validator forbids. Surface stays byte-identical with web. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/frontend/store/__tests__/device-slice.test.ts | 2 +- src/frontend/store/slices/device/types.ts | 2 +- src/middleware/shared/ports/runtime-port.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 1f97faec5..3859637eb 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -1,7 +1,7 @@ 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 type { EtherCATRuntimeStatusResponse } from '../../../types/ethercat' import { createDeviceSlice, DeviceSlice } from '../slices/device' import { defaultDeviceConfiguration } from '../slices/device/data/types' import * as pinsValidation from '../slices/device/validation/pins' diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index e5c7991a0..219274272 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/runtime-port' import type { BoardInfo, CommunicationPort, @@ -6,7 +7,6 @@ import type { PlcStatus, TimingStats, } from '../../../../middleware/shared/ports/types' -import type { EtherCATRuntimeStatusResponse } from '../../../../types/ethercat' // --------------------------------------------------------------------------- // Device available options diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 04d2fb5cc..938117385 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -177,3 +177,9 @@ export interface RuntimePort { /** Get EtherCAT runtime status (plugin state, slave status, cycle metrics). */ getEthercatRuntimeStatus?(): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> } + +// Re-export the EtherCAT runtime-status response type so adjacent layers +// (e.g. the device store slice, which the architecture validator forbids +// from importing types/ via relative paths) can pick it up through the +// ports layer that already legitimately depends on it. +export type { EtherCATRuntimeStatusResponse } from '@root/types/ethercat' From 2fb6ee08d3f76d4899f5039e441ea5939736583c Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 27 Apr 2026 13:53:15 +0200 Subject: [PATCH 04/16] sync(ethercat): apply prettier on board.tsx Mirrors openplc-web's prettier reformat on the new EtherCAT card grid hunks in the Configuration screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/device/configuration/board.tsx | 99 +++++++++---------- 1 file changed, 48 insertions(+), 51 deletions(-) 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 00c4986d9..bc39705e9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -561,60 +561,60 @@ 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 && ( +
+

+ 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' && ethercatMasters.map((master, idx) => { @@ -633,10 +633,7 @@ const Board = memo(function () { EtherCAT Statistics{' '} — {busLabel} -
+
Master State From 5e0930ac13d430f6119363e1584e95796690a89d Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Thu, 30 Apr 2026 17:07:53 +0200 Subject: [PATCH 05/16] refactor(ethercat): mirror PR feedback fixes from openplc-web Mirror of openplc-web@3b6c397f to keep the shared frontend surface byte-identical. Bundles four review-feedback fixes from PR #741: - discovered-device-table: drop the duplicate tab stop and screen-reader announcement on each row. The row carries the button semantics; the checkbox is now presentational (tabIndex={-1}, aria-hidden, pointer-events-none). - use-runtime-polling: swallow a rejected EtherCAT runtime-status call to null inside Promise.all so a transient ethercat error no longer rejects the whole poll cycle and tears down a healthy runtime connection. - ethercat/index.tsx (handleAddSelected): rebuild the scanned-devices selection as `prev minus added` instead of replacing it with the unmatched set so already-configured selections and concurrent state changes are preserved. - Extract the duplicated EtherCAT stats UI from board.tsx and orchestrators-list.tsx into ethercat-stats-section.tsx plus a normalizeEthercatStatus() helper (with unit-test suite). The two call sites consume className/cardsClassName/withSectionId props for their layout variants. Net -171 lines across the two callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/device/configuration/board.tsx | 107 +--------------- .../components/discovered-device-table.tsx | 9 +- .../components/ethercat-stats-section.tsx | 108 +++++++++++++++++ .../editor/device/ethercat/index.tsx | 14 ++- .../orchestrators/orchestrators-list.tsx | 114 ++---------------- src/frontend/hooks/use-runtime-polling.ts | 5 +- .../utils/__tests__/ethercat-status.test.ts | 100 +++++++++++++++ src/frontend/utils/ethercat-status.ts | 35 ++++++ 8 files changed, 282 insertions(+), 210 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/ethercat-stats-section.tsx create mode 100644 src/frontend/utils/__tests__/ethercat-status.test.ts create mode 100644 src/frontend/utils/ethercat-status.ts 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 bc39705e9..281514cf1 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 () { @@ -330,33 +332,7 @@ const Board = memo(function () { } }, [setIncludeEthercatStatsInPolling]) - // Normalise the runtime's two response shapes into a single array. Modern - // runtimes ship `masters[]` (one entry per configured EtherCAT bus); older - // ones inline the fields for a single master at the response root. Either - // way we render one stats section per master. - const ethercatMasters = useMemo(() => { - if (!ethercatStatus) return [] - if (ethercatStatus.masters && ethercatStatus.masters.length > 0) return ethercatStatus.masters - if (ethercatStatus.plugin_state === undefined) return [] - return [ - { - name: '', - plugin_state: ethercatStatus.plugin_state, - slave_count: ethercatStatus.slave_count ?? 0, - expected_wkc: ethercatStatus.expected_wkc ?? 0, - slaves: ethercatStatus.slaves ?? [], - metrics: ethercatStatus.metrics ?? { - cycle_count: 0, - wkc_error_count: 0, - avg_cycle_us: 0, - max_cycle_us: 0, - max_exchange_us: 0, - consecutive_wkc_errors: 0, - recovery_attempts: 0, - }, - }, - ] - }, [ethercatStatus]) + const ethercatMasters = useMemo(() => normalizeEthercatStatus(ethercatStatus), [ethercatStatus]) return ( @@ -616,80 +592,9 @@ const Board = memo(function () {
)} - {connectionStatus === 'connected' && - ethercatMasters.map((master, idx) => { - // Project supports more than one EtherCAT bus per device; surface - // the bus name in the section header so users can tell which set - // of stats they're looking at. Fall back to a positional label - // for the single-master legacy response shape (no `name`). - const busLabel = master.name || `Bus ${idx + 1}` - const sectionId = master.name ? `ethercat-stats-${master.name}` : `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} - -
- )} -
-
- ) - })} + {connectionStatus === 'connected' && ( + + )} ) : (
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 8e515fce5..97bdc1561 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 @@ -90,10 +90,15 @@ const DiscoveredDeviceTable = ({ )} > + {/* The row carries the button semantics (role/tabIndex/aria-pressed); the + checkbox is purely visual so screen-reader and keyboard users don't get + a duplicate tab stop / "button pressed, checkbox checked" announcement + per row. */} onSelectDevice(dm.device.position, !!checked)} - onClick={(e) => e.stopPropagation()} + tabIndex={-1} + aria-hidden='true' + className='pointer-events-none' /> 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..1a479ed8d --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/ethercat-stats-section.tsx @@ -0,0 +1,108 @@ +import { cn } from '@root/frontend/utils/cn' +import type { EtherCATMasterStatus } from '@root/types/ethercat' + +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' + +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) => { + // Project supports more than one EtherCAT bus per device; surface + // the bus name in the section header so users can tell which set + // of stats they're looking at. Fall back to a positional label + // for the single-master legacy response shape (no `name`). + const busLabel = master.name || `Bus ${idx + 1}` + const sectionId = master.name ? `ethercat-stats-${master.name}` : `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/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index a1617ed41..4c0cf3f8e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -429,8 +429,18 @@ const EtherCATEditor = () => { } // Keep unmatched selected so the user can see which ones failed; clear - // the successfully-added ones from the selection. - setSelectedScannedDevices(new Set(unmatched.map((d) => d.position))) + // 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) { + const addedPositions = new Set(newDevices.map((d) => d.position)) + setSelectedScannedDevices((prev) => { + const next = new Set(prev) + for (const position of addedPositions) next.delete(position) + return next + }) + } if (unmatched.length > 0) { setUnmatchedAddAttempt(unmatched) 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 95fde159c..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 @@ -7,9 +7,11 @@ 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. @@ -312,34 +314,10 @@ const OrchestratorsList = () => { } }, [deviceActions]) - // Normalise the runtime's two response shapes into a single array. Modern - // runtimes ship `masters[]` (one entry per configured EtherCAT bus); older - // ones inline the fields for a single master at the response root. Either - // way we render one stats section per master. - const ethercatMasters = useMemo(() => { - const status = runtimeConnection.ethercatStatus - if (!status) return [] - if (status.masters && status.masters.length > 0) return status.masters - if (status.plugin_state === undefined) return [] - return [ - { - name: '', - plugin_state: status.plugin_state, - slave_count: status.slave_count ?? 0, - expected_wkc: status.expected_wkc ?? 0, - slaves: status.slaves ?? [], - metrics: status.metrics ?? { - cycle_count: 0, - wkc_error_count: 0, - avg_cycle_us: 0, - max_cycle_us: 0, - max_exchange_us: 0, - consecutive_wkc_errors: 0, - recovery_attempts: 0, - }, - }, - ] - }, [runtimeConnection.ethercatStatus]) + const ethercatMasters = useMemo( + () => normalizeEthercatStatus(runtimeConnection.ethercatStatus), + [runtimeConnection.ethercatStatus], + ) // Handle device switch confirmation const handleConfirmDeviceSwitch = useCallback(async () => { @@ -682,82 +660,10 @@ const OrchestratorsList = () => { )}
- {ethercatMasters.map((master, idx) => { - // Project supports more than one EtherCAT bus per device; surface - // the bus name in the section header so users can tell which set - // of stats they're looking at. Fall back to a positional label - // for the single-master legacy response shape (no `name`). - const busLabel = master.name || `Bus ${idx + 1}` - const sectionId = master.name ? `ethercat-stats-${master.name}` : `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} - -
- )} -
-
- ) - })} +
)}
diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 4a6b2ee8f..26fab3186 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -90,9 +90,12 @@ export const useRuntimePolling = () => { // 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() + ? runtime.getEthercatRuntimeStatus().catch(() => null) : Promise.resolve(null) const [statusResult, logsResult, ethercatResult] = await Promise.all([ 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..d1364a825 --- /dev/null +++ b/src/frontend/utils/__tests__/ethercat-status.test.ts @@ -0,0 +1,100 @@ +import type { EtherCATMasterStatus, EtherCATRuntimeStatusResponse } from '@root/types/ethercat' + +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('falls through to the legacy single-master shape when masters is empty', () => { + const status: EtherCATRuntimeStatusResponse = { + masters: [], + plugin_state: 'OPERATIONAL', + slave_count: 3, + expected_wkc: 6, + slaves: [], + metrics: baseMetrics, + } + expect(normalizeEthercatStatus(status)).toEqual([ + { + name: '', + plugin_state: 'OPERATIONAL', + slave_count: 3, + expected_wkc: 6, + slaves: [], + metrics: baseMetrics, + }, + ]) + }) + + it('returns an empty array when neither masters nor plugin_state is present', () => { + expect(normalizeEthercatStatus({})).toEqual([]) + }) + + it('synthesises a single master from the flat root fields', () => { + const status: EtherCATRuntimeStatusResponse = { + plugin_state: 'OPERATIONAL', + slave_count: 2, + expected_wkc: 4, + slaves: [], + metrics: baseMetrics, + } + const result = normalizeEthercatStatus(status) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: '', + plugin_state: 'OPERATIONAL', + slave_count: 2, + expected_wkc: 4, + slaves: [], + metrics: baseMetrics, + }) + }) + + it('defaults missing flat fields when synthesising the legacy master', () => { + const status: EtherCATRuntimeStatusResponse = { plugin_state: 'INIT' } + const result = normalizeEthercatStatus(status) + expect(result).toEqual([ + { + name: '', + plugin_state: 'INIT', + slave_count: 0, + expected_wkc: 0, + slaves: [], + metrics: { + cycle_count: 0, + wkc_error_count: 0, + avg_cycle_us: 0, + max_cycle_us: 0, + max_exchange_us: 0, + consecutive_wkc_errors: 0, + recovery_attempts: 0, + }, + }, + ]) + }) +}) diff --git a/src/frontend/utils/ethercat-status.ts b/src/frontend/utils/ethercat-status.ts new file mode 100644 index 000000000..e9cd82540 --- /dev/null +++ b/src/frontend/utils/ethercat-status.ts @@ -0,0 +1,35 @@ +import type { EtherCATMasterStatus, EtherCATRuntimeStatusResponse } from '@root/types/ethercat' + +const EMPTY_METRICS: EtherCATMasterStatus['metrics'] = { + cycle_count: 0, + wkc_error_count: 0, + avg_cycle_us: 0, + max_cycle_us: 0, + max_exchange_us: 0, + consecutive_wkc_errors: 0, + recovery_attempts: 0, +} + +/** + * Normalise the runtime's two response shapes into a single masters array. + * Modern runtimes ship `masters[]` (one entry per configured EtherCAT bus); + * older ones inline the fields for a single master at the response root. + * Either way callers render one stats section per master. + */ +export function normalizeEthercatStatus( + status: EtherCATRuntimeStatusResponse | null | undefined, +): EtherCATMasterStatus[] { + if (!status) return [] + if (status.masters && status.masters.length > 0) return status.masters + if (status.plugin_state === undefined) return [] + return [ + { + name: '', + plugin_state: status.plugin_state, + slave_count: status.slave_count ?? 0, + expected_wkc: status.expected_wkc ?? 0, + slaves: status.slaves ?? [], + metrics: status.metrics ?? EMPTY_METRICS, + }, + ] +} From 78ce5d0e9c4f0a3c122d0d8445a7974bb3129842 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Thu, 30 Apr 2026 17:23:56 +0200 Subject: [PATCH 06/16] fix(ethercat): mirror tsc + prettier follow-up from openplc-web Mirror of openplc-web@61fea7cb to keep the shared frontend surface byte-identical: - ethercat/index.tsx: rebuild the scanned-device selection cleanup so it iterates newDevices directly with a `position !== undefined` guard, fixing the tsc error that broke the web build CI on the earlier mirror commit. - discovered-device-table, ethercat-stats-section: prettier-formatted to satisfy the format CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../device/ethercat/components/discovered-device-table.tsx | 7 +------ .../device/ethercat/components/ethercat-stats-section.tsx | 5 +---- .../_features/[workspace]/editor/device/ethercat/index.tsx | 4 ++-- 3 files changed, 4 insertions(+), 12 deletions(-) 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 97bdc1561..96afad516 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 @@ -94,12 +94,7 @@ const DiscoveredDeviceTable = ({ checkbox is purely visual so screen-reader and keyboard users don't get a duplicate tab stop / "button pressed, checkbox checked" announcement per row. */} -