diff --git a/docs/ethercat-architecture.md b/docs/ethercat-architecture.md new file mode 100644 index 000000000..e9e66e4e2 --- /dev/null +++ b/docs/ethercat-architecture.md @@ -0,0 +1,565 @@ +# EtherCAT Architecture — OpenPLC Editor + +Complete architectural reference of the EtherCAT functionality, from device creation to runtime JSON generation. + +--- + +## 1. Overview + +The EtherCAT system spans both Electron processes: + +``` +Main Process (Node.js) Renderer Process (React) +├── ESI Service (parse, store, query) ├── Zustand Store (project state) +├── IPC Handlers (scan, status, ESI) ├── EtherCAT Editor (bus-level UI) +├── Compiler Module (JSON generation) ├── Device Editor (per-slave UI) +└── Runtime HTTP Client (scan/status) └── Utilities (matching, config gen) +``` + +**Key data flow:** +``` +ESI XML upload → Parse (main) → Repository (disk) + ↓ +Network scan → Match against repo → Add to project → Configure → Generate JSON → Runtime +``` + +--- + +## 2. Type System + +### Core Types (`src/types/ethercat/esi-types.ts`) + +| Type | Purpose | +|------|---------| +| `ESIDevice` | Full parsed ESI device (PDOs, SyncManagers, CoE, FMMUs) | +| `ESIDeviceSummary` | Lightweight summary for repository listing (channel counts, I/O bytes) | +| `ESIChannel` | UI-friendly flattened PDO entry (name, direction, bitLen, offset) | +| `ConfiguredEtherCATDevice` | A device added to the project (config, mappings, persisted PDOs) | +| `EtherCATSlaveConfig` | Per-slave settings (addressing, timeouts, watchdog, DC) | +| `PersistedChannelInfo` / `PersistedPdo` | Runtime-serializable channel/PDO metadata | +| `SDOConfigurationEntry` | CoE SDO startup parameter | +| `ESIRepositoryItemLight` | Repository entry with vendor info and device summaries | +| `ESIDeviceRef` | Pointer to a device in the repository (`repositoryItemId` + `deviceIndex`) | +| `ScannedDeviceMatch` | A scanned device paired with its ESI matches (exact/partial/none) | + +### Discovery Types (`src/types/ethercat/index.ts`) + +| Type | Purpose | +|------|---------| +| `EtherCATDevice` | Network-discovered device (position, vendor_id, product_code, state) | +| `EtherCATScanResponse` | Scan result (status, devices[], scan_time_ms) | +| `NetworkInterface` | Adapter for scanning (name, description) | +| `EtherCATRuntimeStatusResponse` | Runtime state machine (masters/slaves with states, WKC) | + +### Project Persistence (`src/types/PLC/open-plc.ts`) + +```typescript +PLCRemoteDevice { + name: string + protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' + ethercatConfig?: EthercatConfig +} + +EthercatConfig { + masterConfig?: EtherCATMasterConfig // interface, cycleTimeUs, watchdogTimeoutCycles + devices: ConfiguredEtherCATDevice[] +} +``` + +Stored in `project.json` under `data.remoteDevices[]`. + +--- + +## 3. ESI Repository System + +### Storage Layout + +``` +/devices/esi/ +├── repository.json # v2 index with device summaries +├── .xml # Individual ESI XML files +├── .xml +└── ... +``` + +### ESI Service (`src/main/services/esi-service/index.ts`) + +Runs in the **main process**. Key methods: + +| Method | Purpose | +|--------|---------| +| `parseAndSaveFile(projectPath, filename, content)` | Parse XML, save with UUID, append to v2 index | +| `loadRepositoryLight(projectPath)` | Fast load from v2 index (pre-computed summaries) | +| `loadDeviceFull(projectPath, itemId, deviceIndex)` | On-demand full parse of a specific device | +| `deleteRepositoryItemV2(projectPath, itemId)` | Remove XML + update index | +| `clearRepository(projectPath)` | Delete all ESI files and index | +| `migrateRepositoryToV2(projectPath)` | Convert v1 (metadata-only) → v2 (with summaries) | + +### ESI Parser (`src/main/services/esi-service/esi-parser-main.ts`) + +Two parsing modes: + +1. **Light** — `parseESILight(xmlString, filename?)` → `ESIDeviceSummary[]` + - Counts PDO entries without building full structures + - Returns: `inputChannelCount`, `outputChannelCount`, `totalInputBytes`, `totalOutputBytes` + - Used for repository listing + +2. **Full** — `parseESIDeviceFull(xmlString, deviceIndex)` → `ESIDevice` + - On-demand when configuring a specific device + - Returns complete: FMMUs, SyncManagers, RxPDOs, TxPDOs, CoE Objects + +Parser details: +- Uses `fast-xml-parser` +- Handles localized text (prefers English LcId 1033) +- Normalizes hex formats (`#x`, `0x`) +- CoE Dictionary: DataTypes + Objects with PDO mapping metadata + +### IPC Bridge for ESI + +``` +Renderer Main +window.bridge.esiParseAndSaveFile() → handleESIParseAndSaveFile() +window.bridge.esiLoadRepositoryLight() → handleESILoadRepositoryLight() +window.bridge.esiLoadDeviceFull() → handleESILoadDeviceFull() +window.bridge.esiDeleteXmlFile() → handleESIDeleteXmlFile() +window.bridge.esiClearRepository() → handleESIClearRepository() +window.bridge.esiMigrateRepository() → handleESIMigrateRepository() +``` + +Defined in `src/main/modules/ipc/main.ts` (handlers) and `src/main/modules/ipc/renderer.ts` (invocations). + +--- + +## 4. Remote Device (Bus) Creation + +### Store Action (`src/renderer/store/slices/project/slice.ts`) + +`createRemoteDevice(device: PLCRemoteDevice)`: +1. Validates name doesn't conflict with POUs/datatypes +2. Pushes to `project.data.remoteDevices[]` +3. **Auto-creates a system task** for EtherCAT: + +```typescript +{ + name: "EtherCAT_", // ethercatTaskName() + triggering: 'Cyclic', + interval: "T#1ms", // cycleTimeUsToIecInterval(1000) + priority: 1, + isSystemTask: true, + associatedDevice: deviceName, +} +``` + +Related actions: +- `deleteRemoteDevice(name)` — removes device + associated system task +- `updateRemoteDeviceName(oldName, newName)` — renames both device and task +- `updateEthercatConfig(deviceName, ethercatConfig)` — updates config and **syncs cycle time to task interval** + +### Task Helpers (`src/utils/ethercat/ethercat-task-helpers.ts`) + +| Function | Output | +|----------|--------| +| `ethercatTaskName("Master1")` | `"EtherCAT_Master1"` | +| `cycleTimeUsToIecInterval(1000)` | `"T#1ms"` | +| `cycleTimeUsToIecInterval(500)` | `"T#500us"` | + +--- + +## 5. Device Scanning (Online) + +### IPC Handlers (`src/main/modules/ipc/main.ts`) + +| Handler | Runtime Endpoint | Purpose | +|---------|-----------------|---------| +| `handleEtherCATGetStatus` | `GET /api/discovery/ethercat/status` | Service availability | +| `handleEtherCATGetInterfaces` | `POST /api/plugin-command` | List network adapters | +| `handleEtherCATScan` | `POST /api/plugin-command` | Discover devices on interface | +| `handleEtherCATTest` | `POST /api/discovery/ethercat/test` | Test connection | +| `handleEtherCATValidate` | `POST /api/discovery/ethercat/validate` | Validate configuration | +| `handleEtherCATGetRuntimeStatus` | Runtime API | Master/slave state machine | + +Scan request payload: +```json +{ + "plugin": "ethercat", + "command": "scan", + "params": { "interface": "eth0", "timeout_ms": 5000 } +} +``` + +Scan response: +```typescript +{ + status: 'success' | 'error', + devices: EtherCATDevice[], // { position, name, vendor_id, product_code, revision, state, input_bytes, output_bytes } + message: string, + scan_time_ms: number, +} +``` + +### Renderer IPC (`src/main/modules/ipc/renderer.ts`) + +```typescript +window.bridge.etherCATGetStatus(ipAddress, jwtToken) +window.bridge.etherCATGetInterfaces(ipAddress, jwtToken) +window.bridge.etherCATScan(ipAddress, jwtToken, { interface, timeout_ms }) +``` + +--- + +## 6. Device Matching + +**File:** `src/utils/ethercat/device-matcher.ts` + +`matchDevicesToRepository(scannedDevices, repository)` → `ScannedDeviceMatch[]` + +Match quality levels: +| Quality | Criteria | +|---------|----------| +| **exact** | Vendor ID + Product Code + Revision all match | +| **partial** | Vendor ID + Product Code match (revision differs) | +| **none** | No match found | + +Output per scanned device: +```typescript +{ + device: EtherCATDevice, // from scan + matches: DeviceMatch[], // sorted by quality (best first) +} +``` + +Helper: `countMatchedDevices(matches)` → `{ total, exact, partial, none }` + +--- + +## 7. Adding a Device to the Project + +Two paths: + +### A. From Scan (online) + +1. User selects matched devices in the scan table +2. `handleAddSelectedFromScan()` in `EtherCATEditor`: + - Loads full ESI data via `esiLoadDeviceFull()` + - Enriches with `enrichDeviceData()` + - Creates `ConfiguredEtherCATDevice` with `addedFrom: 'scan'` + - Calls `syncDevicesToStore()` + +### B. From Repository (offline) + +1. User clicks `+` button → `DeviceBrowserModal` opens +2. Selects a device from the ESI repository +3. `handleAddDeviceFromBrowser()` in `EtherCATEditor`: + - Loads full ESI data via `esiLoadDeviceFull()` + - Enriches with `enrichDeviceData()` + - Creates `ConfiguredEtherCATDevice` with `addedFrom: 'repository'` + - Assigns next available position + - Calls `syncDevicesToStore()` + +### Device Enrichment (`src/utils/ethercat/enrich-device-data.ts`) + +`enrichDeviceData(esiDevice)` extracts fields spread into `ConfiguredEtherCATDevice`: + +| Field | Source | +|-------|--------| +| `channelInfo: PersistedChannelInfo[]` | `buildChannelInfo()` — full channel metadata with IEC types | +| `rxPdos, txPdos: PersistedPdo[]` | `persistPdos()` — PDOs with padding preserved | +| `slaveType: string` | `deriveSlaveType()` — heuristic: `digital_input`, `analog_output`, `coupler`, etc. | +| `sdoConfigurations: SDOConfigurationEntry[]` | `extractDefaultSdoConfigurations()` — RW objects in 0x2000+ range | + +### Default Slave Config (`src/utils/ethercat/device-config-defaults.ts`) + +`createDefaultSlaveConfig()` returns: +```typescript +{ + startupChecks: { checkVendorId: true, checkProductCode: true }, + addressing: { ethercatAddress: 0 }, // 0 = auto from position + timeouts: { sdoTimeoutMs: 1000, initToPreOpTimeoutMs: 3000, safeOpToOpTimeoutMs: 10000 }, + watchdog: { smWatchdogEnabled: true, smWatchdogMs: 100, pdiWatchdogEnabled: false, ... }, + distributedClocks: { dcEnabled: false, dcSync0Enabled: false, ... }, +} +``` + +--- + +## 8. Device Configuration (Per-Slave) + +### Editor Components + +``` +src/renderer/components/_features/[workspace]/editor/device/ethercat/ +├── index.tsx # Bus-level editor (3 tabs: Network, Repository, Advanced) +├── ethercat-device-editor.tsx # Per-device editor (4 tabs below) +└── components/ + ├── scan-bus-tab.tsx # Network scan + configured devices list + ├── repository-tab.tsx # ESI file management + ├── advanced-tab.tsx # Master config (cycle time, watchdog) + ├── device-configuration-form.tsx # Slave config forms (addressing, DC, watchdog) + ├── channel-mapping-table.tsx # IEC variable mapping table + ├── sdo-parameters-table.tsx # CoE SDO configuration table + ├── device-browser-modal.tsx # Modal for browsing ESI repo to add devices + ├── interface-selector.tsx # Network interface dropdown + └── discovered-device-table.tsx # Scan results table +``` + +### Per-Device Editor Tabs + +1. **Device Info** — vendor, product code, revision, source, channel counts +2. **Configuration** — `DeviceConfigurationForm` with sections: + - Addressing (EtherCAT address) + - Timeouts (SDO, init→preop, safeop→op) + - Watchdog (SM watchdog, PDI watchdog) + - Distributed Clocks (DC sync0/sync1) +3. **Startup Parameters** — `SdoParametersSection`: editable CoE SDO entries +4. **Channel Mappings** — `ChannelMappingsSection`: IEC 61131-3 located variable assignments + +### Channel Mapping Utilities (`src/utils/ethercat/esi-parser.ts`) + +| Function | Purpose | +|----------|---------| +| `pdoToChannels(device)` | Flatten PDOs → `ESIChannel[]` (skips padding `0x0000`) | +| `generateIecLocation(channel, offset?)` | Generate `%IX0.0`, `%QW2`, etc. | +| `generateDefaultChannelMappings(channels, usedAddresses?)` | Auto-generate non-conflicting IEC addresses | +| `esiTypeToIecType(dataType, bitLen)` | Map ESI types → IEC types (BOOL, BYTE, WORD, ...) | + +IEC location format: +``` +%. + direction: I (input) or Q (output) + size: X (bit), B (byte), W (word), D (dword), L (lword) + Example: BOOL input at byte 0, bit 2 → %IX0.2 +``` + +### SDO Extraction (`src/utils/ethercat/sdo-config-defaults.ts`) + +`extractDefaultSdoConfigurations(coeObjects)` → `SDOConfigurationEntry[]` + +- Filters CoE objects in range 0x2000+ (user-configurable) +- Excludes system objects (0x0000–0x1FFF) +- For complex objects: extracts RW sub-items with default values + +### Device Configuration Hook (`src/renderer/hooks/use-device-configuration.ts`) + +`useDeviceConfiguration({ device, projectPath, ... })` provides: +- Lazy-loads full ESI device on first render +- Manages channel list and CoE objects +- Handles alias changes in channel mappings +- Provides `updateConfig()` for slave config sections + +--- + +## 9. State Management + +### Zustand Store Slices + +The EtherCAT state lives primarily in the **Project Slice** (`src/renderer/store/slices/project/slice.ts`): + +``` +project.data.remoteDevices[] → PLCRemoteDevice[] + └── ethercatConfig + ├── masterConfig: { networkInterface, cycleTimeUs, watchdogTimeoutCycles } + └── devices: ConfiguredEtherCATDevice[] +``` + +Key actions on the project slice: + +| Action | Purpose | +|--------|---------| +| `createRemoteDevice()` | Add bus + auto-create system task | +| `deleteRemoteDevice()` | Remove bus + delete system task | +| `updateEthercatConfig()` | Update master config and/or device list, sync task interval | +| `updateRemoteDeviceName()` | Rename bus + associated task | + +The **Editor Slice** manages the active editor model: +- Bus editor: `type: 'plc-remote-device'`, `meta: { name, protocol: 'ethercat' }` +- Device editor: `type: 'plc-ethercat-device'`, `meta: { name, busName, deviceId }` + +### EtherCAT Editor State (`src/renderer/components/.../ethercat/index.tsx`) + +Local component state in `EtherCATEditor`: + +``` +Repository: ESIRepositoryItemLight[] (loaded from ESI service) +Interfaces: NetworkInterface[] (fetched from runtime) +Scan: scannedDevices[], deviceMatches[], selectedScannedDevices +Service: serviceAvailable, serviceMessage +``` + +Store-derived: +``` +configuredDevices = remoteDevice.ethercatConfig.devices +masterConfig = remoteDevice.ethercatConfig.masterConfig +``` + +--- + +## 10. JSON Configuration Generation for Runtime + +### Generator (`src/utils/ethercat/generate-ethercat-config.ts`) + +`generateEthercatConfig(remoteDevices[])` → JSON string or `null` + +Iterates all remote devices with `protocol === 'ethercat'`, produces: + +```typescript +interface RuntimeRootEntry { + name: string + protocol: "ETHERCAT" + config: { + master: { + interface: string // "eth0" + cycle_time_us: number + watchdog_timeout_cycles: number + task_name?: string // "EtherCAT_Master1" + task_cycle_time_us?: number + } + slaves: RuntimeSlave[] + diagnostics: { + log_connections: true + log_data_access: false + log_errors: true + max_log_entries: 10000 + status_update_interval_ms: 500 + } + } +} +``` + +Each slave: +```typescript +interface RuntimeSlave { + position: number + name: string + type: string // "digital_input", "analog_output", etc. + vendor_id: string // "0x0002" + product_code: string + revision: string + config: { + startup_checks: { ... } + addressing: { ethercat_address: number } + timeouts: { ... } + watchdog: { ... } + distributed_clocks: { ... } + } + channels: RuntimeChannel[] // { index, name, type, bit_length, iec_location, pdo refs } + sdo_configurations: RuntimeSdoConfig[] + rx_pdos: RuntimePdo[] // Full PDO layout with padding + tx_pdos: RuntimePdo[] +} +``` + +Channel type derivation: `deriveChannelType(direction, bitLen)` → `"digital_input"` | `"analog_input"` | `"digital_output"` | `"analog_output"` + +SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF`), float, negative. + +### Compiler Integration (`src/main/modules/compiler/compiler-module.ts`) + +`handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, handleOutputData)`: + +1. Calls `generateEthercatConfig(projectData.remoteDevices)` +2. Creates `conf/` directory in firmware build folder +3. Writes `conf/ethercat.json` +4. Part of the larger build pipeline alongside `modbus-master.json`, `s7comm.json`, `opcua.json` + +--- + +## 11. End-to-End Flow + +``` +1. CREATE BUS + UI: Add Remote Device → protocol: ethercat + Store: createRemoteDevice() → remoteDevices[] + system task + +2. UPLOAD ESI FILES + UI: Repository tab → drag & drop XML + IPC: esiParseAndSaveFile() → main process → parseESILight() + Disk: devices/esi/.xml + repository.json + +3. SCAN NETWORK (online) or ADD MANUALLY (offline) + Online: + IPC: etherCATScan() → runtime HTTP → EtherCATScanResponse + Match: matchDevicesToRepository() → exact/partial/none + Select: user picks matched devices → handleAddSelectedFromScan() + Offline: + UI: + button → DeviceBrowserModal → select from repository + Handler: handleAddDeviceFromBrowser() + +4. ENRICH & STORE + IPC: esiLoadDeviceFull() → parseESIDeviceFull() + Util: enrichDeviceData() → channelInfo, PDOs, slaveType, SDOs + Store: updateEthercatConfig() → devices[] + sync task interval + +5. CONFIGURE DEVICE + UI: Click device in tree → EtherCATDeviceEditor + Tabs: Config (addressing, DC, watchdog), SDO params, Channel mappings + Store: updateEthercatConfig() on each change + +6. BUILD FIRMWARE + Compiler: generateEthercatConfig(remoteDevices) + Output: conf/ethercat.json + Runtime: loads JSON → initializes master/slaves → starts cyclic task +``` + +--- + +## 12. File Reference + +### Types +| File | Contents | +|------|----------| +| `src/types/ethercat/esi-types.ts` | ESIDevice, ConfiguredEtherCATDevice, channels, PDOs, SDOs | +| `src/types/ethercat/index.ts` | EtherCATDevice, scan/status responses, NetworkInterface | +| `src/types/PLC/open-plc.ts` | Zod schemas: EthercatConfig, EtherCATMasterConfig, PLCRemoteDevice | + +### Main Process +| File | Contents | +|------|----------| +| `src/main/services/esi-service/index.ts` | ESI file persistence and repository management | +| `src/main/services/esi-service/esi-parser-main.ts` | XML parsing (light and full modes) | +| `src/main/modules/ipc/main.ts` | IPC handlers for scan, status, ESI operations | +| `src/main/modules/ipc/renderer.ts` | Renderer-side IPC bridge (`window.bridge.*`) | +| `src/main/modules/compiler/compiler-module.ts` | Firmware build: writes `conf/ethercat.json` | + +### Renderer — Utilities +| File | Contents | +|------|----------| +| `src/utils/ethercat/device-matcher.ts` | Match scanned devices against ESI repository | +| `src/utils/ethercat/enrich-device-data.ts` | Extract persistable data from full ESI device | +| `src/utils/ethercat/esi-parser.ts` | pdoToChannels, generateIecLocation, default mappings | +| `src/utils/ethercat/device-config-defaults.ts` | DEFAULT_SLAVE_CONFIG | +| `src/utils/ethercat/sdo-config-defaults.ts` | Extract default SDO configurations from CoE | +| `src/utils/ethercat/ethercat-task-helpers.ts` | Task naming, cycle time conversion | +| `src/utils/ethercat/generate-ethercat-config.ts` | Generate runtime JSON from project state | + +### Renderer — Components +| File | Contents | +|------|----------| +| `src/renderer/components/.../ethercat/index.tsx` | Bus-level editor (Network, Repository, Advanced tabs) | +| `src/renderer/components/.../ethercat/ethercat-device-editor.tsx` | Per-device editor (Info, Config, SDO, Channels tabs) | +| `src/renderer/components/.../ethercat/components/scan-bus-tab.tsx` | Scan UI + configured devices list with +/- | +| `src/renderer/components/.../ethercat/components/device-browser-modal.tsx` | Browse ESI repo to add devices | +| `src/renderer/components/.../ethercat/components/device-configuration-form.tsx` | Slave config forms | +| `src/renderer/components/.../ethercat/components/channel-mapping-table.tsx` | IEC variable mapping | +| `src/renderer/components/.../ethercat/components/sdo-parameters-table.tsx` | CoE SDO editing | + +### Renderer — Store +| File | Contents | +|------|----------| +| `src/renderer/store/slices/project/slice.ts` | createRemoteDevice, updateEthercatConfig, delete, rename | +| `src/renderer/store/slices/editor/types.ts` | Editor model schema (plc-ethercat-device variant) | +| `src/renderer/store/slices/tabs/utils.ts` | CreateEtherCATDeviceEditor | +| `src/renderer/store/slices/shared/index.ts` | openFile, closeFile, forceCloseFile, deleteEthercatDevice | +| `src/renderer/hooks/use-device-configuration.ts` | Lazy-load full device, manage channels/SDOs | + +--- + +## 13. Constraints & Notes + +1. **ESI parsing is CPU-bound** — runs in main process. Sequential uploads recommended for UI responsiveness. +2. **Cycle time ↔ task sync** — `updateEthercatConfig()` auto-updates the associated system task interval. +3. **Address uniqueness** — IEC addresses must be unique across all remote devices (Modbus + EtherCAT). `usedAddresses` is tracked when generating mappings. +4. **CoE SDO range** — only objects in 0x2000+ are user-configurable. System objects (0x0000–0x1FFF) are runtime-managed. +5. **PDO padding** — entries with `index: "0x0000"` are padding: excluded from channel lists but preserved in persisted PDOs for correct byte offsets. +6. **System tasks** — auto-created on device creation, auto-deleted on removal. Marked with `isSystemTask: true`. +7. **Repository v2 migration** — old v1 (metadata-only) auto-migrates to v2 (with device summaries) on first load. +8. **Editor caching** — editor models are cached by `meta.name`. When removing a device, its tab/editor must be explicitly closed to avoid stale `deviceId` references on re-add. diff --git a/docs/strucpp-migration/04-debugger.md b/docs/strucpp-migration/04-debugger.md new file mode 100644 index 000000000..40035b44a --- /dev/null +++ b/docs/strucpp-migration/04-debugger.md @@ -0,0 +1,441 @@ +# Phase 4: Debugger + +## Status: Design locked, ready to implement + +## Goals + +- Replace the flat `debug_vars[]` mechanism (MatIEC + xml2st) with a scalable scheme built + on STruC++'s `IECVar` forcing API. +- Work well for small embedded targets (~50 variables on Arduino Mega) **and** large + Linux projects (50K+ variables on Runtime v4). +- Minimize rewrite of the editor's upper layers — the composite-key tree, polling loop, + forcing UI, and Zustand store should remain effectively unchanged. +- Full leaf-level addressability: every array element and every struct/FB field is + independently readable and forceable. + +## Why the current design fails + +The MatIEC pipeline generates `debug.c` containing a single `debug_vars[]` array with one +entry per **leaf** (arrays and structs expanded element-by-element). Real-world failure +mode observed on a user project: + +``` +src/debug.c:32926:1: error: size of array is too large +``` + +That project had ~33,000 leaves. AVR GCC's `size_t` is 16-bit, so a single object cannot +exceed 32,767 bytes — with a multi-field struct per entry the ceiling is hit well before +that element count. Moving metadata to Flash is necessary but not sufficient: the +*single-array* constraint itself is the blocker. + +## Design summary + +1. **Every leaf is expanded.** No runtime walking of composite variables — each array + element and struct/FB field gets its own table entry. +2. **Multiple debug arrays.** The compiler emits the table as a set of arrays, each capped + at 8,000 entries (safe margin under AVR's 32,767-byte object limit, assuming 4 B/entry). + On Linux the same split is used for consistency. +3. **Compact per-entry format.** `struct Entry { void* ptr; uint8_t type_tag; uint8_t _pad; }` + = 4 bytes. Entry table lives in Flash (`PROGMEM` on AVR, `.rodata` on Linux). Zero SRAM + cost. +4. **Agnostic runtime dispatcher.** Part of the STruC++ runtime headers — not per-project + generated code. A `TypeOps` table indexed by `type_tag` holds function pointers to + `force_impl`, `unforce_impl`, `read_impl` instantiated for each IEC elementary + type. Covers all leaves because STruC++ wraps every leaf as `IECVar` where T is an + elementary type. +5. **Protocol addressing: `(array_idx: u8, elem_idx: u16)`.** 256 arrays × 65K entries = + 16M addressable leaves. More than enough for any realistic project. +6. **Two-stage rollout:** polling-based first (Modbus FCs 0x41–0x45 with the new PDU + layout), subscription/stream later (0x46–0x48). Subscription is orthogonal to addressing + and can ship as a follow-up without breaking the base protocol. + +--- + +## What gets generated vs. what ships in the runtime + +### Per-project (emitted by `strucpp-compiler.ts`) + +**`generated_debug.cpp`** — pointer tables only: + +```cpp +#include "generated.hpp" +#include "strucpp/debug_dispatch.hpp" + +using namespace strucpp::debug; + +// Each array caps at ~8000 entries to stay under AVR's 32767-byte single-object limit. +const Entry debug_arr_0[] PROGMEM = { + { (void*)&g_config.INSTANCE0.blink, TAG_BOOL }, + { (void*)&g_config.INSTANCE0.counter, TAG_INT }, + { (void*)&g_config.INSTANCE0.speeds[0], TAG_INT }, + { (void*)&g_config.INSTANCE0.speeds[1], TAG_INT }, + // ... +}; +const Entry debug_arr_1[] PROGMEM = { /* next batch */ }; + +const Entry* const debug_arrays[] PROGMEM = { debug_arr_0, debug_arr_1 }; +const uint16_t debug_array_counts[] PROGMEM = { 8000, 5231 }; +const uint8_t debug_array_count = 2; +``` + +**`debug-map.json`** — editor-only manifest: + +```json +{ + "version": 2, + "md5": "", + "typeTags": { + "BOOL": 0, "SINT": 1, "INT": 2, "DINT": 3, "LINT": 4, + "REAL": 5, "LREAL": 6, "STRING": 7, "TIME": 8 + }, + "arrays": [ + { "index": 0, "count": 8000 }, + { "index": 1, "count": 5231 } + ], + "leaves": [ + { "arrayIdx": 0, "elemIdx": 0, "path": "INSTANCE0.blink", "type": "BOOL", "size": 1 }, + { "arrayIdx": 0, "elemIdx": 1, "path": "INSTANCE0.counter", "type": "INT", "size": 2 }, + { "arrayIdx": 0, "elemIdx": 2, "path": "INSTANCE0.speeds[0]", "type": "INT", "size": 2 }, + { "arrayIdx": 0, "elemIdx": 3, "path": "INSTANCE0.speeds[1]", "type": "INT", "size": 2 } + ] +} +``` + +Packing rule: emit leaves in declaration order, flush to a new debug array when the +current one reaches 8,000 entries **or** at a program boundary (whichever comes first). +Program-boundary flush isolates per-program churn from downstream arrays. + +### Shared across all projects (STruC++ runtime headers) + +**`strucpp/debug_dispatch.hpp`** — the generic handler, unchanged across projects: + +```cpp +namespace strucpp::debug { + +enum TypeTag : uint8_t { + TAG_BOOL = 0, TAG_SINT, TAG_USINT, TAG_INT, TAG_UINT, + TAG_DINT, TAG_UDINT, TAG_LINT, TAG_ULINT, + TAG_REAL, TAG_LREAL, + TAG_BYTE, TAG_WORD, TAG_DWORD, TAG_LWORD, + TAG_TIME, TAG_DATE, TAG_TOD, TAG_DT, + TAG_STRING, TAG_WSTRING, + TAG__COUNT +}; + +struct Entry { void* ptr; uint8_t tag; uint8_t _pad; }; + +template +static void force_impl(void* p, const uint8_t* bytes) { + T v; memcpy(&v, bytes, sizeof(T)); + static_cast*>(p)->force(v); +} +template +static void unforce_impl(void* p) { + static_cast*>(p)->unforce(); +} +template +static void read_impl(const void* p, uint8_t* dest) { + T v = static_cast*>(p)->get(); + memcpy(dest, &v, sizeof(T)); +} + +struct TypeOps { + void (*force) (void*, const uint8_t*); + void (*unforce)(void*); + void (*read) (const void*, uint8_t*); + uint8_t size; +}; + +constexpr TypeOps type_ops[TAG__COUNT] = { + /*BOOL */ { force_impl, unforce_impl, read_impl, 1 }, + /*SINT */ { force_impl, unforce_impl, read_impl, 1 }, + /*... */ + /*STRING*/ { /* special-case: variable-length */ nullptr, nullptr, nullptr, 0 }, +}; + +inline Entry read_entry(uint8_t arr, uint16_t elem); // PROGMEM-aware accessor + +inline void handle_set(uint8_t arr, uint16_t elem, bool forcing, const uint8_t* bytes) { + auto e = read_entry(arr, elem); + if (forcing) type_ops[e.tag].force(e.ptr, bytes); + else type_ops[e.tag].unforce(e.ptr); +} + +inline void handle_read(uint8_t arr, uint16_t elem, uint8_t* dest) { + auto e = read_entry(arr, elem); + type_ops[e.tag].read(e.ptr, dest); +} + +} // namespace strucpp::debug +``` + +STRING/WSTRING need special handling (variable length). Wire format: +`{uint16_t length, bytes[length]}`. Implementation reads/writes into `IECString` via +a specialization rather than the generic `read_impl` template. + +### PROGMEM access on ATmega2560 + +Debug tables will cross the 64 KB Flash boundary for large projects. The `read_entry()` +accessor uses `pgm_read_*_far()` on AVR, a plain array access everywhere else: + +```cpp +inline Entry read_entry(uint8_t arr, uint16_t elem) { +#ifdef __AVR__ + uint32_t base = pgm_get_far_address(debug_arrays); + const Entry* table = (const Entry*)pgm_read_word_far(base + arr * sizeof(void*)); + Entry e; + // read 4 bytes from PROGMEM, handling far address construction + uint32_t entry_addr = pgm_get_far_address(*table) + elem * sizeof(Entry); + e.ptr = (void*)pgm_read_word_far(entry_addr); + e.tag = pgm_read_byte_far(entry_addr + 2); + return e; +#else + return debug_arrays[arr][elem]; +#endif +} +``` + +Slight perf cost (~4 cycles extra per lookup) — irrelevant at debugger polling cadence. + +--- + +## Wire protocol + +Function codes keep the 0x41–0x45 numbering (same Modbus dispatcher structure), but the +addressing fields change from `u16 flat_index` to `(u8 array_idx, u16 elem_idx)`. + +| FC | Name | Request payload | Response payload | +|------|---------------------|--------------------------------------------------------|--------------------------------------------------| +| 0x41 | DEBUG_INFO | (empty) | `[array_count:u8, (elem_count:u16)×array_count]` | +| 0x42 | DEBUG_SET | `arr:u8, elem:u16, force:u8, len:u16, value...` | `status:u8` | +| 0x43 | DEBUG_GET_RANGE | `arr:u8, start_elem:u16, end_elem:u16` | `status:u8, last_elem:u16, tick:u32, size:u16, data...` | +| 0x44 | DEBUG_GET_LIST | `count:u16, (arr:u8, elem:u16)×count` | `status:u8, last_idx:u16, tick:u32, size:u16, data...` | +| 0x45 | DEBUG_GET_MD5 | `endian_check:u16` | `status:u8, md5:ascii, endian_echo:u16` | + +Phase 4b additions (subscribe/stream): + +| FC | Name | Request payload | Response payload | +|------|---------------------|--------------------------------------------------------|--------------------------------------------------| +| 0x46 | WATCH_SUBSCRIBE | `interval_ms:u16, count:u16, (arr:u8, elem:u16)×count` | `handle:u8, status:u8` | +| 0x47 | WATCH_UNSUBSCRIBE | `handle:u8` | `status:u8` | +| 0x48 | STREAM (unsolicited)| — | `handle:u8, tick:u32, size:u16, data...` | + +Notes: + +- Endianness: data payloads are native byte order of the target (no swap on the wire). + Editor probes with FC 0x45 and byte-swaps locally if target differs. Matches current + behavior. +- Chunking: same as today — if a GET_RANGE / GET_LIST response exceeds the Modbus frame + limit, the target returns what fits and reports `last_idx`. Editor retries from there. +- Per-array addressing implies that `DEBUG_GET_RANGE` operates within a single debug + array. Cross-array batch reads use `DEBUG_GET_LIST`. +- Unsolicited STREAM frames break strict Modbus master/slave semantics but are safe in + practice because OpenPLC targets use Modbus RTU over USB-CDC (full-duplex) or TCP. + RS-485 half-duplex users should stay on polling mode. + +--- + +## Implementation plan + +### 4.1 STruC++ runtime — debug dispatch headers *(ships in strucpp@≥0.3.0)* + +- `src/runtime/include/debug_dispatch.hpp` — `TypeTag` enum, `Entry` struct, `TypeOps` table, + templated `force_impl` / `unforce_impl` / `read_impl`, STRING/WSTRING specializations, + PROGMEM-aware `read_entry()`. +- `src/runtime/include/debug_handler.hpp` — protocol-level helpers: + `handle_info()`, `handle_set()`, `handle_get_range()`, `handle_get_list()`, + `handle_get_md5()` — frame-agnostic (take input/output buffers, return bytes written). +- Unit tests in the STruC++ repo covering the templated helpers with a mocked Entry table. + +**Exit criteria:** STruC++ runtime exports a single `strucpp::debug::handle_*` API that the +editor's Arduino sketch and the Runtime v4 `.so` can both call. No per-project code here. + +### 4.2 Editor — code generator for `generated_debug.cpp` + `debug-map.json` + +Location: `src/backend/shared/utils/PLC/generate-debug-table.ts` + +Inputs: +- `CompileResult` from the STruC++ compile wrapper (AST + symbol tables + project model). +- The `program.st` source (for MD5). + +Outputs (written to the board's `src/` directory alongside `generated.cpp`): +- `generated_debug.cpp` (Flash-resident pointer tables, packing rule described above). +- `debug-map.json` (editor-side path→address manifest). + +Core algorithm: + +``` +for each program instance in projectModel: + for each variable in program.vars (in declaration order): + walk(variable, path = "INSTANCE0.varName"): + if leaf (elementary type): + emit entry { &path, tag(type) } + elif array: + for i in dimensions: + walk(element, path = "${path}[${i}]") + elif struct/FB: + for field in fields: + walk(field, path = "${path}.${field.name}") + flush to new debug array // program-boundary flush +``` + +Cap per array at 8,000 entries; new array also starts when an element would push the +byte count past 32,000 (safety margin vs. AVR's 32,767 limit). + +### 4.3 Editor — compiler-module wiring + +`src/backend/editor/compiler/compiler-module.ts`: +- After `handleCompileSTtoCpp()` produces `generated.cpp/.hpp`, call + `generateDebugTable(compileResult)` to emit the two new files. +- Add `generated_debug.cpp` to the list of sources arduino-cli compiles (lives in the + `src/` library directory, picked up automatically). +- No changes to the Arduino sketch itself — it doesn't need to know about debug tables. + +### 4.4 Embedded — ModbusSlave integration + +`resources/sources/StrucppBaremetal/ModbusSlave.cpp`: +- Replace the existing 0x41–0x45 handler bodies (which call the MatIEC-era + `get_var_count()` / `get_var_addr()` / `set_trace()` weak externs) with direct calls + into `strucpp::debug::handle_*`. +- Drop the weak-extern declarations. +- Behavior change for FC 0x41: returns `{array_count, (elem_count)×N}` instead of a + single `var_count`. PDU grows slightly but remains under the Modbus frame limit even + for 256 arrays (≤513 bytes — chunk if exceeded, though realistic projects have ≤10 + arrays). + +### 4.5 Editor — frontend and middleware changes + +These are the **only** editor-side changes (kept minimal on purpose): + +**`src/middleware/shared/ports/debugger-port.ts`:** +```ts +export interface DebugAddr { arrayIdx: number; elemIdx: number } + +getVariablesList(refs: DebugAddr[]): Promise +setVariable(ref: DebugAddr, force: boolean, valueBuffer?: Uint8Array): Promise +readDebugMap(projectPath: string, boardTarget: string): Promise +``` + +**`src/frontend/utils/debug-parser.ts`:** Add `parseDebugMapV2()` alongside existing +`parseDebugFile()`. Both paths exist only long enough to flip users over — v1 (MatIEC) is +removed once Phase 4 ships. + +**`src/frontend/utils/debugger-session.ts` (`buildVariableIndexMap`):** The composite key +continues to be `pouName:varName.field[idx]`. The value stored in +`workspace.debugVariableIndexes` changes from `number` to `DebugAddr`. Everything +downstream that reads this map updates accordingly (a small, mechanical edit — the polling +loop, the force handler, the tree builder all go through this map). + +**`src/frontend/hooks/useDebugPolling.ts`:** No structural change — same batching logic, +same 50/200 ms cadences, same round-robin. Just passes `DebugAddr[]` through to the port +instead of `number[]`. + +**`src/frontend/hooks/useDebugSession.ts`:** Reads `debug-map.json` at session start +(via `debuggerPort.readDebugMap()`) instead of `debug.c`. The rest of the lifecycle +(MD5 verify, build tree, start polling) is unchanged. + +**Tree builder (`debug-tree-builder.ts` / `debug-tree-traversal.ts`):** Today it expands +arrays eagerly using the flat index. Under v2 it keeps doing the same expansion — the +source of truth moves from `debug.c` to `debug-map.json`, but the UI shape and composite +keys stay identical. A later optimization could lazy-load array children on expand, but +it's not needed for correctness and is out of scope for this phase. + +**IPC adapter (`debugger-adapter.ts`):** Serializes `DebugAddr` pairs over the bridge. +Trivial. + +### 4.6 Subscribe / stream (Phase 4b — follow-up) + +Deferred until Phase 4a is stable and the basic polling path is validated end-to-end. + +Target side: +- `strucpp::debug::handle_subscribe()` stores `{handle, interval_ms, address_list[]}` in + a fixed-size table (max ~4 subscriptions). On each scan cycle, if `tick * scan_ms >= + next_emit_time`, assemble a 0x48 frame and hand it to `ModbusSlave.sendUnsolicited()`. +- `ModbusSlave` gets a new `sendUnsolicited(frame, len)` method. Must not interleave with + a request currently being served — use a tiny flag. + +Editor side: +- `debuggerPort.subscribe(addrs, intervalMs) -> handle` and a handler in `useDebugPolling` + that, when subscriptions are active, stops polling those addresses and listens for + STREAM frames instead. + +### 4.7 Runtime v4 integration + +`src/backend/shared/utils/PLC/generate-v4-compat.ts` already exports C-linkage shims for +the Runtime v4 `.so`. Add: + +```c +extern "C" uint8_t strucpp_debug_array_count(); +extern "C" uint16_t strucpp_debug_elem_count(uint8_t arr); +extern "C" void strucpp_debug_set (uint8_t arr, uint16_t elem, bool force, + const uint8_t* bytes, uint16_t len); +extern "C" void strucpp_debug_read(uint8_t arr, uint16_t elem, + uint8_t* dest, uint16_t* size_out); +``` + +These are thin wrappers over `strucpp::debug::handle_*`. The runtime's existing +`debug_handler.c` is replaced by a much smaller shim that parses Modbus PDUs and calls +these exports. + +--- + +## Scalability summary + +| Project size | Debug arrays | Flash (table) | SRAM | +|--------------|--------------|---------------|------| +| 50 leaves | 1 | 200 B | 0 B | +| 500 leaves | 1 | 2 KB | 0 B | +| 3,500 leaves | 1 | 14 KB | 0 B | +| 20,000 leaves| 3 | 80 KB | 0 B | +| 50,000 leaves| 7 | 200 KB | 0 B | + +(AVR Mega has 256 KB Flash — fits projects up to ~50K leaves *in theory*; realistic +embedded deployments top out well below that.) + +--- + +## Testing strategy + +1. **Unit tests (STruC++ repo):** `debug_dispatch.hpp` force/read cycle for each + `TypeTag`, using a synthetic Entry table and a mock IECVar. +2. **Compile test:** run `generate-debug-table.ts` against a project with mixed scalars, + arrays, structs, FBs → verify `generated_debug.cpp` compiles on AVR (arduino-cli + `arduino:avr:mega`) and `debug-map.json` shape matches. +3. **Size regression:** the 35K-leaf user project that currently fails MatIEC must + compile cleanly with the new pipeline. +4. **End-to-end (Arduino Mega):** reuse the Chris Demo blink project. Force `blink := + TRUE` from the editor, verify PB7 stays HIGH across scan cycles. Unforce, verify + oscillation resumes. Validate in the avr8js simulator first, then hardware. +5. **End-to-end (Runtime v4):** same blink project compiled as `.so`, dlopen'd by the + runtime, debugger connects via WebSocket, force/read verified. +6. **Regression on existing hierarchical UI:** the composite-key tree, force badges, + graph/plot, and per-FB instance switching must render identically. + +--- + +## Out of scope for Phase 4 + +- Breakpoints / step / continue. STruC++ doesn't have source-level step support at this + point; separate initiative. +- On-demand / lazy array expansion in the UI. Full eager expansion is kept for + compatibility; an optimization pass can add lazy loading later if large-array + rendering becomes a bottleneck. +- Per-variable rate limiting on subscriptions. A single interval per subscription handle + is sufficient for Phase 4b. + +--- + +## Open items to confirm during implementation + +1. **`g_config` static address stability on AVR.** The `&g_config.INSTANCE0.counter` + expressions in `generated_debug.cpp` must be constant expressions for PROGMEM + initializers. If the AVR linker ever decides `g_config` needs dynamic construction + (it shouldn't — it's a static POD-constructible instance), we'd need to populate the + table at runtime in `setup()`. Verify with a test compile. +2. **String length encoding.** Fixed-size `IECString` has a runtime length field plus + up to N bytes of data. Wire encoding for Phase 4a: `{u16 len, bytes[len]}`. Confirm + that forcing a longer value than N is rejected cleanly (return an error status in FC + 0x42). +3. **MD5 scope.** Current MatIEC MD5 covers `program.st`. Under v2 the debug-map layout + depends on STruC++ version too — consider hashing `{program.st, strucpp_version}` so + that a runtime library bump invalidates stale editor state automatically. diff --git a/package-lock.json b/package-lock.json index 5838f9729..e0b94399b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.6.0", "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", @@ -5141,6 +5142,18 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nodable/entities": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", + "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -15714,6 +15727,42 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", + "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^1.1.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -23729,6 +23778,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -27175,6 +27239,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/package.json b/package.json index 682f985d1..7d3c473d2 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", + "fast-xml-parser": "^5.6.0", "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", diff --git a/src/App.tsx b/src/App.tsx index d3512da51..063ce3364 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,7 @@ import { import { StartScreen } from './frontend/screens/start-screen' import { WorkspaceScreen } from './frontend/screens/workspace-screen' import { openPLCStoreBase, useOpenPLCStore } from './frontend/store' -import { editorPorts, setRuntimeIpAddress } from './middleware/editor-platform' +import { editorPorts, setProjectPath, setRuntimeIpAddress } from './middleware/editor-platform' import { PlatformProvider } from './middleware/shared/providers' // Initialize system libraries at module load time (before first render) @@ -66,6 +66,12 @@ export default function App() { setRuntimeIpAddress(runtimeIpAddress) }, [runtimeIpAddress]) + // Sync project path to the platform adapter so the ESI port can access it + const projectPath = useOpenPLCStore((state) => state.project.meta.path) + useEffect(() => { + setProjectPath(projectPath) + }, [projectPath]) + return ( {path === '' ? : } diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 73a000836..94bd4c7ea 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' 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 type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices' import type { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { @@ -183,7 +184,13 @@ class CompilerModule { async #getBoardRuntime(board: string) { const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) - return halsFileContent[board]['compiler'] + if (halsFileContent[board]) { + return halsFileContent[board]['compiler'] + } + + // Board not found in hals.json or installed VPP packages + + throw new Error(`Board "${board}" not found in hals.json or installed VPP packages`) } #executeXml2st(args: string[]) { @@ -1328,6 +1335,24 @@ class CompilerModule { } } + async handleGenerateEthercatConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + handleOutputData: HandleOutputDataCallback, + ): Promise { + const ethercatConfig = generateEthercatConfig(projectData.remoteDevices) + + if (ethercatConfig) { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + const configFilePath = join(confFolderPath, 'ethercat.json') + await writeFile(configFilePath, ethercatConfig, 'utf-8') + handleOutputData('Generated conf/ethercat.json', 'info') + } else { + handleOutputData('No EtherCAT devices configured, skipping ethercat.json generation', 'info') + } + } + async embedCBlocksInProgramSt( sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, @@ -1429,7 +1454,9 @@ class CompilerModule { }) // --- Check for unsupported features on non-v4 targets --- - const isRuntimeV4 = boardTarget === 'OpenPLC Runtime v4' + // VPP boards with runtime-v4 target type use openplc-compiler and are also v4-capable + const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' + const isRuntimeV4 = boardRuntime === 'openplc-compiler' && !isRuntimeV3 const hasServers = projectData.servers && projectData.servers.length > 0 const hasRemoteDevices = projectData.remoteDevices && projectData.remoteDevices.length > 0 @@ -1683,6 +1710,43 @@ class CompilerModule { message: 'Source files generated successfully at: ' + sourceTargetFolderPath, }) + // Generate Runtime v4 conf/* files for BOTH compile-only and upload flows. + // Without this, compile-only never produces ethercat.json (and other configs), + // so users who only want the generated sources miss runtime configuration. + if (isRuntimeV4) { + try { + await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + await this.handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error generating Runtime v4 configs: ${error instanceof Error ? error.message : String(error)}`, + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + } + if (compileOnly) { _mainProcessPort.postMessage({ logLevel: 'info', @@ -1714,8 +1778,6 @@ class CompilerModule { } try { - const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' - let fileBuffer: Buffer let filename: string let contentType: string @@ -1737,31 +1799,8 @@ class CompilerModule { filename = 'program.st' contentType = 'text/plain' } else { - // Clean conf folder from previous compilations to avoid stale config files - await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate Modbus Slave config for Runtime v4 - await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate Modbus Master config for Runtime v4 - await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate S7Comm config for Runtime v4 - await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Generate OPC-UA config for Runtime v4 - await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - + // Runtime v4 conf/* files were already generated above, before the + // compile-only early return, so compile-only flows also get them. _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compressing source files for OpenPLC Runtime v4...', diff --git a/src/backend/editor/ethercat/esi-service.ts b/src/backend/editor/ethercat/esi-service.ts new file mode 100644 index 000000000..8540d7259 --- /dev/null +++ b/src/backend/editor/ethercat/esi-service.ts @@ -0,0 +1,522 @@ +import type { ESIDeviceSummary, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { promises } from 'fs' +import { basename, dirname, join } from 'path' +import { v4 as uuidv4 } from 'uuid' + +import { parseESILight } from '../../shared/ethercat/esi-parser-main' +import { fileOrDirectoryExists } from '../utils' + +/** + * ESI Repository Index stored in devices/esi/repository.json + * Version 2 includes device summaries inline for instant loading. + * + * The in-disk format keeps loadedAt as Unix ms (number) for backward + * compatibility with existing project files. The shared + * ESIRepositoryItem{Light} types expose loadedAt as an ISO 8601 string + * (matching the web backend); conversion happens at every border. + */ +interface ESIRepositoryIndex { + version: number + items: Array<{ + id: string + filename: string + vendorId: string + vendorName: string + deviceCount: number + loadedAt: number + warnings?: string[] + devices?: ESIDeviceSummary[] + }> +} + +/** + * Convert a loadedAt value coming from the shared types (string, ISO 8601) + * into the Unix ms format persisted on disk. Tolerates numeric inputs + * during the transition so nothing breaks if an older caller still passes + * a number. + */ +function isoToMs(value: string | number): number { + if (typeof value === 'number') return value + const ms = new Date(value).getTime() + return Number.isFinite(ms) ? ms : Date.now() +} + +/** + * Convert a loadedAt value from the in-disk format (Unix ms) into the + * ISO 8601 string exposed through the shared types. + */ +function msToIso(value: number): string { + return new Date(value).toISOString() +} + +/** + * Response type for ESI service operations + */ +interface ESIServiceResponse { + success: boolean + error?: string +} + +/** + * ESI Service - Handles persistence of ESI XML files in the project + * + * ESI files are stored in the project's devices/esi/ directory. + * Each XML file is saved with its UUID as filename. + * A repository.json index file tracks all loaded ESI files. + */ +class ESIService { + private readonly ESI_DIR = 'devices/esi' + private readonly REPOSITORY_FILE = 'repository.json' + private indexLock: Promise = Promise.resolve() + + /** + * Serialize access to the repository index to prevent race conditions. + */ + private withIndexLock(fn: () => Promise): Promise { + const current = this.indexLock + let resolve: () => void + this.indexLock = new Promise((r) => (resolve = r)) + return current.then(fn).finally(() => resolve!()) + } + + /** + * Get the ESI directory path for a project + */ + private getEsiDir(projectPath: string): string { + const basePath = basename(projectPath) === 'project.json' ? dirname(projectPath) : projectPath + return join(basePath, this.ESI_DIR) + } + + /** + * Get the repository index file path + */ + private getRepositoryPath(projectPath: string): string { + return join(this.getEsiDir(projectPath), this.REPOSITORY_FILE) + } + + private static readonly UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + /** + * Get the path for an ESI XML file (validates itemId is a UUID to prevent path traversal) + */ + private getXmlPath(projectPath: string, itemId: string): string { + if (!ESIService.UUID_REGEX.test(itemId)) { + throw new Error(`Invalid item ID: ${itemId}`) + } + return join(this.getEsiDir(projectPath), `${itemId}.xml`) + } + + /** + * Ensure the ESI directory exists + */ + private async ensureEsiDir(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) { + await promises.mkdir(esiDir, { recursive: true }) + } + } + + /** + * Load the ESI repository index from disk + */ + async loadRepositoryIndex(projectPath: string): Promise { + const repoPath = this.getRepositoryPath(projectPath) + + let content: string + try { + content = await promises.readFile(repoPath, 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + // Re-throw I/O errors (permission, locked file, ...) so callers don't + // silently overwrite what is probably a working file. + throw error + } + + try { + return JSON.parse(content) as ESIRepositoryIndex + } catch (parseError) { + // Corrupted JSON: rename the bad file to `.corrupt-.bak` and + // treat the repository as empty. This gives callers a clear recovery + // path (re-parse the XMLs on disk) instead of being stuck with a fatal + // throw on every load, while preserving the original file for forensics. + const backupPath = `${repoPath}.corrupt-${Date.now()}.bak` + try { + await promises.rename(repoPath, backupPath) + console.warn( + `[ESIService] Corrupted repository index at ${repoPath} — backed up to ${backupPath} and treating repository as empty.`, + parseError, + ) + } catch (renameError) { + console.error( + `[ESIService] Corrupted repository index at ${repoPath} and backup rename failed. Leaving file in place.`, + { parseError, renameError }, + ) + } + return null + } + } + + /** + * Save the ESI repository index to disk in v2 format with device summaries + */ + async saveRepositoryIndexV2(projectPath: string, items: ESIRepositoryItemLight[]): Promise { + try { + await this.ensureEsiDir(projectPath) + + const index: ESIRepositoryIndex = { + version: 2, + items: items.map((item) => ({ + id: item.id, + filename: item.filename, + vendorId: item.vendor.id, + vendorName: item.vendor.name, + deviceCount: item.devices.length, + loadedAt: isoToMs(item.loadedAt), + warnings: item.warnings, + devices: item.devices, + })), + } + + const repoPath = this.getRepositoryPath(projectPath) + const tmpPath = `${repoPath}.tmp` + await promises.writeFile(tmpPath, JSON.stringify(index, null, 2), 'utf-8') + await promises.rename(tmpPath, repoPath) + + return { success: true } + } catch (error) { + console.error('Error saving ESI repository index v2:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save repository index', + } + } + } + + /** + * Save an ESI XML file to disk + */ + async saveXmlFile(projectPath: string, itemId: string, xmlContent: string): Promise { + try { + await this.ensureEsiDir(projectPath) + + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.writeFile(xmlPath, xmlContent, 'utf-8') + + return { success: true } + } catch (error) { + console.error('Error saving ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save XML file', + } + } + } + + /** + * Load an ESI XML file from disk + */ + async loadXmlFile( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + const content = await promises.readFile(xmlPath, 'utf-8') + return { success: true, content } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: 'XML file not found' } + } + console.error('Error loading ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load XML file', + } + } + } + + /** + * Delete an ESI XML file from disk + */ + async deleteXmlFile(projectPath: string, itemId: string): Promise { + try { + const xmlPath = this.getXmlPath(projectPath, itemId) + await promises.unlink(xmlPath) + return { success: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: true } // already deleted + } + console.error('Error deleting ESI XML file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete XML file', + } + } + } + + /** + * Delete a repository item and update the v2 index (no re-parsing) + */ + async deleteRepositoryItemV2(projectPath: string, itemId: string): Promise { + return this.withIndexLock(async () => { + try { + // Delete the XML file + const deleteResult = await this.deleteXmlFile(projectPath, itemId) + if (!deleteResult.success) { + return deleteResult + } + + // Update the v2 index without the deleted item + const currentItems = await this.loadLightItemsFromIndex(projectPath) + const updatedItems = currentItems.filter((i) => i.id !== itemId) + return this.saveRepositoryIndexV2(projectPath, updatedItems) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete repository item', + } + } + }) + } + + /** + * Load light items from the v2 repository index. + * + * Assumes the caller has already confirmed the index is fully v2-shaped + * (all items carry `devices`). Items missing `devices` are treated as a + * schema violation — they were already rejected by `loadRepositoryLight`. + */ + private async loadLightItemsFromIndex(projectPath: string): Promise { + const index = await this.loadRepositoryIndex(projectPath) + if (!index || index.version !== 2) return [] + return index.items.map((i) => ({ + id: i.id, + filename: i.filename, + vendor: { id: i.vendorId, name: i.vendorName }, + devices: i.devices ?? [], + loadedAt: msToIso(i.loadedAt), + warnings: i.warnings, + })) + } + + /** + * Load repository as lightweight items (v2 instant, v1 needs migration). + * + * A v2 index is only considered valid when ALL items carry a `devices` + * array. If any item is missing it (partial write, manual edit, external + * corruption), we flag the whole index as needing re-migration so the + * user's view never silently drops items. + */ + async loadRepositoryLight( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> { + try { + const index = await this.loadRepositoryIndex(projectPath) + + if (!index) { + return { success: true, items: [] } + } + + if (index.items.length === 0) { + return { success: true, items: [] } + } + + // V2 index is valid only when every item has inline device summaries. + // Any item missing `devices` means we either have a v1 index or a + // partially-migrated v2 — in both cases, trigger migration. + const allItemsHaveDevices = index.version === 2 && index.items.every((i) => i.devices !== undefined) + if (allItemsHaveDevices) { + const items = await this.loadLightItemsFromIndex(projectPath) + return { success: true, items } + } + + return { success: true, needsMigration: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load repository', + } + } + } + + /** + * Migrate a v1 repository to v2 by re-parsing all XML files with parseESILight + */ + async migrateRepositoryToV2( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> { + return this.withIndexLock(async () => { + try { + const index = await this.loadRepositoryIndex(projectPath) + if (!index) { + return { success: true, items: [] } + } + + const items: ESIRepositoryItemLight[] = [] + + for (const indexItem of index.items) { + try { + const xmlResult = await this.loadXmlFile(projectPath, indexItem.id) + if (xmlResult.success && xmlResult.content) { + const parseResult = parseESILight(xmlResult.content, indexItem.filename) + if (parseResult.success && parseResult.vendor && parseResult.devices) { + items.push({ + id: indexItem.id, + filename: indexItem.filename, + vendor: parseResult.vendor, + devices: parseResult.devices, + loadedAt: msToIso(indexItem.loadedAt), + warnings: parseResult.warnings || indexItem.warnings, + }) + } + } + } catch (err) { + console.error(`Failed to migrate ESI file ${indexItem.filename}:`, err) + } + } + + // Save as v2 + const saveResult = await this.saveRepositoryIndexV2(projectPath, items) + if (!saveResult.success) { + return { success: false, error: saveResult.error || 'Failed to save migrated index' } + } + + return { success: true, items } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate repository', + } + } + }) + } + + /** + * Parse and save a single ESI file. Returns the saved item on success. + * Called once per file from the renderer's sequential upload loop. + * + * ## Duplicate handling + * When `filename` already exists in the repository index, we return + * `{ success: true, duplicate: true }` WITHOUT `item`. The adapter (and + * ultimately the UI) treats this as a no-op rather than an error — uploading + * the same file again is not a failure, it's just nothing to do. Callers + * that need to distinguish "added" from "skipped" must check `duplicate` or + * the presence of `item`. + */ + async parseAndSaveFile( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; duplicate?: boolean; error?: string }> { + // Parse outside the lock (CPU-bound, no index access) + const parseResult = parseESILight(content, filename) + if (!parseResult.success || !parseResult.vendor || !parseResult.devices) { + return { success: false, error: parseResult.error || 'Parse failed' } + } + + return this.withIndexLock(async () => { + try { + // Check for duplicate + const existingIndex = await this.loadRepositoryIndex(projectPath) + const existingFilenames = new Set(existingIndex?.items.map((i) => i.filename) ?? []) + if (existingFilenames.has(filename)) { + // Skip duplicate: success with an explicit `duplicate` flag so the + // caller can tell this apart from a real add without inferring it + // from a missing `item`. + return { success: true, duplicate: true } + } + + // Save XML to disk + const itemId = uuidv4() + const saveResult = await this.saveXmlFile(projectPath, itemId, content) + if (!saveResult.success) { + return { success: false, error: saveResult.error ?? 'Failed to save XML file' } + } + + const item: ESIRepositoryItemLight = { + id: itemId, + filename, + vendor: parseResult.vendor!, + devices: parseResult.devices!, + loadedAt: new Date().toISOString(), + warnings: parseResult.warnings, + } + + // Append to v2 index. If the index write fails, delete the XML we just + // wrote so we don't leave an orphan that's unreferenced by the index. + const currentItems = await this.loadLightItemsFromIndex(projectPath) + const indexResult = await this.saveRepositoryIndexV2(projectPath, [...currentItems, item]) + if (!indexResult.success) { + await this.deleteXmlFile(projectPath, itemId) + return { success: false, error: indexResult.error ?? 'Failed to update repository index' } + } + + return { success: true, item } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + }) + } + + /** + * Clear the entire ESI repository: delete all XML files and reset the index. + */ + async clearRepository(projectPath: string): Promise { + return this.withIndexLock(async () => { + try { + const esiDir = this.getEsiDir(projectPath) + if (!fileOrDirectoryExists(esiDir)) return { success: true } + + // Delete only XML files, not the index + const entries = await promises.readdir(esiDir) + const xmlFiles = entries.filter((entry) => entry.endsWith('.xml')) + await Promise.all(xmlFiles.map((entry) => promises.unlink(join(esiDir, entry)))) + + // Write empty v2 index + const repoPath = this.getRepositoryPath(projectPath) + await promises.writeFile(repoPath, JSON.stringify({ version: 2, items: [] }, null, 2), 'utf-8') + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear repository', + } + } + }) + } + + /** + * Check if ESI directory exists for a project + */ + hasEsiDirectory(projectPath: string): boolean { + return fileOrDirectoryExists(this.getEsiDir(projectPath)) + } + + /** + * Get list of XML files in the ESI directory + */ + async listXmlFiles(projectPath: string): Promise { + const esiDir = this.getEsiDir(projectPath) + + if (!fileOrDirectoryExists(esiDir)) { + return [] + } + + try { + const entries = await promises.readdir(esiDir) + return entries.filter((entry) => entry.endsWith('.xml')) + } catch { + return [] + } + } +} + +export { ESIService } +export type { ESIRepositoryIndex, ESIServiceResponse } diff --git a/src/backend/editor/ethercat/index.ts b/src/backend/editor/ethercat/index.ts new file mode 100644 index 000000000..2a452f194 --- /dev/null +++ b/src/backend/editor/ethercat/index.ts @@ -0,0 +1,2 @@ +export type { ESIRepositoryIndex, ESIServiceResponse } from './esi-service' +export { ESIService } from './esi-service' diff --git a/src/backend/shared/ethercat/collect-used-iec-addresses.ts b/src/backend/shared/ethercat/collect-used-iec-addresses.ts new file mode 100644 index 000000000..0145b16b6 --- /dev/null +++ b/src/backend/shared/ethercat/collect-used-iec-addresses.ts @@ -0,0 +1,41 @@ +/** + * Collect every IEC address (e.g. `%QX0.0`, `%IW2`) currently in use across + * all remote devices in the project — Modbus TCP I/O points and EtherCAT + * channel mappings. Used to seed `generateDefaultChannelMappings` so newly + * added devices receive non-conflicting IEC locations. + * + * Typed structurally to accept both the schema-derived `PLCRemoteDevice` + * type and the store's slightly looser inferred type without coupling. + */ + +type RemoteDeviceForAddressCollection = { + modbusTcpConfig?: { + ioGroups?: Array<{ + ioPoints?: Array<{ iecLocation: string }> + }> + } + ethercatConfig?: { + devices?: Array<{ + channelMappings?: Array<{ iecLocation: string }> + }> + } +} + +export function collectUsedIecAddresses(remoteDevices: RemoteDeviceForAddressCollection[] | undefined): Set { + const addresses = new Set() + if (!remoteDevices) return addresses + + for (const rd of remoteDevices) { + for (const group of rd.modbusTcpConfig?.ioGroups ?? []) { + for (const point of group.ioPoints ?? []) { + addresses.add(point.iecLocation) + } + } + for (const dev of rd.ethercatConfig?.devices ?? []) { + for (const mapping of dev.channelMappings ?? []) { + addresses.add(mapping.iecLocation) + } + } + } + return addresses +} diff --git a/src/backend/shared/ethercat/device-config-defaults.ts b/src/backend/shared/ethercat/device-config-defaults.ts new file mode 100644 index 000000000..483e7f8f7 --- /dev/null +++ b/src/backend/shared/ethercat/device-config-defaults.ts @@ -0,0 +1,50 @@ +import type { EtherCATSlaveConfig } from '@root/middleware/shared/ports/esi-types' + +/** + * Default per-slave configuration for a newly added EtherCAT device. + * Values based on SOEM defaults and industrial best practices. + */ +export const DEFAULT_SLAVE_CONFIG: Readonly = { + startupChecks: { + checkVendorId: true, + checkProductCode: true, + }, + addressing: { + ethercatAddress: 0, + }, + timeouts: { + sdoTimeoutMs: 1000, + initToPreOpTimeoutMs: 3000, + safeOpToOpTimeoutMs: 10000, + }, + watchdog: { + smWatchdogEnabled: true, + smWatchdogMs: 100, + pdiWatchdogEnabled: false, + pdiWatchdogMs: 100, + }, + distributedClocks: { + dcEnabled: false, + dcSyncUnitCycleUs: 0, + dcSync0Enabled: false, + dcSync0CycleUs: 0, + dcSync0ShiftUs: 0, + dcSync1Enabled: false, + dcSync1CycleUs: 0, + dcSync1ShiftUs: 0, + }, +} + +/** + * Creates a fresh mutable copy of the default slave config. + * Each device gets its own config object to avoid shared-reference mutations. + */ +export function createDefaultSlaveConfig(): EtherCATSlaveConfig { + return { + startupChecks: { ...DEFAULT_SLAVE_CONFIG.startupChecks }, + addressing: { ...DEFAULT_SLAVE_CONFIG.addressing }, + timeouts: { ...DEFAULT_SLAVE_CONFIG.timeouts }, + watchdog: { ...DEFAULT_SLAVE_CONFIG.watchdog }, + distributedClocks: { ...DEFAULT_SLAVE_CONFIG.distributedClocks }, + } +} diff --git a/src/backend/shared/ethercat/device-matcher.ts b/src/backend/shared/ethercat/device-matcher.ts new file mode 100644 index 000000000..1c048f068 --- /dev/null +++ b/src/backend/shared/ethercat/device-matcher.ts @@ -0,0 +1,177 @@ +/** + * EtherCAT Device Matcher Utility + * + * Provides functions to match scanned EtherCAT devices against ESI repository items. + */ + +import type { + DeviceMatch, + DeviceMatchQuality, + ESIRepositoryItemLight, + ScannedDeviceMatch, +} from '@root/middleware/shared/ports/esi-types' +import type { EtherCATDevice } from '@root/types/ethercat' + +/** + * Parse a hex string to a number for comparison. + * Handles formats: "0x1234", "#x1234", "1234". + * Returns NaN for unparseable input so callers can explicitly reject invalid IDs + * instead of silently coercing them to 0 and producing false matches. + */ +function parseHexToNumber(hexString: string): number { + const cleaned = hexString.replace(/#x/gi, '0x') + return Number(cleaned) +} + +/** + * Determine the match quality between a scanned device and an ESI device + */ +function getMatchQuality( + scannedVendorId: number, + scannedProductCode: number, + scannedRevision: number, + esiVendorId: string, + esiProductCode: string, + esiRevisionNo: string, +): DeviceMatchQuality { + const esiVendorNum = parseHexToNumber(esiVendorId) + const esiProductNum = parseHexToNumber(esiProductCode) + const esiRevisionNum = parseHexToNumber(esiRevisionNo) + + // Reject ESI entries whose numeric IDs failed to parse — without this, + // unparseable strings used to collapse to 0 and falsely match real devices. + if (Number.isNaN(esiVendorNum) || Number.isNaN(esiProductNum) || Number.isNaN(esiRevisionNum)) { + return 'none' + } + + // Check vendor ID first - must match for any level of match + if (scannedVendorId !== esiVendorNum) { + return 'none' + } + + // Check product code - must match for partial or exact + if (scannedProductCode !== esiProductNum) { + return 'none' + } + + // Check revision - exact match requires revision match + if (scannedRevision === esiRevisionNum) { + return 'exact' + } + + // Vendor and product match, but different revision + return 'partial' +} + +/** + * Find all matches for a single scanned device in the repository + */ +function findMatchesForDevice(scannedDevice: EtherCATDevice, repository: ESIRepositoryItemLight[]): DeviceMatch[] { + const matches: DeviceMatch[] = [] + + for (const repoItem of repository) { + const esiVendorId = repoItem.vendor.id + + for (let deviceIndex = 0; deviceIndex < repoItem.devices.length; deviceIndex++) { + const esiDevice = repoItem.devices[deviceIndex] + const matchQuality = getMatchQuality( + scannedDevice.vendor_id, + scannedDevice.product_code, + scannedDevice.revision, + esiVendorId, + esiDevice.type.productCode, + esiDevice.type.revisionNo, + ) + + if (matchQuality !== 'none') { + matches.push({ + repositoryItemId: repoItem.id, + deviceIndex, + matchQuality, + esiDevice, + }) + } + } + } + + // Sort matches: exact first, then partial + matches.sort((a, b) => { + if (a.matchQuality === 'exact' && b.matchQuality !== 'exact') return -1 + if (a.matchQuality !== 'exact' && b.matchQuality === 'exact') return 1 + return 0 + }) + + return matches +} + +/** + * Match an array of scanned devices against the ESI repository + * + * @param scannedDevices - Array of devices discovered via network scan + * @param repository - Array of loaded ESI repository items + * @returns Array of ScannedDeviceMatch objects with match information + */ +export function matchDevicesToRepository( + scannedDevices: EtherCATDevice[], + repository: ESIRepositoryItemLight[], +): ScannedDeviceMatch[] { + return scannedDevices.map((device) => { + const matches = findMatchesForDevice(device, repository) + + return { + device: { + position: device.position, + name: device.name, + vendor_id: device.vendor_id, + product_code: device.product_code, + revision: device.revision, + serial_number: device.serial_number, + state: device.state, + input_bytes: device.input_bytes, + output_bytes: device.output_bytes, + }, + matches, + // Auto-select the best match if there's an exact match + selectedMatch: + matches.length > 0 && matches[0].matchQuality === 'exact' + ? { + repositoryItemId: matches[0].repositoryItemId, + deviceIndex: matches[0].deviceIndex, + } + : undefined, + } + }) +} + +/** + * Get the best match quality from a list of matches + */ +export function getBestMatchQuality(matches: DeviceMatch[]): DeviceMatchQuality { + if (matches.length === 0) return 'none' + if (matches.some((m) => m.matchQuality === 'exact')) return 'exact' + if (matches.some((m) => m.matchQuality === 'partial')) return 'partial' + return 'none' +} + +/** + * Count devices by match quality + */ +export function countMatchedDevices(deviceMatches: ScannedDeviceMatch[]): { + exact: number + partial: number + none: number + total: number +} { + let exact = 0 + let partial = 0 + let none = 0 + + for (const dm of deviceMatches) { + const bestQuality = getBestMatchQuality(dm.matches) + if (bestQuality === 'exact') exact++ + else if (bestQuality === 'partial') partial++ + else none++ + } + + return { exact, partial, none, total: deviceMatches.length } +} diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts new file mode 100644 index 000000000..7d04d2eab --- /dev/null +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -0,0 +1,125 @@ +/** + * EtherCAT Device Data Enrichment + * + * Pure functions that extract persistable data from a full ESIDevice. + * Used when adding devices to persist channel/PDO metadata for runtime config generation. + */ + +import type { + ESIDevice, + ESIPdo, + EtherCATChannelMapping, + PersistedChannelInfo, + PersistedPdo, + PersistedPdoEntry, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' + +import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' +import { extractDefaultSdoConfigurations } from './sdo-config-defaults' + +/** + * Convert ESIPdo[] to PersistedPdo[] format. + * Preserves all entries including padding for complete PDO layout. + */ +export function persistPdos(pdos: ESIPdo[]): PersistedPdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): PersistedPdoEntry => ({ + index: entry.index, + subIndex: entry.subIndex, + bitLen: entry.bitLen, + name: entry.name, + dataType: entry.dataType, + }), + ), + })) +} + +/** + * Build persisted channel info from ESIDevice using pdoToChannels. + * Extracts full metadata needed for runtime config generation. + */ +export function buildChannelInfo(device: ESIDevice): PersistedChannelInfo[] { + const channels = pdoToChannels(device) + return channels.map( + (ch): PersistedChannelInfo => ({ + channelId: ch.id, + name: ch.name, + direction: ch.direction, + pdoIndex: ch.pdoIndex, + entryIndex: ch.entryIndex, + entrySubIndex: ch.entrySubIndex, + dataType: ch.dataType, + bitLen: ch.bitLen, + iecType: esiTypeToIecType(ch.dataType, ch.bitLen), + }), + ) +} + +/** + * Derive slave device type from PDO structure. + * Uses heuristics based on PDO direction and data sizes. + */ +export function deriveSlaveType(device: ESIDevice): string { + const hasNonPaddingEntry = (pdos: ESIPdo[]): boolean => + pdos.some((pdo) => pdo.entries.some((e) => e.name !== 'Padding' && e.index !== '0x0000')) + + const allBitSized = (pdos: ESIPdo[]): boolean => + pdos.every((pdo) => + pdo.entries.filter((e) => e.name !== 'Padding' && e.index !== '0x0000').every((e) => e.bitLen === 1), + ) + + const hasTxData = hasNonPaddingEntry(device.txPdo) + const hasRxData = hasNonPaddingEntry(device.rxPdo) + + if (!hasTxData && !hasRxData) return 'coupler' + + const txAllBit = hasTxData && allBitSized(device.txPdo) + const rxAllBit = hasRxData && allBitSized(device.rxPdo) + + if (hasTxData && !hasRxData) { + return txAllBit ? 'digital_input' : 'analog_input' + } + + if (hasRxData && !hasTxData) { + return rxAllBit ? 'digital_output' : 'analog_output' + } + + // Both directions + if (txAllBit && rxAllBit) return 'digital_io' + return 'analog_io' +} + +/** + * Enrich device data by extracting all persistable info from a full ESIDevice. + * Returns fields to spread into ConfiguredEtherCATDevice. + * + * `usedAddresses` is the set of IEC addresses already taken by other devices + * in the project; the generated `channelMappings` will avoid them. Pass an + * up-to-date set when adding a device so its outputs/inputs receive valid, + * non-conflicting IEC locations from the start (otherwise the runtime can't + * bind them and the slave appears inert until the editor page is opened). + */ +export function enrichDeviceData( + device: ESIDevice, + usedAddresses?: Set, +): { + channelInfo: PersistedChannelInfo[] + rxPdos: PersistedPdo[] + txPdos: PersistedPdo[] + slaveType: string + sdoConfigurations?: SDOConfigurationEntry[] + channelMappings: EtherCATChannelMapping[] +} { + return { + channelInfo: buildChannelInfo(device), + rxPdos: persistPdos(device.rxPdo), + txPdos: persistPdos(device.txPdo), + slaveType: deriveSlaveType(device), + sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, + channelMappings: generateDefaultChannelMappings(pdoToChannels(device), usedAddresses), + } +} diff --git a/src/backend/shared/ethercat/esi-parser-main.ts b/src/backend/shared/ethercat/esi-parser-main.ts new file mode 100644 index 000000000..2ee57c19e --- /dev/null +++ b/src/backend/shared/ethercat/esi-parser-main.ts @@ -0,0 +1,659 @@ +/** + * ESI XML Parser for Main Process + * + * Uses fast-xml-parser for high-performance parsing in the Node.js main process. + * Provides two parsing levels: + * - parseESILight: Extracts lightweight device summaries (fast, for repository listing) + * - parseESIDeviceFull: Extracts complete device data on-demand (for configuration) + */ + +import type { + ESICoEObject, + ESICoESubItem, + ESIDevice, + ESIDeviceSummary, + ESIDeviceType, + ESIFMMU, + ESIGroup, + ESIPdo, + ESIPdoEntry, + ESISyncManager, + ESIVendor, +} from '@root/middleware/shared/ports/esi-types' +import { XMLParser } from 'fast-xml-parser' + +// ===================== SHARED HELPERS ===================== + +/** + * Parse hex string to normalized 0x format + */ +function parseHexValue(value: string | number | undefined | null): string { + if (value === undefined || value === null) return '0x0' + const str = String(value) + const cleaned = str.replace(/#x/gi, '0x') + return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}` +} + +/** + * Ensure a value is always an array (fast-xml-parser returns single items as objects) + */ +function ensureArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return [] + return Array.isArray(value) ? value : [value] +} + +/** + * Get text value from a parsed element that may be string, number, object with #text, + * or array of localized objects (e.g. [{#text: "Name EN", @_LcId: "1033"}, {#text: "Name DE", @_LcId: "1031"}]). + * For arrays, prefers LcId 1033 (English) then falls back to the first element. + */ +function getTextValue(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value.trim() + if (typeof value === 'number') return String(value) + if (Array.isArray(value)) { + if (value.length === 0) return '' + // Prefer English (LcId 1033), fall back to first element + const english = (value as Record[]).find( + (v) => typeof v === 'object' && v !== null && '@_LcId' in v && String(v['@_LcId'] as string) === '1033', + ) + return getTextValue(english ?? value[0]) + } + if (typeof value === 'object' && value !== null && '#text' in value) { + return getTextValue((value as { '#text': unknown })['#text']) + } + return typeof value === 'object' ? JSON.stringify(value) : String(value as string) +} + +/** + * Create a configured XMLParser instance + */ +function createParser(): XMLParser { + return new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: false, + trimValues: true, + isArray: (tagName: string) => { + // Tags that can appear multiple times and should always be arrays + const arrayTags = ['Device', 'Group', 'RxPdo', 'TxPdo', 'Entry', 'Fmmu', 'Sm', 'Object', 'SubItem', 'DataType'] + return arrayTags.includes(tagName) + }, + }) +} + +// ===================== LIGHT PARSING ===================== + +interface ESILightResult { + success: boolean + vendor?: ESIVendor + devices?: ESIDeviceSummary[] + warnings?: string[] + error?: string +} + +/** + * Parse ESI XML extracting only lightweight device summaries. + * Counts PDO entries and sums bit lengths without building full entry objects. + */ +export function parseESILight(xmlString: string, filename?: string): ESILightResult { + const warnings: string[] = [] + + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + // Parse Vendor + const vendorObj = root['Vendor'] as Record | undefined + if (!vendorObj) { + return { success: false, error: 'Invalid ESI file: Missing Vendor information' } + } + + const vendor: ESIVendor = { + id: parseHexValue(vendorObj['Id'] as string | number | undefined), + name: getTextValue(vendorObj['Name']) || 'Unknown Vendor', + } + + // Parse Groups + const descriptions = root['Descriptions'] as Record | undefined + const groupsMap = new Map() + + if (descriptions) { + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groups = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const group of groups) { + const groupType = getTextValue(group['Type']) + const groupName = getTextValue(group['Name']) + if (groupType) { + groupsMap.set(groupType, groupName) + } + } + } + } + + // Parse Devices (lightweight) + const devices: ESIDeviceSummary[] = [] + + if (descriptions) { + const devicesObj = descriptions['Devices'] as Record | undefined + if (devicesObj) { + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + for (const deviceEl of deviceElements) { + const summary = parseDeviceSummary(deviceEl, groupsMap) + devices.push(summary) + } + } + } + + if (devices.length === 0) { + warnings.push(`No devices found in ESI file${filename ? ` (${filename})` : ''}`) + } + + return { + success: true, + vendor, + devices, + warnings: warnings.length > 0 ? warnings : undefined, + } + } catch (error) { + return { + success: false, + error: `Failed to parse ESI file: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +/** + * Extract a lightweight device summary from a parsed device element + */ +function parseDeviceSummary(deviceEl: Record, groupsMap: Map): ESIDeviceSummary { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const groupName = groupsMap.get(groupType) || undefined + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Count PDO entries and compute bytes + const txPdos = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + const rxPdos = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + + const { channelCount: inputChannelCount, totalBits: inputBits } = countPdoEntries(txPdos) + const { channelCount: outputChannelCount, totalBits: outputBits } = countPdoEntries(rxPdos) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName, + physics, + inputChannelCount, + outputChannelCount, + totalInputBytes: Math.ceil(inputBits / 8), + totalOutputBytes: Math.ceil(outputBits / 8), + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Count non-padding entries and total bits in PDO list (without building full entry objects) + */ +function countPdoEntries(pdos: Record[]): { + channelCount: number + totalBits: number +} { + let channelCount = 0 + let totalBits = 0 + + for (const pdo of pdos) { + const entries = ensureArray(pdo['Entry'] as Record | Record[]) + for (const entry of entries) { + const bitLen = parseInt(getTextValue(entry['BitLen']) || '0', 10) || 0 + totalBits += bitLen + + // Count non-padding entries + const index = entry['Index'] + if (index !== undefined && index !== null) { + const indexStr = getTextValue(index) + if (indexStr && indexStr !== '0' && indexStr !== '#x0000' && indexStr !== '0x0000') { + channelCount++ + } + } + } + } + + return { channelCount, totalBits } +} + +// ===================== FULL DEVICE PARSING ===================== + +interface ESIDeviceFullResult { + success: boolean + device?: ESIDevice + error?: string +} + +/** + * Parse a single device from ESI XML at the given index with full detail. + * Used on-demand when complete PDO/SM/FMMU data is needed. + */ +export function parseESIDeviceFull(xmlString: string, deviceIndex: number): ESIDeviceFullResult { + try { + const parser = createParser() + const parsed = parser.parse(xmlString) as Record + + const root = parsed['EtherCATInfo'] as Record | undefined + if (!root) { + return { success: false, error: 'Invalid ESI file: Missing EtherCATInfo root element' } + } + + const descriptions = root['Descriptions'] as Record | undefined + if (!descriptions) { + return { success: false, error: 'No Descriptions element found' } + } + + // Parse groups for name lookup + const groups: ESIGroup[] = [] + const groupsObj = descriptions['Groups'] as Record | undefined + if (groupsObj) { + const groupElements = ensureArray(groupsObj['Group'] as Record | Record[]) + for (const g of groupElements) { + groups.push({ + type: getTextValue(g['Type']), + name: getTextValue(g['Name']), + imageUrl: getTextValue(g['ImageData16x14']) || undefined, + description: getTextValue(g['Comment']) || undefined, + }) + } + } + + const devicesObj = descriptions['Devices'] as Record | undefined + if (!devicesObj) { + return { success: false, error: 'No Devices element found' } + } + + const deviceElements = ensureArray(devicesObj['Device'] as Record | Record[]) + + if (deviceIndex < 0 || deviceIndex >= deviceElements.length) { + return { success: false, error: `Device index ${deviceIndex} out of range (0-${deviceElements.length - 1})` } + } + + const deviceEl = deviceElements[deviceIndex] + const device = parseFullDevice(deviceEl, groups) + + return { success: true, device } + } catch (error) { + return { + success: false, + error: `Failed to parse device: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ===================== COE DICTIONARY PARSING ===================== + +/** + * Parsed DataType info from the Dictionary's DataTypes section. + */ +interface ParsedDataTypeInfo { + name: string + bitSize: number + subItems: { + subIdx: number + name: string + type: string + bitSize: number + bitOffset: number + access: 'RO' | 'RW' | 'WO' + defaultValue?: string + pdoMapping?: boolean + }[] +} + +/** + * Parse access string from ESI XML to normalized access type. + */ +function parseAccessRights(accessStr: string | undefined): 'RO' | 'RW' | 'WO' { + if (!accessStr) return 'RO' + const lower = accessStr.toLowerCase().trim() + if (lower === 'rw' || lower === 'readwrite' || lower === 'read/write') return 'RW' + if (lower === 'wo' || lower === 'writeonly' || lower === 'write') return 'WO' + return 'RO' +} + +/** + * Parse the CoE Object Dictionary from a device element. + * Navigates Device > Profile > Dictionary and extracts DataTypes and Objects. + */ +function parseCoEDictionary(deviceEl: Record): ESICoEObject[] | undefined { + // Navigate to Profile > Dictionary + const profile = deviceEl['Profile'] as Record | undefined + if (!profile) return undefined + + const dictionary = profile['Dictionary'] as Record | undefined + if (!dictionary) return undefined + + // Step 1: Build DataType map + const dataTypeMap = new Map() + const dataTypesEl = dictionary['DataTypes'] as Record | undefined + if (dataTypesEl) { + const dtElements = ensureArray(dataTypesEl['DataType'] as Record | Record[]) + for (const dt of dtElements) { + const dtName = getTextValue(dt['Name']) + if (!dtName) continue + + const dtBitSize = parseInt(getTextValue(dt['BitSize']) || '0', 10) || 0 + const subItems: ParsedDataTypeInfo['subItems'] = [] + + const subItemElements = ensureArray(dt['SubItem'] as Record | Record[]) + for (const si of subItemElements) { + const siSubIdx = parseInt(getTextValue(si['SubIdx']) || '0', 10) + const siName = getTextValue(si['Name']) + const siType = getTextValue(si['Type']) + const siBitSize = parseInt(getTextValue(si['BitSize']) || '0', 10) || 0 + const siBitOffset = parseInt(getTextValue(si['BitOffs']) || '0', 10) || 0 + + // Parse flags + let siAccess: 'RO' | 'RW' | 'WO' = 'RO' + let siPdoMapping: boolean | undefined + const siFlags = si['Flags'] as Record | undefined + if (siFlags) { + siAccess = parseAccessRights(getTextValue(siFlags['Access'])) + const pdoMappingStr = getTextValue(siFlags['PdoMapping']) + if (pdoMappingStr) siPdoMapping = pdoMappingStr.toLowerCase() !== 'false' && pdoMappingStr !== '0' + } + + const siDefaultValue = getTextValue(si['DefaultValue']) || undefined + + subItems.push({ + subIdx: siSubIdx, + name: siName, + type: siType, + bitSize: siBitSize, + bitOffset: siBitOffset, + access: siAccess, + defaultValue: siDefaultValue, + pdoMapping: siPdoMapping, + }) + } + + dataTypeMap.set(dtName, { name: dtName, bitSize: dtBitSize, subItems }) + } + } + + // Step 2: Parse Objects + const objectsEl = dictionary['Objects'] as Record | undefined + if (!objectsEl) return undefined + + const objectElements = ensureArray(objectsEl['Object'] as Record | Record[]) + if (objectElements.length === 0) return undefined + + const coeObjects: ESICoEObject[] = [] + + for (const objEl of objectElements) { + const indexStr = getTextValue(objEl['Index']) + if (!indexStr) continue + + const index = parseHexValue(indexStr) + const name = getTextValue(objEl['Name']) || 'Unnamed' + const typeName = getTextValue(objEl['Type']) + const bitSize = parseInt(getTextValue(objEl['BitSize']) || '0', 10) || 0 + + // Parse object-level flags + let access: 'RO' | 'RW' | 'WO' = 'RO' + let pdoMapping = false + let category: 'M' | 'O' | 'C' | undefined + let pdoMappingDirection: 'R' | 'T' | 'RT' | undefined + + const flags = objEl['Flags'] as Record | undefined + if (flags) { + access = parseAccessRights(getTextValue(flags['Access'])) + const pdoMappingStr = getTextValue(flags['PdoMapping']) + if (pdoMappingStr) { + const lower = pdoMappingStr.toLowerCase() + if (lower === 'r') { + pdoMapping = true + pdoMappingDirection = 'R' + } else if (lower === 't') { + pdoMapping = true + pdoMappingDirection = 'T' + } else if (lower === 'rt' || lower === 'tr') { + pdoMapping = true + pdoMappingDirection = 'RT' + } else if (lower !== 'false' && lower !== '0' && lower !== '') { + pdoMapping = true + } + } + const categoryStr = getTextValue(flags['Category']) + if (categoryStr === 'M' || categoryStr === 'O' || categoryStr === 'C') { + category = categoryStr + } + } + + // Parse default value from object-level Info + let defaultValue = getTextValue(objEl['DefaultValue']) || undefined + + // Resolve DataType to build sub-items for complex objects + const dtInfo = typeName ? dataTypeMap.get(typeName) : undefined + let subItems: ESICoESubItem[] | undefined + + if (dtInfo && dtInfo.subItems.length > 0) { + // Build override map from object-level Info > SubItem + const overrideMap = new Map() + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + const infoSubItems = ensureArray(infoEl['SubItem'] as Record | Record[]) + 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 + if (isiName) { + overrideMap.set(isiName, { defaultValue: isiDefaultValue }) + } + } + } + + // Merge DataType sub-items with object-level overrides + subItems = dtInfo.subItems.map((si): ESICoESubItem => { + const override = overrideMap.get(si.name) + return { + subIndex: String(si.subIdx), + name: si.name, + type: si.type, + bitSize: si.bitSize, + access: si.access, + pdoMapping: si.pdoMapping, + defaultValue: override?.defaultValue ?? si.defaultValue, + } + }) + } + + // If no sub-items were built and there's an Info > DefaultValue at object level + if (!subItems && !defaultValue) { + const infoEl = objEl['Info'] as Record | undefined + if (infoEl) { + defaultValue = getTextValue(infoEl['DefaultValue']) || undefined + } + } + + coeObjects.push({ + index, + name, + type: typeName, + bitSize, + access, + pdoMapping, + category, + pdoMappingDirection, + defaultValue, + subItems, + }) + } + + return coeObjects.length > 0 ? coeObjects : undefined +} + +/** + * Parse a complete ESIDevice from a parsed device element + */ +function parseFullDevice(deviceEl: Record, groups: ESIGroup[]): ESIDevice { + // Parse Type + const typeEl = deviceEl['Type'] as Record | string | undefined + let type: ESIDeviceType + if (typeEl && typeof typeEl === 'object') { + type = { + productCode: parseHexValue(typeEl['@_ProductCode'] as string | undefined), + revisionNo: parseHexValue(typeEl['@_RevisionNo'] as string | undefined), + name: getTextValue(typeEl['#text'] ?? typeEl['@_Name']), + } + } else { + type = { productCode: '0x0', revisionNo: '0x0', name: getTextValue(typeEl) || 'Unknown' } + } + + // Group + const groupType = getTextValue(deviceEl['GroupType']) + const group = groups.find((g) => g.type === groupType) + + // Physics + const physics = (deviceEl['@_Physics'] as string | undefined) || undefined + + // Parse FMMUs + const fmmu: ESIFMMU[] = [] + const fmmuElements = ensureArray( + deviceEl['Fmmu'] as (Record | string) | (Record | string)[], + ) + const validFmmuTypes: ESIFMMU['type'][] = ['Outputs', 'Inputs', 'MbxState'] + for (const f of fmmuElements) { + const fmmuText = typeof f === 'string' ? f : getTextValue(f['#text'] ?? f) + const fmmuType = validFmmuTypes.includes(fmmuText as ESIFMMU['type']) ? (fmmuText as ESIFMMU['type']) : 'Outputs' + fmmu.push({ type: fmmuType }) + } + + // Parse Sync Managers + const syncManagers: ESISyncManager[] = [] + const smElements = ensureArray(deviceEl['Sm'] as Record | Record[]) + for (let i = 0; i < smElements.length; i++) { + const sm = smElements[i] + const smTypeMap: Record = { + MbxOut: 'MbxOut', + MbxIn: 'MbxIn', + Outputs: 'Outputs', + Inputs: 'Inputs', + } + const smText = getTextValue(sm['#text'] ?? sm) + syncManagers.push({ + index: i, + startAddress: parseHexValue(sm['@_StartAddress'] as string | undefined), + controlByte: parseHexValue(sm['@_ControlByte'] as string | undefined), + defaultSize: parseInt(getTextValue(sm['@_DefaultSize']) || '0', 10) || 0, + enable: getTextValue(sm['@_Enable']) !== '0', + type: smTypeMap[smText] || 'Outputs', + }) + } + + // Parse RxPDOs + const rxPdo: ESIPdo[] = [] + const rxPdoElements = ensureArray(deviceEl['RxPdo'] as Record | Record[]) + for (const pdoEl of rxPdoElements) { + rxPdo.push(parseFullPdo(pdoEl)) + } + + // Parse TxPDOs + const txPdo: ESIPdo[] = [] + const txPdoElements = ensureArray(deviceEl['TxPdo'] as Record | Record[]) + for (const pdoEl of txPdoElements) { + txPdo.push(parseFullPdo(pdoEl)) + } + + // Parse CoE Object Dictionary + const coeObjects = parseCoEDictionary(deviceEl) + + return { + type, + name: getTextValue(deviceEl['Name']) || 'Unknown Device', + groupName: group?.name, + physics, + fmmu, + syncManagers, + rxPdo, + txPdo, + coeObjects, + description: getTextValue(deviceEl['Comment']) || undefined, + } +} + +/** + * Parse a full PDO with all entries + */ +function parseFullPdo(pdoEl: Record): ESIPdo { + const entries: ESIPdoEntry[] = [] + const entryElements = ensureArray(pdoEl['Entry'] as Record | Record[]) + + for (const entryEl of entryElements) { + const entry = parseFullPdoEntry(entryEl) + if (entry) { + entries.push(entry) + } + } + + return { + index: parseHexValue(pdoEl['Index'] as string | undefined), + name: getTextValue(pdoEl['Name']) || 'Unnamed PDO', + fixed: getTextValue(pdoEl['@_Fixed']).toLowerCase() === 'true', + mandatory: getTextValue(pdoEl['@_Mandatory']).toLowerCase() === 'true', + smIndex: pdoEl['@_Sm'] !== undefined ? parseInt(getTextValue(pdoEl['@_Sm']) || '0', 10) : undefined, + entries, + } +} + +/** + * Parse a single PDO entry + */ +function parseFullPdoEntry(entryEl: Record): ESIPdoEntry | null { + const indexValue = entryEl['Index'] + const bitLen = parseInt(getTextValue(entryEl['BitLen']) || '0', 10) || 0 + + const indexStr = getTextValue(indexValue) + + // Padding entry (no index but has bit length) + if (!indexStr && bitLen > 0) { + return { + index: '0x0000', + subIndex: '0x00', + bitLen, + name: 'Padding', + dataType: 'BIT', + } + } + + if (!indexStr) return null + + return { + index: parseHexValue(indexStr), + subIndex: parseHexValue(getTextValue(entryEl['SubIndex'])), + bitLen, + name: getTextValue(entryEl['Name']) || 'Unnamed', + dataType: getTextValue(entryEl['DataType']) || 'BYTE', + comment: getTextValue(entryEl['Comment']) || undefined, + } +} diff --git a/src/backend/shared/ethercat/esi-parser.ts b/src/backend/shared/ethercat/esi-parser.ts new file mode 100644 index 000000000..7592e26d6 --- /dev/null +++ b/src/backend/shared/ethercat/esi-parser.ts @@ -0,0 +1,365 @@ +/** + * EtherCAT ESI Channel Utilities + * + * Functions for working with parsed ESI device data: + * - PDO-to-channel conversion for UI + * - IEC 61131-3 address generation + * - Default channel mapping generation + * - Device summary calculation + * + * XML parsing is handled exclusively by the main-process parser + * in src/main/services/esi-service/esi-parser-main.ts. + */ + +import type { + ESIChannel, + ESIDataType, + ESIDevice, + ESIPdo, + EtherCATChannelMapping, +} from '@root/middleware/shared/ports/esi-types' + +/** + * Map ESI data type to IEC 61131-3 type + */ +export function esiTypeToIecType(esiType: ESIDataType, bitLen: number): string { + const typeMap: Record = { + BOOL: 'BOOL', + SINT: 'SINT', + INT: 'INT', + DINT: 'DINT', + LINT: 'LINT', + USINT: 'USINT', + UINT: 'UINT', + UDINT: 'UDINT', + ULINT: 'ULINT', + REAL: 'REAL', + LREAL: 'LREAL', + STRING: 'STRING', + BYTE: 'BYTE', + WORD: 'WORD', + DWORD: 'DWORD', + } + + if (typeMap[esiType]) { + return typeMap[esiType] + } + + // Handle BIT types + if (esiType.startsWith('BIT') || bitLen === 1) { + return 'BOOL' + } + + // Infer from bit length + switch (bitLen) { + case 1: + return 'BOOL' + case 8: + return 'BYTE' + case 16: + return 'WORD' + case 32: + return 'DWORD' + case 64: + return 'LWORD' + default: + return 'BYTE' + } +} + +/** + * Convert PDO entries to channels for UI + */ +export function pdoToChannels(device: ESIDevice): ESIChannel[] { + const channels: ESIChannel[] = [] + let inputBitOffset = 0 + let outputBitOffset = 0 + + // Process TxPDOs (inputs - slave to master) + for (const pdo of device.txPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + inputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'input', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: inputBitOffset, + byteOffset: Math.floor(inputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + inputBitOffset += entry.bitLen + } + } + + // Process RxPDOs (outputs - master to slave) + for (const pdo of device.rxPdo) { + for (const entry of pdo.entries) { + // Skip padding entries for channel list + if (entry.name === 'Padding' && entry.index === '0x0000') { + outputBitOffset += entry.bitLen + continue + } + + channels.push({ + id: `${pdo.index}-${entry.index}-${entry.subIndex}`, + direction: 'output', + pdoIndex: pdo.index, + pdoName: pdo.name, + entryIndex: entry.index, + entrySubIndex: entry.subIndex, + name: entry.name, + dataType: entry.dataType, + bitLen: entry.bitLen, + bitOffset: outputBitOffset, + byteOffset: Math.floor(outputBitOffset / 8), + iecType: esiTypeToIecType(entry.dataType, entry.bitLen), + selected: false, + }) + + outputBitOffset += entry.bitLen + } + } + + return channels +} + +/** + * Generate an IEC 61131-3 located variable address for a channel. + * Direction: input -> %I, output -> %Q + * Size prefix based on iecType: + * BOOL -> X (bit addressing): %IX. + * BYTE/SINT/USINT -> B: %IB + * WORD/INT/UINT -> W: %IW + * DWORD/DINT/UDINT/REAL -> D: %ID + * LWORD/LINT/ULINT/LREAL -> L: %IL + * + * When `globalBitOffset` is provided, it overrides the channel's own byte/bit offsets + * to allow generating globally unique addresses across multiple slaves. + */ +export function generateIecLocation(channel: ESIChannel, globalBitOffset?: number): string { + const dirPrefix = channel.direction === 'input' ? '%I' : '%Q' + + const byteOffset = globalBitOffset !== undefined ? Math.floor(globalBitOffset / 8) : channel.byteOffset + const bitOffset = globalBitOffset !== undefined ? globalBitOffset % 8 : channel.bitOffset % 8 + + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return `${dirPrefix}X${byteOffset}.${bitOffset}` + case 'BYTE': + case 'SINT': + case 'USINT': + return `${dirPrefix}B${byteOffset}` + case 'WORD': + case 'INT': + case 'UINT': + return `${dirPrefix}W${byteOffset}` + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return `${dirPrefix}D${byteOffset}` + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return `${dirPrefix}L${byteOffset}` + default: + return `${dirPrefix}B${byteOffset}` + } +} + +/** + * A direction-tagged bit range for conflict detection on IEC locations. + * Used to catch overlaps across different access widths (e.g. `%IX0.0`, + * `%IB0`, and `%IW0` all touch byte 0 but compare unequal as strings). + */ +type IecBitRange = { direction: 'I' | 'Q'; startBit: number; endBit: number } + +const IEC_WIDTH_BITS: Record = { + X: 1, + B: 8, + W: 16, + D: 32, + L: 64, +} + +/** + * Parse an IEC location string (`%IX0.0`, `%IB0`, `%QW2`, ...) into a bit range. + * Returns `null` for unrecognized formats so callers can skip them instead of + * treating them as false matches. + */ +export function parseIecLocationToBitRange(location: string): IecBitRange | null { + const match = /^%([IQ])([XBWDL])(\d+)(?:\.(\d+))?$/.exec(location) + if (!match) return null + const [, dir, kind, byteStr, bitStr] = match + const width = IEC_WIDTH_BITS[kind] + if (width === undefined) return null + const byte = Number.parseInt(byteStr, 10) + const bit = bitStr !== undefined ? Number.parseInt(bitStr, 10) : 0 + const startBit = byte * 8 + bit + return { direction: dir as 'I' | 'Q', startBit, endBit: startBit + width - 1 } +} + +function bitRangesOverlap(a: IecBitRange, b: IecBitRange): boolean { + return a.direction === b.direction && a.startBit <= b.endBit && b.startBit <= a.endBit +} + +/** + * Get the size in bits for a channel based on its IEC type. + */ +function getChannelBitSize(channel: ESIChannel): number { + const iecUpper = channel.iecType.toUpperCase() + switch (iecUpper) { + case 'BOOL': + return 1 + case 'BYTE': + case 'SINT': + case 'USINT': + return 8 + case 'WORD': + case 'INT': + case 'UINT': + return 16 + case 'DWORD': + case 'DINT': + case 'UDINT': + case 'REAL': + return 32 + case 'LWORD': + case 'LINT': + case 'ULINT': + case 'LREAL': + return 64 + default: + return 8 + } +} + +/** + * Generate default channel mappings with auto-generated IEC addresses for all channels. + * When `usedAddresses` is provided, addresses are generated to avoid conflicts with + * already-used locations from other devices (Modbus or EtherCAT). + */ +export function generateDefaultChannelMappings( + channels: ESIChannel[], + usedAddresses?: Set, +): EtherCATChannelMapping[] { + // Normalize existing addresses to bit ranges so conflict detection catches + // overlaps across widths (e.g. `%IB0` vs `%IW0` vs `%IX0.0` all overlap byte 0). + const usedRanges: IecBitRange[] = [] + if (usedAddresses) { + for (const addr of usedAddresses) { + const range = parseIecLocationToBitRange(addr) + if (range) usedRanges.push(range) + } + } + + const conflicts = (candidate: string): boolean => { + const range = parseIecLocationToBitRange(candidate) + if (!range) return false + return usedRanges.some((r) => bitRangesOverlap(range, r)) + } + + const inputChannels = channels.filter((c) => c.direction === 'input') + const outputChannels = channels.filter((c) => c.direction === 'output') + + const mappings: EtherCATChannelMapping[] = [] + + for (const group of [inputChannels, outputChannels]) { + let currentBitOffset = 0 + + for (const channel of group) { + const bitSize = getChannelBitSize(channel) + + // Align to byte boundary for non-bit types + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + + let candidate = generateIecLocation(channel, currentBitOffset) + + // Find a non-conflicting address (checks bit-range overlap, not string equality) + while (conflicts(candidate)) { + currentBitOffset += bitSize + // Re-align if needed + if (bitSize > 1 && currentBitOffset % 8 !== 0) { + currentBitOffset = Math.ceil(currentBitOffset / 8) * 8 + } + candidate = generateIecLocation(channel, currentBitOffset) + } + + const candidateRange = parseIecLocationToBitRange(candidate) + if (candidateRange) usedRanges.push(candidateRange) + mappings.push({ + channelId: channel.id, + iecLocation: candidate, + userEdited: false, + alias: '', + }) + + currentBitOffset += bitSize + } + } + + return mappings +} + +/** + * Calculate total PDO size in bytes + */ +export function calculatePdoSize(pdos: ESIPdo[]): number { + let totalBits = 0 + for (const pdo of pdos) { + for (const entry of pdo.entries) { + totalBits += entry.bitLen + } + } + return Math.ceil(totalBits / 8) +} + +/** + * Get device summary information + */ +export function getDeviceSummary(device: ESIDevice): { + totalInputBytes: number + totalOutputBytes: number + inputChannelCount: number + outputChannelCount: number + hasCoe: boolean +} { + const inputBytes = calculatePdoSize(device.txPdo) + const outputBytes = calculatePdoSize(device.rxPdo) + + let inputChannels = 0 + let outputChannels = 0 + + for (const pdo of device.txPdo) { + inputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + for (const pdo of device.rxPdo) { + outputChannels += pdo.entries.filter((e) => e.name !== 'Padding').length + } + + return { + totalInputBytes: inputBytes, + totalOutputBytes: outputBytes, + inputChannelCount: inputChannels, + outputChannelCount: outputChannels, + hasCoe: device.coeObjects !== undefined && device.coeObjects.length > 0, + } +} diff --git a/src/backend/shared/ethercat/ethercat-task-helpers.ts b/src/backend/shared/ethercat/ethercat-task-helpers.ts new file mode 100644 index 000000000..abc42cc5e --- /dev/null +++ b/src/backend/shared/ethercat/ethercat-task-helpers.ts @@ -0,0 +1,19 @@ +/** + * Generates a system task name from an EtherCAT device name. + * Example: "Master1" -> "EtherCAT_Master1" + */ +export function ethercatTaskName(deviceName: string): string { + return `EtherCAT_${deviceName}` +} + +/** + * Converts a cycle time in microseconds to an IEC 61131-3 time interval string. + * Examples: 1000 -> "T#1ms", 500 -> "T#500us", 20000 -> "T#20ms" + */ +export function cycleTimeUsToIecInterval(cycleTimeUs: number): string { + if (cycleTimeUs <= 0) return 'T#1ms' + if (cycleTimeUs % 1000 === 0) { + return `T#${cycleTimeUs / 1000}ms` + } + return `T#${cycleTimeUs}us` +} diff --git a/src/backend/shared/ethercat/generate-ethercat-config.ts b/src/backend/shared/ethercat/generate-ethercat-config.ts new file mode 100644 index 000000000..482a43530 --- /dev/null +++ b/src/backend/shared/ethercat/generate-ethercat-config.ts @@ -0,0 +1,339 @@ +import type { PLCRemoteDevice } from '@root/backend/shared/types/PLC/open-plc' +import type { + ConfiguredEtherCATDevice, + PersistedChannelInfo, + PersistedPdo, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' + +import { ethercatTaskName } from './ethercat-task-helpers' + +// Runtime JSON interfaces (snake_case for plugin consumption) + +interface RuntimePdoEntry { + index: string + subindex: number + bit_length: number + name: string + data_type: string +} + +interface RuntimePdo { + index: string + name: string + entries: RuntimePdoEntry[] +} + +interface RuntimeChannel { + index: number + name: string + type: string + bit_length: number + iec_location: string + pdo_index: string + pdo_entry_index: string + pdo_entry_subindex: number +} + +interface RuntimeSdoConfig { + index: string + subindex: number + value: number + data_type: string + bit_length: number + name: string + comment: string +} + +interface RuntimeSlaveConfig { + startup_checks: { + check_vendor_id: boolean + check_product_code: boolean + } + addressing: { + ethercat_address: number + } + timeouts: { + sdo_timeout_ms: number + init_to_preop_timeout_ms: number + safeop_to_op_timeout_ms: number + } + watchdog: { + sm_watchdog_enabled: boolean + sm_watchdog_ms: number + pdi_watchdog_enabled: boolean + pdi_watchdog_ms: number + } + distributed_clocks: { + enabled: boolean + sync_unit_cycle_us: number + sync0_enabled: boolean + sync0_cycle_us: number + sync0_shift_us: number + sync1_enabled: boolean + sync1_cycle_us: number + sync1_shift_us: number + } +} + +interface RuntimeSlave { + position: number + name: string + type: string + vendor_id: string + product_code: string + revision: string + config: RuntimeSlaveConfig + channels: RuntimeChannel[] + sdo_configurations: RuntimeSdoConfig[] + rx_pdos: RuntimePdo[] + tx_pdos: RuntimePdo[] +} + +interface RuntimeMaster { + interface: string + cycle_time_us: number + watchdog_timeout_cycles: number + task_name?: string + task_cycle_time_us?: number +} + +interface RuntimeDiagnostics { + log_connections: boolean + log_data_access: boolean + log_errors: boolean + max_log_entries: number + status_update_interval_ms: number +} + +interface RuntimeConfig { + master: RuntimeMaster + slaves: RuntimeSlave[] + diagnostics: RuntimeDiagnostics +} + +interface RuntimeRootEntry { + name: string + protocol: string + config: RuntimeConfig +} + +/** + * Converts a hex string (e.g., "0x01") to an integer. + */ +function hexToInt(hex: string): number { + return parseInt(hex, 16) +} + +/** + * Parses a user-entered value string into a numeric value. + * Handles decimal ("100"), hex ("0xFF", "#xFF"), float ("3.14"), and negative ("-50"). + * Returns 0 for empty or unparseable strings. + */ +function parseNumericValue(str: string): number { + if (!str || str.trim() === '') return 0 + + const trimmed = str.trim() + + // Handle hex prefixes: "0x" / "0X" / "#x" / "#X" + if (/^(0x|#x)/i.test(trimmed)) { + const hexStr = trimmed.replace(/^#x/i, '0x') + const parsed = Number(hexStr) + return isNaN(parsed) ? 0 : parsed + } + + const parsed = Number(trimmed) + return isNaN(parsed) ? 0 : parsed +} + +/** + * Derives the channel type string from direction and bit length. + */ +function deriveChannelType(direction: 'input' | 'output', bitLen: number): string { + if (direction === 'input') { + return bitLen === 1 ? 'digital_input' : 'analog_input' + } + return bitLen === 1 ? 'digital_output' : 'analog_output' +} + +/** + * Converts persisted PDOs to runtime PDO format. + * Entries with index "0x0000" are treated as padding. + */ +function convertPdos(pdos: PersistedPdo[]): RuntimePdo[] { + return pdos.map((pdo) => ({ + index: pdo.index, + name: pdo.name, + entries: pdo.entries.map( + (entry): RuntimePdoEntry => ({ + index: entry.index, + subindex: hexToInt(entry.subIndex), + bit_length: entry.bitLen, + name: entry.name, + data_type: entry.index === '0x0000' ? 'PAD' : entry.dataType, + }), + ), + })) +} + +/** + * Builds runtime channels by joining channelInfo with channelMappings. + */ +function buildChannels( + channelInfo: PersistedChannelInfo[], + channelMappings: { channelId: string; iecLocation: string }[], +): RuntimeChannel[] { + const mappingMap = new Map(channelMappings.map((m) => [m.channelId, m.iecLocation])) + + return channelInfo.map((ch, index) => ({ + index, + name: ch.name, + type: deriveChannelType(ch.direction, ch.bitLen), + bit_length: ch.bitLen, + iec_location: mappingMap.get(ch.channelId) ?? '', + pdo_index: ch.pdoIndex, + pdo_entry_index: ch.entryIndex, + pdo_entry_subindex: hexToInt(ch.entrySubIndex), + })) +} + +/** + * Converts SDOConfigurationEntry[] to RuntimeSdoConfig[] for the runtime plugin. + */ +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}`, + }), + ) +} + +/** + * Builds a runtime slave from a configured device. + */ +function buildSlave(device: ConfiguredEtherCATDevice, index: number): RuntimeSlave { + const position = device.position ?? index + 1 + const channels = device.channelInfo ? buildChannels(device.channelInfo, device.channelMappings) : [] + const rxPdos = device.rxPdos ? convertPdos(device.rxPdos) : [] + const txPdos = device.txPdos ? convertPdos(device.txPdos) : [] + + const cfg = device.config + + return { + position, + name: device.name, + type: device.slaveType ?? 'coupler', + vendor_id: device.vendorId, + product_code: device.productCode, + revision: device.revisionNo, + config: { + startup_checks: { + check_vendor_id: cfg.startupChecks.checkVendorId, + check_product_code: cfg.startupChecks.checkProductCode, + }, + addressing: { + ethercat_address: cfg.addressing.ethercatAddress, + }, + timeouts: { + sdo_timeout_ms: cfg.timeouts.sdoTimeoutMs, + init_to_preop_timeout_ms: cfg.timeouts.initToPreOpTimeoutMs, + safeop_to_op_timeout_ms: cfg.timeouts.safeOpToOpTimeoutMs, + }, + watchdog: { + sm_watchdog_enabled: cfg.watchdog.smWatchdogEnabled, + sm_watchdog_ms: cfg.watchdog.smWatchdogMs, + pdi_watchdog_enabled: cfg.watchdog.pdiWatchdogEnabled, + pdi_watchdog_ms: cfg.watchdog.pdiWatchdogMs, + }, + distributed_clocks: { + enabled: cfg.distributedClocks.dcEnabled, + sync_unit_cycle_us: cfg.distributedClocks.dcSyncUnitCycleUs, + sync0_enabled: cfg.distributedClocks.dcSync0Enabled, + sync0_cycle_us: cfg.distributedClocks.dcSync0CycleUs, + sync0_shift_us: cfg.distributedClocks.dcSync0ShiftUs, + sync1_enabled: cfg.distributedClocks.dcSync1Enabled, + sync1_cycle_us: cfg.distributedClocks.dcSync1CycleUs, + sync1_shift_us: cfg.distributedClocks.dcSync1ShiftUs, + }, + }, + channels, + sdo_configurations: buildSdoConfigurations(device.sdoConfigurations), + rx_pdos: rxPdos, + tx_pdos: txPdos, + } +} + +/** + * Generates the EtherCAT plugin configuration JSON from the project's remote devices. + * Produces the exact contract expected by the OpenPLC runtime EtherCAT plugin. + * + * Output format: array root `[{ name, protocol: "ETHERCAT", config: { master, slaves[], diagnostics } }]` + * + * @param remoteDevices - Array of PLCRemoteDevice from the project data + * @returns The EtherCAT configuration as a JSON string, or null if no devices are configured + */ +export const generateEthercatConfig = (remoteDevices: PLCRemoteDevice[] | undefined): string | null => { + if (!remoteDevices || remoteDevices.length === 0) { + return null + } + + const ethercatRemoteDevices = remoteDevices.filter( + (device) => + device.protocol === 'ethercat' && device.ethercatConfig && (device.ethercatConfig.masterConfig?.enabled ?? true), + ) + + if (ethercatRemoteDevices.length === 0) { + return null + } + + // Build one root entry per EtherCAT remote device + const rootEntries: RuntimeRootEntry[] = [] + + for (const remoteDevice of ethercatRemoteDevices) { + const devices = (remoteDevice.ethercatConfig?.devices ?? []) as ConfiguredEtherCATDevice[] + const slaves = devices.map((device, i) => buildSlave(device, i)) + + if (slaves.length === 0) continue + + const cycleTimeUs = remoteDevice.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskName = ethercatTaskName(remoteDevice.name) + + const master: RuntimeMaster = { + interface: remoteDevice.ethercatConfig?.masterConfig?.networkInterface || 'eth0', + cycle_time_us: cycleTimeUs, + watchdog_timeout_cycles: remoteDevice.ethercatConfig?.masterConfig?.watchdogTimeoutCycles ?? 3, + task_name: taskName, + task_cycle_time_us: cycleTimeUs, + } + + rootEntries.push({ + name: remoteDevice.name || 'ethercat_master', + protocol: 'ETHERCAT', + config: { + master, + slaves, + diagnostics: { + log_connections: true, + log_data_access: false, + log_errors: true, + max_log_entries: 10000, + status_update_interval_ms: 500, + }, + }, + }) + } + + if (rootEntries.length === 0) { + return null + } + + return JSON.stringify(rootEntries, null, 2) +} diff --git a/src/backend/shared/ethercat/index.ts b/src/backend/shared/ethercat/index.ts new file mode 100644 index 000000000..b89a1087e --- /dev/null +++ b/src/backend/shared/ethercat/index.ts @@ -0,0 +1,9 @@ +export { collectUsedIecAddresses } from './collect-used-iec-addresses' +export { createDefaultSlaveConfig, DEFAULT_SLAVE_CONFIG } from './device-config-defaults' +export { countMatchedDevices, getBestMatchQuality, matchDevicesToRepository } from './device-matcher' +export { buildChannelInfo, deriveSlaveType, persistPdos } from './enrich-device-data' +export { esiTypeToIecType, pdoToChannels } from './esi-parser' +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' diff --git a/src/backend/shared/ethercat/sdo-config-defaults.ts b/src/backend/shared/ethercat/sdo-config-defaults.ts new file mode 100644 index 000000000..9b3ab6646 --- /dev/null +++ b/src/backend/shared/ethercat/sdo-config-defaults.ts @@ -0,0 +1,84 @@ +/** + * SDO Configuration Defaults Extraction + * + * 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. + */ + +import type { ESICoEObject, SDOConfigurationEntry } from '@root/middleware/shared/ports/esi-types' + +/** + * Parse a hex index string to a number for range comparison. + */ +function hexToNumber(hex: string): number { + return parseInt(hex.replace('0x', ''), 16) || 0 +} + +/** + * Check if an object index is in a configurable range. + * Excludes: + * 0x0000-0x0FFF: Data types (internal) + * 0x1000-0x1FFF: Communication / identity parameters (managed by master) + */ +function isConfigurableRange(index: string): boolean { + const num = hexToNumber(index) + return num >= 0x2000 +} + +/** + * 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. + */ +export function extractDefaultSdoConfigurations(coeObjects: ESICoEObject[]): SDOConfigurationEntry[] { + const entries: SDOConfigurationEntry[] = [] + + for (const obj of coeObjects) { + if (!isConfigurableRange(obj.index)) 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 + + entries.push({ + index: obj.index, + subIndex: subIdx, + value: defaultValue, + defaultValue, + dataType: sub.type, + bitLength: sub.bitSize, + name: sub.name, + objectName: obj.name, + }) + } + } else { + // Simple object: use object-level values + if (obj.access !== 'RW') continue + if (obj.defaultValue === undefined || obj.defaultValue === null) continue + const defaultValue = obj.defaultValue + + entries.push({ + index: obj.index, + subIndex: 0, + value: defaultValue, + defaultValue, + dataType: obj.type, + bitLength: obj.bitSize, + name: obj.name, + objectName: obj.name, + }) + } + } + + return entries +} diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 18207b615..febb4d74a 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -165,6 +165,8 @@ const PLCTaskSchema = z.object({ triggering: z.enum(['Cyclic', 'Interrupt']), interval: z.string(), // TODO: Must have a regex validation for this. Probably a new modal must be created to handle this. priority: z.number(), // TODO: implement this validation. This must be a positive integer from 0 to 100 + isSystemTask: z.boolean().optional(), + associatedDevice: z.string().optional(), }) type PLCTask = z.infer @@ -609,10 +611,136 @@ type ModbusTcpConfig = z.infer const PLCRemoteDeviceProtocolSchema = z.enum(['modbus-tcp', 'ethernet-ip', 'ethercat', 'profinet']) type PLCRemoteDeviceProtocol = z.infer +// ---- EtherCAT Configuration Schemas ---- + +const EtherCATChannelMappingSchema = z.object({ + channelId: z.string(), + iecLocation: z.string(), + userEdited: z.boolean(), + alias: z.string().optional(), +}) + +const ESIDeviceRefSchema = z.object({ + repositoryItemId: z.string(), + deviceIndex: z.number(), +}) + +const EtherCATStartupChecksSchema = z.object({ + checkVendorId: z.boolean(), + checkProductCode: z.boolean(), +}) + +const EtherCATAddressingSchema = z.object({ + ethercatAddress: z.number().int().min(0).max(65535), +}) + +const EtherCATTimeoutsSchema = z.object({ + sdoTimeoutMs: z.number().int().min(0), + initToPreOpTimeoutMs: z.number().int().min(0), + safeOpToOpTimeoutMs: z.number().int().min(0), +}) + +const EtherCATWatchdogSchema = z.object({ + smWatchdogEnabled: z.boolean(), + smWatchdogMs: z.number().int().min(0), + pdiWatchdogEnabled: z.boolean(), + pdiWatchdogMs: z.number().int().min(0), +}) + +const EtherCATDistributedClocksSchema = z.object({ + dcEnabled: z.boolean(), + dcSyncUnitCycleUs: z.number().int().min(0), + dcSync0Enabled: z.boolean(), + dcSync0CycleUs: z.number().int().min(0), + dcSync0ShiftUs: z.number().int().min(0), + dcSync1Enabled: z.boolean(), + dcSync1CycleUs: z.number().int().min(0), + dcSync1ShiftUs: z.number().int().min(0), +}) + +const EtherCATSlaveConfigSchema = z.object({ + startupChecks: EtherCATStartupChecksSchema, + addressing: EtherCATAddressingSchema, + timeouts: EtherCATTimeoutsSchema, + watchdog: EtherCATWatchdogSchema, + distributedClocks: EtherCATDistributedClocksSchema, +}) + +const PersistedPdoEntrySchema = z.object({ + index: z.string(), + subIndex: z.string(), + bitLen: z.number(), + name: z.string(), + dataType: z.string(), +}) + +const PersistedPdoSchema = z.object({ + index: z.string(), + name: z.string(), + entries: z.array(PersistedPdoEntrySchema), +}) + +const PersistedChannelInfoSchema = z.object({ + channelId: z.string(), + name: z.string(), + direction: z.enum(['input', 'output']), + pdoIndex: z.string(), + entryIndex: z.string(), + entrySubIndex: z.string(), + dataType: z.string(), + bitLen: z.number(), + iecType: z.string(), +}) + +const SDOConfigurationEntrySchema = z.object({ + index: z.string(), + subIndex: z.number(), + value: z.string(), + defaultValue: z.string(), + dataType: z.string(), + bitLength: z.number(), + name: z.string(), + objectName: z.string(), +}) + +const ConfiguredEtherCATDeviceSchema = z.object({ + id: z.string(), + position: z.number().optional(), + name: z.string(), + esiDeviceRef: ESIDeviceRefSchema, + vendorId: z.string(), + productCode: z.string(), + revisionNo: z.string(), + addedFrom: z.enum(['repository', 'scan']), + config: EtherCATSlaveConfigSchema, + channelMappings: z.array(EtherCATChannelMappingSchema), + channelInfo: z.array(PersistedChannelInfoSchema).optional(), + rxPdos: z.array(PersistedPdoSchema).optional(), + txPdos: z.array(PersistedPdoSchema).optional(), + slaveType: z.string().optional(), + sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), +}) + +const EtherCATMasterConfigSchema = z.object({ + enabled: z.boolean().optional(), + networkInterface: z.string(), + cycleTimeUs: z.number().int().min(100).max(100000), + watchdogTimeoutCycles: z.number().int().min(1).max(100).optional(), + taskPriority: z.number().int().min(1).max(31).optional(), +}) +type EtherCATMasterConfig = z.infer + +const EthercatConfigSchema = z.object({ + masterConfig: EtherCATMasterConfigSchema.optional(), + devices: z.array(ConfiguredEtherCATDeviceSchema), +}) +type EthercatConfig = z.infer + const PLCRemoteDeviceSchema = z.object({ name: z.string(), protocol: PLCRemoteDeviceProtocolSchema, modbusTcpConfig: ModbusTcpConfigSchema.optional(), + ethercatConfig: EthercatConfigSchema.optional(), }) type PLCRemoteDevice = z.infer @@ -682,6 +810,9 @@ type PLCProject = z.infer export { baseTypeSchema, bodySchema, + ConfiguredEtherCATDeviceSchema, + EthercatConfigSchema, + EtherCATMasterConfigSchema, ModbusErrorHandlingSchema, ModbusFunctionCodeSchema, ModbusIOGroupSchema, @@ -736,11 +867,14 @@ export { S7CommSlaveConfigSchema, S7CommSystemAreaSchema, S7CommSystemAreasSchema, + SDOConfigurationEntrySchema, } export type { BaseType, BodySchema, + EthercatConfig, + EtherCATMasterConfig, ModbusErrorHandling, ModbusFunctionCode, ModbusIOGroup, diff --git a/src/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index fdd4bbe3f..0b2b3694e 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -47,6 +47,7 @@ const TabIcons = { orchestrators: , 'remote-device': , server: , + 'ethercat-device': , } const Tab = (props: ITabProps) => { @@ -71,7 +72,8 @@ const Tab = (props: ITabProps) => { | 'pin-mapping' | 'orchestrators' | 'remote-device' - | 'server' = 'il' + | 'server' + | 'ethercat-device' = 'il' if (fileDerivation?.type === 'data-type' || fileDerivation?.type === 'device') { languageOrDerivation = fileDerivation?.derivation @@ -92,6 +94,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'server') { languageOrDerivation = 'server' } + if (fileDerivation?.type === 'ethercat-device') { + languageOrDerivation = 'ethercat-device' + } const { file: associatedFile } = getFile({ name: fileName || '' }) const handleFileName = useCallback( diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index 0054eec65..dd1e29cf2 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -62,7 +62,7 @@ const ServerProtocolSources = [ const RemoteDeviceProtocolSources = [ { value: 'modbus-tcp', label: 'Modbus', disabled: false }, { value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true }, - { value: 'ethercat', label: 'EtherCAT', disabled: true }, + { value: 'ethercat', label: 'EtherCAT', disabled: false }, { value: 'profinet', label: 'PROFINET', disabled: true }, ] as const diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx new file mode 100644 index 000000000..2b2709faa --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx @@ -0,0 +1,117 @@ +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import { cn } from '@root/frontend/utils/cn' + +type AdvancedTabProps = { + masterConfig: EtherCATMasterConfig + onUpdateMasterConfig: (updates: Partial) => void +} + +const inputClassName = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const AdvancedTab = ({ masterConfig, onUpdateMasterConfig }: AdvancedTabProps) => { + return ( +
+ {/* Enable Plugin */} +
+

Enable Bus

+
+
+ + {/* Cycle Time */} +
+

+ Cycle Time (microseconds) +

+
+ onUpdateMasterConfig({ cycleTimeUs: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 100) onUpdateMasterConfig({ cycleTimeUs: 100 }) + else if (val > 100000) onUpdateMasterConfig({ cycleTimeUs: 100000 }) + }} + min={100} + max={100000} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + EtherCAT bus cycle time in microseconds (100 - 100000) + +
+
+ + {/* Task Priority */} +
+

Task Priority

+
+ onUpdateMasterConfig({ taskPriority: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 1) onUpdateMasterConfig({ taskPriority: 1 }) + else if (val > 31) onUpdateMasterConfig({ taskPriority: 31 }) + }} + min={1} + max={31} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + Priority of the EtherCAT cyclic task (1 - 31) + +
+
+ + {/* Watchdog Timeout */} +
+

+ Watchdog Timeout (cycles) +

+
+ onUpdateMasterConfig({ watchdogTimeoutCycles: Number(e.target.value) })} + onBlur={(e) => { + const val = Number(e.target.value) + if (!val || val < 1) onUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) + else if (val > 100) onUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) + }} + min={1} + max={100} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + Number of missed cycles before watchdog triggers (1 - 100) + +
+
+
+ ) +} + +export { AdvancedTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx new file mode 100644 index 000000000..348201276 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/channel-mapping-table.tsx @@ -0,0 +1,223 @@ +import { cn } from '@root/frontend/utils/cn' +import type { ESIChannel, EtherCATChannelMapping } from '@root/middleware/shared/ports/esi-types' +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +type ChannelMappingTableProps = { + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onAliasChange: (channelId: string, newAlias: string) => void +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * Alias cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const AliasCell = React.memo( + ({ + channelId, + alias, + onAliasChange, + }: { + channelId: string + alias: string + onAliasChange: (channelId: string, newAlias: string) => void + }) => { + const [localAlias, setLocalAlias] = useState(alias) + + useEffect(() => { + setLocalAlias(alias) + }, [alias]) + + const handleBlur = useCallback(() => { + if (localAlias !== alias) { + onAliasChange(channelId, localAlias) + } + }, [channelId, localAlias, alias, onAliasChange]) + + return ( + setLocalAlias(e.target.value)} + onBlur={handleBlur} + placeholder='Alias' + className='h-[24px] w-full 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' + /> + ) + }, +) + +/** + * Channel Mapping Table Component + * + * Displays channels with auto-generated IEC 61131-3 located variable addresses (read-only) + * and editable alias column. + */ +const ChannelMappingTable = ({ channels, mappings, onAliasChange }: ChannelMappingTableProps) => { + const [filterDirection, setFilterDirection] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + // Build a lookup map from channelId to mapping + const mappingMap = useMemo(() => { + const map = new Map() + for (const m of mappings) { + map.set(m.channelId, m) + } + return map + }, [mappings]) + + // Build a 1-based per-direction index for each channel (stable regardless of filtering) + const channelIndexMap = useMemo(() => { + const map = new Map() + let inputIdx = 0 + let outputIdx = 0 + for (const ch of channels) { + if (ch.direction === 'input') { + inputIdx++ + map.set(ch.id, inputIdx) + } else { + outputIdx++ + map.set(ch.id, outputIdx) + } + } + return map + }, [channels]) + + // Filter channels based on direction and search + const filteredChannels = useMemo(() => { + return channels.filter((channel) => { + if (filterDirection !== 'all' && channel.direction !== filterDirection) { + return false + } + + if (searchTerm) { + const search = searchTerm.toLowerCase() + const mapping = mappingMap.get(channel.id) + return ( + channel.iecType.toLowerCase().includes(search) || + (mapping?.iecLocation.toLowerCase().includes(search) ?? false) || + (mapping?.alias?.toLowerCase().includes(search) ?? false) + ) + } + + return true + }) + }, [channels, filterDirection, searchTerm, mappingMap]) + + const inputCount = channels.filter((c) => c.direction === 'input').length + const outputCount = channels.filter((c) => c.direction === 'output').length + + return ( +
+ {/* Filters */} +
+ {/* Direction Filter */} +
+ + + +
+ + {/* Search */} + setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredChannels.length === 0 ? ( + + + + ) : ( + filteredChannels.map((channel) => { + const mapping = mappingMap.get(channel.id) + return ( + + + + + + + + ) + }) + )} + +
+ # + + Dir + + IEC Type + + Address + Alias
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ {channelIndexMap.get(channel.id) ?? ''} + + {channel.direction === 'input' ? 'Input' : 'Output'} + + {channel.iecType} + + {mapping?.iecLocation ?? ''} + + +
+
+
+ ) +} + +export { ChannelMappingTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx new file mode 100644 index 000000000..2dab3f5c6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-device-row.tsx @@ -0,0 +1,242 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' +import { useMemo } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type ConfiguredDeviceRowProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + isExpanded: boolean + onToggleExpand: () => void + isSelected: boolean + onSelect: () => void + onUpdateDevice: (config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +const ConfiguredDeviceRow = ({ + device, + repository, + isExpanded, + onToggleExpand, + isSelected, + onSelect, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDeviceRowProps) => { + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const ioSummary = esiDevice ? `${esiDevice.inputChannelCount} / ${esiDevice.outputChannelCount}` : '-' + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + enabled: isExpanded, + }) + + return ( + <> + {/* Main row */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect() + } + }} + className={cn( + 'cursor-pointer border-b border-neutral-200 dark:border-neutral-800', + isSelected && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + + {device.name} + {esiDevice?.name || 'Unknown'} + + {device.position !== undefined ? device.position : '-'} + + {ioSummary} + + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + + + + + {/* Expanded details */} + {isExpanded && ( + <> + {/* Device Info Section */} + + + +
+
+ Device Info +
+
+
+ Vendor:{' '} + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID:{' '} + {device.vendorId} +
+
+ Product Code:{' '} + {device.productCode} +
+
+ Revision:{' '} + {device.revisionNo} +
+
+ ESI File:{' '} + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group:{' '} + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels:{' '} + {esiDevice.inputChannelCount} +
+
+ Output Channels:{' '} + {esiDevice.outputChannelCount} +
+ + )} +
+
+ + + + {/* Configuration Section */} + + + +
+
+ Configuration +
+ +
+ + + + {/* Startup Parameters (SDO) Section */} + + + +
+
+ Startup Parameters (SDO) +
+ +
+ + + + {/* Channel Mappings Section */} + + + +
+
+ Channel Mappings +
+ +
+ + + + )} + + ) +} + +export { ConfiguredDeviceRow } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx new file mode 100644 index 000000000..fe17634d2 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/configured-devices.tsx @@ -0,0 +1,160 @@ +import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import TableActions from '@root/frontend/components/_atoms/table-actions' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' +import { useCallback, useEffect, useState } from 'react' + +import { ConfiguredDeviceRow } from './configured-device-row' + +type ConfiguredDevicesProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onAddDevice: () => void + onRemoveDevice: (deviceId: string) => void + onUpdateDevice: (deviceId: string, config: EtherCATSlaveConfig) => void + projectPath: string + onUpdateChannelMappings: (deviceId: string, mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (deviceId: string, data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (deviceId: string, configs: SDOConfigurationEntry[]) => void + usedAddresses: Set +} + +/** + * Configured Devices Component + * + * Displays the list of configured EtherCAT devices with add/remove functionality. + */ +const ConfiguredDevices = ({ + devices, + repository, + onAddDevice, + onRemoveDevice, + onUpdateDevice, + projectPath, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, + usedAddresses, +}: ConfiguredDevicesProps) => { + const [expandedDevices, setExpandedDevices] = useState>(new Set()) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + // Clear stale selection when device list changes externally + useEffect(() => { + if (selectedDeviceId && !devices.some((d) => d.id === selectedDeviceId)) { + setSelectedDeviceId(null) + } + }, [devices, selectedDeviceId]) + + const handleToggleExpand = useCallback((deviceId: string) => { + setExpandedDevices((prev) => { + const next = new Set(prev) + if (next.has(deviceId)) { + next.delete(deviceId) + } else { + next.add(deviceId) + } + return next + }) + }, []) + + const handleRemoveSelected = useCallback(() => { + if (selectedDeviceId) { + onRemoveDevice(selectedDeviceId) + setSelectedDeviceId(null) + } + }, [selectedDeviceId, onRemoveDevice]) + + return ( +
+ {/* Header with actions */} +
+

+ Configured Devices {devices.length > 0 && `(${devices.length})`} +

+ , + id: 'add-device-button', + }, + { + ariaLabel: 'Remove Device', + onClick: handleRemoveSelected, + disabled: !selectedDeviceId, + icon: , + id: 'remove-device-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
+ + {/* Devices table */} +
+ + + + + + + + + + + + + {devices.length === 0 ? ( + + + + ) : ( + devices.map((device) => ( + handleToggleExpand(device.id)} + isSelected={selectedDeviceId === device.id} + onSelect={() => setSelectedDeviceId(device.id)} + onUpdateDevice={(config) => onUpdateDevice(device.id, config)} + projectPath={projectPath} + onUpdateChannelMappings={(mappings) => onUpdateChannelMappings(device.id, mappings)} + onEnrichDevice={(data) => onEnrichDevice(device.id, data)} + onUpdateSdoConfigurations={(configs) => onUpdateSdoConfigurations(device.id, configs)} + usedAddresses={usedAddresses} + /> + )) + )} + +
+ Name + + Type + + Position + + Channels (In/Out) + Source
+ No devices configured. Click the + button to add a device from the repository, or use the Discovery + tab to scan and add devices. +
+
+
+ ) +} + +export { ConfiguredDevices } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx new file mode 100644 index 000000000..9345febe0 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-browser-modal.tsx @@ -0,0 +1,274 @@ +import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/frontend/components/_molecules/modal' +import { cn } from '@root/frontend/utils/cn' +import type { ESIDeviceRef, ESIDeviceSummary, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useCallback, useMemo, useState } from 'react' + +type DeviceBrowserModalProps = { + isOpen: boolean + onClose: () => void + onSelectDevice: (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => void + repository: ESIRepositoryItemLight[] +} + +/** + * Device Browser Modal Component + * + * Modal for browsing and selecting devices from the ESI repository. + * Groups devices by vendor for easier navigation. + */ +const DeviceBrowserModal = ({ isOpen, onClose, onSelectDevice, repository }: DeviceBrowserModalProps) => { + const [searchTerm, setSearchTerm] = useState('') + const [selectedRef, setSelectedRef] = useState(null) + const [expandedVendors, setExpandedVendors] = useState>(new Set()) + + // Group devices by vendor + const groupedDevices = useMemo(() => { + const groups: Map< + string, + { + vendorId: string + vendorName: string + devices: Array<{ + repoItem: ESIRepositoryItemLight + device: ESIDeviceSummary + deviceIndex: number + }> + } + > = new Map() + + for (const repoItem of repository) { + const vendorKey = repoItem.vendor.id + if (!groups.has(vendorKey)) { + groups.set(vendorKey, { + vendorId: repoItem.vendor.id, + vendorName: repoItem.vendor.name, + devices: [], + }) + } + + for (let i = 0; i < repoItem.devices.length; i++) { + const device = repoItem.devices[i] + // Apply search filter + if (searchTerm) { + const search = searchTerm.toLowerCase() + const matches = + device.name.toLowerCase().includes(search) || + device.type.productCode.toLowerCase().includes(search) || + repoItem.vendor.name.toLowerCase().includes(search) + if (!matches) continue + } + + groups.get(vendorKey)!.devices.push({ + repoItem, + device, + deviceIndex: i, + }) + } + } + + // Remove empty groups + for (const [key, group] of groups) { + if (group.devices.length === 0) { + groups.delete(key) + } + } + + return Array.from(groups.values()) + }, [repository, searchTerm]) + + const handleToggleVendor = useCallback((vendorId: string) => { + setExpandedVendors((prev) => { + const next = new Set(prev) + if (next.has(vendorId)) { + next.delete(vendorId) + } else { + next.add(vendorId) + } + return next + }) + }, []) + + const handleSelectDevice = useCallback((repoItemId: string, deviceIndex: number) => { + setSelectedRef({ repositoryItemId: repoItemId, deviceIndex }) + }, []) + + const handleConfirm = useCallback(() => { + if (!selectedRef) return + + const repoItem = repository.find((r) => r.id === selectedRef.repositoryItemId) + if (!repoItem) return + + const device = repoItem.devices[selectedRef.deviceIndex] + if (!device) return + + onSelectDevice(selectedRef, device, repoItem) + setSelectedRef(null) + setSearchTerm('') + onClose() + }, [selectedRef, repository, onSelectDevice, onClose]) + + const handleClose = useCallback(() => { + setSelectedRef(null) + setSearchTerm('') + onClose() + }, [onClose]) + + // Auto-expand all vendors when searching + const effectiveExpandedVendors = useMemo(() => { + if (searchTerm) { + return new Set(groupedDevices.map((g) => g.vendorId)) + } + return expandedVendors + }, [searchTerm, groupedDevices, expandedVendors]) + + const totalDevices = groupedDevices.reduce((sum, g) => sum + g.devices.length, 0) + + return ( + !open && handleClose()}> + + + Add Device from Repository + + + {/* Search */} +
+ setSearchTerm(e.target.value)} + className='h-[34px] w-full rounded-md border border-neutral-300 bg-white px-3 text-sm text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> +
+ + {/* Device count */} +
+ {totalDevices} device(s) in {groupedDevices.length} vendor(s) +
+ + {/* Device list */} +
+ {groupedDevices.length === 0 ? ( +
+

+ {repository.length === 0 + ? 'No ESI files loaded. Upload files in the Repository tab first.' + : 'No devices match your search.'} +

+
+ ) : ( +
+ {groupedDevices.map((group) => ( +
+ {/* Vendor header */} + + + {/* Devices */} + {effectiveExpandedVendors.has(group.vendorId) && ( +
+ {group.devices.map(({ repoItem, device, deviceIndex }) => { + const isSelected = + selectedRef?.repositoryItemId === repoItem.id && selectedRef?.deviceIndex === deviceIndex + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} + +export { DeviceBrowserModal } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx new file mode 100644 index 000000000..5c9a09140 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx @@ -0,0 +1,422 @@ +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import { cn } from '@root/frontend/utils/cn' +import type { + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' + +import { ChannelMappingTable } from './channel-mapping-table' +import { SdoParametersTable } from './sdo-parameters-table' + +const inputClassName = + 'h-[26px] w-24 rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus:border-brand-medium-dark dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + +const disabledInputClassName = 'cursor-not-allowed opacity-50' + +const parseNumericInput = (value: string, min = 0): number | undefined => { + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed < min) return undefined + return parsed +} + +// ===================== Configuration Form ===================== + +type DeviceConfigurationFormProps = { + config: EtherCATSlaveConfig + updateConfig: (section: K, updates: Partial) => void +} + +const DeviceConfigurationForm = ({ config, updateConfig }: DeviceConfigurationFormProps) => ( +
+ {/* Startup Checks */} +
+
Startup Checks
+
+ + +
+
+ + {/* Addressing */} +
+
Addressing
+
+
+ EtherCAT Address + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('addressing', { ethercatAddress: val }) + }} + min={0} + max={65535} + className={inputClassName} + /> + 0 = auto +
+
+
+ + {/* Timeouts */} +
+
Timeouts
+
+
+ SDO (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { sdoTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+ I→P (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { initToPreOpTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+ + P→S/S→O (ms) + + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('timeouts', { safeOpToOpTimeoutMs: val }) + }} + min={0} + className={inputClassName} + /> +
+
+
+ + {/* Watchdog */} +
+
Watchdog
+
+
+ +
+ Time (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('watchdog', { smWatchdogMs: val }) + }} + min={0} + className={cn(inputClassName, !config.watchdog.smWatchdogEnabled && disabledInputClassName)} + /> +
+
+
+ +
+ Time (ms) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('watchdog', { pdiWatchdogMs: val }) + }} + min={0} + className={cn(inputClassName, !config.watchdog.pdiWatchdogEnabled && disabledInputClassName)} + /> +
+
+
+
+ + {/* Distributed Clocks (DC) */} +
+
Distributed Clocks (DC)
+
+
+ +
+ + Sync Unit Cycle (us) + + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSyncUnitCycleUs: val }) + }} + min={0} + className={cn(inputClassName, !config.distributedClocks.dcEnabled && disabledInputClassName)} + /> + 0 = master cycle +
+
+ + {/* SYNC0 */} +
+ +
+ Cycle (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync0CycleUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync0Enabled) && + disabledInputClassName, + )} + /> +
+
+ Shift (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync0ShiftUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync0Enabled) && + disabledInputClassName, + )} + /> +
+
+ + {/* SYNC1 */} +
+ +
+ Cycle (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync1CycleUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync1Enabled) && + disabledInputClassName, + )} + /> +
+
+ Shift (us) + { + const val = parseNumericInput(e.target.value) + if (val !== undefined) updateConfig('distributedClocks', { dcSync1ShiftUs: val }) + }} + min={0} + className={cn( + inputClassName, + (!config.distributedClocks.dcEnabled || !config.distributedClocks.dcSync1Enabled) && + disabledInputClassName, + )} + /> +
+
+
+
+
+) + +// ===================== SDO Parameters Section ===================== + +type SdoParametersSectionProps = { + isLoading: boolean + loadError: string | null + sdoConfigurations: SDOConfigurationEntry[] | undefined + coeObjects: ESICoEObject[] | undefined + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +const SdoParametersSection = ({ + isLoading, + loadError, + sdoConfigurations, + coeObjects, + onUpdateSdoConfigurations, +}: SdoParametersSectionProps) => ( + <> + {isLoading && ( +
+ + Loading CoE data... +
+ )} + + {!isLoading && sdoConfigurations && sdoConfigurations.length > 0 && ( + + )} + + {!isLoading && !loadError && sdoConfigurations && sdoConfigurations.length === 0 && ( +

+ No configurable SDO parameters found in this device's CoE dictionary. +

+ )} + + {!isLoading && !loadError && !sdoConfigurations && coeObjects && coeObjects.length > 0 && ( +
+

+ CoE Object Dictionary available. Auto-configure startup parameters? +

+ +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {!isLoading && !loadError && !sdoConfigurations && !coeObjects && ( +

+ No CoE Object Dictionary available for this device. +

+ )} + +) + +// ===================== Channel Mappings Section ===================== + +type ChannelMappingsSectionProps = { + isLoading: boolean + loadError: string | null + channels: ESIChannel[] + mappings: EtherCATChannelMapping[] + onAliasChange: (channelId: string, alias: string) => void +} + +const ChannelMappingsSection = ({ + isLoading, + loadError, + channels, + mappings, + onAliasChange, +}: ChannelMappingsSectionProps) => ( + <> + {isLoading && ( +
+ + Loading channels... +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {!isLoading && !loadError && channels.length === 0 && ( +

+ No channels available for this device. +

+ )} + + {!isLoading && !loadError && channels.length > 0 && ( + + )} + +) + +export { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx new file mode 100644 index 000000000..de7d1d093 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-detail-panel.tsx @@ -0,0 +1,219 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' +import { useMemo, useState } from 'react' + +import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection } from './device-configuration-form' + +type DeviceDetailPanelProps = { + device: ConfiguredEtherCATDevice + repository: ESIRepositoryItemLight[] + projectPath: string + usedAddresses: Set + onUpdateDevice: (config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +const DeviceDetailPanel = ({ + device, + repository, + projectPath, + usedAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + onUpdateSdoConfigurations, +}: DeviceDetailPanelProps) => { + const [activeTab, setActiveTab] = useState('info') + + const esiDevice = useMemo(() => { + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device.esiDeviceRef]) + + const repoItem = useMemo(() => { + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + }, [repository, device.esiDeviceRef.repositoryItemId]) + + const externalAddresses = useMemo(() => { + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device.channelMappings]) + + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + }) + + return ( +
+ {/* Device header */} +
+

{device.name}

+

+ {esiDevice?.name || 'Unknown'} — Position {device.position ?? '-'} +

+
+ + {/* Sub-tabs */} + setActiveTab(v as DeviceDetailTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + {/* Device Info Tab */} + +
+
+
+ Vendor + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID + {device.vendorId} +
+
+ Product Code + {device.productCode} +
+
+ Revision + {device.revisionNo} +
+
+ ESI File + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels + {esiDevice.inputChannelCount} +
+
+ Output Channels + {esiDevice.outputChannelCount} +
+ + )} +
+ Source + + {device.addedFrom === 'scan' ? 'Scan' : 'Manual'} + +
+
+
+
+ + {/* Configuration Tab */} + +
+
+ +
+
+
+ + {/* Startup Parameters Tab */} + +
+ +
+
+ + {/* Channel Mappings Tab */} + +
+ +
+
+
+
+ ) +} + +export { DeviceDetailPanel } 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 new file mode 100644 index 000000000..d69b200e6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/device-scan-table.tsx @@ -0,0 +1,137 @@ +import { cn } from '@root/frontend/utils/cn' +import type { EtherCATDevice } from '@root/types/ethercat' + +type DeviceScanTableProps = { + devices: EtherCATDevice[] + selectedPosition: number | null + onSelectDevice: (position: number) => void + isScanning: boolean +} + +/** + * Get CSS class for EtherCAT state badge + */ +const getStateBadgeClass = (state: EtherCATDevice['state']) => { + switch (state) { + case 'OP': + return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + case 'SAFE-OP': + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + case 'PRE-OP': + return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + case 'INIT': + return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + case 'BOOT': + return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + case 'NONE': + case 'UNKNOWN': + default: + return 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400' + } +} + +/** + * Table displaying discovered EtherCAT devices + */ +const DeviceScanTable = ({ devices, selectedPosition, onSelectDevice, isScanning }: DeviceScanTableProps) => { + return ( +
+ + + + + + + + + + + + + + {isScanning ? ( + + + + ) : devices.length === 0 ? ( + + + + ) : ( + devices.map((device) => ( + onSelectDevice(device.position)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelectDevice(device.position) + } + }} + className={cn( + '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', + selectedPosition === device.position && 'bg-brand/10 dark:bg-brand/20', + )} + > + + + + + + + + + )) + )} + +
+ Pos + + Name + + Vendor + + Product + + State + + CoE + I/O Size
+
+
+ Scanning for devices... +
+
+ No devices found. Select an interface and click "Scan Devices". +
+ {device.position} + + {device.name} + + 0x{device.vendor_id.toString(16).toUpperCase().padStart(4, '0')} + + 0x{device.product_code.toString(16).toUpperCase().padStart(8, '0')} + + + {device.state} + + + {device.has_coe ? 'Yes' : 'No'} + + {device.input_bytes}B / {device.output_bytes}B +
+
+ ) +} + +export { DeviceScanTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx new file mode 100644 index 000000000..2982b9681 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/devices-tab.tsx @@ -0,0 +1,218 @@ +import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import TableActions from '@root/frontend/components/_atoms/table-actions' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, +} from '@root/middleware/shared/ports/esi-types' +import { useCallback, useMemo, useState } from 'react' + +import { DeviceBrowserModal } from './device-browser-modal' +import { ESIRepository } from './esi-repository' + +type DevicesTabProps = { + devices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onRepositoryChange: (items: ESIRepositoryItemLight[]) => void + projectPath: string + isLoadingRepository: boolean + repositoryError: string | null + onRetryRepository: () => void + onAddDeviceFromBrowser: ( + ref: ESIDeviceRef, + device: ESIDeviceSummary, + repoItem: ESIRepositoryItemLight, + ) => void | Promise + onRemoveDevice: (deviceId: string) => void + /** Called when user double-clicks a device to open its editor */ + onOpenDevice: (deviceId: string, deviceName: string) => void +} + +const DevicesTab = ({ + devices, + repository, + onRepositoryChange, + projectPath, + isLoadingRepository, + repositoryError, + onRetryRepository, + onAddDeviceFromBrowser, + onRemoveDevice, + onOpenDevice, +}: DevicesTabProps) => { + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + const [showRepository, setShowRepository] = useState(false) + + const selectedDevice = useMemo(() => { + return devices.find((d) => d.id === selectedDeviceId) ?? null + }, [devices, selectedDeviceId]) + + const effectiveSelectedId = selectedDevice ? selectedDeviceId : devices.length > 0 ? devices[0].id : null + + const handleRemoveSelected = useCallback(() => { + if (effectiveSelectedId) { + onRemoveDevice(effectiveSelectedId) + setSelectedDeviceId(null) + } + }, [effectiveSelectedId, onRemoveDevice]) + + return ( +
+ {/* Repository Section (collapsible) */} +
+ + + {showRepository && ( +
+ {repositoryError && ( +
+

Failed to load repository: {repositoryError}

+ +
+ )} + +
+ )} +
+ + {/* Configured Devices List */} +
+ {/* Header */} +
+

+ Configured Devices + {devices.length > 0 && ({devices.length})} +

+ setIsDeviceBrowserOpen(true), + icon: , + id: 'add-device-button', + }, + { + ariaLabel: 'Remove Device', + onClick: handleRemoveSelected, + disabled: !effectiveSelectedId, + icon: , + id: 'remove-device-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
+ + {/* Device List */} +
+ {devices.length === 0 ? ( +
+

+ No devices configured. Click + to add a device from the repository. +

+
+ ) : ( + devices.map((device) => { + const isActive = device.id === effectiveSelectedId + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + const esiDevice = repoItem?.devices[device.esiDeviceRef.deviceIndex] + + return ( + + ) + }) + )} +
+ + {/* Hint */} + {devices.length > 0 && ( +
+

+ Double-click a device to open its configuration +

+
+ )} +
+ + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={(...args) => void onAddDeviceFromBrowser(...args)} + repository={repository} + /> +
+ ) +} + +export { DevicesTab } 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 new file mode 100644 index 000000000..d3ab20416 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/diagnostics-tab.tsx @@ -0,0 +1,184 @@ +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 { DiscoveredDeviceTable } from './discovered-device-table' +import { InterfaceSelector } from './interface-selector' +import { RuntimeStatusPanel } from './runtime-status-panel' + +type DiagnosticsTabProps = { + isConnectedToRuntime: boolean + ipAddress: string | null + jwtToken: string | null + /** Master name to filter status from multi-master response */ + masterName?: string + // Service status + serviceAvailable: boolean | null + serviceMessage: string + // Network interfaces + interfaces: NetworkInterface[] + selectedInterface: string + onSelectInterface: (value: string) => void + isLoadingInterfaces: boolean + interfaceError: string | null + // Scan + isScanning: boolean + scanError: string | null + scanTimeMs: number | null + scanMessage: string + scannedDevices: EtherCATDevice[] + onScan: () => void + // Match results + deviceMatches: ScannedDeviceMatch[] + matchCounts: { total: number; exact: number; partial: number; none: number } + // Selection + selectedScannedDevices: Set + onSelectScannedDevice: (position: number, selected: boolean) => void + onSelectAllScanned: (selected: boolean) => void + onAddSelectedFromScan: () => void +} + +const DiagnosticsTab = ({ + isConnectedToRuntime, + ipAddress, + jwtToken, + masterName, + serviceAvailable, + serviceMessage, + interfaces, + selectedInterface, + onSelectInterface, + isLoadingInterfaces, + interfaceError, + isScanning, + scanError, + scanTimeMs, + scanMessage, + onScan, + deviceMatches, + matchCounts, + selectedScannedDevices, + onSelectScannedDevice, + onSelectAllScanned, + onAddSelectedFromScan, +}: DiagnosticsTabProps) => { + return ( +
+ {/* Runtime Status */} + {isConnectedToRuntime && ipAddress && jwtToken && ( + + )} + + {/* Not connected state */} + {!isConnectedToRuntime && ( +
+
+

Not connected to runtime

+

+ Connect to the OpenPLC Runtime to scan for EtherCAT devices. +

+
+
+ )} + + {/* Service not available state */} + {isConnectedToRuntime && serviceAvailable === false && ( +
+
+

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+
+ )} + + {/* Connected state - Discovery */} + {isConnectedToRuntime && serviceAvailable !== false && ( +
+ {/* Interface Selection and Scan Controls */} +
+ + + + + {scanTimeMs !== null && ( + + Completed in {scanTimeMs}ms{scanMessage ? ` — ${scanMessage}` : ''} + + )} +
+ + {/* Error/Status Messages */} + {scanError && ( +
+

{scanError}

+
+ )} + + {/* Match summary */} + {deviceMatches.length > 0 && ( +
+
+ + Found {matchCounts.total} device(s): + + {matchCounts.exact} exact + {matchCounts.partial} partial + {matchCounts.none} no match +
+ {selectedScannedDevices.size > 0 && ( + + )} +
+ )} + + {/* Discovered Devices Table */} + +
+ )} +
+ ) +} + +export { DiagnosticsTab } 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 new file mode 100644 index 000000000..5d74b82ad --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx @@ -0,0 +1,128 @@ +import { getBestMatchQuality } from '@root/backend/shared/ethercat/device-matcher' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { cn } from '@root/frontend/utils/cn' +import type { ScannedDeviceMatch } from '@root/middleware/shared/ports/esi-types' + +type DiscoveredDeviceTableProps = { + deviceMatches: ScannedDeviceMatch[] + selectedDevices: Set + onSelectDevice: (position: number, selected: boolean) => void + onSelectAll: (selected: boolean) => void + isScanning: boolean +} + +/** + * Discovered Device Table Component + * + * Displays scanned EtherCAT devices with match indicators and selection checkboxes. + */ +const DiscoveredDeviceTable = ({ + deviceMatches, + selectedDevices, + onSelectDevice, + 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)) + + const handleSelectAll = () => { + onSelectAll(!allSelected) + } + + return ( +
+ + + + + + + + + + + + {deviceMatches.length === 0 ? ( + + + + ) : ( + deviceMatches.map((dm) => { + 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 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) + } + }} + 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', + isSelected && 'bg-brand/10 dark:bg-brand/20', + !isSelectable && 'opacity-60', + )} + > + + + + + + + ) + }) + )} + +
+ + PosNameVendorProduct
+ {isScanning + ? 'Scanning for devices...' + : 'No devices found. Click "Scan" to discover EtherCAT devices on the network.'} +
+ onSelectDevice(dm.device.position, !!checked)} + onClick={(e) => e.stopPropagation()} + disabled={!isSelectable} + /> + + {dm.device.position} + + {displayName} + + 0x{dm.device.vendor_id.toString(16).padStart(4, '0').toUpperCase()} + + 0x{dm.device.product_code.toString(16).padStart(8, '0').toUpperCase()} +
+
+ ) +} + +export { DiscoveredDeviceTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx new file mode 100644 index 000000000..7206b5a38 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-channels-table.tsx @@ -0,0 +1,225 @@ +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { cn } from '@root/frontend/utils/cn' +import type { ESIChannel } from '@root/middleware/shared/ports/esi-types' +import { useMemo, useState } from 'react' + +type ESIChannelsTableProps = { + channels: ESIChannel[] + onChannelSelect?: (channelId: string, selected: boolean) => void + onChannelSelectAll?: (selected: boolean) => void + selectedChannels?: Set + showSelection?: boolean +} + +type FilterDirection = 'all' | 'input' | 'output' + +/** + * ESI Channels Table Component + * + * Displays PDO channels from an ESI file with filtering and selection capabilities. + */ +const ESIChannelsTable = ({ + channels, + onChannelSelect, + onChannelSelectAll, + selectedChannels = new Set(), + showSelection = true, +}: ESIChannelsTableProps) => { + const [filterDirection, setFilterDirection] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + // Filter channels based on direction and search + const filteredChannels = useMemo(() => { + return channels.filter((channel) => { + // Direction filter + if (filterDirection !== 'all' && channel.direction !== filterDirection) { + return false + } + + // Search filter + if (searchTerm) { + const search = searchTerm.toLowerCase() + return ( + channel.name.toLowerCase().includes(search) || + channel.pdoName.toLowerCase().includes(search) || + channel.dataType.toLowerCase().includes(search) || + channel.entryIndex.toLowerCase().includes(search) + ) + } + + return true + }) + }, [channels, filterDirection, searchTerm]) + + // Count by direction + const inputCount = channels.filter((c) => c.direction === 'input').length + const outputCount = channels.filter((c) => c.direction === 'output').length + + // Check if all filtered channels are selected + const allSelected = filteredChannels.length > 0 && filteredChannels.every((c) => selectedChannels.has(c.id)) + const someSelected = filteredChannels.some((c) => selectedChannels.has(c.id)) + + const handleSelectAll = () => { + if (onChannelSelectAll) { + onChannelSelectAll(!allSelected) + } + } + + return ( +
+ {/* Filters */} +
+ {/* Direction Filter */} +
+ + + +
+ + {/* Search */} + setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> + + {/* Selection count */} + {showSelection && selectedChannels.size > 0 && ( + + {selectedChannels.size} channel(s) selected + + )} +
+ + {/* Table */} +
+ + + + {showSelection && ( + + )} + + + + + + + + + + + {filteredChannels.length === 0 ? ( + + + + ) : ( + filteredChannels.map((channel) => ( + showSelection && onChannelSelect?.(channel.id, !selectedChannels.has(channel.id))} + className={cn( + 'cursor-pointer border-b border-neutral-200 transition-colors dark:border-neutral-800', + 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + selectedChannels.has(channel.id) && 'bg-brand/10 dark:bg-brand/20', + )} + > + {showSelection && ( + + )} + + + + + + + + + )) + )} + +
+ + + Dir + + Name + + PDO + + Index + + Type + + Bits + + IEC Type +
+ {channels.length === 0 ? 'No channels available' : 'No channels match the current filter'} +
+ onChannelSelect?.(channel.id, !!checked)} + onClick={(e) => e.stopPropagation()} + /> + + {channel.direction === 'input' ? 'Input' : 'Output'} + + {channel.name} + + {channel.pdoName} + + {channel.entryIndex}:{channel.entrySubIndex} + {channel.dataType}{channel.bitLen} + {channel.iecType} +
+
+
+ ) +} + +export { ESIChannelsTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx new file mode 100644 index 000000000..7cbceef8c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx @@ -0,0 +1,165 @@ +import { getDeviceSummary } from '@root/backend/shared/ethercat/esi-parser' +import { cn } from '@root/frontend/utils/cn' +import type { ESIDevice, ESIFile } from '@root/middleware/shared/ports/esi-types' + +type ESIDeviceInfoProps = { + esiFile: ESIFile + selectedDeviceIndex: number + onSelectDevice: (index: number) => void +} + +/** + * ESI Device Info Component + * + * Displays vendor information and device details from an ESI file. + */ +const ESIDeviceInfo = ({ esiFile, selectedDeviceIndex, onSelectDevice }: ESIDeviceInfoProps) => { + const selectedDevice: ESIDevice | undefined = esiFile.devices[selectedDeviceIndex] + const summary = selectedDevice ? getDeviceSummary(selectedDevice) : null + + return ( +
+ {/* Vendor Information */} +
+

Vendor

+
+
+ Name: + {esiFile.vendor.name} +
+
+ ID: + {esiFile.vendor.id} +
+
+
+ + {/* Device Selector (if multiple devices) */} + {esiFile.devices.length > 1 && ( +
+ +
+ {esiFile.devices.map((device, index) => ( + + ))} +
+
+ )} + + {/* Selected Device Information */} + {selectedDevice && ( +
+
+
+

{selectedDevice.name}

+

{selectedDevice.type.name}

+
+ {selectedDevice.groupName && ( + + {selectedDevice.groupName} + + )} +
+ + {/* Device Details Grid */} +
+
+ Product Code +

+ {selectedDevice.type.productCode} +

+
+
+ Revision +

+ {selectedDevice.type.revisionNo} +

+
+
+ Physics +

{selectedDevice.physics || 'N/A'}

+
+
+ CoE Support +

{summary?.hasCoe ? 'Yes' : 'No'}

+
+
+ + {/* I/O Summary */} + {summary && ( +
+

I/O Summary

+
+
+

{summary.inputChannelCount}

+

Input Channels

+
+
+

{summary.outputChannelCount}

+

Output Channels

+
+
+

+ {summary.totalInputBytes} B +

+

Input Size

+
+
+

+ {summary.totalOutputBytes} B +

+

Output Size

+
+
+
+ )} + + {/* Sync Managers */} + {selectedDevice.syncManagers.length > 0 && ( +
+

+ Sync Managers +

+
+ {selectedDevice.syncManagers.map((sm) => ( +
+ SM{sm.index}: {sm.type} +
+ ))} +
+
+ )} + + {/* Description */} + {selectedDevice.description && ( +
+

Description

+

{selectedDevice.description}

+
+ )} +
+ )} +
+ ) +} + +export { ESIDeviceInfo } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx new file mode 100644 index 000000000..a91b91180 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-parse-progress.tsx @@ -0,0 +1,41 @@ +type ESIParseProgressProps = { + currentFile?: string + currentFileIndex: number + totalFiles: number + percentage: number +} + +/** + * ESI Parse Progress Component + * + * Shows a progress bar with file-by-file status during ESI XML parsing. + */ +const ESIParseProgress = ({ currentFile, currentFileIndex, totalFiles, percentage }: ESIParseProgressProps) => { + return ( +
+
+ + Processing file {currentFileIndex + 1} / {totalFiles} + + {percentage}% +
+ + {/* Progress bar */} +
+
+
+ + {/* Current file name */} + {currentFile && ( + + {currentFile} + + )} +
+ ) +} + +export { ESIParseProgress } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx new file mode 100644 index 000000000..712904fd2 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository-table.tsx @@ -0,0 +1,222 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { cn } from '@root/frontend/utils/cn' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useCallback, useState } from 'react' + +type ESIRepositoryTableProps = { + repository: ESIRepositoryItemLight[] + onRemoveItem: (itemId: string) => void | Promise + onClearAll: () => void | Promise + isLoading?: boolean +} + +/** + * ESI Repository Table Component + * + * Displays loaded ESI files with expandable rows showing contained devices. + */ +const ESIRepositoryTable = ({ repository, onRemoveItem, onClearAll, isLoading = false }: ESIRepositoryTableProps) => { + const [expandedItems, setExpandedItems] = useState>(new Set()) + + const handleToggleExpand = useCallback((itemId: string) => { + setExpandedItems((prev) => { + const next = new Set(prev) + if (next.has(itemId)) { + next.delete(itemId) + } else { + next.add(itemId) + } + return next + }) + }, []) + + if (repository.length === 0) { + return ( +
+

+ No ESI files loaded. Upload files above to populate the repository. +

+
+ ) + } + + const totalDevices = repository.reduce((sum, item) => sum + item.devices.length, 0) + + return ( +
+ {/* Header with count and clear button */} +
+ + Loaded Files ({repository.length}) - {totalDevices} device(s) + + +
+ + {/* Repository list */} +
+ + + + + + + + + + + + {repository.map((item) => ( + handleToggleExpand(item.id)} + onRemove={() => void onRemoveItem(item.id)} + /> + ))} + +
+ Filename + + Vendor + + Devices + + Actions +
+
+
+ ) +} + +type RepositoryItemRowProps = { + item: ESIRepositoryItemLight + isExpanded: boolean + onToggleExpand: () => void + onRemove: () => void +} + +const RepositoryItemRow = ({ item, isExpanded, onToggleExpand, onRemove }: RepositoryItemRowProps) => { + return ( + <> + {/* Main row */} + + + + + +
+ + + + + {item.filename} + + {item.warnings && item.warnings.length > 0 && ( + + {item.warnings.length} warning(s) + + )} +
+ + + {item.vendor.name} + ({item.vendor.id}) + + {item.devices.length} + + + + + + {/* Expanded device rows */} + {isExpanded && + item.devices.map((device, index) => ( + + + +
+
+ + + + {device.name} +
+ + {device.type.productCode} + + + Rev: {device.type.revisionNo} + + {device.groupName && ( + + {device.groupName} + + )} +
+ + + ))} + + {/* Warnings row */} + {isExpanded && item.warnings && item.warnings.length > 0 && ( + + + +
+ {item.warnings.map((warning, index) => ( + + {warning} + + ))} +
+ + + )} + + ) +} + +export { ESIRepositoryTable } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx new file mode 100644 index 000000000..4cb7a0c5a --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-repository.tsx @@ -0,0 +1,148 @@ +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import { useCallback, useState } from 'react' + +import { ESIRepositoryTable } from './esi-repository-table' +import { ESIUpload } from './esi-upload' + +type ESIServiceResponse = { success: boolean; error?: string } + +type ESIRepositoryProps = { + repository: ESIRepositoryItemLight[] + onRepositoryChange: (repository: ESIRepositoryItemLight[]) => void + projectPath: string + isLoading?: boolean +} + +/** + * ESI Repository Component + * + * Manages the ESI file repository with upload and display functionality. + * Combines upload zone with repository table. + * Files are parsed and saved in the main process; this component receives ready items. + */ +const ESIRepository = ({ repository, onRepositoryChange, projectPath, isLoading = false }: ESIRepositoryProps) => { + const esi = useEsi() + const [uploadErrors, setUploadErrors] = useState>([]) + const [isSaving, setIsSaving] = useState(false) + + const handleFilesLoaded = useCallback( + (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => { + // Items are already parsed and saved by the main process + onRepositoryChange(items) + setUploadErrors(errors ?? []) + }, + [onRepositoryChange], + ) + + const handleRemoveItem = useCallback( + async (itemId: string) => { + setIsSaving(true) + + try { + const result: ESIServiceResponse = await esi!.deleteRepositoryItem(itemId) + + if (result.success) { + onRepositoryChange(repository.filter((item) => item.id !== itemId)) + } else { + console.error('Failed to delete ESI item:', result.error) + } + } catch (err) { + console.error('Error deleting ESI item:', err) + } finally { + setIsSaving(false) + } + }, + [repository, onRepositoryChange, projectPath], + ) + + const handleClearAll = useCallback(async () => { + setIsSaving(true) + + try { + const result: ESIServiceResponse = await esi!.clearRepository() + if (result.success) { + onRepositoryChange([]) + setUploadErrors([]) + } else { + console.error('Failed to clear ESI repository:', result.error) + } + } catch (err) { + console.error('Error clearing ESI repository:', err) + } finally { + setIsSaving(false) + } + }, [onRepositoryChange, projectPath]) + + const [errorsExpanded, setErrorsExpanded] = useState(false) + + const isProcessing = isLoading || isSaving + + return ( +
+ {/* Upload Area */} + + + {/* Error Summary (collapsible) */} + {uploadErrors.length > 0 && ( +
+
+ + +
+ {errorsExpanded && ( +
+ {uploadErrors.map((error, index) => ( +
+ {error.filename || 'File'}: {error.error} +
+ ))} +
+ )} +
+ )} + + {/* Repository Table */} +
+ +
+
+ ) +} + +export { ESIRepository } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx new file mode 100644 index 000000000..42cd63c03 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -0,0 +1,225 @@ +import { cn } from '@root/frontend/utils/cn' +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import { useCallback, useRef, useState } from 'react' + +import { ESIParseProgress } from './esi-parse-progress' + +type ParseProgress = { + active: boolean + currentFile?: string + currentFileIndex: number + totalFiles: number + percentage: number +} + +type ESIUploadProps = { + onFilesLoaded: (items: ESIRepositoryItemLight[], errors?: Array<{ filename: string; error: string }>) => void + repository: ESIRepositoryItemLight[] + isLoading?: boolean +} + +/** + * ESI File Upload Component + * + * Allows users to upload multiple EtherCAT ESI XML files via drag-and-drop or file picker. + * Files are read and sent to the main process one at a time to avoid memory issues. + */ +const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadProps) => { + const esi = useEsi() + const [isDragging, setIsDragging] = useState(false) + const [parseProgress, setParseProgress] = useState({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 0, + }) + const fileInputRef = useRef(null) + + const processFiles = useCallback( + async (files: FileList) => { + const xmlFiles = Array.from(files).filter((file) => file.name.toLowerCase().endsWith('.xml')) + + if (xmlFiles.length === 0) { + onFilesLoaded(repository, [{ filename: '', error: 'No XML files found. Please upload .xml ESI files.' }]) + return + } + + setParseProgress({ + active: true, + currentFile: xmlFiles[0].name, + currentFileIndex: 0, + totalFiles: xmlFiles.length, + percentage: 0, + }) + + const newItems: ESIRepositoryItemLight[] = [] + const errors: Array<{ filename: string; error: string }> = [] + + const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB + + // Process files one at a time to avoid memory issues + for (let i = 0; i < xmlFiles.length; i++) { + const file = xmlFiles[i] + + setParseProgress({ + active: true, + currentFile: file.name, + currentFileIndex: i, + totalFiles: xmlFiles.length, + percentage: Math.round((i / xmlFiles.length) * 100), + }) + + if (file.size > MAX_FILE_SIZE) { + errors.push({ + filename: file.name, + error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 100MB.`, + }) + continue + } + + try { + const text = await file.text() + const result = await esi!.parseAndSaveFile(file.name, text) + + if (result.success && result.item) { + newItems.push(result.item) + } else if (result.success) { + // Duplicate content already in the repository — skip silently. + // See EsiPort.parseAndSaveFile for the duplicate-handling contract. + } else { + errors.push({ filename: file.name, error: result.error ?? 'Parse failed' }) + } + } catch (err) { + errors.push({ filename: file.name, error: err instanceof Error ? err.message : String(err) }) + } + } + + setParseProgress({ + active: false, + currentFileIndex: 0, + totalFiles: 0, + percentage: 100, + }) + + onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) + }, + [onFilesLoaded, repository, esi], + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = e.dataTransfer.files + if (files.length > 0) { + void processFiles(files) + } + }, + [processFiles], + ) + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + void processFiles(files) + } + // Reset input so same files can be selected again + e.target.value = '' + }, + [processFiles], + ) + + const handleClick = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const isProcessing = isLoading || parseProgress.active + + return ( +
+ + + {/* Drop zone */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + aria-label='Upload ESI XML files. Click or drag and drop.' + className={cn( + 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors', + isDragging + ? 'bg-brand/10 dark:bg-brand/20 border-brand' + : 'border-neutral-300 hover:border-brand hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800/50', + isProcessing && 'pointer-events-none opacity-50', + )} + > + + + {parseProgress.active ? ( +
+ +
+ ) : ( +
+ + + + + Drop ESI files here or browse + + + Supports multiple .xml ESI files (ETG.2000) + + {repository.length > 0 && ( + + {repository.length} file(s) currently loaded + + )} +
+ )} +
+
+ ) +} + +export { ESIUpload } 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 new file mode 100644 index 000000000..9716381b2 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx @@ -0,0 +1,220 @@ +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' +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 { useEffect, useState } from 'react' + +type GlobalSettingsTabProps = { + masterConfig: EtherCATMasterConfig + onUpdateMasterConfig: (updates: Partial) => void + isConnectedToRuntime: boolean + interfaces: NetworkInterface[] + isLoadingInterfaces: boolean + onRefreshInterfaces: () => void +} + +const inputClassName = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const GlobalSettingsTab = ({ + masterConfig, + onUpdateMasterConfig, + isConnectedToRuntime, + interfaces, + isLoadingInterfaces, + onRefreshInterfaces, +}: GlobalSettingsTabProps) => { + // Local draft state so the user's in-progress keystrokes (including + // temporarily empty or out-of-range values) render without writing + // NaN / invalid numbers into the persisted masterConfig. Only finite, + // in-range values propagate to the store on change; onBlur clamps any + // remaining bad draft back into range. + const [cycleTimeDraft, setCycleTimeDraft] = useState(String(masterConfig.cycleTimeUs)) + const [watchdogDraft, setWatchdogDraft] = useState(String(masterConfig.watchdogTimeoutCycles ?? 3)) + + useEffect(() => { + setCycleTimeDraft(String(masterConfig.cycleTimeUs)) + }, [masterConfig.cycleTimeUs]) + + useEffect(() => { + setWatchdogDraft(String(masterConfig.watchdogTimeoutCycles ?? 3)) + }, [masterConfig.watchdogTimeoutCycles]) + + const commitIfValid = (raw: string, min: number, max: number, commit: (value: number) => void) => { + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed >= min && parsed <= max) { + commit(parsed) + } + } + + return ( +
+ {/* Enable Plugin */} +
+

Enable Plugin

+
+
+ + {/* Network Interface */} +
+

+ Network Interface +

+
+ {isConnectedToRuntime && interfaces.length > 0 ? ( +
+ + +
+ ) : ( + onUpdateMasterConfig({ networkInterface: e.target.value })} + placeholder='eth0' + className={cn(inputClassName, 'max-w-[320px]')} + /> + )} + + {isConnectedToRuntime && interfaces.length > 0 + ? 'Select from runtime interfaces' + : 'Interface name on the runtime host (e.g. eth0, enp3s0)'} + +
+
+ + {/* Cycle Time */} +
+

+ Cycle Time (microseconds) +

+
+ { + setCycleTimeDraft(e.target.value) + commitIfValid(e.target.value, 100, 100000, (v) => onUpdateMasterConfig({ cycleTimeUs: v })) + }} + onBlur={(e) => { + const val = Number(e.target.value) + if (!Number.isFinite(val) || val < 100) { + onUpdateMasterConfig({ cycleTimeUs: 100 }) + setCycleTimeDraft('100') + } else if (val > 100000) { + onUpdateMasterConfig({ cycleTimeUs: 100000 }) + setCycleTimeDraft('100000') + } + }} + min={100} + max={100000} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + EtherCAT bus cycle time in microseconds (100 - 100000) + +
+
+ + {/* Watchdog Timeout */} +
+

+ Watchdog Timeout (cycles) +

+
+ { + setWatchdogDraft(e.target.value) + commitIfValid(e.target.value, 1, 100, (v) => onUpdateMasterConfig({ watchdogTimeoutCycles: v })) + }} + onBlur={(e) => { + const val = Number(e.target.value) + if (!Number.isFinite(val) || val < 1) { + onUpdateMasterConfig({ watchdogTimeoutCycles: 1 }) + setWatchdogDraft('1') + } else if (val > 100) { + onUpdateMasterConfig({ watchdogTimeoutCycles: 100 }) + setWatchdogDraft('100') + } + }} + min={1} + max={100} + className={cn(inputClassName, 'max-w-[200px]')} + /> + + Number of missed cycles before watchdog triggers (1 - 100) + +
+
+
+ ) +} + +export { GlobalSettingsTab } 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 new file mode 100644 index 000000000..065dcf497 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/interface-selector.tsx @@ -0,0 +1,212 @@ +import * as Popover from '@radix-ui/react-popover' +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +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 { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +type InterfaceSelectorProps = { + interfaces: NetworkInterface[] + selectedInterface: string + onSelectInterface: (value: string) => void + isLoading: boolean + error: string | null +} + +/** + * Editable combobox for network interface selection. + * Shows a dropdown with available interfaces from runtime, but also allows typing custom values. + * Follows the same pattern as the Modbus RTU SerialPortCombobox. + */ +const InterfaceSelector = ({ + interfaces, + selectedInterface, + onSelectInterface, + isLoading, + error, +}: InterfaceSelectorProps) => { + const [isOpen, setIsOpen] = useState(false) + const [inputValue, setInputValue] = useState(selectedInterface) + const inputRef = useRef(null) + const optionRefs = useRef>([]) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + // Sync input value with external value changes + useEffect(() => { + setInputValue(selectedInterface) + }, [selectedInterface]) + + // Build options from interfaces + const options = useMemo( + () => interfaces.map((iface) => ({ value: iface.name, label: iface.description || iface.name })), + [interfaces], + ) + + // Filter options based on input + const filteredOptions = useMemo(() => { + if (!inputValue.trim()) return options + const lowerInput = inputValue.toLowerCase() + return options.filter( + (opt) => opt.value.toLowerCase().includes(lowerInput) || opt.label.toLowerCase().includes(lowerInput), + ) + }, [options, inputValue]) + + // Focus input and select all text when dropdown opens + useEffect(() => { + if (isOpen) { + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + const currentIndex = options.findIndex((opt) => opt.value === selectedInterface) + setHighlightedIndex(currentIndex >= 0 ? currentIndex : -1) + }, 0) + } + }, [isOpen, options, selectedInterface]) + + // Scroll highlighted option into view + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length && optionRefs.current[highlightedIndex]) { + optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' }) + } + }, [highlightedIndex, filteredOptions.length]) + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + setHighlightedIndex(-1) + } + + const handleInputBlur = () => { + if (inputValue !== selectedInterface) { + onSelectInterface(inputValue) + } + } + + const handleSelectOption = useCallback( + (optionValue: string) => { + setInputValue(optionValue) + onSelectInterface(optionValue) + setIsOpen(false) + }, + [onSelectInterface], + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : 0)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + handleSelectOption(filteredOptions[highlightedIndex].value) + } else if (inputValue.trim()) { + onSelectInterface(inputValue.trim()) + setIsOpen(false) + } + } else if (e.key === 'Escape') { + setIsOpen(false) + } + } + + const handleOpenChange = (open: boolean) => { + if (!open && inputValue.trim() !== selectedInterface) { + onSelectInterface(inputValue.trim()) + } + setIsOpen(open) + } + + return ( +
+ + + + + + + +
+ +
+
+ {isLoading ? ( +
+ Loading interfaces... +
+ ) : filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( +
(optionRefs.current[index] = el)} + className={cn( + 'flex w-full cursor-pointer flex-col px-2 py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-800', + (selectedInterface === option.value || highlightedIndex === index) && + 'bg-neutral-100 dark:bg-neutral-800', + )} + onMouseEnter={() => setHighlightedIndex(index)} + onClick={() => handleSelectOption(option.value)} + role='option' + aria-selected={highlightedIndex === index} + > + + {option.value} + + {option.label !== option.value && ( + + {option.label} + + )} +
+ )) + ) : ( +
+ {options.length === 0 + ? 'No interfaces available. Type a custom value.' + : 'No matches. Type a custom value.'} +
+ )} +
+ {inputValue.trim() && !filteredOptions.some((opt) => opt.value === inputValue.trim()) && ( +
handleSelectOption(inputValue.trim())} + > + + + Use "{inputValue.trim()}" + +
+ )} +
+
+
+ + {error &&

{error}

} +
+ ) +} + +export { InterfaceSelector } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx new file mode 100644 index 000000000..2935f06c0 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/repository-tab.tsx @@ -0,0 +1,47 @@ +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' + +import { ESIRepository } from './esi-repository' + +type RepositoryTabProps = { + repository: ESIRepositoryItemLight[] + onRepositoryChange: (items: ESIRepositoryItemLight[]) => void + projectPath: string + isLoadingRepository: boolean + repositoryError: string | null + onRetryRepository: () => void +} + +const RepositoryTab = ({ + repository, + onRepositoryChange, + projectPath, + isLoadingRepository, + repositoryError, + onRetryRepository, +}: RepositoryTabProps) => { + return ( +
+
+ {repositoryError && ( +
+

Failed to load repository: {repositoryError}

+ +
+ )} + +
+
+ ) +} + +export { RepositoryTab } 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 new file mode 100644 index 000000000..4b3162a05 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/runtime-status-panel.tsx @@ -0,0 +1,354 @@ +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' +import { useCallback, useEffect, useRef, useState } from 'react' + +const POLL_INTERVAL_MS = 2000 + +type StatusColor = 'green' | 'yellow' | 'red' | 'gray' | 'blue' + +const stateColorMap: Record = { + IDLE: 'gray', + SCANNING: 'blue', + CONFIGURING: 'blue', + TRANSITIONING: 'blue', + OPERATIONAL: 'green', + RECOVERING: 'yellow', + ERROR: 'red', + STOPPED: 'gray', +} + +const colorClasses: Record = { + green: 'bg-green-500', + yellow: 'bg-yellow-500', + red: 'bg-red-500', + gray: 'bg-neutral-400', + blue: 'bg-blue-500', +} + +const colorTextClasses: Record = { + green: 'text-green-700 dark:text-green-400', + yellow: 'text-yellow-700 dark:text-yellow-400', + red: 'text-red-700 dark:text-red-400', + gray: 'text-neutral-600 dark:text-neutral-400', + blue: 'text-blue-700 dark:text-blue-400', +} + +function StatusDot({ color }: { color: StatusColor }) { + return +} + +function SlaveStateCell({ status }: { status: EtherCATSlaveStatus }) { + const isOp = status.state === 'OP' + const hasError = status.has_error + return ( + + {status.state} + + ) +} + +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. */ + 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. + */ +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, + } + } + + return null +} + +function extractErrorMessage(rawError: string): string { + // Try to parse JSON error responses from the runtime (e.g. {"status":"error","message":"..."}) + try { + const parsed = JSON.parse(rawError) as Record + if (typeof parsed.message === 'string') return parsed.message + if (typeof parsed.error === 'string') return parsed.error + } catch { + // Not JSON, continue with raw string + } + return rawError +} + +/** + * Detect only errors that indicate the EtherCAT plugin endpoint is missing or + * disabled on the runtime (HTTP 404, HTML error page, or explicit not-loaded + * messages). Network/connectivity failures like timeouts and "connection + * refused" are NOT plugin-state issues — they must surface as real errors so + * users can diagnose their connection instead of seeing a misleading + * "plugin not active" banner. + */ +function isPluginNotActiveError(message: string): boolean { + const lower = message.toLowerCase() + return ( + lower.includes('not loaded') || + lower.includes('not available') || + lower.includes('plugin not active') || + lower.includes('plugin not found') || + lower.includes('(null) + const [error, setError] = useState(null) + const [pluginNotActive, setPluginNotActive] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const intervalRef = useRef | null>(null) + + const fetchStatus = useCallback(async () => { + if (!isConnected || !ipAddress || !jwtToken) { + setStatus(null) + return + } + + setIsLoading(true) + try { + const result = await runtime.getEthercatRuntimeStatus!() + if (result.success && result.data) { + setStatus(result.data) + setError(null) + setPluginNotActive(false) + } else { + const rawError = result.error ?? 'Failed to get status' + const cleanMessage = extractErrorMessage(rawError) + if (isPluginNotActiveError(cleanMessage)) { + setPluginNotActive(true) + setError(null) + setStatus(null) + } else { + setPluginNotActive(false) + setError(cleanMessage) + } + } + } catch (err) { + setError(String(err)) + setPluginNotActive(false) + } finally { + setIsLoading(false) + } + }, [isConnected, ipAddress, jwtToken]) + + // Start polling when connected + useEffect(() => { + if (!isConnected) { + setStatus(null) + setError(null) + setPluginNotActive(false) + return + } + + void fetchStatus() + + intervalRef.current = setInterval(() => { + void fetchStatus() + }, POLL_INTERVAL_MS) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [isConnected, fetchStatus]) + + if (!isConnected) { + return ( +
+ + Not connected to runtime +
+ ) + } + + if (pluginNotActive) { + return ( +
+ + + EtherCAT plugin not active - start PLC with EtherCAT configuration + +
+ ) + } + + if (error && !status) { + return ( +
+
+ + {error} +
+ +
+ ) + } + + const masterStatus = status ? resolveMasterStatus(status, masterName) : null + + if (!status || !masterStatus) { + return ( +
+ + {isLoading ? 'Loading status...' : 'Waiting for status...'} + +
+ ) + } + + const pluginState = masterStatus.plugin_state + const stateColor = stateColorMap[pluginState] ?? 'gray' + const metrics: EtherCATCycleMetrics = masterStatus.metrics + + return ( +
+ {/* Plugin state header */} +
+
+ + {pluginState} + + ({masterStatus.slave_count} slave{masterStatus.slave_count !== 1 ? 's' : ''}, WKC= + {masterStatus.expected_wkc}) + +
+ +
+ + {/* Cycle metrics */} + {(pluginState === 'OPERATIONAL' || pluginState === 'RECOVERING' || pluginState === 'ERROR') && ( +
+ + + + + + {metrics.recovery_attempts > 0 && } +
+ )} + + {/* Slave table */} + {masterStatus.slaves.length > 0 && ( +
+ + + + + + + + + + + + {masterStatus.slaves.map((slave) => ( + + + + + + + + ))} + +
PosNameStateAL StatusErrors
{slave.position}{slave.name} + + + {slave.al_status_code === 0 + ? '-' + : `0x${slave.al_status_code.toString(16).toUpperCase().padStart(4, '0')}`} + + {slave.error_count > 0 ? ( + {slave.error_count} + ) : ( + '0' + )} +
+
+ )} +
+ ) +} + +export { RuntimeStatusPanel } 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 new file mode 100644 index 000000000..f2a6500a9 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/scan-bus-tab.tsx @@ -0,0 +1,275 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import TableActions from '@root/frontend/components/_atoms/table-actions' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + ScannedDeviceMatch, +} from '@root/middleware/shared/ports/esi-types' +import type { EtherCATDevice, NetworkInterface } from '@root/types/ethercat' +import { useState } from 'react' + +import { DeviceBrowserModal } from './device-browser-modal' +import { DiscoveredDeviceTable } from './discovered-device-table' +import { InterfaceSelector } from './interface-selector' + +type ScanBusTabProps = { + isConnectedToRuntime: boolean + // Service status + serviceAvailable: boolean | null + serviceMessage: string + // Network interfaces + interfaces: NetworkInterface[] + selectedInterface: string + onSelectInterface: (value: string) => void + isLoadingInterfaces: boolean + interfaceError: string | null + // Scan + isScanning: boolean + scanError: string | null + scanTimeMs: number | null + scanMessage: string + scannedDevices: EtherCATDevice[] + onScan: () => void + // Match results + deviceMatches: ScannedDeviceMatch[] + // Selection + selectedScannedDevices: Set + onSelectScannedDevice: (position: number, selected: boolean) => void + onSelectAllScanned: (selected: boolean) => void + onAddSelectedFromScan: () => void + // Configured devices & manual add/remove + configuredDevices: ConfiguredEtherCATDevice[] + repository: ESIRepositoryItemLight[] + onAddDeviceFromBrowser: (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => void + onRemoveDevice: (deviceId: string) => void +} + +const ScanBusTab = ({ + isConnectedToRuntime, + serviceAvailable, + serviceMessage, + interfaces, + selectedInterface, + onSelectInterface, + isLoadingInterfaces, + interfaceError, + isScanning, + scanError, + scanTimeMs, + scanMessage, + onScan, + deviceMatches, + selectedScannedDevices, + onSelectScannedDevice, + onSelectAllScanned, + onAddSelectedFromScan, + configuredDevices, + repository, + onAddDeviceFromBrowser, + onRemoveDevice, +}: ScanBusTabProps) => { + const [isDeviceBrowserOpen, setIsDeviceBrowserOpen] = useState(false) + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + return ( +
+ {/* Service not available state */} + {isConnectedToRuntime && serviceAvailable === false && ( +
+

+ EtherCAT Discovery Service Not Available +

+

{serviceMessage}

+
+ )} + + {/* Interface Selection and Scan Controls */} +
+
+ + + + + {scanTimeMs !== null && ( + + Completed in {scanTimeMs}ms{scanMessage ? ` — ${scanMessage}` : ''} + + )} +
+ + {/* Error/Status Messages */} + {scanError && ( +
+

{scanError}

+
+ )} + + {/* Side-by-side: Scanned Devices (left) + Configured Devices (right) */} +
+ {/* Scanned Devices — left */} +
+
+

Scanned Devices

+ +
+ +
+ + {/* Configured Devices — right */} +
+ {/* Header with +/- actions */} +
+

+ Configured Devices + {configuredDevices.length > 0 && ( + ({configuredDevices.length}) + )} +

+ setIsDeviceBrowserOpen(true), + icon: , + id: 'add-ethercat-device-button', + }, + { + ariaLabel: 'Remove Device', + onClick: () => { + if (selectedDeviceId) { + onRemoveDevice(selectedDeviceId) + setSelectedDeviceId(null) + } + }, + disabled: !selectedDeviceId, + icon: , + id: 'remove-ethercat-device-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
+ + {/* Device table */} +
+ + + + + + + + + + + {configuredDevices.length === 0 ? ( + + + + ) : ( + configuredDevices.map((device) => { + const isActive = device.id === selectedDeviceId + + return ( + setSelectedDeviceId(device.id === selectedDeviceId ? null : device.id)} + className={cn( + 'cursor-pointer border-b border-neutral-200 transition-colors dark:border-neutral-800', + isActive + ? 'bg-brand/10 dark:bg-brand/20' + : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50', + )} + > + + + + + + ) + }) + )} + +
+ Pos + + Name + + Vendor + + Product +
+ No devices configured. Click + to add a device from the repository. +
+ {device.position ?? '-'} + + {device.name} + + 0x{parseInt(device.vendorId, 16).toString(16).padStart(4, '0').toUpperCase()} + + 0x{parseInt(device.productCode, 16).toString(16).padStart(8, '0').toUpperCase()} +
+
+
+
+
+ + {/* Device Browser Modal */} + setIsDeviceBrowserOpen(false)} + onSelectDevice={onAddDeviceFromBrowser} + repository={repository} + /> +
+ ) +} + +export { ScanBusTab } 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 new file mode 100644 index 000000000..c249e488b --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/sdo-parameters-table.tsx @@ -0,0 +1,389 @@ +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { cn } from '@root/frontend/utils/cn' +import type { SDOConfigurationEntry } from '@root/middleware/shared/ports/esi-types' +import { useCallback, useEffect, useMemo, useState } from 'react' + +type SdoParametersTableProps = { + sdoConfigurations: SDOConfigurationEntry[] + onUpdateSdoConfigurations: (configs: SDOConfigurationEntry[]) => void +} + +/** + * A group of SDO entries sharing the same parent object (index + objectName). + */ +interface SdoObjectGroup { + index: string + objectName: string + entries: SDOConfigurationEntry[] +} + +/** + * Get numeric range for a data type. + */ +function getDataTypeRange(dataType: string, bitLength: number): { min: number; max: number } | null { + const upper = dataType.toUpperCase() + + if (upper === 'BOOL') return { min: 0, max: 1 } + if (upper === 'USINT' || upper === 'UINT8') return { min: 0, max: 255 } + if (upper === 'SINT' || upper === 'INT8') return { min: -128, max: 127 } + if (upper === 'UINT' || upper === 'UINT16') return { min: 0, max: 65535 } + if (upper === 'INT' || upper === 'INT16') return { min: -32768, max: 32767 } + if (upper === 'UDINT' || upper === 'UINT32') return { min: 0, max: 4294967295 } + if (upper === 'DINT' || upper === 'INT32') return { min: -2147483648, max: 2147483647 } + if (upper === 'REAL' || upper === 'REAL32' || upper === 'FLOAT') return { min: -3.4028235e38, max: 3.4028235e38 } + if (upper === 'LREAL' || upper === 'REAL64' || upper === 'DOUBLE') + return { min: -1.7976931348623157e308, max: 1.7976931348623157e308 } + + // Fallback based on bit length for unsigned + if (bitLength > 0 && bitLength <= 32) { + return { min: 0, max: Math.pow(2, bitLength) - 1 } + } + + return null +} + +/** + * Check if the data type is boolean. + */ +function isBoolType(dataType: string): boolean { + return dataType.toUpperCase() === 'BOOL' +} + +/** + * Value cell with local state to avoid re-rendering the entire table on every keystroke. + */ +const ValueCell = ({ + entry, + onValueChange, +}: { + entry: SDOConfigurationEntry + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + const [localValue, setLocalValue] = useState(entry.value) + + useEffect(() => { + setLocalValue(entry.value) + }, [entry.value]) + + 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]) + + if (isBoolType(entry.dataType)) { + return ( + { + const newVal = e.target.checked ? '1' : '0' + setLocalValue(newVal) + onValueChange(entry.index, entry.subIndex, newVal) + }} + className='h-4 w-4 accent-brand' + /> + ) + } + + const range = getDataTypeRange(entry.dataType, entry.bitLength) + const isFloat = ['REAL', 'REAL32', 'FLOAT', 'LREAL', 'REAL64', 'DOUBLE'].includes(entry.dataType.toUpperCase()) + const isNumeric = range !== null + + return ( + 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' + /> + ) +} + +/** + * SDO Parameters Table Component + * + * Displays configurable SDO startup parameters grouped by parent CoE object. + * Each object is a collapsible section header; sub-items are shown as editable rows beneath. + */ +const SdoParametersTable = ({ sdoConfigurations, onUpdateSdoConfigurations }: SdoParametersTableProps) => { + const [searchTerm, setSearchTerm] = useState('') + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + + // Group entries by parent object index + const groups = useMemo(() => { + const map = new Map() + for (const entry of sdoConfigurations) { + const existing = map.get(entry.index) + if (existing) { + existing.entries.push(entry) + } else { + map.set(entry.index, { index: entry.index, objectName: entry.objectName, entries: [entry] }) + } + } + return Array.from(map.values()) + }, [sdoConfigurations]) + + // Filter groups and entries by search term + const filteredGroups = useMemo(() => { + if (!searchTerm) return groups + + const search = searchTerm.toLowerCase() + const result: SdoObjectGroup[] = [] + + for (const group of groups) { + // If group name/index matches, include entire group + if (group.objectName.toLowerCase().includes(search) || group.index.toLowerCase().includes(search)) { + result.push(group) + continue + } + + // Otherwise filter entries within the group + const matchedEntries = group.entries.filter( + (entry) => + entry.name.toLowerCase().includes(search) || + entry.dataType.toLowerCase().includes(search) || + entry.index.toLowerCase().includes(search), + ) + + if (matchedEntries.length > 0) { + result.push({ ...group, entries: matchedEntries }) + } + } + + return result + }, [groups, searchTerm]) + + const handleToggleGroup = useCallback((index: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + }, []) + + const handleExpandAll = useCallback(() => { + setCollapsedGroups(new Set()) + }, []) + + const handleCollapseAll = useCallback(() => { + setCollapsedGroups(new Set(groups.map((g) => g.index))) + }, [groups]) + + const handleValueChange = useCallback( + (index: string, subIndex: number, value: string) => { + const updated = sdoConfigurations.map((entry) => + entry.index === index && entry.subIndex === subIndex ? { ...entry, value } : entry, + ) + onUpdateSdoConfigurations(updated) + }, + [sdoConfigurations, onUpdateSdoConfigurations], + ) + + const handleResetAll = useCallback(() => { + const reset = sdoConfigurations.map((entry) => ({ ...entry, value: entry.defaultValue })) + onUpdateSdoConfigurations(reset) + }, [sdoConfigurations, onUpdateSdoConfigurations]) + + const hasModifiedValues = sdoConfigurations.some((entry) => entry.value !== entry.defaultValue) + const totalEntries = sdoConfigurations.length + + return ( +
+ {/* Toolbar */} +
+ setSearchTerm(e.target.value)} + className='h-[30px] rounded-md border border-neutral-300 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + /> + +
+ + +
+ + {filteredGroups.length} object(s), {totalEntries} parameter(s) + {hasModifiedValues && ' — modified values highlighted'} + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {filteredGroups.length === 0 ? ( + + + + ) : ( + filteredGroups.map((group) => { + const isCollapsed = collapsedGroups.has(group.index) + const groupHasModified = group.entries.some((e) => e.value !== e.defaultValue) + + return ( + handleToggleGroup(group.index)} + onValueChange={handleValueChange} + /> + ) + }) + )} + +
+ Sub + + Name + + Type + + Bits + + Default + + Value +
+ {sdoConfigurations.length === 0 + ? 'No configurable SDO parameters found' + : 'No parameters match the current filter'} +
+
+
+ ) +} + +/** + * Renders a group header row + its sub-item rows (when expanded). + * Extracted as a component to avoid key issues with fragments in map. + */ +const GroupRows = ({ + group, + isCollapsed, + hasModified, + onToggle, + onValueChange, +}: { + group: SdoObjectGroup + isCollapsed: boolean + hasModified: boolean + onToggle: () => void + onValueChange: (index: string, subIndex: number, value: string) => void +}) => { + return ( + <> + {/* Group header row */} + + + + + +
+ {group.index} + {group.objectName} + + ({group.entries.length} param{group.entries.length !== 1 && 's'}) + + {hasModified && ( + + modified + + )} +
+ + + + {/* Sub-item rows */} + {!isCollapsed && + group.entries.map((entry) => { + const isModified = entry.value !== entry.defaultValue + return ( + + + {entry.subIndex} + + {entry.name} + + {entry.dataType} + {entry.bitLength} + + {entry.defaultValue || '-'} + + + + + + ) + })} + + ) +} + +export { SdoParametersTable } 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 new file mode 100644 index 000000000..14fbf77e7 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -0,0 +1,339 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { collectUsedIecAddresses } from '@root/backend/shared/ethercat' +import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configuration' +import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIDeviceSummary, + ESIRepositoryItemLight, + EtherCATChannelMapping, + EtherCATSlaveConfig, + SDOConfigurationEntry, +} from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + ChannelMappingsSection, + DeviceConfigurationForm, + SdoParametersSection, +} from './components/device-configuration-form' + +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +/** + * Standalone full-page editor for a single EtherCAT slave device. + * + * Opened when the user clicks on a device child node in the project tree. + * Reads `busName` and `deviceId` from the editor meta and looks up the + * device from the Zustand store. + */ +const EtherCATDeviceEditor = () => { + const { editor, project, projectActions, workspaceActions } = useOpenPLCStore() + const esi = useEsi() + + const busName = editor.type === 'plc-ethercat-device' ? editor.meta.busName : '' + const deviceId = editor.type === 'plc-ethercat-device' ? editor.meta.deviceId : '' + const projectPath = project.meta.path + + const [activeTab, setActiveTab] = useState('info') + + // Repository state + const [repository, setRepository] = useState([]) + const repositoryLoadedRef = useRef(false) + + // Look up the remote device (bus) and the specific configured device + const remoteDevice = useMemo(() => { + return project.data.remoteDevices?.find((d) => d.name === busName) + }, [project.data.remoteDevices, busName]) + + const configuredDevices = useMemo(() => { + return (remoteDevice?.ethercatConfig?.devices ?? []) as unknown as ConfiguredEtherCATDevice[] + }, [remoteDevice]) + + const device = useMemo(() => { + return configuredDevices.find((d) => d.id === deviceId) ?? null + }, [configuredDevices, deviceId]) + + const masterConfig = useMemo(() => { + return ( + remoteDevice?.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + } + ) + }, [remoteDevice]) + + // Collect all IEC addresses used across all remote devices + const usedAddresses = useMemo(() => collectUsedIecAddresses(project.data.remoteDevices), [project.data.remoteDevices]) + + // Exclude the current device's own addresses from the "external" set + const externalAddresses = useMemo(() => { + if (!device) return usedAddresses + const filtered = new Set(usedAddresses) + for (const mapping of device.channelMappings) { + filtered.delete(mapping.iecLocation) + } + return filtered + }, [usedAddresses, device]) + + // Sync helpers + const deviceName = device?.name ?? '' + + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(busName, { masterConfig, devices }) + // Mark the slave file dirty (same pattern as other file types) + const { sharedWorkspaceActions } = useOpenPLCStore.getState() + if (deviceName) { + sharedWorkspaceActions.handleFileAndWorkspaceSavedState(deviceName) + } else { + workspaceActions.setEditingState('unsaved') + } + }, + [busName, projectActions, masterConfig, deviceName], + ) + + const handleUpdateDevice = useCallback( + (config: EtherCATSlaveConfig) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, config } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleUpdateChannelMappings = useCallback( + (channelMappings: EtherCATChannelMapping[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, channelMappings } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleEnrichDevice = useCallback( + (data: EnrichDeviceData) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, ...data } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + const handleUpdateSdoConfigurations = useCallback( + (sdoConfigurations: SDOConfigurationEntry[]) => { + syncDevicesToStore(configuredDevices.map((d) => (d.id === deviceId ? { ...d, sdoConfigurations } : d))) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + + // Load ESI repository. Resets and reloads whenever `projectPath` changes + // so switching projects picks up the new project's repository instead of + // serving the prior one from the stale "already loaded" flag. + useEffect(() => { + let cancelled = false + repositoryLoadedRef.current = false + + const loadRepository = async () => { + if (!projectPath) return + + try { + const result = await esi!.loadRepositoryLight() + if (cancelled) return + + if (result.success && result.items) { + setRepository(result.items) + repositoryLoadedRef.current = true + } else if ('needsMigration' in result && result.needsMigration) { + const migrationResult = await esi!.migrateRepository() + if (cancelled) return + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) + repositoryLoadedRef.current = true + } + } else { + repositoryLoadedRef.current = true + } + } catch (error) { + if (cancelled) return + console.error('Failed to load ESI repository:', error) + } + } + + void loadRepository() + return () => { + cancelled = true + } + }, [projectPath, esi]) + + // Resolve ESI device summary and repo item for info display + const esiDevice = useMemo(() => { + if (!device) return null + const repoItem = repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) + if (!repoItem) return null + return repoItem.devices[device.esiDeviceRef.deviceIndex] || null + }, [repository, device]) + + const repoItem = useMemo(() => { + if (!device) return null + return repository.find((r) => r.id === device.esiDeviceRef.repositoryItemId) ?? null + }, [repository, device]) + + // Use the device configuration hook for channels, CoE objects, etc. + const { channels, coeObjects, isLoadingChannels, channelLoadError, handleAliasChange, updateConfig } = + useDeviceConfiguration({ + device: device as ConfiguredEtherCATDevice, + projectPath, + externalAddresses, + onUpdateDevice: handleUpdateDevice, + onUpdateChannelMappings: handleUpdateChannelMappings, + onEnrichDevice: handleEnrichDevice, + enabled: device !== null, + }) + + // Fallback when device is not found + if (!device) { + return ( +
+
+

Device not found

+

+ The EtherCAT device could not be found. It may have been removed from the bus configuration. +

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

{device.name}

+
+ + {/* Tabs */} + setActiveTab(v as DeviceDetailTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + {/* Device Info Tab */} + +
+
+
+ Vendor + {repoItem?.vendor.name || 'Unknown'} +
+
+ Vendor ID + {device.vendorId} +
+
+ Product Code + {device.productCode} +
+
+ Revision + {device.revisionNo} +
+
+ ESI File + {repoItem?.filename || 'Not found'} +
+ {esiDevice?.groupName && ( +
+ Group + {esiDevice.groupName} +
+ )} + {esiDevice && ( + <> +
+ Input Channels + {esiDevice.inputChannelCount} +
+
+ Output Channels + {esiDevice.outputChannelCount} +
+ + )} +
+
+
+ + {/* Configuration Tab */} + +
+
+ +
+
+
+ + {/* Startup Parameters Tab */} + +
+ +
+
+ + {/* Channel Mappings Tab */} + +
+ +
+
+
+
+ ) +} + +export { EtherCATDeviceEditor } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx new file mode 100644 index 000000000..a6b377de6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -0,0 +1,591 @@ +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 { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' +import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' +import type { + ConfiguredEtherCATDevice, + ESIDeviceRef, + ESIDeviceSummary, + ESIRepositoryItemLight, + ScannedDeviceMatch, +} from '@root/middleware/shared/ports/esi-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' + +import { AdvancedTab } from './components/advanced-tab' +import { RepositoryTab } from './components/repository-tab' +import { ScanBusTab } from './components/scan-bus-tab' + +type EditorTab = 'scan-bus' | 'repository' | 'advanced' + +const TabItem = ({ + value, + label, + isActive, + badge, +}: { + value: string + label: string + isActive: boolean + badge?: React.ReactNode +}) => ( + + {label} + {badge} + +) + +/** + * EtherCAT Bus Editor + * + * Three-tab layout: + * - Scan Bus: Network interface selection and device discovery/scanning + * - Repository: ESI file repository management + * - Advanced: Master configuration (enable plugin, cycle time, watchdog) + * + * Individual device configuration (I/O mapping, SDO, etc.) is handled by + * EtherCATDeviceEditor, opened from the project tree. + */ +const EtherCATEditor = () => { + const { + editor, + runtimeConnection, + project, + projectActions, + workspaceActions, + sharedWorkspaceActions, + editorActions, + } = useOpenPLCStore() + const runtime = useRuntime() + const esi = useEsi() + + const deviceName = editor.type === 'plc-remote-device' ? editor.meta.name : '' + const projectPath = project.meta.path + + // Runtime connection state + const { connectionStatus, ipAddress } = runtimeConnection + const isConnectedToRuntime = connectionStatus === 'connected' && ipAddress !== null + + // Tab state + const [activeTab, setActiveTab] = useState('scan-bus') + + // Repository state + const [repository, setRepository] = useState([]) + const [isLoadingRepository, setIsLoadingRepository] = useState(false) + const [repositoryError, setRepositoryError] = useState(null) + const [repositoryLoadRetry, setRepositoryLoadRetry] = useState(0) + const repositoryLoadedRef = useRef(false) + + // Configured devices from Zustand store + const remoteDevice = useMemo(() => { + return project.data.remoteDevices?.find((d) => d.name === deviceName) + }, [project.data.remoteDevices, deviceName]) + + const configuredDevices = useMemo(() => { + return (remoteDevice?.ethercatConfig?.devices ?? []) as unknown as ConfiguredEtherCATDevice[] + }, [remoteDevice]) + + const masterConfig = useMemo(() => { + return ( + remoteDevice?.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + } + ) + }, [remoteDevice]) + + const syncDevicesToStore = useCallback( + (devices: ConfiguredEtherCATDevice[]) => { + projectActions.updateEthercatConfig(deviceName, { masterConfig, devices }) + workspaceActions.setEditingState('unsaved') + }, + [deviceName, projectActions, masterConfig, workspaceActions], + ) + + const handleUpdateMasterConfig = useCallback( + (updates: Partial) => { + const newMasterConfig = { ...masterConfig, ...updates } + projectActions.updateEthercatConfig(deviceName, { + masterConfig: newMasterConfig, + devices: configuredDevices, + }) + workspaceActions.setEditingState('unsaved') + }, + [deviceName, projectActions, masterConfig, configuredDevices, workspaceActions], + ) + + // Network interfaces state + const [interfaces, setInterfaces] = useState([]) + const [selectedInterface, _setSelectedInterface] = useState(masterConfig.networkInterface || '') + const setSelectedInterface = useCallback( + (value: string) => { + _setSelectedInterface(value) + handleUpdateMasterConfig({ networkInterface: value }) + }, + [handleUpdateMasterConfig], + ) + // Sync local state when masterConfig loads/changes (e.g. after project open) + useEffect(() => { + if (masterConfig.networkInterface) { + _setSelectedInterface(masterConfig.networkInterface) + } + }, [masterConfig.networkInterface]) + + const [isLoadingInterfaces, setIsLoadingInterfaces] = useState(false) + const [interfaceError, setInterfaceError] = useState(null) + + // EtherCAT service status + const [serviceAvailable, setServiceAvailable] = useState(null) + const [serviceMessage, setServiceMessage] = useState('') + + // Scan state + const [scannedDevices, setScannedDevices] = useState([]) + const [isScanning, setIsScanning] = useState(false) + const [scanError, setScanError] = useState(null) + const [scanMessage, setScanMessage] = useState('') + const [scanTimeMs, setScanTimeMs] = useState(null) + + // Discovery selection state + const [selectedScannedDevices, setSelectedScannedDevices] = useState>(new Set()) + + // Matched devices + const deviceMatches = useMemo(() => { + return matchDevicesToRepository(scannedDevices, repository) + }, [scannedDevices, repository]) + + // Check EtherCAT service status + const checkServiceStatus = useCallback(async () => { + if (!isConnectedToRuntime) { + setServiceAvailable(null) + setServiceMessage('') + return + } + + try { + const result = await runtime.getEthercatServiceStatus!() + if (result.success && result.data) { + setServiceAvailable(result.data.available) + setServiceMessage(result.data.message) + } else { + setServiceAvailable(false) + setServiceMessage(!result.success ? (result.error ?? 'Failed') : 'Failed to check service status') + } + } catch (error) { + setServiceAvailable(false) + setServiceMessage(String(error)) + } + }, [isConnectedToRuntime]) + + // Fetch network interfaces from runtime + const fetchInterfaces = useCallback(async () => { + if (!isConnectedToRuntime) { + setInterfaces([]) + setInterfaceError('Not connected to runtime') + return + } + + setIsLoadingInterfaces(true) + setInterfaceError(null) + + try { + const result = await runtime.getNetworkInterfaces!() + if (result.success && result.data) { + const fetchedInterfaces = result.data + setInterfaces(fetchedInterfaces) + const names = new Set(fetchedInterfaces.map((i) => i.name)) + if (fetchedInterfaces.length > 0) { + _setSelectedInterface((prev) => { + const next = prev && names.has(prev) ? prev : fetchedInterfaces[0].name + handleUpdateMasterConfig({ networkInterface: next }) + return next + }) + } else { + setSelectedInterface('') + } + } else { + setInterfaces([]) + setInterfaceError(!result.success ? (result.error ?? 'Failed') : 'Failed to fetch interfaces') + } + } catch (error) { + setInterfaces([]) + setInterfaceError(String(error)) + } finally { + setIsLoadingInterfaces(false) + } + }, [isConnectedToRuntime]) + + // Scan for EtherCAT devices + const scanDevices = useCallback(async () => { + if (!isConnectedToRuntime || !selectedInterface) { + setScanError('Please select a network interface') + return + } + + setIsScanning(true) + setScanError(null) + setScanMessage('') + setScannedDevices([]) + setSelectedScannedDevices(new Set()) + setScanTimeMs(null) + + try { + const result = await runtime.scanEthercatDevices!({ + interface: selectedInterface, + timeout_ms: 5000, + }) + + if (result.success && result.data) { + setScannedDevices(result.data.devices) + setScanMessage(result.data.message) + setScanTimeMs(result.data.scan_time_ms) + + if (result.data.status !== 'success') { + setScanError(`Scan completed with status: ${result.data.status}`) + } + } else { + setScanError(!result.success ? (result.error ?? 'Failed') : 'Scan failed') + } + } catch (error) { + setScanError(String(error)) + } finally { + setIsScanning(false) + } + }, [isConnectedToRuntime, selectedInterface]) + + // Reset repository loaded flag when project changes + useEffect(() => { + repositoryLoadedRef.current = false + }, [projectPath]) + + // Load ESI repository + useEffect(() => { + let cancelled = false + + const loadRepository = async () => { + if (!projectPath || repositoryLoadedRef.current) return + + setIsLoadingRepository(true) + setRepositoryError(null) + + try { + const result = await esi!.loadRepositoryLight() + if (cancelled) return + + if (result.success && result.items) { + setRepository(result.items) + repositoryLoadedRef.current = true + } else if ('needsMigration' in result && result.needsMigration) { + // One-time migration from v1 to v2 + const migrationResult = await esi!.migrateRepository() + if (cancelled) return + if (migrationResult.success && migrationResult.items) { + setRepository(migrationResult.items) + repositoryLoadedRef.current = true + } else { + setRepositoryError(!migrationResult.success ? migrationResult.error : 'Failed to migrate repository') + } + } else if (!result.success) { + setRepositoryError(result.error) + } else { + repositoryLoadedRef.current = true + } + } catch (error) { + if (cancelled) return + console.error('Failed to load ESI repository:', error) + setRepositoryError(String(error)) + } finally { + if (!cancelled) setIsLoadingRepository(false) + } + } + + void loadRepository() + return () => { + cancelled = true + } + }, [projectPath, repositoryLoadRetry]) + + // Check service status and fetch interfaces when runtime connection changes + useEffect(() => { + if (isConnectedToRuntime) { + void checkServiceStatus() + void fetchInterfaces() + } else { + setServiceAvailable(null) + setInterfaces([]) + setScannedDevices([]) + } + }, [isConnectedToRuntime, checkServiceStatus, fetchInterfaces]) + + // Initialize ethercatConfig in store if missing + useEffect(() => { + if (remoteDevice && !remoteDevice.ethercatConfig) { + projectActions.updateEthercatConfig(deviceName, { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: [], + }) + } + }, [remoteDevice, deviceName, projectActions]) + + // Discovery handlers + const handleSelectScannedDevice = useCallback((position: number, selected: boolean) => { + setSelectedScannedDevices((prev) => { + const next = new Set(prev) + if (selected) { + next.add(position) + } else { + next.delete(position) + } + return next + }) + }, []) + + 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)) + } else { + setSelectedScannedDevices(new Set()) + } + }, + [deviceMatches], + ) + + const handleAddSelectedFromScan = useCallback(async () => { + const newDevices: ConfiguredEtherCATDevice[] = [] + const existingPositions = new Set(configuredDevices.map((d) => d.position)) + const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) + + for (const position of selectedScannedDevices) { + // 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 + + // Use the best match (first one, which is sorted by quality) + const bestMatch = match.matches[0] + const repoItem = repository.find((r) => r.id === bestMatch.repositoryItemId) + if (!repoItem) continue + + let enriched: Partial = { channelMappings: [] } + const result = await esi!.loadDeviceFull(bestMatch.repositoryItemId, bestMatch.deviceIndex) + if (result.success && result.device) { + enriched = enrichDeviceData(result.device, usedAddresses) + // Reserve the freshly assigned addresses so the next device in the + // batch doesn't collide with them. + for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) + } + + newDevices.push({ + id: uuidv4(), + position: match.device.position, + name: bestMatch.esiDevice.name || match.device.name, + esiDeviceRef: { + repositoryItemId: bestMatch.repositoryItemId, + deviceIndex: bestMatch.deviceIndex, + }, + vendorId: repoItem.vendor.id, + productCode: bestMatch.esiDevice.type.productCode, + revisionNo: bestMatch.esiDevice.type.revisionNo, + addedFrom: 'scan', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + }) + } + + if (newDevices.length > 0) { + syncDevicesToStore([...configuredDevices, ...newDevices]) + setSelectedScannedDevices(new Set()) + } + }, [ + selectedScannedDevices, + deviceMatches, + repository, + configuredDevices, + syncDevicesToStore, + projectPath, + project.data.remoteDevices, + ]) + + const handleRetryRepository = useCallback(() => { + setRepositoryError(null) + repositoryLoadedRef.current = false + setRepositoryLoadRetry((c) => c + 1) + }, []) + + const handleAddDeviceFromBrowser = useCallback( + async (ref: ESIDeviceRef, device: ESIDeviceSummary, repoItem: ESIRepositoryItemLight) => { + let enriched: Partial = { channelMappings: [] } + const result = await esi!.loadDeviceFull(ref.repositoryItemId, ref.deviceIndex) + if (result.success && result.device) { + const usedAddresses = collectUsedIecAddresses(project.data.remoteDevices) + enriched = enrichDeviceData(result.device, usedAddresses) + } + + const nextPosition = + configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 + + const newDevice: ConfiguredEtherCATDevice = { + id: uuidv4(), + position: nextPosition, + name: device.name, + esiDeviceRef: ref, + vendorId: repoItem.vendor.id, + productCode: device.type.productCode, + revisionNo: device.type.revisionNo, + addedFrom: 'repository', + config: createDefaultSlaveConfig(), + channelMappings: [], + ...enriched, + } + + syncDevicesToStore([...configuredDevices, newDevice]) + + // Register file entry for the new slave so Ctrl+S and dirty tracking work + const { fileActions } = useOpenPLCStore.getState() + fileActions.addFile({ name: newDevice.name, type: 'ethercat-device', filePath: deviceName }) + }, + [configuredDevices, syncDevicesToStore, deviceName, project.data.remoteDevices], + ) + + const handleRemoveDevice = useCallback( + (deviceId: string) => { + const device = configuredDevices.find((d) => d.id === deviceId) + if (device) { + // Remove cached editor model to avoid stale deviceId on re-add + editorActions.removeModel(device.name) + // Close the device tab only if it's open (without switching away from current tab) + const { tabs, tabsActions } = useOpenPLCStore.getState() + const hasTab = tabs.some((t) => t.name === device.name) + if (hasTab) { + tabsActions.removeTab(device.name) + } + } + syncDevicesToStore(configuredDevices.filter((d) => d.id !== deviceId)) + }, + [configuredDevices, syncDevicesToStore, sharedWorkspaceActions, editorActions], + ) + + return ( +
+ {/* Header */} +
+

EtherCAT Bus: {deviceName}

+

EtherCAT Master Configuration

+
+ + {/* Tabs */} + setActiveTab(v as EditorTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + 0 ? ( + + {scannedDevices.length} + + ) : undefined + } + /> + 0 ? ( + + {repository.length} + + ) : undefined + } + /> + + + + {/* Scan Bus Tab */} + + void scanDevices()} + deviceMatches={deviceMatches} + selectedScannedDevices={selectedScannedDevices} + onSelectScannedDevice={handleSelectScannedDevice} + onSelectAllScanned={handleSelectAllScanned} + onAddSelectedFromScan={() => void handleAddSelectedFromScan()} + configuredDevices={configuredDevices} + repository={repository} + onAddDeviceFromBrowser={(...args) => void handleAddDeviceFromBrowser(...args)} + onRemoveDevice={handleRemoveDevice} + /> + + + {/* Repository Tab */} + + + + + {/* Advanced Tab */} + + + + +
+ ) +} + +export { EtherCATDeviceEditor } from './ethercat-device-editor' +export { EtherCATEditor } diff --git a/src/frontend/components/_molecules/breadcrumbs/index.tsx b/src/frontend/components/_molecules/breadcrumbs/index.tsx index 2e1031976..1bff16917 100644 --- a/src/frontend/components/_molecules/breadcrumbs/index.tsx +++ b/src/frontend/components/_molecules/breadcrumbs/index.tsx @@ -10,6 +10,8 @@ import { ConfigIcon } from '../../../assets/icons/interface/Config' import { ArrayIcon } from '../../../assets/icons/project/Array' import { EnumIcon } from '../../../assets/icons/project/Enum' import { PLCIcon } from '../../../assets/icons/project/PLC' +import { RemoteDeviceIcon } from '../../../assets/icons/project/RemoteDevice' +import { ServerIcon } from '../../../assets/icons/project/Server' import { StructureIcon } from '../../../assets/icons/project/Structure' import { LanguageIcon, LanguageIconType } from '../../../data/constants/language-icons' import { PouIcon, PouIconType } from '../../../data/constants/pou-icons' @@ -28,7 +30,7 @@ type INavigationPanelBreadcrumbsProps = ComponentProps<'ol'> & { const Breadcrumbs = () => { const { - editor: { meta }, + editor, project: { meta: { name }, data: { dataTypes }, @@ -37,6 +39,8 @@ const Breadcrumbs = () => { workspaceActions: { setFbSelectedInstance }, } = useOpenPLCStore() + const { meta } = editor + const derivationIcons = { enumerated: EnumIcon, structure: StructureIcon, @@ -105,6 +109,45 @@ const Breadcrumbs = () => { // Determine if we should show the instance dropdown const showInstanceDropdown = isFunctionBlock && isDebuggerVisible && fbInstances.length > 0 + // Remote device and server breadcrumbs + if (editor.type === 'plc-remote-device' || editor.type === 'plc-server') { + const Icon = editor.type === 'plc-server' ? ServerIcon : RemoteDeviceIcon + const category = editor.type === 'plc-server' ? 'Servers' : 'Remote Devices' + return ( +
    +
  1. + +
  2. +
  3. + +
  4. +
  5. + +
  6. +
+ ) + } + + // EtherCAT slave device breadcrumbs + if (editor.type === 'plc-ethercat-device') { + return ( +
    +
  1. + +
  2. +
  3. + +
  4. +
  5. + +
  6. +
  7. + +
  8. +
+ ) + } + return ( & { + leafLang: IProjectTreeLeafProps['leafLang'] + leafType: WorkspaceProjectTreeLeafType + label?: string + children?: ReactNode +} + +const ProjectTreeExpandableLeaf = ({ + leafLang, + leafType, + label, + children, + onClick: handleLeafClick, + ...res +}: IProjectTreeExpandableLeafProps) => { + const { + editor: { + meta: { name }, + }, + workspace: { selectedProjectTreeLeaf, isDebuggerVisible }, + workspaceActions: { setSelectedProjectTreeLeaf }, + remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice }, + fileActions: { getFile }, + } = useOpenPLCStore() + + const [isExpanded, setIsExpanded] = useState(true) + const [isEditing, setIsEditing] = useState(false) + const [newLabel, setNewLabel] = useState(label || '') + const [isPopoverOpen, setPopoverOpen] = useState(false) + const inputNameRef = useRef(null) + + const { LeafIcon } = LeafSources[leafLang] + const { file: associatedFile } = getFile({ name: label || '' }) + const handleLabel = useCallback((l: string | undefined) => unsavedLabel(l, associatedFile), [associatedFile]) + + const handleLeafSelection = () => { + if (!label) return + const { label: currentLabel } = selectedProjectTreeLeaf + if (label === currentLabel) return + setSelectedProjectTreeLeaf({ label, type: leafType }) + } + + const handleRenameFile = async (renamed: string) => { + setIsEditing(false) + if (!renamed || !label) return + const res = renameRemoteDevice(label, renamed) + if (!res.ok) setNewLabel(label || '') + } + + const handleDeleteFile = () => { + if (label) deleteRemoteDeviceRequest(label) + } + + useEffect(() => { + if (isEditing && inputNameRef.current) { + inputNameRef.current.focus() + inputNameRef.current.select() + } + }, [inputNameRef, isEditing]) + + const popoverOptions = useMemo( + () => [ + { + name: 'Rename', + onClick: () => setIsEditing(true), + icon: , + }, + { + name: 'Delete', + onClick: () => handleDeleteFile(), + icon: , + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [label], + ) + + return ( +
  • +
    + { + e.stopPropagation() + setIsExpanded(!isExpanded) + }} + /> +
    { + handleLeafSelection() + if (label === name) return + if (handleLeafClick) handleLeafClick(e as unknown as React.MouseEvent) + }} + > + + {isEditing ? ( + setNewLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleRenameFile(newLabel.trim() || '') + if (e.key === 'Escape') setIsEditing(false) + }} + onBlur={() => void handleRenameFile(newLabel || '')} + className='w-full border-0 bg-transparent px-1 text-xs text-neutral-850 focus:outline-none dark:text-neutral-300' + /> + ) : ( + !isDebuggerVisible && setIsEditing(true)} + > + {handleLabel(label) || ''} + + )} +
    + + + e.stopPropagation()} + > + + + + e.stopPropagation()} + > + {popoverOptions.map((option, index) => ( +
    { + option.onClick() + setPopoverOpen(false) + }} + > + {option.icon} +

    {option.name}

    +
    + ))} +
    +
    +
    +
    + + {children && isExpanded &&
      {children}
    } +
  • + ) +} + type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { leafLang: | 'il' @@ -256,8 +439,11 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'devOrchestrators' | 'server' | 'remoteDevice' + | 'ethercatDevice' leafType: WorkspaceProjectTreeLeafType label?: string + busName?: string + deviceId?: string } const LeafSources = { @@ -277,8 +463,17 @@ const LeafSources = { devOrchestrators: { LeafIcon: OrchestratorIcon }, server: { LeafIcon: ServerIcon }, remoteDevice: { LeafIcon: RemoteDeviceIcon }, + ethercatDevice: { LeafIcon: DeviceTransferIcon }, } -const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, ...res }: IProjectTreeLeafProps) => { +const ProjectTreeLeaf = ({ + leafLang, + leafType, + label, + busName, + deviceId, + onClick: handleLeafClick, + ...res +}: IProjectTreeLeafProps) => { const { editor: { meta: { name }, @@ -289,6 +484,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, datatypeActions: { deleteRequest: deleteDatatypeRequest, rename: renameDatatype, duplicate: duplicateDatatype }, serverActions: { deleteRequest: deleteServerRequest, rename: renameServer }, remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice }, + ethercatDeviceActions: { delete: deleteEthercatDevice, rename: renameEthercatDevice }, fileActions: { getFile }, } = useOpenPLCStore() @@ -302,6 +498,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, const isDatatype = useMemo(() => leafLang === 'arr' || leafLang === 'enum' || leafLang === 'str', [leafLang]) const isServer = useMemo(() => leafLang === 'server', [leafLang]) const isRemoteDevice = useMemo(() => leafLang === 'remoteDevice', [leafLang]) + const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice', [leafLang]) const { LeafIcon } = LeafSources[leafLang] const { file: associatedFile } = getFile({ name: label || '' }) @@ -323,10 +520,10 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, setSelectedProjectTreeLeaf({ label, type: leafType }) } - const handleRenameFile = (newLabel: string) => { + const handleRenameFile = async (newLabel: string) => { setIsEditing(false) - if (!isAPou && !isDatatype && !isServer && !isRemoteDevice) { + if (!isAPou && !isDatatype && !isServer && !isRemoteDevice && !isEthercatDevice) { toast({ title: 'Error', description: 'Only POU, datatype, server, or remote device files can be renamed.', @@ -375,6 +572,14 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, } return } + + if (isEthercatDevice && busName && deviceId) { + const res = renameEthercatDevice(busName, deviceId, newLabel) + if (!res.ok) { + setNewLabel(label || '') + } + return + } } const handleDuplicateFile = () => { @@ -414,7 +619,7 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, } const handleDeleteFile = () => { - if (!isAPou && !isDatatype && !isServer && !isRemoteDevice) { + if (!isAPou && !isDatatype && !isServer && !isRemoteDevice && !isEthercatDevice) { toast({ title: 'Error', description: 'Only POU, datatype, server, or remote device files can be deleted.', @@ -452,11 +657,10 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, return } - toast({ - title: 'Error', - description: 'Only POU, datatype, server, or remote device files can be deleted.', - variant: 'fail', - }) + if (isEthercatDevice && busName && deviceId) { + deleteEthercatDevice(busName, deviceId) + return + } } const handleLabel = useCallback((label: string | undefined) => unsavedLabel(label, associatedFile), [associatedFile]) @@ -590,4 +794,4 @@ const ProjectTreeLeaf = ({ leafLang, leafType, label, onClick: handleLeafClick, ) } -export { ProjectTreeBranch, ProjectTreeLeaf, ProjectTreeNestedBranch, ProjectTreeRoot } +export { ProjectTreeBranch, ProjectTreeExpandableLeaf, ProjectTreeLeaf, ProjectTreeNestedBranch, ProjectTreeRoot } diff --git a/src/frontend/components/_molecules/task-table/index.tsx b/src/frontend/components/_molecules/task-table/index.tsx index 23237074f..c9e16e070 100644 --- a/src/frontend/components/_molecules/task-table/index.tsx +++ b/src/frontend/components/_molecules/task-table/index.tsx @@ -15,7 +15,7 @@ const columns = [ size: 150, minSize: 100, maxSize: 150, - cell: EditableNameCell, + cell: (props) => EditableNameCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('triggering', { header: 'Triggering', @@ -23,7 +23,7 @@ const columns = [ size: 468, minSize: 150, maxSize: 468, - cell: SelectableTriggerCell, + cell: (props) => SelectableTriggerCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('interval', { header: 'Interval', @@ -31,7 +31,7 @@ const columns = [ minSize: 150, maxSize: 468, enableResizing: true, - cell: SelectableIntervalCell, + cell: (props) => SelectableIntervalCell({ ...props, editable: !props.row.original.isSystemTask }), }), columnHelper.accessor('priority', { header: 'Priority', @@ -39,7 +39,7 @@ const columns = [ size: 468, minSize: 150, maxSize: 468, - cell: EditablePriorityCell, + cell: (props) => EditablePriorityCell({ ...props, editable: !props.row.original.isSystemTask }), }), ] diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index b4603f8d6..fe81b9943 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -7,7 +7,12 @@ import { extractSearchQuery } from '../../../store/slices/search/utils' import type { TabsProps } from '../../../store/slices/tabs' import { CreateEditorObjectFromTab } from '../../../store/slices/tabs/utils' import { CreatePLCElement } from '../../_features/[workspace]/create-element' -import { ProjectTreeBranch, ProjectTreeLeaf, ProjectTreeRoot } from '../../_molecules/project-tree' +import { + ProjectTreeBranch, + ProjectTreeExpandableLeaf, + ProjectTreeLeaf, + ProjectTreeRoot, +} from '../../_molecules/project-tree' type PouLeafLang = 'il' | 'st' | 'ld' | 'sfc' | 'fbd' | 'python' | 'cpp' @@ -299,21 +304,55 @@ const Project = () => { {[...(remoteDevices || [])] .sort((a, b) => a.name.localeCompare(b.name)) - .map((device) => ( - - handleCreateTab({ - name: device.name, - path: `/device/remote/${device.name}`, - elementType: { type: 'remote-device', protocol: device.protocol }, - }) - } - /> - ))} + .map((device) => + device.protocol === 'ethercat' ? ( + + handleCreateTab({ + name: device.name, + path: `/devices/remote/${device.name}.json`, + elementType: { type: 'remote-device', protocol: device.protocol }, + }) + } + > + {device.ethercatConfig?.devices?.map((child) => ( + + handleCreateTab({ + name: child.name, + path: `/devices/remote/${device.name}/devices/${child.id}`, + elementType: { type: 'ethercat-device', busName: device.name, deviceId: child.id }, + }) + } + /> + ))} + + ) : ( + + handleCreateTab({ + name: device.name, + path: `/device/remote/${device.name}`, + elementType: { type: 'remote-device', protocol: device.protocol }, + }) + } + /> + ), + )}
    diff --git a/src/frontend/components/_organisms/task-editor/index.tsx b/src/frontend/components/_organisms/task-editor/index.tsx index c58ab8c1b..5b1a2ada7 100644 --- a/src/frontend/components/_organisms/task-editor/index.tsx +++ b/src/frontend/components/_organisms/task-editor/index.tsx @@ -164,11 +164,12 @@ const TaskEditor = () => { } const taskNames = filteredTasks.map((t) => t.name) + const { isSystemTask: _, associatedDevice: __, ...baseTask } = task if (selectedRow === ROWS_NOT_SELECTED) { createTask({ data: { - ...task, + ...baseTask, name: getNextName(task.name, taskNames), }, }) @@ -180,7 +181,7 @@ const TaskEditor = () => { return } - createTask({ data: { ...task, name: getNextName(task.name, taskNames) }, rowToInsert: selectedRow + 1 }) + createTask({ data: { ...baseTask, name: getNextName(task.name, taskNames) }, rowToInsert: selectedRow + 1 }) updateModelTasks({ display: 'table', selectedRow: selectedRow + 1, @@ -249,6 +250,9 @@ const TaskEditor = () => { } } + const selectedRowIndex = 'selectedRow' in editorTasks ? parseInt(editorTasks.selectedRow) : -1 + const isSelectedTaskSystem = selectedRowIndex >= 0 && taskData[selectedRowIndex]?.isSystemTask === true + const isInstanceInCode = 'instance' in editor && editor.instance?.display === 'code' if (isInstanceInCode) return null @@ -279,7 +283,10 @@ const TaskEditor = () => { { ariaLabel: 'Remove Tasks table row button', onClick: handleDeleteTask, - disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED, + disabled: + isDebuggerVisible || + parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || + isSelectedTaskSystem, icon: , id: 'remove-task-button', }, @@ -289,7 +296,8 @@ const TaskEditor = () => { disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || - parseInt(editorTasks.selectedRow) === 0, + parseInt(editorTasks.selectedRow) === 0 || + isSelectedTaskSystem, icon: , id: 'move-task-up-button', }, @@ -299,7 +307,8 @@ const TaskEditor = () => { disabled: isDebuggerVisible || parseInt(editorTasks.selectedRow) === ROWS_NOT_SELECTED || - parseInt(editorTasks.selectedRow) === taskData.length - 1, + parseInt(editorTasks.selectedRow) === taskData.length - 1 || + isSelectedTaskSystem, icon: , id: 'move-task-down-button', }, diff --git a/src/frontend/hooks/use-device-configuration.ts b/src/frontend/hooks/use-device-configuration.ts new file mode 100644 index 000000000..72b49239c --- /dev/null +++ b/src/frontend/hooks/use-device-configuration.ts @@ -0,0 +1,134 @@ +import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { generateDefaultChannelMappings, pdoToChannels } from '@root/backend/shared/ethercat/esi-parser' +import { extractDefaultSdoConfigurations } from '@root/backend/shared/ethercat/sdo-config-defaults' +import type { + ConfiguredEtherCATDevice, + EnrichDeviceData, + ESIChannel, + ESICoEObject, + EtherCATChannelMapping, + EtherCATSlaveConfig, +} from '@root/middleware/shared/ports/esi-types' +import { useEsi } from '@root/middleware/shared/providers/platform-context' +import { useCallback, useEffect, useRef, useState } from 'react' + +type UseDeviceConfigurationParams = { + device: ConfiguredEtherCATDevice + projectPath: string + externalAddresses: Set + onUpdateDevice: (config: EtherCATSlaveConfig) => void + onUpdateChannelMappings: (mappings: EtherCATChannelMapping[]) => void + onEnrichDevice: (data: EnrichDeviceData) => void + enabled?: boolean +} + +type UseDeviceConfigurationResult = { + channels: ESIChannel[] + coeObjects: ESICoEObject[] | undefined + isLoadingChannels: boolean + channelLoadError: string | null + handleAliasChange: (channelId: string, alias: string) => void + updateConfig: (section: K, updates: Partial) => void +} + +export function useDeviceConfiguration({ + device, + projectPath, + externalAddresses, + onUpdateDevice, + onUpdateChannelMappings, + onEnrichDevice, + enabled = true, +}: UseDeviceConfigurationParams): UseDeviceConfigurationResult { + const esiPort = useEsi() + const [channels, setChannels] = useState([]) + const [coeObjects, setCoeObjects] = useState(undefined) + const [isLoadingChannels, setIsLoadingChannels] = useState(false) + const [channelLoadError, setChannelLoadError] = useState(null) + const fullDeviceLoadedRef = useRef(false) + + // Capture latest callback refs to avoid stale closures and unstable deps + const onUpdateDeviceRef = useRef(onUpdateDevice) + onUpdateDeviceRef.current = onUpdateDevice + const onUpdateChannelMappingsRef = useRef(onUpdateChannelMappings) + onUpdateChannelMappingsRef.current = onUpdateChannelMappings + const onEnrichDeviceRef = useRef(onEnrichDevice) + onEnrichDeviceRef.current = onEnrichDevice + + useEffect(() => { + if (!enabled || !device || fullDeviceLoadedRef.current) return + + const loadFullDevice = async () => { + setIsLoadingChannels(true) + setChannelLoadError(null) + + try { + const result = await esiPort!.loadDeviceFull( + device.esiDeviceRef.repositoryItemId, + device.esiDeviceRef.deviceIndex, + ) + + if (result.success && result.device) { + const deviceChannels = pdoToChannels(result.device) + setChannels(deviceChannels) + setCoeObjects(result.device.coeObjects) + fullDeviceLoadedRef.current = true + + if (device.channelMappings.length === 0 && deviceChannels.length > 0) { + onUpdateChannelMappingsRef.current(generateDefaultChannelMappings(deviceChannels, externalAddresses)) + } + + if (!device.channelInfo || !device.rxPdos || !device.txPdos) { + const { sdoConfigurations, ...rest } = enrichDeviceData(result.device, externalAddresses) + onEnrichDeviceRef.current(device.sdoConfigurations !== undefined ? rest : { ...rest, sdoConfigurations }) + } else if (device.sdoConfigurations === undefined && result.device.coeObjects?.length) { + onEnrichDeviceRef.current({ + channelInfo: device.channelInfo, + rxPdos: device.rxPdos, + txPdos: device.txPdos, + slaveType: device.slaveType ?? '', + sdoConfigurations: extractDefaultSdoConfigurations(result.device.coeObjects), + }) + } + } else { + setChannelLoadError(!result.success ? (result.error ?? 'Failed') : 'Failed to load device data') + } + } catch (error) { + setChannelLoadError(String(error)) + } finally { + setIsLoadingChannels(false) + } + } + + void loadFullDevice() + }, [enabled, projectPath, device?.esiDeviceRef?.repositoryItemId, device?.esiDeviceRef?.deviceIndex]) + + const handleAliasChange = useCallback( + (channelId: string, alias: string) => { + if (!device) return + const updated = device.channelMappings.map((m) => (m.channelId === channelId ? { ...m, alias } : m)) + onUpdateChannelMappingsRef.current(updated) + }, + [device?.channelMappings], + ) + + const updateConfig = useCallback( + (section: K, updates: Partial) => { + if (!device) return + onUpdateDeviceRef.current({ + ...device.config, + [section]: { ...device.config[section], ...updates }, + }) + }, + [device?.config], + ) + + return { + channels, + coeObjects, + isLoadingChannels, + channelLoadError, + handleAliasChange, + updateConfig, + } +} diff --git a/src/frontend/hooks/use-store-selectors.ts b/src/frontend/hooks/use-store-selectors.ts index 03b7c5e65..f7a20b80b 100644 --- a/src/frontend/hooks/use-store-selectors.ts +++ b/src/frontend/hooks/use-store-selectors.ts @@ -97,18 +97,37 @@ const remoteDeviceSelectors = { const ioPoints: RemoteDeviceIOPoint[] = [] for (const device of remoteDevices) { - if (!device.modbusTcpConfig?.ioGroups) continue - for (const ioGroup of device.modbusTcpConfig.ioGroups) { - for (const point of ioGroup.ioPoints ?? []) { - ioPoints.push({ - deviceName: device.name, - ioGroupName: ioGroup.name, - ioPointId: point.id, - ioPointName: point.name, - ioPointType: point.type, - iecLocation: point.iecLocation, - alias: point.alias ?? '', - }) + // Modbus IO points + if (device.modbusTcpConfig?.ioGroups) { + for (const ioGroup of device.modbusTcpConfig.ioGroups) { + for (const point of ioGroup.ioPoints ?? []) { + ioPoints.push({ + deviceName: device.name, + ioGroupName: ioGroup.name, + ioPointId: point.id, + ioPointName: point.name, + ioPointType: point.type, + iecLocation: point.iecLocation, + alias: point.alias ?? '', + }) + } + } + } + // EtherCAT channel mappings + if (device.ethercatConfig?.devices) { + for (const dev of device.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + if (!mapping.alias) continue + ioPoints.push({ + deviceName: device.name, + ioGroupName: dev.name, + ioPointId: mapping.channelId, + ioPointName: mapping.channelId, + ioPointType: 'ethercat', + iecLocation: mapping.iecLocation, + alias: mapping.alias, + }) + } } } } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index d6b7ee3da..5466fad52 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -10,6 +10,7 @@ import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-c import { BranchStatusBar } from '../components/_features/[workspace]/branches' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' +import { EtherCATDeviceEditor, EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' import { GraphicalEditor } from '../components/_features/[workspace]/editor/graphical' import { MonacoEditor } from '../components/_features/[workspace]/editor/monaco' @@ -445,7 +446,15 @@ const WorkspaceScreen = () => { <> {editor['type'] === 'plc-resource' && } {editor['type'] === 'plc-device' && } - {editor['type'] === 'plc-remote-device' && } + {editor['type'] === 'plc-remote-device' && editor.meta.protocol === 'ethercat' && ( + + )} + {editor['type'] === 'plc-remote-device' && editor.meta.protocol !== 'ethercat' && ( + + )} + {editor['type'] === 'plc-ethercat-device' && ( + + )} {editor['type'] === 'plc-server' && editor.meta.protocol === 'modbus-tcp' && ( )} diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index eba9000aa..5efee21dd 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -266,6 +266,16 @@ export async function executeSaveFile(fileName: string, projectPort: ProjectPort JSON.stringify(device, null, 2), ) if (!res.success) return fail(res.error ?? 'Save failed') + } else if (file.type === 'ethercat-device') { + // Slave devices live inside the parent bus file. filePath holds the bus name. + const busName = file.filePath + const bus = project.data.remoteDevices?.find((d) => d.name === busName) + if (!bus) return fail(`Parent bus "${busName}" not found for device "${fileName}".`) + const res = await projectPort.saveFile( + joinPath(projectPath, 'devices/remote', `${busName}.json`), + JSON.stringify(bus, null, 2), + ) + if (!res.success) return fail(res.error ?? 'Save failed') } else { // data-type, resource: live in project.json const debugVariables = collectDebugVariables( diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 565eda096..d4dd285b2 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -15,6 +15,7 @@ import { createSearchSlice } from '../slices/search/slice' import { createSharedSlice } from '../slices/shared/slice' import type { SharedRootState } from '../slices/shared/types' import { createTabsSlice } from '../slices/tabs/slice' +import { createVersionControlSlice } from '../slices/version-control/slice' import { createWorkspaceSlice } from '../slices/workspace/slice' function makeStore() { @@ -32,6 +33,7 @@ function makeStore() { ...createFBDFlowSlice(...args), ...createLadderFlowSlice(...args), ...createHistorySlice(...args), + ...createVersionControlSlice(...args), ...createSharedSlice(...args), })) } @@ -705,6 +707,41 @@ describe('createSharedSlice', () => { store.getState().remoteDeviceActions.delete('Device1') expect(store.getState().editor.meta.name).toBe('Device2') }) + + it('cascades to EtherCAT children so their tabs, editors and files are removed', () => { + // Set up an EtherCAT bus with two configured slave devices. + store.getState().projectActions.createRemoteDevice({ + data: { name: 'eth', protocol: 'ethercat' }, + }) + store.getState().projectActions.updateEthercatConfig('eth', { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, watchdogTimeoutCycles: 3 }, + devices: [ + { id: 'slave-1', name: 'EK1100' }, + { id: 'slave-2', name: 'EL1008' }, + ] as never, + }) + // Register renderer-side state the UI would have created for each child. + for (const child of ['EK1100', 'EL1008']) { + store + .getState() + .editorActions.addModel({ type: 'plc-remote-device', meta: { name: child, protocol: 'ethercat' } }) + store.getState().fileActions.addFile({ name: child, type: 'remote-device', filePath: child }) + store.getState().tabsActions.updateTabs({ + name: child, + elementType: { type: 'remote-device', protocol: 'ethercat' }, + }) + } + + expect(store.getState().tabs.map((t) => t.name)).toEqual(expect.arrayContaining(['EK1100', 'EL1008'])) + + store.getState().remoteDeviceActions.delete('eth') + + const state = store.getState() + expect(state.project.data.remoteDevices?.some((d) => d.name === 'eth')).toBe(false) + expect(state.files['EK1100']).toBeUndefined() + expect(state.files['EL1008']).toBeUndefined() + expect(state.tabs.some((t) => t.name === 'EK1100' || t.name === 'EL1008')).toBe(false) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 6c22dc9b2..40b544fb9 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -150,6 +150,14 @@ export type EditorModel = EditorModelBase & protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' } } + | { + type: 'plc-ethercat-device' + meta: { + name: string + busName: string + deviceId: string + } + } ) // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/file/types.ts b/src/frontend/store/slices/file/types.ts index 1fd8d416e..ad838665b 100644 --- a/src/frontend/store/slices/file/types.ts +++ b/src/frontend/store/slices/file/types.ts @@ -7,6 +7,7 @@ export type FileSliceType = | 'resource' | 'server' | 'remote-device' + | 'ethercat-device' | null export type FileSliceData = { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 7ea804c84..f1ff583e6 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -1,3 +1,5 @@ +import { cycleTimeUsToIecInterval, ethercatTaskName } from '@root/backend/shared/ethercat/ethercat-task-helpers' +import type { EthercatConfig } from '@root/backend/shared/types/PLC/open-plc' import { produce } from 'immer' import { StateCreator } from 'zustand' @@ -191,6 +193,26 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { slice.project = state + + // Migration: ensure system tasks exist for all EtherCAT devices + const ethercatDevices = (slice.project.data.remoteDevices ?? []).filter((d) => d.protocol === 'ethercat') + for (const device of ethercatDevices) { + const existingTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === device.name, + ) + if (!existingTask) { + const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskPriority = device.ethercatConfig?.masterConfig?.taskPriority ?? 1 + slice.project.data.configurations.resource.tasks.unshift({ + name: ethercatTaskName(device.name), + triggering: 'Cyclic' as const, + interval: cycleTimeUsToIecInterval(cycleTimeUs), + priority: taskPriority, + isSystemTask: true, + associatedDevice: device.name, + }) + } + } }), ) }, @@ -598,26 +620,34 @@ const createProjectSlice: StateCreator = (se setTasks: ({ tasks }) => { setState( produce((slice: ProjectSlice) => { - slice.project.data.configurations.resource.tasks = tasks + // Preserve system tasks (auto-created for EtherCAT devices) + const systemTasks = slice.project.data.configurations.resource.tasks.filter((t) => t.isSystemTask) + slice.project.data.configurations.resource.tasks = [...systemTasks, ...tasks.filter((t) => !t.isSystemTask)] }), ) return ok() }, updateTask: (dto) => { + let response = ok() setState( produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks + if (tasks[dto.rowId]?.isSystemTask) { + response = { ok: false, title: 'System task', message: 'System tasks cannot be modified' } + return + } if (dto.rowId >= 0 && dto.rowId < tasks.length) { tasks[dto.rowId] = { ...tasks[dto.rowId], ...dto.data } } }), ) - return ok() + return response }, deleteTask: ({ rowId }) => { setState( produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks + if (tasks[rowId]?.isSystemTask) return if (rowId >= 0 && rowId < tasks.length) tasks.splice(rowId, 1) }), ) @@ -627,6 +657,7 @@ const createProjectSlice: StateCreator = (se produce((slice: ProjectSlice) => { const tasks = slice.project.data.configurations.resource.tasks if (rowId < 0 || rowId >= tasks.length) return + if (tasks[rowId].isSystemTask) return const [item] = tasks.splice(rowId, 1) tasks.splice(newIndex, 0, item) }), @@ -1011,6 +1042,20 @@ const createProjectSlice: StateCreator = (se device.modbusTcpConfig = { host: '127.0.0.1', port: 502, slaveId: 1, timeout: 1000, ioGroups: [] } } slice.project.data.remoteDevices.push(device) + + // Auto-create system task for EtherCAT devices + if (device.protocol === 'ethercat') { + const cycleTimeUs = device.ethercatConfig?.masterConfig?.cycleTimeUs ?? 1000 + const taskPriority = device.ethercatConfig?.masterConfig?.taskPriority ?? 1 + slice.project.data.configurations.resource.tasks.unshift({ + name: ethercatTaskName(device.name), + triggering: 'Cyclic' as const, + interval: cycleTimeUsToIecInterval(cycleTimeUs), + priority: taskPriority, + isSystemTask: true, + associatedDevice: device.name, + }) + } }), ) return ok() @@ -1019,8 +1064,19 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { if (!slice.project.data.remoteDevices) return + const deviceToDelete = slice.project.data.remoteDevices.find((d) => d.name === name) slice.pendingDeletions.push(`devices/remote/${name}.json`) slice.project.data.remoteDevices = slice.project.data.remoteDevices.filter((d) => d.name !== name) + + // Remove associated system task for EtherCAT devices + if (deviceToDelete?.protocol === 'ethercat') { + const taskIndex = slice.project.data.configurations.resource.tasks.findIndex( + (t) => t.isSystemTask && t.associatedDevice === name, + ) + if (taskIndex !== -1) { + slice.project.data.configurations.resource.tasks.splice(taskIndex, 1) + } + } }), ) return ok() @@ -1033,7 +1089,20 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { const device = slice.project.data.remoteDevices?.find((d) => d.name === name) - if (device) device.name = newName + if (!device) return + + // Update associated system task name for EtherCAT devices + if (device.protocol === 'ethercat') { + const systemTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === name, + ) + if (systemTask) { + systemTask.name = ethercatTaskName(newName) + systemTask.associatedDevice = newName + } + } + + device.name = newName }), ) return ok() @@ -1064,6 +1133,16 @@ const createProjectSlice: StateCreator = (se usedAddresses.add(p.iecLocation) } } + // Include EtherCAT channel mappings from all remote devices + for (const rd of slice.project.data.remoteDevices ?? []) { + if (rd.ethercatConfig?.devices) { + for (const dev of rd.ethercatConfig.devices) { + for (const mapping of dev.channelMappings) { + usedAddresses.add(mapping.iecLocation) + } + } + } + } const ioPoints = generateIOPoints(group.functionCode, group.length, group.name, usedAddresses) device.modbusTcpConfig.ioGroups.push({ ...group, ioPoints }) @@ -1105,6 +1184,44 @@ const createProjectSlice: StateCreator = (se ) return ok() }, + updateEthercatConfig: (deviceName: string, ethercatConfig: EthercatConfig | Record) => { + let response = ok() + setState( + produce((slice: ProjectSlice) => { + if (!slice.project.data.remoteDevices) { + response = { ok: false, message: 'No remote devices found' } + return + } + const device = slice.project.data.remoteDevices.find((d) => d.name === deviceName) + if (!device) { + response = { ok: false, message: 'Remote device not found' } + return + } + if (device.protocol !== 'ethercat') { + response = { ok: false, message: 'Device is not an EtherCAT device' } + return + } + device.ethercatConfig = ethercatConfig as typeof device.ethercatConfig + + // Sync master config fields to the associated system task + const masterCfg = (ethercatConfig as EthercatConfig).masterConfig + if (masterCfg) { + const systemTask = slice.project.data.configurations.resource.tasks.find( + (t) => t.isSystemTask && t.associatedDevice === deviceName, + ) + if (systemTask) { + if (masterCfg.cycleTimeUs !== undefined) { + systemTask.interval = cycleTimeUsToIecInterval(masterCfg.cycleTimeUs) + } + if (masterCfg.taskPriority !== undefined) { + systemTask.priority = masterCfg.taskPriority + } + } + } + }), + ) + return response + }, }, }) diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 06f761483..105dfbc25 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -239,6 +239,7 @@ export type ProjectActions = { ) => ProjectResponse deleteIOGroup: (deviceName: string, groupId: string) => ProjectResponse updateIOPointAlias: (deviceName: string, groupId: string, pointId: string, alias: string) => ProjectResponse + updateEthercatConfig: (deviceName: string, ethercatConfig: Record) => ProjectResponse } // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 0d0abb2e8..e7b98103a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -280,12 +280,90 @@ const createSharedSlice: StateCreator = (s getState().modalActions.openModal('confirm-delete-element', { name, elementType: 'remote-device' }) }, - delete: (name) => deleteElement(getState(), name, (n) => getState().projectActions.deleteRemoteDevice(n)), + delete: (name) => { + // Cascade: purge EtherCAT children first so their tabs, editor + // models, and file entries are cleaned up before the bus vanishes + // from the tree. Without this step the children survive the + // parent delete as orphan state. + const state = getState() + const bus = state.project.data.remoteDevices?.find((d) => d.name === name) + const children = bus?.protocol === 'ethercat' ? (bus.ethercatConfig?.devices ?? []) : [] + // Snapshot ids — ethercatDeviceActions.delete mutates the same + // array via updateEthercatConfig, so iterating the live array + // would skip every second child. + for (const childId of children.map((d) => d.id)) { + state.ethercatDeviceActions.delete(name, childId) + } + return deleteElement(getState(), name, (n) => getState().projectActions.deleteRemoteDevice(n)) + }, rename: (oldName, newName) => renameElement(getState(), oldName, newName, (o, n) => getState().projectActions.updateRemoteDeviceName(o, n)), }, + ethercatDeviceActions: { + delete: (busName, deviceId) => { + const state = getState() + const remoteDevice = state.project.data.remoteDevices?.find((d) => d.name === busName) + if (!remoteDevice) return { ok: false, message: 'Bus not found' } + + const device = remoteDevice.ethercatConfig?.devices?.find((d) => d.id === deviceId) + if (!device) return { ok: false, message: 'EtherCAT device not found' } + + const deviceName = device.name + state.projectActions.updateEthercatConfig(busName, { + masterConfig: remoteDevice.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + }, + devices: (remoteDevice.ethercatConfig?.devices ?? []).filter((d) => d.id !== deviceId), + }) + state.editorActions.removeModel(deviceName) + state.tabsActions.removeTab(deviceName) + // EtherCAT children are registered in the file slice on project + // load (see register files for save-state tracking). Drop the + // entry here so it doesn't linger when the child is removed + // directly or via a bus cascade. + state.fileActions.removeFile({ name: deviceName }) + + const currentEditor = state.editor + if (currentEditor.type !== 'available' && currentEditor.meta.name === deviceName) { + state.editorActions.clearEditor() + } + + return { ok: true } + }, + + rename: (busName, deviceId, newName) => { + const state = getState() + const remoteDevice = state.project.data.remoteDevices?.find((d) => d.name === busName) + if (!remoteDevice) return { ok: false, message: 'Bus not found' } + + const devices = remoteDevice.ethercatConfig?.devices ?? [] + const device = devices.find((d) => d.id === deviceId) + if (!device) return { ok: false, message: 'EtherCAT device not found' } + + const oldName = device.name + const updatedDevices = devices.map((d) => (d.id === deviceId ? { ...d, name: newName } : d)) + state.projectActions.updateEthercatConfig(busName, { + masterConfig: remoteDevice.ethercatConfig?.masterConfig ?? { + networkInterface: 'eth0', + cycleTimeUs: 1000, + watchdogTimeoutCycles: 3, + }, + devices: updatedDevices, + }) + state.editorActions.updateEditorName(oldName, newName) + state.tabsActions.updateTabName(oldName, newName) + // Rekey the file slice entry so save-state tracking follows the rename + // instead of orphaning the old name when the slave is first-class. + state.fileActions.updateFile({ name: oldName, newName }) + + return { ok: true } + }, + }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState: (name) => { const { file } = getState().fileActions.getFile({ name }) @@ -547,6 +625,16 @@ const createSharedSlice: StateCreator = (s if (remoteDevices) { remoteDevices.forEach((d) => { files[d.name] = { type: 'remote-device', filePath: d.name, saved: true } + // Register file entries for EtherCAT slave devices (children of the bus). + // Keyed by slave.name to match how the rest of the file registry, tabs, + // and editor models identify slaves. Rename flows in + // `ethercatDeviceActions.rename` call `fileActions.updateFile({ name, newName })` + // to rekey this entry so it never orphans. + if (d.protocol === 'ethercat' && d.ethercatConfig?.devices) { + for (const slave of d.ethercatConfig.devices) { + files[slave.name] = { type: 'ethercat-device', filePath: d.name, saved: true } + } + } }) } files['Resource'] = { type: 'resource', filePath: 'Resource', saved: true } diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 896c948cd..0dad42439 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -107,6 +107,11 @@ export type RemoteDeviceActions = { rename: (oldName: string, newName: string) => SharedResponse } +export type EtherCATDeviceActions = { + delete: (busName: string, deviceId: string) => SharedResponse + rename: (busName: string, deviceId: string, newName: string) => SharedResponse +} + export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void markSaved: (pouName: string) => void @@ -151,6 +156,7 @@ export type SharedSlice = { datatypeActions: DatatypeActions serverActions: ServerActions remoteDeviceActions: RemoteDeviceActions + ethercatDeviceActions: EtherCATDeviceActions snapshotActions: SnapshotActions sharedWorkspaceActions: SharedWorkspaceActions } diff --git a/src/frontend/store/slices/shared/utils.ts b/src/frontend/store/slices/shared/utils.ts index 4127e177a..66cbdc094 100644 --- a/src/frontend/store/slices/shared/utils.ts +++ b/src/frontend/store/slices/shared/utils.ts @@ -159,6 +159,13 @@ export function createEditorObjectForRemoteDevice( } } +export function createEditorObjectForEtherCATDevice(name: string, busName: string, deviceId: string): EditorModel { + return { + type: 'plc-ethercat-device', + meta: { name, busName, deviceId }, + } +} + export function createTabObject( name: string, pouType: 'program' | 'function' | 'function-block', diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index ec2ce28d8..a63a30702 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -14,6 +14,7 @@ export type TabsProps = { | { type: 'device'; derivation: 'configuration' | 'pin-mapping' | 'orchestrators' } | { type: 'server'; protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua' } | { type: 'remote-device'; protocol: 'modbus-tcp' | 'ethernet-ip' | 'ethercat' | 'profinet' } + | { type: 'ethercat-device'; busName: string; deviceId: string } configuration?: Record } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index d03c612e9..dec71f363 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -99,6 +99,11 @@ const CreateRemoteDeviceEditor = ( meta: { name, protocol }, }) +const CreateEtherCATDeviceEditor = (name: string, busName: string, deviceId: string): EditorModel => ({ + type: 'plc-ethercat-device', + meta: { name, busName, deviceId }, +}) + const CreateServerEditor = ( name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua', @@ -124,6 +129,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateDeviceEditor(name, elementType.derivation) case 'remote-device': return CreateRemoteDeviceEditor(name, elementType.protocol) + case 'ethercat-device': + return CreateEtherCATDeviceEditor(name, elementType.busName, elementType.deviceId) case 'server': return CreateServerEditor(name, elementType.protocol) } @@ -133,6 +140,7 @@ export { CreateDeviceEditor, CreateEditorModelObject, CreateEditorObjectFromTab, + CreateEtherCATDeviceEditor, CreatePLCGraphicalObject, CreatePLCTextualObject, CreateRemoteDeviceEditor, diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index c6df2321b..4a0c4c673 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -34,6 +34,7 @@ export type WorkspaceProjectTreeLeafType = | 'resource' | 'server' | 'remote-device' + | 'ethercat-device' | null // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/PLC/xml-generator/codesys/instances-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/instances-xml.ts index 18b0f3736..2166b70e5 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/instances-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/instances-xml.ts @@ -5,7 +5,9 @@ import { PouInstance, TaskXML } from '@root/middleware/shared/ports/xml-types/co export const codeSysInstanceToXml = (xml: BaseXml, configuration: PLCConfiguration) => { const { instances, tasks, globalVariables } = configuration.resource - tasks.forEach((task) => { + const sortedTasks = [...tasks].sort((a, b) => a.priority - b.priority) + + sortedTasks.forEach((task) => { const i: PouInstance[] = instances .filter((i) => i.task === task.name) diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/instances-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/instances-xml.ts index 0abfabc68..8b88e1eed 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/instances-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/instances-xml.ts @@ -8,7 +8,9 @@ import { convertTypeToXml } from './type-xml' export const oldEditorInstanceToXml = (xml: BaseXml, configuration: PLCConfiguration) => { const { instances, tasks, globalVariables } = configuration.resource - tasks.forEach((task) => { + const sortedTasks = [...tasks].sort((a, b) => a.priority - b.priority) + + sortedTasks.forEach((task) => { const i: PouInstance[] = instances .filter((i) => i.task === task.name) diff --git a/src/frontend/utils/parse-resource-configuration-to-string.ts b/src/frontend/utils/parse-resource-configuration-to-string.ts index a206f4c5a..e722b008d 100644 --- a/src/frontend/utils/parse-resource-configuration-to-string.ts +++ b/src/frontend/utils/parse-resource-configuration-to-string.ts @@ -8,7 +8,9 @@ export function parseResourceConfigurationToString(taskList: PLCTask[], instance return '(* No tasks or program instances declared. *)' } - const usedTasks: PLCTask[] = [...taskList] + const usedTasks: PLCTask[] = [...taskList].sort( + (a, b) => (a.priority ?? DEFAULT_PRIORITY) - (b.priority ?? DEFAULT_PRIORITY), + ) let out = 'CONFIGURATION Config0\n' out += '\tRESOURCE Res0 ON PLC\n\n' diff --git a/src/frontend/utils/parse-resource-string-to-configuration.ts b/src/frontend/utils/parse-resource-string-to-configuration.ts index 1bee818d7..28e7d3553 100644 --- a/src/frontend/utils/parse-resource-string-to-configuration.ts +++ b/src/frontend/utils/parse-resource-string-to-configuration.ts @@ -108,7 +108,7 @@ export function parseResourceStringToConfiguration(configString: string): { name, triggering: DEFAULT_TRIGGERING, interval: '', - priority: 0, + priority: 1, } if (params) { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 684e163c5..12330d41f 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,7 +1,20 @@ +import { ESIService } from '@root/backend/editor/ethercat' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' -import { RuntimeLogEntry } from '@root/middleware/shared/ports/types' +import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' @@ -48,6 +61,8 @@ class MainProcessBridge implements MainIpcModule { private fileWatchers: Map = new Map() // avr8js ATmega2560 emulator instance for the built-in simulator private simulatorModule = new SimulatorModule() + // ESI repository service for EtherCAT device descriptions + private esiService = new ESIService() constructor({ ipcMain, @@ -241,8 +256,8 @@ class MainProcessBridge implements MainIpcModule { if (responseParser) { try { return { success: true, data: responseParser(data) } - } catch { - return { success: false, error: 'Invalid response format' } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Invalid response format' } } } return { success: true } @@ -296,6 +311,101 @@ class MainProcessBridge implements MainIpcModule { } } + /** + * Wrap a service call with standardized error handling. + */ + private async wrapServiceCall(fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + return { success: false, error: String(error) } + } + } + + /** + * Make an authenticated POST request to the runtime API with automatic token refresh on 401/403. + */ + makeRuntimeApiPostRequest( + ipAddress: string, + jwtToken: string, + endpoint: string, + body: string, + responseParser: (data: string) => T, + timeoutMs?: number, + ): Promise<{ success: true; data: T } | { success: false; error: string }> { + type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => { + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + resolve({ success: true, data: responseParser(data) }) + } catch (err) { + resolve({ success: false, error: err instanceof Error ? err.message : 'Invalid response format' }) + } + } else { + // Propagate HTTP status so the caller can detect 401/403 for + // token-refresh without relying on brittle message parsing. + resolve({ + success: false, + error: data || `Unexpected status: ${res.statusCode}`, + statusCode: res.statusCode, + }) + } + }) + }, + ) + req.setTimeout(timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + req.write(body) + req.end() + }) + } + + const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => + r.success ? r : { success: false, error: r.error } + + return doRequest(jwtToken).then((result) => { + const statusCode = !result.success ? result.statusCode : undefined + if (!result.success && this.isTokenExpiredError(statusCode, result.error)) { + return this.attemptTokenRefresh().then((refreshResult) => { + if (refreshResult.success && refreshResult.accessToken) { + if (this.mainWindow && this.mainWindow.webContents) { + this.mainWindow.webContents.send('runtime:token-refreshed', refreshResult.accessToken) + } + return doRequest(refreshResult.accessToken).then(stripStatus) + } + return { success: false as const, error: `Token refresh failed: ${refreshResult.error || 'Unknown error'}` } + }) + } + return stripStatus(result) + }) + } + handleRuntimeGetStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, @@ -551,6 +661,25 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('runtime:clear-credentials', this.handleRuntimeClearCredentials) this.registerHandle('runtime:get-serial-ports', this.handleRuntimeGetSerialPorts) + // ===================== ETHERCAT DISCOVERY ===================== + this.registerHandle('ethercat:get-interfaces', this.handleEtherCATGetInterfaces) + this.registerHandle('ethercat:get-status', this.handleEtherCATGetStatus) + this.registerHandle('ethercat:scan', this.handleEtherCATScan) + this.registerHandle('ethercat:test', this.handleEtherCATTest) + this.registerHandle('ethercat:validate', this.handleEtherCATValidate) + this.registerHandle('ethercat:get-runtime-status', this.handleEtherCATGetRuntimeStatus) + + // ===================== ESI REPOSITORY ===================== + this.registerHandle('esi:load-repository-index', this.handleESILoadRepositoryIndex) + this.registerHandle('esi:save-xml-file', this.handleESISaveXmlFile) + this.registerHandle('esi:load-xml-file', this.handleESILoadXmlFile) + this.registerHandle('esi:delete-xml-file', this.handleESIDeleteXmlFile) + this.registerHandle('esi:parse-and-save-file', this.handleESIParseAndSaveFile) + this.registerHandle('esi:clear-repository', this.handleESIClearRepository) + this.registerHandle('esi:load-device-full', this.handleESILoadDeviceFull) + this.registerHandle('esi:load-repository-light', this.handleESILoadRepositoryLight) + this.registerHandle('esi:migrate-repository', this.handleESIMigrateRepository) + // ===================== SIMULATOR ===================== this.registerHandle('simulator:load-firmware', this.handleSimulatorLoadFirmware) this.registerHandle('simulator:stop', this.handleSimulatorStop) @@ -1370,6 +1499,241 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== ETHERCAT DISCOVERY HANDLERS ===================== + + handleEtherCATGetInterfaces = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => { + try { + const result = await this.makeRuntimeApiRequest<{ interfaces: NetworkInterface[] }>( + ipAddress, + jwtToken, + '/api/discovery/interfaces', + (data: string) => { + const response = JSON.parse(data) as { status: string; interfaces: NetworkInterface[] } + return { interfaces: response.interfaces || [] } + }, + ) + if (result.success && result.data) { + return { success: true, data: result.data.interfaces } + } else { + return { success: false, error: result.success ? 'No data returned' : result.error } + } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATGetStatus = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => { + try { + const result = await this.makeRuntimeApiRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/status', + (data: string) => { + const parsed = JSON.parse(data) as unknown + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as { available?: unknown }).available !== 'boolean' || + typeof (parsed as { message?: unknown }).message !== 'string' + ) { + throw new Error('EtherCAT status response did not match expected shape') + } + return parsed as EtherCATServiceStatusResponse + }, + ) + if (result.success && result.data) { + return { success: true, data: result.data } + } else { + return { success: false, error: result.success ? 'No data returned' : result.error } + } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATScan = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'scan', + params: { interface: scanRequest.interface, timeout_ms: scanRequest.timeout_ms }, + }) + const scanTimeout = (scanRequest.timeout_ms || 5000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return { + status: (pluginResponse.status as string) ?? 'success', + devices: (pluginResponse.devices as EtherCATScanResponse['devices']) ?? [], + message: (pluginResponse.message as string) ?? '', + scan_time_ms: (pluginResponse.scan_time_ms as number) ?? 0, + interface: scanRequest.interface, + } as EtherCATScanResponse + }, + scanTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATTest = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { + try { + const postData = JSON.stringify(testRequest) + const testTimeout = (testRequest.timeout_ms || 3000) + 10000 + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/test', + postData, + (data: string) => JSON.parse(data) as EtherCATTestResponse, + testTimeout, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATValidate = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => { + try { + const postData = JSON.stringify(validateRequest) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/discovery/ethercat/validate', + postData, + (data: string) => JSON.parse(data) as EtherCATValidateResponse, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + handleEtherCATGetRuntimeStatus = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => { + try { + const postData = JSON.stringify({ + plugin: 'ethercat', + command: 'status', + }) + + const result = await this.makeRuntimeApiPostRequest( + ipAddress, + jwtToken, + '/api/plugin-command', + postData, + (data: string) => { + const pluginResponse = JSON.parse(data) as Record + if (pluginResponse.error) throw new Error(pluginResponse.error as string) + return pluginResponse as unknown as EtherCATRuntimeStatusResponse + }, + ) + + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: String(error) } + } + } + + // ===================== ESI REPOSITORY HANDLERS ===================== + + handleESILoadRepositoryIndex = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(async () => { + const index = await this.esiService.loadRepositoryIndex(projectPath) + return { success: true as const, data: index } + }) + + handleESISaveXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string, xmlContent: string) => + this.wrapServiceCall(() => this.esiService.saveXmlFile(projectPath, itemId, xmlContent)) + + handleESILoadXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.loadXmlFile(projectPath, itemId)) + + handleESIDeleteXmlFile = async (_event: IpcMainInvokeEvent, projectPath: string, itemId: string) => + this.wrapServiceCall(() => this.esiService.deleteRepositoryItemV2(projectPath, itemId)) + + handleESIParseAndSaveFile = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + filename: string, + content: string, + ) => this.wrapServiceCall(() => this.esiService.parseAndSaveFile(projectPath, filename, content)) + + handleESIClearRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.clearRepository(projectPath)) + + handleESILoadDeviceFull = async ( + _event: IpcMainInvokeEvent, + projectPath: string, + itemId: string, + deviceIndex: number, + ) => + this.wrapServiceCall(async () => { + const xmlResult = await this.esiService.loadXmlFile(projectPath, itemId) + if (!xmlResult.success || !xmlResult.content) { + return { success: false as const, error: xmlResult.error || 'XML file not found' } + } + return parseESIDeviceFull(xmlResult.content, deviceIndex) + }) + + handleESILoadRepositoryLight = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.loadRepositoryLight(projectPath)) + + handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => + this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + handleSimulatorLoadFirmware = async ( _event: IpcMainInvokeEvent, hexPath: string, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index b75d533c5..42702770c 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,17 @@ -import type { PLCProjectData, RuntimeLogEntry } from '@root/middleware/shared/ports/types' +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, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { ipcRenderer, IpcRendererEvent } from 'electron' @@ -356,6 +369,98 @@ const rendererProcessBridge = { return () => ipcRenderer.removeListener('runtime:token-refreshed', callback) }, + // ===================== ETHERCAT DISCOVERY METHODS ===================== + etherCATGetInterfaces: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => + ipcRenderer.invoke('ethercat:get-interfaces', ipAddress, jwtToken), + + etherCATGetStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-status', ipAddress, jwtToken), + + etherCATScan: ( + ipAddress: string, + jwtToken: string, + scanRequest: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => + ipcRenderer.invoke('ethercat:scan', ipAddress, jwtToken, scanRequest), + + etherCATTest: ( + ipAddress: string, + jwtToken: string, + testRequest: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => + ipcRenderer.invoke('ethercat:test', ipAddress, jwtToken, testRequest), + + etherCATValidate: ( + ipAddress: string, + jwtToken: string, + validateRequest: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => + ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + + etherCATGetRuntimeStatus: ( + ipAddress: string, + jwtToken: string, + ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => + ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress, jwtToken), + + // ===================== ESI REPOSITORY METHODS ===================== + esiLoadRepositoryIndex: ( + projectPath: string, + ): Promise<{ + success: boolean + data?: { version: number; items: Array> } + error?: string + }> => ipcRenderer.invoke('esi:load-repository-index', projectPath), + + esiSaveXmlFile: ( + projectPath: string, + itemId: string, + xmlContent: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:save-xml-file', projectPath, itemId, xmlContent), + + esiLoadXmlFile: ( + projectPath: string, + itemId: string, + ): Promise<{ success: boolean; content?: string; error?: string }> => + ipcRenderer.invoke('esi:load-xml-file', projectPath, itemId), + + esiDeleteXmlFile: (projectPath: string, itemId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:delete-xml-file', projectPath, itemId), + + esiParseAndSaveFile: ( + projectPath: string, + filename: string, + content: string, + ): Promise<{ success: boolean; item?: ESIRepositoryItemLight; duplicate?: boolean; error?: string }> => + ipcRenderer.invoke('esi:parse-and-save-file', projectPath, filename, content), + + esiClearRepository: (projectPath: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('esi:clear-repository', projectPath), + + esiLoadDeviceFull: ( + projectPath: string, + itemId: string, + deviceIndex: number, + ): Promise<{ success: boolean; device?: ESIDevice; error?: string }> => + ipcRenderer.invoke('esi:load-device-full', projectPath, itemId, deviceIndex), + + esiLoadRepositoryLight: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; needsMigration?: boolean; error?: string }> => + ipcRenderer.invoke('esi:load-repository-light', projectPath), + + esiMigrateRepository: ( + projectPath: string, + ): Promise<{ success: boolean; items?: ESIRepositoryItemLight[]; error?: string }> => + ipcRenderer.invoke('esi:migrate-repository', projectPath), + // ===================== SIMULATOR METHODS ===================== simulatorLoadFirmware: (hexPath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('simulator:load-firmware', hexPath), diff --git a/src/middleware/adapters/editor/esi-adapter.ts b/src/middleware/adapters/editor/esi-adapter.ts new file mode 100644 index 000000000..fb5bcda3c --- /dev/null +++ b/src/middleware/adapters/editor/esi-adapter.ts @@ -0,0 +1,114 @@ +/** + * Editor EsiPort adapter — delegates to Electron IPC bridge. + * + * Communicates with the main process ESIService for ESI repository + * management. The adapter injects the project path from a callback + * so UI components never pass it explicitly. + * + * IPC channels: + * esi:load-repository-light (invoke) + * esi:migrate-repository (invoke) + * esi:parse-and-save-file (invoke) + * esi:delete-xml-file (invoke) + * esi:clear-repository (invoke) + * esi:load-device-full (invoke) + */ + +import type { EsiPort } from '../../shared/ports/esi-port' +import type { Result } from '../../shared/ports/types' + +export function createEditorEsiAdapter(getProjectPath: () => string): EsiPort { + function requireProjectPath(): string { + const path = getProjectPath() + if (!path) throw new Error('No project path available') + return path + } + + return { + async loadRepositoryLight() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiLoadRepositoryLight(projectPath) + if (result.success) { + return { success: true, items: result.items ?? [], needsMigration: result.needsMigration } as Result<{ + items: typeof result.items extends undefined ? never : NonNullable + needsMigration?: boolean + }> + } + return { success: false, error: result.error ?? 'Failed to load repository' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async migrateRepository() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiMigrateRepository(projectPath) + if (result.success) { + return { success: true, items: result.items ?? [] } as Result<{ + items: NonNullable + }> + } + return { success: false, error: result.error ?? 'Migration failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async parseAndSaveFile(filename, content) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiParseAndSaveFile(projectPath, filename, content) + if (result.success && result.item) { + return { success: true, item: result.item } + } + if (result.success) { + // Duplicate file — surface it explicitly so callers can distinguish + // a successful add from a silent skip instead of squinting at `!item`. + return { success: true, duplicate: true } + } + return { success: false, error: result.error ?? 'Parse failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async deleteRepositoryItem(itemId) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiDeleteXmlFile(projectPath, itemId) + return result.success + ? ({ success: true } as Result) + : { success: false, error: result.error ?? 'Delete failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async clearRepository() { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiClearRepository(projectPath) + return result.success + ? ({ success: true } as Result) + : { success: false, error: result.error ?? 'Clear failed' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + + async loadDeviceFull(itemId, deviceIndex) { + try { + const projectPath = requireProjectPath() + const result = await window.bridge.esiLoadDeviceFull(projectPath, itemId, deviceIndex) + if (result.success && result.device) { + return { success: true, device: result.device } as Result<{ device: NonNullable }> + } + return { success: false, error: result.error ?? 'Device not found' } + } catch (err) { + return { success: false, error: String(err) } + } + }, + } +} diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index 08ac0ff3d..ad58893e6 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -135,5 +135,61 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP } return window.bridge.onRuntimeTokenRefreshed(handler) }, + + // --- EtherCAT Discovery --- + + async getNetworkInterfaces() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetInterfaces(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async getEthercatServiceStatus() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetStatus(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async scanEthercatDevices(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATScan(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async testEthercatConnection(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATTest(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async validateEthercatConfig(request) { + try { + const ip = requireIp() + return await window.bridge.etherCATValidate(ip, jwtToken, request) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async getEthercatRuntimeStatus() { + try { + const ip = requireIp() + return await window.bridge.etherCATGetRuntimeStatus(ip, jwtToken) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, } } diff --git a/src/middleware/editor-platform.ts b/src/middleware/editor-platform.ts index 76c7ec040..d18cb6e15 100644 --- a/src/middleware/editor-platform.ts +++ b/src/middleware/editor-platform.ts @@ -17,6 +17,7 @@ import { createEditorAcceleratorAdapter } from './adapters/editor/accelerator-ad import { createEditorCompilerAdapter } from './adapters/editor/compiler-adapter' import { createEditorDebuggerAdapter } from './adapters/editor/debugger-adapter' import { createEditorDeviceAdapter } from './adapters/editor/device-adapter' +import { createEditorEsiAdapter } from './adapters/editor/esi-adapter' import { createEditorOrchestratorAdapter } from './adapters/editor/orchestrator-adapter' import { createEditorProjectAdapter } from './adapters/editor/project-adapter' import { createEditorRuntimeAdapter } from './adapters/editor/runtime-adapter' @@ -33,11 +34,16 @@ import type { PlatformPorts } from './shared/providers/types' * Set by the store/UI when the user configures or connects to a device. */ let _runtimeIpAddress = '' +let _projectPath = '' export function setRuntimeIpAddress(ip: string): void { _runtimeIpAddress = ip } +export function setProjectPath(path: string): void { + _projectPath = path +} + /** * Editor platform ports — all port interfaces wired to Electron IPC bridge. */ @@ -53,6 +59,7 @@ export const editorPorts: PlatformPorts = { window: createEditorWindowAdapter(), accelerator: createEditorAcceleratorAdapter(), theme: createEditorThemeAdapter(), + esi: createEditorEsiAdapter(() => _projectPath), versionControl: createEditorVersionControlAdapter(), capabilities: { ...EDITOR_CAPABILITIES, isDevMode: process.env.NODE_ENV === 'development' }, } diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts new file mode 100644 index 000000000..240e73fc7 --- /dev/null +++ b/src/middleware/shared/ports/esi-port.ts @@ -0,0 +1,63 @@ +/** + * EsiPort — Abstracts ESI (EtherCAT Slave Information) repository management. + * + * The ESI repository stores parsed EtherCAT device description files (XML) + * for use in device configuration. The persistence mechanism differs between + * platforms: + * + * Editor adapter: Delegates to main process ESIService which reads/writes + * XML files and a JSON index in the project's devices/esi/ directory. + * Web adapter: Will delegate to a backend API for ESI file management. + * + * All parsing and processing happens in the shared backend + * (src/backend/shared/ethercat/). The port only handles persistence I/O. + */ + +import type { ESIDevice, ESIRepositoryItemLight } from './esi-types' +import type { Result } from './types' + +export interface EsiPort { + /** + * Load the ESI repository as lightweight items (instant from v2 cached index). + * Returns needsMigration=true if the repository is in v1 format. + */ + loadRepositoryLight(): Promise> + + /** + * Migrate a v1 repository to v2 format with device summaries. + * Returns the updated list of lightweight items. + */ + migrateRepository(): Promise> + + /** + * Parse an ESI XML file and save it to the repository. + * The filename and raw XML content are provided; parsing happens on the backend. + * + * On success, `item` is present when the file was newly added, and omitted + * with `duplicate: true` when a repository entry with the same filename + * already exists — letting callers distinguish a real add from a silent skip. + * Dedup is filename-based: reimporting the same bytes under a different name + * will add a new entry, and replacing a file's contents without renaming it + * is reported as a duplicate. + */ + parseAndSaveFile( + filename: string, + content: string, + ): Promise> + + /** + * Delete a single repository item and its associated XML file. + */ + deleteRepositoryItem(itemId: string): Promise + + /** + * Clear the entire ESI repository (bulk delete all files + reset index). + */ + clearRepository(): Promise + + /** + * Load a full ESI device on-demand (with PDOs, sync managers, FMMU, CoE). + * This is called when the user selects a device for detailed configuration. + */ + loadDeviceFull(itemId: string, deviceIndex: number): Promise> +} diff --git a/src/middleware/shared/ports/esi-types.ts b/src/middleware/shared/ports/esi-types.ts new file mode 100644 index 000000000..1f2f0f13a --- /dev/null +++ b/src/middleware/shared/ports/esi-types.ts @@ -0,0 +1,626 @@ +/** + * EtherCAT Slave Information (ESI) Types + * + * Types for parsing and representing ESI XML files following ETG.2000 specification. + * ESI files describe EtherCAT slave device properties, PDO mappings, and communication settings. + */ + +// ===================== VENDOR ===================== + +/** + * Vendor information from ESI file + */ +export interface ESIVendor { + /** Vendor ID (hex format, e.g., "0x0002" for Beckhoff) */ + id: string + /** Vendor name */ + name: string +} + +// ===================== DEVICE INFO ===================== + +/** + * Device type information + */ +export interface ESIDeviceType { + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** Type name/description */ + name: string +} + +/** + * Sync Manager configuration + */ +export interface ESISyncManager { + /** SM index (0-3 typically) */ + index: number + /** Start address */ + startAddress: string + /** Control byte */ + controlByte: string + /** Default size */ + defaultSize: number + /** Enable flag */ + enable: boolean + /** SM type: Mailbox Out, Mailbox In, Process Data Out, Process Data In */ + type: 'MbxOut' | 'MbxIn' | 'Outputs' | 'Inputs' +} + +/** + * FMMU (Fieldbus Memory Management Unit) configuration + */ +export interface ESIFMMU { + /** FMMU type: Outputs, Inputs, MbxState */ + type: 'Outputs' | 'Inputs' | 'MbxState' +} + +// ===================== PDO ENTRIES ===================== + +/** + * EtherCAT data types used in PDO entries + * Common types: BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, + * REAL, LREAL, STRING, BYTE, WORD, DWORD, BIT, BIT2-BIT7 + * Using string to allow vendor-specific custom types + */ +export type ESIDataType = string + +/** + * PDO Entry - represents a single variable in a PDO + */ +export interface ESIPdoEntry { + /** Entry index (hex, e.g., "#x6000") */ + index: string + /** Entry subindex (hex, e.g., "#x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name/identifier */ + name: string + /** Data type */ + dataType: ESIDataType + /** Optional: Comment/description */ + comment?: string +} + +/** + * Process Data Object - TxPdo (slave to master) or RxPdo (master to slave) + */ +export interface ESIPdo { + /** PDO index (hex, e.g., "#x1600" for RxPdo, "#x1A00" for TxPdo) */ + index: string + /** PDO name */ + name: string + /** Whether this PDO is fixed (cannot be modified) */ + fixed: boolean + /** Whether this PDO is mandatory */ + mandatory: boolean + /** SM index this PDO is assigned to */ + smIndex?: number + /** List of entries in this PDO */ + entries: ESIPdoEntry[] +} + +// ===================== COE (CANopen over EtherCAT) ===================== + +/** + * CoE Object Dictionary entry + */ +export interface ESICoEObject { + /** Object index (hex) */ + index: string + /** Object name */ + name: string + /** Object type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** PDO mapping allowed */ + pdoMapping: boolean + /** Object category: M=Mandatory, O=Optional, C=Conditional */ + category?: 'M' | 'O' | 'C' + /** PDO mapping direction: R=RxPDO, T=TxPDO, RT=both */ + pdoMappingDirection?: 'R' | 'T' | 'RT' + /** Default value */ + defaultValue?: string + /** Subindexes for complex objects */ + subItems?: ESICoESubItem[] +} + +/** + * CoE Object subitem (for array/record types) + */ +export interface ESICoESubItem { + /** Subindex */ + subIndex: string + /** Name */ + name: string + /** Data type */ + type: string + /** Bit size */ + bitSize: number + /** Access rights */ + access: 'RO' | 'RW' | 'WO' + /** Whether this sub-item can be PDO-mapped */ + pdoMapping?: boolean + /** Default value */ + defaultValue?: string +} + +// ===================== SDO CONFIGURATION ===================== + +/** + * SDO (Service Data Object) configuration entry for startup parameters. + * Each entry represents a single parameter to be written to the slave at startup. + */ +export interface SDOConfigurationEntry { + /** Object index (hex, e.g., "0x8000") */ + index: string + /** Subindex: 0 for simple objects, 1+ for sub-items */ + subIndex: number + /** Value configured by the user */ + value: string + /** Default value from the ESI */ + defaultValue: string + /** Data type (e.g., "UINT16", "BOOL") */ + dataType: string + /** Bit length of the parameter */ + bitLength: number + /** Parameter name */ + name: string + /** Parent object name */ + objectName: string +} + +// ===================== DEVICE ENRICHMENT ===================== + +/** + * Data extracted from a full ESIDevice for persistence into ConfiguredEtherCATDevice. + * Returned by enrichDeviceData() and used by device configuration components. + */ +export type EnrichDeviceData = { + channelInfo?: PersistedChannelInfo[] + rxPdos?: PersistedPdo[] + txPdos?: PersistedPdo[] + slaveType?: string + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== DEVICE ===================== + +/** + * Complete ESI Device representation + */ +export interface ESIDevice { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** FMMU configurations */ + fmmu: ESIFMMU[] + /** Sync Manager configurations */ + syncManagers: ESISyncManager[] + /** RxPDOs (master to slave) */ + rxPdo: ESIPdo[] + /** TxPDOs (slave to master) */ + txPdo: ESIPdo[] + /** CoE objects (optional) */ + coeObjects?: ESICoEObject[] + /** Device image URL (optional) */ + imageUrl?: string + /** Additional description */ + description?: string +} + +// ===================== GROUP ===================== + +/** + * Device group/category + */ +export interface ESIGroup { + /** Group type identifier */ + type: string + /** Group name */ + name: string + /** Group image URL (optional) */ + imageUrl?: string + /** Group description */ + description?: string +} + +// ===================== COMPLETE ESI FILE ===================== + +/** + * Complete ESI file representation + */ +export interface ESIFile { + /** Vendor information */ + vendor: ESIVendor + /** Device groups */ + groups: ESIGroup[] + /** Devices in the file */ + devices: ESIDevice[] + /** Original filename */ + filename?: string + /** File version info */ + version?: string + /** Creation/modification info */ + infoData?: { + version?: string + creationDate?: string + modificationDate?: string + vendorUrl?: string + } +} + +// ===================== PARSED CHANNEL (for UI) ===================== + +/** + * Represents a channel that can be mapped to a located variable + * This is a flattened view of PDO entries for easier UI handling + */ +export interface ESIChannel { + /** Unique identifier for this channel */ + id: string + /** PDO type: input (TxPdo) or output (RxPdo) */ + direction: 'input' | 'output' + /** Parent PDO index */ + pdoIndex: string + /** Parent PDO name */ + pdoName: string + /** Entry index */ + entryIndex: string + /** Entry subindex */ + entrySubIndex: string + /** Channel name */ + name: string + /** Data type */ + dataType: ESIDataType + /** Bit length */ + bitLen: number + /** Bit offset within the PDO */ + bitOffset: number + /** Byte offset (calculated) */ + byteOffset: number + /** IEC 61131-3 compatible type */ + iecType: string + /** Whether this channel is selected for mapping */ + selected?: boolean + /** Mapped variable name (if assigned) */ + mappedVariable?: string +} + +// ===================== PERSISTED PDO/CHANNEL DATA ===================== + +/** + * Persisted PDO entry - stored in project.json for runtime config generation. + * Includes padding entries (index "0x0000") for complete PDO layout. + */ +export interface PersistedPdoEntry { + /** Entry index (hex, e.g., "0x6000") */ + index: string + /** Entry subindex (hex, e.g., "0x01") */ + subIndex: string + /** Bit length of the data */ + bitLen: number + /** Entry name */ + name: string + /** Data type (e.g., "BOOL", "INT16", "BIT" for padding) */ + dataType: string +} + +/** + * Persisted PDO - stored in project.json for runtime config generation. + */ +export interface PersistedPdo { + /** PDO index (hex, e.g., "0x1A00") */ + index: string + /** PDO name */ + name: string + /** PDO entries including padding */ + entries: PersistedPdoEntry[] +} + +/** + * Persisted channel info with full metadata from ESI. + * Enriches the minimal channelId stored in EtherCATChannelMapping. + */ +export interface PersistedChannelInfo { + /** Unique channel ID matching ESIChannel.id format */ + channelId: string + /** Channel display name from ESI */ + name: string + /** Channel direction */ + direction: 'input' | 'output' + /** Parent PDO index (hex) */ + pdoIndex: string + /** Entry index (hex) */ + entryIndex: string + /** Entry subindex (hex) */ + entrySubIndex: string + /** ESI data type */ + dataType: string + /** Bit length */ + bitLen: number + /** IEC 61131-3 compatible type */ + iecType: string +} + +// ===================== CHANNEL MAPPING ===================== + +/** + * Mapping of an ESI channel to an IEC 61131-3 located variable address + */ +export interface EtherCATChannelMapping { + /** Matches ESIChannel.id */ + channelId: string + /** IEC 61131-3 located variable address (e.g., "%IX0.0", "%QW2") */ + iecLocation: string + /** True if the user manually edited this address */ + userEdited: boolean + /** User-editable alias for this channel mapping */ + alias?: string +} + +// ===================== PARSE RESULT ===================== + +/** + * Result of parsing an ESI file + */ +export interface ESIParseResult { + success: boolean + data?: ESIFile + error?: string + warnings?: string[] +} + +// ===================== DEVICE SUMMARY (lightweight) ===================== + +/** + * Lightweight device metadata without PDOs/SM/FMMU. + * Used for repository listing and device matching without full parsing. + */ +export interface ESIDeviceSummary { + /** Device type information */ + type: ESIDeviceType + /** Device name */ + name: string + /** Group name (category) */ + groupName?: string + /** Physics type (e.g., "YY") */ + physics?: string + /** Pre-computed count of non-padding TxPDO entries */ + inputChannelCount: number + /** Pre-computed count of non-padding RxPDO entries */ + outputChannelCount: number + /** Pre-computed total input bytes */ + totalInputBytes: number + /** Pre-computed total output bytes */ + totalOutputBytes: number + /** Additional description */ + description?: string +} + +// ===================== REPOSITORY ===================== + +/** + * Item in the ESI repository (a loaded ESI file) + */ +export interface ESIRepositoryItem { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Devices contained in this file */ + devices: ESIDevice[] + /** ISO 8601 UTC timestamp when this file was loaded */ + loadedAt: string + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +/** + * Lightweight repository item with device summaries instead of full ESIDevice objects. + * Used for UI display and matching without loading full PDO data. + */ +export interface ESIRepositoryItemLight { + /** Unique identifier for this repository item */ + id: string + /** Original filename */ + filename: string + /** Vendor information */ + vendor: ESIVendor + /** Lightweight device summaries */ + devices: ESIDeviceSummary[] + /** ISO 8601 UTC timestamp when this file was loaded */ + loadedAt: string + /** Parsing warnings (non-fatal issues) */ + warnings?: string[] +} + +// ===================== CONFIGURED DEVICES ===================== + +/** + * Reference to an ESI device in the repository + */ +export interface ESIDeviceRef { + /** ID of the repository item containing the device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number +} + +/** + * A configured EtherCAT device in the project + */ +export interface ConfiguredEtherCATDevice { + /** Unique identifier */ + id: string + /** Position in the EtherCAT network (from scan or manual assignment) */ + position?: number + /** User-editable name for this device */ + name: string + /** Reference to the ESI device definition */ + esiDeviceRef: ESIDeviceRef + /** Vendor ID (hex format) */ + vendorId: string + /** Product code (hex format) */ + productCode: string + /** Revision number (hex format) */ + revisionNo: string + /** How this device was added */ + addedFrom: 'repository' | 'scan' + /** Per-slave configuration settings */ + config: EtherCATSlaveConfig + /** Channel-to-located-variable mappings */ + channelMappings: EtherCATChannelMapping[] + /** Enriched channel metadata from ESI (persisted for runtime config generation) */ + channelInfo?: PersistedChannelInfo[] + /** RxPDOs with full layout including padding (persisted for runtime config generation) */ + rxPdos?: PersistedPdo[] + /** TxPDOs with full layout including padding (persisted for runtime config generation) */ + txPdos?: PersistedPdo[] + /** Slave device type classification (e.g., "digital_input", "coupler") */ + slaveType?: string + /** SDO startup parameters extracted from CoE Object Dictionary */ + sdoConfigurations?: SDOConfigurationEntry[] +} + +// ===================== PER-SLAVE CONFIGURATION ===================== + +/** + * Startup identity checks for an EtherCAT slave. + * When enabled, the master verifies the slave's identity during startup. + */ +export interface EtherCATStartupChecks { + /** Verify slave vendor ID matches ESI definition */ + checkVendorId: boolean + /** Verify slave product code matches ESI definition */ + checkProductCode: boolean +} + +/** + * Addressing configuration for an EtherCAT slave. + */ +export interface EtherCATAddressing { + /** Fixed EtherCAT station address (configured address). 0 = auto-assign from position (1001+) */ + ethercatAddress: number +} + +/** + * Timeout settings for an EtherCAT slave. + */ +export interface EtherCATTimeouts { + /** SDO (Service Data Object) operation timeout in milliseconds */ + sdoTimeoutMs: number + /** Init to Pre-Operational state transition timeout in milliseconds */ + initToPreOpTimeoutMs: number + /** Pre-Op to Safe-Op and Safe-Op to Operational transition timeout in milliseconds */ + safeOpToOpTimeoutMs: number +} + +/** + * Watchdog settings for an EtherCAT slave. + */ +export interface EtherCATWatchdog { + /** Enable Sync Manager watchdog */ + smWatchdogEnabled: boolean + /** Sync Manager watchdog time in milliseconds */ + smWatchdogMs: number + /** Enable Process Data Interface (PDI) watchdog */ + pdiWatchdogEnabled: boolean + /** PDI watchdog time in milliseconds */ + pdiWatchdogMs: number +} + +/** + * Distributed Clocks (DC) settings for an EtherCAT slave. + * DC provides synchronized timing across all slaves in the network. + */ +export interface EtherCATDistributedClocks { + /** Enable Distributed Clocks for this slave */ + dcEnabled: boolean + /** Base sync unit cycle time in microseconds. 0 = use master cycle time */ + dcSyncUnitCycleUs: number + /** Enable SYNC0 pulse generation */ + dcSync0Enabled: boolean + /** SYNC0 cycle time in microseconds. 0 = use master cycle time */ + dcSync0CycleUs: number + /** SYNC0 shift/offset time in microseconds */ + dcSync0ShiftUs: number + /** Enable SYNC1 pulse generation */ + dcSync1Enabled: boolean + /** SYNC1 cycle time in microseconds. 0 = use master cycle time */ + dcSync1CycleUs: number + /** SYNC1 shift/offset time in microseconds */ + dcSync1ShiftUs: number +} + +/** + * Complete per-slave configuration for a configured EtherCAT device. + */ +export interface EtherCATSlaveConfig { + /** Identity verification during startup */ + startupChecks: EtherCATStartupChecks + /** Network addressing */ + addressing: EtherCATAddressing + /** Communication timeouts */ + timeouts: EtherCATTimeouts + /** Watchdog settings */ + watchdog: EtherCATWatchdog + /** Distributed Clocks (DC) settings */ + distributedClocks: EtherCATDistributedClocks +} + +// ===================== DEVICE MATCHING ===================== + +/** + * Quality of match between a scanned device and ESI device + */ +export type DeviceMatchQuality = 'exact' | 'partial' | 'none' + +/** + * A potential match for a scanned device + */ +export interface DeviceMatch { + /** ID of the repository item containing the matched device */ + repositoryItemId: string + /** Index of the device within the repository item */ + deviceIndex: number + /** Quality of the match */ + matchQuality: DeviceMatchQuality + /** The matched ESI device (lightweight summary) */ + esiDevice: ESIDeviceSummary +} + +/** + * A scanned device with its potential matches from the repository + */ +export interface ScannedDeviceMatch { + /** The scanned device from network discovery */ + device: { + position: number + name: string + vendor_id: number + product_code: number + revision: number + serial_number: number + state: string + input_bytes: number + output_bytes: number + } + /** List of potential matches from the repository */ + matches: DeviceMatch[] + /** The match selected by the user for addition */ + selectedMatch?: ESIDeviceRef +} diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index b951bc074..c01175914 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -81,6 +81,15 @@ export interface PlatformCapabilities { */ hasDirectProgramUpload: boolean + // --- Packages --- + + /** True if the app supports installing/managing VPP board packages. */ + + // --- EtherCAT --- + + /** True if the app supports EtherCAT device configuration and ESI repository. */ + hasEthercat: boolean + // --- Environment --- /** True when running in a development build (Vite DEV / webpack development mode). */ @@ -109,6 +118,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasAIAssistant: false, hasProxiedRuntimeConnection: false, hasDirectProgramUpload: false, + hasEthercat: true, isDevMode: false, } @@ -130,5 +140,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasAIAssistant: true, hasProxiedRuntimeConnection: true, hasDirectProgramUpload: true, + hasEthercat: false, isDevMode: false, } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index fe432af85..04d2fb5cc 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -37,6 +37,18 @@ * - runtimeLogout() */ +import type { + EtherCATRuntimeStatusResponse, + EtherCATScanRequest, + EtherCATScanResponse, + EtherCATServiceStatusResponse, + EtherCATTestRequest, + EtherCATTestResponse, + EtherCATValidateRequest, + EtherCATValidateResponse, + NetworkInterface, +} from '@root/types/ethercat' + import type { PlcStatus, RuntimeLogEntry, SerialPort, TimingStats, Unsubscribe } from './types' export interface LoginParams { @@ -138,4 +150,30 @@ export interface RuntimePort { * Returns unsubscribe function. */ onTokenRefreshed?(callback: (newToken: string) => void): Unsubscribe + + // --- EtherCAT Discovery (runtime device commands) --- + + /** Get network interfaces available on the runtime device. */ + getNetworkInterfaces?(): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> + + /** Check if the EtherCAT service is available on the runtime. */ + getEthercatServiceStatus?(): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> + + /** Scan for EtherCAT devices on a network interface. */ + scanEthercatDevices?( + request: EtherCATScanRequest, + ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> + + /** Test connection to a specific EtherCAT slave. */ + testEthercatConnection?( + request: EtherCATTestRequest, + ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> + + /** Validate an EtherCAT configuration against the runtime. */ + validateEthercatConfig?( + request: EtherCATValidateRequest, + ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> + + /** Get EtherCAT runtime status (plugin state, slave status, cycle metrics). */ + getEthercatRuntimeStatus?(): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 04117924f..404123765 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -57,6 +57,8 @@ export interface PLCTask { triggering: 'Cyclic' | 'Interrupt' interval: string priority: number + isSystemTask?: boolean + associatedDevice?: string } export interface PLCInstance { @@ -387,6 +389,26 @@ export interface PLCRemoteDevice { name: string protocol: RemoteDeviceProtocol modbusTcpConfig?: ModbusRemoteTcpConfig + ethercatConfig?: { + masterConfig?: { + enabled?: boolean + networkInterface: string + cycleTimeUs: number + watchdogTimeoutCycles?: number + taskPriority?: number + } + devices: Array<{ + id: string + name: string + channelMappings: Array<{ + channelId: string + iecLocation: string + userEdited: boolean + alias?: string + }> + [key: string]: unknown + }> + } } // --------------------------------------------------------------------------- diff --git a/src/middleware/shared/providers/platform-context.tsx b/src/middleware/shared/providers/platform-context.tsx index 5ebfd0a42..005e503a1 100644 --- a/src/middleware/shared/providers/platform-context.tsx +++ b/src/middleware/shared/providers/platform-context.tsx @@ -105,3 +105,7 @@ export function useCapabilities() { export function useAI() { return usePlatform().ai } + +export function useEsi() { + return usePlatform().esi +} diff --git a/src/middleware/shared/providers/types.ts b/src/middleware/shared/providers/types.ts index 454615698..b1cc10831 100644 --- a/src/middleware/shared/providers/types.ts +++ b/src/middleware/shared/providers/types.ts @@ -8,6 +8,7 @@ import type { AIPort } from '../ports/ai-port' import type { CompilerPort } from '../ports/compiler-port' import type { DebuggerPort } from '../ports/debugger-port' import type { DevicePort } from '../ports/device-port' +import type { EsiPort } from '../ports/esi-port' import type { OrchestratorPort } from '../ports/orchestrator-port' import type { PlatformCapabilities } from '../ports/platform-capabilities' import type { ProjectPort } from '../ports/project-port' @@ -32,5 +33,6 @@ export interface PlatformPorts { theme: ThemePort versionControl: VersionControlPort capabilities: PlatformCapabilities + esi?: EsiPort ai?: AIPort } diff --git a/src/types/PLC/units/task.ts b/src/types/PLC/units/task.ts new file mode 100644 index 000000000..1473eda03 --- /dev/null +++ b/src/types/PLC/units/task.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +const PLCTaskSchema = z.object({ + id: z.string().optional(), + name: z.string(), // TODO: This should be homologate. Concept: An unique identifier for the task object. + triggering: z.enum(['CYCLIC', 'INTERRUPT']), + interval: z.string(), // TODO: Must have a regex validation for this. Probably a new modal must be created to handle this. + priority: z.number(), // TODO: implement this validation. This must be a positive integer from 0 to 100 + isSystemTask: z.boolean().optional(), + associatedDevice: z.string().optional(), +}) + +type PLCTask = z.infer + +export { PLCTask, PLCTaskSchema } diff --git a/src/types/ethercat/index.ts b/src/types/ethercat/index.ts new file mode 100644 index 000000000..2fc16e149 --- /dev/null +++ b/src/types/ethercat/index.ts @@ -0,0 +1,338 @@ +/** + * EtherCAT Discovery Service Types + * + * Types for communication with the OpenPLC Runtime EtherCAT discovery endpoints. + * Based on the runtime's /api/discovery/* REST API. + */ + +// NOTE: ESI types (ESIDevice, ESIRepositoryItemLight, etc.) live in +// src/middleware/shared/ports/esi-types.ts (shared surface). Import them +// from '@root/middleware/shared/ports/esi-types' directly. + +// ===================== ENUMS ===================== + +/** + * Status codes returned by the discovery service + */ +export type EtherCATDiscoveryStatus = + | 'success' + | 'error' + | 'timeout' + | 'permission_denied' + | 'interface_not_found' + | 'not_available' + +/** + * EtherCAT slave states as reported by the discovery service + */ +export type EtherCATSlaveState = 'NONE' | 'INIT' | 'PRE-OP' | 'BOOT' | 'SAFE-OP' | 'OP' | 'UNKNOWN' + +// ===================== NETWORK INTERFACES ===================== + +/** + * Represents a network interface available for EtherCAT communication + */ +export interface NetworkInterface { + /** Interface name (e.g., "eth0", "enp3s0") */ + name: string + /** Human-readable description of the interface */ + description: string +} + +/** + * Response from GET /api/discovery/interfaces + */ +export interface NetworkInterfacesResponse { + status: 'success' | 'error' + interfaces: NetworkInterface[] + message?: string +} + +// ===================== ETHERCAT DEVICE ===================== + +/** + * Represents an EtherCAT slave device discovered on the network + */ +export interface EtherCATDevice { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name (e.g., "EK1100", "EL1008") */ + name: string + /** Vendor ID (e.g., 2 for Beckhoff) */ + vendor_id: number + /** Product code identifying the device type */ + product_code: number + /** Hardware revision number */ + revision: number + /** Serial number (0 if not available) */ + serial_number: number + /** Configured station address */ + config_address: number + /** Alias address (0 if not set) */ + alias: number + /** Current EtherCAT state */ + state: EtherCATSlaveState + /** AL (Application Layer) status code */ + al_status_code: number + /** Whether the device supports CoE (CANopen over EtherCAT) */ + has_coe: boolean + /** Number of input bytes */ + input_bytes: number + /** Number of output bytes */ + output_bytes: number +} + +// ===================== SERVICE STATUS ===================== + +/** + * Response from GET /api/discovery/ethercat/status + */ +export interface EtherCATServiceStatusResponse { + /** Whether the EtherCAT discovery service is available */ + available: boolean + /** Status message */ + message: string +} + +// ===================== SCAN ===================== + +/** + * Request body for POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanRequest { + /** Network interface to scan (e.g., "eth0") */ + interface: string + /** Scan timeout in milliseconds (default: 5000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/scan + */ +export interface EtherCATScanResponse { + status: EtherCATDiscoveryStatus + /** List of discovered EtherCAT devices */ + devices: EtherCATDevice[] + /** Human-readable result message */ + message: string + /** Time taken to complete the scan in milliseconds */ + scan_time_ms: number + /** Interface that was scanned */ + interface: string +} + +// ===================== CONNECTION TEST ===================== + +/** + * Request body for POST /api/discovery/ethercat/test + */ +export interface EtherCATTestRequest { + /** Network interface to use */ + interface: string + /** Position of the slave to test (1-indexed) */ + position: number + /** Connection test timeout in milliseconds (default: 3000) */ + timeout_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/test + */ +export interface EtherCATTestResponse { + status: EtherCATDiscoveryStatus + /** Whether the connection was successful */ + connected: boolean + /** Device information if connected */ + device?: EtherCATDevice + /** Human-readable result message */ + message: string + /** Response time in milliseconds */ + response_time_ms: number +} + +// ===================== VALIDATION ===================== + +/** + * PDO mapping entry for validation + */ +export interface PDOMappingEntry { + /** PDO address */ + address: string + /** Optional: data type */ + type?: string + /** Optional: bit offset */ + bit_offset?: number +} + +/** + * Slave configuration entry for validation requests. + * Not to be confused with EtherCATSlaveConfig from esi-types.ts (per-slave runtime config). + */ +export interface EtherCATValidationSlaveEntry { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Vendor ID */ + vendor_id?: number + /** Product code */ + product_code?: number + /** PDO mapping configuration */ + pdo_mapping?: Record +} + +/** + * Request body for POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateRequest { + /** Network interface to use */ + interface: string + /** List of slave configurations */ + slaves: EtherCATValidationSlaveEntry[] + /** Cycle time in milliseconds */ + cycle_time_ms?: number +} + +/** + * Response from POST /api/discovery/ethercat/validate + */ +export interface EtherCATValidateResponse { + /** Whether the configuration is valid */ + valid: boolean + /** List of validation errors (configuration is invalid if non-empty) */ + errors: string[] + /** List of warnings (configuration is valid but may have issues) */ + warnings: string[] +} + +// ===================== IPC RESPONSE WRAPPERS ===================== + +/** + * Generic IPC response wrapper for EtherCAT operations + */ +export interface EtherCATIPCResponse { + success: boolean + data?: T + error?: string +} + +/** + * IPC response for listing network interfaces + */ +export type ListInterfacesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for checking service status + */ +export type ServiceStatusIPCResponse = EtherCATIPCResponse + +/** + * IPC response for scanning EtherCAT devices + */ +export type ScanDevicesIPCResponse = EtherCATIPCResponse + +/** + * IPC response for testing connection to a device + */ +export type TestConnectionIPCResponse = EtherCATIPCResponse + +/** + * IPC response for validating configuration + */ +export type ValidateConfigIPCResponse = EtherCATIPCResponse + +// ===================== RUNTIME STATUS MONITORING ===================== + +/** + * EtherCAT plugin state machine states as reported by the runtime + */ +export type EtherCATPluginState = + | 'IDLE' + | 'SCANNING' + | 'CONFIGURING' + | 'TRANSITIONING' + | 'OPERATIONAL' + | 'RECOVERING' + | 'ERROR' + | 'STOPPED' + +/** + * Per-slave status snapshot from the runtime + */ +export interface EtherCATSlaveStatus { + /** Position in the EtherCAT chain (1-indexed) */ + position: number + /** Device name */ + name: string + /** Current EtherCAT AL state (e.g., "OP", "SAFE-OP", "INIT") */ + state: string + /** AL status code (0 = no error) */ + al_status_code: number + /** Cumulative error count for this slave */ + error_count: number + /** Whether the slave has an error condition */ + has_error: boolean +} + +/** + * Cycle performance metrics from the EtherCAT thread + */ +export interface EtherCATCycleMetrics { + /** Total cycles executed since last reset */ + cycle_count: number + /** Total WKC errors since last reset */ + wkc_error_count: number + /** Average cycle time in microseconds */ + avg_cycle_us: number + /** Maximum cycle time in microseconds */ + max_cycle_us: number + /** Maximum process data exchange time in microseconds */ + max_exchange_us: number + /** Current consecutive WKC error count */ + consecutive_wkc_errors: number + /** Number of recovery attempts since last successful recovery */ + recovery_attempts: number +} + +/** + * Per-master status snapshot (used in multi-master responses) + */ +export interface EtherCATMasterStatus { + /** Master name from configuration */ + name: string + /** Current plugin state */ + plugin_state: EtherCATPluginState + /** Number of configured slaves */ + slave_count: number + /** Expected working counter value */ + expected_wkc: number + /** Per-slave status array */ + slaves: EtherCATSlaveStatus[] + /** Cycle performance metrics */ + metrics: EtherCATCycleMetrics +} + +/** + * 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. + */ +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 +} + +/** + * IPC response for getting runtime status + */ +export type RuntimeStatusIPCResponse = EtherCATIPCResponse