From d2a9c87a258ab7c5104b16590424b06bbab4f4ed Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Thu, 15 Jan 2026 17:06:41 -0500
Subject: [PATCH 01/21] docs: Add OPC-UA server configuration design
documentation
Add comprehensive design documentation for the OPC-UA server
configuration feature including:
- Design overview with architecture and configuration flow
- UI screen specifications for all 5 configuration tabs
- JSON configuration mapping between editor and runtime formats
- Implementation phases with detailed deliverables
The OPC-UA server configuration will allow users to expose PLC
variables through the OPC-UA protocol with support for:
- Multiple security profiles (None, Sign, SignAndEncrypt)
- User authentication (password and certificate-based)
- Role-based access control (viewer, operator, engineer)
- Complex data types (structures, arrays, nested function blocks)
Co-Authored-By: Claude Opus 4.5
---
.../01-design-overview.md | 414 ++++++++
.../02-ui-screen-specifications.md | 818 +++++++++++++++
.../03-json-configuration-mapping.md | 959 +++++++++++++++++
.../04-implementation-phases.md | 973 ++++++++++++++++++
docs/opcua-server-configuration/README.md | 39 +
5 files changed, 3203 insertions(+)
create mode 100644 docs/opcua-server-configuration/01-design-overview.md
create mode 100644 docs/opcua-server-configuration/02-ui-screen-specifications.md
create mode 100644 docs/opcua-server-configuration/03-json-configuration-mapping.md
create mode 100644 docs/opcua-server-configuration/04-implementation-phases.md
create mode 100644 docs/opcua-server-configuration/README.md
diff --git a/docs/opcua-server-configuration/01-design-overview.md b/docs/opcua-server-configuration/01-design-overview.md
new file mode 100644
index 000000000..7edaa6da3
--- /dev/null
+++ b/docs/opcua-server-configuration/01-design-overview.md
@@ -0,0 +1,414 @@
+# OPC-UA Server Configuration - Design Overview
+
+## 1. Architecture Overview
+
+### 1.1 System Context
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ OpenPLC Editor │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ OPC-UA Configuration UI │ │
+│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
+│ │ │ General │ │ Security │ │ Users │ │ Address Space │ │ │
+│ │ │ Settings │ │ Profiles │ │ │ │ (Variables) │ │ │
+│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Project State (Zustand Store) │ │
+│ │ - OPC-UA config stored WITHOUT indices │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ (on compile) │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Compiler Module │ │
+│ │ 1. xml2st generates debug.c (with variable indices) │ │
+│ │ 2. Parse debug.c to get index mapping │ │
+│ │ 3. Resolve variable references → indices │ │
+│ │ 4. Generate opcua.json with resolved indices │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│ OpenPLC Runtime │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ OPC-UA Plugin │ │
+│ │ Reads opcua.json configuration │ │
+│ │ Exposes variables via OPC-UA protocol │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 1.2 Configuration Flow
+
+The OPC-UA configuration follows a two-phase approach:
+
+#### Phase 1: Configuration Time (No Compilation Required)
+
+1. User opens OPC-UA server configuration from the Servers panel
+2. User configures server settings, security profiles, users
+3. User selects variables from the project's POU tree structure
+4. User configures OPC-UA properties for each selected variable (node ID, permissions, etc.)
+5. Configuration is saved in the project file **without variable indices**
+
+#### Phase 2: Compilation Time (Index Resolution)
+
+1. User compiles the project
+2. xml2st generates `debug.c` with all variable indices
+3. Compiler parses `debug.c` using existing `parseDebugFile()` function
+4. Compiler matches user's selected variables to their debug indices
+5. Compiler generates `opcua.json` with fully resolved indices
+6. `opcua.json` is included in the compiled project package
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Configuration Flow Diagram │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌────────────────────┐ ┌────────────────────┐ │
+│ │ Project POUs │ │ OPC-UA Config UI │ │
+│ │ (Programs, FBs, │ ───► │ (Select Variables │ │
+│ │ Global Variables) │ │ Configure Props) │ │
+│ └────────────────────┘ └─────────┬──────────┘ │
+│ │ │
+│ ▼ │
+│ ┌────────────────────┐ │
+│ │ Project File │ │
+│ │ (Config WITHOUT │ │
+│ │ indices) │ │
+│ └─────────┬──────────┘ │
+│ │ │
+│ ▼ [User clicks Compile] │
+│ ┌────────────────────┐ │
+│ │ xml2st │ │
+│ │ (Generates debug.c)│ │
+│ └─────────┬──────────┘ │
+│ │ │
+│ ▼ │
+│ ┌────────────────────┐ ┌────────────────────┐ │
+│ │ parseDebugFile() │ ◄─── │ debug.c │ │
+│ │ (Extract indices) │ │ (Variable indices) │ │
+│ └─────────┬──────────┘ └────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌────────────────────┐ ┌────────────────────┐ │
+│ │ Resolve indices │ ───► │ opcua.json │ │
+│ │ for each variable │ │ (WITH indices) │ │
+│ └────────────────────┘ └────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+## 2. Integration with Existing Systems
+
+### 2.1 Debugger Integration
+
+The OPC-UA plugin uses the **same debug variable indices** as the OpenPLC debugger. This means:
+
+- We reuse `parseDebugFile()` from `src/renderer/utils/parse-debug-file.ts`
+- We reuse the path matching logic from the debugger implementation
+- Variable indices are consistent between debugger and OPC-UA access
+
+### 2.2 Project Structure Integration
+
+OPC-UA configuration follows the same patterns as Modbus and S7Comm:
+
+- Configuration stored in `project.data.servers[]` array
+- Protocol discriminated by `server.protocol === 'opcua'`
+- Configuration in `server.opcuaServerConfig` object
+
+### 2.3 Compiler Integration
+
+The compiler module generates `opcua.json` similar to how it generates `modbus_slave.json` and `s7comm.json`:
+
+```typescript
+// In compiler-module.ts
+async handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData) {
+ const opcuaConfig = generateOpcUaConfig(
+ projectData.servers,
+ parsedDebugVariables // From debug.c parsing
+ )
+ if (opcuaConfig) {
+ await writeFile(
+ join(sourceTargetFolderPath, 'conf', 'opcua.json'),
+ opcuaConfig
+ )
+ }
+}
+```
+
+## 3. Data Model
+
+### 3.1 Editor Data Model (Stored in Project - No Indices)
+
+```typescript
+interface OpcUaServerConfig {
+ server: {
+ enabled: boolean
+ name: string
+ applicationUri: string
+ productUri: string
+ bindAddress: string
+ port: number
+ endpointPath: string
+ }
+
+ securityProfiles: OpcUaSecurityProfile[]
+
+ security: {
+ serverCertificateStrategy: 'auto_self_signed' | 'custom'
+ serverCertificateCustom: string | null
+ serverPrivateKeyCustom: string | null
+ trustedClientCertificates: OpcUaTrustedCertificate[]
+ }
+
+ users: OpcUaUser[]
+
+ cycleTimeMs: number
+
+ addressSpace: {
+ namespaceUri: string
+ nodes: OpcUaNodeConfig[] // Hierarchical tree of selected nodes
+ }
+}
+
+// Variable/Node configuration WITHOUT index
+interface OpcUaNodeConfig {
+ id: string // UUID
+
+ // Reference to PLC variable (resolved to index at compile time)
+ pouName: string // "main", "GVL", "FB_Motor"
+ variablePath: string // "MOTOR_SPEED", "TON0.ET", "SENSOR.temperature"
+ variableType: string // IEC type: "INT", "BOOL", "REAL", etc.
+
+ // OPC-UA node configuration
+ nodeId: string // "PLC.Main.MotorSpeed"
+ browseName: string
+ displayName: string
+ description: string
+ initialValue: any
+ permissions: {
+ viewer: 'r' | 'w' | 'rw'
+ operator: 'r' | 'w' | 'rw'
+ engineer: 'r' | 'w' | 'rw'
+ }
+
+ // For complex types
+ nodeType: 'variable' | 'structure' | 'array'
+ children?: OpcUaNodeConfig[] // For structures: field configs
+ arrayLength?: number // For arrays: element count
+}
+```
+
+### 3.2 Runtime Data Model (Generated JSON - With Indices)
+
+The runtime expects a JSON array with the following structure:
+
+```typescript
+interface OpcUaRuntimeConfig {
+ name: string // "opcua_server"
+ protocol: "OPC-UA"
+ config: {
+ server: {
+ name: string
+ application_uri: string
+ product_uri: string
+ endpoint_url: string // Full URL: opc.tcp://host:port/path
+ security_profiles: RuntimeSecurityProfile[]
+ }
+ security: {
+ server_certificate_strategy: string
+ server_certificate_custom: string | null
+ server_private_key_custom: string | null
+ trusted_client_certificates: { id: string; pem: string }[]
+ }
+ users: RuntimeUser[]
+ cycle_time_ms: number
+ address_space: {
+ namespace_uri: string
+ variables: RuntimeVariable[] // With resolved indices
+ structures: RuntimeStructure[] // With resolved field indices
+ arrays: RuntimeArray[] // With resolved start indices
+ }
+ }
+}
+```
+
+## 4. Variable Hierarchy Support
+
+### 4.1 Hierarchical Variable Structure
+
+PLC programs can have deeply nested variable structures:
+
+```
+Program: main
+├── MOTOR_SPEED (INT) → Simple variable
+├── IS_RUNNING (BOOL) → Simple variable
+├── MOTOR_CONTROLLER (FB_MotorControl) → Function Block instance
+│ ├── ENABLE (BOOL) → FB input
+│ ├── SPEED_SETPOINT (INT) → FB input
+│ ├── ACTUAL_SPEED (INT) → FB output
+│ └── PID_CTRL (FB_PID) → Nested FB
+│ ├── KP (REAL)
+│ ├── KI (REAL)
+│ └── OUTPUT (REAL)
+├── SENSOR_DATA (T_SensorData) → Structure
+│ ├── temperature (REAL) → Struct field
+│ ├── pressure (REAL) → Struct field
+│ └── status (T_Status) → Nested structure
+│ ├── is_valid (BOOL)
+│ └── error_code (INT)
+├── TEMPERATURES (ARRAY[1..10] OF REAL) → Array
+└── SENSOR_ARRAY (ARRAY[1..5] OF T_SensorData) → Array of structures
+ └── [1] (T_SensorData)
+ ├── temperature (REAL)
+ └── ...
+```
+
+### 4.2 Debug Path Format
+
+The debugger uses specific path formats for each variable type:
+
+| Type | Debug Path Format | Example |
+|------|-------------------|---------|
+| Simple | `RES0__INSTANCE.VARNAME` | `RES0__INSTANCE0.MOTOR_SPEED` |
+| FB Variable | `RES0__INSTANCE.FBVAR.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.ENABLE` |
+| Nested FB | `RES0__INSTANCE.FB1.FB2.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.PID_CTRL.KP` |
+| Struct Field | `RES0__INSTANCE.VAR.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.temperature` |
+| Nested Struct | `RES0__INSTANCE.VAR.value.NESTED.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.status.value.is_valid` |
+| Array Element | `RES0__INSTANCE.VAR.value.table[i]` | `RES0__INSTANCE0.TEMPERATURES.value.table[0]` |
+| Global | `CONFIG0__VARNAME` | `CONFIG0__SYSTEM_STATE` |
+
+### 4.3 Index Resolution at Compile Time
+
+```typescript
+const resolveVariableIndex = (
+ pouName: string,
+ variablePath: string,
+ debugVariables: DebugVariable[]
+): number => {
+ // Build the debug path based on variable type
+ const debugPath = buildDebugPath(pouName, variablePath)
+
+ // Find matching entry in parsed debug.c
+ const debugVar = debugVariables.find(dv =>
+ dv.name.toUpperCase() === debugPath.toUpperCase()
+ )
+
+ if (!debugVar) {
+ throw new Error(`Cannot resolve index for ${pouName}:${variablePath}`)
+ }
+
+ return debugVar.index
+}
+
+const buildDebugPath = (pouName: string, variablePath: string): string => {
+ // Handle global variables
+ if (pouName === 'GVL' || pouName === 'CONFIG') {
+ return `CONFIG0__${variablePath.toUpperCase()}`
+ }
+
+ // Handle program/FB instance variables
+ // The instance name format depends on resource configuration
+ return `RES0__INSTANCE0.${variablePath.toUpperCase()}`
+}
+```
+
+## 5. Security Model
+
+### 5.1 Security Profiles
+
+OPC-UA supports multiple security profiles that can be enabled simultaneously:
+
+| Profile | Security Policy | Security Mode | Auth Methods |
+|---------|-----------------|---------------|--------------|
+| Insecure | None | None | Anonymous |
+| Signed | Basic256Sha256 | Sign | Username, Certificate |
+| Encrypted | Basic256Sha256 | SignAndEncrypt | Username, Certificate |
+
+### 5.2 User Roles and Permissions
+
+Three user roles with different access levels:
+
+| Role | Description | Default Variable Access |
+|------|-------------|------------------------|
+| Viewer | Read-only monitoring | Read only |
+| Operator | Can modify operational variables | Read/Write (configurable) |
+| Engineer | Full administrative access | Read/Write |
+
+Each variable can have custom permissions per role.
+
+## 6. File Structure
+
+```
+src/
+├── types/PLC/
+│ └── open-plc.ts # Add OPC-UA type schemas
+├── utils/
+│ └── opcua/
+│ ├── generate-opcua-config.ts # JSON generator with index resolution
+│ ├── bcrypt-utils.ts # Password hashing utilities
+│ └── index.ts
+├── renderer/
+│ ├── utils/
+│ │ └── parse-debug-file.ts # REUSE for index resolution
+│ ├── store/slices/project/
+│ │ └── slice.ts # Add OPC-UA actions
+│ └── components/_features/[workspace]/editor/
+│ └── server/
+│ └── opcua-server/
+│ ├── index.tsx # Main tabbed component
+│ ├── tabs/
+│ │ ├── general-settings.tsx
+│ │ ├── security-profiles.tsx
+│ │ ├── certificates.tsx
+│ │ ├── users.tsx
+│ │ └── address-space.tsx
+│ └── components/
+│ ├── variable-tree.tsx
+│ ├── variable-config-modal.tsx
+│ └── ...
+└── main/modules/compiler/
+ └── compiler-module.ts # Add OPC-UA JSON generation
+```
+
+## 7. Key Design Decisions
+
+### 7.1 Index Resolution at Compile Time
+
+**Decision**: Store variable references (pouName + variablePath) in project, resolve indices only during compilation.
+
+**Rationale**:
+- Users can configure OPC-UA without building first
+- Indices may change when program is modified
+- Consistent with how debugger works
+
+### 7.2 Reuse Debugger Infrastructure
+
+**Decision**: Reuse `parseDebugFile()` and path matching logic from debugger.
+
+**Rationale**:
+- OPC-UA plugin uses same debug indices as debugger
+- Proven, tested code
+- Maintains consistency
+
+### 7.3 Tabbed UI Interface
+
+**Decision**: Use tabbed interface instead of single scrollable page.
+
+**Rationale**:
+- OPC-UA configuration is significantly more complex than Modbus/S7Comm
+- Tabs help organize related settings
+- Reduces cognitive load on users
+
+### 7.4 Tree-Based Variable Selection
+
+**Decision**: Display all project variables in a tree structure, allowing selection of individual leaves or entire branches (structures/arrays).
+
+**Rationale**:
+- Supports deeply nested hierarchies (FB inside FB inside Program)
+- Intuitive selection of complex types
+- Consistent with how POUs are structured
diff --git a/docs/opcua-server-configuration/02-ui-screen-specifications.md b/docs/opcua-server-configuration/02-ui-screen-specifications.md
new file mode 100644
index 000000000..353ed3fb8
--- /dev/null
+++ b/docs/opcua-server-configuration/02-ui-screen-specifications.md
@@ -0,0 +1,818 @@
+# OPC-UA Server Configuration - UI Screen Specifications
+
+## 1. Overall Layout
+
+The OPC-UA server configuration uses a **tabbed interface** with 5 main tabs:
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ OPC-UA Server Configuration [X] Close │
+├─────────────────────────────────────────────────────────────────────────┤
+│ ┌──────────┬──────────┬──────────┬──────────┬───────────────────────┐ │
+│ │ General │ Security │ Users │ Certifi- │ Address Space │ │
+│ │ Settings │ Profiles │ │ cates │ │ │
+│ └──────────┴──────────┴──────────┴──────────┴───────────────────────┘ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ │ │
+│ │ [Active Tab Content] │ │
+│ │ │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 2. Tab 1: General Settings
+
+### 2.1 Layout
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ General Settings │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Enable OPC-UA Server [Toggle: Off] │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Server Identity │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Server Name │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ OpenPLC OPC UA Server │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Application URI │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ urn:openplc:opcua:server │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Unique identifier for this application instance) │
+│ │
+│ Product URI │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ urn:openplc:runtime │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Identifier for the product type) │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Network Configuration │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Bind Address Port │
+│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
+│ │ 0.0.0.0 [▼]│ │ 4840 │ │
+│ └─────────────────────────┘ └─────────────────────────┘ │
+│ (0.0.0.0 = all interfaces) (Default: 4840) │
+│ │
+│ Endpoint Path │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ /openplc/opcua │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Full Endpoint URL: │
+│ opc.tcp://0.0.0.0:4840/openplc/opcua │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Performance │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Synchronization Cycle Time (ms) │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 100 │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Lower values = faster updates, higher CPU usage. Range: 10-10000) │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 2.2 Fields Specification
+
+| Field | Type | Default | Validation | Description |
+|-------|------|---------|------------|-------------|
+| enabled | boolean | false | - | Master switch for OPC-UA server |
+| name | string | "OpenPLC OPC UA Server" | max 128 chars | Human-readable server name |
+| applicationUri | string | "urn:openplc:opcua:server" | valid URI | Unique application identifier |
+| productUri | string | "urn:openplc:runtime" | valid URI | Product type identifier |
+| bindAddress | string | "0.0.0.0" | valid IP | Network interface to bind |
+| port | number | 4840 | 1-65535 | TCP port number |
+| endpointPath | string | "/openplc/opcua" | starts with / | URL path component |
+| cycleTimeMs | number | 100 | 10-10000 | Sync interval in milliseconds |
+
+---
+
+## 3. Tab 2: Security Profiles
+
+### 3.1 Layout
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Security Profiles │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Configure which security profiles clients can use to connect. │
+│ At least one profile must be enabled. │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [+] Add Security Profile │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [✓] Insecure (No Security) [Edit] [Del] │ │
+│ │ Policy: None | Mode: None │ │
+│ │ Authentication: Anonymous │ │
+│ │ ⚠ Warning: No encryption or authentication. Use only for │ │
+│ │ development/testing. │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [ ] Signed [Edit] [Del] │ │
+│ │ Policy: Basic256Sha256 | Mode: Sign │ │
+│ │ Authentication: Username, Certificate │ │
+│ │ Messages are signed but not encrypted. │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [ ] Signed & Encrypted [Edit] [Del] │ │
+│ │ Policy: Basic256Sha256 | Mode: SignAndEncrypt │ │
+│ │ Authentication: Username, Certificate │ │
+│ │ Full security: messages are signed and encrypted. │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Note: Security profiles with Certificate authentication require │
+│ trusted client certificates to be configured in the Certificates tab. │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 3.2 Add/Edit Security Profile Modal
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Add Security Profile [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Profile Name │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ signed_encrypted │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Unique identifier for this profile) │
+│ │
+│ Enabled [Toggle: On] │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Security Settings │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ Security Policy │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Basic256Sha256 [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ Options: None, Basic128Rsa15, Basic256, Basic256Sha256 │
+│ │
+│ Security Mode │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ SignAndEncrypt [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ Options: None, Sign, SignAndEncrypt │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Authentication Methods │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [ ] Anonymous │
+│ (Only available with Security Policy: None) │
+│ │
+│ [✓] Username / Password │
+│ (Users must be configured in the Users tab) │
+│ │
+│ [✓] Certificate │
+│ (Client certificates must be added to trusted list) │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Save Profile] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 3.3 Validation Rules
+
+- At least one security profile must be enabled
+- "Anonymous" authentication only available when Security Policy = "None"
+- Security Mode "None" requires Security Policy "None"
+- Profile names must be unique
+
+---
+
+## 4. Tab 3: Users
+
+### 4.1 Layout
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ User Accounts │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Configure user accounts for OPC-UA client authentication. │
+│ At least one user is required when Username authentication is enabled. │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [+] Add User │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 👤 viewer [Edit] [Del] │ │
+│ │ Type: Password Authentication │ │
+│ │ Role: Viewer (Read-only access) │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 👤 operator [Edit] [Del] │ │
+│ │ Type: Password Authentication │ │
+│ │ Role: Operator (Read/Write per variable permissions) │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 👤 engineer [Edit] [Del] │ │
+│ │ Type: Password Authentication │ │
+│ │ Role: Engineer (Full access) │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 🔐 engineer_cert [Edit] [Del] │ │
+│ │ Type: Certificate Authentication │ │
+│ │ Certificate: engineer_client │ │
+│ │ Role: Engineer (Full access) │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Role Descriptions: │
+│ • Viewer: Can only read variable values (monitoring) │
+│ • Operator: Can read/write based on per-variable permissions │
+│ • Engineer: Full administrative access to all variables │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 4.2 Add/Edit User Modal
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Add User [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Authentication Type │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Password [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ Options: Password, Certificate │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Password Authentication (visible when type=Password)│
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Username │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ operator │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Password │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ •••••••• [👁]│ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Confirm Password │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ •••••••• [👁]│ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Certificate Authentication (visible when type=Certificate)│
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Client Certificate │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ engineer_client [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Select from trusted certificates configured in Certificates tab) │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ User Role │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Role │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Operator [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ Options: Viewer, Operator, Engineer │
+│ │
+│ Role Permissions: │
+│ • Viewer: Read-only access to all variables │
+│ • Operator: Read/Write based on per-variable permission settings │
+│ • Engineer: Full administrative access │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Save User] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 4.3 Password Handling
+
+Passwords are hashed using bcrypt before storage:
+
+```typescript
+import bcrypt from 'bcryptjs'
+
+const hashPassword = async (password: string): Promise => {
+ const salt = await bcrypt.genSalt(12)
+ return bcrypt.hash(password, salt)
+}
+```
+
+The plain text password is **never** stored in the project file.
+
+---
+
+## 5. Tab 4: Certificates
+
+### 5.1 Layout
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Certificate Management │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Server Certificate │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Certificate Strategy │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Auto-generate Self-Signed [▼] │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ Options: Auto-generate Self-Signed, Use Custom Certificate │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Custom Certificate (visible when strategy=custom) │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ Server Certificate (PEM format) │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ -----BEGIN CERTIFICATE----- │ │
+│ │ MIIEpDCCAowCCQC7... │ │
+│ │ │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ [Browse File...] │
+│ │
+│ Server Private Key (PEM format) │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ -----BEGIN PRIVATE KEY----- │ │
+│ │ MIIEvgIBADANBg... │ │
+│ │ │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ [Browse File...] │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Trusted Client Certificates │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Client certificates that are trusted for certificate authentication. │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ [+] Add Trusted Certificate │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 📜 engineer_client [View] [Del] │ │
+│ │ Subject: CN=Engineer Client, O=MyCompany │ │
+│ │ Valid: 2024-01-01 to 2025-12-31 │ │
+│ │ Fingerprint: A1:B2:C3:D4:E5:F6:... │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 📜 scada_client [View] [Del] │ │
+│ │ Subject: CN=SCADA System, O=Industrial Corp │ │
+│ │ Valid: 2024-06-01 to 2026-06-01 │ │
+│ │ Fingerprint: 12:34:56:78:9A:BC:... │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 5.2 Add Trusted Certificate Modal
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Add Trusted Client Certificate [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Certificate ID │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ engineer_client │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Unique identifier used to reference this certificate in user config) │
+│ │
+│ Certificate (PEM format) │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ -----BEGIN CERTIFICATE----- │ │
+│ │ MIIEpDCCAowCCQC7... │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ [Browse File...] │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Certificate Details (parsed from PEM) │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ Subject: CN=Engineer Client, O=MyCompany │
+│ Issuer: CN=My CA, O=MyCompany │
+│ Valid From: 2024-01-01 00:00:00 UTC │
+│ Valid To: 2025-12-31 23:59:59 UTC │
+│ Fingerprint: A1:B2:C3:D4:E5:F6:78:90:AB:CD:EF:12:34:56:78:90 │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Add Certificate] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 6. Tab 5: Address Space (Variable Selection)
+
+### 6.1 Overview
+
+The Address Space tab allows users to select which PLC variables to expose via OPC-UA. It displays **all project variables in a hierarchical tree structure**, supporting:
+
+- Simple variables (INT, BOOL, REAL, etc.)
+- Function Block instances with their internal variables
+- Nested Function Blocks (FB inside FB inside Program)
+- Structures with fields
+- Nested Structures (struct inside struct)
+- Arrays of any type
+- Arrays of structures
+
+### 6.2 Main Layout
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Address Space Configuration │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Namespace URI │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ urn:openplc:opcua:namespace │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ Select variables to expose via OPC-UA. You can select individual │
+│ variables (leaves) or entire structures/arrays/function blocks │
+│ (branches). Variable indices are resolved automatically during │
+│ project compilation. │
+│ │
+│ ┌────────────────────────────────┬────────────────────────────────┐ │
+│ │ Available PLC Variables │ Selected for OPC-UA │ │
+│ │ (from project) │ │ │
+│ ├────────────────────────────────┼────────────────────────────────┤ │
+│ │ ▼ 📁 main (Program) │ ▼ PLC.Main.MotorSpeed │ │
+│ │ ├── ☐ MOTOR_SPEED (INT) │ │ main:MOTOR_SPEED │ │
+│ │ ├── ☐ IS_RUNNING (BOOL) │ │ Type: INT │ │
+│ │ ├── ▶ ☐ TON0 (TON) │ │ Perms: V:r O:rw E:rw │ │
+│ │ ├── ▶ ☐ MOTOR_CTRL (FB_Mot) │ ├────────────────────────────│ │
+│ │ ├── ▶ ☐ SENSOR (T_Sensor) │ ▼ PLC.Main.MOTOR_CTRL │ │
+│ │ └── ▶ ☐ TEMPS (ARRAY[1..10])│ │ main:MOTOR_CTRL (FB) │ │
+│ │ ▼ 📁 GVL (Global Variables) │ │ [Entire FB selected] │ │
+│ │ ├── ☐ SYSTEM_STATE (INT) │ ├── ENABLE (BOOL) │ │
+│ │ └── ☐ ERROR_CODE (DINT) │ ├── SPEED_SP (INT) │ │
+│ │ ► 📁 FB_MotorControl (FB) │ └── PID_CTRL.OUTPUT (REAL) │ │
+│ │ ► 📁 FB_PID (FB) │ ─ PLC.GVL.SystemState │ │
+│ │ │ │ GVL:SYSTEM_STATE │ │
+│ │ │ │ Type: INT │ │
+│ │ │ │ │
+│ ├────────────────────────────────┼────────────────────────────────┤ │
+│ │ [Filter: ________] │ │ │
+│ │ │ [↑] [↓] [Edit] [Remove] │ │
+│ │ [Add Selected →] │ │ │
+│ └────────────────────────────────┴────────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 6.3 Variable Tree Structure
+
+The left panel shows all project variables in a tree:
+
+```
+▼ 📁 main (Program)
+ ├── MOTOR_SPEED (INT) ← Simple variable (leaf)
+ ├── IS_RUNNING (BOOL) ← Simple variable (leaf)
+ ├── ▼ TON0 (TON) ← Standard FB instance
+ │ ├── IN (BOOL) ← FB input
+ │ ├── PT (TIME) ← FB input
+ │ ├── Q (BOOL) ← FB output
+ │ └── ET (TIME) ← FB output
+ ├── ▼ MOTOR_CTRL (FB_MotorControl) ← Custom FB instance
+ │ ├── ENABLE (BOOL) ← FB variable
+ │ ├── SPEED_SETPOINT (INT) ← FB variable
+ │ ├── ACTUAL_SPEED (INT) ← FB variable
+ │ └── ▼ PID_CTRL (FB_PID) ← Nested FB (2 levels deep)
+ │ ├── KP (REAL)
+ │ ├── KI (REAL)
+ │ ├── KD (REAL)
+ │ └── OUTPUT (REAL)
+ ├── ▼ SENSOR (T_SensorData) ← Structure instance
+ │ ├── temperature (REAL) ← Struct field
+ │ ├── pressure (REAL) ← Struct field
+ │ └── ▼ status (T_Status) ← Nested structure
+ │ ├── is_valid (BOOL)
+ │ └── error_code (INT)
+ ├── ▼ TEMPS (ARRAY[1..10] OF REAL) ← Array
+ │ ├── [1] (REAL)
+ │ ├── [2] (REAL)
+ │ └── ... (10 elements)
+ └── ▼ SENSORS (ARRAY[1..5] OF T_SensorData) ← Array of structures
+ ├── ▼ [1] (T_SensorData)
+ │ ├── temperature (REAL)
+ │ ├── pressure (REAL)
+ │ └── ▼ status (T_Status)
+ │ └── ...
+ └── ... (5 elements)
+
+▼ 📁 GVL (Global Variables)
+ ├── SYSTEM_STATE (INT)
+ ├── ERROR_CODE (DINT)
+ └── ▼ CONFIG (T_SystemConfig)
+ └── ...
+
+► 📁 FB_MotorControl (Function Block Definition)
+ └── (Shows FB type definition - variables not directly selectable)
+
+► 📁 FB_PID (Function Block Definition)
+ └── (Shows FB type definition)
+```
+
+### 6.4 Selection Behavior
+
+Users can select:
+
+1. **Individual Leaves**: Select a single simple variable
+2. **Entire Branches**: Select a structure, array, or FB to include all children
+
+```
+Selection Examples:
+
+☐ MOTOR_SPEED (INT) → Selects only this variable
+☑ MOTOR_CTRL (FB_MotorControl) → Selects entire FB with all nested variables
+ ☑ ENABLE (BOOL) (auto-selected as part of parent)
+ ☑ SPEED_SETPOINT (INT) (auto-selected as part of parent)
+ ☑ PID_CTRL (FB_PID) (auto-selected as part of parent)
+ ☑ KP (REAL) (auto-selected, nested)
+ ☑ OUTPUT (REAL) (auto-selected, nested)
+
+Partial selection is NOT allowed - either select the parent or individual children
+```
+
+### 6.5 Edit Variable/Node Modal
+
+When editing a selected variable/node:
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Configure OPC-UA Node [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ PLC Variable: main:MOTOR_SPEED (INT) │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ OPC-UA Node Configuration │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Node ID │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ PLC.Main.MotorSpeed │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ (Unique identifier in OPC-UA address space) │
+│ │
+│ Browse Name │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ MotorSpeed │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Display Name │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Motor Speed │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Description │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Current motor speed in RPM │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Initial Value │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ 0 │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Access Permissions │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Role Access Level │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Viewer [Read Only ▼] │
+│ Operator [Read/Write ▼] │
+│ Engineer [Read/Write ▼] │
+│ │
+│ Options: Read Only, Write Only, Read/Write │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Save] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 6.6 Edit Structure/FB Modal
+
+When editing a structure or function block (branch selection):
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Configure OPC-UA Structure Node [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ PLC Variable: main:SENSOR (T_SensorData) │
+│ Type: Structure │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Structure Node Configuration │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Node ID │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ PLC.Main.Sensor │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Browse Name │ Display Name │
+│ ┌────────────────────┼────────────────────────────────────────────┐ │
+│ │ Sensor │ Sensor Data │ │
+│ └────────────────────┴────────────────────────────────────────────┘ │
+│ │
+│ Description │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Main sensor data structure │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Field Configuration │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Field │ Type │ Initial │ Viewer │ Operator │ Engr │ │
+│ ├─────────────────┼───────┼─────────┼────────┼──────────┼───────┤ │
+│ │ temperature │ REAL │ 0.0 │ r │ r │ rw │ │
+│ │ pressure │ REAL │ 0.0 │ r │ r │ rw │ │
+│ │ status.is_valid │ BOOL │ false │ r │ r │ r │ │
+│ │ status.error_co │ INT │ 0 │ r │ r │ rw │ │
+│ └─────────────────┴───────┴─────────┴────────┴──────────┴───────┘ │
+│ │
+│ [Edit Field Permissions...] │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Save] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 6.7 Edit Array Modal
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Configure OPC-UA Array Node [X] │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ PLC Variable: main:TEMPS (ARRAY[1..10] OF REAL) │
+│ Type: Array │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Array Node Configuration │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Node ID │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ PLC.Main.Temperatures │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Browse Name │ Display Name │
+│ ┌────────────────────┼────────────────────────────────────────────┐ │
+│ │ Temperatures │ Temperature Readings │ │
+│ └────────────────────┴────────────────────────────────────────────┘ │
+│ │
+│ Description │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Array of temperature sensor values │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ Array Properties │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ Element Type: REAL │
+│ Array Length: 10 (indices 1 to 10) │
+│ Initial Value: 0.0 (applied to all elements) │
+│ │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ Access Permissions (applies to entire array) │
+│ ═══════════════════════════════════════════════════════════════════ │
+│ │
+│ Viewer: [Read Only ▼] │
+│ Operator: [Read/Write ▼] │
+│ Engineer: [Read/Write ▼] │
+│ │
+│ ───────────────────────────────────────────────────────────────────── │
+│ │
+│ [Cancel] [Save] │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 6.8 Deep Nesting Example
+
+For deeply nested structures (FB inside FB, struct inside struct), the tree expands fully:
+
+```
+▼ 📁 main (Program)
+ └── ▼ PROCESS_CTRL (FB_ProcessController)
+ ├── ENABLE (BOOL)
+ ├── MODE (INT)
+ └── ▼ HEATER_CTRL (FB_HeaterController)
+ ├── SETPOINT (REAL)
+ ├── ACTUAL (REAL)
+ └── ▼ PID (FB_PID)
+ ├── KP (REAL)
+ ├── KI (REAL)
+ ├── KD (REAL)
+ ├── ERROR (REAL)
+ └── OUTPUT (REAL)
+
+When user selects PROCESS_CTRL, all nested items are included.
+The variablePath for deepest item would be:
+ "PROCESS_CTRL.HEATER_CTRL.PID.OUTPUT"
+```
+
+---
+
+## 7. Responsive Design Considerations
+
+### 7.1 Minimum Window Size
+
+- Minimum width: 800px
+- Minimum height: 600px
+
+### 7.2 Panel Resizing
+
+The Address Space tab should support resizable panels:
+- Left panel (Available Variables): min 250px, max 50%
+- Right panel (Selected Variables): min 250px, remaining space
+
+### 7.3 Tree Virtualization
+
+For projects with many variables, implement virtualized tree rendering:
+- Use react-window or similar for large lists
+- Lazy-load children when expanding nodes
+- Show loading indicator while expanding complex FBs
+
+---
+
+## 8. Accessibility
+
+### 8.1 Keyboard Navigation
+
+- Tab: Move between panels and controls
+- Arrow keys: Navigate tree structure
+- Space: Toggle checkbox selection
+- Enter: Expand/collapse tree node or open edit modal
+
+### 8.2 Screen Reader Support
+
+- Proper ARIA labels on all interactive elements
+- Tree structure uses role="tree" and role="treeitem"
+- Selection state announced: "Selected" / "Not selected"
+
+### 8.3 Color Contrast
+
+- All text meets WCAG 2.1 AA contrast requirements
+- Icons have sufficient contrast or text alternatives
+- Error states use both color and icon indicators
diff --git a/docs/opcua-server-configuration/03-json-configuration-mapping.md b/docs/opcua-server-configuration/03-json-configuration-mapping.md
new file mode 100644
index 000000000..23a3412b0
--- /dev/null
+++ b/docs/opcua-server-configuration/03-json-configuration-mapping.md
@@ -0,0 +1,959 @@
+# OPC-UA Server Configuration - JSON Configuration Mapping
+
+This document describes how the editor configuration maps to the runtime JSON format expected by the OPC-UA plugin.
+
+## 1. Overview
+
+The OPC-UA configuration undergoes transformation during compilation:
+
+```
+┌─────────────────────────┐ ┌─────────────────────────┐
+│ Editor Format │ │ Runtime Format │
+│ (Project File) │ ──► │ (opcua.json) │
+├─────────────────────────┤ ├─────────────────────────┤
+│ • camelCase properties │ │ • snake_case properties │
+│ • Variable references │ │ • Resolved indices │
+│ • No indices │ │ • Full variable info │
+│ • TypeScript types │ │ • JSON structure │
+└─────────────────────────┘ └─────────────────────────┘
+```
+
+## 2. Editor Data Model (Stored in Project)
+
+### 2.1 Complete Type Definitions
+
+```typescript
+// ═══════════════════════════════════════════════════════════════════════
+// Security Profile
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaSecurityProfile {
+ id: string // UUID
+ name: string // Profile identifier (e.g., "insecure", "signed")
+ enabled: boolean
+ securityPolicy: 'None' | 'Basic128Rsa15' | 'Basic256' | 'Basic256Sha256'
+ securityMode: 'None' | 'Sign' | 'SignAndEncrypt'
+ authMethods: ('Anonymous' | 'Username' | 'Certificate')[]
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// Trusted Certificate
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaTrustedCertificate {
+ id: string // Certificate identifier (referenced by users)
+ pem: string // PEM-encoded certificate content
+ // Derived fields for display (parsed from PEM)
+ subject?: string
+ validFrom?: string
+ validTo?: string
+ fingerprint?: string
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// User Account
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaUser {
+ id: string // UUID
+ type: 'password' | 'certificate'
+ // For password auth
+ username: string | null
+ passwordHash: string | null // bcrypt hash (never plain text)
+ // For certificate auth
+ certificateId: string | null // References trustedCertificate.id
+ // Common
+ role: 'viewer' | 'operator' | 'engineer'
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// Permissions
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaPermissions {
+ viewer: 'r' | 'w' | 'rw'
+ operator: 'r' | 'w' | 'rw'
+ engineer: 'r' | 'w' | 'rw'
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// Address Space Node (Variable/Structure/Array)
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaNodeConfig {
+ id: string // UUID for tracking
+
+ // PLC variable reference (resolved to index at compile time)
+ pouName: string // "main", "GVL", "FB_Motor"
+ variablePath: string // "MOTOR_SPEED", "CTRL.PID.OUTPUT"
+ variableType: string // IEC type
+
+ // OPC-UA configuration
+ nodeId: string // OPC-UA node identifier
+ browseName: string
+ displayName: string
+ description: string
+ initialValue: boolean | number | string
+ permissions: OpcUaPermissions
+
+ // Type classification
+ nodeType: 'variable' | 'structure' | 'array'
+
+ // For structures: nested field configurations
+ fields?: OpcUaFieldConfig[]
+
+ // For arrays: element count (derived from type)
+ arrayLength?: number
+ elementType?: string
+}
+
+interface OpcUaFieldConfig {
+ fieldPath: string // "temperature", "status.is_valid"
+ displayName: string
+ initialValue: boolean | number | string
+ permissions: OpcUaPermissions
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// Complete Server Configuration
+// ═══════════════════════════════════════════════════════════════════════
+
+interface OpcUaServerConfig {
+ server: {
+ enabled: boolean
+ name: string
+ applicationUri: string
+ productUri: string
+ bindAddress: string
+ port: number
+ endpointPath: string
+ }
+
+ securityProfiles: OpcUaSecurityProfile[]
+
+ security: {
+ serverCertificateStrategy: 'auto_self_signed' | 'custom'
+ serverCertificateCustom: string | null
+ serverPrivateKeyCustom: string | null
+ trustedClientCertificates: OpcUaTrustedCertificate[]
+ }
+
+ users: OpcUaUser[]
+
+ cycleTimeMs: number
+
+ addressSpace: {
+ namespaceUri: string
+ nodes: OpcUaNodeConfig[]
+ }
+}
+```
+
+## 3. Runtime JSON Format (Generated)
+
+### 3.1 Complete Structure
+
+The runtime expects a JSON array containing plugin configurations:
+
+```json
+[
+ {
+ "name": "opcua_server",
+ "protocol": "OPC-UA",
+ "config": {
+ "server": {
+ "name": "string",
+ "application_uri": "string",
+ "product_uri": "string",
+ "endpoint_url": "string",
+ "security_profiles": [
+ {
+ "name": "string",
+ "enabled": true,
+ "security_policy": "string",
+ "security_mode": "string",
+ "auth_methods": ["string"]
+ }
+ ]
+ },
+ "security": {
+ "server_certificate_strategy": "string",
+ "server_certificate_custom": "string|null",
+ "server_private_key_custom": "string|null",
+ "trusted_client_certificates": [
+ {
+ "id": "string",
+ "pem": "string"
+ }
+ ]
+ },
+ "users": [
+ {
+ "type": "string",
+ "username": "string|null",
+ "password_hash": "string|null",
+ "certificate_id": "string|null",
+ "role": "string"
+ }
+ ],
+ "cycle_time_ms": 100,
+ "address_space": {
+ "namespace_uri": "string",
+ "variables": [...],
+ "structures": [...],
+ "arrays": [...]
+ }
+ }
+ }
+]
+```
+
+### 3.2 Address Space Variable Format
+
+```json
+{
+ "node_id": "PLC.Main.MotorSpeed",
+ "browse_name": "MotorSpeed",
+ "display_name": "Motor Speed",
+ "datatype": "INT",
+ "initial_value": 0,
+ "description": "Current motor speed in RPM",
+ "index": 5,
+ "permissions": {
+ "viewer": "r",
+ "operator": "rw",
+ "engineer": "rw"
+ }
+}
+```
+
+### 3.3 Address Space Structure Format
+
+```json
+{
+ "node_id": "PLC.Main.Sensor",
+ "browse_name": "Sensor",
+ "display_name": "Sensor Data",
+ "description": "Main sensor data structure",
+ "fields": [
+ {
+ "name": "temperature",
+ "datatype": "REAL",
+ "initial_value": 0.0,
+ "index": 10,
+ "permissions": {
+ "viewer": "r",
+ "operator": "r",
+ "engineer": "rw"
+ }
+ },
+ {
+ "name": "pressure",
+ "datatype": "REAL",
+ "initial_value": 0.0,
+ "index": 11,
+ "permissions": {
+ "viewer": "r",
+ "operator": "r",
+ "engineer": "rw"
+ }
+ },
+ {
+ "name": "status.is_valid",
+ "datatype": "BOOL",
+ "initial_value": false,
+ "index": 12,
+ "permissions": {
+ "viewer": "r",
+ "operator": "r",
+ "engineer": "r"
+ }
+ }
+ ]
+}
+```
+
+### 3.4 Address Space Array Format
+
+```json
+{
+ "node_id": "PLC.Main.Temperatures",
+ "browse_name": "Temperatures",
+ "display_name": "Temperature Readings",
+ "datatype": "REAL",
+ "length": 10,
+ "initial_value": 0.0,
+ "index": 20,
+ "permissions": {
+ "viewer": "r",
+ "operator": "rw",
+ "engineer": "rw"
+ }
+}
+```
+
+## 4. Field Mapping Reference
+
+### 4.1 Server Settings
+
+| Editor Field | Runtime JSON Field | Transformation |
+|--------------|-------------------|----------------|
+| server.enabled | (not in JSON) | Only generate JSON if enabled |
+| server.name | config.server.name | Direct copy |
+| server.applicationUri | config.server.application_uri | camelCase → snake_case |
+| server.productUri | config.server.product_uri | camelCase → snake_case |
+| server.bindAddress | config.server.endpoint_url | Combined into URL |
+| server.port | config.server.endpoint_url | Combined into URL |
+| server.endpointPath | config.server.endpoint_url | Combined into URL |
+
+**Endpoint URL Construction:**
+```typescript
+const endpointUrl = `opc.tcp://${bindAddress}:${port}${endpointPath}`
+// Example: opc.tcp://0.0.0.0:4840/openplc/opcua
+```
+
+### 4.2 Security Profiles
+
+| Editor Field | Runtime JSON Field | Transformation |
+|--------------|-------------------|----------------|
+| securityProfile.name | security_profiles[].name | Direct copy |
+| securityProfile.enabled | security_profiles[].enabled | Direct copy |
+| securityProfile.securityPolicy | security_profiles[].security_policy | camelCase → snake_case |
+| securityProfile.securityMode | security_profiles[].security_mode | camelCase → snake_case |
+| securityProfile.authMethods | security_profiles[].auth_methods | camelCase → snake_case |
+
+**Note:** Only enabled profiles are included in the output.
+
+### 4.3 Security Settings
+
+| Editor Field | Runtime JSON Field | Transformation |
+|--------------|-------------------|----------------|
+| security.serverCertificateStrategy | security.server_certificate_strategy | camelCase → snake_case |
+| security.serverCertificateCustom | security.server_certificate_custom | camelCase → snake_case |
+| security.serverPrivateKeyCustom | security.server_private_key_custom | camelCase → snake_case |
+| security.trustedClientCertificates | security.trusted_client_certificates | Map to {id, pem} only |
+
+### 4.4 Users
+
+| Editor Field | Runtime JSON Field | Transformation |
+|--------------|-------------------|----------------|
+| user.type | users[].type | Direct copy |
+| user.username | users[].username | Direct copy |
+| user.passwordHash | users[].password_hash | camelCase → snake_case |
+| user.certificateId | users[].certificate_id | camelCase → snake_case |
+| user.role | users[].role | Direct copy |
+
+### 4.5 Address Space Variables
+
+| Editor Field | Runtime JSON Field | Transformation |
+|--------------|-------------------|----------------|
+| node.nodeId | variables[].node_id | camelCase → snake_case |
+| node.browseName | variables[].browse_name | camelCase → snake_case |
+| node.displayName | variables[].display_name | camelCase → snake_case |
+| node.variableType | variables[].datatype | Renamed |
+| node.initialValue | variables[].initial_value | camelCase → snake_case |
+| node.description | variables[].description | Direct copy |
+| (resolved) | variables[].index | **Resolved from debug.c** |
+| node.permissions | variables[].permissions | Direct copy |
+
+## 5. Index Resolution Process
+
+### 5.1 Overview
+
+Variable indices are resolved during compilation by matching the editor's variable references to entries in the generated `debug.c` file.
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Index Resolution Flow │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ Editor Config debug.c Runtime JSON │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ pouName: │ │ debug_vars[]│ │ index: 5 │ │
+│ │ "main" │ Match │ = { │ Copy │ datatype: │ │
+│ │ variablePath│ ──────────► │ [5] VAR │ ──────► │ "INT" │ │
+│ │ "SPEED" │ │ ... │ │ │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 5.2 Path Building Rules
+
+Different variable types require different debug path formats:
+
+#### Simple Variables (Program/FB Instance)
+
+```typescript
+// Editor: pouName="main", variablePath="MOTOR_SPEED"
+// Debug path: RES0__INSTANCE0.MOTOR_SPEED
+const buildSimplePath = (pouName: string, varPath: string): string => {
+ return `RES0__INSTANCE0.${varPath.toUpperCase()}`
+}
+```
+
+#### Global Variables
+
+```typescript
+// Editor: pouName="GVL", variablePath="SYSTEM_STATE"
+// Debug path: CONFIG0__SYSTEM_STATE
+const buildGlobalPath = (varPath: string): string => {
+ return `CONFIG0__${varPath.toUpperCase()}`
+}
+```
+
+#### Structure Fields
+
+```typescript
+// Editor: pouName="main", variablePath="SENSOR.temperature"
+// Debug path: RES0__INSTANCE0.SENSOR.value.TEMPERATURE
+const buildStructFieldPath = (pouName: string, varPath: string): string => {
+ const parts = varPath.split('.')
+ const structVar = parts[0]
+ const fieldPath = parts.slice(1).join('.value.')
+ return `RES0__INSTANCE0.${structVar.toUpperCase()}.value.${fieldPath.toUpperCase()}`
+}
+```
+
+#### Nested Structure Fields
+
+```typescript
+// Editor: pouName="main", variablePath="SENSOR.status.is_valid"
+// Debug path: RES0__INSTANCE0.SENSOR.value.STATUS.value.IS_VALID
+const buildNestedStructPath = (pouName: string, varPath: string): string => {
+ const parts = varPath.split('.')
+ let path = `RES0__INSTANCE0.${parts[0].toUpperCase()}`
+ for (let i = 1; i < parts.length; i++) {
+ path += `.value.${parts[i].toUpperCase()}`
+ }
+ return path
+}
+```
+
+#### Function Block Variables
+
+```typescript
+// Editor: pouName="main", variablePath="TON0.ET"
+// Debug path: RES0__INSTANCE0.TON0.ET
+const buildFBVarPath = (pouName: string, varPath: string): string => {
+ return `RES0__INSTANCE0.${varPath.toUpperCase()}`
+}
+```
+
+#### Nested Function Block Variables
+
+```typescript
+// Editor: pouName="main", variablePath="MOTOR_CTRL.PID.OUTPUT"
+// Debug path: RES0__INSTANCE0.MOTOR_CTRL.PID.OUTPUT
+const buildNestedFBPath = (pouName: string, varPath: string): string => {
+ return `RES0__INSTANCE0.${varPath.toUpperCase()}`
+}
+```
+
+#### Array Elements
+
+```typescript
+// Editor: pouName="main", variablePath="TEMPS"
+// Debug path for element [0]: RES0__INSTANCE0.TEMPS.value.table[0]
+// Index is the starting index; elements are sequential
+const buildArrayPath = (pouName: string, varPath: string, elementIndex: number): string => {
+ return `RES0__INSTANCE0.${varPath.toUpperCase()}.value.table[${elementIndex}]`
+}
+```
+
+### 5.3 Resolution Algorithm
+
+```typescript
+import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file'
+
+interface ResolvedVariable {
+ nodeId: string
+ browseName: string
+ displayName: string
+ datatype: string
+ initialValue: any
+ description: string
+ index: number
+ permissions: OpcUaPermissions
+}
+
+const resolveVariableIndex = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[]
+): number => {
+ // Build the expected debug path based on variable location
+ let debugPath: string
+
+ if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
+ // Global variable
+ debugPath = `CONFIG0__${node.variablePath.toUpperCase()}`
+ } else {
+ // Instance variable (program or FB)
+ // Handle nested paths by checking for struct/array patterns
+ debugPath = buildInstancePath(node.pouName, node.variablePath)
+ }
+
+ // Find matching entry in debug.c
+ const match = debugVariables.find(
+ dv => dv.name.toUpperCase() === debugPath.toUpperCase()
+ )
+
+ if (!match) {
+ throw new Error(
+ `Cannot resolve index for variable: ${node.pouName}:${node.variablePath}\n` +
+ `Expected debug path: ${debugPath}`
+ )
+ }
+
+ return match.index
+}
+
+const buildInstancePath = (pouName: string, variablePath: string): string => {
+ // For struct fields, insert ".value." before each field access
+ // For FB variables, use direct dot notation
+ // This logic should match the debugger's path building
+
+ const parts = variablePath.split('.')
+
+ // Check if this involves struct fields (need .value. insertion)
+ // This requires type information to determine correctly
+ // For now, use the same logic as the debugger
+
+ return `RES0__INSTANCE0.${variablePath.toUpperCase()}`
+}
+```
+
+### 5.4 Structure Index Resolution
+
+For structures, each field gets its own index:
+
+```typescript
+const resolveStructureIndices = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[]
+): RuntimeStructure => {
+ const fields = node.fields!.map(field => {
+ const fieldPath = `${node.variablePath}.${field.fieldPath}`
+ const debugPath = buildStructFieldDebugPath(node.pouName, fieldPath)
+
+ const match = debugVariables.find(
+ dv => dv.name.toUpperCase() === debugPath.toUpperCase()
+ )
+
+ if (!match) {
+ throw new Error(`Cannot resolve index for field: ${fieldPath}`)
+ }
+
+ return {
+ name: field.fieldPath,
+ datatype: getFieldType(node.variableType, field.fieldPath),
+ initial_value: field.initialValue,
+ index: match.index,
+ permissions: field.permissions,
+ }
+ })
+
+ return {
+ node_id: node.nodeId,
+ browse_name: node.browseName,
+ display_name: node.displayName,
+ description: node.description,
+ fields,
+ }
+}
+```
+
+### 5.5 Array Index Resolution
+
+For arrays, only the starting index is stored; elements are sequential:
+
+```typescript
+const resolveArrayIndex = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[]
+): RuntimeArray => {
+ // Find the first element's index
+ const firstElementPath = buildArrayElementPath(
+ node.pouName,
+ node.variablePath,
+ 0 // First element (C-style index)
+ )
+
+ const match = debugVariables.find(
+ dv => dv.name.toUpperCase() === firstElementPath.toUpperCase()
+ )
+
+ if (!match) {
+ throw new Error(`Cannot resolve index for array: ${node.variablePath}`)
+ }
+
+ return {
+ node_id: node.nodeId,
+ browse_name: node.browseName,
+ display_name: node.displayName,
+ datatype: node.elementType!,
+ length: node.arrayLength!,
+ initial_value: node.initialValue,
+ index: match.index, // Starting index
+ permissions: node.permissions,
+ }
+}
+```
+
+## 6. Complete Generator Implementation
+
+```typescript
+// src/utils/opcua/generate-opcua-config.ts
+
+import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file'
+import type { PLCServer, OpcUaServerConfig, OpcUaNodeConfig } from '@root/types/PLC/open-plc'
+
+interface RuntimeConfig {
+ name: string
+ protocol: 'OPC-UA'
+ config: {
+ server: RuntimeServerConfig
+ security: RuntimeSecurityConfig
+ users: RuntimeUser[]
+ cycle_time_ms: number
+ address_space: RuntimeAddressSpace
+ }
+}
+
+export const generateOpcUaConfig = (
+ servers: PLCServer[] | undefined,
+ debugFileContent: string
+): string | null => {
+ // 1. Find OPC-UA server configuration
+ if (!servers || servers.length === 0) return null
+
+ const opcuaServer = servers.find(
+ s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled
+ )
+
+ if (!opcuaServer?.opcuaServerConfig) return null
+
+ const config = opcuaServer.opcuaServerConfig
+
+ // 2. Parse debug.c to get variable indices
+ const parsed = parseDebugFile(debugFileContent)
+ const debugVariables = parsed.variables
+
+ // 3. Build runtime configuration
+ const runtimeConfig: RuntimeConfig = {
+ name: 'opcua_server',
+ protocol: 'OPC-UA',
+ config: {
+ server: buildServerConfig(config),
+ security: buildSecurityConfig(config),
+ users: buildUsersConfig(config),
+ cycle_time_ms: config.cycleTimeMs,
+ address_space: buildAddressSpace(config, debugVariables),
+ },
+ }
+
+ // 4. Return as JSON string
+ return JSON.stringify([runtimeConfig], null, 2)
+}
+
+const buildServerConfig = (config: OpcUaServerConfig): RuntimeServerConfig => {
+ const { server, securityProfiles } = config
+
+ return {
+ name: server.name,
+ application_uri: server.applicationUri,
+ product_uri: server.productUri,
+ endpoint_url: `opc.tcp://${server.bindAddress}:${server.port}${server.endpointPath}`,
+ security_profiles: securityProfiles
+ .filter(sp => sp.enabled)
+ .map(sp => ({
+ name: sp.name,
+ enabled: sp.enabled,
+ security_policy: sp.securityPolicy,
+ security_mode: sp.securityMode,
+ auth_methods: sp.authMethods,
+ })),
+ }
+}
+
+const buildSecurityConfig = (config: OpcUaServerConfig): RuntimeSecurityConfig => {
+ const { security } = config
+
+ return {
+ server_certificate_strategy: security.serverCertificateStrategy,
+ server_certificate_custom: security.serverCertificateCustom,
+ server_private_key_custom: security.serverPrivateKeyCustom,
+ trusted_client_certificates: security.trustedClientCertificates.map(cert => ({
+ id: cert.id,
+ pem: cert.pem,
+ })),
+ }
+}
+
+const buildUsersConfig = (config: OpcUaServerConfig): RuntimeUser[] => {
+ return config.users.map(user => ({
+ type: user.type,
+ username: user.username,
+ password_hash: user.passwordHash,
+ certificate_id: user.certificateId,
+ role: user.role,
+ }))
+}
+
+const buildAddressSpace = (
+ config: OpcUaServerConfig,
+ debugVariables: DebugVariable[]
+): RuntimeAddressSpace => {
+ const variables: RuntimeVariable[] = []
+ const structures: RuntimeStructure[] = []
+ const arrays: RuntimeArray[] = []
+
+ for (const node of config.addressSpace.nodes) {
+ switch (node.nodeType) {
+ case 'variable':
+ variables.push(resolveVariable(node, debugVariables))
+ break
+ case 'structure':
+ structures.push(resolveStructure(node, debugVariables))
+ break
+ case 'array':
+ arrays.push(resolveArray(node, debugVariables))
+ break
+ }
+ }
+
+ return {
+ namespace_uri: config.addressSpace.namespaceUri,
+ variables,
+ structures,
+ arrays,
+ }
+}
+
+// ... resolution functions as defined in section 5
+```
+
+## 7. Example Transformation
+
+### 7.1 Editor Configuration (Stored in Project)
+
+```json
+{
+ "server": {
+ "enabled": true,
+ "name": "OpenPLC OPC UA Server",
+ "applicationUri": "urn:openplc:opcua:server",
+ "productUri": "urn:openplc:runtime",
+ "bindAddress": "0.0.0.0",
+ "port": 4840,
+ "endpointPath": "/openplc/opcua"
+ },
+ "securityProfiles": [
+ {
+ "id": "uuid-1",
+ "name": "insecure",
+ "enabled": true,
+ "securityPolicy": "None",
+ "securityMode": "None",
+ "authMethods": ["Anonymous"]
+ }
+ ],
+ "security": {
+ "serverCertificateStrategy": "auto_self_signed",
+ "serverCertificateCustom": null,
+ "serverPrivateKeyCustom": null,
+ "trustedClientCertificates": []
+ },
+ "users": [
+ {
+ "id": "uuid-2",
+ "type": "password",
+ "username": "engineer",
+ "passwordHash": "$2b$12$...",
+ "certificateId": null,
+ "role": "engineer"
+ }
+ ],
+ "cycleTimeMs": 100,
+ "addressSpace": {
+ "namespaceUri": "urn:openplc:opcua:namespace",
+ "nodes": [
+ {
+ "id": "uuid-3",
+ "pouName": "main",
+ "variablePath": "MOTOR_SPEED",
+ "variableType": "INT",
+ "nodeId": "PLC.Main.MotorSpeed",
+ "browseName": "MotorSpeed",
+ "displayName": "Motor Speed",
+ "description": "Current motor speed",
+ "initialValue": 0,
+ "permissions": {
+ "viewer": "r",
+ "operator": "rw",
+ "engineer": "rw"
+ },
+ "nodeType": "variable"
+ }
+ ]
+ }
+}
+```
+
+### 7.2 Generated Runtime JSON (opcua.json)
+
+```json
+[
+ {
+ "name": "opcua_server",
+ "protocol": "OPC-UA",
+ "config": {
+ "server": {
+ "name": "OpenPLC OPC UA Server",
+ "application_uri": "urn:openplc:opcua:server",
+ "product_uri": "urn:openplc:runtime",
+ "endpoint_url": "opc.tcp://0.0.0.0:4840/openplc/opcua",
+ "security_profiles": [
+ {
+ "name": "insecure",
+ "enabled": true,
+ "security_policy": "None",
+ "security_mode": "None",
+ "auth_methods": ["Anonymous"]
+ }
+ ]
+ },
+ "security": {
+ "server_certificate_strategy": "auto_self_signed",
+ "server_certificate_custom": null,
+ "server_private_key_custom": null,
+ "trusted_client_certificates": []
+ },
+ "users": [
+ {
+ "type": "password",
+ "username": "engineer",
+ "password_hash": "$2b$12$...",
+ "certificate_id": null,
+ "role": "engineer"
+ }
+ ],
+ "cycle_time_ms": 100,
+ "address_space": {
+ "namespace_uri": "urn:openplc:opcua:namespace",
+ "variables": [
+ {
+ "node_id": "PLC.Main.MotorSpeed",
+ "browse_name": "MotorSpeed",
+ "display_name": "Motor Speed",
+ "datatype": "INT",
+ "initial_value": 0,
+ "description": "Current motor speed",
+ "index": 5,
+ "permissions": {
+ "viewer": "r",
+ "operator": "rw",
+ "engineer": "rw"
+ }
+ }
+ ],
+ "structures": [],
+ "arrays": []
+ }
+ }
+ }
+]
+```
+
+## 8. Error Handling
+
+### 8.1 Index Resolution Errors
+
+When a variable cannot be resolved:
+
+```typescript
+class OpcUaConfigError extends Error {
+ constructor(
+ public readonly variableRef: string,
+ public readonly expectedPath: string,
+ public readonly message: string
+ ) {
+ super(message)
+ this.name = 'OpcUaConfigError'
+ }
+}
+
+// Usage:
+if (!match) {
+ throw new OpcUaConfigError(
+ `${node.pouName}:${node.variablePath}`,
+ debugPath,
+ `Cannot resolve OPC-UA variable index.\n` +
+ `Variable: ${node.pouName}:${node.variablePath}\n` +
+ `Expected debug path: ${debugPath}\n` +
+ `This may happen if the PLC program was modified after configuring OPC-UA.\n` +
+ `Please verify the variable exists in the program.`
+ )
+}
+```
+
+### 8.2 Validation Before Generation
+
+```typescript
+const validateAddressSpace = (
+ nodes: OpcUaNodeConfig[],
+ debugVariables: DebugVariable[]
+): { valid: boolean; errors: string[] } => {
+ const errors: string[] = []
+
+ for (const node of nodes) {
+ try {
+ resolveVariableIndex(node, debugVariables)
+ } catch (e) {
+ errors.push((e as Error).message)
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ }
+}
+```
+
+## 9. Integration with Compiler
+
+The OPC-UA JSON generation is called as part of the compilation process:
+
+```typescript
+// In compiler-module.ts
+
+async handleGenerateOpcUaConfig(
+ sourceTargetFolderPath: string,
+ projectData: PLCProjectData
+): Promise {
+ // Check if OPC-UA is enabled
+ const opcuaServer = projectData.servers?.find(
+ s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled
+ )
+
+ if (!opcuaServer) {
+ // OPC-UA not configured, skip
+ return
+ }
+
+ // Read the debug.c file generated by xml2st
+ const debugCPath = join(sourceTargetFolderPath, 'debug.c')
+ const debugContent = await readFile(debugCPath, 'utf-8')
+
+ // Generate the OPC-UA configuration
+ const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent)
+
+ if (opcuaJson) {
+ // Write to conf/opcua.json
+ const confDir = join(sourceTargetFolderPath, 'conf')
+ await mkdir(confDir, { recursive: true })
+ await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8')
+ }
+}
+```
diff --git a/docs/opcua-server-configuration/04-implementation-phases.md b/docs/opcua-server-configuration/04-implementation-phases.md
new file mode 100644
index 000000000..192e21c4d
--- /dev/null
+++ b/docs/opcua-server-configuration/04-implementation-phases.md
@@ -0,0 +1,973 @@
+# OPC-UA Server Configuration - Implementation Phases
+
+This document outlines a phased approach to implementing the OPC-UA server configuration feature in OpenPLC Editor.
+
+## Overview
+
+The implementation is divided into 6 phases, designed to deliver incremental functionality while managing complexity:
+
+| Phase | Focus | Deliverable |
+|-------|-------|-------------|
+| 1 | Foundation & Types | Type definitions, project integration, basic UI shell |
+| 2 | General Settings & Security Profiles | First two tabs fully functional |
+| 3 | Users & Certificates | Authentication and certificate management |
+| 4 | Address Space - Basic Variables | Variable tree and simple variable selection |
+| 5 | Address Space - Complex Types | Structures, arrays, nested FBs |
+| 6 | Compiler Integration & Testing | JSON generation, end-to-end testing |
+
+---
+
+## Phase 1: Foundation & Type Definitions
+
+### Objective
+Establish the foundational type system and basic UI infrastructure for OPC-UA configuration.
+
+### Deliverables
+
+#### 1.1 Type Definitions
+
+**File:** `src/types/PLC/open-plc.ts`
+
+Add Zod schemas for all OPC-UA configuration types:
+
+```typescript
+// Security Profile Schema
+const OpcUaSecurityProfileSchema = z.object({
+ id: z.string().uuid(),
+ name: z.string().min(1).max(64),
+ enabled: z.boolean(),
+ securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']),
+ securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']),
+ authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])).min(1),
+})
+
+// Trusted Certificate Schema
+const OpcUaTrustedCertificateSchema = z.object({
+ id: z.string().min(1).max(64),
+ pem: z.string(),
+ subject: z.string().optional(),
+ validFrom: z.string().optional(),
+ validTo: z.string().optional(),
+ fingerprint: z.string().optional(),
+})
+
+// User Schema
+const OpcUaUserSchema = z.object({
+ id: z.string().uuid(),
+ type: z.enum(['password', 'certificate']),
+ username: z.string().nullable(),
+ passwordHash: z.string().nullable(),
+ certificateId: z.string().nullable(),
+ role: z.enum(['viewer', 'operator', 'engineer']),
+})
+
+// Permissions Schema
+const OpcUaPermissionsSchema = z.object({
+ viewer: z.enum(['r', 'w', 'rw']).default('r'),
+ operator: z.enum(['r', 'w', 'rw']).default('r'),
+ engineer: z.enum(['r', 'w', 'rw']).default('rw'),
+})
+
+// Field Configuration Schema (for structures)
+const OpcUaFieldConfigSchema = z.object({
+ fieldPath: z.string(),
+ displayName: z.string(),
+ initialValue: z.union([z.boolean(), z.number(), z.string()]),
+ permissions: OpcUaPermissionsSchema,
+})
+
+// Node Configuration Schema
+const OpcUaNodeConfigSchema = z.object({
+ id: z.string().uuid(),
+ pouName: z.string(),
+ variablePath: z.string(),
+ variableType: z.string(),
+ nodeId: z.string(),
+ browseName: z.string(),
+ displayName: z.string(),
+ description: z.string().default(''),
+ initialValue: z.union([z.boolean(), z.number(), z.string()]),
+ permissions: OpcUaPermissionsSchema,
+ nodeType: z.enum(['variable', 'structure', 'array']),
+ fields: z.array(OpcUaFieldConfigSchema).optional(),
+ arrayLength: z.number().optional(),
+ elementType: z.string().optional(),
+})
+
+// Complete Server Configuration Schema
+const OpcUaServerConfigSchema = z.object({
+ server: z.object({
+ enabled: z.boolean().default(false),
+ name: z.string().max(128).default('OpenPLC OPC UA Server'),
+ applicationUri: z.string().default('urn:openplc:opcua:server'),
+ productUri: z.string().default('urn:openplc:runtime'),
+ bindAddress: z.string().default('0.0.0.0'),
+ port: z.number().int().min(1).max(65535).default(4840),
+ endpointPath: z.string().default('/openplc/opcua'),
+ }),
+ securityProfiles: z.array(OpcUaSecurityProfileSchema).default([]),
+ security: z.object({
+ serverCertificateStrategy: z.enum(['auto_self_signed', 'custom']).default('auto_self_signed'),
+ serverCertificateCustom: z.string().nullable().default(null),
+ serverPrivateKeyCustom: z.string().nullable().default(null),
+ trustedClientCertificates: z.array(OpcUaTrustedCertificateSchema).default([]),
+ }),
+ users: z.array(OpcUaUserSchema).default([]),
+ cycleTimeMs: z.number().int().min(10).max(10000).default(100),
+ addressSpace: z.object({
+ namespaceUri: z.string().default('urn:openplc:opcua:namespace'),
+ nodes: z.array(OpcUaNodeConfigSchema).default([]),
+ }),
+})
+```
+
+#### 1.2 Protocol Extension
+
+**File:** `src/types/PLC/open-plc.ts`
+
+Add 'opcua' to the PLCServer protocol union:
+
+```typescript
+const PLCServerSchema = z.object({
+ name: z.string(),
+ protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']),
+ modbusSlaveConfig: ModbusSlaveConfigSchema.optional(),
+ s7commSlaveConfig: S7CommSlaveConfigSchema.optional(),
+ opcuaServerConfig: OpcUaServerConfigSchema.optional(), // Add this
+})
+```
+
+#### 1.3 Project Slice Actions
+
+**File:** `src/renderer/store/slices/project/slice.ts`
+
+Add basic CRUD actions for OPC-UA configuration:
+
+```typescript
+// OPC-UA Server Actions
+createOpcUaServer: (serverName: string) => void
+updateOpcUaServerSettings: (serverName: string, settings: Partial) => void
+deleteOpcUaServer: (serverName: string) => void
+```
+
+#### 1.4 UI Shell Component
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx`
+
+Create the basic tabbed UI structure:
+
+```tsx
+const OpcUaServerEditor = () => {
+ const [activeTab, setActiveTab] = useState('general')
+
+ const tabs = [
+ { id: 'general', label: 'General Settings' },
+ { id: 'security', label: 'Security Profiles' },
+ { id: 'users', label: 'Users' },
+ { id: 'certificates', label: 'Certificates' },
+ { id: 'address-space', label: 'Address Space' },
+ ]
+
+ return (
+
+
+
+ {activeTab === 'general' &&
}
+ {activeTab === 'security' &&
}
+ {activeTab === 'users' &&
}
+ {activeTab === 'certificates' &&
}
+ {activeTab === 'address-space' &&
}
+
+
+ )
+}
+```
+
+### Acceptance Criteria
+
+- [ ] All Zod schemas defined and exported
+- [ ] PLCServer type updated with opcuaServerConfig
+- [ ] Basic project slice actions implemented
+- [ ] Tabbed UI component renders with placeholder content
+- [ ] OPC-UA server can be created/deleted from Servers panel
+
+---
+
+## Phase 2: General Settings & Security Profiles
+
+### Objective
+Implement the first two configuration tabs with full functionality.
+
+### Deliverables
+
+#### 2.1 General Settings Tab
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/general-settings.tsx`
+
+Implement all fields:
+- Enable toggle
+- Server name
+- Application URI
+- Product URI
+- Bind address (dropdown with common options)
+- Port number
+- Endpoint path
+- Cycle time with validation
+
+```tsx
+const GeneralSettingsTab = () => {
+ const { project, projectActions } = useOpenPLCStore()
+ const opcuaConfig = getOpcUaConfig(project)
+
+ const handleUpdateServer = (updates: Partial) => {
+ projectActions.updateOpcUaServerSettings(serverName, updates)
+ }
+
+ return (
+
+ {/* Enable Toggle */}
+
+ Enable OPC-UA Server
+ handleUpdateServer({ enabled })}
+ />
+
+
+ {/* Server Identity Section */}
+
+ Server Identity
+ {/* Form fields */}
+
+
+ {/* Network Configuration Section */}
+
+ Network Configuration
+ {/* Form fields with endpoint URL preview */}
+
+
+ {/* Performance Section */}
+
+ Performance
+ {/* Cycle time input */}
+
+
+ )
+}
+```
+
+#### 2.2 Security Profiles Tab
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/security-profiles.tsx`
+
+Implement:
+- List of security profiles with enable/disable toggle
+- Add/Edit/Delete profile functionality
+- Profile modal with all settings
+- Validation rules (Anonymous only with None policy, etc.)
+
+#### 2.3 Security Profile Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx`
+
+Implement modal with:
+- Profile name input
+- Enable toggle
+- Security policy dropdown
+- Security mode dropdown
+- Authentication methods checkboxes
+- Validation feedback
+
+#### 2.4 Project Slice Actions
+
+Add actions for security profile management:
+
+```typescript
+addOpcUaSecurityProfile: (serverName: string, profile: OpcUaSecurityProfile) => void
+updateOpcUaSecurityProfile: (serverName: string, profileId: string, updates: Partial) => void
+removeOpcUaSecurityProfile: (serverName: string, profileId: string) => void
+```
+
+### Acceptance Criteria
+
+- [ ] General settings tab saves all fields correctly
+- [ ] Endpoint URL preview updates dynamically
+- [ ] Cycle time validates range (10-10000)
+- [ ] Security profiles can be added/edited/removed
+- [ ] At least one profile must be enabled (validation)
+- [ ] Anonymous auth only available with None policy
+- [ ] Profile list shows summary of each profile
+
+---
+
+## Phase 3: Users & Certificates
+
+### Objective
+Implement user authentication and certificate management.
+
+### Deliverables
+
+#### 3.1 Users Tab
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/users.tsx`
+
+Implement:
+- User list with role badges
+- Add/Edit/Delete user functionality
+- User type toggle (password/certificate)
+- Password validation and hashing
+
+#### 3.2 User Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx`
+
+Implement:
+- Authentication type selector
+- Password fields with strength indicator
+- Certificate selector (from trusted certificates)
+- Role selector with descriptions
+
+#### 3.3 Password Hashing Utility
+
+**File:** `src/utils/opcua/bcrypt-utils.ts`
+
+```typescript
+import bcrypt from 'bcryptjs'
+
+export const hashPassword = async (password: string): Promise => {
+ const salt = await bcrypt.genSalt(12)
+ return bcrypt.hash(password, salt)
+}
+
+export const verifyPassword = async (
+ password: string,
+ hash: string
+): Promise => {
+ return bcrypt.compare(password, hash)
+}
+```
+
+#### 3.4 Certificates Tab
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/certificates.tsx`
+
+Implement:
+- Server certificate strategy selector
+- Custom certificate upload (when strategy = custom)
+- Private key upload
+- Trusted client certificates list
+- Certificate details display
+
+#### 3.5 Certificate Parsing Utility
+
+**File:** `src/utils/opcua/certificate-utils.ts`
+
+Use a library like `node-forge` to parse PEM certificates:
+
+```typescript
+import forge from 'node-forge'
+
+export interface CertificateInfo {
+ subject: string
+ issuer: string
+ validFrom: Date
+ validTo: Date
+ fingerprint: string
+}
+
+export const parsePemCertificate = (pem: string): CertificateInfo => {
+ const cert = forge.pki.certificateFromPem(pem)
+ return {
+ subject: cert.subject.getField('CN')?.value || 'Unknown',
+ issuer: cert.issuer.getField('CN')?.value || 'Unknown',
+ validFrom: cert.validity.notBefore,
+ validTo: cert.validity.notAfter,
+ fingerprint: forge.md.sha256
+ .create()
+ .update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes())
+ .digest()
+ .toHex(),
+ }
+}
+```
+
+#### 3.6 Add Trusted Certificate Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx`
+
+Implement:
+- Certificate ID input
+- PEM content text area
+- File browse button
+- Certificate details preview (parsed from PEM)
+
+### Acceptance Criteria
+
+- [ ] Password users can be created with bcrypt hashing
+- [ ] Certificate users can be created referencing trusted certs
+- [ ] At least one user required when Username auth enabled
+- [ ] Server certificate strategy works (auto/custom)
+- [ ] Custom certificates can be uploaded via file or paste
+- [ ] Trusted certificates show parsed details (subject, validity)
+- [ ] Certificate IDs are unique
+
+---
+
+## Phase 4: Address Space - Basic Variables
+
+### Objective
+Implement the variable tree and basic variable selection functionality.
+
+### Deliverables
+
+#### 4.1 Address Space Tab
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/address-space.tsx`
+
+Implement:
+- Namespace URI input
+- Split-pane layout (available variables | selected variables)
+- Add/Remove buttons
+- Filter input for variable search
+
+#### 4.2 Variable Tree Component
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx`
+
+Build the hierarchical variable tree from project POUs:
+
+```tsx
+interface TreeNode {
+ id: string
+ name: string
+ type: 'program' | 'function_block' | 'global' | 'variable' | 'structure' | 'array'
+ variableType?: string // IEC type for variables
+ children?: TreeNode[]
+ pouName: string
+ variablePath: string
+ isSelectable: boolean
+ isExpanded?: boolean
+}
+
+const VariableTree = ({
+ nodes,
+ selectedIds,
+ onSelect,
+ onExpand,
+}: VariableTreeProps) => {
+ const renderNode = (node: TreeNode, depth: number) => (
+
+
+ {node.children && (
+ onExpand(node.id)}
+ />
+ )}
+ {node.isSelectable && (
+ onSelect(node)}
+ />
+ )}
+
+ {node.name}
+ {node.variableType && (
+ ({node.variableType})
+ )}
+
+ {node.isExpanded && node.children?.map(child =>
+ renderNode(child, depth + 1)
+ )}
+
+ )
+
+ return {nodes.map(node => renderNode(node, 0))}
+}
+```
+
+#### 4.3 Variable Extraction from Project
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts`
+
+Extract variables from project POUs:
+
+```typescript
+const useProjectVariables = (): TreeNode[] => {
+ const { project } = useOpenPLCStore()
+
+ return useMemo(() => {
+ const nodes: TreeNode[] = []
+
+ // Programs and Function Block instances
+ for (const pou of project.data.pous) {
+ if (pou.type === 'program') {
+ nodes.push(buildProgramNode(pou, project.data))
+ }
+ }
+
+ // Global Variables
+ if (project.data.globalVariables?.length) {
+ nodes.push(buildGlobalVariablesNode(project.data.globalVariables))
+ }
+
+ return nodes
+ }, [project.data])
+}
+
+const buildProgramNode = (pou: POU, projectData: PLCProjectData): TreeNode => {
+ return {
+ id: `pou-${pou.data.name}`,
+ name: pou.data.name,
+ type: 'program',
+ pouName: pou.data.name,
+ variablePath: '',
+ isSelectable: false,
+ children: pou.data.variables.map(v =>
+ buildVariableNode(v, pou.data.name, projectData)
+ ),
+ }
+}
+```
+
+#### 4.4 Simple Variable Configuration Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx`
+
+Implement modal for configuring selected variables:
+- Node ID (auto-generated, editable)
+- Browse name
+- Display name
+- Description
+- Initial value (type-appropriate input)
+- Permissions matrix
+
+#### 4.5 Selected Variables List
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx`
+
+Display selected variables with:
+- Node summary (node ID, type, permissions)
+- Edit/Remove buttons
+- Drag-and-drop reordering
+
+### Acceptance Criteria
+
+- [ ] Variable tree displays all program variables
+- [ ] Tree supports expand/collapse for nested items
+- [ ] Simple variables (INT, BOOL, REAL, etc.) can be selected
+- [ ] Selected variables appear in right panel
+- [ ] Variables can be configured with OPC-UA properties
+- [ ] Node IDs are auto-generated but editable
+- [ ] Permissions can be set per variable
+- [ ] Variables can be removed from selection
+
+---
+
+## Phase 5: Address Space - Complex Types
+
+### Objective
+Add support for structures, arrays, and deeply nested function blocks.
+
+### Deliverables
+
+#### 5.1 Structure Support
+
+Extend the variable tree to show structure fields:
+
+```typescript
+const buildStructureNode = (
+ variable: Variable,
+ pouName: string,
+ structType: StructType
+): TreeNode => {
+ return {
+ id: `${pouName}-${variable.name}`,
+ name: variable.name,
+ type: 'structure',
+ variableType: variable.type.value,
+ pouName,
+ variablePath: variable.name,
+ isSelectable: true, // Can select entire structure
+ children: structType.variable.map(field => ({
+ id: `${pouName}-${variable.name}-${field.name}`,
+ name: field.name,
+ type: 'variable',
+ variableType: field.type.value,
+ pouName,
+ variablePath: `${variable.name}.${field.name}`,
+ isSelectable: true, // Or select individual fields
+ })),
+ }
+}
+```
+
+#### 5.2 Structure Configuration Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/structure-config-modal.tsx`
+
+Implement modal for structure configuration:
+- Structure-level properties (node ID, browse name, etc.)
+- Field table with individual permissions
+- Nested structure handling
+
+#### 5.3 Array Support
+
+Extend the variable tree for arrays:
+
+```typescript
+const buildArrayNode = (
+ variable: Variable,
+ pouName: string,
+ arrayDimension: string // e.g., "1..10"
+): TreeNode => {
+ const [min, max] = arrayDimension.split('..').map(Number)
+ const length = max - min + 1
+
+ return {
+ id: `${pouName}-${variable.name}`,
+ name: variable.name,
+ type: 'array',
+ variableType: `ARRAY[${arrayDimension}] OF ${elementType}`,
+ pouName,
+ variablePath: variable.name,
+ isSelectable: true,
+ children: Array.from({ length }, (_, i) => ({
+ id: `${pouName}-${variable.name}-${min + i}`,
+ name: `[${min + i}]`,
+ type: 'variable',
+ variableType: elementType,
+ pouName,
+ variablePath: `${variable.name}[${min + i}]`,
+ isSelectable: true,
+ })),
+ }
+}
+```
+
+#### 5.4 Array Configuration Modal
+
+**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/array-config-modal.tsx`
+
+Implement modal for array configuration:
+- Array-level properties
+- Element type display
+- Length display
+- Permissions (apply to all elements)
+
+#### 5.5 Nested Function Block Support
+
+Handle deeply nested FB instances:
+
+```typescript
+const buildFunctionBlockNode = (
+ variable: Variable,
+ pouName: string,
+ fbType: FunctionBlock,
+ projectData: PLCProjectData,
+ currentPath: string = ''
+): TreeNode => {
+ const variablePath = currentPath
+ ? `${currentPath}.${variable.name}`
+ : variable.name
+
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'function_block',
+ variableType: variable.type.value,
+ pouName,
+ variablePath,
+ isSelectable: true, // Select entire FB
+ children: fbType.variable.map(fbVar => {
+ // Check if this FB variable is itself an FB
+ const nestedFbType = findFunctionBlock(fbVar.type.value, projectData)
+
+ if (nestedFbType) {
+ // Recursive: nested FB
+ return buildFunctionBlockNode(
+ fbVar,
+ pouName,
+ nestedFbType,
+ projectData,
+ variablePath
+ )
+ }
+
+ // Leaf: simple variable
+ return {
+ id: `${pouName}-${variablePath}-${fbVar.name}`,
+ name: fbVar.name,
+ type: 'variable',
+ variableType: fbVar.type.value,
+ pouName,
+ variablePath: `${variablePath}.${fbVar.name}`,
+ isSelectable: true,
+ }
+ }),
+ }
+}
+```
+
+#### 5.6 Selection Behavior for Complex Types
+
+Implement selection logic:
+
+```typescript
+const handleSelectNode = (node: TreeNode) => {
+ if (node.type === 'variable') {
+ // Simple toggle
+ toggleSelection(node.id)
+ } else {
+ // Complex type: select/deselect all children
+ const childIds = getAllChildIds(node)
+ if (allSelected(childIds)) {
+ deselectAll(childIds)
+ } else {
+ selectAll(childIds)
+ }
+ }
+}
+```
+
+#### 5.7 Deeply Nested Path Display
+
+Show full path in selected variables list:
+
+```
+main:PROCESS_CTRL.HEATER.PID.OUTPUT
+ └── FB ──┘ └─ FB ─┘ └─ var
+```
+
+### Acceptance Criteria
+
+- [ ] Structures display with expandable fields
+- [ ] Entire structure or individual fields can be selected
+- [ ] Arrays display with expandable elements
+- [ ] Entire array can be selected (not individual elements for now)
+- [ ] Nested FBs display correctly (FB inside FB inside FB)
+- [ ] All levels of nesting are navigable
+- [ ] Selection of parent auto-selects all children
+- [ ] Path display shows full hierarchy
+
+---
+
+## Phase 6: Compiler Integration & Testing
+
+### Objective
+Integrate OPC-UA JSON generation into the compiler and perform end-to-end testing.
+
+### Deliverables
+
+#### 6.1 JSON Generator Implementation
+
+**File:** `src/utils/opcua/generate-opcua-config.ts`
+
+Implement the complete generator as documented in `03-json-configuration-mapping.md`:
+
+```typescript
+export const generateOpcUaConfig = (
+ servers: PLCServer[] | undefined,
+ debugFileContent: string
+): string | null => {
+ // Implementation as documented
+}
+```
+
+#### 6.2 Index Resolution Implementation
+
+**File:** `src/utils/opcua/resolve-indices.ts`
+
+Implement index resolution logic:
+
+```typescript
+export const resolveVariableIndex = (
+ pouName: string,
+ variablePath: string,
+ debugVariables: DebugVariable[]
+): number => {
+ // Build debug path based on variable type
+ // Match against parsed debug.c
+ // Return index or throw error
+}
+```
+
+#### 6.3 Compiler Module Integration
+
+**File:** `src/main/modules/compiler/compiler-module.ts`
+
+Add OPC-UA configuration generation to the compilation pipeline:
+
+```typescript
+import { generateOpcUaConfig } from '@root/utils/opcua'
+
+// In the compile method, after debug.c is generated:
+async handleGenerateOpcUaConfig(
+ sourceTargetFolderPath: string,
+ projectData: PLCProjectData
+): Promise {
+ const opcuaServer = projectData.servers?.find(
+ s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled
+ )
+
+ if (!opcuaServer) return
+
+ const debugCPath = join(sourceTargetFolderPath, 'debug.c')
+ const debugContent = await readFile(debugCPath, 'utf-8')
+
+ const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent)
+
+ if (opcuaJson) {
+ const confDir = join(sourceTargetFolderPath, 'conf')
+ await mkdir(confDir, { recursive: true })
+ await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8')
+ }
+}
+```
+
+#### 6.4 Error Handling & User Feedback
+
+Implement error handling for common issues:
+
+- Variable not found in debug.c
+- Invalid configuration (no enabled profiles, no users, etc.)
+- Circular references in FB structures
+
+```typescript
+export class OpcUaConfigError extends Error {
+ constructor(
+ public code: 'VARIABLE_NOT_FOUND' | 'INVALID_CONFIG' | 'CIRCULAR_REF',
+ public details: Record,
+ message: string
+ ) {
+ super(message)
+ }
+}
+```
+
+#### 6.5 Unit Tests
+
+**File:** `src/utils/opcua/__tests__/generate-opcua-config.test.ts`
+
+Write comprehensive tests:
+
+```typescript
+describe('generateOpcUaConfig', () => {
+ it('returns null when OPC-UA not configured', () => {
+ // Test
+ })
+
+ it('generates valid JSON for simple variables', () => {
+ // Test
+ })
+
+ it('resolves structure field indices correctly', () => {
+ // Test
+ })
+
+ it('resolves array start indices correctly', () => {
+ // Test
+ })
+
+ it('handles nested FB paths correctly', () => {
+ // Test
+ })
+
+ it('throws error for unresolvable variables', () => {
+ // Test
+ })
+})
+```
+
+#### 6.6 Integration Tests
+
+**File:** `e2e/opcua-configuration.spec.ts`
+
+Write end-to-end tests:
+
+```typescript
+test('OPC-UA server can be configured and compiled', async ({ page }) => {
+ // 1. Create new project
+ // 2. Add program with variables
+ // 3. Configure OPC-UA server
+ // 4. Select variables for address space
+ // 5. Compile project
+ // 6. Verify opcua.json is generated correctly
+})
+```
+
+#### 6.7 Documentation Updates
+
+Update user documentation:
+- How to configure OPC-UA server
+- Security best practices
+- Troubleshooting guide
+
+### Acceptance Criteria
+
+- [ ] JSON generator produces valid output
+- [ ] Index resolution works for all variable types
+- [ ] Compiler generates opcua.json when OPC-UA enabled
+- [ ] Errors provide helpful messages
+- [ ] Unit tests pass with >80% coverage
+- [ ] E2E tests pass
+- [ ] Generated JSON validated against runtime plugin
+
+---
+
+## Summary Timeline
+
+| Phase | Description | Dependencies |
+|-------|-------------|--------------|
+| 1 | Foundation & Types | None |
+| 2 | General Settings & Security | Phase 1 |
+| 3 | Users & Certificates | Phase 2 |
+| 4 | Address Space - Basic | Phase 1 |
+| 5 | Address Space - Complex | Phase 4 |
+| 6 | Compiler Integration | Phases 1-5 |
+
+**Parallel Work Opportunities:**
+- Phases 2-3 (Security/Users) can be developed in parallel with Phases 4-5 (Address Space)
+- Unit tests can be written alongside each phase
+
+---
+
+## Risk Mitigation
+
+### Technical Risks
+
+| Risk | Mitigation |
+|------|------------|
+| Complex nested FB paths | Reuse debugger's proven path building logic |
+| Password security | Use bcryptjs, never store plain text |
+| Certificate parsing errors | Validate PEM format before storing |
+| Large variable trees | Implement tree virtualization |
+
+### Integration Risks
+
+| Risk | Mitigation |
+|------|------------|
+| Index resolution failures | Comprehensive error messages with expected path |
+| Runtime incompatibility | Test against actual OPC-UA plugin during development |
+| Project file migration | Provide default values for new fields |
+
+---
+
+## Dependencies
+
+### External Libraries
+
+| Library | Purpose | Phase |
+|---------|---------|-------|
+| bcryptjs | Password hashing | 3 |
+| node-forge | Certificate parsing | 3 |
+| react-window | Tree virtualization | 4 |
+| uuid | Generate unique IDs | 1 |
+
+### Internal Dependencies
+
+| Component | Purpose | Phase |
+|-----------|---------|-------|
+| parseDebugFile | Index resolution | 6 |
+| Project store | State management | 1 |
+| Compiler module | JSON generation | 6 |
diff --git a/docs/opcua-server-configuration/README.md b/docs/opcua-server-configuration/README.md
new file mode 100644
index 000000000..b6902db6c
--- /dev/null
+++ b/docs/opcua-server-configuration/README.md
@@ -0,0 +1,39 @@
+# OPC-UA Server Configuration
+
+This folder contains design documentation for the OPC-UA server configuration feature in OpenPLC Editor.
+
+## Overview
+
+The OPC-UA server configuration allows users to configure an OPC-UA server that runs on the OpenPLC Runtime. This feature enables industrial clients to access PLC variables through the OPC-UA protocol, which is significantly more complex than the existing Modbus and S7Comm protocols.
+
+## Key Features
+
+- **Full Variable Access**: Unlike Modbus/S7Comm which only access I/O image tables, OPC-UA can expose any PLC program variable
+- **Security Profiles**: Multiple security configurations (None, Sign, SignAndEncrypt)
+- **Certificate Management**: Server and client certificate handling
+- **User Authentication**: Password-based and certificate-based authentication
+- **Role-Based Access Control**: Viewer, Operator, and Engineer roles with per-variable permissions
+- **Complex Data Types**: Support for structures, arrays, and nested function blocks
+
+## Documentation Structure
+
+1. **[Design Overview](./01-design-overview.md)** - High-level architecture, configuration flow, and integration points
+2. **[UI Screen Specifications](./02-ui-screen-specifications.md)** - Detailed UI designs for each configuration tab
+3. **[JSON Configuration Mapping](./03-json-configuration-mapping.md)** - How editor configuration maps to runtime JSON format
+4. **[Implementation Phases](./04-implementation-phases.md)** - Phased implementation plan with deliverables
+
+## Related Documentation
+
+- OpenPLC Runtime OPC-UA Plugin: See the `RTOP-100-OPC-UA` branch of openplc-runtime
+- Existing protocol implementations: Modbus and S7Comm configurations in openplc-editor
+
+## Key Differences from Modbus/S7Comm
+
+| Aspect | Modbus/S7Comm | OPC-UA |
+|--------|---------------|--------|
+| Variable Access | I/O Image Tables only | Any PLC variable |
+| Security | Basic or none | Multiple security profiles |
+| Authentication | None | Anonymous, Username/Password, Certificate |
+| Access Control | None | Role-based per-variable permissions |
+| Data Types | Simple registers/coils | Variables, Structures, Arrays |
+| Configuration Complexity | Low-Medium | High |
From 8fd81ec5ad2519d6193a90e978f9476f71a07db9 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Thu, 15 Jan 2026 20:20:10 -0500
Subject: [PATCH 02/21] feat: Implement Phase 1 OPC-UA server configuration
Add foundational OPC-UA server configuration support including:
- Add OPC-UA type definitions (security profiles, users, certificates,
permissions, address space nodes, server config) to open-plc.ts
- Add 'opcua' protocol to server schemas (PLCServerProtocolSchema,
editor types, tabs types)
- Add OPC-UA option to server creation dropdown in element-card
- Implement OPC-UA server initialization with default configuration
- Create OpcUaServerEditor component with 5-tab interface:
- General Settings (functional): server name, URIs, port, bind
address, endpoint path, cycle time, enable toggle
- Security Profiles (placeholder for Phase 2)
- Users (placeholder for Phase 3)
- Certificates (placeholder for Phase 4)
- Address Space (placeholder for Phase 5)
- Add updateOpcUaServerConfig action to project slice
- Style OPC-UA editor to match existing Modbus/S7Comm server editors
Co-Authored-By: Claude Opus 4.5
---
.../create-element/element-card/index.tsx | 3 +-
.../[workspace]/editor/server/index.ts | 1 +
.../editor/server/opcua-server/index.tsx | 471 ++++++++++++++++++
src/renderer/screens/workspace-screen.tsx | 7 +-
src/renderer/store/slices/editor/types.ts | 2 +-
src/renderer/store/slices/project/slice.ts | 102 ++++
src/renderer/store/slices/project/types.ts | 5 +
src/renderer/store/slices/tabs/types.ts | 2 +-
src/renderer/store/slices/tabs/utils.ts | 2 +-
src/types/PLC/open-plc.ts | 159 +++++-
10 files changed, 748 insertions(+), 6 deletions(-)
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
diff --git a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx
index 42fe2cc47..a68a01bed 100644
--- a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx
+++ b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx
@@ -37,7 +37,7 @@ type CreateDataTypeFormProps = {
type CreateServerFormProps = {
name: string
- protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip'
+ protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua'
}
type CreateRemoteDeviceFormProps = {
@@ -48,6 +48,7 @@ type CreateRemoteDeviceFormProps = {
const ServerProtocolSources = [
{ value: 'modbus-tcp', label: 'Modbus/TCP', disabled: false },
{ value: 's7comm', label: 'Siemens S7comm', disabled: false },
+ { value: 'opcua', label: 'OPC-UA', disabled: false },
{ value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true },
] as const
diff --git a/src/renderer/components/_features/[workspace]/editor/server/index.ts b/src/renderer/components/_features/[workspace]/editor/server/index.ts
index 3686bd98a..6448c3767 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/index.ts
+++ b/src/renderer/components/_features/[workspace]/editor/server/index.ts
@@ -1,2 +1,3 @@
export { ModbusServerEditor } from './modbus-server'
+export { OpcUaServerEditor } from './opcua-server'
export { S7CommServerEditor } from './s7comm-server'
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
new file mode 100644
index 000000000..9fa6ab931
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
@@ -0,0 +1,471 @@
+import * as Tabs from '@radix-ui/react-tabs'
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { OpcUaServerConfig } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+/**
+ * OPC-UA Server Editor Component
+ *
+ * This component provides the configuration interface for the OPC-UA server.
+ * It consists of 5 tabs:
+ * - General Settings: Server name, port, endpoint configuration
+ * - Security Profiles: Security policies and authentication methods
+ * - Users: User accounts with roles and permissions
+ * - Certificates: Server and client certificate management
+ * - Address Space: Variable selection for OPC-UA exposure
+ */
+
+const DEFAULT_NETWORK_INTERFACE_OPTIONS = [
+ { value: '0.0.0.0', label: 'All Interfaces (0.0.0.0)' },
+ { value: '127.0.0.1', label: 'Localhost (127.0.0.1)' },
+]
+
+// Input styles matching Modbus server editor
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+
+// Tab item component
+const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => (
+
+ {label}
+
+)
+
+// Placeholder component for tabs under development
+const PlaceholderContent = ({ title, description }: { title: string; description: string }) => (
+
+
+
{title}
+
{description}
+
+
+)
+
+export const OpcUaServerEditor = () => {
+ const {
+ editor,
+ project,
+ projectActions: { updateOpcUaServerConfig },
+ workspaceActions: { setEditingState },
+ } = useOpenPLCStore()
+
+ const [activeTab, setActiveTab] = useState('general')
+
+ // Get the current server configuration
+ const serverName = editor.type === 'plc-server' ? editor.meta.name : ''
+ const protocol = editor.type === 'plc-server' ? editor.meta.protocol : ''
+
+ const server = useMemo(() => {
+ return project.data.servers?.find((s) => s.name === serverName)
+ }, [project.data.servers, serverName])
+
+ const config = server?.opcuaServerConfig
+
+ // Initialize local state from server config
+ const [localConfig, setLocalConfig] = useState(null)
+
+ useEffect(() => {
+ if (config) {
+ setLocalConfig(config)
+ }
+ }, [config])
+
+ // Handle server settings updates
+ const handleServerSettingsUpdate = useCallback(
+ (updates: Partial) => {
+ if (!localConfig) return
+
+ // Update local state immediately for responsive UI
+ setLocalConfig((prev) => {
+ if (!prev) return prev
+ return {
+ ...prev,
+ server: { ...prev.server, ...updates },
+ }
+ })
+
+ // Persist to store
+ updateOpcUaServerConfig(serverName, { server: updates })
+ setEditingState('unsaved')
+ },
+ [localConfig, serverName, updateOpcUaServerConfig, setEditingState],
+ )
+
+ // Handle cycle time update (top-level config field)
+ const handleCycleTimeUpdate = useCallback(
+ (cycleTimeMs: number) => {
+ if (!localConfig) return
+
+ setLocalConfig((prev) => {
+ if (!prev) return prev
+ return { ...prev, cycleTimeMs }
+ })
+
+ updateOpcUaServerConfig(serverName, { cycleTimeMs })
+ setEditingState('unsaved')
+ },
+ [localConfig, serverName, updateOpcUaServerConfig, setEditingState],
+ )
+
+ if (protocol !== 'opcua') {
+ return (
+
+
+ Configuration for {protocol} servers is not yet available.
+
+
+ )
+ }
+
+ if (!localConfig) {
+ return (
+
+
Loading OPC-UA configuration...
+
+ )
+ }
+
+ return (
+
+
+
OPC-UA Server: {serverName}
+
Protocol: OPC-UA
+
+
+
+ {/* Tab Navigation */}
+
+
+
+
+
+
+
+
+ {/* General Settings Tab */}
+
+
+
+
+
+
+ {/* Security Profiles Tab */}
+
+
+
+
+ {/* Users Tab */}
+
+
+
+
+ {/* Certificates Tab */}
+
+
+
+
+ {/* Address Space Tab */}
+
+
+
+
+
+ )
+}
+
+// General Settings Tab Component
+interface GeneralSettingsTabProps {
+ config: OpcUaServerConfig
+ onServerUpdate: (updates: Partial) => void
+ onCycleTimeUpdate: (cycleTimeMs: number) => void
+}
+
+const GeneralSettingsTab = ({ config, onServerUpdate, onCycleTimeUpdate }: GeneralSettingsTabProps) => {
+ // Local state for text inputs to allow typing before validation
+ const [port, setPort] = useState(config.server.port.toString())
+ const [cycleTime, setCycleTime] = useState(config.cycleTimeMs.toString())
+ const [serverDisplayName, setServerDisplayName] = useState(config.server.name)
+ const [applicationUri, setApplicationUri] = useState(config.server.applicationUri)
+ const [productUri, setProductUri] = useState(config.server.productUri)
+ const [endpointPath, setEndpointPath] = useState(config.server.endpointPath)
+
+ // Sync local state when config changes
+ useEffect(() => {
+ setPort(config.server.port.toString())
+ setCycleTime(config.cycleTimeMs.toString())
+ setServerDisplayName(config.server.name)
+ setApplicationUri(config.server.applicationUri)
+ setProductUri(config.server.productUri)
+ setEndpointPath(config.server.endpointPath)
+ }, [config])
+
+ const handlePortBlur = useCallback(() => {
+ const portNum = parseInt(port, 10)
+ if (!isNaN(portNum) && portNum >= 1 && portNum <= 65535) {
+ if (portNum !== config.server.port) {
+ onServerUpdate({ port: portNum })
+ }
+ } else {
+ setPort(config.server.port.toString())
+ }
+ }, [port, config.server.port, onServerUpdate])
+
+ const handleCycleTimeBlur = useCallback(() => {
+ const value = parseInt(cycleTime, 10)
+ if (!isNaN(value) && value >= 10 && value <= 10000) {
+ if (value !== config.cycleTimeMs) {
+ onCycleTimeUpdate(value)
+ }
+ } else {
+ setCycleTime(config.cycleTimeMs.toString())
+ }
+ }, [cycleTime, config.cycleTimeMs, onCycleTimeUpdate])
+
+ const handleServerNameBlur = useCallback(() => {
+ if (serverDisplayName !== config.server.name) {
+ onServerUpdate({ name: serverDisplayName })
+ }
+ }, [serverDisplayName, config.server.name, onServerUpdate])
+
+ const handleApplicationUriBlur = useCallback(() => {
+ if (applicationUri !== config.server.applicationUri) {
+ onServerUpdate({ applicationUri })
+ }
+ }, [applicationUri, config.server.applicationUri, onServerUpdate])
+
+ const handleProductUriBlur = useCallback(() => {
+ if (productUri !== config.server.productUri) {
+ onServerUpdate({ productUri })
+ }
+ }, [productUri, config.server.productUri, onServerUpdate])
+
+ const handleEndpointPathBlur = useCallback(() => {
+ if (endpointPath !== config.server.endpointPath) {
+ onServerUpdate({ endpointPath })
+ }
+ }, [endpointPath, config.server.endpointPath, onServerUpdate])
+
+ return (
+
+ {/* Server Configuration Section */}
+
+
Server Configuration
+
+ {/* Enable Server Toggle */}
+
+
Enable Server
+
+ onServerUpdate({ enabled: e.target.checked })}
+ className='peer sr-only'
+ />
+
+
+
+ {config.server.enabled ? 'Server will start when PLC runs' : 'Server is disabled'}
+
+
+
+ {/* Server Name */}
+
+
Server Name
+
+ setServerDisplayName(e.target.value)}
+ onBlur={handleServerNameBlur}
+ placeholder='OpenPLC OPC UA Server'
+ className={inputStyles}
+ />
+
+
+
+ {/* Bind Address */}
+
+
Network Interface
+
+ onServerUpdate({ bindAddress: value })}>
+
+
+ {DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+ {/* Port */}
+
+
Port
+
+ setPort(e.target.value)}
+ onBlur={handlePortBlur}
+ placeholder='4840'
+ min='1'
+ max='65535'
+ className={inputStyles}
+ />
+
+
Default: 4840
+
+
+ {/* Endpoint Path */}
+
+
Endpoint Path
+
+ setEndpointPath(e.target.value)}
+ onBlur={handleEndpointPathBlur}
+ placeholder='/openplc/opcua'
+ className={inputStyles}
+ />
+
+
+
+
+ {/* Application Identity Section */}
+
+
Application Identity
+
+ Configure the OPC-UA application identity URIs. These are used by clients to identify the server.
+
+
+ {/* Application URI */}
+
+
Application URI
+
+ setApplicationUri(e.target.value)}
+ onBlur={handleApplicationUriBlur}
+ placeholder='urn:openplc:opcua:server'
+ className={inputStyles}
+ />
+
+
+
+ {/* Product URI */}
+
+
Product URI
+
+ setProductUri(e.target.value)}
+ onBlur={handleProductUriBlur}
+ placeholder='urn:openplc:runtime'
+ className={inputStyles}
+ />
+
+
+
+
+ {/* Timing Configuration Section */}
+
+
Timing Configuration
+
+ {/* Cycle Time */}
+
+
Cycle Time (ms)
+
+ setCycleTime(e.target.value)}
+ onBlur={handleCycleTimeBlur}
+ placeholder='100'
+ min='10'
+ max='10000'
+ className={inputStyles}
+ />
+
+
+ Update frequency for OPC-UA variables (10-10000ms)
+
+
+
+
+ {/* Namespace Configuration Section */}
+
+
Namespace Configuration
+
+ {/* Namespace URI */}
+
+
+ The namespace URI for OpenPLC variables in the OPC-UA address space
+
+
+
+ )
+}
diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx
index f916254a0..ce1c3f853 100644
--- a/src/renderer/screens/workspace-screen.tsx
+++ b/src/renderer/screens/workspace-screen.tsx
@@ -13,7 +13,11 @@ import { DeviceEditor } from '../components/_features/[workspace]/editor/device'
import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device'
import { GraphicalEditor } from '../components/_features/[workspace]/editor/graphical'
import { ResourcesEditor } from '../components/_features/[workspace]/editor/resource-editor'
-import { ModbusServerEditor, S7CommServerEditor } from '../components/_features/[workspace]/editor/server'
+import {
+ ModbusServerEditor,
+ OpcUaServerEditor,
+ S7CommServerEditor,
+} from '../components/_features/[workspace]/editor/server'
import { Search } from '../components/_features/[workspace]/search'
import { VariablesPanel } from '../components/_molecules/variables-panel'
import AboutModal from '../components/_organisms/about-modal'
@@ -1652,6 +1656,7 @@ const WorkspaceScreen = () => {
)}
{editor['type'] === 'plc-server' && editor.meta.protocol === 's7comm' && }
+ {editor['type'] === 'plc-server' && editor.meta.protocol === 'opcua' && }
{editor['type'] === 'plc-remote-device' && }
{(editor['type'] === 'plc-textual' || editor['type'] === 'plc-graphical') && (
{
},
}
}
+ if (serverData.protocol === 'opcua' && !serverData.opcuaServerConfig) {
+ return {
+ ...serverData,
+ opcuaServerConfig: {
+ ...DEFAULT_OPCUA_SERVER_CONFIG,
+ securityProfiles: DEFAULT_OPCUA_SERVER_CONFIG.securityProfiles.map((profile) => ({
+ ...profile,
+ id: uuidv4(),
+ })),
+ },
+ }
+ }
return serverData
}
@@ -1539,6 +1587,60 @@ const createProjectSlice: StateCreator = (se
return response
},
+ /**
+ * OPC-UA Server Actions
+ */
+ updateOpcUaServerConfig: (serverName, configUpdate): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server) {
+ response = { ok: false, message: 'Server not found' }
+ return
+ }
+ if (server.protocol !== 'opcua') {
+ response = { ok: false, message: 'Server is not an OPC-UA server' }
+ return
+ }
+ if (!server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA configuration not found' }
+ return
+ }
+
+ // Deep merge the config update
+ const mergeDeep = (target: Record, source: Record) => {
+ for (const key of Object.keys(source)) {
+ if (source[key] !== undefined) {
+ if (
+ source[key] &&
+ typeof source[key] === 'object' &&
+ !Array.isArray(source[key]) &&
+ target[key] &&
+ typeof target[key] === 'object' &&
+ !Array.isArray(target[key])
+ ) {
+ mergeDeep(target[key] as Record, source[key] as Record)
+ } else {
+ target[key] = source[key]
+ }
+ }
+ }
+ }
+
+ mergeDeep(
+ server.opcuaServerConfig as unknown as Record,
+ configUpdate as unknown as Record,
+ )
+ }),
+ )
+ return response
+ },
+
/**
* Remote Device Actions
*/
diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts
index 3b4161fd4..a26bf9d85 100644
--- a/src/renderer/store/slices/project/types.ts
+++ b/src/renderer/store/slices/project/types.ts
@@ -385,6 +385,11 @@ const _projectActionsSchema = z.object({
.returns(projectResponseSchema),
updateS7CommLogging: z.function().args(z.string(), S7CommLoggingSchema.partial()).returns(projectResponseSchema),
+ /**
+ * OPC-UA Server Actions
+ */
+ updateOpcUaServerConfig: z.function().args(z.string(), z.record(z.unknown())).returns(projectResponseSchema),
+
/**
* Remote Device Actions
*/
diff --git a/src/renderer/store/slices/tabs/types.ts b/src/renderer/store/slices/tabs/types.ts
index 7d9f324e3..430c812a1 100644
--- a/src/renderer/store/slices/tabs/types.ts
+++ b/src/renderer/store/slices/tabs/types.ts
@@ -30,7 +30,7 @@ const tabsPropsSchema = z.object({
}),
z.object({
type: z.literal('server'),
- protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip']),
+ protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']),
}),
z.object({
type: z.literal('remote-device'),
diff --git a/src/renderer/store/slices/tabs/utils.ts b/src/renderer/store/slices/tabs/utils.ts
index b48bc9e80..2b1699267 100644
--- a/src/renderer/store/slices/tabs/utils.ts
+++ b/src/renderer/store/slices/tabs/utils.ts
@@ -126,7 +126,7 @@ const CreateDeviceEditor = (name = 'device', derivation: 'configuration'): Edito
throw new Error('Invalid derivation value')
}
-const CreateServerEditor = (name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip'): EditorModel => {
+const CreateServerEditor = (name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua'): EditorModel => {
const editor = CreateEditorObject({
type: 'plc-server',
meta: {
diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts
index 972a78af2..c5c8a8f8f 100644
--- a/src/types/PLC/open-plc.ts
+++ b/src/types/PLC/open-plc.ts
@@ -276,7 +276,7 @@ const PLCConfigurationSchema = z.object({
})
type PLCConfiguration = z.infer
-const PLCServerProtocolSchema = z.enum(['modbus-tcp', 's7comm', 'ethernet-ip'])
+const PLCServerProtocolSchema = z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua'])
type PLCServerProtocol = z.infer
const ModbusSlaveBufferMappingSchema = z.object({
@@ -401,11 +401,140 @@ const S7CommSlaveConfigSchema = z.object({
})
type S7CommSlaveConfig = z.infer
+// ═══════════════════════════════════════════════════════════════════════════
+// OPC-UA Server Configuration Types
+// ═══════════════════════════════════════════════════════════════════════════
+
+// OPC-UA Security Policy Enumeration
+const OpcUaSecurityPolicySchema = z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256'])
+type OpcUaSecurityPolicy = z.infer
+
+// OPC-UA Security Mode Enumeration
+const OpcUaSecurityModeSchema = z.enum(['None', 'Sign', 'SignAndEncrypt'])
+type OpcUaSecurityMode = z.infer
+
+// OPC-UA Authentication Method Enumeration
+const OpcUaAuthMethodSchema = z.enum(['Anonymous', 'Username', 'Certificate'])
+type OpcUaAuthMethod = z.infer
+
+// OPC-UA User Role Enumeration
+const OpcUaUserRoleSchema = z.enum(['viewer', 'operator', 'engineer'])
+type OpcUaUserRole = z.infer
+
+// OPC-UA Security Profile Schema
+const OpcUaSecurityProfileSchema = z.object({
+ id: z.string(),
+ name: z.string().min(1).max(64),
+ enabled: z.boolean(),
+ securityPolicy: OpcUaSecurityPolicySchema,
+ securityMode: OpcUaSecurityModeSchema,
+ authMethods: z.array(OpcUaAuthMethodSchema).min(1),
+})
+type OpcUaSecurityProfile = z.infer
+
+// OPC-UA Trusted Certificate Schema
+const OpcUaTrustedCertificateSchema = z.object({
+ id: z.string().min(1).max(64),
+ pem: z.string(),
+ subject: z.string().optional(),
+ validFrom: z.string().optional(),
+ validTo: z.string().optional(),
+ fingerprint: z.string().optional(),
+})
+type OpcUaTrustedCertificate = z.infer
+
+// OPC-UA User Schema
+const OpcUaUserSchema = z.object({
+ id: z.string(),
+ type: z.enum(['password', 'certificate']),
+ username: z.string().nullable(),
+ passwordHash: z.string().nullable(),
+ certificateId: z.string().nullable(),
+ role: OpcUaUserRoleSchema,
+})
+type OpcUaUser = z.infer
+
+// OPC-UA Permissions Schema (per-variable access control)
+const OpcUaPermissionsSchema = z.object({
+ viewer: z.enum(['r', 'w', 'rw']),
+ operator: z.enum(['r', 'w', 'rw']),
+ engineer: z.enum(['r', 'w', 'rw']),
+})
+type OpcUaPermissions = z.infer
+
+// OPC-UA Field Configuration Schema (for structure fields)
+const OpcUaFieldConfigSchema = z.object({
+ fieldPath: z.string(),
+ displayName: z.string(),
+ initialValue: z.union([z.boolean(), z.number(), z.string()]),
+ permissions: OpcUaPermissionsSchema,
+})
+type OpcUaFieldConfig = z.infer
+
+// OPC-UA Node Configuration Schema (variable/structure/array)
+const OpcUaNodeConfigSchema = z.object({
+ id: z.string(),
+ pouName: z.string(),
+ variablePath: z.string(),
+ variableType: z.string(),
+ nodeId: z.string(),
+ browseName: z.string(),
+ displayName: z.string(),
+ description: z.string(),
+ initialValue: z.union([z.boolean(), z.number(), z.string()]),
+ permissions: OpcUaPermissionsSchema,
+ nodeType: z.enum(['variable', 'structure', 'array']),
+ fields: z.array(OpcUaFieldConfigSchema).optional(),
+ arrayLength: z.number().optional(),
+ elementType: z.string().optional(),
+})
+type OpcUaNodeConfig = z.infer
+
+// OPC-UA Server Settings Schema
+const OpcUaServerSettingsSchema = z.object({
+ enabled: z.boolean(),
+ name: z.string().max(128),
+ applicationUri: z.string(),
+ productUri: z.string(),
+ bindAddress: z.string(),
+ port: z.number().int().min(1).max(65535),
+ endpointPath: z.string(),
+})
+type OpcUaServerSettings = z.infer
+
+// OPC-UA Security Configuration Schema
+const OpcUaSecurityConfigSchema = z.object({
+ serverCertificateStrategy: z.enum(['auto_self_signed', 'custom']),
+ serverCertificateCustom: z.string().nullable(),
+ serverPrivateKeyCustom: z.string().nullable(),
+ trustedClientCertificates: z.array(OpcUaTrustedCertificateSchema),
+})
+type OpcUaSecurityConfig = z.infer
+
+// OPC-UA Address Space Configuration Schema
+const OpcUaAddressSpaceConfigSchema = z.object({
+ namespaceUri: z.string(),
+ nodes: z.array(OpcUaNodeConfigSchema),
+})
+type OpcUaAddressSpaceConfig = z.infer
+
+// Complete OPC-UA Server Configuration Schema
+const OpcUaServerConfigSchema = z.object({
+ server: OpcUaServerSettingsSchema,
+ securityProfiles: z.array(OpcUaSecurityProfileSchema),
+ security: OpcUaSecurityConfigSchema,
+ users: z.array(OpcUaUserSchema),
+ cycleTimeMs: z.number().int().min(10).max(10000),
+ addressSpace: OpcUaAddressSpaceConfigSchema,
+})
+type OpcUaServerConfig = z.infer
+
const PLCServerSchema = z.object({
name: z.string(),
protocol: PLCServerProtocolSchema,
modbusSlaveConfig: ModbusSlaveConfigSchema.optional(),
s7commSlaveConfig: S7CommSlaveConfigSchema.optional(),
+ opcuaServerConfig: OpcUaServerConfigSchema.optional(),
})
type PLCServer = z.infer
@@ -528,6 +657,20 @@ export {
ModbusSlaveBufferMappingSchema,
ModbusSlaveConfigSchema,
ModbusTcpConfigSchema,
+ OpcUaAddressSpaceConfigSchema,
+ OpcUaAuthMethodSchema,
+ OpcUaFieldConfigSchema,
+ OpcUaNodeConfigSchema,
+ OpcUaPermissionsSchema,
+ OpcUaSecurityConfigSchema,
+ OpcUaSecurityModeSchema,
+ OpcUaSecurityPolicySchema,
+ OpcUaSecurityProfileSchema,
+ OpcUaServerConfigSchema,
+ OpcUaServerSettingsSchema,
+ OpcUaTrustedCertificateSchema,
+ OpcUaUserRoleSchema,
+ OpcUaUserSchema,
PLCArrayDatatypeSchema,
PLCConfigurationSchema,
PLCDataTypeSchema,
@@ -571,6 +714,20 @@ export type {
ModbusSlaveBufferMapping,
ModbusSlaveConfig,
ModbusTcpConfig,
+ OpcUaAddressSpaceConfig,
+ OpcUaAuthMethod,
+ OpcUaFieldConfig,
+ OpcUaNodeConfig,
+ OpcUaPermissions,
+ OpcUaSecurityConfig,
+ OpcUaSecurityMode,
+ OpcUaSecurityPolicy,
+ OpcUaSecurityProfile,
+ OpcUaServerConfig,
+ OpcUaServerSettings,
+ OpcUaTrustedCertificate,
+ OpcUaUser,
+ OpcUaUserRole,
PLCArrayDatatype,
PLCConfiguration,
PLCDataType,
From 088765db6105a461fb7ba2b4b135e7d7f55c9e56 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Thu, 15 Jan 2026 20:51:25 -0500
Subject: [PATCH 03/21] feat: Implement Phase 2 OPC-UA Security Profiles tab
- Add security profile CRUD actions to project slice:
- addOpcUaSecurityProfile: Add new profile with duplicate name validation
- updateOpcUaSecurityProfile: Update existing profile
- removeOpcUaSecurityProfile: Remove profile (prevents deleting last one)
- Create SecurityProfileModal component with validation:
- Anonymous auth only with "None" security policy
- Security Mode must match Policy constraints
- Profile names must be unique
- At least one auth method required
- Create SecurityProfilesTab component:
- List profiles with enable/disable toggles
- Add/Edit/Delete functionality
- Warning for insecure profiles (None policy)
- Prevents disabling last enabled profile
- Update OpcUaServerEditor to use SecurityProfilesTab
Co-Authored-By: Claude Opus 4.5
---
.../components/security-profile-modal.tsx | 383 ++++++++++++++++++
.../components/security-profiles-tab.tsx | 230 +++++++++++
.../editor/server/opcua-server/index.tsx | 13 +-
src/renderer/store/slices/project/slice.ts | 111 ++++-
src/renderer/store/slices/project/types.ts | 31 ++
5 files changed, 763 insertions(+), 5 deletions(-)
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
new file mode 100644
index 000000000..361ebc89d
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
@@ -0,0 +1,383 @@
+import { Checkbox } from '@root/renderer/components/_atoms/checkbox'
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
+import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
+import type { OpcUaSecurityProfile } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+
+type SecurityPolicy = 'None' | 'Basic128Rsa15' | 'Basic256' | 'Basic256Sha256'
+type SecurityMode = 'None' | 'Sign' | 'SignAndEncrypt'
+type AuthMethod = 'Anonymous' | 'Username' | 'Certificate'
+
+interface SecurityProfileModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSave: (profile: OpcUaSecurityProfile) => void
+ existingProfile?: OpcUaSecurityProfile
+ existingNames: string[]
+}
+
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm 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 SECURITY_POLICIES: { value: SecurityPolicy; label: string }[] = [
+ { value: 'None', label: 'None (No Security)' },
+ { value: 'Basic128Rsa15', label: 'Basic128Rsa15' },
+ { value: 'Basic256', label: 'Basic256' },
+ { value: 'Basic256Sha256', label: 'Basic256Sha256 (Recommended)' },
+]
+
+const SECURITY_MODES: { value: SecurityMode; label: string }[] = [
+ { value: 'None', label: 'None' },
+ { value: 'Sign', label: 'Sign Only' },
+ { value: 'SignAndEncrypt', label: 'Sign and Encrypt' },
+]
+
+export const SecurityProfileModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ existingProfile,
+ existingNames,
+}: SecurityProfileModalProps) => {
+ const isEditing = !!existingProfile
+
+ // Form state
+ const [name, setName] = useState('')
+ const [enabled, setEnabled] = useState(true)
+ const [securityPolicy, setSecurityPolicy] = useState('None')
+ const [securityMode, setSecurityMode] = useState('None')
+ const [authMethods, setAuthMethods] = useState(['Anonymous'])
+
+ // Reset form when modal opens/closes or profile changes
+ useEffect(() => {
+ if (isOpen && existingProfile) {
+ setName(existingProfile.name)
+ setEnabled(existingProfile.enabled)
+ setSecurityPolicy(existingProfile.securityPolicy)
+ setSecurityMode(existingProfile.securityMode)
+ setAuthMethods(existingProfile.authMethods)
+ } else if (isOpen && !existingProfile) {
+ // Reset to defaults for new profile
+ setName('')
+ setEnabled(true)
+ setSecurityPolicy('None')
+ setSecurityMode('None')
+ setAuthMethods(['Anonymous'])
+ }
+ }, [isOpen, existingProfile])
+
+ // Validation rules
+ const validationErrors = useMemo(() => {
+ const errors: string[] = []
+
+ // Name validation
+ if (!name.trim()) {
+ errors.push('Profile name is required')
+ } else if (name.length > 64) {
+ errors.push('Profile name must be 64 characters or less')
+ } else {
+ // Check for duplicate name (case insensitive), excluding current profile when editing
+ const isDuplicate = existingNames.some(
+ (existingName) =>
+ existingName.toLowerCase() === name.trim().toLowerCase() &&
+ (!existingProfile || existingName.toLowerCase() !== existingProfile.name.toLowerCase()),
+ )
+ if (isDuplicate) {
+ errors.push('A profile with this name already exists')
+ }
+ }
+
+ // Security Policy + Mode validation
+ if (securityPolicy === 'None' && securityMode !== 'None') {
+ errors.push('Security Mode must be "None" when Security Policy is "None"')
+ }
+ if (securityPolicy !== 'None' && securityMode === 'None') {
+ errors.push('Security Mode cannot be "None" when using a security policy')
+ }
+
+ // Anonymous validation
+ if (authMethods.includes('Anonymous') && securityPolicy !== 'None') {
+ errors.push('Anonymous authentication is only available with Security Policy "None"')
+ }
+
+ // At least one auth method required
+ if (authMethods.length === 0) {
+ errors.push('At least one authentication method is required')
+ }
+
+ return errors
+ }, [name, securityPolicy, securityMode, authMethods, existingNames, existingProfile])
+
+ const isValid = validationErrors.length === 0
+
+ // Handle security policy change - auto-adjust mode if needed
+ const handleSecurityPolicyChange = useCallback((policy: SecurityPolicy) => {
+ setSecurityPolicy(policy)
+ if (policy === 'None') {
+ setSecurityMode('None')
+ // Anonymous is only valid with None policy, so add it if no auth methods
+ setAuthMethods((prev) => {
+ if (prev.length === 0) return ['Anonymous']
+ return prev
+ })
+ } else {
+ // If switching from None to a real policy, remove Anonymous and set a valid mode
+ setAuthMethods((prev) => prev.filter((m) => m !== 'Anonymous'))
+ setSecurityMode((prev) => (prev === 'None' ? 'SignAndEncrypt' : prev))
+ }
+ }, [])
+
+ // Handle auth method toggle
+ const toggleAuthMethod = useCallback(
+ (method: AuthMethod) => {
+ setAuthMethods((prev) => {
+ if (prev.includes(method)) {
+ // Don't allow removing the last auth method
+ if (prev.length === 1) return prev
+ return prev.filter((m) => m !== method)
+ }
+ // Don't allow Anonymous with non-None policy
+ if (method === 'Anonymous' && securityPolicy !== 'None') {
+ return prev
+ }
+ return [...prev, method]
+ })
+ },
+ [securityPolicy],
+ )
+
+ // Handle save
+ const handleSave = useCallback(() => {
+ if (!isValid) return
+
+ const profile: OpcUaSecurityProfile = {
+ id: existingProfile?.id ?? uuidv4(),
+ name: name.trim(),
+ enabled,
+ securityPolicy,
+ securityMode,
+ authMethods,
+ }
+
+ onSave(profile)
+ onClose()
+ }, [isValid, name, enabled, securityPolicy, securityMode, authMethods, existingProfile, onSave, onClose])
+
+ return (
+ !open && onClose()}>
+
+
+ {isEditing ? 'Edit Security Profile' : 'Add Security Profile'}
+
+
+
+ {/* Profile Name */}
+
+ Profile Name
+ setName(e.target.value)}
+ placeholder='e.g., secure_encrypted'
+ maxLength={64}
+ className={inputStyles}
+ />
+ Unique identifier for this profile
+
+
+ {/* Enable Toggle */}
+
+
Enabled
+
+ setEnabled(e.target.checked)}
+ className='peer sr-only'
+ />
+
+
+
+
+ {/* Security Settings Section */}
+
+
Security Settings
+
+ {/* Security Policy */}
+
+ Security Policy
+ handleSecurityPolicyChange(v as SecurityPolicy)}>
+
+
+ {SECURITY_POLICIES.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ {/* Security Mode */}
+
+ Security Mode
+ setSecurityMode(v as SecurityMode)}
+ disabled={securityPolicy === 'None'}
+ >
+
+
+ {SECURITY_MODES.filter((m) =>
+ securityPolicy === 'None' ? m.value === 'None' : m.value !== 'None',
+ ).map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+ {securityPolicy === 'None' && (
+
+ Security Mode is fixed to "None" when Security Policy is "None"
+
+ )}
+
+
+
+ {/* Authentication Methods Section */}
+
+
+ Authentication Methods
+
+
+ Select at least one authentication method for this profile.
+
+
+ {/* Anonymous */}
+
+
toggleAuthMethod('Anonymous')}
+ disabled={securityPolicy !== 'None'}
+ />
+
+
+ Anonymous
+
+
+ {securityPolicy !== 'None'
+ ? 'Only available with Security Policy "None"'
+ : 'No authentication required'}
+
+
+
+
+ {/* Username */}
+
+
toggleAuthMethod('Username')}
+ />
+
+ Username / Password
+
+ Users must be configured in the Users tab
+
+
+
+
+ {/* Certificate */}
+
+
toggleAuthMethod('Certificate')}
+ />
+
+ Certificate
+
+ Client certificates must be added to trusted list
+
+
+
+
+
+ {/* Validation Errors */}
+ {validationErrors.length > 0 && (
+
+
+ {validationErrors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {isEditing ? 'Save Changes' : 'Add Profile'}
+
+
+
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
new file mode 100644
index 000000000..291832fa4
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
@@ -0,0 +1,230 @@
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useMemo, useState } from 'react'
+
+import { SecurityProfileModal } from './security-profile-modal'
+
+interface SecurityProfilesTabProps {
+ config: OpcUaServerConfig
+ serverName: string
+ onConfigChange: () => void
+}
+
+// Helper to get a human-readable description for a security profile
+const getProfileDescription = (profile: OpcUaSecurityProfile): string => {
+ if (profile.securityPolicy === 'None') {
+ return 'No encryption or authentication. Use only for development/testing.'
+ }
+ if (profile.securityMode === 'Sign') {
+ return 'Messages are signed but not encrypted.'
+ }
+ return 'Full security: messages are signed and encrypted.'
+}
+
+// Helper to format auth methods for display
+const formatAuthMethods = (methods: OpcUaSecurityProfile['authMethods']): string => {
+ return methods.join(', ')
+}
+
+export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: SecurityProfilesTabProps) => {
+ const {
+ projectActions: { addOpcUaSecurityProfile, updateOpcUaSecurityProfile, removeOpcUaSecurityProfile },
+ } = useOpenPLCStore()
+
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [editingProfile, setEditingProfile] = useState(undefined)
+
+ // Get existing profile names for validation
+ const existingNames = useMemo(() => config.securityProfiles.map((p) => p.name), [config.securityProfiles])
+
+ // Check if at least one profile is enabled
+ const hasEnabledProfile = useMemo(() => config.securityProfiles.some((p) => p.enabled), [config.securityProfiles])
+
+ // Handle adding a new profile
+ const handleAddProfile = useCallback(() => {
+ setEditingProfile(undefined)
+ setIsModalOpen(true)
+ }, [])
+
+ // Handle editing an existing profile
+ const handleEditProfile = useCallback((profile: OpcUaSecurityProfile) => {
+ setEditingProfile(profile)
+ setIsModalOpen(true)
+ }, [])
+
+ // Handle saving a profile (add or update)
+ const handleSaveProfile = useCallback(
+ (profile: OpcUaSecurityProfile) => {
+ if (editingProfile) {
+ updateOpcUaSecurityProfile(serverName, profile.id, profile)
+ } else {
+ addOpcUaSecurityProfile(serverName, profile)
+ }
+ onConfigChange()
+ },
+ [serverName, editingProfile, addOpcUaSecurityProfile, updateOpcUaSecurityProfile, onConfigChange],
+ )
+
+ // Handle deleting a profile
+ const handleDeleteProfile = useCallback(
+ (profileId: string) => {
+ const result = removeOpcUaSecurityProfile(serverName, profileId)
+ if (result.ok) {
+ onConfigChange()
+ }
+ },
+ [serverName, removeOpcUaSecurityProfile, onConfigChange],
+ )
+
+ // Handle toggling profile enabled state
+ const handleToggleEnabled = useCallback(
+ (profile: OpcUaSecurityProfile) => {
+ // Don't allow disabling if it's the last enabled profile
+ if (profile.enabled) {
+ const enabledCount = config.securityProfiles.filter((p) => p.enabled).length
+ if (enabledCount <= 1) {
+ return // Can't disable the last enabled profile
+ }
+ }
+ updateOpcUaSecurityProfile(serverName, profile.id, { enabled: !profile.enabled })
+ onConfigChange()
+ },
+ [serverName, config.securityProfiles, updateOpcUaSecurityProfile, onConfigChange],
+ )
+
+ return (
+
+ {/* Header and description */}
+
+
+ Configure which security profiles clients can use to connect. At least one profile must be enabled.
+
+
+
+ {/* Warning if no profiles are enabled */}
+ {!hasEnabledProfile && (
+
+
+ Warning: At least one security profile must be enabled for clients to connect.
+
+
+ )}
+
+ {/* Add Profile Button */}
+
+ +
+ Add Security Profile
+
+
+ {/* Security Profiles List */}
+
+ {config.securityProfiles.map((profile) => (
+
+ {/* Header row with toggle, name, and actions */}
+
+
+ {/* Enable Toggle */}
+
+ handleToggleEnabled(profile)}
+ className='peer sr-only'
+ disabled={profile.enabled && config.securityProfiles.filter((p) => p.enabled).length <= 1}
+ />
+ p.enabled).length <= 1 &&
+ 'cursor-not-allowed opacity-50',
+ )}
+ />
+
+
+ {/* Profile Name */}
+
+ {profile.name}
+
+
+
+ {/* Actions */}
+
+ handleEditProfile(profile)}
+ className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ >
+ Edit
+
+ handleDeleteProfile(profile.id)}
+ disabled={config.securityProfiles.length <= 1}
+ className={cn(
+ 'h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950',
+ config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
+ )}
+ >
+ Delete
+
+
+
+
+ {/* Profile Details */}
+
+
+ Policy: {profile.securityPolicy}
+ {' | '}
+ Mode: {profile.securityMode}
+
+
+ Authentication: {formatAuthMethods(profile.authMethods)}
+
+
+ {getProfileDescription(profile)}
+
+
+ {/* Warning for insecure profiles */}
+ {profile.securityPolicy === 'None' && profile.enabled && (
+
+
!
+
+ Warning: No encryption or authentication. Use only for development/testing.
+
+
+ )}
+
+
+ ))}
+
+
+ {/* Note about certificates */}
+
+
+ Note: Security profiles with Certificate authentication require trusted client certificates to be configured
+ in the Certificates tab.
+
+
+
+ {/* Modal for adding/editing profiles */}
+
setIsModalOpen(false)}
+ onSave={handleSaveProfile}
+ existingProfile={editingProfile}
+ existingNames={existingNames}
+ />
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
index 9fa6ab931..8a74992ca 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
@@ -7,6 +7,8 @@ import type { OpcUaServerConfig } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { SecurityProfilesTab } from './components/security-profiles-tab'
+
/**
* OPC-UA Server Editor Component
*
@@ -169,10 +171,13 @@ export const OpcUaServerEditor = () => {
{/* Security Profiles Tab */}
-
+
+ setEditingState('unsaved')}
+ />
+
{/* Users Tab */}
diff --git a/src/renderer/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts
index f1a6fa87b..286ce1ed6 100644
--- a/src/renderer/store/slices/project/slice.ts
+++ b/src/renderer/store/slices/project/slice.ts
@@ -1,7 +1,7 @@
import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast'
+import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/types/PLC/open-plc'
import {
ModbusIOPoint,
- OpcUaServerConfig,
PLCArrayDatatype,
PLCDataType,
PLCGlobalVariable,
@@ -1641,6 +1641,115 @@ const createProjectSlice: StateCreator
= (se
return response
},
+ addOpcUaSecurityProfile: (serverName: string, profile: OpcUaSecurityProfile): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ // Check for duplicate profile name
+ const nameExists = server.opcuaServerConfig.securityProfiles.some(
+ (p) => p.name.toLowerCase() === profile.name.toLowerCase(),
+ )
+ if (nameExists) {
+ response = { ok: false, message: `A security profile named "${profile.name}" already exists` }
+ toast({
+ title: 'Invalid Security Profile',
+ description: `A security profile named "${profile.name}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ server.opcuaServerConfig.securityProfiles.push(profile)
+ }),
+ )
+ return response
+ },
+
+ updateOpcUaSecurityProfile: (
+ serverName: string,
+ profileId: string,
+ updates: Partial,
+ ): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const profile = server.opcuaServerConfig.securityProfiles.find((p) => p.id === profileId)
+ if (!profile) {
+ response = { ok: false, message: 'Security profile not found' }
+ return
+ }
+ // Check for duplicate name if changing
+ if (updates.name !== undefined && updates.name.toLowerCase() !== profile.name.toLowerCase()) {
+ const nameExists = server.opcuaServerConfig.securityProfiles.some(
+ (p) => p.id !== profileId && p.name.toLowerCase() === updates.name!.toLowerCase(),
+ )
+ if (nameExists) {
+ response = { ok: false, message: `A security profile named "${updates.name}" already exists` }
+ toast({
+ title: 'Invalid Security Profile',
+ description: `A security profile named "${updates.name}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ }
+ Object.assign(profile, updates)
+ }),
+ )
+ return response
+ },
+
+ removeOpcUaSecurityProfile: (serverName: string, profileId: string): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const index = server.opcuaServerConfig.securityProfiles.findIndex((p) => p.id === profileId)
+ if (index === -1) {
+ response = { ok: false, message: 'Security profile not found' }
+ return
+ }
+ // Prevent deleting the last profile
+ if (server.opcuaServerConfig.securityProfiles.length <= 1) {
+ response = { ok: false, message: 'At least one security profile is required' }
+ toast({
+ title: 'Cannot Delete Profile',
+ description: 'At least one security profile must exist.',
+ variant: 'fail',
+ })
+ return
+ }
+ server.opcuaServerConfig.securityProfiles.splice(index, 1)
+ }),
+ )
+ return response
+ },
+
/**
* Remote Device Actions
*/
diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts
index a26bf9d85..050df0923 100644
--- a/src/renderer/store/slices/project/types.ts
+++ b/src/renderer/store/slices/project/types.ts
@@ -389,6 +389,37 @@ const _projectActionsSchema = z.object({
* OPC-UA Server Actions
*/
updateOpcUaServerConfig: z.function().args(z.string(), z.record(z.unknown())).returns(projectResponseSchema),
+ addOpcUaSecurityProfile: z
+ .function()
+ .args(
+ z.string(),
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ enabled: z.boolean(),
+ securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']),
+ securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']),
+ authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])),
+ }),
+ )
+ .returns(projectResponseSchema),
+ updateOpcUaSecurityProfile: z
+ .function()
+ .args(
+ z.string(),
+ z.string(),
+ z
+ .object({
+ name: z.string(),
+ enabled: z.boolean(),
+ securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']),
+ securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']),
+ authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])),
+ })
+ .partial(),
+ )
+ .returns(projectResponseSchema),
+ removeOpcUaSecurityProfile: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
/**
* Remote Device Actions
From ec1ec87b2922cb503600a26a808117a6cf9c4355 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Thu, 15 Jan 2026 21:17:45 -0500
Subject: [PATCH 04/21] feat: Implement Phase 3 OPC-UA Users and Certificates
tabs
Users Tab:
- Add user CRUD actions (addOpcUaUser, updateOpcUaUser, removeOpcUaUser)
- UserModal with password/certificate authentication type selector
- Password hashing using SHA-256 (Web Crypto API)
- Role selector (Viewer/Operator/Engineer) with descriptions
- Certificate user binding to trusted certificates
- Validation for duplicate usernames and certificate bindings
Certificates Tab:
- Server certificate strategy (auto_self_signed/custom)
- Custom certificate and private key upload with file browse
- Trusted client certificates list with Add/Delete
- Certificate ID validation and PEM format validation
- Prevents deleting certificates bound to users
Co-Authored-By: Claude Opus 4.5
---
.../components/certificate-modal.tsx | 263 ++++++++++++
.../components/certificates-tab.tsx | 327 ++++++++++++++
.../opcua-server/components/user-modal.tsx | 406 ++++++++++++++++++
.../opcua-server/components/users-tab.tsx | 237 ++++++++++
.../editor/server/opcua-server/index.tsx | 20 +-
src/renderer/store/slices/project/slice.ts | 230 +++++++++-
src/renderer/store/slices/project/types.ts | 63 +++
7 files changed, 1537 insertions(+), 9 deletions(-)
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
new file mode 100644
index 000000000..12eb5f252
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
@@ -0,0 +1,263 @@
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
+import type { OpcUaTrustedCertificate } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+interface CertificateModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSave: (certificate: OpcUaTrustedCertificate) => void
+ existingCertificateIds: string[]
+}
+
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm 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 textareaStyles =
+ 'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-mono text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+
+// Simple PEM validation and info extraction
+// Note: For production, consider using a proper certificate parsing library like node-forge
+const parsePemCertificate = (pem: string): { valid: boolean; subject?: string; error?: string } => {
+ const trimmed = pem.trim()
+
+ if (!trimmed.startsWith('-----BEGIN CERTIFICATE-----')) {
+ return { valid: false, error: 'Certificate must start with "-----BEGIN CERTIFICATE-----"' }
+ }
+
+ if (!trimmed.endsWith('-----END CERTIFICATE-----')) {
+ return { valid: false, error: 'Certificate must end with "-----END CERTIFICATE-----"' }
+ }
+
+ // Extract the base64 content
+ const lines = trimmed.split('\n')
+ const contentLines = lines.slice(1, -1)
+ const base64Content = contentLines.join('').trim()
+
+ if (base64Content.length < 100) {
+ return { valid: false, error: 'Certificate content appears to be too short' }
+ }
+
+ // Try to validate base64
+ try {
+ atob(base64Content)
+ } catch {
+ return { valid: false, error: 'Certificate contains invalid base64 content' }
+ }
+
+ // Certificate appears valid - we can't parse the details without a proper library
+ // but we can indicate it's a valid PEM format
+ return { valid: true, subject: '(Certificate details available after adding)' }
+}
+
+// Generate a simple fingerprint for display (not cryptographically secure)
+const generateSimpleFingerprint = (pem: string): string => {
+ const hash = Array.from(pem)
+ .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
+ .toString(16)
+ .toUpperCase()
+ .padStart(8, '0')
+ return `${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}:...`
+}
+
+export const CertificateModal = ({ isOpen, onClose, onSave, existingCertificateIds }: CertificateModalProps) => {
+ // Form state
+ const [certificateId, setCertificateId] = useState('')
+ const [pemContent, setPemContent] = useState('')
+ const [pemError, setPemError] = useState(null)
+
+ // Reset form when modal opens/closes
+ useEffect(() => {
+ if (isOpen) {
+ setCertificateId('')
+ setPemContent('')
+ setPemError(null)
+ }
+ }, [isOpen])
+
+ // Validate PEM when content changes
+ useEffect(() => {
+ if (pemContent.trim()) {
+ const result = parsePemCertificate(pemContent)
+ if (!result.valid) {
+ setPemError(result.error ?? 'Invalid certificate')
+ } else {
+ setPemError(null)
+ }
+ } else {
+ setPemError(null)
+ }
+ }, [pemContent])
+
+ // Certificate preview info
+ const certificateInfo = useMemo(() => {
+ if (!pemContent.trim() || pemError) return null
+ return {
+ fingerprint: generateSimpleFingerprint(pemContent),
+ }
+ }, [pemContent, pemError])
+
+ // Validation rules
+ const validationErrors = useMemo(() => {
+ const errors: string[] = []
+
+ // Certificate ID validation
+ if (!certificateId.trim()) {
+ errors.push('Certificate ID is required')
+ } else if (certificateId.length > 64) {
+ errors.push('Certificate ID must be 64 characters or less')
+ } else if (!/^[a-zA-Z0-9_-]+$/.test(certificateId.trim())) {
+ errors.push('Certificate ID can only contain letters, numbers, hyphens, and underscores')
+ } else {
+ // Check for duplicate ID
+ const isDuplicate = existingCertificateIds.some((id) => id.toLowerCase() === certificateId.trim().toLowerCase())
+ if (isDuplicate) {
+ errors.push('A certificate with this ID already exists')
+ }
+ }
+
+ // PEM validation
+ if (!pemContent.trim()) {
+ errors.push('Certificate PEM content is required')
+ } else if (pemError) {
+ errors.push(pemError)
+ }
+
+ return errors
+ }, [certificateId, pemContent, pemError, existingCertificateIds])
+
+ const isValid = validationErrors.length === 0
+
+ // Handle file browse
+ const handleBrowseFile = useCallback(() => {
+ // Use the file input approach since we're in a web context
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.pem,.crt,.cer'
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (file) {
+ void file.text().then((content) => {
+ setPemContent(content)
+ })
+ }
+ }
+ input.click()
+ }, [])
+
+ // Handle save
+ const handleSave = useCallback(() => {
+ if (!isValid) return
+
+ const certificate: OpcUaTrustedCertificate = {
+ id: certificateId.trim(),
+ pem: pemContent.trim(),
+ fingerprint: certificateInfo?.fingerprint,
+ }
+
+ onSave(certificate)
+ onClose()
+ }, [isValid, certificateId, pemContent, certificateInfo, onSave, onClose])
+
+ return (
+ !open && onClose()}>
+
+
+ Add Trusted Client Certificate
+
+
+
+ {/* Certificate ID */}
+
+ Certificate ID
+ setCertificateId(e.target.value)}
+ placeholder='e.g., engineer_client'
+ maxLength={64}
+ className={inputStyles}
+ />
+
+ Unique identifier used to reference this certificate in user configuration
+
+
+
+ {/* PEM Content */}
+
+ Certificate (PEM format)
+
+
+ {/* Certificate Details Preview */}
+ {certificateInfo && (
+
+
+ Certificate Details
+
+
+
+ Fingerprint: {certificateInfo.fingerprint}
+
+
+ Note: Full certificate details (subject, validity dates) will be parsed by the runtime.
+
+
+
+ )}
+
+ {/* Validation Errors */}
+ {validationErrors.length > 0 && (
+
+
+ {validationErrors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ Add Certificate
+
+
+
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
new file mode 100644
index 000000000..8323edd48
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
@@ -0,0 +1,327 @@
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { OpcUaServerConfig, OpcUaTrustedCertificate } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useMemo, useState } from 'react'
+
+import { CertificateModal } from './certificate-modal'
+
+interface CertificatesTabProps {
+ config: OpcUaServerConfig
+ serverName: string
+ onConfigChange: () => void
+}
+
+const textareaStyles =
+ 'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-mono text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+
+type CertificateStrategy = 'auto_self_signed' | 'custom'
+
+export const CertificatesTab = ({ config, serverName, onConfigChange }: CertificatesTabProps) => {
+ const {
+ projectActions: { updateOpcUaServerCertificateStrategy, addOpcUaTrustedCertificate, removeOpcUaTrustedCertificate },
+ } = useOpenPLCStore()
+
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [customCert, setCustomCert] = useState(config.security.serverCertificateCustom ?? '')
+ const [customKey, setCustomKey] = useState(config.security.serverPrivateKeyCustom ?? '')
+
+ // Get existing certificate IDs for validation
+ const existingCertificateIds = useMemo(
+ () => config.security.trustedClientCertificates.map((c) => c.id),
+ [config.security.trustedClientCertificates],
+ )
+
+ // Handle strategy change
+ const handleStrategyChange = useCallback(
+ (strategy: CertificateStrategy) => {
+ if (strategy === 'custom') {
+ updateOpcUaServerCertificateStrategy(serverName, strategy, customCert || null, customKey || null)
+ } else {
+ updateOpcUaServerCertificateStrategy(serverName, strategy, null, null)
+ }
+ onConfigChange()
+ },
+ [serverName, customCert, customKey, updateOpcUaServerCertificateStrategy, onConfigChange],
+ )
+
+ // Handle custom certificate change
+ const handleCustomCertChange = useCallback(
+ (cert: string) => {
+ setCustomCert(cert)
+ if (config.security.serverCertificateStrategy === 'custom') {
+ updateOpcUaServerCertificateStrategy(serverName, 'custom', cert || null, customKey || null)
+ onConfigChange()
+ }
+ },
+ [
+ serverName,
+ customKey,
+ config.security.serverCertificateStrategy,
+ updateOpcUaServerCertificateStrategy,
+ onConfigChange,
+ ],
+ )
+
+ // Handle custom key change
+ const handleCustomKeyChange = useCallback(
+ (key: string) => {
+ setCustomKey(key)
+ if (config.security.serverCertificateStrategy === 'custom') {
+ updateOpcUaServerCertificateStrategy(serverName, 'custom', customCert || null, key || null)
+ onConfigChange()
+ }
+ },
+ [
+ serverName,
+ customCert,
+ config.security.serverCertificateStrategy,
+ updateOpcUaServerCertificateStrategy,
+ onConfigChange,
+ ],
+ )
+
+ // Handle file browse for certificate
+ const handleBrowseCertFile = useCallback(() => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.pem,.crt,.cer'
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (file) {
+ void file.text().then((content) => {
+ handleCustomCertChange(content)
+ })
+ }
+ }
+ input.click()
+ }, [handleCustomCertChange])
+
+ // Handle file browse for key
+ const handleBrowseKeyFile = useCallback(() => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.pem,.key'
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (file) {
+ void file.text().then((content) => {
+ handleCustomKeyChange(content)
+ })
+ }
+ }
+ input.click()
+ }, [handleCustomKeyChange])
+
+ // Handle adding a trusted certificate
+ const handleAddCertificate = useCallback(() => {
+ setIsModalOpen(true)
+ }, [])
+
+ // Handle saving a trusted certificate
+ const handleSaveCertificate = useCallback(
+ (certificate: OpcUaTrustedCertificate) => {
+ addOpcUaTrustedCertificate(serverName, certificate)
+ onConfigChange()
+ },
+ [serverName, addOpcUaTrustedCertificate, onConfigChange],
+ )
+
+ // Handle deleting a trusted certificate
+ const handleDeleteCertificate = useCallback(
+ (certificateId: string) => {
+ const result = removeOpcUaTrustedCertificate(serverName, certificateId)
+ if (result.ok) {
+ onConfigChange()
+ }
+ },
+ [serverName, removeOpcUaTrustedCertificate, onConfigChange],
+ )
+
+ return (
+
+ {/* Server Certificate Section */}
+
+
Server Certificate
+
+ {/* Certificate Strategy */}
+
+ Certificate Strategy
+ handleStrategyChange(v as CertificateStrategy)}
+ >
+
+
+
+
+ Auto-generate Self-Signed
+
+
+
+
+ Use Custom Certificate
+
+
+
+
+
+ {config.security.serverCertificateStrategy === 'auto_self_signed'
+ ? 'The runtime will automatically generate a self-signed certificate'
+ : 'Provide your own certificate and private key'}
+
+
+
+ {/* Custom Certificate Fields */}
+ {config.security.serverCertificateStrategy === 'custom' && (
+
+ {/* Server Certificate */}
+
+ Server Certificate (PEM format)
+
+
+ {/* Server Private Key */}
+
+ Server Private Key (PEM format)
+
+
+ )}
+
+
+ {/* Trusted Client Certificates Section */}
+
+
+ Trusted Client Certificates
+
+
+ Client certificates that are trusted for certificate authentication.
+
+
+ {/* Add Certificate Button */}
+
+ +
+ Add Trusted Certificate
+
+
+ {/* Certificates List */}
+ {config.security.trustedClientCertificates.length === 0 ? (
+
+
+ No trusted certificates configured. Add certificates to enable certificate-based authentication.
+
+
+ ) : (
+
+ {config.security.trustedClientCertificates.map((cert) => (
+
+ {/* Header row with icon, name, and actions */}
+
+
+ 📜
+
+ {cert.id}
+
+
+
+ {/* Actions */}
+
+ handleDeleteCertificate(cert.id)}
+ className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ >
+ Delete
+
+
+
+
+ {/* Certificate Details */}
+
+ {cert.subject && (
+
+ Subject: {cert.subject}
+
+ )}
+ {cert.validFrom && cert.validTo && (
+
+ Valid: {cert.validFrom} to {cert.validTo}
+
+ )}
+ {cert.fingerprint && (
+
+ Fingerprint: {cert.fingerprint}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {/* Modal for adding certificates */}
+
setIsModalOpen(false)}
+ onSave={handleSaveCertificate}
+ existingCertificateIds={existingCertificateIds}
+ />
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
new file mode 100644
index 000000000..b7ed199de
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -0,0 +1,406 @@
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
+import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
+import type { OpcUaTrustedCertificate, OpcUaUser } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+
+type AuthType = 'password' | 'certificate'
+type UserRole = 'viewer' | 'operator' | 'engineer'
+
+interface UserModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSave: (user: OpcUaUser) => void
+ existingUser?: OpcUaUser
+ existingUsernames: string[]
+ trustedCertificates: OpcUaTrustedCertificate[]
+ usedCertificateIds: string[]
+}
+
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm 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 ROLE_OPTIONS: { value: UserRole; label: string; description: string }[] = [
+ { value: 'viewer', label: 'Viewer', description: 'Read-only access to all variables' },
+ { value: 'operator', label: 'Operator', description: 'Read/Write based on per-variable permission settings' },
+ { value: 'engineer', label: 'Engineer', description: 'Full administrative access' },
+]
+
+// Simple password hashing using SHA-256 (Web Crypto API)
+// Note: For production, consider using bcrypt or similar
+const hashPassword = async (password: string): Promise => {
+ const encoder = new TextEncoder()
+ const data = encoder.encode(password)
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
+}
+
+export const UserModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ existingUser,
+ existingUsernames,
+ trustedCertificates,
+ usedCertificateIds,
+}: UserModalProps) => {
+ const isEditing = !!existingUser
+
+ // Form state
+ const [authType, setAuthType] = useState('password')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [showPassword, setShowPassword] = useState(false)
+ const [certificateId, setCertificateId] = useState(null)
+ const [role, setRole] = useState('operator')
+
+ // Reset form when modal opens/closes or user changes
+ useEffect(() => {
+ if (isOpen && existingUser) {
+ setAuthType(existingUser.type)
+ setUsername(existingUser.username ?? '')
+ setPassword('')
+ setConfirmPassword('')
+ setShowPassword(false)
+ setCertificateId(existingUser.certificateId)
+ setRole(existingUser.role)
+ } else if (isOpen && !existingUser) {
+ // Reset to defaults for new user
+ setAuthType('password')
+ setUsername('')
+ setPassword('')
+ setConfirmPassword('')
+ setShowPassword(false)
+ setCertificateId(null)
+ setRole('operator')
+ }
+ }, [isOpen, existingUser])
+
+ // Available certificates (not bound to other users, or bound to current user when editing)
+ const availableCertificates = useMemo(() => {
+ return trustedCertificates.filter(
+ (cert) => !usedCertificateIds.includes(cert.id) || existingUser?.certificateId === cert.id,
+ )
+ }, [trustedCertificates, usedCertificateIds, existingUser])
+
+ // Validation rules
+ const validationErrors = useMemo(() => {
+ const errors: string[] = []
+
+ if (authType === 'password') {
+ // Username validation
+ if (!username.trim()) {
+ errors.push('Username is required')
+ } else if (username.length > 64) {
+ errors.push('Username must be 64 characters or less')
+ } else {
+ // Check for duplicate username (case insensitive), excluding current user when editing
+ const isDuplicate = existingUsernames.some(
+ (existingName) =>
+ existingName.toLowerCase() === username.trim().toLowerCase() &&
+ (!existingUser || existingUser.username?.toLowerCase() !== existingName.toLowerCase()),
+ )
+ if (isDuplicate) {
+ errors.push('A user with this username already exists')
+ }
+ }
+
+ // Password validation (only required for new users or when changing password)
+ if (!isEditing || password) {
+ if (!password) {
+ errors.push('Password is required')
+ } else if (password.length < 4) {
+ errors.push('Password must be at least 4 characters')
+ } else if (password !== confirmPassword) {
+ errors.push('Passwords do not match')
+ }
+ }
+ } else {
+ // Certificate validation
+ if (!certificateId) {
+ errors.push('Please select a certificate')
+ }
+ if (availableCertificates.length === 0 && !existingUser?.certificateId) {
+ errors.push('No trusted certificates available. Add certificates in the Certificates tab first.')
+ }
+ }
+
+ return errors
+ }, [
+ authType,
+ username,
+ password,
+ confirmPassword,
+ certificateId,
+ existingUsernames,
+ existingUser,
+ isEditing,
+ availableCertificates,
+ ])
+
+ const isValid = validationErrors.length === 0
+
+ // Handle save
+ const handleSave = useCallback(async () => {
+ if (!isValid) return
+
+ let passwordHash: string | null = null
+
+ if (authType === 'password' && password) {
+ passwordHash = await hashPassword(password)
+ } else if (authType === 'password' && isEditing && existingUser?.passwordHash) {
+ // Keep existing password hash if not changing
+ passwordHash = existingUser.passwordHash
+ }
+
+ const user: OpcUaUser = {
+ id: existingUser?.id ?? uuidv4(),
+ type: authType,
+ username: authType === 'password' ? username.trim() : null,
+ passwordHash: authType === 'password' ? passwordHash : null,
+ certificateId: authType === 'certificate' ? certificateId : null,
+ role,
+ }
+
+ onSave(user)
+ onClose()
+ }, [isValid, authType, username, password, certificateId, role, existingUser, isEditing, onSave, onClose])
+
+ return (
+ !open && onClose()}>
+
+
+ {isEditing ? 'Edit User' : 'Add User'}
+
+
+
+ {/* Authentication Type */}
+
+ Authentication Type
+ setAuthType(v as AuthType)}>
+
+
+
+
+ Password
+
+
+
+
+ Certificate
+
+
+
+
+
+
+ {/* Password Authentication Section */}
+ {authType === 'password' && (
+
+
+ Password Authentication
+
+
+ {/* Username */}
+
+ Username
+ setUsername(e.target.value)}
+ placeholder='e.g., operator1'
+ maxLength={64}
+ className={inputStyles}
+ />
+
+
+ {/* Password */}
+
+
+ {isEditing ? 'New Password (leave blank to keep current)' : 'Password'}
+
+
+ setPassword(e.target.value)}
+ placeholder='••••••••'
+ className={cn(inputStyles, 'pr-10')}
+ />
+ setShowPassword(!showPassword)}
+ className='absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
+ >
+ {showPassword ? '🙈' : '👁'}
+
+
+
+
+ {/* Confirm Password */}
+
+ Confirm Password
+ setConfirmPassword(e.target.value)}
+ placeholder='••••••••'
+ className={inputStyles}
+ />
+
+
+ )}
+
+ {/* Certificate Authentication Section */}
+ {authType === 'certificate' && (
+
+
+ Certificate Authentication
+
+
+ {availableCertificates.length === 0 ? (
+
+ No trusted certificates available. Add certificates in the Certificates tab first.
+
+ ) : (
+
+ Client Certificate
+ setCertificateId(v || null)}>
+
+
+ {availableCertificates.map((cert) => (
+
+
+ {cert.id} {cert.subject && `(${cert.subject})`}
+
+
+ ))}
+
+
+
+ Select from trusted certificates configured in the Certificates tab
+
+
+ )}
+
+ )}
+
+ {/* User Role Section */}
+
+
User Role
+
+
+ Role
+ setRole(v as UserRole)}>
+
+
+ {ROLE_OPTIONS.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ {/* Role Descriptions */}
+
+ {ROLE_OPTIONS.map((option) => (
+
+ • {option.label}: {option.description}
+
+ ))}
+
+
+
+ {/* Validation Errors */}
+ {validationErrors.length > 0 && (
+
+
+ {validationErrors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Cancel
+
+ void handleSave()}
+ disabled={!isValid}
+ className={cn(
+ 'h-[32px] rounded-md bg-brand px-4 font-caption text-xs font-medium text-white hover:bg-brand-medium-dark',
+ !isValid && 'cursor-not-allowed opacity-50',
+ )}
+ >
+ {isEditing ? 'Save Changes' : 'Add User'}
+
+
+
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
new file mode 100644
index 000000000..93b7bc2b0
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
@@ -0,0 +1,237 @@
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { OpcUaServerConfig, OpcUaUser } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useMemo, useState } from 'react'
+
+import { UserModal } from './user-modal'
+
+interface UsersTabProps {
+ config: OpcUaServerConfig
+ serverName: string
+ onConfigChange: () => void
+}
+
+// Helper to get role display info
+const getRoleInfo = (role: OpcUaUser['role']): { label: string; description: string; color: string } => {
+ switch (role) {
+ case 'viewer':
+ return {
+ label: 'Viewer',
+ description: 'Read-only access',
+ color: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
+ }
+ case 'operator':
+ return {
+ label: 'Operator',
+ description: 'Read/Write per variable permissions',
+ color: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
+ }
+ case 'engineer':
+ return {
+ label: 'Engineer',
+ description: 'Full access',
+ color: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
+ }
+ }
+}
+
+// Helper to get user display name
+const getUserDisplayName = (user: OpcUaUser): string => {
+ if (user.type === 'password') {
+ return user.username ?? 'Unknown'
+ }
+ return user.certificateId ?? 'Unknown Certificate'
+}
+
+export const UsersTab = ({ config, serverName, onConfigChange }: UsersTabProps) => {
+ const {
+ projectActions: { addOpcUaUser, updateOpcUaUser, removeOpcUaUser },
+ } = useOpenPLCStore()
+
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [editingUser, setEditingUser] = useState(undefined)
+
+ // Check if username authentication is enabled in any security profile
+ const usernameAuthEnabled = useMemo(
+ () => config.securityProfiles.some((p) => p.enabled && p.authMethods.includes('Username')),
+ [config.securityProfiles],
+ )
+
+ // Check if we have any password users when username auth is enabled
+ const passwordUserCount = useMemo(() => config.users.filter((u) => u.type === 'password').length, [config.users])
+
+ // Get existing usernames for validation
+ const existingUsernames = useMemo(
+ () => config.users.filter((u) => u.type === 'password' && u.username).map((u) => u.username as string),
+ [config.users],
+ )
+
+ // Get certificate IDs that are already bound to users
+ const usedCertificateIds = useMemo(
+ () => config.users.filter((u) => u.type === 'certificate' && u.certificateId).map((u) => u.certificateId as string),
+ [config.users],
+ )
+
+ // Handle adding a new user
+ const handleAddUser = useCallback(() => {
+ setEditingUser(undefined)
+ setIsModalOpen(true)
+ }, [])
+
+ // Handle editing an existing user
+ const handleEditUser = useCallback((user: OpcUaUser) => {
+ setEditingUser(user)
+ setIsModalOpen(true)
+ }, [])
+
+ // Handle saving a user (add or update)
+ const handleSaveUser = useCallback(
+ (user: OpcUaUser) => {
+ if (editingUser) {
+ updateOpcUaUser(serverName, user.id, user)
+ } else {
+ addOpcUaUser(serverName, user)
+ }
+ onConfigChange()
+ },
+ [serverName, editingUser, addOpcUaUser, updateOpcUaUser, onConfigChange],
+ )
+
+ // Handle deleting a user
+ const handleDeleteUser = useCallback(
+ (userId: string) => {
+ removeOpcUaUser(serverName, userId)
+ onConfigChange()
+ },
+ [serverName, removeOpcUaUser, onConfigChange],
+ )
+
+ return (
+
+ {/* Header and description */}
+
+
+ Configure user accounts for OPC-UA client authentication.
+
+
+
+ {/* Warning if username auth is enabled but no users */}
+ {usernameAuthEnabled && passwordUserCount === 0 && (
+
+
+ Warning: Username authentication is enabled but no password users exist. Add at least one user for clients
+ to authenticate.
+
+
+ )}
+
+ {/* Add User Button */}
+
+ +
+ Add User
+
+
+ {/* Users List */}
+ {config.users.length === 0 ? (
+
+
+ No users configured. Add users for password or certificate authentication.
+
+
+ ) : (
+
+ {config.users.map((user) => {
+ const roleInfo = getRoleInfo(user.role)
+ return (
+
+ {/* Header row with icon, name, and actions */}
+
+
+ {/* User Icon */}
+ {user.type === 'password' ? '👤' : '🔐'}
+
+ {/* User Name */}
+
+ {getUserDisplayName(user)}
+
+
+ {/* Role Badge */}
+
+ {roleInfo.label}
+
+
+
+ {/* Actions */}
+
+ handleEditUser(user)}
+ className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ >
+ Edit
+
+ handleDeleteUser(user.id)}
+ className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ >
+ Delete
+
+
+
+
+ {/* User Details */}
+
+
+ Type: {' '}
+ {user.type === 'password' ? 'Password Authentication' : 'Certificate Authentication'}
+
+ {user.type === 'certificate' && user.certificateId && (
+
+ Certificate: {user.certificateId}
+
+ )}
+
{roleInfo.description}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Role Descriptions */}
+
+
Role Descriptions:
+
+
+ • Viewer: Can only read variable values (monitoring)
+
+
+ • Operator: Can read/write based on per-variable permissions
+
+
+ • Engineer: Full administrative access to all variables
+
+
+
+
+ {/* Modal for adding/editing users */}
+
setIsModalOpen(false)}
+ onSave={handleSaveUser}
+ existingUser={editingUser}
+ existingUsernames={existingUsernames}
+ trustedCertificates={config.security.trustedClientCertificates}
+ usedCertificateIds={usedCertificateIds}
+ />
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
index 8a74992ca..5dc3536b6 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
@@ -7,7 +7,9 @@ import type { OpcUaServerConfig } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { CertificatesTab } from './components/certificates-tab'
import { SecurityProfilesTab } from './components/security-profiles-tab'
+import { UsersTab } from './components/users-tab'
/**
* OPC-UA Server Editor Component
@@ -182,18 +184,20 @@ export const OpcUaServerEditor = () => {
{/* Users Tab */}
-
+
+ setEditingState('unsaved')} />
+
{/* Certificates Tab */}
-
+
+ setEditingState('unsaved')}
+ />
+
{/* Address Space Tab */}
diff --git a/src/renderer/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts
index 286ce1ed6..53262a62d 100644
--- a/src/renderer/store/slices/project/slice.ts
+++ b/src/renderer/store/slices/project/slice.ts
@@ -1,5 +1,10 @@
import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast'
-import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/types/PLC/open-plc'
+import type {
+ OpcUaSecurityProfile,
+ OpcUaServerConfig,
+ OpcUaTrustedCertificate,
+ OpcUaUser,
+} from '@root/types/PLC/open-plc'
import {
ModbusIOPoint,
PLCArrayDatatype,
@@ -1750,6 +1755,229 @@ const createProjectSlice: StateCreator = (se
return response
},
+ /**
+ * OPC-UA User Actions
+ */
+ addOpcUaUser: (serverName: string, user: OpcUaUser): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ // Check for duplicate username (for password users)
+ if (user.type === 'password' && user.username) {
+ const usernameExists = server.opcuaServerConfig.users.some(
+ (u) => u.type === 'password' && u.username?.toLowerCase() === user.username?.toLowerCase(),
+ )
+ if (usernameExists) {
+ response = { ok: false, message: `A user with username "${user.username}" already exists` }
+ toast({
+ title: 'Invalid User',
+ description: `A user with username "${user.username}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ }
+ // Check for duplicate certificate binding (for certificate users)
+ if (user.type === 'certificate' && user.certificateId) {
+ const certBindingExists = server.opcuaServerConfig.users.some(
+ (u) => u.type === 'certificate' && u.certificateId === user.certificateId,
+ )
+ if (certBindingExists) {
+ response = { ok: false, message: `A user is already bound to this certificate` }
+ toast({
+ title: 'Invalid User',
+ description: 'A user is already bound to this certificate.',
+ variant: 'fail',
+ })
+ return
+ }
+ }
+ server.opcuaServerConfig.users.push(user)
+ }),
+ )
+ return response
+ },
+
+ updateOpcUaUser: (serverName: string, userId: string, updates: Partial): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const user = server.opcuaServerConfig.users.find((u) => u.id === userId)
+ if (!user) {
+ response = { ok: false, message: 'User not found' }
+ return
+ }
+ // Check for duplicate username if changing
+ if (updates.username !== undefined && updates.username !== user.username) {
+ const usernameExists = server.opcuaServerConfig.users.some(
+ (u) =>
+ u.id !== userId &&
+ u.type === 'password' &&
+ u.username?.toLowerCase() === updates.username?.toLowerCase(),
+ )
+ if (usernameExists) {
+ response = { ok: false, message: `A user with username "${updates.username}" already exists` }
+ toast({
+ title: 'Invalid User',
+ description: `A user with username "${updates.username}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ }
+ Object.assign(user, updates)
+ }),
+ )
+ return response
+ },
+
+ removeOpcUaUser: (serverName: string, userId: string): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const index = server.opcuaServerConfig.users.findIndex((u) => u.id === userId)
+ if (index === -1) {
+ response = { ok: false, message: 'User not found' }
+ return
+ }
+ server.opcuaServerConfig.users.splice(index, 1)
+ }),
+ )
+ return response
+ },
+
+ /**
+ * OPC-UA Certificate Actions
+ */
+ updateOpcUaServerCertificateStrategy: (
+ serverName: string,
+ strategy: 'auto_self_signed' | 'custom',
+ customCert?: string | null,
+ customKey?: string | null,
+ ): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ server.opcuaServerConfig.security.serverCertificateStrategy = strategy
+ if (strategy === 'custom') {
+ server.opcuaServerConfig.security.serverCertificateCustom = customCert ?? null
+ server.opcuaServerConfig.security.serverPrivateKeyCustom = customKey ?? null
+ } else {
+ server.opcuaServerConfig.security.serverCertificateCustom = null
+ server.opcuaServerConfig.security.serverPrivateKeyCustom = null
+ }
+ }),
+ )
+ return response
+ },
+
+ addOpcUaTrustedCertificate: (serverName: string, certificate: OpcUaTrustedCertificate): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ // Check for duplicate certificate ID
+ const idExists = server.opcuaServerConfig.security.trustedClientCertificates.some(
+ (c) => c.id.toLowerCase() === certificate.id.toLowerCase(),
+ )
+ if (idExists) {
+ response = { ok: false, message: `A certificate with ID "${certificate.id}" already exists` }
+ toast({
+ title: 'Invalid Certificate',
+ description: `A certificate with ID "${certificate.id}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ server.opcuaServerConfig.security.trustedClientCertificates.push(certificate)
+ }),
+ )
+ return response
+ },
+
+ removeOpcUaTrustedCertificate: (serverName: string, certificateId: string): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ // Check if any user is bound to this certificate
+ const userBound = server.opcuaServerConfig.users.find(
+ (u) => u.type === 'certificate' && u.certificateId === certificateId,
+ )
+ if (userBound) {
+ response = { ok: false, message: 'Cannot delete certificate that is bound to a user' }
+ toast({
+ title: 'Cannot Delete Certificate',
+ description: 'This certificate is bound to a user. Remove the user first.',
+ variant: 'fail',
+ })
+ return
+ }
+ const index = server.opcuaServerConfig.security.trustedClientCertificates.findIndex(
+ (c) => c.id === certificateId,
+ )
+ if (index === -1) {
+ response = { ok: false, message: 'Certificate not found' }
+ return
+ }
+ server.opcuaServerConfig.security.trustedClientCertificates.splice(index, 1)
+ }),
+ )
+ return response
+ },
+
/**
* Remote Device Actions
*/
diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts
index 050df0923..d9f316e34 100644
--- a/src/renderer/store/slices/project/types.ts
+++ b/src/renderer/store/slices/project/types.ts
@@ -421,6 +421,69 @@ const _projectActionsSchema = z.object({
.returns(projectResponseSchema),
removeOpcUaSecurityProfile: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+ /**
+ * OPC-UA User Actions
+ */
+ addOpcUaUser: z
+ .function()
+ .args(
+ z.string(),
+ z.object({
+ id: z.string(),
+ type: z.enum(['password', 'certificate']),
+ username: z.string().nullable(),
+ passwordHash: z.string().nullable(),
+ certificateId: z.string().nullable(),
+ role: z.enum(['viewer', 'operator', 'engineer']),
+ }),
+ )
+ .returns(projectResponseSchema),
+ updateOpcUaUser: z
+ .function()
+ .args(
+ z.string(),
+ z.string(),
+ z
+ .object({
+ type: z.enum(['password', 'certificate']),
+ username: z.string().nullable(),
+ passwordHash: z.string().nullable(),
+ certificateId: z.string().nullable(),
+ role: z.enum(['viewer', 'operator', 'engineer']),
+ })
+ .partial(),
+ )
+ .returns(projectResponseSchema),
+ removeOpcUaUser: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+
+ /**
+ * OPC-UA Certificate Actions
+ */
+ updateOpcUaServerCertificateStrategy: z
+ .function()
+ .args(
+ z.string(),
+ z.enum(['auto_self_signed', 'custom']),
+ z.string().nullable().optional(),
+ z.string().nullable().optional(),
+ )
+ .returns(projectResponseSchema),
+ addOpcUaTrustedCertificate: z
+ .function()
+ .args(
+ z.string(),
+ z.object({
+ id: z.string(),
+ pem: z.string(),
+ subject: z.string().optional(),
+ validFrom: z.string().optional(),
+ validTo: z.string().optional(),
+ fingerprint: z.string().optional(),
+ }),
+ )
+ .returns(projectResponseSchema),
+ removeOpcUaTrustedCertificate: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+
/**
* Remote Device Actions
*/
From 02e4149a5880eea491244798faf0a6054b7ee4b1 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Thu, 15 Jan 2026 22:00:14 -0500
Subject: [PATCH 05/21] feat: Implement Phase 4 OPC-UA Address Space tab
Add variable selection and OPC-UA node configuration:
- Add address space node actions to project slice:
- updateOpcUaAddressSpaceNamespace
- addOpcUaNode, updateOpcUaNode, removeOpcUaNode
- Create use-project-variables hook to extract variables from POUs
into a hierarchical tree structure
- Create UI components:
- VariableTree: displays project variables with expand/collapse
- VariableConfigModal: configure OPC-UA node properties
- SelectedVariablesList: shows configured OPC-UA nodes
- AddressSpaceTab: split-pane layout with namespace URI input
- Features:
- Browse and select PLC variables from programs and GVL
- Configure node ID, browse name, display name, description
- Set initial values and per-role permissions (viewer/operator/engineer)
- Support for variables, structures, arrays, and function blocks
Co-Authored-By: Claude Opus 4.5
---
.../components/address-space-tab.tsx | 226 ++++++++
.../components/selected-variables-list.tsx | 109 ++++
.../components/variable-config-modal.tsx | 499 ++++++++++++++++++
.../opcua-server/components/variable-tree.tsx | 261 +++++++++
.../hooks/use-project-variables.ts | 418 +++++++++++++++
.../editor/server/opcua-server/index.tsx | 22 +-
src/renderer/store/slices/project/slice.ts | 121 +++++
src/renderer/store/slices/project/types.ts | 12 +
8 files changed, 1654 insertions(+), 14 deletions(-)
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
new file mode 100644
index 000000000..354b99039
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
@@ -0,0 +1,226 @@
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { OpcUaNodeConfig, OpcUaServerConfig } from '@root/types/PLC/open-plc'
+import { useCallback, useMemo, useState } from 'react'
+
+import { findTreeNodeById, useProjectVariables, type VariableTreeNode } from '../hooks/use-project-variables'
+import { SelectedVariablesList } from './selected-variables-list'
+import { VariableConfigModal } from './variable-config-modal'
+import { VariableTree } from './variable-tree'
+
+interface AddressSpaceTabProps {
+ config: OpcUaServerConfig
+ serverName: string
+ onConfigChange: () => void
+}
+
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+
+export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressSpaceTabProps) => {
+ const {
+ projectActions: { updateOpcUaAddressSpaceNamespace, addOpcUaNode, updateOpcUaNode, removeOpcUaNode },
+ } = useOpenPLCStore()
+
+ // Get all project variables for the tree
+ const projectVariables = useProjectVariables()
+
+ // Local state
+ const [filter, setFilter] = useState('')
+ const [selectedVariableIds, setSelectedVariableIds] = useState>(() => {
+ // Initialize with IDs of already configured nodes
+ return new Set(config.addressSpace.nodes.map((n) => `${n.pouName}-${n.variablePath}`))
+ })
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [editingVariable, setEditingVariable] = useState(null)
+ const [editingConfig, setEditingConfig] = useState(undefined)
+
+ // Get existing node IDs for validation
+ const existingNodeIds = useMemo(() => config.addressSpace.nodes.map((n) => n.nodeId), [config.addressSpace.nodes])
+
+ // Handle namespace URI change
+ const handleNamespaceChange = useCallback(
+ (namespaceUri: string) => {
+ updateOpcUaAddressSpaceNamespace(serverName, namespaceUri)
+ onConfigChange()
+ },
+ [serverName, updateOpcUaAddressSpaceNamespace, onConfigChange],
+ )
+
+ // Handle variable selection from tree
+ const handleVariableSelect = useCallback(
+ (node: VariableTreeNode) => {
+ if (!node.isSelectable) return
+
+ // Check if already selected
+ const nodeKey = `${node.pouName}-${node.variablePath}`
+ if (selectedVariableIds.has(nodeKey)) {
+ // Deselect - remove from config if it exists
+ const existingNode = config.addressSpace.nodes.find(
+ (n) => n.pouName === node.pouName && n.variablePath === node.variablePath,
+ )
+ if (existingNode) {
+ removeOpcUaNode(serverName, existingNode.id)
+ onConfigChange()
+ }
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(nodeKey)
+ return next
+ })
+ } else {
+ // Select - open modal to configure
+ setEditingVariable(node)
+ setEditingConfig(undefined)
+ setIsModalOpen(true)
+ }
+ },
+ [selectedVariableIds, config.addressSpace.nodes, serverName, removeOpcUaNode, onConfigChange],
+ )
+
+ // Handle save from configuration modal
+ const handleSaveConfig = useCallback(
+ (nodeConfig: OpcUaNodeConfig) => {
+ const nodeKey = `${nodeConfig.pouName}-${nodeConfig.variablePath}`
+
+ if (editingConfig) {
+ // Update existing node
+ updateOpcUaNode(serverName, editingConfig.id, nodeConfig)
+ } else {
+ // Add new node
+ addOpcUaNode(serverName, nodeConfig)
+ setSelectedVariableIds((prev) => new Set(prev).add(nodeKey))
+ }
+ onConfigChange()
+ },
+ [serverName, editingConfig, addOpcUaNode, updateOpcUaNode, onConfigChange],
+ )
+
+ // Handle edit from selected list
+ const handleEditNode = useCallback(
+ (node: OpcUaNodeConfig) => {
+ // Find the corresponding tree node
+ const treeNode = findTreeNodeById(projectVariables, `${node.pouName}-${node.variablePath}`)
+ if (treeNode) {
+ setEditingVariable(treeNode)
+ setEditingConfig(node)
+ setIsModalOpen(true)
+ }
+ },
+ [projectVariables],
+ )
+
+ // Handle remove from selected list
+ const handleRemoveNode = useCallback(
+ (nodeId: string) => {
+ const node = config.addressSpace.nodes.find((n) => n.id === nodeId)
+ if (node) {
+ removeOpcUaNode(serverName, nodeId)
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(`${node.pouName}-${node.variablePath}`)
+ return next
+ })
+ onConfigChange()
+ }
+ },
+ [config.addressSpace.nodes, serverName, removeOpcUaNode, onConfigChange],
+ )
+
+ return (
+
+ {/* Header */}
+
+
+ Select PLC variables to expose via OPC-UA. Variable indices are resolved automatically during project
+ compilation.
+
+
+
+ {/* Namespace URI */}
+
+ Namespace URI
+ handleNamespaceChange(e.target.value)}
+ placeholder='urn:openplc:opcua:namespace'
+ className={inputStyles}
+ />
+
+ Unique namespace identifier for your OPC-UA address space
+
+
+
+ {/* Split Pane Layout */}
+
+ {/* Left Panel - Available Variables */}
+
+ {/* Panel Header */}
+
+
+ Available PLC Variables
+
+
Select variables to expose
+
+
+ {/* Filter Input */}
+
+ setFilter(e.target.value)}
+ placeholder='Filter variables...'
+ className='h-[28px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ />
+
+
+ {/* Variable Tree */}
+
+
+
+
+
+ {/* Right Panel - Selected Variables */}
+
+ {/* Panel Header */}
+
+
Selected for OPC-UA
+
+ {config.addressSpace.nodes.length} variable{config.addressSpace.nodes.length !== 1 ? 's' : ''} configured
+
+
+
+ {/* Selected Variables List */}
+
+
+
+
+
+
+ {/* Variable Configuration Modal */}
+
{
+ setIsModalOpen(false)
+ setEditingVariable(null)
+ setEditingConfig(undefined)
+ }}
+ onSave={handleSaveConfig}
+ variable={editingVariable}
+ existingConfig={editingConfig}
+ existingNodeIds={existingNodeIds}
+ />
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
new file mode 100644
index 000000000..244211565
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
@@ -0,0 +1,109 @@
+import type { OpcUaNodeConfig } from '@root/types/PLC/open-plc'
+
+interface SelectedVariablesListProps {
+ nodes: OpcUaNodeConfig[]
+ onEdit: (node: OpcUaNodeConfig) => void
+ onRemove: (nodeId: string) => void
+}
+
+// Get icon component for node type
+const NodeTypeIcon = ({ nodeType }: { nodeType: OpcUaNodeConfig['nodeType'] }) => {
+ switch (nodeType) {
+ case 'structure':
+ return (
+
+ S
+
+ )
+ case 'array':
+ return (
+
+ []
+
+ )
+ case 'variable':
+ default:
+ return (
+
+ V
+
+ )
+ }
+}
+
+// Format permissions for display
+const formatPermissions = (permissions: OpcUaNodeConfig['permissions']): string => {
+ return `V:${permissions.viewer} O:${permissions.operator} E:${permissions.engineer}`
+}
+
+export const SelectedVariablesList = ({ nodes, onEdit, onRemove }: SelectedVariablesListProps) => {
+ if (nodes.length === 0) {
+ return (
+
+
+ No variables selected. Select variables from the left panel to expose them via OPC-UA.
+
+
+ )
+ }
+
+ return (
+
+ {nodes.map((node) => (
+
+ {/* Header row with icon, name, and actions */}
+
+
+ {/* Node Icon */}
+
+
+
+
+ {/* Node Info */}
+
+
+ {node.displayName}
+
+ {node.nodeId}
+
+
+
+ {/* Actions */}
+
+ onEdit(node)}
+ className='h-[24px] rounded-md border border-neutral-300 bg-white px-2 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ >
+ Edit
+
+ onRemove(node.id)}
+ className='h-[24px] rounded-md border border-red-300 bg-white px-2 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ >
+ Remove
+
+
+
+
+ {/* Details */}
+
+
+ Variable: {node.pouName}:{node.variablePath}
+
+
+ Type: {node.variableType}
+
+
+ Permissions: {formatPermissions(node.permissions)}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
new file mode 100644
index 000000000..8b90d23ac
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -0,0 +1,499 @@
+import { InputWithRef } from '@root/renderer/components/_atoms/input'
+import { Label } from '@root/renderer/components/_atoms/label'
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
+import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
+import type { OpcUaNodeConfig, OpcUaPermissions } from '@root/types/PLC/open-plc'
+import { cn } from '@root/utils'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+
+import type { VariableTreeNode } from '../hooks/use-project-variables'
+
+interface VariableConfigModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSave: (config: OpcUaNodeConfig) => void
+ variable: VariableTreeNode | null
+ existingConfig?: OpcUaNodeConfig
+ existingNodeIds: string[]
+}
+
+const inputStyles =
+ 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm 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 textareaStyles =
+ 'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-caption text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+
+type PermissionLevel = 'r' | 'w' | 'rw'
+
+/**
+ * Generate a default node ID based on the variable path
+ */
+const generateNodeId = (pouName: string, variablePath: string): string => {
+ const cleanPath = variablePath.replace(/\./g, '.').replace(/\[/g, '_').replace(/\]/g, '')
+ return `PLC.${pouName}.${cleanPath}`
+}
+
+/**
+ * Generate a browse name from the variable path
+ */
+const generateBrowseName = (variablePath: string): string => {
+ const parts = variablePath.split('.')
+ return parts[parts.length - 1] || variablePath
+}
+
+/**
+ * Generate a display name from the variable path
+ */
+const generateDisplayName = (variablePath: string): string => {
+ const parts = variablePath.split('.')
+ const name = parts[parts.length - 1] || variablePath
+ // Convert camelCase/snake_case to Title Case with spaces
+ return name
+ .replace(/_/g, ' ')
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/^\s+/, '')
+ .split(' ')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ')
+}
+
+/**
+ * Get default initial value based on type
+ */
+const getDefaultInitialValue = (variableType?: string): string | number | boolean => {
+ if (!variableType) return 0
+ const type = variableType.toLowerCase()
+
+ if (type === 'bool') return false
+ if (type.includes('real') || type.includes('lreal')) return 0.0
+ if (type.includes('string')) return ''
+ return 0
+}
+
+/**
+ * Determine node type from variable tree node
+ */
+const getNodeType = (node: VariableTreeNode): 'variable' | 'structure' | 'array' => {
+ if (node.type === 'array') return 'array'
+ if (node.type === 'structure' || node.type === 'function_block') return 'structure'
+ return 'variable'
+}
+
+export const VariableConfigModal = ({
+ isOpen,
+ onClose,
+ onSave,
+ variable,
+ existingConfig,
+ existingNodeIds,
+}: VariableConfigModalProps) => {
+ // Form state
+ const [nodeId, setNodeId] = useState('')
+ const [browseName, setBrowseName] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [description, setDescription] = useState('')
+ const [initialValue, setInitialValue] = useState('')
+ const [viewerPerm, setViewerPerm] = useState('r')
+ const [operatorPerm, setOperatorPerm] = useState('r')
+ const [engineerPerm, setEngineerPerm] = useState('rw')
+
+ // Initialize form when modal opens or variable changes
+ useEffect(() => {
+ if (isOpen && variable) {
+ if (existingConfig) {
+ // Editing existing configuration
+ setNodeId(existingConfig.nodeId)
+ setBrowseName(existingConfig.browseName)
+ setDisplayName(existingConfig.displayName)
+ setDescription(existingConfig.description)
+ setInitialValue(String(existingConfig.initialValue))
+ setViewerPerm(existingConfig.permissions.viewer)
+ setOperatorPerm(existingConfig.permissions.operator)
+ setEngineerPerm(existingConfig.permissions.engineer)
+ } else {
+ // New configuration - generate defaults
+ setNodeId(generateNodeId(variable.pouName, variable.variablePath))
+ setBrowseName(generateBrowseName(variable.variablePath))
+ setDisplayName(generateDisplayName(variable.variablePath))
+ setDescription('')
+ setInitialValue(String(getDefaultInitialValue(variable.variableType)))
+ setViewerPerm('r')
+ setOperatorPerm('r')
+ setEngineerPerm('rw')
+ }
+ }
+ }, [isOpen, variable, existingConfig])
+
+ // Validation
+ const validationErrors = useMemo(() => {
+ const errors: string[] = []
+
+ if (!nodeId.trim()) {
+ errors.push('Node ID is required')
+ } else if (nodeId.length > 128) {
+ errors.push('Node ID must be 128 characters or less')
+ } else {
+ // Check for duplicate Node ID (excluding current node if editing)
+ const isDuplicate = existingNodeIds.some((id) => {
+ if (existingConfig && existingConfig.nodeId === id) return false
+ return id.toLowerCase() === nodeId.trim().toLowerCase()
+ })
+ if (isDuplicate) {
+ errors.push('A node with this ID already exists')
+ }
+ }
+
+ if (!browseName.trim()) {
+ errors.push('Browse name is required')
+ } else if (browseName.length > 64) {
+ errors.push('Browse name must be 64 characters or less')
+ }
+
+ if (!displayName.trim()) {
+ errors.push('Display name is required')
+ } else if (displayName.length > 128) {
+ errors.push('Display name must be 128 characters or less')
+ }
+
+ return errors
+ }, [nodeId, browseName, displayName, existingNodeIds, existingConfig])
+
+ const isValid = validationErrors.length === 0
+
+ // Handle save
+ const handleSave = useCallback(() => {
+ if (!isValid || !variable) return
+
+ // Parse initial value based on type
+ let parsedInitialValue: boolean | number | string = initialValue
+ const varType = variable.variableType?.toLowerCase() || ''
+ if (varType === 'bool') {
+ parsedInitialValue = initialValue.toLowerCase() === 'true' || initialValue === '1'
+ } else if (
+ varType.includes('int') ||
+ varType.includes('real') ||
+ varType.includes('word') ||
+ varType.includes('byte')
+ ) {
+ const num = parseFloat(initialValue)
+ parsedInitialValue = isNaN(num) ? 0 : num
+ }
+
+ const permissions: OpcUaPermissions = {
+ viewer: viewerPerm,
+ operator: operatorPerm,
+ engineer: engineerPerm,
+ }
+
+ const config: OpcUaNodeConfig = {
+ id: existingConfig?.id || uuidv4(),
+ pouName: variable.pouName,
+ variablePath: variable.variablePath,
+ variableType: variable.variableType || 'unknown',
+ nodeId: nodeId.trim(),
+ browseName: browseName.trim(),
+ displayName: displayName.trim(),
+ description: description.trim(),
+ initialValue: parsedInitialValue,
+ permissions,
+ nodeType: getNodeType(variable),
+ }
+
+ onSave(config)
+ onClose()
+ }, [
+ isValid,
+ variable,
+ nodeId,
+ browseName,
+ displayName,
+ description,
+ initialValue,
+ viewerPerm,
+ operatorPerm,
+ engineerPerm,
+ existingConfig,
+ onSave,
+ onClose,
+ ])
+
+ if (!variable) return null
+
+ return (
+ !open && onClose()}>
+
+
+ {existingConfig ? 'Edit' : 'Configure'} OPC-UA Node
+
+
+
+ {/* Variable Info */}
+
+
+ PLC Variable: {variable.pouName}:{variable.variablePath}
+
+
+ Type: {variable.variableType || 'Unknown'}
+
+
+
+ {/* Node ID */}
+
+ Node ID
+ setNodeId(e.target.value)}
+ placeholder='e.g., PLC.Main.MotorSpeed'
+ maxLength={128}
+ className={inputStyles}
+ />
+
+ Unique identifier in the OPC-UA address space
+
+
+
+ {/* Browse Name & Display Name */}
+
+
+ Browse Name
+ setBrowseName(e.target.value)}
+ placeholder='e.g., MotorSpeed'
+ maxLength={64}
+ className={inputStyles}
+ />
+
+
+ Display Name
+ setDisplayName(e.target.value)}
+ placeholder='e.g., Motor Speed'
+ maxLength={128}
+ className={inputStyles}
+ />
+
+
+
+ {/* Description */}
+
+ Description
+
+
+ {/* Initial Value */}
+
+ Initial Value
+ setInitialValue(e.target.value)}
+ placeholder={variable.variableType?.toLowerCase() === 'bool' ? 'true/false' : '0'}
+ className={inputStyles}
+ />
+
+ Default value when the OPC-UA server starts
+
+
+
+ {/* Permissions */}
+
+
Access Permissions
+
+
+ {/* Viewer Permission */}
+
+ Viewer
+ setViewerPerm(v as PermissionLevel)}>
+
+
+
+
+ Read Only
+
+
+
+
+ Write Only
+
+
+
+
+ Read/Write
+
+
+
+
+
+
+ {/* Operator Permission */}
+
+ Operator
+ setOperatorPerm(v as PermissionLevel)}>
+
+
+
+
+ Read Only
+
+
+
+
+ Write Only
+
+
+
+
+ Read/Write
+
+
+
+
+
+
+ {/* Engineer Permission */}
+
+ Engineer
+ setEngineerPerm(v as PermissionLevel)}>
+
+
+
+
+ Read Only
+
+
+
+
+ Write Only
+
+
+
+
+ Read/Write
+
+
+
+
+
+
+
+
+ Configure access permissions for each user role
+
+
+
+ {/* Validation Errors */}
+ {validationErrors.length > 0 && (
+
+
+ {validationErrors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {existingConfig ? 'Save Changes' : 'Add to Address Space'}
+
+
+
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
new file mode 100644
index 000000000..f164847de
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
@@ -0,0 +1,261 @@
+import { cn } from '@root/utils'
+import { useCallback, useState } from 'react'
+
+import type { VariableTreeNode } from '../hooks/use-project-variables'
+
+interface VariableTreeProps {
+ nodes: VariableTreeNode[]
+ selectedIds: Set
+ onSelect: (node: VariableTreeNode) => void
+ filter?: string
+}
+
+// Icons for different node types (using text abbreviations)
+const NodeIcon = ({ type }: { type: VariableTreeNode['type'] }) => {
+ switch (type) {
+ case 'program':
+ return (
+
+ P
+
+ )
+ case 'function_block':
+ return (
+
+ FB
+
+ )
+ case 'global':
+ return (
+
+ G
+
+ )
+ case 'structure':
+ return (
+
+ S
+
+ )
+ case 'array':
+ return (
+
+ []
+
+ )
+ case 'variable':
+ return (
+
+ V
+
+ )
+ default:
+ return null
+ }
+}
+
+// Expand/collapse icon
+const ExpandIcon = ({ expanded, onClick }: { expanded: boolean; onClick: (e: React.MouseEvent) => void }) => (
+
+ {expanded ? '▼' : '▶'}
+
+)
+
+// Checkbox for selection
+const Checkbox = ({
+ checked,
+ indeterminate,
+ onChange,
+}: {
+ checked: boolean
+ indeterminate?: boolean
+ onChange: () => void
+}) => (
+ {
+ e.stopPropagation()
+ onChange()
+ }}
+ className={cn(
+ 'flex h-4 w-4 items-center justify-center rounded border',
+ checked
+ ? 'border-brand bg-brand text-white'
+ : indeterminate
+ ? 'bg-brand/50 border-brand text-white'
+ : 'border-neutral-400 bg-white dark:border-neutral-600 dark:bg-neutral-800',
+ )}
+ >
+ {checked && ✓ }
+ {!checked && indeterminate && − }
+
+)
+
+interface TreeNodeRowProps {
+ node: VariableTreeNode
+ depth: number
+ selectedIds: Set
+ expandedIds: Set
+ onToggleExpand: (nodeId: string) => void
+ onSelect: (node: VariableTreeNode) => void
+ filter?: string
+}
+
+const TreeNodeRow = ({ node, depth, selectedIds, expandedIds, onToggleExpand, onSelect, filter }: TreeNodeRowProps) => {
+ const hasChildren = node.children && node.children.length > 0
+ const isExpanded = expandedIds.has(node.id)
+ const isSelected = selectedIds.has(node.id)
+
+ // Check if any children are selected (for indeterminate state)
+ const hasSelectedChildren = hasChildren && node.children!.some((child) => checkAnySelected(child, selectedIds))
+
+ // Filter match
+ const matchesFilter = !filter || node.name.toLowerCase().includes(filter.toLowerCase())
+ const hasMatchingChildren =
+ !filter || (hasChildren && node.children!.some((child) => checkFilterMatch(child, filter)))
+
+ // Don't render if doesn't match filter and no children match
+ if (!matchesFilter && !hasMatchingChildren) {
+ return null
+ }
+
+ return (
+ <>
+
+ {/* Expand/collapse button */}
+ {hasChildren ? (
+
{
+ e.stopPropagation()
+ onToggleExpand(node.id)
+ }}
+ />
+ ) : (
+
+ )}
+
+ {/* Selection checkbox (only for selectable nodes) */}
+ {node.isSelectable ? (
+ onSelect(node)}
+ />
+ ) : (
+
+ )}
+
+ {/* Node icon */}
+
+
+ {/* Node name */}
+
+ {node.name}
+
+
+ {/* Variable type */}
+ {node.variableType && (
+ ({node.variableType})
+ )}
+
+
+ {/* Render children if expanded */}
+ {hasChildren && isExpanded && (
+ <>
+ {node.children!.map((child) => (
+
+ ))}
+ >
+ )}
+ >
+ )
+}
+
+// Helper to check if any node or its children are selected
+const checkAnySelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
+ if (selectedIds.has(node.id)) return true
+ if (node.children) {
+ return node.children.some((child) => checkAnySelected(child, selectedIds))
+ }
+ return false
+}
+
+// Helper to check if node or any children match filter
+const checkFilterMatch = (node: VariableTreeNode, filter: string): boolean => {
+ if (node.name.toLowerCase().includes(filter.toLowerCase())) return true
+ if (node.children) {
+ return node.children.some((child) => checkFilterMatch(child, filter))
+ }
+ return false
+}
+
+export const VariableTree = ({ nodes, selectedIds, onSelect, filter }: VariableTreeProps) => {
+ const [expandedIds, setExpandedIds] = useState>(() => {
+ // Auto-expand top-level nodes
+ return new Set(nodes.map((n) => n.id))
+ })
+
+ const handleToggleExpand = useCallback((nodeId: string) => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(nodeId)) {
+ next.delete(nodeId)
+ } else {
+ next.add(nodeId)
+ }
+ return next
+ })
+ }, [])
+
+ if (nodes.length === 0) {
+ return (
+
+
+ No program variables found. Create a program with variables first.
+
+
+ )
+ }
+
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
new file mode 100644
index 000000000..be7ce979a
--- /dev/null
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
@@ -0,0 +1,418 @@
+import { useOpenPLCStore } from '@root/renderer/store'
+import type { PLCDataType, PLCPou, PLCVariable } from '@root/types/PLC/open-plc'
+import { useMemo } from 'react'
+
+/**
+ * Tree node type for variable tree display
+ */
+export interface VariableTreeNode {
+ id: string
+ name: string
+ type: 'program' | 'function_block' | 'global' | 'variable' | 'structure' | 'array'
+ variableType?: string // IEC type for variables
+ children?: VariableTreeNode[]
+ pouName: string
+ variablePath: string
+ isSelectable: boolean
+ isExpanded?: boolean
+ // Additional metadata for OPC-UA node configuration
+ variableClass?: string
+ initialValue?: string | null
+}
+
+/**
+ * Check if a type is a base IEC type (reserved for Phase 5 - Complex Types)
+ */
+const _isBaseType = (typeName: string): boolean => {
+ const baseTypes = [
+ 'bool',
+ 'sint',
+ 'int',
+ 'dint',
+ 'lint',
+ 'usint',
+ 'uint',
+ 'udint',
+ 'ulint',
+ 'real',
+ 'lreal',
+ 'time',
+ 'date',
+ 'tod',
+ 'dt',
+ 'string',
+ 'byte',
+ 'word',
+ 'dword',
+ 'lword',
+ ]
+ return baseTypes.includes(typeName.toLowerCase())
+}
+
+/**
+ * Check if a type is a standard function block (TON, TOF, CTU, etc.)
+ * Reserved for Phase 5 - Complex Types
+ */
+const _isStandardFunctionBlock = (typeName: string): boolean => {
+ const standardFBs = ['ton', 'tof', 'tp', 'ctu', 'ctd', 'ctud', 'r_trig', 'f_trig', 'sr', 'rs']
+ return standardFBs.includes(typeName.toLowerCase())
+}
+
+/**
+ * Find a data type by name in the project's data types
+ */
+const findDataType = (typeName: string, dataTypes: PLCDataType[]): PLCDataType | undefined => {
+ return dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase())
+}
+
+/**
+ * Find a function block by name in the project's POUs
+ */
+const findFunctionBlock = (typeName: string, pous: PLCPou[]): PLCPou | undefined => {
+ return pous.find((pou) => pou.type === 'function-block' && pou.data.name.toLowerCase() === typeName.toLowerCase())
+}
+
+/**
+ * Get the type name from a PLCVariable's type field
+ */
+const getTypeName = (variable: PLCVariable | { type: PLCVariable['type']; name: string }): string => {
+ if (variable.type.definition === 'base-type') {
+ return variable.type.value
+ } else if (variable.type.definition === 'array') {
+ return variable.type.value
+ } else {
+ return variable.type.value
+ }
+}
+
+/**
+ * Get display type string for a variable
+ */
+const getDisplayType = (variable: PLCVariable | { type: PLCVariable['type']; name: string }): string => {
+ if (variable.type.definition === 'base-type') {
+ return variable.type.value.toUpperCase()
+ } else if (variable.type.definition === 'array' && variable.type.data) {
+ const dimensions = variable.type.data.dimensions.map((d) => d.dimension).join(', ')
+ const baseType =
+ variable.type.data.baseType.definition === 'base-type'
+ ? variable.type.data.baseType.value.toUpperCase()
+ : variable.type.data.baseType.value
+ return `ARRAY[${dimensions}] OF ${baseType}`
+ } else if (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') {
+ return variable.type.value
+ }
+ return variable.type.value
+}
+
+/**
+ * Build a tree node for a structure type
+ */
+const buildStructureNode = (
+ variable: PLCVariable,
+ pouName: string,
+ dataType: PLCDataType,
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+ parentPath: string = '',
+): VariableTreeNode => {
+ if (dataType.derivation !== 'structure') {
+ throw new Error('Expected structure data type')
+ }
+
+ const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'structure',
+ variableType: dataType.name,
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass: 'class' in variable ? variable.class : undefined,
+ initialValue: variable.initialValue,
+ children: dataType.variable.map((field) => {
+ const fieldTypeName = getTypeName(field)
+ const fieldPath = `${variablePath}.${field.name}`
+
+ // Check if field is a nested structure
+ const nestedDataType = findDataType(fieldTypeName, dataTypes)
+ if (nestedDataType && nestedDataType.derivation === 'structure') {
+ return buildStructureNode(
+ { ...field, location: '', documentation: '' } as PLCVariable,
+ pouName,
+ nestedDataType,
+ dataTypes,
+ pous,
+ variablePath,
+ )
+ }
+
+ // Check if field is an array
+ if (field.type.definition === 'array' && field.type.data) {
+ return buildArrayNode(
+ { ...field, location: '', documentation: '' } as PLCVariable,
+ pouName,
+ dataTypes,
+ pous,
+ variablePath,
+ )
+ }
+
+ // Simple variable field
+ return {
+ id: `${pouName}-${fieldPath}`,
+ name: field.name,
+ type: 'variable' as const,
+ variableType: getDisplayType(field),
+ pouName,
+ variablePath: fieldPath,
+ isSelectable: true,
+ initialValue: field.initialValue?.simpleValue?.value,
+ }
+ }),
+ }
+}
+
+/**
+ * Build a tree node for an array type
+ * Note: _dataTypes and _pous are reserved for Phase 5 (array element expansion)
+ */
+const buildArrayNode = (
+ variable: PLCVariable,
+ pouName: string,
+ _dataTypes: PLCDataType[],
+ _pous: PLCPou[],
+ parentPath: string = '',
+): VariableTreeNode => {
+ const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+
+ // Arrays can be selected as a whole
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'array',
+ variableType: getDisplayType(variable),
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass: 'class' in variable ? variable.class : undefined,
+ initialValue: variable.initialValue,
+ // For now, we don't expand individual array elements
+ // This can be added in Phase 5 for complex types
+ }
+}
+
+/**
+ * Build a tree node for a function block instance
+ */
+const buildFunctionBlockNode = (
+ variable: PLCVariable,
+ pouName: string,
+ fbPou: PLCPou,
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+ parentPath: string = '',
+): VariableTreeNode => {
+ if (fbPou.type !== 'function-block') {
+ throw new Error('Expected function block POU')
+ }
+
+ const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'function_block',
+ variableType: fbPou.data.name,
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass: 'class' in variable ? variable.class : undefined,
+ children: fbPou.data.variables.map((fbVar) => {
+ return buildVariableNode(fbVar, pouName, dataTypes, pous, variablePath)
+ }),
+ }
+}
+
+/**
+ * Build a tree node for any variable type
+ */
+const buildVariableNode = (
+ variable: PLCVariable,
+ pouName: string,
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+ parentPath: string = '',
+): VariableTreeNode => {
+ const typeName = getTypeName(variable)
+ const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+
+ // Check if it's an array
+ if (variable.type.definition === 'array') {
+ return buildArrayNode(variable, pouName, dataTypes, pous, parentPath)
+ }
+
+ // Check if it's a user-defined structure
+ const dataType = findDataType(typeName, dataTypes)
+ if (dataType && dataType.derivation === 'structure') {
+ return buildStructureNode(variable, pouName, dataType, dataTypes, pous, parentPath)
+ }
+
+ // Check if it's a function block instance
+ const fbPou = findFunctionBlock(typeName, pous)
+ if (fbPou) {
+ return buildFunctionBlockNode(variable, pouName, fbPou, dataTypes, pous, parentPath)
+ }
+
+ // Check if it's a standard function block (TON, TOF, etc.)
+ // For now, we treat standard FBs as simple variables since we don't expose their internal state
+ // This can be expanded in Phase 5
+
+ // Simple variable
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'variable',
+ variableType: getDisplayType(variable),
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass: variable.class,
+ initialValue: variable.initialValue,
+ }
+}
+
+/**
+ * Build a tree node for a program POU
+ */
+const buildProgramNode = (pou: PLCPou, dataTypes: PLCDataType[], pous: PLCPou[]): VariableTreeNode => {
+ if (pou.type !== 'program') {
+ throw new Error('Expected program POU')
+ }
+
+ return {
+ id: `pou-${pou.data.name}`,
+ name: pou.data.name,
+ type: 'program',
+ pouName: pou.data.name,
+ variablePath: '',
+ isSelectable: false,
+ children: pou.data.variables.map((v) => buildVariableNode(v, pou.data.name, dataTypes, pous)),
+ }
+}
+
+/**
+ * Build a tree node for global variables
+ */
+const buildGlobalVariablesNode = (
+ globalVariables: PLCVariable[],
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+): VariableTreeNode => {
+ return {
+ id: 'global-variables',
+ name: 'GVL (Global Variables)',
+ type: 'global',
+ pouName: 'GVL',
+ variablePath: '',
+ isSelectable: false,
+ children: globalVariables.map((v) => buildVariableNode({ ...v, class: 'global' }, 'GVL', dataTypes, pous)),
+ }
+}
+
+/**
+ * Hook to extract variables from the project for OPC-UA address space configuration.
+ * Returns a hierarchical tree structure of all selectable variables.
+ */
+export const useProjectVariables = (): VariableTreeNode[] => {
+ const {
+ project: { data: projectData },
+ } = useOpenPLCStore()
+
+ return useMemo(() => {
+ const nodes: VariableTreeNode[] = []
+
+ // Programs with their variables
+ for (const pou of projectData.pous) {
+ if (pou.type === 'program') {
+ nodes.push(buildProgramNode(pou, projectData.dataTypes, projectData.pous))
+ }
+ }
+
+ // Global Variables
+ const globalVars = projectData.configuration.resource.globalVariables
+ if (globalVars && globalVars.length > 0) {
+ nodes.push(
+ buildGlobalVariablesNode(
+ globalVars.map((v) => ({ ...v, class: 'global' })) as PLCVariable[],
+ projectData.dataTypes,
+ projectData.pous,
+ ),
+ )
+ }
+
+ return nodes
+ }, [projectData.pous, projectData.dataTypes, projectData.configuration.resource.globalVariables])
+}
+
+/**
+ * Helper to find a tree node by its ID
+ */
+export const findTreeNodeById = (nodes: VariableTreeNode[], id: string): VariableTreeNode | undefined => {
+ for (const node of nodes) {
+ if (node.id === id) {
+ return node
+ }
+ if (node.children) {
+ const found = findTreeNodeById(node.children, id)
+ if (found) {
+ return found
+ }
+ }
+ }
+ return undefined
+}
+
+/**
+ * Helper to get all selectable leaf node IDs from a tree
+ */
+export const getAllSelectableIds = (nodes: VariableTreeNode[]): string[] => {
+ const ids: string[] = []
+ const traverse = (node: VariableTreeNode) => {
+ if (node.isSelectable) {
+ ids.push(node.id)
+ }
+ node.children?.forEach(traverse)
+ }
+ nodes.forEach(traverse)
+ return ids
+}
+
+/**
+ * Helper to check if a node ID or any of its children are selected
+ */
+export const isNodeOrChildrenSelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
+ if (selectedIds.has(node.id)) {
+ return true
+ }
+ if (node.children) {
+ return node.children.some((child) => isNodeOrChildrenSelected(child, selectedIds))
+ }
+ return false
+}
+
+/**
+ * Helper to get all child IDs (including nested children) for a node
+ */
+export const getAllChildIds = (node: VariableTreeNode): string[] => {
+ const ids: string[] = []
+ const traverse = (n: VariableTreeNode) => {
+ if (n.isSelectable) {
+ ids.push(n.id)
+ }
+ n.children?.forEach(traverse)
+ }
+ traverse(node)
+ return ids
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
index 5dc3536b6..4149f91f1 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
@@ -7,6 +7,7 @@ import type { OpcUaServerConfig } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { AddressSpaceTab } from './components/address-space-tab'
import { CertificatesTab } from './components/certificates-tab'
import { SecurityProfilesTab } from './components/security-profiles-tab'
import { UsersTab } from './components/users-tab'
@@ -49,16 +50,6 @@ const TabItem = ({ value, label, isActive }: { value: string; label: string; isA
)
-// Placeholder component for tabs under development
-const PlaceholderContent = ({ title, description }: { title: string; description: string }) => (
-
-
-
{title}
-
{description}
-
-
-)
-
export const OpcUaServerEditor = () => {
const {
editor,
@@ -202,10 +193,13 @@ export const OpcUaServerEditor = () => {
{/* Address Space Tab */}
-
+
+
setEditingState('unsaved')}
+ />
+
diff --git a/src/renderer/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts
index 53262a62d..af0af4ce4 100644
--- a/src/renderer/store/slices/project/slice.ts
+++ b/src/renderer/store/slices/project/slice.ts
@@ -1,5 +1,6 @@
import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast'
import type {
+ OpcUaNodeConfig,
OpcUaSecurityProfile,
OpcUaServerConfig,
OpcUaTrustedCertificate,
@@ -1978,6 +1979,126 @@ const createProjectSlice: StateCreator = (se
return response
},
+ /**
+ * OPC-UA Address Space Node Actions
+ */
+ updateOpcUaAddressSpaceNamespace: (serverName: string, namespaceUri: string): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ server.opcuaServerConfig.addressSpace.namespaceUri = namespaceUri
+ }),
+ )
+ return response
+ },
+
+ addOpcUaNode: (serverName: string, node: OpcUaNodeConfig): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ // Check for duplicate node ID
+ const nodeIdExists = server.opcuaServerConfig.addressSpace.nodes.some(
+ (n) => n.nodeId.toLowerCase() === node.nodeId.toLowerCase(),
+ )
+ if (nodeIdExists) {
+ response = { ok: false, message: `A node with ID "${node.nodeId}" already exists` }
+ toast({
+ title: 'Invalid Node',
+ description: `A node with ID "${node.nodeId}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ server.opcuaServerConfig.addressSpace.nodes.push(node)
+ }),
+ )
+ return response
+ },
+
+ updateOpcUaNode: (serverName: string, nodeId: string, updates: Partial): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const nodeIndex = server.opcuaServerConfig.addressSpace.nodes.findIndex((n) => n.id === nodeId)
+ if (nodeIndex === -1) {
+ response = { ok: false, message: 'Node not found' }
+ return
+ }
+ // If updating nodeId, check for duplicate
+ if (updates.nodeId) {
+ const currentNode = server.opcuaServerConfig.addressSpace.nodes[nodeIndex]
+ if (updates.nodeId !== currentNode.nodeId) {
+ const nodeIdExists = server.opcuaServerConfig.addressSpace.nodes.some(
+ (n, i) => i !== nodeIndex && n.nodeId.toLowerCase() === updates.nodeId!.toLowerCase(),
+ )
+ if (nodeIdExists) {
+ response = { ok: false, message: `A node with ID "${updates.nodeId}" already exists` }
+ toast({
+ title: 'Invalid Node ID',
+ description: `A node with ID "${updates.nodeId}" already exists.`,
+ variant: 'fail',
+ })
+ return
+ }
+ }
+ }
+ Object.assign(server.opcuaServerConfig.addressSpace.nodes[nodeIndex], updates)
+ }),
+ )
+ return response
+ },
+
+ removeOpcUaNode: (serverName: string, nodeId: string): ProjectResponse => {
+ let response: ProjectResponse = { ok: true }
+ setState(
+ produce(({ project }: ProjectSlice) => {
+ if (!project.data.servers) {
+ response = { ok: false, message: 'No servers found' }
+ return
+ }
+ const server = project.data.servers.find((s) => s.name === serverName)
+ if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) {
+ response = { ok: false, message: 'OPC-UA server not found' }
+ return
+ }
+ const index = server.opcuaServerConfig.addressSpace.nodes.findIndex((n) => n.id === nodeId)
+ if (index === -1) {
+ response = { ok: false, message: 'Node not found' }
+ return
+ }
+ server.opcuaServerConfig.addressSpace.nodes.splice(index, 1)
+ }),
+ )
+ return response
+ },
+
/**
* Remote Device Actions
*/
diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts
index d9f316e34..74e8cfd8a 100644
--- a/src/renderer/store/slices/project/types.ts
+++ b/src/renderer/store/slices/project/types.ts
@@ -1,5 +1,6 @@
import {
bodySchema,
+ OpcUaNodeConfigSchema,
PLCDataTypeSchema,
PLCFunctionBlockSchema,
PLCFunctionSchema,
@@ -484,6 +485,17 @@ const _projectActionsSchema = z.object({
.returns(projectResponseSchema),
removeOpcUaTrustedCertificate: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+ /**
+ * OPC-UA Address Space Node Actions
+ */
+ updateOpcUaAddressSpaceNamespace: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+ addOpcUaNode: z.function().args(z.string(), OpcUaNodeConfigSchema).returns(projectResponseSchema),
+ updateOpcUaNode: z
+ .function()
+ .args(z.string(), z.string(), OpcUaNodeConfigSchema.partial())
+ .returns(projectResponseSchema),
+ removeOpcUaNode: z.function().args(z.string(), z.string()).returns(projectResponseSchema),
+
/**
* Remote Device Actions
*/
From ee8a5a7dccac34f257a018d585b1f0a729927bf1 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Fri, 16 Jan 2026 07:49:59 -0500
Subject: [PATCH 06/21] feat: Implement Phases 5 and 6 OPC-UA complex types and
compiler integration
Phase 5 - Address Space Complex Types:
- Add array element expansion in variable tree (up to 50 elements)
- Implement parent selection behavior (select/deselect all children)
- Add structure/array info display in configuration modal
- Add field permissions table for structures and function blocks
- Include arrayInfo and structureInfo metadata in tree nodes
Phase 6 - Compiler Integration:
- Create OPC-UA JSON generator utility (src/utils/opcua/)
- Implement index resolution from debug.c for variables, structures, arrays
- Integrate OPC-UA config generation into compiler pipeline
- Add OpcUaConfigError class for detailed error reporting
- Add unit tests for generator (15 tests passing)
- Generate conf/opcua.json during compilation for Runtime v4
Co-Authored-By: Claude Opus 4.5
---
src/main/modules/compiler/compiler-module.ts | 67 +++
.../components/address-space-tab.tsx | 57 ++-
.../components/variable-config-modal.tsx | 232 ++++++++-
.../hooks/use-project-variables.ts | 230 ++++++++-
.../__tests__/generate-opcua-config.test.ts | 335 +++++++++++++
src/utils/opcua/generate-opcua-config.ts | 449 ++++++++++++++++++
src/utils/opcua/index.ts | 25 +
src/utils/opcua/resolve-indices.ts | 302 ++++++++++++
src/utils/opcua/types.ts | 31 ++
9 files changed, 1711 insertions(+), 17 deletions(-)
create mode 100644 src/utils/opcua/__tests__/generate-opcua-config.test.ts
create mode 100644 src/utils/opcua/generate-opcua-config.ts
create mode 100644 src/utils/opcua/index.ts
create mode 100644 src/utils/opcua/resolve-indices.ts
create mode 100644 src/utils/opcua/types.ts
diff --git a/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts
index 0302091ff..0b7d6d391 100644
--- a/src/main/modules/compiler/compiler-module.ts
+++ b/src/main/modules/compiler/compiler-module.ts
@@ -16,6 +16,7 @@ import { type CppPouData as CppPouDataCode, generateCBlocksCode } from '@root/ut
import { type CppPouData as CppPouDataHeader, generateCBlocksHeader } from '@root/utils/cpp/generateCBlocksHeader'
import { generateModbusMasterConfig } from '@root/utils/modbus/generate-modbus-master-config'
import { generateModbusSlaveConfig } from '@root/utils/modbus/generate-modbus-slave-config'
+import { generateOpcUaConfig, OpcUaConfigError } from '@root/utils/opcua'
import { parsePlcStatus } from '@root/utils/plc-status'
import { getRuntimeHttpsOptions } from '@root/utils/runtime-https-config'
import { generateS7CommConfig } from '@root/utils/s7comm'
@@ -1210,6 +1211,67 @@ class CompilerModule {
}
}
+ /**
+ * Generate OPC-UA server configuration for Runtime v4.
+ * Reads debug.c to resolve variable indices and generates opcua.json.
+ */
+ async handleGenerateOpcUaConfig(
+ sourceTargetFolderPath: string,
+ projectData: ProjectState['data'],
+ handleOutputData: HandleOutputDataCallback,
+ ): Promise {
+ try {
+ // Check if there's an enabled OPC-UA server
+ const opcuaServer = projectData.servers?.find(
+ (s) => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled,
+ )
+
+ if (!opcuaServer || !opcuaServer.opcuaServerConfig) {
+ handleOutputData('No OPC-UA server configured, skipping opcua.json generation', 'info')
+ return
+ }
+
+ // Read the debug.c file generated by xml2st
+ const debugCPath = join(sourceTargetFolderPath, 'debug.c')
+ let debugContent: string
+
+ try {
+ debugContent = await readFile(debugCPath, 'utf-8')
+ } catch {
+ handleOutputData('Warning: Could not read debug.c file. OPC-UA variable indices may not be resolved.', 'error')
+ debugContent = ''
+ }
+
+ // Generate the OPC-UA configuration
+ const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent)
+
+ if (opcuaJson) {
+ // Ensure conf directory exists
+ const confFolderPath = join(sourceTargetFolderPath, 'conf')
+ await mkdir(confFolderPath, { recursive: true })
+
+ // Write the configuration file
+ const configFilePath = join(confFolderPath, 'opcua.json')
+ await writeFile(configFilePath, opcuaJson, 'utf-8')
+ handleOutputData('Generated conf/opcua.json', 'info')
+
+ // Log the number of configured nodes
+ const nodeCount = opcuaServer.opcuaServerConfig.addressSpace.nodes.length
+ handleOutputData(`OPC-UA Address Space: ${nodeCount} node(s) configured`, 'info')
+ } else {
+ handleOutputData('OPC-UA server enabled but no configuration generated', 'info')
+ }
+ } catch (error) {
+ if (error instanceof OpcUaConfigError) {
+ handleOutputData(`OPC-UA Configuration Error:\n${error.message}`, 'error')
+ } else {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ handleOutputData(`Failed to generate OPC-UA config: ${errorMessage}`, 'error')
+ }
+ throw error
+ }
+ }
+
async embedCBlocksInProgramSt(
sourceTargetFolderPath: string,
handleOutputData: HandleOutputDataCallback,
@@ -1639,6 +1701,11 @@ class CompilerModule {
_mainProcessPort.postMessage({ logLevel, message: data })
})
+ // Generate OPC-UA config for Runtime v4
+ await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => {
+ _mainProcessPort.postMessage({ logLevel, message: data })
+ })
+
_mainProcessPort.postMessage({
logLevel: 'info',
message: 'Compressing source files for OpenPLC Runtime v4...',
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
index 354b99039..2f6b3e8fa 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
@@ -4,7 +4,13 @@ import { useOpenPLCStore } from '@root/renderer/store'
import type { OpcUaNodeConfig, OpcUaServerConfig } from '@root/types/PLC/open-plc'
import { useCallback, useMemo, useState } from 'react'
-import { findTreeNodeById, useProjectVariables, type VariableTreeNode } from '../hooks/use-project-variables'
+import {
+ findTreeNodeById,
+ getSelectableDescendantIds,
+ isComplexType,
+ useProjectVariables,
+ type VariableTreeNode,
+} from '../hooks/use-project-variables'
import { SelectedVariablesList } from './selected-variables-list'
import { VariableConfigModal } from './variable-config-modal'
import { VariableTree } from './variable-tree'
@@ -64,11 +70,34 @@ export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressS
removeOpcUaNode(serverName, existingNode.id)
onConfigChange()
}
- setSelectedVariableIds((prev) => {
- const next = new Set(prev)
- next.delete(nodeKey)
- return next
- })
+
+ // For complex types, also deselect all children
+ if (isComplexType(node)) {
+ const descendantIds = getSelectableDescendantIds(node)
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(nodeKey)
+ // Remove all descendant IDs
+ descendantIds.forEach((id) => next.delete(id))
+ return next
+ })
+
+ // Also remove any configured descendant nodes
+ descendantIds.forEach((descendantId) => {
+ const descendantNode = config.addressSpace.nodes.find(
+ (n) => `${n.pouName}-${n.variablePath}` === descendantId,
+ )
+ if (descendantNode) {
+ removeOpcUaNode(serverName, descendantNode.id)
+ }
+ })
+ } else {
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(nodeKey)
+ return next
+ })
+ }
} else {
// Select - open modal to configure
setEditingVariable(node)
@@ -90,11 +119,23 @@ export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressS
} else {
// Add new node
addOpcUaNode(serverName, nodeConfig)
- setSelectedVariableIds((prev) => new Set(prev).add(nodeKey))
+
+ // For complex types, also mark all descendants as selected
+ if (editingVariable && isComplexType(editingVariable)) {
+ const descendantIds = getSelectableDescendantIds(editingVariable)
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.add(nodeKey)
+ descendantIds.forEach((id) => next.add(id))
+ return next
+ })
+ } else {
+ setSelectedVariableIds((prev) => new Set(prev).add(nodeKey))
+ }
}
onConfigChange()
},
- [serverName, editingConfig, addOpcUaNode, updateOpcUaNode, onConfigChange],
+ [serverName, editingConfig, editingVariable, addOpcUaNode, updateOpcUaNode, onConfigChange],
)
// Handle edit from selected list
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index 8b90d23ac..72041094a 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -2,12 +2,12 @@ import { InputWithRef } from '@root/renderer/components/_atoms/input'
import { Label } from '@root/renderer/components/_atoms/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
-import type { OpcUaNodeConfig, OpcUaPermissions } from '@root/types/PLC/open-plc'
+import type { OpcUaFieldConfig, OpcUaNodeConfig, OpcUaPermissions } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
-import type { VariableTreeNode } from '../hooks/use-project-variables'
+import { isComplexType, type VariableTreeNode } from '../hooks/use-project-variables'
interface VariableConfigModalProps {
isOpen: boolean
@@ -80,6 +80,73 @@ const getNodeType = (node: VariableTreeNode): 'variable' | 'structure' | 'array'
return 'variable'
}
+/**
+ * Generate default field configs from a structure/FB node
+ */
+const generateDefaultFieldConfigs = (
+ node: VariableTreeNode,
+ parentPermissions: OpcUaPermissions,
+): OpcUaFieldConfig[] => {
+ if (!node.children) return []
+
+ return node.children.map((child) => ({
+ fieldPath: child.variablePath,
+ displayName: child.name,
+ initialValue: getDefaultInitialValue(child.variableType),
+ permissions: { ...parentPermissions },
+ }))
+}
+
+/**
+ * Permission selector component for inline use in tables
+ */
+const PermissionSelect = ({
+ value,
+ onChange,
+ className,
+}: {
+ value: PermissionLevel
+ onChange: (value: PermissionLevel) => void
+ className?: string
+}) => (
+ onChange(v as PermissionLevel)}>
+
+
+
+
+ R
+
+
+
+
+ W
+
+
+
+
+ RW
+
+
+
+
+)
+
export const VariableConfigModal = ({
isOpen,
onClose,
@@ -98,6 +165,14 @@ export const VariableConfigModal = ({
const [operatorPerm, setOperatorPerm] = useState('r')
const [engineerPerm, setEngineerPerm] = useState('rw')
+ // Field configurations (for structures/arrays)
+ const [fieldConfigs, setFieldConfigs] = useState([])
+
+ // Check if this is a complex type
+ const isStructureOrFb = variable && (variable.type === 'structure' || variable.type === 'function_block')
+ const isArray = variable && variable.type === 'array'
+ const isComplex = variable && isComplexType(variable)
+
// Initialize form when modal opens or variable changes
useEffect(() => {
if (isOpen && variable) {
@@ -111,6 +186,8 @@ export const VariableConfigModal = ({
setViewerPerm(existingConfig.permissions.viewer)
setOperatorPerm(existingConfig.permissions.operator)
setEngineerPerm(existingConfig.permissions.engineer)
+ // Load existing field configs
+ setFieldConfigs(existingConfig.fields || [])
} else {
// New configuration - generate defaults
setNodeId(generateNodeId(variable.pouName, variable.variablePath))
@@ -121,6 +198,9 @@ export const VariableConfigModal = ({
setViewerPerm('r')
setOperatorPerm('r')
setEngineerPerm('rw')
+ // Generate default field configs for complex types
+ const defaultPerms: OpcUaPermissions = { viewer: 'r', operator: 'r', engineer: 'rw' }
+ setFieldConfigs(isComplexType(variable) ? generateDefaultFieldConfigs(variable, defaultPerms) : [])
}
}
}, [isOpen, variable, existingConfig])
@@ -161,6 +241,32 @@ export const VariableConfigModal = ({
const isValid = validationErrors.length === 0
+ // Apply parent permissions to all fields
+ const applyPermissionsToAllFields = useCallback(() => {
+ setFieldConfigs((prev) =>
+ prev.map((field) => ({
+ ...field,
+ permissions: {
+ viewer: viewerPerm,
+ operator: operatorPerm,
+ engineer: engineerPerm,
+ },
+ })),
+ )
+ }, [viewerPerm, operatorPerm, engineerPerm])
+
+ // Update a single field's permission
+ const updateFieldPermission = useCallback(
+ (fieldPath: string, role: 'viewer' | 'operator' | 'engineer', value: PermissionLevel) => {
+ setFieldConfigs((prev) =>
+ prev.map((field) =>
+ field.fieldPath === fieldPath ? { ...field, permissions: { ...field.permissions, [role]: value } } : field,
+ ),
+ )
+ },
+ [],
+ )
+
// Handle save
const handleSave = useCallback(() => {
if (!isValid || !variable) return
@@ -198,6 +304,11 @@ export const VariableConfigModal = ({
initialValue: parsedInitialValue,
permissions,
nodeType: getNodeType(variable),
+ // Include field configs for complex types
+ fields: isComplex ? fieldConfigs : undefined,
+ // Include array info if applicable
+ arrayLength: variable.arrayInfo?.totalLength,
+ elementType: variable.arrayInfo?.elementType,
}
onSave(config)
@@ -214,6 +325,8 @@ export const VariableConfigModal = ({
operatorPerm,
engineerPerm,
existingConfig,
+ isComplex,
+ fieldConfigs,
onSave,
onClose,
])
@@ -222,7 +335,7 @@ export const VariableConfigModal = ({
return (
!open && onClose()}>
-
+
{existingConfig ? 'Edit' : 'Configure'} OPC-UA Node
@@ -459,6 +572,119 @@ export const VariableConfigModal = ({
+ {/* Array Info */}
+ {isArray && variable?.arrayInfo && (
+
+
Array Information
+
+
+ Dimensions: [{variable.arrayInfo.dimensions.join(', ')}]
+
+
+ Element Type: {variable.arrayInfo.elementType}
+
+
+ Total Elements: {variable.arrayInfo.totalLength}
+
+
+
+ )}
+
+ {/* Structure Info */}
+ {isStructureOrFb && variable?.structureInfo && (
+
+
+ Structure Information
+
+
+
+ Type: {variable.structureInfo.structTypeName}
+
+
+ Fields: {variable.structureInfo.fieldCount}
+
+
+
+ )}
+
+ {/* Field Permissions Table (for structures/FBs) */}
+ {isComplex && fieldConfigs.length > 0 && (
+
+
+
+ Field Permissions ({fieldConfigs.length} fields)
+
+
+ Apply Parent Permissions to All
+
+
+
+ {/* Table */}
+
+
+
+
+
+ Field
+
+
+ Viewer
+
+
+ Operator
+
+
+ Engineer
+
+
+
+
+ {fieldConfigs.map((field, index) => (
+
+
+ {field.displayName}
+
+
+ updateFieldPermission(field.fieldPath, 'viewer', v)}
+ />
+
+
+ updateFieldPermission(field.fieldPath, 'operator', v)}
+ />
+
+
+ updateFieldPermission(field.fieldPath, 'engineer', v)}
+ />
+
+
+ ))}
+
+
+
+
+
+ Configure individual permissions for each field. Use "Apply Parent Permissions" to set all fields to
+ match the parent variable.
+
+
+ )}
+
{/* Validation Errors */}
{validationErrors.length > 0 && (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
index be7ce979a..c30f7ca69 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
@@ -18,6 +18,16 @@ export interface VariableTreeNode {
// Additional metadata for OPC-UA node configuration
variableClass?: string
initialValue?: string | null
+ // Phase 5: Complex types metadata
+ arrayInfo?: {
+ dimensions: string[] // e.g., ["1..10"] or ["0..4", "0..2"] for multi-dimensional
+ elementType: string // Base type of array elements
+ totalLength: number // Total number of elements
+ }
+ structureInfo?: {
+ structTypeName: string // Name of the structure type
+ fieldCount: number // Number of fields
+ }
}
/**
@@ -131,6 +141,10 @@ const buildStructureNode = (
isSelectable: true,
variableClass: 'class' in variable ? variable.class : undefined,
initialValue: variable.initialValue,
+ structureInfo: {
+ structTypeName: dataType.name,
+ fieldCount: dataType.variable.length,
+ },
children: dataType.variable.map((field) => {
const fieldTypeName = getTypeName(field)
const fieldPath = `${variablePath}.${field.name}`
@@ -174,20 +188,155 @@ const buildStructureNode = (
}
}
+/**
+ * Parse array dimension string (e.g., "1..10" or "0..5") to get bounds
+ */
+const parseArrayDimension = (dimension: string): { min: number; max: number; length: number } => {
+ const parts = dimension.split('..')
+ if (parts.length === 2) {
+ const min = parseInt(parts[0], 10)
+ const max = parseInt(parts[1], 10)
+ return { min, max, length: max - min + 1 }
+ }
+ // Fallback for single number (length)
+ const len = parseInt(dimension, 10) || 1
+ return { min: 0, max: len - 1, length: len }
+}
+
+/**
+ * Maximum number of array elements to expand in the tree
+ * Larger arrays will show as a single selectable node
+ */
+const MAX_ARRAY_EXPANSION = 50
+
/**
* Build a tree node for an array type
- * Note: _dataTypes and _pous are reserved for Phase 5 (array element expansion)
+ * Phase 5: Now includes array element expansion for reasonably sized arrays
*/
const buildArrayNode = (
variable: PLCVariable,
pouName: string,
- _dataTypes: PLCDataType[],
- _pous: PLCPou[],
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
parentPath: string = '',
): VariableTreeNode => {
const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
- // Arrays can be selected as a whole
+ // Extract array info from type
+ if (variable.type.definition !== 'array' || !variable.type.data) {
+ // Fallback for unexpected type
+ return {
+ id: `${pouName}-${variablePath}`,
+ name: variable.name,
+ type: 'array',
+ variableType: getDisplayType(variable),
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass: 'class' in variable ? variable.class : undefined,
+ initialValue: variable.initialValue,
+ }
+ }
+
+ const dimensions = variable.type.data.dimensions.map((d) => d.dimension)
+ const elementBaseType = variable.type.data.baseType
+
+ // Get element type name
+ const elementTypeName =
+ elementBaseType.definition === 'base-type' ? elementBaseType.value.toUpperCase() : elementBaseType.value
+
+ // Calculate total length (for multi-dimensional, multiply all dimensions)
+ let totalLength = 1
+ const parsedDimensions = dimensions.map((d) => {
+ const parsed = parseArrayDimension(d)
+ totalLength *= parsed.length
+ return parsed
+ })
+
+ // Build array info
+ const arrayInfo = {
+ dimensions,
+ elementType: elementTypeName,
+ totalLength,
+ }
+
+ // For single-dimensional arrays with reasonable size, expand elements
+ const shouldExpand = dimensions.length === 1 && totalLength <= MAX_ARRAY_EXPANSION
+
+ let children: VariableTreeNode[] | undefined
+
+ if (shouldExpand) {
+ const { min, max } = parsedDimensions[0]
+
+ // Check if element type is a structure or FB
+ const elementDataType =
+ elementBaseType.definition !== 'base-type' ? findDataType(elementBaseType.value, dataTypes) : undefined
+ const elementFbPou =
+ elementBaseType.definition !== 'base-type' ? findFunctionBlock(elementBaseType.value, pous) : undefined
+
+ children = []
+ for (let i = min; i <= max; i++) {
+ const elementPath = `${variablePath}[${i}]`
+
+ if (elementDataType && elementDataType.derivation === 'structure') {
+ // Array of structures
+ children.push({
+ id: `${pouName}-${elementPath}`,
+ name: `[${i}]`,
+ type: 'structure',
+ variableType: elementTypeName,
+ pouName,
+ variablePath: elementPath,
+ isSelectable: true,
+ structureInfo: {
+ structTypeName: elementDataType.name,
+ fieldCount: elementDataType.variable.length,
+ },
+ children: elementDataType.variable.map((field) => {
+ const fieldPath = `${elementPath}.${field.name}`
+ return {
+ id: `${pouName}-${fieldPath}`,
+ name: field.name,
+ type: 'variable' as const,
+ variableType: getDisplayType(field),
+ pouName,
+ variablePath: fieldPath,
+ isSelectable: true,
+ }
+ }),
+ })
+ } else if (elementFbPou) {
+ // Array of function blocks
+ children.push(
+ buildFunctionBlockNode(
+ {
+ name: `[${i}]`,
+ type: { definition: 'derived', value: elementFbPou.data.name },
+ location: '',
+ documentation: '',
+ } as PLCVariable,
+ pouName,
+ elementFbPou,
+ dataTypes,
+ pous,
+ variablePath,
+ ),
+ )
+ } else {
+ // Array of simple types
+ children.push({
+ id: `${pouName}-${elementPath}`,
+ name: `[${i}]`,
+ type: 'variable',
+ variableType: elementTypeName,
+ pouName,
+ variablePath: elementPath,
+ isSelectable: true,
+ })
+ }
+ }
+ }
+
return {
id: `${pouName}-${variablePath}`,
name: variable.name,
@@ -198,8 +347,8 @@ const buildArrayNode = (
isSelectable: true,
variableClass: 'class' in variable ? variable.class : undefined,
initialValue: variable.initialValue,
- // For now, we don't expand individual array elements
- // This can be added in Phase 5 for complex types
+ arrayInfo,
+ children,
}
}
@@ -416,3 +565,72 @@ export const getAllChildIds = (node: VariableTreeNode): string[] => {
traverse(node)
return ids
}
+
+/**
+ * Phase 5: Get only the selectable descendants (excluding the node itself)
+ */
+export const getSelectableDescendantIds = (node: VariableTreeNode): string[] => {
+ const ids: string[] = []
+ const traverse = (n: VariableTreeNode, isRoot: boolean) => {
+ if (!isRoot && n.isSelectable) {
+ ids.push(n.id)
+ }
+ n.children?.forEach((child) => traverse(child, false))
+ }
+ traverse(node, true)
+ return ids
+}
+
+/**
+ * Phase 5: Check if all selectable descendants are selected
+ */
+export const areAllChildrenSelected = (node: VariableTreeNode, selectedIds: Set
): boolean => {
+ const descendantIds = getSelectableDescendantIds(node)
+ if (descendantIds.length === 0) return false
+ return descendantIds.every((id) => selectedIds.has(id))
+}
+
+/**
+ * Phase 5: Check if any selectable descendants are selected
+ */
+export const areAnyChildrenSelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
+ const descendantIds = getSelectableDescendantIds(node)
+ return descendantIds.some((id) => selectedIds.has(id))
+}
+
+/**
+ * Phase 5: Selection state for complex types
+ */
+export type SelectionState = 'none' | 'some' | 'all'
+
+/**
+ * Phase 5: Get the selection state of a node and its descendants
+ */
+export const getSelectionState = (node: VariableTreeNode, selectedIds: Set): SelectionState => {
+ // Check if the node itself is selected
+ if (selectedIds.has(node.id)) {
+ return 'all'
+ }
+
+ // For nodes with children, check descendants
+ if (node.children && node.children.length > 0) {
+ const descendantIds = getSelectableDescendantIds(node)
+ if (descendantIds.length === 0) {
+ return 'none'
+ }
+
+ const selectedCount = descendantIds.filter((id) => selectedIds.has(id)).length
+ if (selectedCount === 0) return 'none'
+ if (selectedCount === descendantIds.length) return 'all'
+ return 'some'
+ }
+
+ return 'none'
+}
+
+/**
+ * Phase 5: Check if a variable type is a complex type (structure, array, or FB)
+ */
+export const isComplexType = (node: VariableTreeNode): boolean => {
+ return node.type === 'structure' || node.type === 'array' || node.type === 'function_block'
+}
diff --git a/src/utils/opcua/__tests__/generate-opcua-config.test.ts b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
new file mode 100644
index 000000000..6137f375b
--- /dev/null
+++ b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
@@ -0,0 +1,335 @@
+import type { OpcUaServerConfig, PLCServer } from '@root/types/PLC/open-plc'
+
+import { generateOpcUaConfig, parseDebugFile, validateOpcUaConfig } from '../generate-opcua-config'
+import { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from '../resolve-indices'
+
+// Sample debug.c content for testing
+const sampleDebugContent = `
+#define VAR_COUNT 10
+
+debug_vars_t debug_vars[] = {
+ { &(RES0__MAIN.MOTOR_SPEED), INT_ENUM },
+ { &(RES0__MAIN.TEMPERATURE), REAL_ENUM },
+ { &(RES0__MAIN.IS_RUNNING), BOOL_ENUM },
+ { &(RES0__MAIN.SENSOR.value.TEMP), REAL_ENUM },
+ { &(RES0__MAIN.SENSOR.value.PRESSURE), REAL_ENUM },
+ { &(RES0__MAIN.TEMPS.value.table[0]), REAL_ENUM },
+ { &(RES0__MAIN.TEMPS.value.table[1]), REAL_ENUM },
+ { &(RES0__MAIN.TEMPS.value.table[2]), REAL_ENUM },
+ { &(CONFIG0__GLOBAL_VAR), INT_ENUM },
+ { &(CONFIG0__SYSTEM_STATE), BOOL_ENUM },
+};
+`
+
+// Sample OPC-UA server configuration
+const createSampleConfig = (): OpcUaServerConfig => ({
+ server: {
+ enabled: true,
+ name: 'Test OPC-UA Server',
+ applicationUri: 'urn:test:opcua:server',
+ productUri: 'urn:test:runtime',
+ bindAddress: '0.0.0.0',
+ port: 4840,
+ endpointPath: '/test/opcua',
+ },
+ cycleTimeMs: 100,
+ securityProfiles: [
+ {
+ id: 'profile-1',
+ name: 'insecure',
+ enabled: true,
+ securityPolicy: 'None',
+ securityMode: 'None',
+ authMethods: ['Anonymous'],
+ },
+ ],
+ security: {
+ serverCertificateStrategy: 'auto_self_signed',
+ serverCertificateCustom: null,
+ serverPrivateKeyCustom: null,
+ trustedClientCertificates: [],
+ },
+ users: [
+ {
+ id: 'user-1',
+ type: 'password',
+ username: 'admin',
+ passwordHash: '$2b$12$test',
+ certificateId: null,
+ role: 'engineer',
+ },
+ ],
+ addressSpace: {
+ namespaceUri: 'urn:test:opcua:namespace',
+ nodes: [],
+ },
+})
+
+describe('parseDebugFile', () => {
+ it('should parse debug variables from debug.c content', () => {
+ const variables = parseDebugFile(sampleDebugContent)
+
+ expect(variables).toHaveLength(10)
+ expect(variables[0]).toEqual({
+ name: 'RES0__MAIN.MOTOR_SPEED',
+ type: 'INT_ENUM',
+ index: 0,
+ })
+ expect(variables[8]).toEqual({
+ name: 'CONFIG0__GLOBAL_VAR',
+ type: 'INT_ENUM',
+ index: 8,
+ })
+ })
+
+ it('should return empty array for invalid content', () => {
+ const variables = parseDebugFile('no debug vars here')
+ expect(variables).toHaveLength(0)
+ })
+
+ it('should handle empty content', () => {
+ const variables = parseDebugFile('')
+ expect(variables).toHaveLength(0)
+ })
+})
+
+describe('resolveVariableIndex', () => {
+ const debugVariables = parseDebugFile(sampleDebugContent)
+
+ it('should resolve simple program variable', () => {
+ const node = {
+ id: 'test-1',
+ pouName: 'main',
+ variablePath: 'MOTOR_SPEED',
+ variableType: 'INT',
+ nodeId: 'PLC.Main.MotorSpeed',
+ browseName: 'MotorSpeed',
+ displayName: 'Motor Speed',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'variable' as const,
+ }
+
+ const index = resolveVariableIndex(node, debugVariables)
+ expect(index).toBe(0)
+ })
+
+ it('should resolve global variable', () => {
+ const node = {
+ id: 'test-2',
+ pouName: 'GVL',
+ variablePath: 'GLOBAL_VAR',
+ variableType: 'INT',
+ nodeId: 'PLC.GVL.GlobalVar',
+ browseName: 'GlobalVar',
+ displayName: 'Global Variable',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'variable' as const,
+ }
+
+ const index = resolveVariableIndex(node, debugVariables)
+ expect(index).toBe(8)
+ })
+
+ it('should throw error for non-existent variable', () => {
+ const node = {
+ id: 'test-3',
+ pouName: 'main',
+ variablePath: 'NON_EXISTENT',
+ variableType: 'INT',
+ nodeId: 'PLC.Main.NonExistent',
+ browseName: 'NonExistent',
+ displayName: 'Non Existent',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'variable' as const,
+ }
+
+ expect(() => resolveVariableIndex(node, debugVariables)).toThrow(OpcUaConfigError)
+ })
+})
+
+describe('resolveArrayIndex', () => {
+ const debugVariables = parseDebugFile(sampleDebugContent)
+
+ it('should resolve array starting index', () => {
+ const node = {
+ id: 'test-4',
+ pouName: 'main',
+ variablePath: 'TEMPS',
+ variableType: 'ARRAY[0..2] OF REAL',
+ nodeId: 'PLC.Main.Temps',
+ browseName: 'Temps',
+ displayName: 'Temperatures',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'array' as const,
+ arrayLength: 3,
+ elementType: 'REAL',
+ }
+
+ const index = resolveArrayIndex(node, debugVariables)
+ expect(index).toBe(5) // First element [0] is at index 5
+ })
+})
+
+describe('resolveStructureIndices', () => {
+ const debugVariables = parseDebugFile(sampleDebugContent)
+
+ it('should resolve structure field indices', () => {
+ const node = {
+ id: 'test-5',
+ pouName: 'main',
+ variablePath: 'SENSOR',
+ variableType: 'SensorData',
+ nodeId: 'PLC.Main.Sensor',
+ browseName: 'Sensor',
+ displayName: 'Sensor Data',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'structure' as const,
+ fields: [
+ {
+ fieldPath: 'TEMP',
+ displayName: 'Temperature',
+ initialValue: 0.0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: 'PRESSURE',
+ displayName: 'Pressure',
+ initialValue: 0.0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ ],
+ }
+
+ const resolvedFields = resolveStructureIndices(node, debugVariables)
+ expect(resolvedFields).toHaveLength(2)
+ expect(resolvedFields[0].index).toBe(3) // SENSOR.value.TEMP
+ expect(resolvedFields[1].index).toBe(4) // SENSOR.value.PRESSURE
+ })
+})
+
+describe('generateOpcUaConfig', () => {
+ it('should return null when no servers configured', () => {
+ const result = generateOpcUaConfig(undefined, sampleDebugContent)
+ expect(result).toBeNull()
+ })
+
+ it('should return null when no OPC-UA server configured', () => {
+ const servers: PLCServer[] = [
+ {
+ name: 'modbus-server',
+ protocol: 'modbus-tcp',
+ },
+ ]
+ const result = generateOpcUaConfig(servers, sampleDebugContent)
+ expect(result).toBeNull()
+ })
+
+ it('should return null when OPC-UA server is disabled', () => {
+ const config = createSampleConfig()
+ config.server.enabled = false
+
+ const servers: PLCServer[] = [
+ {
+ name: 'opcua-server',
+ protocol: 'opcua',
+ opcuaServerConfig: config,
+ },
+ ]
+
+ const result = generateOpcUaConfig(servers, sampleDebugContent)
+ expect(result).toBeNull()
+ })
+
+ it('should generate valid JSON for enabled OPC-UA server', () => {
+ const config = createSampleConfig()
+ config.addressSpace.nodes = [
+ {
+ id: 'node-1',
+ pouName: 'main',
+ variablePath: 'MOTOR_SPEED',
+ variableType: 'INT',
+ nodeId: 'PLC.Main.MotorSpeed',
+ browseName: 'MotorSpeed',
+ displayName: 'Motor Speed',
+ description: 'Motor speed in RPM',
+ initialValue: 0,
+ permissions: { viewer: 'r', operator: 'rw', engineer: 'rw' },
+ nodeType: 'variable',
+ },
+ ]
+
+ const servers: PLCServer[] = [
+ {
+ name: 'opcua-server',
+ protocol: 'opcua',
+ opcuaServerConfig: config,
+ },
+ ]
+
+ const result = generateOpcUaConfig(servers, sampleDebugContent)
+ expect(result).not.toBeNull()
+
+ const parsed = JSON.parse(result!)
+ expect(parsed).toBeInstanceOf(Array)
+ expect(parsed).toHaveLength(1)
+ expect(parsed[0].name).toBe('opcua_server')
+ expect(parsed[0].protocol).toBe('OPC-UA')
+ expect(parsed[0].config.server.endpoint_url).toBe('opc.tcp://0.0.0.0:4840/test/opcua')
+ expect(parsed[0].config.address_space.variables).toHaveLength(1)
+ expect(parsed[0].config.address_space.variables[0].index).toBe(0)
+ })
+})
+
+describe('validateOpcUaConfig', () => {
+ it('should return valid when config is correct', () => {
+ const config = createSampleConfig()
+ config.addressSpace.nodes = [
+ {
+ id: 'node-1',
+ pouName: 'main',
+ variablePath: 'MOTOR_SPEED',
+ variableType: 'INT',
+ nodeId: 'PLC.Main.MotorSpeed',
+ browseName: 'MotorSpeed',
+ displayName: 'Motor Speed',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r', operator: 'r', engineer: 'rw' },
+ nodeType: 'variable',
+ },
+ ]
+
+ const result = validateOpcUaConfig(config, sampleDebugContent)
+ expect(result.valid).toBe(true)
+ expect(result.errors).toHaveLength(0)
+ })
+
+ it('should return errors when no security profiles enabled', () => {
+ const config = createSampleConfig()
+ config.securityProfiles[0].enabled = false
+
+ const result = validateOpcUaConfig(config, sampleDebugContent)
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('At least one security profile must be enabled')
+ })
+
+ it('should return errors when username auth enabled without users', () => {
+ const config = createSampleConfig()
+ config.securityProfiles[0].authMethods = ['Username']
+ config.users = []
+
+ const result = validateOpcUaConfig(config, sampleDebugContent)
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('Username authentication is enabled but no users are configured')
+ })
+})
diff --git a/src/utils/opcua/generate-opcua-config.ts b/src/utils/opcua/generate-opcua-config.ts
new file mode 100644
index 000000000..5c419cb0c
--- /dev/null
+++ b/src/utils/opcua/generate-opcua-config.ts
@@ -0,0 +1,449 @@
+import type {
+ OpcUaNodeConfig,
+ OpcUaPermissions,
+ OpcUaSecurityProfile,
+ OpcUaServerConfig,
+ OpcUaTrustedCertificate,
+ OpcUaUser,
+ PLCServer,
+} from '@root/types/PLC/open-plc'
+
+import { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from './resolve-indices'
+import type { DebugVariable } from './types'
+
+/**
+ * Runtime configuration interfaces
+ * These define the JSON structure expected by the OpenPLC Runtime OPC-UA plugin.
+ * The runtime uses snake_case naming convention.
+ */
+
+interface RuntimeSecurityProfile {
+ name: string
+ enabled: boolean
+ security_policy: string
+ security_mode: string
+ auth_methods: string[]
+}
+
+interface RuntimeServerConfig {
+ name: string
+ application_uri: string
+ product_uri: string
+ endpoint_url: string
+ security_profiles: RuntimeSecurityProfile[]
+}
+
+interface RuntimeTrustedCertificate {
+ id: string
+ pem: string
+}
+
+interface RuntimeSecurityConfig {
+ server_certificate_strategy: string
+ server_certificate_custom: string | null
+ server_private_key_custom: string | null
+ trusted_client_certificates: RuntimeTrustedCertificate[]
+}
+
+interface RuntimeUser {
+ type: string
+ username: string | null
+ password_hash: string | null
+ certificate_id: string | null
+ role: string
+}
+
+interface RuntimeVariablePermissions {
+ viewer: string
+ operator: string
+ engineer: string
+}
+
+interface RuntimeVariable {
+ node_id: string
+ browse_name: string
+ display_name: string
+ datatype: string
+ initial_value: boolean | number | string
+ description: string
+ index: number
+ permissions: RuntimeVariablePermissions
+}
+
+interface RuntimeStructureField {
+ name: string
+ datatype: string
+ initial_value: boolean | number | string
+ index: number
+ permissions: RuntimeVariablePermissions
+}
+
+interface RuntimeStructure {
+ node_id: string
+ browse_name: string
+ display_name: string
+ description: string
+ fields: RuntimeStructureField[]
+}
+
+interface RuntimeArray {
+ node_id: string
+ browse_name: string
+ display_name: string
+ datatype: string
+ length: number
+ initial_value: boolean | number | string
+ index: number
+ permissions: RuntimeVariablePermissions
+}
+
+interface RuntimeAddressSpace {
+ namespace_uri: string
+ variables: RuntimeVariable[]
+ structures: RuntimeStructure[]
+ arrays: RuntimeArray[]
+}
+
+interface RuntimePluginConfig {
+ server: RuntimeServerConfig
+ security: RuntimeSecurityConfig
+ users: RuntimeUser[]
+ cycle_time_ms: number
+ address_space: RuntimeAddressSpace
+}
+
+interface RuntimeConfig {
+ name: string
+ protocol: 'OPC-UA'
+ config: RuntimePluginConfig
+}
+
+/**
+ * Build the runtime server configuration from editor config
+ */
+const buildServerConfig = (config: OpcUaServerConfig): RuntimeServerConfig => {
+ const { server, securityProfiles } = config
+
+ // Build endpoint URL from components
+ const endpointUrl = `opc.tcp://${server.bindAddress}:${server.port}${server.endpointPath}`
+
+ return {
+ name: server.name,
+ application_uri: server.applicationUri,
+ product_uri: server.productUri,
+ endpoint_url: endpointUrl,
+ security_profiles: securityProfiles
+ .filter((sp: OpcUaSecurityProfile) => sp.enabled)
+ .map((sp: OpcUaSecurityProfile) => ({
+ name: sp.name,
+ enabled: sp.enabled,
+ security_policy: sp.securityPolicy,
+ security_mode: sp.securityMode,
+ auth_methods: sp.authMethods,
+ })),
+ }
+}
+
+/**
+ * Build the runtime security configuration from editor config
+ */
+const buildSecurityConfig = (config: OpcUaServerConfig): RuntimeSecurityConfig => {
+ const { security } = config
+
+ return {
+ server_certificate_strategy: security.serverCertificateStrategy,
+ server_certificate_custom: security.serverCertificateCustom,
+ server_private_key_custom: security.serverPrivateKeyCustom,
+ trusted_client_certificates: security.trustedClientCertificates.map((cert: OpcUaTrustedCertificate) => ({
+ id: cert.id,
+ pem: cert.pem,
+ })),
+ }
+}
+
+/**
+ * Build the runtime users configuration from editor config
+ */
+const buildUsersConfig = (config: OpcUaServerConfig): RuntimeUser[] => {
+ return config.users.map((user: OpcUaUser) => ({
+ type: user.type,
+ username: user.username,
+ password_hash: user.passwordHash,
+ certificate_id: user.certificateId,
+ role: user.role,
+ }))
+}
+
+/**
+ * Convert editor permissions to runtime format
+ */
+const convertPermissions = (permissions: OpcUaPermissions): RuntimeVariablePermissions => ({
+ viewer: permissions.viewer,
+ operator: permissions.operator,
+ engineer: permissions.engineer,
+})
+
+/**
+ * Resolve a simple variable and build runtime format
+ */
+const resolveVariable = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeVariable => {
+ const index = resolveVariableIndex(node, debugVariables)
+
+ return {
+ node_id: node.nodeId,
+ browse_name: node.browseName,
+ display_name: node.displayName,
+ datatype: node.variableType,
+ initial_value: node.initialValue,
+ description: node.description,
+ index,
+ permissions: convertPermissions(node.permissions),
+ }
+}
+
+/**
+ * Resolve a structure and build runtime format with field indices
+ */
+const resolveStructure = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeStructure => {
+ const resolvedFields = resolveStructureIndices(node, debugVariables)
+
+ return {
+ node_id: node.nodeId,
+ browse_name: node.browseName,
+ display_name: node.displayName,
+ description: node.description,
+ fields: resolvedFields.map((field) => ({
+ name: field.name,
+ datatype: field.datatype,
+ initial_value: field.initialValue,
+ index: field.index,
+ permissions: convertPermissions(field.permissions),
+ })),
+ }
+}
+
+/**
+ * Resolve an array and build runtime format
+ */
+const resolveArray = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeArray => {
+ const index = resolveArrayIndex(node, debugVariables)
+
+ return {
+ node_id: node.nodeId,
+ browse_name: node.browseName,
+ display_name: node.displayName,
+ datatype: node.elementType || node.variableType,
+ length: node.arrayLength || 1,
+ initial_value: node.initialValue,
+ index,
+ permissions: convertPermissions(node.permissions),
+ }
+}
+
+/**
+ * Build the complete address space configuration
+ */
+const buildAddressSpace = (config: OpcUaServerConfig, debugVariables: DebugVariable[]): RuntimeAddressSpace => {
+ const variables: RuntimeVariable[] = []
+ const structures: RuntimeStructure[] = []
+ const arrays: RuntimeArray[] = []
+ const errors: OpcUaConfigError[] = []
+
+ for (const node of config.addressSpace.nodes) {
+ try {
+ switch (node.nodeType) {
+ case 'variable':
+ variables.push(resolveVariable(node, debugVariables))
+ break
+ case 'structure':
+ structures.push(resolveStructure(node, debugVariables))
+ break
+ case 'array':
+ arrays.push(resolveArray(node, debugVariables))
+ break
+ }
+ } catch (error) {
+ if (error instanceof OpcUaConfigError) {
+ errors.push(error)
+ } else {
+ throw error
+ }
+ }
+ }
+
+ // If there are resolution errors, throw them all together
+ if (errors.length > 0) {
+ const errorMessages = errors.map((e) => e.message).join('\n\n')
+ throw new OpcUaConfigError(
+ 'multiple',
+ 'multiple',
+ `Failed to resolve ${errors.length} OPC-UA variable(s):\n\n${errorMessages}`,
+ )
+ }
+
+ return {
+ namespace_uri: config.addressSpace.namespaceUri,
+ variables,
+ structures,
+ arrays,
+ }
+}
+
+/**
+ * Parse the debug.c file content to extract debug variables
+ */
+export const parseDebugFile = (content: string): DebugVariable[] => {
+ const variables: DebugVariable[] = []
+
+ // Find the debug_vars[] array
+ const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
+
+ if (!debugVarsMatch) {
+ console.warn('Could not find debug_vars[] array in debug.c')
+ return []
+ }
+
+ const arrayContent = debugVarsMatch[1]
+
+ // Parse each entry: { &(VARIABLE_PATH), TYPE }
+ const entryRegex = /\{\s*&\(([^)]+)\)\s*,\s*(\w+)\s*\}/g
+
+ let match
+ let index = 0
+
+ while ((match = entryRegex.exec(arrayContent)) !== null) {
+ const fullPath = match[1].trim()
+ const type = match[2].trim()
+
+ variables.push({
+ name: fullPath,
+ type,
+ index,
+ })
+
+ index++
+ }
+
+ return variables
+}
+
+/**
+ * Generates the OPC-UA configuration JSON for the runtime plugin.
+ * Converts camelCase properties to snake_case expected by the C plugin.
+ * Resolves variable indices from the debug.c file.
+ *
+ * @param servers - Array of configured PLC servers
+ * @param debugFileContent - Content of the generated debug.c file
+ * @returns JSON string for opcua.json or null if no enabled OPC-UA server
+ */
+export const generateOpcUaConfig = (servers: PLCServer[] | undefined, debugFileContent: string): string | null => {
+ // 1. Find OPC-UA server configuration
+ if (!servers || servers.length === 0) {
+ return null
+ }
+
+ const opcuaServer = servers.find((s) => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled)
+
+ if (!opcuaServer?.opcuaServerConfig) {
+ return null
+ }
+
+ const config = opcuaServer.opcuaServerConfig
+
+ // 2. Parse debug.c to get variable indices
+ const debugVariables = parseDebugFile(debugFileContent)
+
+ if (debugVariables.length === 0 && config.addressSpace.nodes.length > 0) {
+ throw new OpcUaConfigError(
+ 'debug.c',
+ 'debug_vars[]',
+ 'Cannot resolve OPC-UA variable indices: debug.c appears to be empty or invalid.\n' +
+ 'This may happen if the PLC program compilation failed.',
+ )
+ }
+
+ // 3. Build runtime configuration
+ const runtimeConfig: RuntimeConfig = {
+ name: 'opcua_server',
+ protocol: 'OPC-UA',
+ config: {
+ server: buildServerConfig(config),
+ security: buildSecurityConfig(config),
+ users: buildUsersConfig(config),
+ cycle_time_ms: config.cycleTimeMs,
+ address_space: buildAddressSpace(config, debugVariables),
+ },
+ }
+
+ // 4. Return as JSON string (wrapped in array as expected by runtime)
+ return JSON.stringify([runtimeConfig], null, 2)
+}
+
+/**
+ * Validates the OPC-UA configuration before generation.
+ * Returns validation errors without throwing.
+ */
+export const validateOpcUaConfig = (
+ config: OpcUaServerConfig,
+ debugFileContent: string,
+): { valid: boolean; errors: string[] } => {
+ const errors: string[] = []
+
+ // Check for enabled security profiles
+ const enabledProfiles = config.securityProfiles.filter((sp) => sp.enabled)
+ if (enabledProfiles.length === 0) {
+ errors.push('At least one security profile must be enabled')
+ }
+
+ // Check for username auth without users
+ const hasUsernameAuth = enabledProfiles.some((sp) => sp.authMethods.includes('Username'))
+ if (hasUsernameAuth && config.users.length === 0) {
+ errors.push('Username authentication is enabled but no users are configured')
+ }
+
+ // Try to resolve all variables
+ const debugVariables = parseDebugFile(debugFileContent)
+
+ for (const node of config.addressSpace.nodes) {
+ try {
+ switch (node.nodeType) {
+ case 'variable':
+ resolveVariableIndex(node, debugVariables)
+ break
+ case 'structure':
+ resolveStructureIndices(node, debugVariables)
+ break
+ case 'array':
+ resolveArrayIndex(node, debugVariables)
+ break
+ }
+ } catch (error) {
+ if (error instanceof OpcUaConfigError) {
+ errors.push(error.message)
+ } else {
+ errors.push(String(error))
+ }
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ }
+}
+
+export type {
+ RuntimeAddressSpace,
+ RuntimeArray,
+ RuntimeConfig,
+ RuntimePluginConfig,
+ RuntimeSecurityConfig,
+ RuntimeSecurityProfile,
+ RuntimeServerConfig,
+ RuntimeStructure,
+ RuntimeStructureField,
+ RuntimeTrustedCertificate,
+ RuntimeUser,
+ RuntimeVariable,
+ RuntimeVariablePermissions,
+}
diff --git a/src/utils/opcua/index.ts b/src/utils/opcua/index.ts
new file mode 100644
index 000000000..ff5148acf
--- /dev/null
+++ b/src/utils/opcua/index.ts
@@ -0,0 +1,25 @@
+/**
+ * OPC-UA Configuration Utilities
+ *
+ * This module provides utilities for generating OPC-UA server configuration
+ * for the OpenPLC Runtime.
+ */
+
+export type {
+ RuntimeAddressSpace,
+ RuntimeArray,
+ RuntimeConfig,
+ RuntimePluginConfig,
+ RuntimeSecurityConfig,
+ RuntimeSecurityProfile,
+ RuntimeServerConfig,
+ RuntimeStructure,
+ RuntimeStructureField,
+ RuntimeTrustedCertificate,
+ RuntimeUser,
+ RuntimeVariable,
+ RuntimeVariablePermissions,
+} from './generate-opcua-config'
+export { generateOpcUaConfig, parseDebugFile, validateOpcUaConfig } from './generate-opcua-config'
+export { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from './resolve-indices'
+export type { DebugVariable, ResolvedField } from './types'
diff --git a/src/utils/opcua/resolve-indices.ts b/src/utils/opcua/resolve-indices.ts
new file mode 100644
index 000000000..80fe02dd6
--- /dev/null
+++ b/src/utils/opcua/resolve-indices.ts
@@ -0,0 +1,302 @@
+import type { OpcUaNodeConfig } from '@root/types/PLC/open-plc'
+
+import type { DebugVariable, ResolvedField } from './types'
+
+/**
+ * Custom error class for OPC-UA configuration errors
+ */
+export class OpcUaConfigError extends Error {
+ constructor(
+ public readonly variableRef: string,
+ public readonly expectedPath: string,
+ message: string,
+ ) {
+ super(message)
+ this.name = 'OpcUaConfigError'
+ }
+}
+
+/**
+ * Build the debug path for a program/FB instance variable.
+ * Handles nested structures, arrays, and function blocks.
+ *
+ * @param pouName - Name of the POU (program or FB)
+ * @param variablePath - Path to the variable (e.g., "MOTOR_SPEED" or "CTRL.OUTPUT")
+ * @returns The expected debug.c path
+ */
+const buildInstancePath = (pouName: string, variablePath: string): string => {
+ // The instance name in debug.c is uppercase
+ // Format: RES0__INSTANCE0.VARIABLE_PATH
+ // Note: INSTANCE0 is typically the program name, but for simplicity we use the standard format
+
+ const pathParts = variablePath.split('.')
+ let debugPath = `RES0__${pouName.toUpperCase()}`
+
+ for (const part of pathParts) {
+ // Check if this part is an array index like "[0]"
+ if (part.includes('[')) {
+ // Handle array access: VAR[0] -> VAR.value.table[0]
+ const bracketIndex = part.indexOf('[')
+ const varName = part.substring(0, bracketIndex)
+ const arrayIndex = part.substring(bracketIndex)
+ debugPath += `.${varName.toUpperCase()}.value.table${arrayIndex}`
+ } else {
+ debugPath += `.${part.toUpperCase()}`
+ }
+ }
+
+ return debugPath
+}
+
+/**
+ * Build the debug path for a structure field.
+ * Structure fields have ".value." inserted before each field access.
+ *
+ * @param pouName - Name of the POU
+ * @param variablePath - Path including structure and field (e.g., "SENSOR.temperature")
+ * @returns The expected debug.c path
+ */
+const buildStructFieldPath = (pouName: string, variablePath: string): string => {
+ const pathParts = variablePath.split('.')
+ let debugPath = `RES0__${pouName.toUpperCase()}`
+
+ // First part is the variable name
+ debugPath += `.${pathParts[0].toUpperCase()}`
+
+ // Subsequent parts are fields, need .value. prefix
+ for (let i = 1; i < pathParts.length; i++) {
+ const part = pathParts[i]
+
+ // Check if this part is an array index
+ if (part.includes('[')) {
+ const bracketIndex = part.indexOf('[')
+ const fieldName = part.substring(0, bracketIndex)
+ const arrayIndex = part.substring(bracketIndex)
+
+ if (fieldName) {
+ debugPath += `.value.${fieldName.toUpperCase()}.value.table${arrayIndex}`
+ } else {
+ // Just an array index like "[0]"
+ debugPath += `.value.table${arrayIndex}`
+ }
+ } else {
+ debugPath += `.value.${part.toUpperCase()}`
+ }
+ }
+
+ return debugPath
+}
+
+/**
+ * Build the debug path for an array's first element.
+ *
+ * @param pouName - Name of the POU
+ * @param variablePath - Path to the array variable
+ * @returns The expected debug.c path for element [0]
+ */
+const buildArrayElementPath = (pouName: string, variablePath: string): string => {
+ // Array elements are stored as VAR.value.table[i]
+ return `RES0__${pouName.toUpperCase()}.${variablePath.toUpperCase()}.value.table[0]`
+}
+
+/**
+ * Build the debug path for a global variable.
+ * Global variables use CONFIG0__ prefix.
+ *
+ * @param variablePath - Path to the global variable
+ * @returns The expected debug.c path
+ */
+const buildGlobalPath = (variablePath: string): string => {
+ return `CONFIG0__${variablePath.toUpperCase()}`
+}
+
+/**
+ * Determine if a variable path looks like a structure field access.
+ * Structure fields contain dots but aren't arrays or simple FB variables.
+ */
+const isStructureFieldPath = (variablePath: string): boolean => {
+ // If it contains a dot and doesn't look like an array element
+ return variablePath.includes('.') && !variablePath.includes('[')
+}
+
+/**
+ * Resolve the index for a simple variable node.
+ *
+ * @param node - The OPC-UA node configuration
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @returns The resolved index
+ * @throws OpcUaConfigError if the variable cannot be resolved
+ */
+export const resolveVariableIndex = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): number => {
+ let debugPath: string
+
+ // Determine the debug path based on variable location
+ if (node.pouName === 'GVL' || node.pouName === 'CONFIG' || node.pouName.toUpperCase() === 'GVL') {
+ // Global variable
+ debugPath = buildGlobalPath(node.variablePath)
+ } else if (isStructureFieldPath(node.variablePath)) {
+ // Structure field access
+ debugPath = buildStructFieldPath(node.pouName, node.variablePath)
+ } else {
+ // Simple instance variable
+ debugPath = buildInstancePath(node.pouName, node.variablePath)
+ }
+
+ // Find matching entry in debug.c (case-insensitive comparison)
+ const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
+
+ if (!match) {
+ // Try alternative paths
+ // Sometimes the path might be simpler without .value. insertions
+ const simplePath = `RES0__${node.pouName.toUpperCase()}.${node.variablePath.toUpperCase()}`
+ const simpleMatch = debugVariables.find((dv) => dv.name.toUpperCase() === simplePath.toUpperCase())
+
+ if (simpleMatch) {
+ return simpleMatch.index
+ }
+
+ throw new OpcUaConfigError(
+ `${node.pouName}:${node.variablePath}`,
+ debugPath,
+ `Cannot resolve OPC-UA variable index.\n` +
+ ` Variable: ${node.pouName}:${node.variablePath}\n` +
+ ` Expected debug path: ${debugPath}\n` +
+ ` This may happen if:\n` +
+ ` - The PLC program was modified after configuring OPC-UA\n` +
+ ` - The variable name is incorrect\n` +
+ ` - The variable was removed from the program\n` +
+ ` Please verify the variable exists in the program.`,
+ )
+ }
+
+ return match.index
+}
+
+/**
+ * Resolve indices for all fields in a structure.
+ *
+ * @param node - The OPC-UA node configuration for the structure
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @returns Array of resolved fields with indices
+ * @throws OpcUaConfigError if any field cannot be resolved
+ */
+export const resolveStructureIndices = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): ResolvedField[] => {
+ if (!node.fields || node.fields.length === 0) {
+ // If no field configs, try to resolve the structure variable itself
+ const index = resolveVariableIndex(node, debugVariables)
+ return [
+ {
+ name: node.variablePath,
+ datatype: node.variableType,
+ initialValue: node.initialValue,
+ index,
+ permissions: node.permissions,
+ },
+ ]
+ }
+
+ const resolvedFields: ResolvedField[] = []
+
+ for (const field of node.fields) {
+ // Build the full path for this field
+ const fullFieldPath = `${node.variablePath}.${field.fieldPath}`
+
+ let debugPath: string
+
+ if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
+ // Global structure field
+ debugPath = buildGlobalPath(fullFieldPath)
+ } else {
+ // Instance structure field
+ debugPath = buildStructFieldPath(node.pouName, fullFieldPath)
+ }
+
+ // Find matching entry
+ const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
+
+ if (!match) {
+ // Try simpler path
+ const simplePath = `RES0__${node.pouName.toUpperCase()}.${fullFieldPath.toUpperCase()}`
+ const simpleMatch = debugVariables.find((dv) => dv.name.toUpperCase() === simplePath.toUpperCase())
+
+ if (simpleMatch) {
+ resolvedFields.push({
+ name: field.fieldPath,
+ datatype: node.variableType, // Will be refined by caller
+ initialValue: field.initialValue,
+ index: simpleMatch.index,
+ permissions: field.permissions,
+ })
+ continue
+ }
+
+ throw new OpcUaConfigError(
+ `${node.pouName}:${fullFieldPath}`,
+ debugPath,
+ `Cannot resolve OPC-UA structure field index.\n` +
+ ` Structure: ${node.pouName}:${node.variablePath}\n` +
+ ` Field: ${field.fieldPath}\n` +
+ ` Expected debug path: ${debugPath}`,
+ )
+ }
+
+ resolvedFields.push({
+ name: field.fieldPath,
+ datatype: match.type || node.variableType,
+ initialValue: field.initialValue,
+ index: match.index,
+ permissions: field.permissions,
+ })
+ }
+
+ return resolvedFields
+}
+
+/**
+ * Resolve the starting index for an array.
+ * Arrays are stored sequentially, so we only need the first element's index.
+ *
+ * @param node - The OPC-UA node configuration for the array
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @returns The index of the first array element
+ * @throws OpcUaConfigError if the array cannot be resolved
+ */
+export const resolveArrayIndex = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): number => {
+ let debugPath: string
+
+ if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
+ // Global array - first element
+ debugPath = `CONFIG0__${node.variablePath.toUpperCase()}.value.table[0]`
+ } else {
+ // Instance array - first element
+ debugPath = buildArrayElementPath(node.pouName, node.variablePath)
+ }
+
+ // Find matching entry
+ const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
+
+ if (!match) {
+ // Try alternative: maybe the array path is simpler
+ const simplePath = `RES0__${node.pouName.toUpperCase()}.${node.variablePath.toUpperCase()}[0]`
+ const simpleMatch = debugVariables.find(
+ (dv) =>
+ dv.name.toUpperCase() === simplePath.toUpperCase() ||
+ dv.name.toUpperCase().endsWith(`${node.variablePath.toUpperCase()}.value.table[0]`),
+ )
+
+ if (simpleMatch) {
+ return simpleMatch.index
+ }
+
+ throw new OpcUaConfigError(
+ `${node.pouName}:${node.variablePath}`,
+ debugPath,
+ `Cannot resolve OPC-UA array index.\n` +
+ ` Array: ${node.pouName}:${node.variablePath}\n` +
+ ` Expected debug path: ${debugPath}\n` +
+ ` Looking for first element [0] of the array.`,
+ )
+ }
+
+ return match.index
+}
diff --git a/src/utils/opcua/types.ts b/src/utils/opcua/types.ts
new file mode 100644
index 000000000..3902bc66f
--- /dev/null
+++ b/src/utils/opcua/types.ts
@@ -0,0 +1,31 @@
+/**
+ * OPC-UA Configuration Types
+ * Types used for OPC-UA config generation and index resolution.
+ */
+
+/**
+ * Represents a debug variable parsed from debug.c
+ */
+export interface DebugVariable {
+ /** Full variable path (e.g., "RES0__INSTANCE0.MOTOR_SPEED") */
+ name: string
+ /** IEC type (e.g., "INT", "BOOL", "REAL") */
+ type: string
+ /** Index in the debug_vars array */
+ index: number
+}
+
+/**
+ * Resolved field information for structures
+ */
+export interface ResolvedField {
+ name: string
+ datatype: string
+ initialValue: boolean | number | string
+ index: number
+ permissions: {
+ viewer: 'r' | 'w' | 'rw'
+ operator: 'r' | 'w' | 'rw'
+ engineer: 'r' | 'w' | 'rw'
+ }
+}
From c2acc65e336475f9ac0fbe8400eed819129eea23 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Fri, 16 Jan 2026 21:43:32 -0500
Subject: [PATCH 07/21] feat: Add shared debug variable utilities and enhance
OPC-UA complex type support
- Create shared utilities for debug variable index resolution:
- debug-variable-finder.ts: Path building, variable lookup functions
- pou-helpers.ts: FB/struct lookup, leaf variable finding
- Enhance OPC-UA to support complex types (FBs, structures, arrays):
- Make complex types selectable in variable tree
- Recursively expand to leaf variables at compile time
- Add debugTypeToIecType() conversion for runtime compatibility
- Update compiler to pass instances to OPC-UA config generation
- Fix TypeScript errors with ArrayData type definition
- Update tests for new instance-based path resolution
Co-Authored-By: Claude Opus 4.5
---
src/main/modules/compiler/compiler-module.ts | 9 +-
.../components/variable-config-modal.tsx | 77 +-
.../hooks/use-project-variables.ts | 667 ++++++++----------
src/renderer/utils/debug-tree-builder.ts | 2 +-
src/utils/debug-variable-finder.ts | 196 +++++
.../__tests__/generate-opcua-config.test.ts | 247 ++++++-
src/utils/opcua/generate-opcua-config.ts | 67 +-
src/utils/opcua/index.ts | 2 +-
src/utils/opcua/resolve-indices.ts | 376 +++++-----
src/utils/opcua/types.ts | 13 +
src/utils/pou-helpers.ts | 209 ++++++
11 files changed, 1257 insertions(+), 608 deletions(-)
create mode 100644 src/utils/debug-variable-finder.ts
create mode 100644 src/utils/pou-helpers.ts
diff --git a/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts
index 0b7d6d391..3ba726d3d 100644
--- a/src/main/modules/compiler/compiler-module.ts
+++ b/src/main/modules/compiler/compiler-module.ts
@@ -1242,8 +1242,15 @@ class CompilerModule {
debugContent = ''
}
+ // Get instances from Resources configuration for index resolution
+ const instances = projectData.configuration.resource.instances.map((inst) => ({
+ name: inst.name,
+ task: inst.task,
+ program: inst.program,
+ }))
+
// Generate the OPC-UA configuration
- const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent)
+ const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent, instances)
if (opcuaJson) {
// Ensure conf directory exists
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index 72041094a..6020570b2 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -44,16 +44,28 @@ const generateBrowseName = (variablePath: string): string => {
/**
* Generate a display name from the variable path
+ * Handles camelCase, snake_case, and ALL_CAPS naming conventions
*/
const generateDisplayName = (variablePath: string): string => {
const parts = variablePath.split('.')
const name = parts[parts.length - 1] || variablePath
- // Convert camelCase/snake_case to Title Case with spaces
- return name
- .replace(/_/g, ' ')
- .replace(/([A-Z])/g, ' $1')
+
+ // First replace underscores with spaces
+ let result = name.replace(/_/g, ' ')
+
+ // Only add spaces before uppercase letters if the string is NOT all uppercase
+ // This prevents "IRRIGATION MAIN CONTROLLER0" from becoming "I R R I G A T I O N..."
+ const isAllUpperCase = name === name.toUpperCase()
+ if (!isAllUpperCase) {
+ // For camelCase/PascalCase: add space before uppercase letters
+ result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
+ }
+
+ // Convert to Title Case
+ return result
.replace(/^\s+/, '')
.split(' ')
+ .filter((word) => word.length > 0)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
@@ -81,18 +93,63 @@ const getNodeType = (node: VariableTreeNode): 'variable' | 'structure' | 'array'
}
/**
- * Generate default field configs from a structure/FB node
+ * Recursively collect all base-type leaf variables from a tree node.
+ * Returns an array of { relativePath, displayName, variableType } for each leaf.
+ */
+const collectLeafVariables = (
+ node: VariableTreeNode,
+ parentPath: string = '',
+): Array<{ relativePath: string; displayName: string; variableType: string }> => {
+ const leaves: Array<{ relativePath: string; displayName: string; variableType: string }> = []
+
+ if (!node.children || node.children.length === 0) {
+ // This node itself is a leaf (base type variable)
+ if (node.type === 'variable' && node.isSelectable) {
+ leaves.push({
+ relativePath: parentPath || node.name,
+ displayName: node.name,
+ variableType: node.variableType || 'unknown',
+ })
+ }
+ return leaves
+ }
+
+ // Recurse into children
+ for (const child of node.children) {
+ const childPath = parentPath ? `${parentPath}.${child.name}` : child.name
+
+ if (child.type === 'variable' && child.isSelectable) {
+ // This is a base-type leaf
+ leaves.push({
+ relativePath: childPath,
+ displayName: child.name,
+ variableType: child.variableType || 'unknown',
+ })
+ } else if (child.children && child.children.length > 0) {
+ // This is a complex type (structure, FB, or array) - recurse
+ const childLeaves = collectLeafVariables(child, childPath)
+ leaves.push(...childLeaves)
+ }
+ }
+
+ return leaves
+}
+
+/**
+ * Generate default field configs from a structure/FB/array node.
+ * Recursively expands to all base-type leaf variables.
+ * The fieldPath is the full relative path from the parent (e.g., "FB1.Q" or "STRUCT.FIELD").
*/
const generateDefaultFieldConfigs = (
node: VariableTreeNode,
parentPermissions: OpcUaPermissions,
): OpcUaFieldConfig[] => {
- if (!node.children) return []
+ const leaves = collectLeafVariables(node)
- return node.children.map((child) => ({
- fieldPath: child.variablePath,
- displayName: child.name,
- initialValue: getDefaultInitialValue(child.variableType),
+ return leaves.map((leaf) => ({
+ fieldPath: leaf.relativePath,
+ displayName: leaf.displayName,
+ initialValue: getDefaultInitialValue(leaf.variableType),
permissions: { ...parentPermissions },
}))
}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
index c30f7ca69..06abb06dc 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts
@@ -1,7 +1,24 @@
import { useOpenPLCStore } from '@root/renderer/store'
import type { PLCDataType, PLCPou, PLCVariable } from '@root/types/PLC/open-plc'
+import {
+ findFunctionBlockVariables,
+ findStructureVariables,
+ isBaseType,
+ isEnumerationType,
+ isFunctionBlockType,
+ type PouVariable,
+} from '@root/utils/pou-helpers'
import { useMemo } from 'react'
+/**
+ * Type for array data extracted from PLCVariable array type.
+ * This is the shape of the 'data' property when type.definition === 'array'.
+ */
+type ArrayData = {
+ baseType: { definition: 'base-type'; value: string } | { definition: 'user-data-type'; value: string }
+ dimensions: Array<{ dimension: string }>
+}
+
/**
* Tree node type for variable tree display
*/
@@ -15,431 +32,382 @@ export interface VariableTreeNode {
variablePath: string
isSelectable: boolean
isExpanded?: boolean
- // Additional metadata for OPC-UA node configuration
variableClass?: string
initialValue?: string | null
- // Phase 5: Complex types metadata
arrayInfo?: {
- dimensions: string[] // e.g., ["1..10"] or ["0..4", "0..2"] for multi-dimensional
- elementType: string // Base type of array elements
- totalLength: number // Total number of elements
+ dimensions: string[]
+ elementType: string
+ totalLength: number
}
structureInfo?: {
- structTypeName: string // Name of the structure type
- fieldCount: number // Number of fields
+ structTypeName: string
+ fieldCount: number
}
}
/**
- * Check if a type is a base IEC type (reserved for Phase 5 - Complex Types)
+ * Get the type name from a variable's type field.
*/
-const _isBaseType = (typeName: string): boolean => {
- const baseTypes = [
- 'bool',
- 'sint',
- 'int',
- 'dint',
- 'lint',
- 'usint',
- 'uint',
- 'udint',
- 'ulint',
- 'real',
- 'lreal',
- 'time',
- 'date',
- 'tod',
- 'dt',
- 'string',
- 'byte',
- 'word',
- 'dword',
- 'lword',
- ]
- return baseTypes.includes(typeName.toLowerCase())
+const getTypeName = (variable: { type: { value: string } }): string => {
+ return variable.type.value
}
/**
- * Check if a type is a standard function block (TON, TOF, CTU, etc.)
- * Reserved for Phase 5 - Complex Types
+ * Get display type string for a variable.
*/
-const _isStandardFunctionBlock = (typeName: string): boolean => {
- const standardFBs = ['ton', 'tof', 'tp', 'ctu', 'ctd', 'ctud', 'r_trig', 'f_trig', 'sr', 'rs']
- return standardFBs.includes(typeName.toLowerCase())
+const _getDisplayType = (variable: PLCVariable | { type: PLCVariable['type']; name: string }): string => {
+ if (variable.type.definition === 'base-type') {
+ return variable.type.value.toUpperCase()
+ } else if (variable.type.definition === 'array' && variable.type.data) {
+ const dimensions = variable.type.data.dimensions.map((d) => d.dimension).join(', ')
+ const baseType =
+ variable.type.data.baseType.definition === 'base-type'
+ ? variable.type.data.baseType.value.toUpperCase()
+ : variable.type.data.baseType.value
+ return `ARRAY[${dimensions}] OF ${baseType}`
+ }
+ return variable.type.value
}
/**
- * Find a data type by name in the project's data types
+ * Parse array dimension string to get bounds.
*/
-const findDataType = (typeName: string, dataTypes: PLCDataType[]): PLCDataType | undefined => {
- return dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase())
+const parseArrayDimension = (dimension: string): { min: number; max: number; length: number } => {
+ const parts = dimension.split('..')
+ if (parts.length === 2) {
+ const min = parseInt(parts[0], 10)
+ const max = parseInt(parts[1], 10)
+ return { min, max, length: max - min + 1 }
+ }
+ const len = parseInt(dimension, 10) || 1
+ return { min: 0, max: len - 1, length: len }
}
-/**
- * Find a function block by name in the project's POUs
- */
-const findFunctionBlock = (typeName: string, pous: PLCPou[]): PLCPou | undefined => {
- return pous.find((pou) => pou.type === 'function-block' && pou.data.name.toLowerCase() === typeName.toLowerCase())
-}
+const MAX_ARRAY_EXPANSION = 50
/**
- * Get the type name from a PLCVariable's type field
+ * Recursively build a tree node for any variable type.
+ * This is the universal handler that works with ANY FB (standard or custom).
*/
-const getTypeName = (variable: PLCVariable | { type: PLCVariable['type']; name: string }): string => {
- if (variable.type.definition === 'base-type') {
- return variable.type.value
- } else if (variable.type.definition === 'array') {
- return variable.type.value
- } else {
- return variable.type.value
+const buildVariableNode = (
+ name: string,
+ typeName: string,
+ typeDefinition: string,
+ pouName: string,
+ parentPath: string,
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+ variableClass?: string,
+ initialValue?: PLCVariable['initialValue'],
+ arrayData?: ArrayData,
+): VariableTreeNode | null => {
+ const variablePath = parentPath ? `${parentPath}.${name}` : name
+
+ // Filter out enumerations
+ if (isEnumerationType(typeName, dataTypes)) {
+ return null
}
-}
-/**
- * Get display type string for a variable
- */
-const getDisplayType = (variable: PLCVariable | { type: PLCVariable['type']; name: string }): string => {
- if (variable.type.definition === 'base-type') {
- return variable.type.value.toUpperCase()
- } else if (variable.type.definition === 'array' && variable.type.data) {
- const dimensions = variable.type.data.dimensions.map((d) => d.dimension).join(', ')
- const baseType =
- variable.type.data.baseType.definition === 'base-type'
- ? variable.type.data.baseType.value.toUpperCase()
- : variable.type.data.baseType.value
- return `ARRAY[${dimensions}] OF ${baseType}`
- } else if (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') {
- return variable.type.value
+ // Handle arrays
+ if (typeDefinition === 'array' && arrayData) {
+ return buildArrayNode(name, pouName, parentPath, dataTypes, pous, variableClass, initialValue, arrayData)
}
- return variable.type.value
+
+ // Base type - this is a selectable leaf
+ if (isBaseType(typeName)) {
+ return {
+ id: `${pouName}-${variablePath}`,
+ name,
+ type: 'variable',
+ variableType: typeName.toUpperCase(),
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass,
+ initialValue,
+ }
+ }
+
+ // Check if it's a structure
+ const structVariables = findStructureVariables(typeName, dataTypes)
+ if (structVariables) {
+ return buildStructureNode(
+ name,
+ typeName,
+ pouName,
+ parentPath,
+ structVariables,
+ dataTypes,
+ pous,
+ variableClass,
+ initialValue,
+ )
+ }
+
+ // Check if it's a function block (standard OR custom - universal lookup)
+ const fbVariables = findFunctionBlockVariables(typeName, pous)
+ if (fbVariables) {
+ return buildFunctionBlockNode(name, typeName, pouName, parentPath, fbVariables, dataTypes, pous, variableClass)
+ }
+
+ // Unknown type - not selectable
+ return null
}
/**
- * Build a tree node for a structure type
+ * Build a tree node for a structure type.
*/
const buildStructureNode = (
- variable: PLCVariable,
+ name: string,
+ structTypeName: string,
pouName: string,
- dataType: PLCDataType,
+ parentPath: string,
+ structVariables: PouVariable[],
dataTypes: PLCDataType[],
pous: PLCPou[],
- parentPath: string = '',
+ variableClass?: string,
+ initialValue?: PLCVariable['initialValue'],
): VariableTreeNode => {
- if (dataType.derivation !== 'structure') {
- throw new Error('Expected structure data type')
- }
-
- const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+ const variablePath = parentPath ? `${parentPath}.${name}` : name
+
+ const children = structVariables
+ .map((field) => {
+ const fieldTypeName = field.type.value
+ // Use type assertion since PouVariable has a simplified type structure
+ const fieldType = field.type as { definition: string; value: string; data?: ArrayData }
+ const arrayData = fieldType.definition === 'array' && fieldType.data ? fieldType.data : undefined
+ return buildVariableNode(
+ field.name,
+ fieldTypeName,
+ field.type.definition,
+ pouName,
+ variablePath,
+ dataTypes,
+ pous,
+ undefined,
+ undefined,
+ arrayData,
+ )
+ })
+ .filter((node): node is VariableTreeNode => node !== null)
return {
id: `${pouName}-${variablePath}`,
- name: variable.name,
+ name,
type: 'structure',
- variableType: dataType.name,
+ variableType: structTypeName,
pouName,
variablePath,
- isSelectable: true,
- variableClass: 'class' in variable ? variable.class : undefined,
- initialValue: variable.initialValue,
+ isSelectable: true, // Selectable - will expand to leaf variables during index resolution
+ variableClass,
+ initialValue,
structureInfo: {
- structTypeName: dataType.name,
- fieldCount: dataType.variable.length,
+ structTypeName: structTypeName,
+ fieldCount: children.length,
},
- children: dataType.variable.map((field) => {
- const fieldTypeName = getTypeName(field)
- const fieldPath = `${variablePath}.${field.name}`
-
- // Check if field is a nested structure
- const nestedDataType = findDataType(fieldTypeName, dataTypes)
- if (nestedDataType && nestedDataType.derivation === 'structure') {
- return buildStructureNode(
- { ...field, location: '', documentation: '' } as PLCVariable,
- pouName,
- nestedDataType,
- dataTypes,
- pous,
- variablePath,
- )
- }
-
- // Check if field is an array
- if (field.type.definition === 'array' && field.type.data) {
- return buildArrayNode(
- { ...field, location: '', documentation: '' } as PLCVariable,
- pouName,
- dataTypes,
- pous,
- variablePath,
- )
- }
-
- // Simple variable field
- return {
- id: `${pouName}-${fieldPath}`,
- name: field.name,
- type: 'variable' as const,
- variableType: getDisplayType(field),
- pouName,
- variablePath: fieldPath,
- isSelectable: true,
- initialValue: field.initialValue?.simpleValue?.value,
- }
- }),
+ children,
}
}
/**
- * Parse array dimension string (e.g., "1..10" or "0..5") to get bounds
+ * Build a tree node for a function block instance.
+ * Works with ANY FB - standard library or custom user-defined.
*/
-const parseArrayDimension = (dimension: string): { min: number; max: number; length: number } => {
- const parts = dimension.split('..')
- if (parts.length === 2) {
- const min = parseInt(parts[0], 10)
- const max = parseInt(parts[1], 10)
- return { min, max, length: max - min + 1 }
+const buildFunctionBlockNode = (
+ name: string,
+ fbTypeName: string,
+ pouName: string,
+ parentPath: string,
+ fbVariables: PouVariable[],
+ dataTypes: PLCDataType[],
+ pous: PLCPou[],
+ variableClass?: string,
+): VariableTreeNode => {
+ const variablePath = parentPath ? `${parentPath}.${name}` : name
+
+ // Recursively build children for each FB variable
+ const children = fbVariables
+ .map((fbVar) => {
+ const varTypeName = fbVar.type.value
+
+ // Determine the actual type definition
+ // Library FBs may use 'derived-type', project FBs use 'derived' or other values
+ let effectiveDefinition = fbVar.type.definition
+
+ // For 'user-data-type', check if it's actually an FB
+ if (effectiveDefinition === 'user-data-type') {
+ if (isFunctionBlockType(varTypeName, pous)) {
+ effectiveDefinition = 'derived'
+ }
+ }
+
+ // Use type assertion since PouVariable has a simplified type structure
+ const fbVarType = fbVar.type as { definition: string; value: string; data?: ArrayData }
+ const arrayData = effectiveDefinition === 'array' && fbVarType.data ? fbVarType.data : undefined
+ return buildVariableNode(
+ fbVar.name,
+ varTypeName,
+ effectiveDefinition,
+ pouName,
+ variablePath,
+ dataTypes,
+ pous,
+ fbVar.class,
+ undefined,
+ arrayData,
+ )
+ })
+ .filter((node): node is VariableTreeNode => node !== null)
+
+ return {
+ id: `${pouName}-${variablePath}`,
+ name,
+ type: 'function_block',
+ variableType: fbTypeName,
+ pouName,
+ variablePath,
+ isSelectable: true, // Selectable - will expand to leaf variables during index resolution
+ variableClass,
+ children,
}
- // Fallback for single number (length)
- const len = parseInt(dimension, 10) || 1
- return { min: 0, max: len - 1, length: len }
}
/**
- * Maximum number of array elements to expand in the tree
- * Larger arrays will show as a single selectable node
- */
-const MAX_ARRAY_EXPANSION = 50
-
-/**
- * Build a tree node for an array type
- * Phase 5: Now includes array element expansion for reasonably sized arrays
+ * Build a tree node for an array type.
*/
const buildArrayNode = (
- variable: PLCVariable,
+ name: string,
pouName: string,
+ parentPath: string,
dataTypes: PLCDataType[],
pous: PLCPou[],
- parentPath: string = '',
+ variableClass?: string,
+ initialValue?: PLCVariable['initialValue'],
+ arrayData?: ArrayData,
): VariableTreeNode => {
- const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
+ const variablePath = parentPath ? `${parentPath}.${name}` : name
- // Extract array info from type
- if (variable.type.definition !== 'array' || !variable.type.data) {
- // Fallback for unexpected type
+ if (!arrayData) {
return {
id: `${pouName}-${variablePath}`,
- name: variable.name,
+ name,
type: 'array',
- variableType: getDisplayType(variable),
+ variableType: 'ARRAY',
pouName,
variablePath,
isSelectable: true,
- variableClass: 'class' in variable ? variable.class : undefined,
- initialValue: variable.initialValue,
+ variableClass,
+ initialValue,
}
}
- const dimensions = variable.type.data.dimensions.map((d) => d.dimension)
- const elementBaseType = variable.type.data.baseType
-
- // Get element type name
+ const dimensions = arrayData.dimensions.map((d: { dimension: string }) => d.dimension)
+ const elementBaseType = arrayData.baseType
const elementTypeName =
elementBaseType.definition === 'base-type' ? elementBaseType.value.toUpperCase() : elementBaseType.value
- // Calculate total length (for multi-dimensional, multiply all dimensions)
let totalLength = 1
- const parsedDimensions = dimensions.map((d) => {
+ const parsedDimensions = dimensions.map((d: string) => {
const parsed = parseArrayDimension(d)
totalLength *= parsed.length
return parsed
})
- // Build array info
- const arrayInfo = {
- dimensions,
- elementType: elementTypeName,
- totalLength,
+ const arrayInfo = { dimensions, elementType: elementTypeName, totalLength }
+
+ // For arrays of base types, the array itself is selectable
+ const elementsAreBaseType = elementBaseType.definition === 'base-type' && isBaseType(elementBaseType.value)
+ if (elementsAreBaseType) {
+ return {
+ id: `${pouName}-${variablePath}`,
+ name,
+ type: 'array',
+ variableType: `ARRAY[${dimensions.join(', ')}] OF ${elementTypeName}`,
+ pouName,
+ variablePath,
+ isSelectable: true,
+ variableClass,
+ initialValue,
+ arrayInfo,
+ }
}
- // For single-dimensional arrays with reasonable size, expand elements
+ // For arrays of complex types, expand elements if reasonable size
const shouldExpand = dimensions.length === 1 && totalLength <= MAX_ARRAY_EXPANSION
-
let children: VariableTreeNode[] | undefined
if (shouldExpand) {
const { min, max } = parsedDimensions[0]
-
- // Check if element type is a structure or FB
- const elementDataType =
- elementBaseType.definition !== 'base-type' ? findDataType(elementBaseType.value, dataTypes) : undefined
- const elementFbPou =
- elementBaseType.definition !== 'base-type' ? findFunctionBlock(elementBaseType.value, pous) : undefined
-
children = []
+
for (let i = min; i <= max; i++) {
- const elementPath = `${variablePath}[${i}]`
-
- if (elementDataType && elementDataType.derivation === 'structure') {
- // Array of structures
- children.push({
- id: `${pouName}-${elementPath}`,
- name: `[${i}]`,
- type: 'structure',
- variableType: elementTypeName,
- pouName,
- variablePath: elementPath,
- isSelectable: true,
- structureInfo: {
- structTypeName: elementDataType.name,
- fieldCount: elementDataType.variable.length,
- },
- children: elementDataType.variable.map((field) => {
- const fieldPath = `${elementPath}.${field.name}`
- return {
- id: `${pouName}-${fieldPath}`,
- name: field.name,
- type: 'variable' as const,
- variableType: getDisplayType(field),
- pouName,
- variablePath: fieldPath,
- isSelectable: true,
- }
- }),
- })
- } else if (elementFbPou) {
- // Array of function blocks
- children.push(
- buildFunctionBlockNode(
- {
- name: `[${i}]`,
- type: { definition: 'derived', value: elementFbPou.data.name },
- location: '',
- documentation: '',
- } as PLCVariable,
- pouName,
- elementFbPou,
- dataTypes,
- pous,
- variablePath,
- ),
- )
- } else {
- // Array of simple types
- children.push({
- id: `${pouName}-${elementPath}`,
- name: `[${i}]`,
- type: 'variable',
- variableType: elementTypeName,
- pouName,
- variablePath: elementPath,
- isSelectable: true,
- })
+ const elementNode = buildVariableNode(
+ `[${i}]`,
+ elementTypeName,
+ elementBaseType.definition,
+ pouName,
+ variablePath,
+ dataTypes,
+ pous,
+ )
+ if (elementNode) {
+ children.push(elementNode)
}
}
}
return {
id: `${pouName}-${variablePath}`,
- name: variable.name,
+ name,
type: 'array',
- variableType: getDisplayType(variable),
+ variableType: `ARRAY[${dimensions.join(', ')}] OF ${elementTypeName}`,
pouName,
variablePath,
- isSelectable: true,
- variableClass: 'class' in variable ? variable.class : undefined,
- initialValue: variable.initialValue,
+ isSelectable: true, // Selectable - will expand to leaf variables during index resolution
+ variableClass,
+ initialValue,
arrayInfo,
children,
}
}
/**
- * Build a tree node for a function block instance
+ * Build a tree node from a PLCVariable.
*/
-const buildFunctionBlockNode = (
+const buildVariableNodeFromPLC = (
variable: PLCVariable,
pouName: string,
- fbPou: PLCPou,
dataTypes: PLCDataType[],
pous: PLCPou[],
parentPath: string = '',
-): VariableTreeNode => {
- if (fbPou.type !== 'function-block') {
- throw new Error('Expected function block POU')
- }
-
- const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
-
- return {
- id: `${pouName}-${variablePath}`,
- name: variable.name,
- type: 'function_block',
- variableType: fbPou.data.name,
- pouName,
- variablePath,
- isSelectable: true,
- variableClass: 'class' in variable ? variable.class : undefined,
- children: fbPou.data.variables.map((fbVar) => {
- return buildVariableNode(fbVar, pouName, dataTypes, pous, variablePath)
- }),
- }
-}
-
-/**
- * Build a tree node for any variable type
- */
-const buildVariableNode = (
- variable: PLCVariable,
- pouName: string,
- dataTypes: PLCDataType[],
- pous: PLCPou[],
- parentPath: string = '',
-): VariableTreeNode => {
+): VariableTreeNode | null => {
const typeName = getTypeName(variable)
- const variablePath = parentPath ? `${parentPath}.${variable.name}` : variable.name
-
- // Check if it's an array
- if (variable.type.definition === 'array') {
- return buildArrayNode(variable, pouName, dataTypes, pous, parentPath)
- }
-
- // Check if it's a user-defined structure
- const dataType = findDataType(typeName, dataTypes)
- if (dataType && dataType.derivation === 'structure') {
- return buildStructureNode(variable, pouName, dataType, dataTypes, pous, parentPath)
- }
-
- // Check if it's a function block instance
- const fbPou = findFunctionBlock(typeName, pous)
- if (fbPou) {
- return buildFunctionBlockNode(variable, pouName, fbPou, dataTypes, pous, parentPath)
- }
-
- // Check if it's a standard function block (TON, TOF, etc.)
- // For now, we treat standard FBs as simple variables since we don't expose their internal state
- // This can be expanded in Phase 5
-
- // Simple variable
- return {
- id: `${pouName}-${variablePath}`,
- name: variable.name,
- type: 'variable',
- variableType: getDisplayType(variable),
+ return buildVariableNode(
+ variable.name,
+ typeName,
+ variable.type.definition,
pouName,
- variablePath,
- isSelectable: true,
- variableClass: variable.class,
- initialValue: variable.initialValue,
- }
+ parentPath,
+ dataTypes,
+ pous,
+ variable.class,
+ variable.initialValue,
+ variable.type.definition === 'array' ? variable.type.data : undefined,
+ )
}
/**
- * Build a tree node for a program POU
+ * Build a tree node for a program POU.
*/
const buildProgramNode = (pou: PLCPou, dataTypes: PLCDataType[], pous: PLCPou[]): VariableTreeNode => {
if (pou.type !== 'program') {
throw new Error('Expected program POU')
}
+ const children = pou.data.variables
+ .map((v) => buildVariableNodeFromPLC(v, pou.data.name, dataTypes, pous))
+ .filter((node): node is VariableTreeNode => node !== null)
+
return {
id: `pou-${pou.data.name}`,
name: pou.data.name,
@@ -447,18 +415,22 @@ const buildProgramNode = (pou: PLCPou, dataTypes: PLCDataType[], pous: PLCPou[])
pouName: pou.data.name,
variablePath: '',
isSelectable: false,
- children: pou.data.variables.map((v) => buildVariableNode(v, pou.data.name, dataTypes, pous)),
+ children,
}
}
/**
- * Build a tree node for global variables
+ * Build a tree node for global variables.
*/
const buildGlobalVariablesNode = (
globalVariables: PLCVariable[],
dataTypes: PLCDataType[],
pous: PLCPou[],
): VariableTreeNode => {
+ const children = globalVariables
+ .map((v) => buildVariableNodeFromPLC({ ...v, class: 'global' }, 'GVL', dataTypes, pous))
+ .filter((node): node is VariableTreeNode => node !== null)
+
return {
id: 'global-variables',
name: 'GVL (Global Variables)',
@@ -466,13 +438,15 @@ const buildGlobalVariablesNode = (
pouName: 'GVL',
variablePath: '',
isSelectable: false,
- children: globalVariables.map((v) => buildVariableNode({ ...v, class: 'global' }, 'GVL', dataTypes, pous)),
+ children,
}
}
/**
* Hook to extract variables from the project for OPC-UA address space configuration.
* Returns a hierarchical tree structure of all selectable variables.
+ * Base types, structures, FBs, and arrays are all selectable.
+ * Complex types (structures, FBs, arrays) are expanded to their leaf variables during index resolution.
*/
export const useProjectVariables = (): VariableTreeNode[] => {
const {
@@ -482,14 +456,12 @@ export const useProjectVariables = (): VariableTreeNode[] => {
return useMemo(() => {
const nodes: VariableTreeNode[] = []
- // Programs with their variables
for (const pou of projectData.pous) {
if (pou.type === 'program') {
nodes.push(buildProgramNode(pou, projectData.dataTypes, projectData.pous))
}
}
- // Global Variables
const globalVars = projectData.configuration.resource.globalVariables
if (globalVars && globalVars.length > 0) {
nodes.push(
@@ -505,119 +477,73 @@ export const useProjectVariables = (): VariableTreeNode[] => {
}, [projectData.pous, projectData.dataTypes, projectData.configuration.resource.globalVariables])
}
-/**
- * Helper to find a tree node by its ID
- */
+// Helper exports for tree manipulation
+
export const findTreeNodeById = (nodes: VariableTreeNode[], id: string): VariableTreeNode | undefined => {
for (const node of nodes) {
- if (node.id === id) {
- return node
- }
+ if (node.id === id) return node
if (node.children) {
const found = findTreeNodeById(node.children, id)
- if (found) {
- return found
- }
+ if (found) return found
}
}
return undefined
}
-/**
- * Helper to get all selectable leaf node IDs from a tree
- */
export const getAllSelectableIds = (nodes: VariableTreeNode[]): string[] => {
const ids: string[] = []
const traverse = (node: VariableTreeNode) => {
- if (node.isSelectable) {
- ids.push(node.id)
- }
+ if (node.isSelectable) ids.push(node.id)
node.children?.forEach(traverse)
}
nodes.forEach(traverse)
return ids
}
-/**
- * Helper to check if a node ID or any of its children are selected
- */
export const isNodeOrChildrenSelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
- if (selectedIds.has(node.id)) {
- return true
- }
- if (node.children) {
- return node.children.some((child) => isNodeOrChildrenSelected(child, selectedIds))
- }
- return false
+ if (selectedIds.has(node.id)) return true
+ return node.children?.some((child) => isNodeOrChildrenSelected(child, selectedIds)) ?? false
}
-/**
- * Helper to get all child IDs (including nested children) for a node
- */
export const getAllChildIds = (node: VariableTreeNode): string[] => {
const ids: string[] = []
const traverse = (n: VariableTreeNode) => {
- if (n.isSelectable) {
- ids.push(n.id)
- }
+ if (n.isSelectable) ids.push(n.id)
n.children?.forEach(traverse)
}
traverse(node)
return ids
}
-/**
- * Phase 5: Get only the selectable descendants (excluding the node itself)
- */
export const getSelectableDescendantIds = (node: VariableTreeNode): string[] => {
const ids: string[] = []
const traverse = (n: VariableTreeNode, isRoot: boolean) => {
- if (!isRoot && n.isSelectable) {
- ids.push(n.id)
- }
+ if (!isRoot && n.isSelectable) ids.push(n.id)
n.children?.forEach((child) => traverse(child, false))
}
traverse(node, true)
return ids
}
-/**
- * Phase 5: Check if all selectable descendants are selected
- */
export const areAllChildrenSelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
const descendantIds = getSelectableDescendantIds(node)
if (descendantIds.length === 0) return false
return descendantIds.every((id) => selectedIds.has(id))
}
-/**
- * Phase 5: Check if any selectable descendants are selected
- */
export const areAnyChildrenSelected = (node: VariableTreeNode, selectedIds: Set): boolean => {
const descendantIds = getSelectableDescendantIds(node)
return descendantIds.some((id) => selectedIds.has(id))
}
-/**
- * Phase 5: Selection state for complex types
- */
export type SelectionState = 'none' | 'some' | 'all'
-/**
- * Phase 5: Get the selection state of a node and its descendants
- */
export const getSelectionState = (node: VariableTreeNode, selectedIds: Set): SelectionState => {
- // Check if the node itself is selected
- if (selectedIds.has(node.id)) {
- return 'all'
- }
+ if (selectedIds.has(node.id)) return 'all'
- // For nodes with children, check descendants
if (node.children && node.children.length > 0) {
const descendantIds = getSelectableDescendantIds(node)
- if (descendantIds.length === 0) {
- return 'none'
- }
+ if (descendantIds.length === 0) return 'none'
const selectedCount = descendantIds.filter((id) => selectedIds.has(id)).length
if (selectedCount === 0) return 'none'
@@ -628,9 +554,6 @@ export const getSelectionState = (node: VariableTreeNode, selectedIds: Set {
return node.type === 'structure' || node.type === 'array' || node.type === 'function_block'
}
diff --git a/src/renderer/utils/debug-tree-builder.ts b/src/renderer/utils/debug-tree-builder.ts
index 5bf1c8d36..8e4b48965 100644
--- a/src/renderer/utils/debug-tree-builder.ts
+++ b/src/renderer/utils/debug-tree-builder.ts
@@ -4,7 +4,7 @@ import type { PLCProject, PLCVariable } from '@root/types/PLC/open-plc'
import { StandardFunctionBlocks } from '../data/library/standard-function-blocks'
import type { DebugVariable } from './parse-debug-file'
-const DEBUG_TREE_LOGGING = false
+const DEBUG_TREE_LOGGING = true
/**
* Normalizes type strings for case-insensitive comparison.
diff --git a/src/utils/debug-variable-finder.ts b/src/utils/debug-variable-finder.ts
new file mode 100644
index 000000000..e72fdfedb
--- /dev/null
+++ b/src/utils/debug-variable-finder.ts
@@ -0,0 +1,196 @@
+/**
+ * Shared utilities for finding debug variable indices from debug.c
+ * Used by both the debugger (renderer) and OPC-UA config generator (main).
+ */
+
+export interface DebugVariableEntry {
+ /** Full path in debug.c (e.g., "RES0__INSTANCE0.MOTOR_SPEED") */
+ name: string
+ /** IEC type enum (e.g., "INT_ENUM", "BOOL_ENUM") */
+ type: string
+ /** Index in the debug_vars array */
+ index: number
+}
+
+export interface PLCInstanceMapping {
+ /** Instance name (e.g., "INSTANCE0") - this appears in debug.c */
+ name: string
+ /** Program POU name being instantiated */
+ program: string
+}
+
+/**
+ * Parse the debug.c file content to extract debug variables.
+ * This is the canonical parser used by both debugger and OPC-UA.
+ */
+export function parseDebugVariables(content: string): DebugVariableEntry[] {
+ const variables: DebugVariableEntry[] = []
+
+ const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
+
+ if (!debugVarsMatch) {
+ console.warn('Could not find debug_vars[] array in debug.c')
+ return []
+ }
+
+ const arrayContent = debugVarsMatch[1]
+ const entryRegex = /\{\s*&\(([^)]+)\)\s*,\s*(\w+)\s*\}/g
+
+ let match
+ let index = 0
+
+ while ((match = entryRegex.exec(arrayContent)) !== null) {
+ variables.push({
+ name: match[1].trim(),
+ type: match[2].trim(),
+ index,
+ })
+ index++
+ }
+
+ return variables
+}
+
+/**
+ * Find the instance name for a program POU from the instances mapping.
+ *
+ * @param pouName - Name of the program POU
+ * @param instances - Array of PLC instances from Resources
+ * @returns The instance name or null if not found
+ */
+export function findInstanceName(pouName: string, instances: PLCInstanceMapping[]): string | null {
+ const instance = instances.find((inst) => inst.program.toUpperCase() === pouName.toUpperCase())
+ return instance ? instance.name : null
+}
+
+/**
+ * Check if a path part is an array index (e.g., "[0]", "[1]", etc.)
+ */
+const isArrayIndex = (part: string): boolean => {
+ return /^\[\d+\]$/.test(part)
+}
+
+/**
+ * Build the debug path for a variable.
+ *
+ * Path formats in debug.c:
+ * - Global variable: CONFIG0__VAR_NAME
+ * - Simple program variable: RES0__INSTANCE0.VAR_NAME
+ * - FB instance variable: RES0__INSTANCE0.FB_INSTANCE.VAR_NAME (no .value.)
+ * - Nested FB variable: RES0__INSTANCE0.FB1.FB2.VAR_NAME (no .value.)
+ * - Structure field: RES0__INSTANCE0.STRUCT_VAR.value.FIELD_NAME
+ * - Array element: RES0__INSTANCE0.ARRAY_VAR.value.table[i]
+ * - Array of FBs field: RES0__INSTANCE0.FB_ARRAY.value.table[i].FIELD_NAME
+ *
+ * @param instanceName - The instance name from Resources (e.g., "INSTANCE0")
+ * @param variablePath - The variable path (e.g., "MOTOR_SPEED" or "FB_INSTANCE.VAR" or "FB_ARRAY.[0].ET")
+ * @param isStructureField - True if accessing a field of a user-defined structure
+ * @param isArrayElement - True if accessing an array element
+ * @param arrayIndex - The array index (0-based) if isArrayElement is true
+ */
+export function buildDebugPath(
+ instanceName: string,
+ variablePath: string,
+ options: {
+ isStructureField?: boolean
+ isArrayElement?: boolean
+ arrayIndex?: number
+ } = {},
+): string {
+ const { isStructureField = false, isArrayElement = false, arrayIndex = 0 } = options
+
+ const pathParts = variablePath.split('.')
+ let debugPath = `RES0__${instanceName.toUpperCase()}`
+
+ if (isArrayElement && pathParts.length === 1) {
+ // Simple array: VAR_NAME -> RES0__INSTANCE.VAR_NAME.value.table[i]
+ debugPath += `.${pathParts[0].toUpperCase()}.value.table[${arrayIndex}]`
+ } else if (isStructureField) {
+ // Structure field: STRUCT.FIELD -> RES0__INSTANCE.STRUCT.value.FIELD
+ // First part is the struct variable name
+ debugPath += `.${pathParts[0].toUpperCase()}`
+ // Subsequent parts are fields, need .value. prefix (but handle array indices specially)
+ for (let i = 1; i < pathParts.length; i++) {
+ const part = pathParts[i]
+ if (isArrayIndex(part)) {
+ // Array index within structure: .value.table[i]
+ debugPath += `.value.table${part}`
+ } else {
+ debugPath += `.value.${part.toUpperCase()}`
+ }
+ }
+ } else {
+ // Simple variable or FB instance variable: no .value. insertion
+ // But handle array indices in the path: FB_ARRAY.[0].ET -> FB_ARRAY.value.table[0].ET
+ for (let i = 0; i < pathParts.length; i++) {
+ const part = pathParts[i]
+ if (isArrayIndex(part)) {
+ // Array index: insert .value.table before the index
+ debugPath += `.value.table${part}`
+ } else {
+ debugPath += `.${part.toUpperCase()}`
+ }
+ }
+ }
+
+ return debugPath
+}
+
+/**
+ * Build the debug path for a global variable.
+ */
+export function buildGlobalDebugPath(variablePath: string): string {
+ return `CONFIG0__${variablePath.toUpperCase()}`
+}
+
+/**
+ * Find a debug variable by its expected path (case-insensitive).
+ *
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @param expectedPath - The expected debug path
+ * @returns The matching debug variable or null
+ */
+export function findDebugVariable(
+ debugVariables: DebugVariableEntry[],
+ expectedPath: string,
+): DebugVariableEntry | null {
+ const upperPath = expectedPath.toUpperCase()
+ return debugVariables.find((dv) => dv.name.toUpperCase() === upperPath) || null
+}
+
+/**
+ * Find the index for a program/FB variable.
+ *
+ * @param instanceName - The instance name from Resources
+ * @param variablePath - The variable path (e.g., "MOTOR_SPEED" or "TIMER0.Q")
+ * @param debugVariables - Parsed debug variables
+ * @param options - Options for structure fields and array elements
+ * @returns The index or null if not found
+ */
+export function findVariableIndex(
+ instanceName: string,
+ variablePath: string,
+ debugVariables: DebugVariableEntry[],
+ options: {
+ isStructureField?: boolean
+ isArrayElement?: boolean
+ arrayIndex?: number
+ } = {},
+): number | null {
+ const debugPath = buildDebugPath(instanceName, variablePath, options)
+ const match = findDebugVariable(debugVariables, debugPath)
+ return match ? match.index : null
+}
+
+/**
+ * Find the index for a global variable.
+ *
+ * @param variablePath - The global variable path
+ * @param debugVariables - Parsed debug variables
+ * @returns The index or null if not found
+ */
+export function findGlobalVariableIndex(variablePath: string, debugVariables: DebugVariableEntry[]): number | null {
+ const debugPath = buildGlobalDebugPath(variablePath)
+ const match = findDebugVariable(debugVariables, debugPath)
+ return match ? match.index : null
+}
diff --git a/src/utils/opcua/__tests__/generate-opcua-config.test.ts b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
index 6137f375b..20ee626d5 100644
--- a/src/utils/opcua/__tests__/generate-opcua-config.test.ts
+++ b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
@@ -2,25 +2,44 @@ import type { OpcUaServerConfig, PLCServer } from '@root/types/PLC/open-plc'
import { generateOpcUaConfig, parseDebugFile, validateOpcUaConfig } from '../generate-opcua-config'
import { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from '../resolve-indices'
+import type { PLCInstanceInfo } from '../types'
// Sample debug.c content for testing
+// Uses INSTANCE0 as the instance name (matching the sampleInstances below)
const sampleDebugContent = `
-#define VAR_COUNT 10
+#define VAR_COUNT 18
debug_vars_t debug_vars[] = {
- { &(RES0__MAIN.MOTOR_SPEED), INT_ENUM },
- { &(RES0__MAIN.TEMPERATURE), REAL_ENUM },
- { &(RES0__MAIN.IS_RUNNING), BOOL_ENUM },
- { &(RES0__MAIN.SENSOR.value.TEMP), REAL_ENUM },
- { &(RES0__MAIN.SENSOR.value.PRESSURE), REAL_ENUM },
- { &(RES0__MAIN.TEMPS.value.table[0]), REAL_ENUM },
- { &(RES0__MAIN.TEMPS.value.table[1]), REAL_ENUM },
- { &(RES0__MAIN.TEMPS.value.table[2]), REAL_ENUM },
+ { &(RES0__INSTANCE0.MOTOR_SPEED), INT_ENUM },
+ { &(RES0__INSTANCE0.TEMPERATURE), REAL_ENUM },
+ { &(RES0__INSTANCE0.IS_RUNNING), BOOL_ENUM },
+ { &(RES0__INSTANCE0.SENSOR.value.TEMP), REAL_ENUM },
+ { &(RES0__INSTANCE0.SENSOR.value.PRESSURE), REAL_ENUM },
+ { &(RES0__INSTANCE0.TEMPS.value.table[0]), REAL_ENUM },
+ { &(RES0__INSTANCE0.TEMPS.value.table[1]), REAL_ENUM },
+ { &(RES0__INSTANCE0.TEMPS.value.table[2]), REAL_ENUM },
{ &(CONFIG0__GLOBAL_VAR), INT_ENUM },
{ &(CONFIG0__SYSTEM_STATE), BOOL_ENUM },
+ { &(RES0__INSTANCE0.TIMER0.ET), TIME_ENUM },
+ { &(RES0__INSTANCE0.TIMER0.Q), BOOL_ENUM },
+ { &(RES0__INSTANCE0.NESTED_STRUCT.value.INNER.value.VALUE1), INT_ENUM },
+ { &(RES0__INSTANCE0.NESTED_STRUCT.value.INNER.value.VALUE2), REAL_ENUM },
+ { &(RES0__INSTANCE0.FB_ARRAY.value.table[0].ET), TIME_ENUM },
+ { &(RES0__INSTANCE0.FB_ARRAY.value.table[0].Q), BOOL_ENUM },
+ { &(RES0__INSTANCE0.FB_ARRAY.value.table[1].ET), TIME_ENUM },
+ { &(RES0__INSTANCE0.FB_ARRAY.value.table[1].Q), BOOL_ENUM },
};
`
+// Sample instances array representing the Resources configuration
+const sampleInstances: PLCInstanceInfo[] = [
+ {
+ name: 'INSTANCE0',
+ task: 'Task0',
+ program: 'main',
+ },
+]
+
// Sample OPC-UA server configuration
const createSampleConfig = (): OpcUaServerConfig => ({
server: {
@@ -69,9 +88,9 @@ describe('parseDebugFile', () => {
it('should parse debug variables from debug.c content', () => {
const variables = parseDebugFile(sampleDebugContent)
- expect(variables).toHaveLength(10)
+ expect(variables).toHaveLength(18)
expect(variables[0]).toEqual({
- name: 'RES0__MAIN.MOTOR_SPEED',
+ name: 'RES0__INSTANCE0.MOTOR_SPEED',
type: 'INT_ENUM',
index: 0,
})
@@ -111,7 +130,7 @@ describe('resolveVariableIndex', () => {
nodeType: 'variable' as const,
}
- const index = resolveVariableIndex(node, debugVariables)
+ const index = resolveVariableIndex(node, debugVariables, sampleInstances)
expect(index).toBe(0)
})
@@ -130,7 +149,7 @@ describe('resolveVariableIndex', () => {
nodeType: 'variable' as const,
}
- const index = resolveVariableIndex(node, debugVariables)
+ const index = resolveVariableIndex(node, debugVariables, sampleInstances)
expect(index).toBe(8)
})
@@ -149,7 +168,25 @@ describe('resolveVariableIndex', () => {
nodeType: 'variable' as const,
}
- expect(() => resolveVariableIndex(node, debugVariables)).toThrow(OpcUaConfigError)
+ expect(() => resolveVariableIndex(node, debugVariables, sampleInstances)).toThrow(OpcUaConfigError)
+ })
+
+ it('should throw error when program has no instance in Resources', () => {
+ const node = {
+ id: 'test-4',
+ pouName: 'unknown_program',
+ variablePath: 'SOME_VAR',
+ variableType: 'INT',
+ nodeId: 'PLC.Unknown.SomeVar',
+ browseName: 'SomeVar',
+ displayName: 'Some Var',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'variable' as const,
+ }
+
+ expect(() => resolveVariableIndex(node, debugVariables, sampleInstances)).toThrow(OpcUaConfigError)
})
})
@@ -173,7 +210,7 @@ describe('resolveArrayIndex', () => {
elementType: 'REAL',
}
- const index = resolveArrayIndex(node, debugVariables)
+ const index = resolveArrayIndex(node, debugVariables, sampleInstances)
expect(index).toBe(5) // First element [0] is at index 5
})
})
@@ -210,16 +247,180 @@ describe('resolveStructureIndices', () => {
],
}
- const resolvedFields = resolveStructureIndices(node, debugVariables)
+ const resolvedFields = resolveStructureIndices(node, debugVariables, sampleInstances)
expect(resolvedFields).toHaveLength(2)
expect(resolvedFields[0].index).toBe(3) // SENSOR.value.TEMP
+ expect(resolvedFields[0].datatype).toBe('REAL') // Converted from REAL_ENUM
expect(resolvedFields[1].index).toBe(4) // SENSOR.value.PRESSURE
+ expect(resolvedFields[1].datatype).toBe('REAL') // Converted from REAL_ENUM
+ })
+
+ it('should convert debug.c type enums to IEC types', () => {
+ // This test verifies that types like "INT_ENUM", "BOOL_ENUM" are converted to "INT", "BOOL"
+ const node = {
+ id: 'test-fb-types',
+ pouName: 'main',
+ variablePath: 'TIMER0',
+ variableType: 'TON',
+ nodeId: 'PLC.Main.Timer0',
+ browseName: 'Timer0',
+ displayName: 'Timer 0',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'structure' as const,
+ fields: [
+ {
+ fieldPath: 'ET',
+ displayName: 'Elapsed Time',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: 'Q',
+ displayName: 'Output',
+ initialValue: false,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ ],
+ }
+
+ const resolvedFields = resolveStructureIndices(node, debugVariables, sampleInstances)
+ // TIME_ENUM should be converted to TIME
+ expect(resolvedFields[0].datatype).toBe('TIME')
+ // BOOL_ENUM should be converted to BOOL
+ expect(resolvedFields[1].datatype).toBe('BOOL')
+ })
+
+ it('should resolve function block with expanded leaf fields', () => {
+ // FB instance with its leaf variables expanded (like TON with ET and Q)
+ const node = {
+ id: 'test-fb-1',
+ pouName: 'main',
+ variablePath: 'TIMER0',
+ variableType: 'TON',
+ nodeId: 'PLC.Main.Timer0',
+ browseName: 'Timer0',
+ displayName: 'Timer 0',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'structure' as const, // FBs use 'structure' nodeType
+ fields: [
+ {
+ fieldPath: 'ET',
+ displayName: 'Elapsed Time',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: 'Q',
+ displayName: 'Output',
+ initialValue: false,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ ],
+ }
+
+ const resolvedFields = resolveStructureIndices(node, debugVariables, sampleInstances)
+ expect(resolvedFields).toHaveLength(2)
+ expect(resolvedFields[0].index).toBe(10) // TIMER0.ET (FB-style path, no .value.)
+ expect(resolvedFields[1].index).toBe(11) // TIMER0.Q
+ })
+
+ it('should resolve nested structure with deep field paths', () => {
+ // Nested structure: NESTED_STRUCT.INNER.VALUE1, NESTED_STRUCT.INNER.VALUE2
+ const node = {
+ id: 'test-nested-1',
+ pouName: 'main',
+ variablePath: 'NESTED_STRUCT',
+ variableType: 'OuterStruct',
+ nodeId: 'PLC.Main.NestedStruct',
+ browseName: 'NestedStruct',
+ displayName: 'Nested Structure',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'structure' as const,
+ fields: [
+ {
+ fieldPath: 'INNER.VALUE1',
+ displayName: 'Inner Value 1',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: 'INNER.VALUE2',
+ displayName: 'Inner Value 2',
+ initialValue: 0.0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ ],
+ }
+
+ const resolvedFields = resolveStructureIndices(node, debugVariables, sampleInstances)
+ expect(resolvedFields).toHaveLength(2)
+ expect(resolvedFields[0].index).toBe(12) // NESTED_STRUCT.value.INNER.value.VALUE1
+ expect(resolvedFields[1].index).toBe(13) // NESTED_STRUCT.value.INNER.value.VALUE2
+ })
+
+ it('should resolve array of FBs with expanded leaf fields', () => {
+ // Array of FBs: FB_ARRAY[0].ET, FB_ARRAY[0].Q, FB_ARRAY[1].ET, FB_ARRAY[1].Q
+ const node = {
+ id: 'test-fb-array-1',
+ pouName: 'main',
+ variablePath: 'FB_ARRAY',
+ variableType: 'ARRAY[0..1] OF TON',
+ nodeId: 'PLC.Main.FbArray',
+ browseName: 'FbArray',
+ displayName: 'FB Array',
+ description: '',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ nodeType: 'array' as const,
+ arrayLength: 2,
+ elementType: 'TON',
+ fields: [
+ {
+ fieldPath: '[0].ET',
+ displayName: '[0] Elapsed Time',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: '[0].Q',
+ displayName: '[0] Output',
+ initialValue: false,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: '[1].ET',
+ displayName: '[1] Elapsed Time',
+ initialValue: 0,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ {
+ fieldPath: '[1].Q',
+ displayName: '[1] Output',
+ initialValue: false,
+ permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
+ },
+ ],
+ }
+
+ // Arrays with fields are treated like structures for resolution
+ const resolvedFields = resolveStructureIndices(node, debugVariables, sampleInstances)
+ expect(resolvedFields).toHaveLength(4)
+ expect(resolvedFields[0].index).toBe(14) // FB_ARRAY[0].ET
+ expect(resolvedFields[1].index).toBe(15) // FB_ARRAY[0].Q
+ expect(resolvedFields[2].index).toBe(16) // FB_ARRAY[1].ET
+ expect(resolvedFields[3].index).toBe(17) // FB_ARRAY[1].Q
})
})
describe('generateOpcUaConfig', () => {
it('should return null when no servers configured', () => {
- const result = generateOpcUaConfig(undefined, sampleDebugContent)
+ const result = generateOpcUaConfig(undefined, sampleDebugContent, sampleInstances)
expect(result).toBeNull()
})
@@ -230,7 +431,7 @@ describe('generateOpcUaConfig', () => {
protocol: 'modbus-tcp',
},
]
- const result = generateOpcUaConfig(servers, sampleDebugContent)
+ const result = generateOpcUaConfig(servers, sampleDebugContent, sampleInstances)
expect(result).toBeNull()
})
@@ -246,7 +447,7 @@ describe('generateOpcUaConfig', () => {
},
]
- const result = generateOpcUaConfig(servers, sampleDebugContent)
+ const result = generateOpcUaConfig(servers, sampleDebugContent, sampleInstances)
expect(result).toBeNull()
})
@@ -276,7 +477,7 @@ describe('generateOpcUaConfig', () => {
},
]
- const result = generateOpcUaConfig(servers, sampleDebugContent)
+ const result = generateOpcUaConfig(servers, sampleDebugContent, sampleInstances)
expect(result).not.toBeNull()
const parsed = JSON.parse(result!)
@@ -309,7 +510,7 @@ describe('validateOpcUaConfig', () => {
},
]
- const result = validateOpcUaConfig(config, sampleDebugContent)
+ const result = validateOpcUaConfig(config, sampleDebugContent, sampleInstances)
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
@@ -318,7 +519,7 @@ describe('validateOpcUaConfig', () => {
const config = createSampleConfig()
config.securityProfiles[0].enabled = false
- const result = validateOpcUaConfig(config, sampleDebugContent)
+ const result = validateOpcUaConfig(config, sampleDebugContent, sampleInstances)
expect(result.valid).toBe(false)
expect(result.errors).toContain('At least one security profile must be enabled')
})
@@ -328,7 +529,7 @@ describe('validateOpcUaConfig', () => {
config.securityProfiles[0].authMethods = ['Username']
config.users = []
- const result = validateOpcUaConfig(config, sampleDebugContent)
+ const result = validateOpcUaConfig(config, sampleDebugContent, sampleInstances)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Username authentication is enabled but no users are configured')
})
diff --git a/src/utils/opcua/generate-opcua-config.ts b/src/utils/opcua/generate-opcua-config.ts
index 5c419cb0c..35a675d72 100644
--- a/src/utils/opcua/generate-opcua-config.ts
+++ b/src/utils/opcua/generate-opcua-config.ts
@@ -9,7 +9,7 @@ import type {
} from '@root/types/PLC/open-plc'
import { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from './resolve-indices'
-import type { DebugVariable } from './types'
+import type { DebugVariable, PLCInstanceInfo } from './types'
/**
* Runtime configuration interfaces
@@ -186,8 +186,12 @@ const convertPermissions = (permissions: OpcUaPermissions): RuntimeVariablePermi
/**
* Resolve a simple variable and build runtime format
*/
-const resolveVariable = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeVariable => {
- const index = resolveVariableIndex(node, debugVariables)
+const resolveVariable = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): RuntimeVariable => {
+ const index = resolveVariableIndex(node, debugVariables, instances)
return {
node_id: node.nodeId,
@@ -204,8 +208,12 @@ const resolveVariable = (node: OpcUaNodeConfig, debugVariables: DebugVariable[])
/**
* Resolve a structure and build runtime format with field indices
*/
-const resolveStructure = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeStructure => {
- const resolvedFields = resolveStructureIndices(node, debugVariables)
+const resolveStructure = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): RuntimeStructure => {
+ const resolvedFields = resolveStructureIndices(node, debugVariables, instances)
return {
node_id: node.nodeId,
@@ -225,8 +233,12 @@ const resolveStructure = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]
/**
* Resolve an array and build runtime format
*/
-const resolveArray = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): RuntimeArray => {
- const index = resolveArrayIndex(node, debugVariables)
+const resolveArray = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): RuntimeArray => {
+ const index = resolveArrayIndex(node, debugVariables, instances)
return {
node_id: node.nodeId,
@@ -243,7 +255,11 @@ const resolveArray = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): R
/**
* Build the complete address space configuration
*/
-const buildAddressSpace = (config: OpcUaServerConfig, debugVariables: DebugVariable[]): RuntimeAddressSpace => {
+const buildAddressSpace = (
+ config: OpcUaServerConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): RuntimeAddressSpace => {
const variables: RuntimeVariable[] = []
const structures: RuntimeStructure[] = []
const arrays: RuntimeArray[] = []
@@ -253,13 +269,21 @@ const buildAddressSpace = (config: OpcUaServerConfig, debugVariables: DebugVaria
try {
switch (node.nodeType) {
case 'variable':
- variables.push(resolveVariable(node, debugVariables))
+ variables.push(resolveVariable(node, debugVariables, instances))
break
case 'structure':
- structures.push(resolveStructure(node, debugVariables))
+ // Structures and FBs are handled the same way - resolve all leaf fields
+ structures.push(resolveStructure(node, debugVariables, instances))
break
case 'array':
- arrays.push(resolveArray(node, debugVariables))
+ // Arrays with fields (complex element types) are treated like structures
+ // because each leaf variable needs individual index resolution
+ if (node.fields && node.fields.length > 0) {
+ structures.push(resolveStructure(node, debugVariables, instances))
+ } else {
+ // Simple arrays of base types
+ arrays.push(resolveArray(node, debugVariables, instances))
+ }
break
}
} catch (error) {
@@ -334,9 +358,14 @@ export const parseDebugFile = (content: string): DebugVariable[] => {
*
* @param servers - Array of configured PLC servers
* @param debugFileContent - Content of the generated debug.c file
+ * @param instances - Array of PLC instances from Resources configuration
* @returns JSON string for opcua.json or null if no enabled OPC-UA server
*/
-export const generateOpcUaConfig = (servers: PLCServer[] | undefined, debugFileContent: string): string | null => {
+export const generateOpcUaConfig = (
+ servers: PLCServer[] | undefined,
+ debugFileContent: string,
+ instances: PLCInstanceInfo[],
+): string | null => {
// 1. Find OPC-UA server configuration
if (!servers || servers.length === 0) {
return null
@@ -371,7 +400,7 @@ export const generateOpcUaConfig = (servers: PLCServer[] | undefined, debugFileC
security: buildSecurityConfig(config),
users: buildUsersConfig(config),
cycle_time_ms: config.cycleTimeMs,
- address_space: buildAddressSpace(config, debugVariables),
+ address_space: buildAddressSpace(config, debugVariables, instances),
},
}
@@ -386,6 +415,7 @@ export const generateOpcUaConfig = (servers: PLCServer[] | undefined, debugFileC
export const validateOpcUaConfig = (
config: OpcUaServerConfig,
debugFileContent: string,
+ instances: PLCInstanceInfo[],
): { valid: boolean; errors: string[] } => {
const errors: string[] = []
@@ -408,13 +438,18 @@ export const validateOpcUaConfig = (
try {
switch (node.nodeType) {
case 'variable':
- resolveVariableIndex(node, debugVariables)
+ resolveVariableIndex(node, debugVariables, instances)
break
case 'structure':
- resolveStructureIndices(node, debugVariables)
+ resolveStructureIndices(node, debugVariables, instances)
break
case 'array':
- resolveArrayIndex(node, debugVariables)
+ // Arrays with fields (complex element types) are validated like structures
+ if (node.fields && node.fields.length > 0) {
+ resolveStructureIndices(node, debugVariables, instances)
+ } else {
+ resolveArrayIndex(node, debugVariables, instances)
+ }
break
}
} catch (error) {
diff --git a/src/utils/opcua/index.ts b/src/utils/opcua/index.ts
index ff5148acf..e36b89b89 100644
--- a/src/utils/opcua/index.ts
+++ b/src/utils/opcua/index.ts
@@ -22,4 +22,4 @@ export type {
} from './generate-opcua-config'
export { generateOpcUaConfig, parseDebugFile, validateOpcUaConfig } from './generate-opcua-config'
export { OpcUaConfigError, resolveArrayIndex, resolveStructureIndices, resolveVariableIndex } from './resolve-indices'
-export type { DebugVariable, ResolvedField } from './types'
+export type { DebugVariable, PLCInstanceInfo, ResolvedField } from './types'
diff --git a/src/utils/opcua/resolve-indices.ts b/src/utils/opcua/resolve-indices.ts
index 80fe02dd6..ac7315cf8 100644
--- a/src/utils/opcua/resolve-indices.ts
+++ b/src/utils/opcua/resolve-indices.ts
@@ -1,6 +1,31 @@
import type { OpcUaNodeConfig } from '@root/types/PLC/open-plc'
+import {
+ buildDebugPath,
+ buildGlobalDebugPath,
+ type DebugVariableEntry,
+ findDebugVariable,
+ findInstanceName,
+ type PLCInstanceMapping,
+} from '@root/utils/debug-variable-finder'
-import type { DebugVariable, ResolvedField } from './types'
+import type { DebugVariable, PLCInstanceInfo, ResolvedField } from './types'
+
+/**
+ * Convert debug.c type enum to IEC type name.
+ * debug.c uses types like "INT_ENUM", "BOOL_ENUM", "REAL_ENUM"
+ * but the OPC-UA runtime expects "INT", "BOOL", "REAL".
+ */
+const debugTypeToIecType = (debugType: string): string => {
+ // Remove _ENUM suffix if present
+ if (debugType.endsWith('_ENUM')) {
+ return debugType.slice(0, -5)
+ }
+ // Remove _P_ENUM or _O_ENUM suffix for pointer/output types
+ if (debugType.endsWith('_P_ENUM') || debugType.endsWith('_O_ENUM')) {
+ return debugType.slice(0, -7)
+ }
+ return debugType
+}
/**
* Custom error class for OPC-UA configuration errors
@@ -17,106 +42,42 @@ export class OpcUaConfigError extends Error {
}
/**
- * Build the debug path for a program/FB instance variable.
- * Handles nested structures, arrays, and function blocks.
- *
- * @param pouName - Name of the POU (program or FB)
- * @param variablePath - Path to the variable (e.g., "MOTOR_SPEED" or "CTRL.OUTPUT")
- * @returns The expected debug.c path
+ * Convert PLCInstanceInfo to PLCInstanceMapping for the shared utility
*/
-const buildInstancePath = (pouName: string, variablePath: string): string => {
- // The instance name in debug.c is uppercase
- // Format: RES0__INSTANCE0.VARIABLE_PATH
- // Note: INSTANCE0 is typically the program name, but for simplicity we use the standard format
-
- const pathParts = variablePath.split('.')
- let debugPath = `RES0__${pouName.toUpperCase()}`
-
- for (const part of pathParts) {
- // Check if this part is an array index like "[0]"
- if (part.includes('[')) {
- // Handle array access: VAR[0] -> VAR.value.table[0]
- const bracketIndex = part.indexOf('[')
- const varName = part.substring(0, bracketIndex)
- const arrayIndex = part.substring(bracketIndex)
- debugPath += `.${varName.toUpperCase()}.value.table${arrayIndex}`
- } else {
- debugPath += `.${part.toUpperCase()}`
- }
- }
-
- return debugPath
-}
+const toInstanceMapping = (instances: PLCInstanceInfo[]): PLCInstanceMapping[] =>
+ instances.map((inst) => ({ name: inst.name, program: inst.program }))
/**
- * Build the debug path for a structure field.
- * Structure fields have ".value." inserted before each field access.
- *
- * @param pouName - Name of the POU
- * @param variablePath - Path including structure and field (e.g., "SENSOR.temperature")
- * @returns The expected debug.c path
+ * Convert DebugVariable to DebugVariableEntry for the shared utility
*/
-const buildStructFieldPath = (pouName: string, variablePath: string): string => {
- const pathParts = variablePath.split('.')
- let debugPath = `RES0__${pouName.toUpperCase()}`
-
- // First part is the variable name
- debugPath += `.${pathParts[0].toUpperCase()}`
-
- // Subsequent parts are fields, need .value. prefix
- for (let i = 1; i < pathParts.length; i++) {
- const part = pathParts[i]
-
- // Check if this part is an array index
- if (part.includes('[')) {
- const bracketIndex = part.indexOf('[')
- const fieldName = part.substring(0, bracketIndex)
- const arrayIndex = part.substring(bracketIndex)
-
- if (fieldName) {
- debugPath += `.value.${fieldName.toUpperCase()}.value.table${arrayIndex}`
- } else {
- // Just an array index like "[0]"
- debugPath += `.value.table${arrayIndex}`
- }
- } else {
- debugPath += `.value.${part.toUpperCase()}`
- }
- }
-
- return debugPath
-}
+const toDebugEntries = (debugVariables: DebugVariable[]): DebugVariableEntry[] =>
+ debugVariables.map((dv) => ({ name: dv.name, type: dv.type, index: dv.index }))
/**
- * Build the debug path for an array's first element.
- *
- * @param pouName - Name of the POU
- * @param variablePath - Path to the array variable
- * @returns The expected debug.c path for element [0]
+ * Try to find a debug variable using multiple path strategies.
+ * First tries FB-style paths (no .value.), then structure-style paths (with .value.).
+ * Returns the match and which style worked.
*/
-const buildArrayElementPath = (pouName: string, variablePath: string): string => {
- // Array elements are stored as VAR.value.table[i]
- return `RES0__${pouName.toUpperCase()}.${variablePath.toUpperCase()}.value.table[0]`
-}
+const findWithFallback = (
+ debugEntries: DebugVariableEntry[],
+ instanceName: string,
+ fullFieldPath: string,
+): { match: DebugVariableEntry | null; usedStructureStyle: boolean } => {
+ // First try FB-style path (no .value. insertion)
+ const fbPath = buildDebugPath(instanceName, fullFieldPath, { isStructureField: false })
+ const fbMatch = findDebugVariable(debugEntries, fbPath)
+ if (fbMatch) {
+ return { match: fbMatch, usedStructureStyle: false }
+ }
-/**
- * Build the debug path for a global variable.
- * Global variables use CONFIG0__ prefix.
- *
- * @param variablePath - Path to the global variable
- * @returns The expected debug.c path
- */
-const buildGlobalPath = (variablePath: string): string => {
- return `CONFIG0__${variablePath.toUpperCase()}`
-}
+ // Try structure-style path (with .value. insertion)
+ const structPath = buildDebugPath(instanceName, fullFieldPath, { isStructureField: true })
+ const structMatch = findDebugVariable(debugEntries, structPath)
+ if (structMatch) {
+ return { match: structMatch, usedStructureStyle: true }
+ }
-/**
- * Determine if a variable path looks like a structure field access.
- * Structure fields contain dots but aren't arrays or simple FB variables.
- */
-const isStructureFieldPath = (variablePath: string): boolean => {
- // If it contains a dot and doesn't look like an array element
- return variablePath.includes('.') && !variablePath.includes('[')
+ return { match: null, usedStructureStyle: false }
}
/**
@@ -124,66 +85,94 @@ const isStructureFieldPath = (variablePath: string): boolean => {
*
* @param node - The OPC-UA node configuration
* @param debugVariables - Parsed debug variables from debug.c
+ * @param instances - Array of PLC instances from Resources configuration
* @returns The resolved index
* @throws OpcUaConfigError if the variable cannot be resolved
*/
-export const resolveVariableIndex = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): number => {
- let debugPath: string
-
- // Determine the debug path based on variable location
+export const resolveVariableIndex = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): number => {
+ const debugEntries = toDebugEntries(debugVariables)
+ const instanceMappings = toInstanceMapping(instances)
+
+ // Handle global variables
if (node.pouName === 'GVL' || node.pouName === 'CONFIG' || node.pouName.toUpperCase() === 'GVL') {
- // Global variable
- debugPath = buildGlobalPath(node.variablePath)
- } else if (isStructureFieldPath(node.variablePath)) {
- // Structure field access
- debugPath = buildStructFieldPath(node.pouName, node.variablePath)
- } else {
- // Simple instance variable
- debugPath = buildInstancePath(node.pouName, node.variablePath)
- }
-
- // Find matching entry in debug.c (case-insensitive comparison)
- const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
-
- if (!match) {
- // Try alternative paths
- // Sometimes the path might be simpler without .value. insertions
- const simplePath = `RES0__${node.pouName.toUpperCase()}.${node.variablePath.toUpperCase()}`
- const simpleMatch = debugVariables.find((dv) => dv.name.toUpperCase() === simplePath.toUpperCase())
+ const debugPath = buildGlobalDebugPath(node.variablePath)
+ const match = findDebugVariable(debugEntries, debugPath)
- if (simpleMatch) {
- return simpleMatch.index
+ if (match) {
+ return match.index
}
throw new OpcUaConfigError(
`${node.pouName}:${node.variablePath}`,
debugPath,
- `Cannot resolve OPC-UA variable index.\n` +
+ `Cannot resolve OPC-UA global variable index.\n` +
` Variable: ${node.pouName}:${node.variablePath}\n` +
- ` Expected debug path: ${debugPath}\n` +
- ` This may happen if:\n` +
- ` - The PLC program was modified after configuring OPC-UA\n` +
- ` - The variable name is incorrect\n` +
- ` - The variable was removed from the program\n` +
- ` Please verify the variable exists in the program.`,
+ ` Expected debug path: ${debugPath}`,
)
}
- return match.index
+ // Look up the instance name for this program
+ const instanceName = findInstanceName(node.pouName, instanceMappings)
+
+ if (!instanceName) {
+ throw new OpcUaConfigError(
+ node.pouName,
+ 'unknown',
+ `Cannot find instance for program "${node.pouName}" in Resources.\n` +
+ ` Make sure the program is instantiated in the Resources configuration.`,
+ )
+ }
+
+ // Build the debug path - simple path for variables and FB instances (no .value.)
+ const debugPath = buildDebugPath(instanceName, node.variablePath, {
+ isStructureField: false,
+ isArrayElement: false,
+ })
+
+ const match = findDebugVariable(debugEntries, debugPath)
+
+ if (match) {
+ return match.index
+ }
+
+ throw new OpcUaConfigError(
+ `${node.pouName}:${node.variablePath}`,
+ debugPath,
+ `Cannot resolve OPC-UA variable index.\n` +
+ ` Variable: ${node.pouName}:${node.variablePath}\n` +
+ ` Expected debug path: ${debugPath}\n` +
+ ` This may happen if:\n` +
+ ` - The PLC program was modified after configuring OPC-UA\n` +
+ ` - The variable name is incorrect\n` +
+ ` - The variable was removed from the program\n` +
+ ` Please verify the variable exists in the program.`,
+ )
}
/**
- * Resolve indices for all fields in a structure.
+ * Resolve indices for all fields in a structure or function block instance.
*
- * @param node - The OPC-UA node configuration for the structure
+ * @param node - The OPC-UA node configuration for the structure/FB
* @param debugVariables - Parsed debug variables from debug.c
+ * @param instances - Array of PLC instances from Resources configuration
* @returns Array of resolved fields with indices
* @throws OpcUaConfigError if any field cannot be resolved
*/
-export const resolveStructureIndices = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): ResolvedField[] => {
+export const resolveStructureIndices = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): ResolvedField[] => {
+ const debugEntries = toDebugEntries(debugVariables)
+ const instanceMappings = toInstanceMapping(instances)
+
if (!node.fields || node.fields.length === 0) {
// If no field configs, try to resolve the structure variable itself
- const index = resolveVariableIndex(node, debugVariables)
+ const index = resolveVariableIndex(node, debugVariables, instances)
return [
{
name: node.variablePath,
@@ -195,54 +184,64 @@ export const resolveStructureIndices = (node: OpcUaNodeConfig, debugVariables: D
]
}
+ // Look up the instance name for this program
+ let instanceName: string | null = null
+ if (node.pouName !== 'GVL' && node.pouName !== 'CONFIG') {
+ instanceName = findInstanceName(node.pouName, instanceMappings)
+ if (!instanceName) {
+ throw new OpcUaConfigError(
+ node.pouName,
+ 'unknown',
+ `Cannot find instance for program "${node.pouName}" in Resources.`,
+ )
+ }
+ }
+
const resolvedFields: ResolvedField[] = []
for (const field of node.fields) {
// Build the full path for this field
- const fullFieldPath = `${node.variablePath}.${field.fieldPath}`
+ // Handle case where field.fieldPath already contains the parent variablePath (legacy configs)
+ let fullFieldPath: string
+ if (field.fieldPath.toUpperCase().startsWith(node.variablePath.toUpperCase() + '.')) {
+ // Field path already includes parent, use it directly
+ fullFieldPath = field.fieldPath
+ } else {
+ fullFieldPath = `${node.variablePath}.${field.fieldPath}`
+ }
- let debugPath: string
+ let match: DebugVariableEntry | null = null
+ let debugPath: string = ''
if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
- // Global structure field
- debugPath = buildGlobalPath(fullFieldPath)
+ // Global structure/FB field
+ debugPath = buildGlobalDebugPath(fullFieldPath)
+ match = findDebugVariable(debugEntries, debugPath)
} else {
- // Instance structure field
- debugPath = buildStructFieldPath(node.pouName, fullFieldPath)
+ // Try both FB-style (no .value.) and structure-style (with .value.) paths
+ const result = findWithFallback(debugEntries, instanceName!, fullFieldPath)
+ match = result.match
+ debugPath = result.usedStructureStyle
+ ? buildDebugPath(instanceName!, fullFieldPath, { isStructureField: true })
+ : buildDebugPath(instanceName!, fullFieldPath, { isStructureField: false })
}
- // Find matching entry
- const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
-
if (!match) {
- // Try simpler path
- const simplePath = `RES0__${node.pouName.toUpperCase()}.${fullFieldPath.toUpperCase()}`
- const simpleMatch = debugVariables.find((dv) => dv.name.toUpperCase() === simplePath.toUpperCase())
-
- if (simpleMatch) {
- resolvedFields.push({
- name: field.fieldPath,
- datatype: node.variableType, // Will be refined by caller
- initialValue: field.initialValue,
- index: simpleMatch.index,
- permissions: field.permissions,
- })
- continue
- }
-
throw new OpcUaConfigError(
`${node.pouName}:${fullFieldPath}`,
debugPath,
- `Cannot resolve OPC-UA structure field index.\n` +
- ` Structure: ${node.pouName}:${node.variablePath}\n` +
+ `Cannot resolve OPC-UA structure/FB field index.\n` +
+ ` Variable: ${node.pouName}:${node.variablePath}\n` +
` Field: ${field.fieldPath}\n` +
- ` Expected debug path: ${debugPath}`,
+ ` Tried paths:\n` +
+ ` - FB style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: false })}\n` +
+ ` - Struct style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: true })}`,
)
}
resolvedFields.push({
name: field.fieldPath,
- datatype: match.type || node.variableType,
+ datatype: match.type ? debugTypeToIecType(match.type) : node.variableType,
initialValue: field.initialValue,
index: match.index,
permissions: field.permissions,
@@ -258,45 +257,54 @@ export const resolveStructureIndices = (node: OpcUaNodeConfig, debugVariables: D
*
* @param node - The OPC-UA node configuration for the array
* @param debugVariables - Parsed debug variables from debug.c
+ * @param instances - Array of PLC instances from Resources configuration
* @returns The index of the first array element
* @throws OpcUaConfigError if the array cannot be resolved
*/
-export const resolveArrayIndex = (node: OpcUaNodeConfig, debugVariables: DebugVariable[]): number => {
+export const resolveArrayIndex = (
+ node: OpcUaNodeConfig,
+ debugVariables: DebugVariable[],
+ instances: PLCInstanceInfo[],
+): number => {
+ const debugEntries = toDebugEntries(debugVariables)
+ const instanceMappings = toInstanceMapping(instances)
+
let debugPath: string
if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
- // Global array - first element
- debugPath = `CONFIG0__${node.variablePath.toUpperCase()}.value.table[0]`
+ // Global array - first element: CONFIG0__VAR.value.table[0]
+ debugPath = `${buildGlobalDebugPath(node.variablePath)}.value.table[0]`
} else {
- // Instance array - first element
- debugPath = buildArrayElementPath(node.pouName, node.variablePath)
- }
+ // Look up the instance name for this program
+ const instanceName = findInstanceName(node.pouName, instanceMappings)
- // Find matching entry
- const match = debugVariables.find((dv) => dv.name.toUpperCase() === debugPath.toUpperCase())
+ if (!instanceName) {
+ throw new OpcUaConfigError(
+ node.pouName,
+ 'unknown',
+ `Cannot find instance for program "${node.pouName}" in Resources.`,
+ )
+ }
- if (!match) {
- // Try alternative: maybe the array path is simpler
- const simplePath = `RES0__${node.pouName.toUpperCase()}.${node.variablePath.toUpperCase()}[0]`
- const simpleMatch = debugVariables.find(
- (dv) =>
- dv.name.toUpperCase() === simplePath.toUpperCase() ||
- dv.name.toUpperCase().endsWith(`${node.variablePath.toUpperCase()}.value.table[0]`),
- )
+ // Instance array - first element
+ debugPath = buildDebugPath(instanceName, node.variablePath, {
+ isArrayElement: true,
+ arrayIndex: 0,
+ })
+ }
- if (simpleMatch) {
- return simpleMatch.index
- }
+ const match = findDebugVariable(debugEntries, debugPath)
- throw new OpcUaConfigError(
- `${node.pouName}:${node.variablePath}`,
- debugPath,
- `Cannot resolve OPC-UA array index.\n` +
- ` Array: ${node.pouName}:${node.variablePath}\n` +
- ` Expected debug path: ${debugPath}\n` +
- ` Looking for first element [0] of the array.`,
- )
+ if (match) {
+ return match.index
}
- return match.index
+ throw new OpcUaConfigError(
+ `${node.pouName}:${node.variablePath}`,
+ debugPath,
+ `Cannot resolve OPC-UA array index.\n` +
+ ` Array: ${node.pouName}:${node.variablePath}\n` +
+ ` Expected debug path: ${debugPath}\n` +
+ ` Looking for first element [0] of the array.`,
+ )
}
diff --git a/src/utils/opcua/types.ts b/src/utils/opcua/types.ts
index 3902bc66f..dea62c908 100644
--- a/src/utils/opcua/types.ts
+++ b/src/utils/opcua/types.ts
@@ -3,6 +3,19 @@
* Types used for OPC-UA config generation and index resolution.
*/
+/**
+ * Represents a PLC instance (program instantiation in Resources).
+ * Used to look up the instance name for a given program POU.
+ */
+export interface PLCInstanceInfo {
+ /** Instance name (e.g., "INSTANCE0") - this appears in debug.c */
+ name: string
+ /** Task name this instance runs under */
+ task: string
+ /** Program POU name being instantiated */
+ program: string
+}
+
/**
* Represents a debug variable parsed from debug.c
*/
diff --git a/src/utils/pou-helpers.ts b/src/utils/pou-helpers.ts
new file mode 100644
index 000000000..780124842
--- /dev/null
+++ b/src/utils/pou-helpers.ts
@@ -0,0 +1,209 @@
+/**
+ * Shared utilities for POU (Program Organization Unit) lookup and variable iteration.
+ * Used by both the debugger and OPC-UA config generator.
+ */
+
+import { StandardFunctionBlocks } from '@root/renderer/data/library'
+import type { PLCDataType, PLCPou } from '@root/types/PLC/open-plc'
+
+/**
+ * Variable definition from a POU or library FB.
+ */
+export interface PouVariable {
+ name: string
+ class?: string
+ type: {
+ definition: string
+ value: string
+ data?: {
+ baseType: { definition: string; value: string }
+ dimensions: Array<{ dimension: string }>
+ }
+ }
+}
+
+/**
+ * Normalizes type strings for case-insensitive comparison.
+ */
+export const normalizeTypeString = (typeStr: string): string => {
+ return typeStr.toLowerCase().replace(/[-_]/g, '')
+}
+
+/**
+ * Base IEC types that are directly accessible in debug.c
+ */
+const BASE_TYPES = [
+ 'bool',
+ 'sint',
+ 'int',
+ 'dint',
+ 'lint',
+ 'usint',
+ 'uint',
+ 'udint',
+ 'ulint',
+ 'real',
+ 'lreal',
+ 'time',
+ 'date',
+ 'tod',
+ 'dt',
+ 'string',
+ 'byte',
+ 'word',
+ 'dword',
+ 'lword',
+]
+
+/**
+ * Check if a type is a base IEC type.
+ */
+export const isBaseType = (typeName: string): boolean => {
+ return BASE_TYPES.includes(typeName.toLowerCase())
+}
+
+/**
+ * Find a function block definition by name.
+ * Searches BOTH the built-in library AND project POUs.
+ * Returns the variables array from the FB definition, or null if not found.
+ */
+export const findFunctionBlockVariables = (typeName: string, projectPous: PLCPou[]): PouVariable[] | null => {
+ const typeNameUpper = typeName.toUpperCase()
+
+ // Check standard library FBs
+ const standardFB = StandardFunctionBlocks.pous.find(
+ (pou) => pou.name.toUpperCase() === typeNameUpper && normalizeTypeString(pou.type) === 'functionblock',
+ )
+ if (standardFB) {
+ return standardFB.variables as PouVariable[]
+ }
+
+ // Check project POUs (user-defined FBs)
+ const customFB = projectPous.find(
+ (pou) => normalizeTypeString(pou.type) === 'functionblock' && pou.data.name.toUpperCase() === typeNameUpper,
+ )
+ if (customFB && customFB.type === 'function-block') {
+ return customFB.data.variables as PouVariable[]
+ }
+
+ return null
+}
+
+/**
+ * Check if a type name is a function block (library or project).
+ */
+export const isFunctionBlockType = (typeName: string, projectPous: PLCPou[]): boolean => {
+ return findFunctionBlockVariables(typeName, projectPous) !== null
+}
+
+/**
+ * Find a structure definition by name in the project's data types.
+ * Returns the variables array from the structure, or null if not found.
+ */
+export const findStructureVariables = (typeName: string, dataTypes: PLCDataType[]): PouVariable[] | null => {
+ const dataType = dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase())
+ if (dataType?.derivation === 'structure') {
+ return dataType.variable as PouVariable[]
+ }
+ return null
+}
+
+/**
+ * Check if a type name is a structure.
+ */
+export const isStructureType = (typeName: string, dataTypes: PLCDataType[]): boolean => {
+ return findStructureVariables(typeName, dataTypes) !== null
+}
+
+/**
+ * Check if a type name is an enumeration.
+ */
+export const isEnumerationType = (typeName: string, dataTypes: PLCDataType[]): boolean => {
+ const dataType = dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase())
+ return dataType?.derivation === 'enumerated'
+}
+
+/**
+ * Represents a leaf variable (base type) found during recursive traversal.
+ */
+export interface LeafVariable {
+ /** Relative path from the parent (e.g., "TON0.Q" or "MY_STRUCT.field1") */
+ relativePath: string
+ /** The base type name (e.g., "BOOL", "INT", "TIME") */
+ typeName: string
+}
+
+/**
+ * Recursively find all base-type leaf variables within a complex type (FB or structure).
+ * This is the core shared logic for both debugger and OPC-UA.
+ *
+ * @param typeName - The type name to expand (e.g., "TON", "MY_CUSTOM_FB", "MY_STRUCT")
+ * @param projectPous - Project POUs for looking up custom FBs
+ * @param dataTypes - Project data types for looking up structures
+ * @param pathPrefix - Current path prefix for building relative paths
+ * @returns Array of leaf variables with their relative paths
+ */
+export const findLeafVariables = (
+ typeName: string,
+ projectPous: PLCPou[],
+ dataTypes: PLCDataType[],
+ pathPrefix: string = '',
+): LeafVariable[] => {
+ const leaves: LeafVariable[] = []
+
+ // Try to find as FB first
+ const fbVariables = findFunctionBlockVariables(typeName, projectPous)
+ if (fbVariables) {
+ for (const fbVar of fbVariables) {
+ const varPath = pathPrefix ? `${pathPrefix}.${fbVar.name}` : fbVar.name
+ const varTypeName = fbVar.type.value
+
+ if (fbVar.type.definition === 'base-type' && isBaseType(varTypeName)) {
+ leaves.push({ relativePath: varPath, typeName: varTypeName.toUpperCase() })
+ } else if (fbVar.type.definition === 'array' && fbVar.type.data) {
+ // For arrays, we need the first element's path - handled separately
+ // Skip for now as arrays need special handling
+ } else if (!isEnumerationType(varTypeName, dataTypes)) {
+ // Recurse into nested FBs or structures
+ const nestedLeaves = findLeafVariables(varTypeName, projectPous, dataTypes, varPath)
+ leaves.push(...nestedLeaves)
+ }
+ }
+ return leaves
+ }
+
+ // Try to find as structure
+ const structVariables = findStructureVariables(typeName, dataTypes)
+ if (structVariables) {
+ for (const field of structVariables) {
+ const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
+ const fieldTypeName = field.type.value
+
+ if (field.type.definition === 'base-type' && isBaseType(fieldTypeName)) {
+ leaves.push({ relativePath: fieldPath, typeName: fieldTypeName.toUpperCase() })
+ } else if (!isEnumerationType(fieldTypeName, dataTypes)) {
+ // Recurse into nested types
+ const nestedLeaves = findLeafVariables(fieldTypeName, projectPous, dataTypes, fieldPath)
+ leaves.push(...nestedLeaves)
+ }
+ }
+ return leaves
+ }
+
+ // If it's a base type itself, return it as a leaf
+ if (isBaseType(typeName)) {
+ leaves.push({ relativePath: pathPrefix, typeName: typeName.toUpperCase() })
+ }
+
+ return leaves
+}
+
+/**
+ * Get all variables from a POU (program or function block).
+ */
+export const getPouVariables = (pou: PLCPou): PouVariable[] => {
+ if (pou.type === 'program' || pou.type === 'function-block') {
+ return pou.data.variables as PouVariable[]
+ }
+ return []
+}
From c9d270a8b58d8b89a43770cc785d70df7615d970 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Fri, 16 Jan 2026 21:45:58 -0500
Subject: [PATCH 08/21] docs: Add debugger/OPC-UA shared utilities refactoring
plan
Comprehensive documentation explaining:
- Problem statement and current code duplication
- Analysis of debug path formats in debug.c
- Current architecture of debugger and OPC-UA systems
- Detailed 6-phase refactoring plan:
1. Consolidate debug file parsing
2. Unify path building
3. Unify variable index lookup
4. Unify tree building with visitor pattern
5. Update debugger polling
6. Remove deprecated code after verification
- Migration strategy with backwards compatibility
- Testing and rollback plans
Co-Authored-By: Claude Opus 4.5
---
docs/debugger-opcua-shared-utilities.md | 544 ++++++++++++++++++++++++
1 file changed, 544 insertions(+)
create mode 100644 docs/debugger-opcua-shared-utilities.md
diff --git a/docs/debugger-opcua-shared-utilities.md b/docs/debugger-opcua-shared-utilities.md
new file mode 100644
index 000000000..285b589b6
--- /dev/null
+++ b/docs/debugger-opcua-shared-utilities.md
@@ -0,0 +1,544 @@
+# Debugger and OPC-UA Shared Utilities Refactoring
+
+## Overview
+
+This document outlines the plan to refactor the OpenPLC Editor codebase to eliminate code duplication between the **debugger** and **OPC-UA** subsystems. Both systems need to resolve debug variable indices from the `debug.c` file generated during compilation, but currently they use separate implementations with duplicated logic.
+
+## Problem Statement
+
+### Current State
+
+The debugger and OPC-UA configuration generator both need to:
+
+1. Parse `debug.c` to extract variable entries with their indices
+2. Build debug paths in specific formats (`RES0__INSTANCE.VAR`, `CONFIG0__GLOBAL`, etc.)
+3. Match PLC variables to debug entries to resolve indices
+4. Handle complex types (function blocks, structures, arrays)
+
+However, these functionalities are implemented **twice**:
+
+| Functionality | Debugger Implementation | OPC-UA Implementation |
+|--------------|------------------------|----------------------|
+| Parse debug.c | `renderer/utils/parse-debug-file.ts` | `utils/debug-variable-finder.ts` |
+| Build paths | `renderer/utils/debug-tree-builder.ts` | `utils/debug-variable-finder.ts` |
+| Find indices | `parse-debug-file.ts:matchVariableWithDebugEntry()` | `debug-variable-finder.ts:findVariableIndex()` |
+| FB/struct lookup | Manual in `debug-tree-builder.ts` | `utils/pou-helpers.ts` |
+| Tree traversal | `debug-tree-builder.ts` | `opcua/resolve-indices.ts` |
+
+### Why This Is a Problem
+
+1. **Bug Duplication**: A fix in one system may not be applied to the other
+2. **Inconsistent Behavior**: Subtle differences in path building can cause one system to work while the other fails
+3. **Maintenance Burden**: Changes to debug.c format require updates in multiple places
+4. **Testing Overhead**: Both implementations need separate test coverage
+
+### Recent Example
+
+During OPC-UA development, shared utilities were created (`debug-variable-finder.ts`, `pou-helpers.ts`) but the debugger was not updated to use them. This led to confusion when debugging issues because the systems used different code paths.
+
+## Architecture Analysis
+
+### Debug Path Formats in debug.c
+
+The IEC 61131-3 compiler generates `debug.c` with variable paths in these formats:
+
+```c
+// Global variables
+{ &(CONFIG0__GLOBAL_VAR), INT_ENUM }
+
+// Program variables (simple)
+{ &(RES0__INSTANCE0.MOTOR_SPEED), INT_ENUM }
+
+// Function block instance variables
+{ &(RES0__INSTANCE0.TON0.Q), BOOL_ENUM }
+{ &(RES0__INSTANCE0.TON0.ET), TIME_ENUM }
+
+// Nested FB variables
+{ &(RES0__INSTANCE0.CONTROLLER0.INNER_FB0.VALUE), REAL_ENUM }
+
+// Structure fields (note the .value. segment)
+{ &(RES0__INSTANCE0.MY_STRUCT.value.FIELD1), INT_ENUM }
+
+// Array elements (note .value.table[i])
+{ &(RES0__INSTANCE0.MY_ARRAY.value.table[0]), INT_ENUM }
+{ &(RES0__INSTANCE0.MY_ARRAY.value.table[1]), INT_ENUM }
+```
+
+Key observations:
+- Instance names come from Resources configuration, NOT POU names
+- FB variables use direct dot notation (no `.value.`)
+- Structure fields require `.value.` before field name
+- Arrays require `.value.table[i]` syntax
+
+### Current Debugger Files
+
+```
+src/renderer/
+├── utils/
+│ ├── parse-debug-file.ts # Parses debug.c, matchVariableWithDebugEntry()
+│ └── debug-tree-builder.ts # Builds UI tree, has own path building
+├── components/_organisms/
+│ └── workspace-activity-bar/
+│ └── default.tsx # Initializes debugger, calls parsing
+└── screens/
+ └── workspace-screen.tsx # Polling loop, builds paths inline
+```
+
+### Current OPC-UA Files
+
+```
+src/utils/
+├── debug-variable-finder.ts # Path building, variable lookup (SHARED)
+├── pou-helpers.ts # FB/struct lookup (SHARED)
+└── opcua/
+ ├── resolve-indices.ts # Uses shared utilities
+ ├── generate-opcua-config.ts # Generates JSON config
+ └── types.ts # Type definitions
+```
+
+### Duplication Details
+
+#### 1. Debug File Parsing
+
+**Debugger** (`parse-debug-file.ts`):
+```typescript
+export function parseDebugFile(content: string): ParsedDebugData {
+ const variables: DebugVariable[] = []
+ const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
+ // ... parsing logic
+ return { variables, totalCount }
+}
+```
+
+**Shared** (`debug-variable-finder.ts`):
+```typescript
+export function parseDebugVariables(content: string): DebugVariableEntry[] {
+ const variables: DebugVariableEntry[] = []
+ const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
+ // ... nearly identical parsing logic
+ return variables
+}
+```
+
+#### 2. Path Building
+
+**Debugger** (`debug-tree-builder.ts`):
+```typescript
+function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string {
+ if (variableClass === 'external') {
+ return `CONFIG0__${variableNameUpper}`
+ }
+ return `RES0__${instanceNameUpper}.${variableNameUpper}`
+}
+```
+
+**Shared** (`debug-variable-finder.ts`):
+```typescript
+export function buildDebugPath(instanceName: string, variablePath: string, options = {}): string {
+ // More comprehensive implementation handling all path formats
+}
+
+export function buildGlobalDebugPath(variablePath: string): string {
+ return `CONFIG0__${variablePath.toUpperCase()}`
+}
+```
+
+#### 3. Index Lookup
+
+**Debugger** (`parse-debug-file.ts`):
+```typescript
+export function matchVariableWithDebugEntry(
+ pouVariableName: string,
+ instanceName: string,
+ debugVariables: DebugVariable[],
+ variableClass?: string,
+): number | null {
+ const expectedPath = `RES0__${instanceNameUpper}.${variableNameUpper}`
+ const match = debugVariables.find((dv) => dv.name === expectedPath)
+ return match ? match.index : null
+}
+```
+
+**Shared** (`debug-variable-finder.ts`):
+```typescript
+export function findVariableIndex(
+ instanceName: string,
+ variablePath: string,
+ debugVariables: DebugVariableEntry[],
+ options = {},
+): number | null {
+ const debugPath = buildDebugPath(instanceName, variablePath, options)
+ const match = findDebugVariable(debugVariables, debugPath)
+ return match ? match.index : null
+}
+```
+
+## Refactoring Plan
+
+The refactoring will be done in 6 phases, each building on the previous. This incremental approach minimizes risk and allows testing at each stage.
+
+### Phase 1: Consolidate Debug File Parsing
+
+**Goal**: Single source of truth for parsing `debug.c`
+
+**Rationale**: The parsing logic is nearly identical in both implementations. Having a single parser ensures consistent behavior and makes it easier to handle any future changes to the debug.c format.
+
+**Changes**:
+
+1. Create canonical parser in `src/utils/debug-parser.ts`:
+ ```typescript
+ // src/utils/debug-parser.ts
+ export interface DebugVariableEntry {
+ name: string // Full path (e.g., "RES0__INSTANCE0.VAR")
+ type: string // Type enum (e.g., "INT_ENUM")
+ index: number // Array index in debug_vars[]
+ }
+
+ export function parseDebugVariables(content: string): DebugVariableEntry[]
+ ```
+
+2. Update `renderer/utils/parse-debug-file.ts` to delegate:
+ ```typescript
+ // Re-export from shared module
+ export { parseDebugVariables as parseDebugFile } from '@root/utils/debug-parser'
+
+ // Keep DebugVariable interface for backwards compatibility
+ export type DebugVariable = DebugVariableEntry
+ ```
+
+3. Update `utils/debug-variable-finder.ts` to import from `debug-parser.ts`
+
+**Files Modified**:
+- Create: `src/utils/debug-parser.ts`
+- Modify: `src/renderer/utils/parse-debug-file.ts`
+- Modify: `src/utils/debug-variable-finder.ts`
+
+**Testing**:
+- Existing debugger tests should pass unchanged
+- Existing OPC-UA tests should pass unchanged
+- Add shared parser unit tests
+
+---
+
+### Phase 2: Unify Path Building
+
+**Goal**: Single implementation for all debug path formats
+
+**Rationale**: Path building is where subtle bugs can occur. The shared `buildDebugPath()` already handles more cases than the debugger's `buildVariableBasePath()`. Unifying ensures both systems handle all path formats correctly.
+
+**Changes**:
+
+1. Ensure `buildDebugPath()` handles all cases:
+ - Simple variables: `RES0__INSTANCE.VAR`
+ - FB instance variables: `RES0__INSTANCE.FB.VAR`
+ - Nested FB variables: `RES0__INSTANCE.FB1.FB2.VAR`
+ - Structure fields: `RES0__INSTANCE.STRUCT.value.FIELD`
+ - Array elements: `RES0__INSTANCE.ARR.value.table[i]`
+ - Global variables: `CONFIG0__VAR`
+
+2. Update `debug-tree-builder.ts` to use shared path builder:
+ ```typescript
+ // Before
+ const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class)
+
+ // After
+ import { buildDebugPath, buildGlobalDebugPath } from '@root/utils/debug-variable-finder'
+ const fullPath = variable.class === 'external'
+ ? buildGlobalDebugPath(variable.name)
+ : buildDebugPath(instanceName, variable.name)
+ ```
+
+3. Keep `buildVariableBasePath()` as deprecated wrapper during transition
+
+**Files Modified**:
+- Modify: `src/utils/debug-variable-finder.ts` (ensure all path formats)
+- Modify: `src/renderer/utils/debug-tree-builder.ts`
+
+**Testing**:
+- Test all path format variations
+- Verify debugger tree building still works
+- Test with nested FBs, arrays of structs, etc.
+
+---
+
+### Phase 3: Unify Variable Index Lookup
+
+**Goal**: Single function for finding variable indices
+
+**Rationale**: Index lookup depends on correct path building. By this phase, paths are unified, so lookup can also be unified.
+
+**Changes**:
+
+1. Update `workspace-activity-bar/default.tsx` to use shared utilities:
+ ```typescript
+ // Before
+ import { parseDebugFile, matchVariableWithDebugEntry } from '../utils/parse-debug-file'
+ const index = matchVariableWithDebugEntry(v.name, instance.name, parsed.variables, v.class)
+
+ // After
+ import { parseDebugVariables } from '@root/utils/debug-parser'
+ import { findVariableIndex, findGlobalVariableIndex } from '@root/utils/debug-variable-finder'
+ const index = v.class === 'external'
+ ? findGlobalVariableIndex(v.name, debugVariables)
+ : findVariableIndex(instance.name, v.name, debugVariables)
+ ```
+
+2. Deprecate `matchVariableWithDebugEntry()` and `matchGlobalVariableWithDebugEntry()`
+
+**Files Modified**:
+- Modify: `src/renderer/components/_organisms/workspace-activity-bar/default.tsx`
+- Modify: `src/renderer/utils/parse-debug-file.ts` (mark functions deprecated)
+
+**Testing**:
+- Verify debugger initialization works correctly
+- Test with various variable types (local, external, FB instances)
+- Ensure index map is populated correctly
+
+---
+
+### Phase 4: Unify Tree Building
+
+**Goal**: Shared tree/leaf variable traversal for both debugger and OPC-UA
+
+**Rationale**: Both systems need to traverse complex types (FBs, structs, arrays) to find leaf variables. The traversal logic is complex and having it in one place reduces bugs.
+
+**Changes**:
+
+1. Create shared tree traversal with visitor pattern:
+ ```typescript
+ // src/utils/debug-tree-traversal.ts
+
+ export interface DebugNodeVisitor {
+ visitLeaf(path: string, compositeKey: string, type: string, index: number | undefined): T
+ visitComplex(path: string, compositeKey: string, type: string, children: T[]): T
+ visitArray(path: string, compositeKey: string, elementType: string, indices: [number, number], children: T[]): T
+ }
+
+ export function traverseVariable(
+ variable: PouVariable,
+ pouName: string,
+ instanceName: string,
+ debugVariables: DebugVariableEntry[],
+ projectPous: PLCPou[],
+ dataTypes: PLCDataType[],
+ visitor: DebugNodeVisitor
+ ): T
+ ```
+
+2. Debugger implements visitor for `DebugTreeNode`:
+ ```typescript
+ const debuggerVisitor: DebugNodeVisitor = {
+ visitLeaf: (path, key, type, index) => ({
+ name: path.split('.').pop()!,
+ fullPath: path,
+ compositeKey: key,
+ type,
+ isComplex: false,
+ debugIndex: index
+ }),
+ // ... other methods
+ }
+ ```
+
+3. OPC-UA implements visitor for `ResolvedField[]`:
+ ```typescript
+ const opcuaVisitor: DebugNodeVisitor = {
+ visitLeaf: (path, key, type, index) => [{
+ name: path,
+ datatype: type,
+ index: index!,
+ // ...
+ }],
+ // ... other methods
+ }
+ ```
+
+**Files Created**:
+- `src/utils/debug-tree-traversal.ts`
+
+**Files Modified**:
+- Modify: `src/renderer/utils/debug-tree-builder.ts` (use shared traversal)
+- Modify: `src/utils/opcua/resolve-indices.ts` (use shared traversal)
+
+**Testing**:
+- Test with deeply nested FBs
+- Test with arrays of structures
+- Test with mixed complex types
+- Verify both debugger and OPC-UA produce correct results
+
+---
+
+### Phase 5: Update Debugger Polling
+
+**Goal**: Consistent index lookup in polling code
+
+**Rationale**: The polling loop in `workspace-screen.tsx` builds paths inline. Using shared utilities ensures consistency with initialization.
+
+**Changes**:
+
+1. Replace inline path construction:
+ ```typescript
+ // Before
+ const debugPath = `RES0__${programInstance.name.toUpperCase()}.${fbInstance.name.toUpperCase()}.${fbVar.name.toUpperCase()}`
+
+ // After
+ import { buildDebugPath } from '@root/utils/debug-variable-finder'
+ const debugPath = buildDebugPath(programInstance.name, `${fbInstance.name}.${fbVar.name}`)
+ ```
+
+2. Use `findInstanceName()` instead of manual instance lookup
+
+3. Centralize FB variable iteration logic
+
+**Files Modified**:
+- Modify: `src/renderer/screens/workspace-screen.tsx`
+
+**Testing**:
+- Test debugger polling with real hardware
+- Verify all variable types update correctly
+- Test force variable functionality
+
+---
+
+### Phase 6: Remove Deprecated Code
+
+**Goal**: Clean up duplicate implementations after verification
+
+**Prerequisites**:
+- All phases 1-5 completed
+- Debugger verified working with real hardware
+- OPC-UA verified working with real hardware
+- No regressions in functionality
+- All tests passing
+
+**Removals**:
+
+1. **`src/renderer/utils/parse-debug-file.ts`**:
+ - Remove `parseDebugFile()` implementation body (keep as re-export)
+ - Remove `matchVariableWithDebugEntry()` function
+ - Remove `matchGlobalVariableWithDebugEntry()` function
+ - Remove `parsePathComponents()` helper
+ - Keep file with re-exports for backwards compatibility
+
+2. **`src/renderer/utils/debug-tree-builder.ts`**:
+ - Remove `buildVariableBasePath()` function
+ - Remove `normalizeTypeString()` (use from `pou-helpers.ts`)
+ - Remove duplicate FB/struct lookup code
+ - Update to be thin wrapper around shared traversal
+
+3. **`src/renderer/screens/workspace-screen.tsx`**:
+ - Remove any remaining inline path building
+
+4. **Update all imports** to point to canonical shared locations
+
+**Final File Structure**:
+
+```
+src/
+├── utils/
+│ ├── debug-parser.ts # CANONICAL: Parse debug.c
+│ ├── debug-variable-finder.ts # CANONICAL: Path building, index lookup
+│ ├── debug-tree-traversal.ts # CANONICAL: Tree traversal (NEW)
+│ ├── pou-helpers.ts # CANONICAL: FB/struct lookup
+│ └── opcua/
+│ ├── resolve-indices.ts # Uses shared utilities
+│ ├── generate-opcua-config.ts
+│ └── types.ts
+├── renderer/
+│ ├── utils/
+│ │ ├── parse-debug-file.ts # Re-exports only (backwards compat)
+│ │ └── debug-tree-builder.ts # UI wrapper using shared traversal
+│ ├── components/_organisms/
+│ │ └── workspace-activity-bar/
+│ │ └── default.tsx # Uses shared utilities
+│ └── screens/
+│ └── workspace-screen.tsx # Uses shared utilities
+└── types/
+ └── debugger.ts # Shared debug node types
+```
+
+**Testing**:
+- Full regression test suite
+- Manual testing with various PLC programs
+- Test edge cases (empty programs, complex nesting)
+
+## Migration Strategy
+
+### Backwards Compatibility
+
+During the transition, we maintain backwards compatibility by:
+
+1. **Re-exporting**: Old import paths continue to work
+2. **Wrapper functions**: Deprecated functions delegate to new implementations
+3. **Type aliases**: Old type names map to new types
+
+Example:
+```typescript
+// src/renderer/utils/parse-debug-file.ts (during transition)
+
+// Re-export new parser with old name
+export { parseDebugVariables as parseDebugFile } from '@root/utils/debug-parser'
+
+// Type alias for backwards compatibility
+export type { DebugVariableEntry as DebugVariable } from '@root/utils/debug-parser'
+
+// Deprecated wrapper (to be removed in Phase 6)
+/** @deprecated Use findVariableIndex from debug-variable-finder instead */
+export function matchVariableWithDebugEntry(...) {
+ console.warn('matchVariableWithDebugEntry is deprecated')
+ return findVariableIndex(...)
+}
+```
+
+### Testing Strategy
+
+Each phase includes specific testing requirements:
+
+1. **Unit Tests**: Test shared utilities in isolation
+2. **Integration Tests**: Test debugger and OPC-UA with shared code
+3. **Hardware Tests**: Verify with actual PLC hardware
+4. **Regression Tests**: Ensure no existing functionality breaks
+
+### Rollback Plan
+
+If issues are discovered:
+
+1. Each phase can be reverted independently
+2. Deprecated wrappers provide fallback during transition
+3. Git branches allow easy rollback to previous state
+
+## Timeline Estimate
+
+| Phase | Complexity | Dependencies |
+|-------|------------|--------------|
+| Phase 1 | Low | None |
+| Phase 2 | Medium | Phase 1 |
+| Phase 3 | Medium | Phase 2 |
+| Phase 4 | High | Phase 2, 3 |
+| Phase 5 | Medium | Phase 2 |
+| Phase 6 | Low | All above + verification |
+
+Recommended order: 1 → 2 → 3 → 5 → 4 → 6
+
+(Phase 5 can be done before Phase 4 as it only requires path building)
+
+## Success Criteria
+
+The refactoring is complete when:
+
+1. ✅ Single debug.c parser used by both systems
+2. ✅ Single path building implementation
+3. ✅ Single index lookup implementation
+4. ✅ Shared tree traversal logic
+5. ✅ No duplicate implementations remain
+6. ✅ All tests pass
+7. ✅ Debugger works correctly with hardware
+8. ✅ OPC-UA works correctly with hardware
+9. ✅ Code coverage maintained or improved
+
+## References
+
+- `src/utils/debug-variable-finder.ts` - Current shared path building
+- `src/utils/pou-helpers.ts` - Current shared POU helpers
+- `src/renderer/utils/debug-tree-builder.ts` - Current debugger tree builder
+- `src/utils/opcua/resolve-indices.ts` - Current OPC-UA index resolution
From 9ee6a1441a3d7943498c69dbe5e7d2276f6b6e33 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?=
Date: Mon, 19 Jan 2026 15:09:13 -0300
Subject: [PATCH 09/21] feat: Add array type parsing support for OPC-UA
configuration
- Add parseArrayType() helper to parse IEC array declarations like
"ARRAY[1..10] OF INT" into proper type definitions with dimensions
- Support both base types and user-defined types as array element types
- Add extractArrayElementType() to resolve element types from array strings
- Improve resolveArray() to properly determine datatype for OPC-UA nodes
Co-Authored-By: Claude Opus 4.5
---
package-lock.json | 4 +-
src/utils/generate-iec-string-to-variables.ts | 59 +++++++++++++++++++
src/utils/opcua/generate-opcua-config.ts | 18 +++++-
3 files changed, 78 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 80e290f52..75e039be1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "open-plc-editor",
- "version": "4.1.0",
+ "version": "4.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-plc-editor",
- "version": "4.1.0",
+ "version": "4.1.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
diff --git a/src/utils/generate-iec-string-to-variables.ts b/src/utils/generate-iec-string-to-variables.ts
index 6edbe8c0a..4bf973919 100644
--- a/src/utils/generate-iec-string-to-variables.ts
+++ b/src/utils/generate-iec-string-to-variables.ts
@@ -37,6 +37,49 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type
return typeof lib === 'object' && lib !== null && 'pous' in lib && Array.isArray((lib as { pous: unknown }).pous)
}
+/**
+ * Parse an array type string like "ARRAY[1..10] OF INT" or "ARRAY[1..10, 1..5] OF MyStruct"
+ * Returns null if not an array type, otherwise returns the parsed array type definition.
+ */
+const parseArrayType = (typeStr: string): PLCVariable['type'] | null => {
+ // Match ARRAY[dimensions] OF baseType
+ const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+(.+)$/i)
+ if (!arrayMatch) return null
+
+ const dimensionsStr = arrayMatch[1]
+ const baseTypeStr = arrayMatch[2].trim()
+
+ // Parse dimensions (can be comma-separated for multi-dimensional arrays)
+ const dimensionParts = dimensionsStr.split(',').map((d) => d.trim())
+ const dimensions = dimensionParts.map((dim) => ({ dimension: dim }))
+
+ // Determine the base type definition
+ const baseCheck = baseTypeSchema.safeParse(baseTypeStr.toLowerCase())
+
+ // Build the array type definition
+ if (baseCheck.success) {
+ // Base type is a valid IEC base type
+ return {
+ definition: 'array' as const,
+ value: typeStr, // Keep the full type string as the value
+ data: {
+ baseType: { definition: 'base-type' as const, value: baseCheck.data },
+ dimensions,
+ },
+ }
+ } else {
+ // Base type is a user-defined type (structure, FB, etc.)
+ return {
+ definition: 'array' as const,
+ value: typeStr, // Keep the full type string as the value
+ data: {
+ baseType: { definition: 'user-data-type' as const, value: baseTypeStr },
+ dimensions,
+ },
+ }
+ }
+}
+
export const parseIecStringToVariables = (
iecString: string,
pous?: PLCPou[],
@@ -91,6 +134,22 @@ export const parseIecStringToVariables = (
}
const parsedType = type.trim()
+
+ // Check if it's an array type first
+ const arrayType = parseArrayType(parsedType)
+ if (arrayType) {
+ variables.push({
+ name: name.trim(),
+ class: currentClass,
+ type: arrayType,
+ location: location ? location.trim() : '',
+ initialValue: initialValue ? initialValue.trim() : null,
+ documentation: documentation ? documentation.trim() : '',
+ debug: false,
+ })
+ return
+ }
+
const baseCheck = baseTypeSchema.safeParse(parsedType.toLowerCase())
const isUserFunctionBlock = pous?.some(
diff --git a/src/utils/opcua/generate-opcua-config.ts b/src/utils/opcua/generate-opcua-config.ts
index 35a675d72..66b4a5ec6 100644
--- a/src/utils/opcua/generate-opcua-config.ts
+++ b/src/utils/opcua/generate-opcua-config.ts
@@ -230,6 +230,15 @@ const resolveStructure = (
}
}
+/**
+ * Extract the element type from an array type string like "ARRAY[1..10] OF INT"
+ * Returns the element type (e.g., "INT") or the original string if parsing fails.
+ */
+const extractArrayElementType = (arrayTypeStr: string): string => {
+ const match = arrayTypeStr.match(/\bOF\s+(\w+)\s*$/i)
+ return match ? match[1].toUpperCase() : arrayTypeStr
+}
+
/**
* Resolve an array and build runtime format
*/
@@ -240,11 +249,18 @@ const resolveArray = (
): RuntimeArray => {
const index = resolveArrayIndex(node, debugVariables, instances)
+ // Get the element type - prefer explicit elementType, otherwise extract from variableType
+ let datatype = node.elementType
+ if (!datatype && node.variableType) {
+ datatype = extractArrayElementType(node.variableType)
+ }
+ datatype = datatype || 'UNKNOWN'
+
return {
node_id: node.nodeId,
browse_name: node.browseName,
display_name: node.displayName,
- datatype: node.elementType || node.variableType,
+ datatype,
length: node.arrayLength || 1,
initial_value: node.initialValue,
index,
From 30d325c2bf8f92b322c4c45f633d1cec09855cdc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?=
Date: Mon, 19 Jan 2026 15:19:13 -0300
Subject: [PATCH 10/21] fix: Address Copilot review comments on array type
parsing
- Improve extractArrayElementType regex to support namespaced types
with colons and dots (e.g., NS1:MyType, System.Int32)
- Make parseArrayType regex more specific to avoid capturing trailing
whitespace, using proper identifier pattern
- Rename ambiguous variable 'dim' to 'dimensionRange' for clarity
Co-Authored-By: Claude Opus 4.5
---
src/utils/generate-iec-string-to-variables.ts | 6 +++---
src/utils/opcua/generate-opcua-config.ts | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/utils/generate-iec-string-to-variables.ts b/src/utils/generate-iec-string-to-variables.ts
index 4bf973919..77760e84f 100644
--- a/src/utils/generate-iec-string-to-variables.ts
+++ b/src/utils/generate-iec-string-to-variables.ts
@@ -42,8 +42,8 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type
* Returns null if not an array type, otherwise returns the parsed array type definition.
*/
const parseArrayType = (typeStr: string): PLCVariable['type'] | null => {
- // Match ARRAY[dimensions] OF baseType
- const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+(.+)$/i)
+ // Match ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally namespaced)
+ const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*)\s*$/i)
if (!arrayMatch) return null
const dimensionsStr = arrayMatch[1]
@@ -51,7 +51,7 @@ const parseArrayType = (typeStr: string): PLCVariable['type'] | null => {
// Parse dimensions (can be comma-separated for multi-dimensional arrays)
const dimensionParts = dimensionsStr.split(',').map((d) => d.trim())
- const dimensions = dimensionParts.map((dim) => ({ dimension: dim }))
+ const dimensions = dimensionParts.map((dimensionRange) => ({ dimension: dimensionRange }))
// Determine the base type definition
const baseCheck = baseTypeSchema.safeParse(baseTypeStr.toLowerCase())
diff --git a/src/utils/opcua/generate-opcua-config.ts b/src/utils/opcua/generate-opcua-config.ts
index 66b4a5ec6..d0b0553ff 100644
--- a/src/utils/opcua/generate-opcua-config.ts
+++ b/src/utils/opcua/generate-opcua-config.ts
@@ -235,7 +235,7 @@ const resolveStructure = (
* Returns the element type (e.g., "INT") or the original string if parsing fails.
*/
const extractArrayElementType = (arrayTypeStr: string): string => {
- const match = arrayTypeStr.match(/\bOF\s+(\w+)\s*$/i)
+ const match = arrayTypeStr.match(/\bOF\s+([A-Za-z0-9_:.]+)\s*$/i)
return match ? match[1].toUpperCase() : arrayTypeStr
}
From 76d28b4b11ff014491a82b0cd7a544c07930dbe0 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 19 Jan 2026 16:47:15 -0500
Subject: [PATCH 11/21] refactor: Unify debugger and OPC-UA variable index
resolution
Consolidate the fallback index resolution logic used by both the debugger
and OPC-UA systems into shared utilities. This ensures consistent behavior
when resolving variable indexes for user-defined types (which can be either
function blocks or structures).
Key changes:
- Add findDebugVariableWithFallback() to debug-variable-finder.ts
- Add getIndexFromMapWithFallback() for polling map lookups
- Update resolve-indices.ts to use shared fallback functions
- Update debug-tree-traversal.ts to use shared fallback for FB fields
- Update workspace-activity-bar to use findVariableIndexWithFallback()
- Update workspace-screen.tsx polling to use shared fallback lookups
- Extract debug-parser.ts with canonical parsing logic
- Remove duplicate parsing logic from parse-debug-file.ts
- Remove duplicate logic from debug-tree-builder.ts
The unified fallback strategy (FB-style path first, then struct-style path)
ensures that if one system can find a variable's index, the other will too.
Co-Authored-By: Claude Opus 4.5
---
src/main/modules/ipc/main.ts | 7 -
.../editor/device/remote-device/index.tsx | 2 +-
.../workspace-activity-bar/default.tsx | 10 +-
src/renderer/screens/workspace-screen.tsx | 48 +-
src/renderer/utils/debug-tree-builder.ts | 753 +++---------------
src/renderer/utils/parse-debug-file.ts | 173 +---
src/utils/debug-parser.ts | 69 ++
src/utils/debug-tree-traversal.ts | 372 +++++++++
src/utils/debug-variable-finder.ts | 226 +++++-
src/utils/opcua/resolve-indices.ts | 55 +-
10 files changed, 797 insertions(+), 918 deletions(-)
create mode 100644 src/utils/debug-parser.ts
create mode 100644 src/utils/debug-tree-traversal.ts
diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts
index a25f777a7..7962f5702 100644
--- a/src/main/modules/ipc/main.ts
+++ b/src/main/modules/ipc/main.ts
@@ -1172,13 +1172,6 @@ class MainProcessBridge implements MainIpcModule {
): Promise<{ success: boolean; error?: string }> => {
const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined
- console.log('[IPC Handler] debugger:set-variable called with:', {
- variableIndex,
- force,
- valueBuffer: buffer?.toString('hex'),
- connectionType: this.debuggerConnectionType,
- })
-
if (this.debuggerConnectionType === 'websocket') {
if (!this.debuggerWebSocketClient) {
console.log('[IPC Handler] WebSocket client not connected')
diff --git a/src/renderer/components/_features/[workspace]/editor/device/remote-device/index.tsx b/src/renderer/components/_features/[workspace]/editor/device/remote-device/index.tsx
index e625dbfcf..a1e7b17b3 100644
--- a/src/renderer/components/_features/[workspace]/editor/device/remote-device/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/device/remote-device/index.tsx
@@ -362,7 +362,7 @@ const RemoteDeviceEditor = () => {
setHost(remoteDevice.modbusTcpConfig.host)
setPort(remoteDevice.modbusTcpConfig.port.toString())
setTimeoutMs(remoteDevice.modbusTcpConfig.timeout.toString())
- setSlaveId((remoteDevice.modbusTcpConfig.slaveId ?? 1).toString())
+ setSlaveId(String(remoteDevice.modbusTcpConfig.slaveId ?? 1))
} else {
setHost('127.0.0.1')
setPort('502')
diff --git a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx
index c54d3a7f7..5bc9c571e 100644
--- a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx
+++ b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx
@@ -4,13 +4,14 @@ import { compileOnlySelectors } from '@root/renderer/hooks'
import { useOpenPLCStore } from '@root/renderer/store'
import type { RuntimeConnection } from '@root/renderer/store/slices/device/types'
import { buildDebugTree } from '@root/renderer/utils/debug-tree-builder'
-import { matchVariableWithDebugEntry, parseDebugFile } from '@root/renderer/utils/parse-debug-file'
import type { DebugTreeNode, FbInstanceInfo } from '@root/types/debugger'
import { PLCPou, PLCProjectData } from '@root/types/PLC/open-plc'
import { BufferToStringArray, cn, isOpenPLCRuntimeTarget } from '@root/utils'
import { addCppLocalVariables } from '@root/utils/cpp/addCppLocalVariables'
import { generateSTCode as generateCppSTCode } from '@root/utils/cpp/generateSTCode'
import { validateCppCode } from '@root/utils/cpp/validateCppCode'
+import { parseDebugFile } from '@root/utils/debug-parser'
+import { findGlobalVariableIndex, findVariableIndexWithFallback } from '@root/utils/debug-variable-finder'
type CppPouData = {
name: string
@@ -885,7 +886,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
const allVariables = pou.data.variables
allVariables.forEach((v) => {
- const index = matchVariableWithDebugEntry(v.name, instance.name, parsed.variables, v.class)
+ // Use fallback to try both FB-style and struct-style paths
+ // This ensures consistent behavior with OPC-UA index resolution
+ const index =
+ v.class === 'external'
+ ? findGlobalVariableIndex(v.name, parsed.variables)
+ : findVariableIndexWithFallback(instance.name, v.name, parsed.variables)
if (index !== null) {
const compositeKey = `${pou.data.name}:${v.name}`
indexMap.set(compositeKey, index)
diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx
index ce1c3f853..c6d0a780f 100644
--- a/src/renderer/screens/workspace-screen.tsx
+++ b/src/renderer/screens/workspace-screen.tsx
@@ -4,6 +4,12 @@ import { useRuntimePolling } from '@root/renderer/hooks/use-runtime-polling'
import { DebugTreeNode } from '@root/types/debugger'
import { isV4Logs, LOG_BUFFER_CAP } from '@root/types/PLC/runtime-logs'
import { cn, isOpenPLCRuntimeTarget } from '@root/utils'
+import {
+ appendToDebugPath,
+ buildDebugPath,
+ getFieldIndexFromMapWithFallback,
+ getIndexFromMapWithFallback,
+} from '@root/utils/debug-variable-finder'
import { useEffect, useRef, useState } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
@@ -259,8 +265,8 @@ const WorkspaceScreen = () => {
fbVariables.forEach((fbVar) => {
if (fbVar.type.definition === 'base-type') {
// Base type variable - add to variableInfoMap
- const debugPath = `${debugPathPrefix}.${fbVar.name.toUpperCase()}`
- const index = debugVariableIndexes.get(debugPath)
+ // Use fallback to try both FB-style and struct-style paths
+ const index = getFieldIndexFromMapWithFallback(debugVariableIndexes, debugPathPrefix, fbVar.name)
if (index !== undefined) {
const varName = `${variableNamePrefix}.${fbVar.name}`
@@ -302,7 +308,7 @@ const WorkspaceScreen = () => {
} else if (fbVar.type.definition === 'derived') {
// Nested function block - recursively process
const nestedFBTypeName = fbVar.type.value.toUpperCase()
- const nestedDebugPath = `${debugPathPrefix}.${fbVar.name.toUpperCase()}`
+ const nestedDebugPath = appendToDebugPath(debugPathPrefix, fbVar.name)
const nestedVarName = `${variableNamePrefix}.${fbVar.name}`
// Look up the nested FB definition
@@ -336,7 +342,7 @@ const WorkspaceScreen = () => {
} else if (fbVar.type.definition === 'user-data-type') {
// Nested struct - recursively process
const structTypeName = fbVar.type.value
- const nestedDebugPath = `${debugPathPrefix}.${fbVar.name.toUpperCase()}`
+ const nestedDebugPath = appendToDebugPath(debugPathPrefix, fbVar.name)
const nestedVarName = `${variableNamePrefix}.${fbVar.name}`
// Check if this is actually a function block (some FBs are defined as user-data-type)
@@ -480,8 +486,12 @@ const WorkspaceScreen = () => {
}
allBaseTypeVars.forEach((fbVar) => {
- const debugPath = `RES0__${programInstance.name.toUpperCase()}.${fbInstance.name.toUpperCase()}.${fbVar.name.toUpperCase()}`
- const index = debugVariableIndexes.get(debugPath)
+ // Use fallback to try both FB-style and struct-style paths
+ const index = getIndexFromMapWithFallback(
+ debugVariableIndexes,
+ programInstance.name,
+ `${fbInstance.name}.${fbVar.name}`,
+ )
if (index !== undefined) {
const blockVarName = `${fbInstance.name}.${fbVar.name}`
@@ -527,7 +537,7 @@ const WorkspaceScreen = () => {
(v) => v.type.definition === 'derived' || v.type.definition === 'user-data-type',
)
if (nestedVariables.length > 0) {
- const debugPathPrefix = `RES0__${programInstance.name.toUpperCase()}.${fbInstance.name.toUpperCase()}`
+ const debugPathPrefix = buildDebugPath(programInstance.name, fbInstance.name)
const variableNamePrefix = fbInstance.name
processNestedVariables(nestedVariables, pou.data.name, debugPathPrefix, variableNamePrefix)
}
@@ -577,8 +587,12 @@ const WorkspaceScreen = () => {
}
boolOutputs.forEach((outputVar) => {
- const debugPath = `RES0__${programInstance.name.toUpperCase()}._TMP_${blockName}${numericId}_${outputVar.name.toUpperCase()}`
- const index = debugVariableIndexes.get(debugPath)
+ // Use fallback to try both FB-style and struct-style paths
+ const index = getIndexFromMapWithFallback(
+ debugVariableIndexes,
+ programInstance.name,
+ `_TMP_${blockName}${numericId}_${outputVar.name}`,
+ )
if (index !== undefined) {
const tempVarName = `_TMP_${blockName}${numericId}_${outputVar.name}`
@@ -626,8 +640,8 @@ const WorkspaceScreen = () => {
// 1. Process base-type variables of this FB
const baseTypeVars = fbVariables.filter((v) => v.type.definition === 'base-type')
baseTypeVars.forEach((fbVar) => {
- const debugPath = `${debugPathPrefix}.${fbVar.name.toUpperCase()}`
- const index = debugVariableIndexes.get(debugPath)
+ // Use fallback to try both FB-style and struct-style paths
+ const index = getFieldIndexFromMapWithFallback(debugVariableIndexes, debugPathPrefix, fbVar.name)
if (index !== undefined) {
const varName = `${variablePathPrefix}.${fbVar.name}`
@@ -738,8 +752,12 @@ const WorkspaceScreen = () => {
boolOutputs.forEach((outputVar) => {
// Debug path uses the full nested path:
// RES0__INSTANCE0.FB_B0.FB_A0._TMP_EQ_STATE7415072_ENO
- const debugPath = `${debugPathPrefix}._TMP_${blockName}${numericId}_${outputVar.name.toUpperCase()}`
- const index = debugVariableIndexes.get(debugPath)
+ // Use fallback to try both FB-style and struct-style paths
+ const index = getFieldIndexFromMapWithFallback(
+ debugVariableIndexes,
+ debugPathPrefix,
+ `_TMP_${blockName}${numericId}_${outputVar.name}`,
+ )
if (index !== undefined) {
// Variable name includes the full nested path for composite key matching
@@ -776,7 +794,7 @@ const WorkspaceScreen = () => {
if (customFB && customFB.type === 'function-block') {
// For custom FBs, recursively visit to process their internals
- const nestedDebugPathPrefix = `${debugPathPrefix}.${nestedFbInstance.name.toUpperCase()}`
+ const nestedDebugPathPrefix = appendToDebugPath(debugPathPrefix, nestedFbInstance.name)
const nestedVariablePathPrefix = `${variablePathPrefix}.${nestedFbInstance.name}`
visitFbInstance(customFB, nestedDebugPathPrefix, nestedVariablePathPrefix, programPouName, new Map())
}
@@ -822,7 +840,7 @@ const WorkspaceScreen = () => {
if (customFB && customFB.type === 'function-block') {
// For custom FBs, use visitFbInstance to process all internals
- const debugPathPrefix = `RES0__${programInstance.name.toUpperCase()}.${fbInstance.name.toUpperCase()}`
+ const debugPathPrefix = buildDebugPath(programInstance.name, fbInstance.name)
const variablePathPrefix = fbInstance.name
visitFbInstance(customFB, debugPathPrefix, variablePathPrefix, programPou.data.name, blockExecutionControlMap)
}
diff --git a/src/renderer/utils/debug-tree-builder.ts b/src/renderer/utils/debug-tree-builder.ts
index 8e4b48965..61b0a3da5 100644
--- a/src/renderer/utils/debug-tree-builder.ts
+++ b/src/renderer/utils/debug-tree-builder.ts
@@ -1,18 +1,18 @@
+/**
+ * Debug tree builder for the debugger UI.
+ *
+ * This module uses the shared traversal visitor pattern from debug-tree-traversal.ts
+ * to build DebugTreeNode structures for displaying variables in the debugger.
+ */
+
import type { DebugTreeNode } from '@root/types/debugger'
import type { PLCProject, PLCVariable } from '@root/types/PLC/open-plc'
+import type { DebugVariableEntry } from '@root/utils/debug-parser'
+import type { DebugNodeVisitor, TraversalContext } from '@root/utils/debug-tree-traversal'
+import { traverseVariable } from '@root/utils/debug-tree-traversal'
+import { buildDebugPath, buildGlobalDebugPath } from '@root/utils/debug-variable-finder'
-import { StandardFunctionBlocks } from '../data/library/standard-function-blocks'
-import type { DebugVariable } from './parse-debug-file'
-
-const DEBUG_TREE_LOGGING = true
-
-/**
- * Normalizes type strings for case-insensitive comparison.
- * Handles variations like 'function-block', 'functionBlock', 'function_block'.
- */
-function normalizeTypeString(typeStr: string): string {
- return typeStr.toLowerCase().replace(/[-_]/g, '')
-}
+const DEBUG_TREE_LOGGING = false
/**
* Logs debug tree information to console (dev-only).
@@ -45,424 +45,34 @@ function logDebugTree(node: DebugTreeNode, indent = 0): void {
}
/**
- * Builds the base path for a variable based on whether it's external (global) or local.
- * External variables use CONFIG0__ prefix, local variables use RES0__INSTANCE. prefix.
- */
-function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string {
- const variableNameUpper = variableName.toUpperCase()
- if (variableClass === 'external') {
- // External variables reference global variables, which use CONFIG0__ prefix
- return `CONFIG0__${variableNameUpper}`
- }
- // Regular POU variables use RES0__INSTANCE.VARNAME format
- const instanceNameUpper = instanceName.toUpperCase()
- return `RES0__${instanceNameUpper}.${variableNameUpper}`
-}
-
-/**
- * Builds a debug tree structure for a PLC variable.
- * Recursively processes complex types (arrays, structs, function blocks).
- *
- * @param variable - The PLC variable definition
- * @param pouName - Name of the POU containing the variable
- * @param instanceName - Instance name from Resources configuration
- * @param debugVariables - Parsed variables from debug.c
- * @param project - The PLC project containing all type definitions
- * @returns A DebugTreeNode representing the variable and its children
- */
-export function buildDebugTree(
- variable: PLCVariable,
- pouName: string,
- instanceName: string,
- debugVariables: DebugVariable[],
- project: PLCProject,
-): DebugTreeNode {
- const compositeKey = `${pouName}:${variable.name}`
-
- let node: DebugTreeNode
-
- if (variable.type.definition === 'base-type') {
- const baseType = variable.type.value.toUpperCase()
- const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class)
-
- const debugVar = debugVariables.find((dv) => dv.name === fullPath)
-
- node = {
- name: variable.name,
- fullPath,
- compositeKey,
- type: baseType,
- isComplex: false,
- debugIndex: debugVar?.index,
- }
- } else if (variable.type.definition === 'derived') {
- node = buildFunctionBlockTree(variable, pouName, instanceName, debugVariables, project)
- } else if (variable.type.definition === 'array') {
- node = buildArrayTree(variable, pouName, instanceName, debugVariables, project)
- } else if (variable.type.definition === 'user-data-type') {
- node = buildStructTree(variable, pouName, instanceName, debugVariables, project)
- } else {
- node = {
- name: variable.name,
- fullPath: buildVariableBasePath(variable.name, instanceName, variable.class),
- compositeKey,
- type: 'UNKNOWN',
- isComplex: false,
- }
- }
-
- if (DEBUG_TREE_LOGGING) {
- console.groupCollapsed(`Debug Tree for ${variable.name}`)
- logDebugTree(node)
- console.groupEnd()
- }
-
- return node
-}
-
-/**
- * Builds a tree node for a Function Block variable.
- * Looks up the FB definition and recursively processes its variables.
- */
-function buildFunctionBlockTree(
- variable: PLCVariable,
- pouName: string,
- instanceName: string,
- debugVariables: DebugVariable[],
- project: PLCProject,
-): DebugTreeNode {
- if (variable.type.definition !== 'derived') {
- throw new Error('Expected derived type for function block')
- }
-
- const fbTypeName = variable.type.value
- const fbTypeNameUpper = fbTypeName.toUpperCase()
-
- const compositeKey = `${pouName}:${variable.name}`
- const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class)
-
- const standardFB = StandardFunctionBlocks.pous.find(
- (pou) => pou.name.toUpperCase() === fbTypeNameUpper && normalizeTypeString(pou.type) === 'functionblock',
- )
-
- const customFB = project.data.pous.find(
- (pou) => normalizeTypeString(pou.type) === 'functionblock' && pou.data.name.toUpperCase() === fbTypeNameUpper,
- )
-
- const fbDefinition = standardFB || (customFB?.type === 'function-block' ? customFB.data : null)
-
- if (!fbDefinition) {
- if (DEBUG_TREE_LOGGING) {
- console.warn(`[buildFunctionBlockTree] FB definition not found for "${fbTypeName}"`)
- console.warn(
- ` Available custom FBs:`,
- project.data.pous.filter((p) => p.type === 'function-block').map((p) => ({ name: p.data.name, type: p.type })),
- )
- const caseInsensitiveMatch = project.data.pous.find(
- (p) => p.type === 'function-block' && p.data.name.toUpperCase() === fbTypeName.toUpperCase(),
- )
- if (caseInsensitiveMatch) {
- console.warn(` Case-insensitive match found: "${caseInsensitiveMatch.data.name}"`)
- }
- }
- return {
- name: variable.name,
- fullPath,
- compositeKey,
- type: fbTypeName,
- isComplex: false,
- }
- }
-
- const children: DebugTreeNode[] = []
-
- for (const fbVar of fbDefinition.variables) {
- if (DEBUG_TREE_LOGGING) {
- console.log(
- `[buildFunctionBlockTree] Processing ${fbTypeName}.${fbVar.name}: definition=${fbVar.type.definition}, value=${fbVar.type.value}`,
- )
- }
-
- const childFullPath = `${fullPath}.${fbVar.name.toUpperCase()}`
- const childCompositeKey = `${compositeKey}.${fbVar.name}`
-
- if (fbVar.type.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === childFullPath)
-
- children.push({
- name: fbVar.name,
- fullPath: childFullPath,
- compositeKey: childCompositeKey,
- type: fbVar.type.value.toUpperCase(),
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (fbVar.type.definition === 'derived' || fbVar.type.definition === 'derived-type') {
- const nestedFBNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- fbVar.type.value,
- 'derived',
- debugVariables,
- project,
- )
- children.push(nestedFBNode)
- } else if (fbVar.type.definition === 'user-data-type') {
- const typeNameUpper = fbVar.type.value.toUpperCase()
- const isStandardFB = StandardFunctionBlocks.pous.some(
- (pou) => pou.name.toUpperCase() === typeNameUpper && normalizeTypeString(pou.type) === 'functionblock',
- )
- const isCustomFB = project.data.pous.some(
- (pou) => normalizeTypeString(pou.type) === 'functionblock' && pou.data.name.toUpperCase() === typeNameUpper,
- )
-
- if (DEBUG_TREE_LOGGING) {
- console.log(
- ` user-data-type "${fbVar.type.value}" resolved as: ${isStandardFB || isCustomFB ? 'FB' : 'struct'}`,
- )
- }
-
- if (isStandardFB || isCustomFB) {
- const nestedFBNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- fbVar.type.value,
- 'derived',
- debugVariables,
- project,
- )
- children.push(nestedFBNode)
- } else {
- const nestedNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- fbVar.type.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- }
- } else if (fbVar.type.definition === 'array') {
- const nestedNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- 'ARRAY',
- 'array',
- debugVariables,
- project,
- fbVar.type.data,
- )
- children.push(nestedNode)
- } else {
- const debugVar = debugVariables.find((dv) => dv.name === childFullPath)
-
- children.push({
- name: fbVar.name,
- fullPath: childFullPath,
- compositeKey: childCompositeKey,
- type: 'UNKNOWN',
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- }
- }
-
- return {
- name: variable.name,
- fullPath,
- compositeKey,
- type: fbTypeName,
- isComplex: true,
- children,
- }
-}
-
-/**
- * Helper function to expand nested complex nodes (structs, arrays, FBs).
- * Used for recursively expanding complex types within structs, arrays, and FBs.
+ * Visitor implementation that produces DebugTreeNode objects.
+ * Used with the shared traversal to build the debug tree structure.
*/
-function expandNestedNode(
- name: string,
- fullPath: string,
- compositeKey: string,
- typeName: string,
- typeDefinition: 'user-data-type' | 'array' | 'derived',
- debugVariables: DebugVariable[],
- project: PLCProject,
- arrayData?: {
- baseType: { definition: 'base-type' | 'user-data-type'; value: string }
- dimensions: Array<{ dimension: string }>
- },
-): DebugTreeNode {
- if (typeDefinition === 'derived') {
- const typeNameUpper = typeName.toUpperCase()
-
- if (DEBUG_TREE_LOGGING) {
- console.log(`[expandNestedNode] Looking up derived type "${typeName}" (uppercase: "${typeNameUpper}")`)
- console.log(` StandardFunctionBlocks.pous.length: ${StandardFunctionBlocks.pous.length}`)
- console.log(
- ` Sample standard FBs:`,
- StandardFunctionBlocks.pous.slice(0, 5).map((p) => ({ name: p.name, type: p.type })),
- )
- }
-
- const standardFB = StandardFunctionBlocks.pous.find(
- (pou) => pou.name.toUpperCase() === typeNameUpper && normalizeTypeString(pou.type) === 'functionblock',
- )
- const customFB = project.data.pous.find(
- (pou) => normalizeTypeString(pou.type) === 'functionblock' && pou.data.name.toUpperCase() === typeNameUpper,
- )
- const fbDefinition = standardFB || (customFB?.type === 'function-block' ? customFB.data : null)
-
- if (DEBUG_TREE_LOGGING) {
- console.log(` standardFB found: ${!!standardFB}`)
- console.log(` customFB found: ${!!customFB}`)
- if (!fbDefinition) {
- const nameOnlyMatch = StandardFunctionBlocks.pous.find((pou) => pou.name.toUpperCase() === typeNameUpper)
- if (nameOnlyMatch) {
- console.warn(
- ` Name match found but type filter failed: name="${nameOnlyMatch.name}", type="${nameOnlyMatch.type}"`,
- )
- }
- }
- }
-
- if (!fbDefinition) {
- return {
- name,
- fullPath,
- compositeKey,
- type: typeName,
- isComplex: false,
- }
- }
-
- const children: DebugTreeNode[] = []
-
- for (const fbVar of fbDefinition.variables) {
- const childFullPath = `${fullPath}.${fbVar.name.toUpperCase()}`
- const childCompositeKey = `${compositeKey}.${fbVar.name}`
-
- if (fbVar.type.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === childFullPath)
-
- children.push({
- name: fbVar.name,
- fullPath: childFullPath,
- compositeKey: childCompositeKey,
- type: fbVar.type.value.toUpperCase(),
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (fbVar.type.definition === 'derived' || fbVar.type.definition === 'derived-type') {
- const nestedNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- fbVar.type.value,
- 'derived',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- } else if (fbVar.type.definition === 'user-data-type') {
- const nestedNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- fbVar.type.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- } else if (fbVar.type.definition === 'array') {
- const nestedNode = expandNestedNode(
- fbVar.name,
- childFullPath,
- childCompositeKey,
- 'ARRAY',
- 'array',
- debugVariables,
- project,
- fbVar.type.data,
- )
- children.push(nestedNode)
- }
- }
-
+class DebugTreeNodeVisitor implements DebugNodeVisitor {
+ visitLeaf(
+ name: string,
+ fullPath: string,
+ compositeKey: string,
+ typeName: string,
+ debugIndex: number | undefined,
+ ): DebugTreeNode {
return {
name,
fullPath,
compositeKey,
type: typeName,
- isComplex: true,
- children,
- }
- } else if (typeDefinition === 'user-data-type') {
- const typeNameUpper = typeName.toUpperCase()
- const structType = project.data.dataTypes.find(
- (dt) => dt?.name.toUpperCase() === typeNameUpper && dt?.derivation === 'structure',
- )
-
- if (!structType || structType.derivation !== 'structure') {
- return {
- name,
- fullPath,
- compositeKey,
- type: typeName,
- isComplex: false,
- }
- }
-
- const children: DebugTreeNode[] = []
-
- for (const structVar of structType.variable) {
- const fieldFullPath = `${fullPath}.value.${structVar.name.toUpperCase()}`
- const fieldCompositeKey = `${compositeKey}.${structVar.name}`
-
- if (structVar.type.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === fieldFullPath)
-
- children.push({
- name: structVar.name,
- fullPath: fieldFullPath,
- compositeKey: fieldCompositeKey,
- type: structVar.type.value.toUpperCase(),
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (structVar.type.definition === 'user-data-type') {
- const nestedNode = expandNestedNode(
- structVar.name,
- fieldFullPath,
- fieldCompositeKey,
- structVar.type.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- } else if (structVar.type.definition === 'array') {
- const nestedNode = expandNestedNode(
- structVar.name,
- fieldFullPath,
- fieldCompositeKey,
- 'ARRAY',
- 'array',
- debugVariables,
- project,
- structVar.type.data,
- )
- children.push(nestedNode)
- }
+ isComplex: false,
+ debugIndex,
}
+ }
+ visitComplex(
+ name: string,
+ fullPath: string,
+ compositeKey: string,
+ typeName: string,
+ children: DebugTreeNode[],
+ ): DebugTreeNode {
return {
name,
fullPath,
@@ -471,76 +81,16 @@ function expandNestedNode(
isComplex: true,
children,
}
- } else if (typeDefinition === 'array' && arrayData) {
- const dimensions = arrayData.dimensions
- if (dimensions.length === 0) {
- return {
- name,
- fullPath,
- compositeKey,
- type: 'ARRAY',
- isComplex: false,
- }
- }
-
- const firstDimension = dimensions[0].dimension
- const dimensionMatch = firstDimension.match(/^(\d+)\.\.(\d+)$/)
-
- if (!dimensionMatch) {
- return {
- name,
- fullPath,
- compositeKey,
- type: 'ARRAY',
- isComplex: false,
- }
- }
-
- const startIndex = parseInt(dimensionMatch[1], 10)
- const endIndex = parseInt(dimensionMatch[2], 10)
- const arraySize = endIndex - startIndex + 1
-
- const baseType = arrayData.baseType
- let elementTypeName = 'UNKNOWN'
-
- if (baseType.definition === 'base-type') {
- elementTypeName = baseType.value.toUpperCase()
- } else if (baseType.definition === 'user-data-type') {
- elementTypeName = baseType.value
- }
-
- const children: DebugTreeNode[] = []
-
- for (let i = 0; i < arraySize; i++) {
- const elementIndex = startIndex + i
- const elementFullPath = `${fullPath}.value.table[${i}]`
- const elementCompositeKey = `${compositeKey}[${elementIndex}]`
-
- if (baseType.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === elementFullPath)
-
- children.push({
- name: `[${elementIndex}]`,
- fullPath: elementFullPath,
- compositeKey: elementCompositeKey,
- type: elementTypeName,
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (baseType.definition === 'user-data-type') {
- const nestedNode = expandNestedNode(
- `[${elementIndex}]`,
- elementFullPath,
- elementCompositeKey,
- baseType.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- }
- }
+ }
+ visitArray(
+ name: string,
+ fullPath: string,
+ compositeKey: string,
+ _elementTypeName: string,
+ arrayIndices: [number, number],
+ children: DebugTreeNode[],
+ ): DebugTreeNode {
return {
name,
fullPath,
@@ -548,189 +98,92 @@ function expandNestedNode(
type: 'ARRAY',
isComplex: true,
children,
- arrayIndices: [startIndex, endIndex],
+ arrayIndices,
}
}
-
- return {
- name,
- fullPath,
- compositeKey,
- type: typeName,
- isComplex: false,
- }
}
/**
- * Builds a tree node for an Array variable.
- * Creates child nodes for each array element using .value.table[i] naming.
+ * Builds a debug tree structure for a PLC variable.
+ * Uses the shared traversal with a visitor pattern to recursively process
+ * complex types (arrays, structs, function blocks).
+ *
+ * @param variable - The PLC variable definition
+ * @param pouName - Name of the POU containing the variable
+ * @param instanceName - Instance name from Resources configuration
+ * @param debugVariables - Parsed variables from debug.c
+ * @param project - The PLC project containing all type definitions
+ * @returns A DebugTreeNode representing the variable and its children
*/
-function buildArrayTree(
+export function buildDebugTree(
variable: PLCVariable,
pouName: string,
instanceName: string,
- debugVariables: DebugVariable[],
+ debugVariables: DebugVariableEntry[],
project: PLCProject,
): DebugTreeNode {
- if (variable.type.definition !== 'array') {
- throw new Error('Expected array type')
- }
-
- const compositeKey = `${pouName}:${variable.name}`
- const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class)
+ // Handle external variables specially - they use global path
+ // For external variables, we need to adjust the traversal
+ if (variable.class === 'external') {
+ // External variables use CONFIG0__ prefix instead of RES0__INSTANCE
+ // Create a modified variable traversal for external variables
+ const fullPath = buildGlobalDebugPath(variable.name)
+ const compositeKey = `${pouName}:${variable.name}`
+
+ if (variable.type.definition === 'base-type') {
+ const debugVar = debugVariables.find((dv) => dv.name === fullPath)
+ const node: DebugTreeNode = {
+ name: variable.name,
+ fullPath,
+ compositeKey,
+ type: variable.type.value.toUpperCase(),
+ isComplex: false,
+ debugIndex: debugVar?.index,
+ }
- const dimensions = variable.type.data.dimensions
- if (dimensions.length === 0) {
- throw new Error('Array must have at least one dimension')
- }
+ if (DEBUG_TREE_LOGGING) {
+ console.groupCollapsed(`Debug Tree for ${variable.name} (external)`)
+ logDebugTree(node)
+ console.groupEnd()
+ }
- const firstDimension = dimensions[0].dimension
- const dimensionMatch = firstDimension.match(/^(\d+)\.\.(\d+)$/)
+ return node
+ }
- if (!dimensionMatch) {
- throw new Error(`Invalid array dimension format: ${firstDimension}`)
+ // For complex external variables, use the standard traversal
+ // but we need to handle this specially since external vars use CONFIG0__ prefix
+ // The shared traversal handles external class automatically
}
- const startIndex = parseInt(dimensionMatch[1], 10)
- const endIndex = parseInt(dimensionMatch[2], 10)
- const arraySize = endIndex - startIndex + 1
-
- const baseType = variable.type.data.baseType
- let elementTypeName = 'UNKNOWN'
-
- if (baseType.definition === 'base-type') {
- elementTypeName = baseType.value.toUpperCase()
- } else if (baseType.definition === 'user-data-type') {
- elementTypeName = baseType.value
+ // Create traversal context
+ const context: TraversalContext = {
+ debugVariables,
+ projectPous: project.data.pous,
+ dataTypes: project.data.dataTypes,
+ instanceName,
+ pouName,
}
- const children: DebugTreeNode[] = []
-
- for (let i = 0; i < arraySize; i++) {
- const elementIndex = startIndex + i
- const elementFullPath = `${fullPath}.value.table[${i}]`
- const elementCompositeKey = `${compositeKey}[${elementIndex}]`
+ // Use the shared traversal with our visitor
+ const visitor = new DebugTreeNodeVisitor()
+ const node = traverseVariable(variable, context, visitor)
- if (baseType.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === elementFullPath)
-
- children.push({
- name: `[${elementIndex}]`,
- fullPath: elementFullPath,
- compositeKey: elementCompositeKey,
- type: elementTypeName,
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (baseType.definition === 'user-data-type') {
- const nestedNode = expandNestedNode(
- `[${elementIndex}]`,
- elementFullPath,
- elementCompositeKey,
- baseType.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- }
+ if (DEBUG_TREE_LOGGING) {
+ console.groupCollapsed(`Debug Tree for ${variable.name}`)
+ logDebugTree(node)
+ console.groupEnd()
}
- return {
- name: variable.name,
- fullPath,
- compositeKey,
- type: 'ARRAY',
- isComplex: true,
- children,
- arrayIndices: [startIndex, endIndex],
- }
+ return node
}
/**
- * Builds a tree node for a Struct (user-data-type) variable.
- * Looks up the struct definition and recursively processes its fields.
+ * Build the base path for a variable - exposed for backward compatibility.
+ * External variables use CONFIG0__ prefix, local variables use RES0__INSTANCE. prefix.
*/
-function buildStructTree(
- variable: PLCVariable,
- pouName: string,
- instanceName: string,
- debugVariables: DebugVariable[],
- project: PLCProject,
-): DebugTreeNode {
- if (variable.type.definition !== 'user-data-type') {
- throw new Error('Expected user-data-type for struct')
- }
-
- const structTypeName = variable.type.value
- const structTypeNameUpper = structTypeName.toUpperCase()
-
- const compositeKey = `${pouName}:${variable.name}`
- const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class)
-
- const structType = project.data.dataTypes.find(
- (dt) => dt?.name.toUpperCase() === structTypeNameUpper && dt?.derivation === 'structure',
- )
-
- if (!structType || structType.derivation !== 'structure') {
- return {
- name: variable.name,
- fullPath,
- compositeKey,
- type: structTypeName,
- isComplex: false,
- }
- }
-
- const children: DebugTreeNode[] = []
-
- for (const structVar of structType.variable) {
- const fieldFullPath = `${fullPath}.value.${structVar.name.toUpperCase()}`
- const fieldCompositeKey = `${compositeKey}.${structVar.name}`
-
- if (structVar.type.definition === 'base-type') {
- const debugVar = debugVariables.find((dv) => dv.name === fieldFullPath)
-
- children.push({
- name: structVar.name,
- fullPath: fieldFullPath,
- compositeKey: fieldCompositeKey,
- type: structVar.type.value.toUpperCase(),
- isComplex: false,
- debugIndex: debugVar?.index,
- })
- } else if (structVar.type.definition === 'user-data-type') {
- const nestedNode = expandNestedNode(
- structVar.name,
- fieldFullPath,
- fieldCompositeKey,
- structVar.type.value,
- 'user-data-type',
- debugVariables,
- project,
- )
- children.push(nestedNode)
- } else if (structVar.type.definition === 'array') {
- const nestedNode = expandNestedNode(
- structVar.name,
- fieldFullPath,
- fieldCompositeKey,
- 'ARRAY',
- 'array',
- debugVariables,
- project,
- structVar.type.data,
- )
- children.push(nestedNode)
- }
- }
-
- return {
- name: variable.name,
- fullPath,
- compositeKey,
- type: structTypeName,
- isComplex: true,
- children,
+export function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string {
+ if (variableClass === 'external') {
+ return buildGlobalDebugPath(variableName)
}
+ return buildDebugPath(instanceName, variableName)
}
diff --git a/src/renderer/utils/parse-debug-file.ts b/src/renderer/utils/parse-debug-file.ts
index 6193e1a5a..6001f9291 100644
--- a/src/renderer/utils/parse-debug-file.ts
+++ b/src/renderer/utils/parse-debug-file.ts
@@ -1,164 +1,17 @@
-export interface DebugVariable {
- name: string
- type: string
- index: number
- /** Parsed path components for identifying parent-child relationships */
- pathComponents?: {
- /** Instance prefix (e.g., "RES0__INSTANCE0") */
- instance: string
- /** Variable name without instance prefix */
- variablePath: string
- /** True if this is a struct field (contains .value.) */
- isStructField: boolean
- /** True if this is an array element (contains .value.table[i]) */
- isArrayElement: boolean
- /** True if this is a function block variable (contains FB_NAME.VAR_NAME) */
- isFunctionBlockVar: boolean
- /** Parent variable path (for nested variables) */
- parentPath?: string
- }
-}
-
-export interface ParsedDebugData {
- variables: DebugVariable[]
- totalCount: number
-}
-
-/**
- * Parses path components from a debug variable path.
- * Identifies the structure of the path (struct field, array element, FB variable, etc.)
- */
-function parsePathComponents(fullPath: string) {
- const firstDotIndex = fullPath.indexOf('.')
- if (firstDotIndex === -1) {
- return {
- instance: fullPath,
- variablePath: '',
- isStructField: false,
- isArrayElement: false,
- isFunctionBlockVar: false,
- }
- }
-
- const instance = fullPath.substring(0, firstDotIndex)
- const variablePath = fullPath.substring(firstDotIndex + 1)
-
- const isStructField = variablePath.includes('.value.') && !variablePath.includes('.value.table[')
-
- const isArrayElement = variablePath.includes('.value.table[')
-
- const dotCount = (variablePath.match(/\./g) || []).length
- const isFunctionBlockVar = dotCount >= 1 && !isStructField && !isArrayElement
-
- let parentPath: string | undefined
-
- if (isStructField) {
- const valueIndex = fullPath.lastIndexOf('.value.')
- if (valueIndex !== -1) {
- parentPath = fullPath.substring(0, valueIndex)
- }
- } else if (isArrayElement) {
- const valueTableIndex = fullPath.lastIndexOf('.value.table[')
- if (valueTableIndex !== -1) {
- const closingBracketIndex = fullPath.indexOf(']', valueTableIndex)
- parentPath =
- closingBracketIndex !== -1
- ? fullPath.substring(0, closingBracketIndex + 1)
- : fullPath.substring(0, valueTableIndex)
- }
- } else if (isFunctionBlockVar) {
- const lastDotIndex = fullPath.lastIndexOf('.')
- if (lastDotIndex !== -1) {
- parentPath = fullPath.substring(0, lastDotIndex)
- }
- }
-
- return {
- instance,
- variablePath,
- isStructField,
- isArrayElement,
- isFunctionBlockVar,
- parentPath,
- }
-}
-
-export function parseDebugFile(content: string): ParsedDebugData {
- const variables: DebugVariable[] = []
-
- const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
-
- if (!debugVarsMatch) {
- console.warn('Could not find debug_vars[] array in debug.c')
- return { variables: [], totalCount: 0 }
- }
-
- const arrayContent = debugVarsMatch[1]
-
- const entryRegex = /\{\s*&\(([^)]+)\)\s*,\s*(\w+)\s*\}/g
-
- let match
- let index = 0
-
- while ((match = entryRegex.exec(arrayContent)) !== null) {
- const fullPath = match[1].trim()
- const type = match[2].trim()
-
- const pathComponents = parsePathComponents(fullPath)
-
- variables.push({
- name: fullPath,
- type: type,
- index: index,
- pathComponents,
- })
-
- index++
- }
-
- const varCountMatch = content.match(/#define\s+VAR_COUNT\s+(\d+)/)
- const totalCount = varCountMatch ? parseInt(varCountMatch[1], 10) : variables.length
-
- return { variables, totalCount }
-}
-
-export function matchVariableWithDebugEntry(
- pouVariableName: string,
- instanceName: string,
- debugVariables: DebugVariable[],
- variableClass?: string,
-): number | null {
- const variableNameUpper = pouVariableName.toUpperCase()
-
- // For external variables, match against the global variable (CONFIG0__VARNAME)
- // This ensures forcing an external variable affects the actual global variable
- if (variableClass === 'external') {
- const globalPath = `CONFIG0__${variableNameUpper}`
- const match = debugVariables.find((dv) => dv.name === globalPath)
- return match ? match.index : null
- }
-
- // For regular POU variables, use the instance path (RES0__INSTANCE.VARNAME)
- const instanceNameUpper = instanceName.toUpperCase()
- const expectedPath = `RES0__${instanceNameUpper}.${variableNameUpper}`
-
- const match = debugVariables.find((dv) => dv.name === expectedPath)
-
- return match ? match.index : null
-}
-
/**
- * Match a global variable with its debug entry.
- * Global variables use CONFIG0__ prefix.
+ * Re-exports from canonical debug-parser module.
+ *
+ * This file provides backwards compatibility for existing imports while
+ * delegating to the shared debug-parser module.
*/
-export function matchGlobalVariableWithDebugEntry(
- globalVariableName: string,
- debugVariables: DebugVariable[],
-): number | null {
- const variableNameUpper = globalVariableName.toUpperCase()
- const expectedPath = `CONFIG0__${variableNameUpper}`
- const match = debugVariables.find((dv) => dv.name === expectedPath)
+// Re-export everything from canonical parser
+export {
+ type DebugVariableEntry,
+ type ParsedDebugData,
+ parseDebugFile,
+ parseDebugVariables,
+} from '@root/utils/debug-parser'
- return match ? match.index : null
-}
+// Type alias for backwards compatibility
+export type DebugVariable = import('@root/utils/debug-parser').DebugVariableEntry
diff --git a/src/utils/debug-parser.ts b/src/utils/debug-parser.ts
new file mode 100644
index 000000000..944427ac5
--- /dev/null
+++ b/src/utils/debug-parser.ts
@@ -0,0 +1,69 @@
+/**
+ * Canonical debug.c parser - shared between debugger and OPC-UA.
+ *
+ * This module provides the single source of truth for parsing debug.c files
+ * generated by the IEC 61131-3 compiler.
+ */
+
+export interface DebugVariableEntry {
+ /** Full path in debug.c (e.g., "RES0__INSTANCE0.MOTOR_SPEED") */
+ name: string
+ /** IEC type enum (e.g., "INT_ENUM", "BOOL_ENUM") */
+ type: string
+ /** Index in the debug_vars array */
+ index: number
+}
+
+export interface ParsedDebugData {
+ variables: DebugVariableEntry[]
+ totalCount: number
+}
+
+/**
+ * Parse the debug.c file content to extract debug variables.
+ *
+ * @param content - The raw content of debug.c file
+ * @returns Array of parsed debug variable entries
+ */
+export function parseDebugVariables(content: string): DebugVariableEntry[] {
+ const variables: DebugVariableEntry[] = []
+
+ const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
+
+ if (!debugVarsMatch) {
+ console.warn('Could not find debug_vars[] array in debug.c')
+ return []
+ }
+
+ const arrayContent = debugVarsMatch[1]
+ const entryRegex = /\{\s*&\(([^)]+)\)\s*,\s*(\w+)\s*\}/g
+
+ let match
+ let index = 0
+
+ while ((match = entryRegex.exec(arrayContent)) !== null) {
+ variables.push({
+ name: match[1].trim(),
+ type: match[2].trim(),
+ index,
+ })
+ index++
+ }
+
+ return variables
+}
+
+/**
+ * Parse the debug.c file and return both variables and total count.
+ *
+ * @param content - The raw content of debug.c file
+ * @returns Parsed debug data with variables and total count
+ */
+export function parseDebugFile(content: string): ParsedDebugData {
+ const variables = parseDebugVariables(content)
+
+ const varCountMatch = content.match(/#define\s+VAR_COUNT\s+(\d+)/)
+ const totalCount = varCountMatch ? parseInt(varCountMatch[1], 10) : variables.length
+
+ return { variables, totalCount }
+}
diff --git a/src/utils/debug-tree-traversal.ts b/src/utils/debug-tree-traversal.ts
new file mode 100644
index 000000000..72d913765
--- /dev/null
+++ b/src/utils/debug-tree-traversal.ts
@@ -0,0 +1,372 @@
+/**
+ * Shared tree traversal utilities for debug variable resolution.
+ *
+ * This module provides a visitor-pattern based traversal for complex PLC types
+ * (function blocks, structures, arrays). Both the debugger and OPC-UA systems
+ * can use these utilities to traverse variable hierarchies consistently.
+ */
+
+import type { PLCDataType, PLCPou, PLCVariable } from '@root/types/PLC/open-plc'
+
+import type { DebugVariableEntry } from './debug-parser'
+import {
+ buildDebugPath,
+ buildGlobalDebugPath,
+ findDebugVariable,
+ findDebugVariableForField,
+} from './debug-variable-finder'
+import { findFunctionBlockVariables, findStructureVariables, normalizeTypeString } from './pou-helpers'
+
+/**
+ * Context for tree traversal containing all necessary lookup data.
+ */
+export interface TraversalContext {
+ /** Parsed debug variables from debug.c */
+ debugVariables: DebugVariableEntry[]
+ /** Project POUs for FB lookup */
+ projectPous: PLCPou[]
+ /** Project data types for struct lookup */
+ dataTypes: PLCDataType[]
+ /** Instance name from Resources configuration */
+ instanceName: string
+ /** POU name for composite key generation */
+ pouName: string
+}
+
+/**
+ * Visitor interface for handling different node types during traversal.
+ * Implement this interface to produce custom output from tree traversal.
+ */
+export interface DebugNodeVisitor {
+ /**
+ * Called for leaf nodes (base types that have a debug index).
+ */
+ visitLeaf(name: string, fullPath: string, compositeKey: string, typeName: string, debugIndex: number | undefined): T
+
+ /**
+ * Called for complex nodes (FBs, structs) with children.
+ */
+ visitComplex(name: string, fullPath: string, compositeKey: string, typeName: string, children: T[]): T
+
+ /**
+ * Called for array nodes with indexed children.
+ */
+ visitArray(
+ name: string,
+ fullPath: string,
+ compositeKey: string,
+ elementTypeName: string,
+ arrayIndices: [number, number],
+ children: T[],
+ ): T
+}
+
+/**
+ * Array data structure from PLCVariable type definition.
+ */
+interface ArrayTypeData {
+ baseType: { definition: 'base-type' | 'user-data-type'; value: string }
+ dimensions: Array<{ dimension: string }>
+}
+
+/**
+ * Check if a type is a function block (standard library or custom).
+ */
+function isFunctionBlock(typeName: string, projectPous: PLCPou[]): boolean {
+ const typeNameUpper = typeName.toUpperCase()
+
+ // Check standard library - import dynamically to avoid circular deps in main process
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const module = require('@root/renderer/data/library/standard-function-blocks') as {
+ StandardFunctionBlocks: { pous: Array<{ name: string; type: string }> }
+ }
+ const isStandard = module.StandardFunctionBlocks.pous.some(
+ (pou) => pou.name.toUpperCase() === typeNameUpper && normalizeTypeString(pou.type) === 'functionblock',
+ )
+ if (isStandard) return true
+ } catch {
+ // StandardFunctionBlocks not available in main process
+ }
+
+ // Check custom FBs
+ return projectPous.some(
+ (pou) => normalizeTypeString(pou.type) === 'functionblock' && pou.data.name.toUpperCase() === typeNameUpper,
+ )
+}
+
+/**
+ * Parse array dimension string (e.g., "1..10") into start and end indices.
+ */
+function parseArrayDimension(dimension: string): [number, number] | null {
+ const match = dimension.match(/^(\d+)\.\.(\d+)$/)
+ if (!match) return null
+ return [parseInt(match[1], 10), parseInt(match[2], 10)]
+}
+
+/**
+ * Traverse a nested node (used recursively for FB fields, struct fields, array elements).
+ */
+function traverseNestedNode(
+ name: string,
+ fullPath: string,
+ compositeKey: string,
+ typeName: string,
+ typeDefinition: 'derived' | 'user-data-type' | 'array',
+ context: TraversalContext,
+ visitor: DebugNodeVisitor,
+ arrayData?: ArrayTypeData,
+): T {
+ const { debugVariables, projectPous, dataTypes } = context
+
+ if (typeDefinition === 'derived') {
+ // Function block type
+ const fbVariables = findFunctionBlockVariables(typeName, projectPous)
+
+ if (!fbVariables) {
+ // FB definition not found - treat as leaf
+ const debugVar = findDebugVariable(debugVariables, fullPath)
+ return visitor.visitLeaf(name, fullPath, compositeKey, typeName, debugVar?.index)
+ }
+
+ const children: T[] = []
+
+ for (const fbVar of fbVariables) {
+ const childCompositeKey = `${compositeKey}.${fbVar.name}`
+
+ if (fbVar.type.definition === 'base-type') {
+ // Use fallback to try both FB-style and struct-style paths
+ // This ensures consistent behavior with OPC-UA index resolution
+ const result = findDebugVariableForField(debugVariables, fullPath, fbVar.name)
+ children.push(
+ visitor.visitLeaf(
+ fbVar.name,
+ result.matchedPath,
+ childCompositeKey,
+ fbVar.type.value.toUpperCase(),
+ result.match?.index,
+ ),
+ )
+ } else {
+ // For non-base-types, build the child path for recursive traversal
+ const childFullPath = `${fullPath}.${fbVar.name.toUpperCase()}`
+
+ if (fbVar.type.definition === 'derived' || fbVar.type.definition === 'derived-type') {
+ children.push(
+ traverseNestedNode(
+ fbVar.name,
+ childFullPath,
+ childCompositeKey,
+ fbVar.type.value,
+ 'derived',
+ context,
+ visitor,
+ ),
+ )
+ } else if (fbVar.type.definition === 'user-data-type') {
+ // Could be FB or struct - check
+ const childTypeDef = isFunctionBlock(fbVar.type.value, projectPous) ? 'derived' : 'user-data-type'
+ children.push(
+ traverseNestedNode(
+ fbVar.name,
+ childFullPath,
+ childCompositeKey,
+ fbVar.type.value,
+ childTypeDef,
+ context,
+ visitor,
+ ),
+ )
+ } else if (fbVar.type.definition === 'array' && fbVar.type.data) {
+ children.push(
+ traverseNestedNode(
+ fbVar.name,
+ childFullPath,
+ childCompositeKey,
+ 'ARRAY',
+ 'array',
+ context,
+ visitor,
+ fbVar.type.data as ArrayTypeData,
+ ),
+ )
+ }
+ }
+ }
+
+ return visitor.visitComplex(name, fullPath, compositeKey, typeName, children)
+ } else if (typeDefinition === 'user-data-type') {
+ // Structure type - fields use .value. prefix in debug path
+ const structVariables = findStructureVariables(typeName, dataTypes)
+
+ if (!structVariables) {
+ const debugVar = findDebugVariable(debugVariables, fullPath)
+ return visitor.visitLeaf(name, fullPath, compositeKey, typeName, debugVar?.index)
+ }
+
+ const children: T[] = []
+
+ for (const field of structVariables) {
+ // Structure fields use .value. prefix
+ const fieldFullPath = `${fullPath}.value.${field.name.toUpperCase()}`
+ const fieldCompositeKey = `${compositeKey}.${field.name}`
+
+ if (field.type.definition === 'base-type') {
+ const debugVar = findDebugVariable(debugVariables, fieldFullPath)
+ children.push(
+ visitor.visitLeaf(
+ field.name,
+ fieldFullPath,
+ fieldCompositeKey,
+ field.type.value.toUpperCase(),
+ debugVar?.index,
+ ),
+ )
+ } else if (field.type.definition === 'user-data-type') {
+ const childTypeDef = isFunctionBlock(field.type.value, projectPous) ? 'derived' : 'user-data-type'
+ children.push(
+ traverseNestedNode(
+ field.name,
+ fieldFullPath,
+ fieldCompositeKey,
+ field.type.value,
+ childTypeDef,
+ context,
+ visitor,
+ ),
+ )
+ } else if (field.type.definition === 'array' && field.type.data) {
+ children.push(
+ traverseNestedNode(
+ field.name,
+ fieldFullPath,
+ fieldCompositeKey,
+ 'ARRAY',
+ 'array',
+ context,
+ visitor,
+ field.type.data as ArrayTypeData,
+ ),
+ )
+ }
+ }
+
+ return visitor.visitComplex(name, fullPath, compositeKey, typeName, children)
+ } else if (typeDefinition === 'array' && arrayData) {
+ // Array type - elements use .value.table[i] pattern
+ const dimensions = arrayData.dimensions
+ if (dimensions.length === 0) {
+ return visitor.visitLeaf(name, fullPath, compositeKey, 'ARRAY', undefined)
+ }
+
+ const indices = parseArrayDimension(dimensions[0].dimension)
+ if (!indices) {
+ return visitor.visitLeaf(name, fullPath, compositeKey, 'ARRAY', undefined)
+ }
+
+ const [startIndex, endIndex] = indices
+ const arraySize = endIndex - startIndex + 1
+ const baseType = arrayData.baseType
+ const elementTypeName = baseType.definition === 'base-type' ? baseType.value.toUpperCase() : baseType.value
+
+ const children: T[] = []
+
+ for (let i = 0; i < arraySize; i++) {
+ const elementIndex = startIndex + i
+ const elementFullPath = `${fullPath}.value.table[${i}]`
+ const elementCompositeKey = `${compositeKey}[${elementIndex}]`
+
+ if (baseType.definition === 'base-type') {
+ const debugVar = findDebugVariable(debugVariables, elementFullPath)
+ children.push(
+ visitor.visitLeaf(
+ `[${elementIndex}]`,
+ elementFullPath,
+ elementCompositeKey,
+ elementTypeName,
+ debugVar?.index,
+ ),
+ )
+ } else if (baseType.definition === 'user-data-type') {
+ const childTypeDef = isFunctionBlock(baseType.value, projectPous) ? 'derived' : 'user-data-type'
+ children.push(
+ traverseNestedNode(
+ `[${elementIndex}]`,
+ elementFullPath,
+ elementCompositeKey,
+ baseType.value,
+ childTypeDef,
+ context,
+ visitor,
+ ),
+ )
+ }
+ }
+
+ return visitor.visitArray(name, fullPath, compositeKey, elementTypeName, [startIndex, endIndex], children)
+ }
+
+ // Unknown type - treat as leaf
+ const debugVar = findDebugVariable(debugVariables, fullPath)
+ return visitor.visitLeaf(name, fullPath, compositeKey, typeName, debugVar?.index)
+}
+
+/**
+ * Traverse a PLC variable and its nested structure using the visitor pattern.
+ *
+ * @param variable - The PLC variable to traverse
+ * @param context - Traversal context with lookup data
+ * @param visitor - Visitor implementation for handling nodes
+ * @returns The result produced by the visitor
+ */
+export function traverseVariable(variable: PLCVariable, context: TraversalContext, visitor: DebugNodeVisitor): T {
+ const { debugVariables, projectPous, pouName, instanceName } = context
+ const compositeKey = `${pouName}:${variable.name}`
+
+ // Build the base path
+ const fullPath =
+ variable.class === 'external' ? buildGlobalDebugPath(variable.name) : buildDebugPath(instanceName, variable.name)
+
+ if (variable.type.definition === 'base-type') {
+ const baseType = variable.type.value.toUpperCase()
+ const debugVar = findDebugVariable(debugVariables, fullPath)
+ return visitor.visitLeaf(variable.name, fullPath, compositeKey, baseType, debugVar?.index)
+ } else if (variable.type.definition === 'derived') {
+ return traverseNestedNode(variable.name, fullPath, compositeKey, variable.type.value, 'derived', context, visitor)
+ } else if (variable.type.definition === 'array') {
+ return traverseNestedNode(
+ variable.name,
+ fullPath,
+ compositeKey,
+ 'ARRAY',
+ 'array',
+ context,
+ visitor,
+ variable.type.data as ArrayTypeData,
+ )
+ } else if (variable.type.definition === 'user-data-type') {
+ // Could be FB or struct
+ const typeDef = isFunctionBlock(variable.type.value, projectPous) ? 'derived' : 'user-data-type'
+ return traverseNestedNode(variable.name, fullPath, compositeKey, variable.type.value, typeDef, context, visitor)
+ }
+
+ // Unknown type
+ return visitor.visitLeaf(variable.name, fullPath, compositeKey, 'UNKNOWN', undefined)
+}
+
+/**
+ * Traverse a nested type starting from a given path (for internal FB/struct traversal).
+ * Useful when you already have the base path and need to traverse children.
+ */
+export function traverseNestedType(
+ name: string,
+ basePath: string,
+ baseCompositeKey: string,
+ typeName: string,
+ typeDefinition: 'derived' | 'user-data-type' | 'array',
+ context: TraversalContext,
+ visitor: DebugNodeVisitor,
+ arrayData?: ArrayTypeData,
+): T {
+ return traverseNestedNode(name, basePath, baseCompositeKey, typeName, typeDefinition, context, visitor, arrayData)
+}
diff --git a/src/utils/debug-variable-finder.ts b/src/utils/debug-variable-finder.ts
index e72fdfedb..755a0468b 100644
--- a/src/utils/debug-variable-finder.ts
+++ b/src/utils/debug-variable-finder.ts
@@ -3,14 +3,10 @@
* Used by both the debugger (renderer) and OPC-UA config generator (main).
*/
-export interface DebugVariableEntry {
- /** Full path in debug.c (e.g., "RES0__INSTANCE0.MOTOR_SPEED") */
- name: string
- /** IEC type enum (e.g., "INT_ENUM", "BOOL_ENUM") */
- type: string
- /** Index in the debug_vars array */
- index: number
-}
+// Re-export types and parser from canonical debug-parser module
+export { type DebugVariableEntry, parseDebugVariables } from './debug-parser'
+
+import type { DebugVariableEntry } from './debug-parser'
export interface PLCInstanceMapping {
/** Instance name (e.g., "INSTANCE0") - this appears in debug.c */
@@ -19,38 +15,6 @@ export interface PLCInstanceMapping {
program: string
}
-/**
- * Parse the debug.c file content to extract debug variables.
- * This is the canonical parser used by both debugger and OPC-UA.
- */
-export function parseDebugVariables(content: string): DebugVariableEntry[] {
- const variables: DebugVariableEntry[] = []
-
- const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/)
-
- if (!debugVarsMatch) {
- console.warn('Could not find debug_vars[] array in debug.c')
- return []
- }
-
- const arrayContent = debugVarsMatch[1]
- const entryRegex = /\{\s*&\(([^)]+)\)\s*,\s*(\w+)\s*\}/g
-
- let match
- let index = 0
-
- while ((match = entryRegex.exec(arrayContent)) !== null) {
- variables.push({
- name: match[1].trim(),
- type: match[2].trim(),
- index,
- })
- index++
- }
-
- return variables
-}
-
/**
* Find the instance name for a program POU from the instances mapping.
*
@@ -158,6 +122,165 @@ export function findDebugVariable(
return debugVariables.find((dv) => dv.name.toUpperCase() === upperPath) || null
}
+/**
+ * Result from findDebugVariableWithFallback indicating which path style worked.
+ */
+export interface DebugVariableFallbackResult {
+ /** The matching debug variable, or null if not found */
+ match: DebugVariableEntry | null
+ /** The path that was used to find the match */
+ matchedPath: string
+ /** Whether the structure-style path (with .value.) was used */
+ usedStructureStyle: boolean
+}
+
+/**
+ * Find a debug variable using multiple path strategies.
+ *
+ * This is the CANONICAL function for resolving variable indices when the type
+ * definition is ambiguous (e.g., user-data-type could be FB or struct).
+ *
+ * The function tries paths in this order:
+ * 1. FB-style path (no .value. insertion) - for function block instances
+ * 2. Structure-style path (with .value. insertion) - for user-defined structures
+ *
+ * Both the debugger and OPC-UA systems MUST use this function to ensure
+ * consistent index resolution across the codebase.
+ *
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @param instanceName - The instance name from Resources
+ * @param fieldPath - The variable path including any nested fields (e.g., "MY_FB.FIELD" or "MY_STRUCT.FIELD")
+ * @returns Result indicating the match and which path style was used
+ */
+export function findDebugVariableWithFallback(
+ debugVariables: DebugVariableEntry[],
+ instanceName: string,
+ fieldPath: string,
+): DebugVariableFallbackResult {
+ // First try FB-style path (no .value. insertion)
+ const fbPath = buildDebugPath(instanceName, fieldPath, { isStructureField: false })
+ const fbMatch = findDebugVariable(debugVariables, fbPath)
+ if (fbMatch) {
+ return { match: fbMatch, matchedPath: fbPath, usedStructureStyle: false }
+ }
+
+ // Try structure-style path (with .value. insertion)
+ const structPath = buildDebugPath(instanceName, fieldPath, { isStructureField: true })
+ const structMatch = findDebugVariable(debugVariables, structPath)
+ if (structMatch) {
+ return { match: structMatch, matchedPath: structPath, usedStructureStyle: true }
+ }
+
+ return { match: null, matchedPath: fbPath, usedStructureStyle: false }
+}
+
+/**
+ * Find the index for a variable using fallback path strategies.
+ *
+ * This is the CANONICAL function for index lookup when the type definition
+ * is ambiguous. Use this instead of findVariableIndex when you're not certain
+ * whether the variable is an FB instance or a structure.
+ *
+ * @param instanceName - The instance name from Resources
+ * @param fieldPath - The variable path
+ * @param debugVariables - Parsed debug variables
+ * @returns The index or null if not found
+ */
+export function findVariableIndexWithFallback(
+ instanceName: string,
+ fieldPath: string,
+ debugVariables: DebugVariableEntry[],
+): number | null {
+ const result = findDebugVariableWithFallback(debugVariables, instanceName, fieldPath)
+ return result.match ? result.match.index : null
+}
+
+/**
+ * Find a debug variable for a field using fallback path strategies.
+ *
+ * This is used during tree traversal when we have a base path and need to
+ * look up a child field. It tries both FB-style (no .value.) and struct-style
+ * (with .value.) paths.
+ *
+ * @param debugVariables - Parsed debug variables from debug.c
+ * @param basePath - The already-built base path (e.g., "RES0__INSTANCE0.MY_VAR")
+ * @param fieldName - The field name to append
+ * @returns Result with match and the path that worked
+ */
+export function findDebugVariableForField(
+ debugVariables: DebugVariableEntry[],
+ basePath: string,
+ fieldName: string,
+): DebugVariableFallbackResult {
+ // Try FB-style path (no .value. insertion)
+ const fbPath = `${basePath}.${fieldName.toUpperCase()}`
+ const fbMatch = findDebugVariable(debugVariables, fbPath)
+ if (fbMatch) {
+ return { match: fbMatch, matchedPath: fbPath, usedStructureStyle: false }
+ }
+
+ // Try struct-style path (with .value. insertion)
+ const structPath = `${basePath}.value.${fieldName.toUpperCase()}`
+ const structMatch = findDebugVariable(debugVariables, structPath)
+ if (structMatch) {
+ return { match: structMatch, matchedPath: structPath, usedStructureStyle: true }
+ }
+
+ return { match: null, matchedPath: fbPath, usedStructureStyle: false }
+}
+
+/**
+ * Look up an index in a map using fallback path strategies.
+ *
+ * This is used when you have a pre-built index map (like debugVariableIndexes)
+ * and need to look up by path, trying both FB-style and struct-style paths.
+ *
+ * @param indexMap - A map from debug paths to indices
+ * @param instanceName - The instance name from Resources
+ * @param fieldPath - The variable path
+ * @returns The index or undefined if not found
+ */
+export function getIndexFromMapWithFallback(
+ indexMap: Map,
+ instanceName: string,
+ fieldPath: string,
+): number | undefined {
+ // Try FB-style path (no .value. insertion)
+ const fbPath = buildDebugPath(instanceName, fieldPath, { isStructureField: false })
+ const fbIndex = indexMap.get(fbPath)
+ if (fbIndex !== undefined) return fbIndex
+
+ // Try struct-style path (with .value. insertion)
+ const structPath = buildDebugPath(instanceName, fieldPath, { isStructureField: true })
+ return indexMap.get(structPath)
+}
+
+/**
+ * Look up an index in a map for a field, using fallback path strategies.
+ *
+ * This is used during polling when we have a base path and need to look up
+ * a child field, trying both FB-style and struct-style paths.
+ *
+ * @param indexMap - A map from debug paths to indices
+ * @param basePath - The already-built base path (e.g., "RES0__INSTANCE0.MY_VAR")
+ * @param fieldName - The field name to append
+ * @returns The index or undefined if not found
+ */
+export function getFieldIndexFromMapWithFallback(
+ indexMap: Map,
+ basePath: string,
+ fieldName: string,
+): number | undefined {
+ // Try FB-style path (no .value. insertion)
+ const fbPath = `${basePath}.${fieldName.toUpperCase()}`
+ const fbIndex = indexMap.get(fbPath)
+ if (fbIndex !== undefined) return fbIndex
+
+ // Try struct-style path (with .value. insertion)
+ const structPath = `${basePath}.value.${fieldName.toUpperCase()}`
+ return indexMap.get(structPath)
+}
+
/**
* Find the index for a program/FB variable.
*
@@ -194,3 +317,26 @@ export function findGlobalVariableIndex(variablePath: string, debugVariables: De
const match = findDebugVariable(debugVariables, debugPath)
return match ? match.index : null
}
+
+/**
+ * Build the base debug path prefix for an instance.
+ * Returns "RES0__INSTANCE_NAME" without any variable path.
+ *
+ * @param instanceName - The instance name from Resources (e.g., "INSTANCE0")
+ * @returns The base path prefix (e.g., "RES0__INSTANCE0")
+ */
+export function buildDebugPathPrefix(instanceName: string): string {
+ return `RES0__${instanceName.toUpperCase()}`
+}
+
+/**
+ * Append a child name to an existing debug path.
+ * Handles the uppercase conversion automatically.
+ *
+ * @param basePath - The existing debug path (e.g., "RES0__INSTANCE0.FB_NAME")
+ * @param childName - The child variable/field name to append
+ * @returns The extended path (e.g., "RES0__INSTANCE0.FB_NAME.CHILD_NAME")
+ */
+export function appendToDebugPath(basePath: string, childName: string): string {
+ return `${basePath}.${childName.toUpperCase()}`
+}
diff --git a/src/utils/opcua/resolve-indices.ts b/src/utils/opcua/resolve-indices.ts
index ac7315cf8..61e994346 100644
--- a/src/utils/opcua/resolve-indices.ts
+++ b/src/utils/opcua/resolve-indices.ts
@@ -4,6 +4,7 @@ import {
buildGlobalDebugPath,
type DebugVariableEntry,
findDebugVariable,
+ findDebugVariableWithFallback,
findInstanceName,
type PLCInstanceMapping,
} from '@root/utils/debug-variable-finder'
@@ -53,33 +54,6 @@ const toInstanceMapping = (instances: PLCInstanceInfo[]): PLCInstanceMapping[] =
const toDebugEntries = (debugVariables: DebugVariable[]): DebugVariableEntry[] =>
debugVariables.map((dv) => ({ name: dv.name, type: dv.type, index: dv.index }))
-/**
- * Try to find a debug variable using multiple path strategies.
- * First tries FB-style paths (no .value.), then structure-style paths (with .value.).
- * Returns the match and which style worked.
- */
-const findWithFallback = (
- debugEntries: DebugVariableEntry[],
- instanceName: string,
- fullFieldPath: string,
-): { match: DebugVariableEntry | null; usedStructureStyle: boolean } => {
- // First try FB-style path (no .value. insertion)
- const fbPath = buildDebugPath(instanceName, fullFieldPath, { isStructureField: false })
- const fbMatch = findDebugVariable(debugEntries, fbPath)
- if (fbMatch) {
- return { match: fbMatch, usedStructureStyle: false }
- }
-
- // Try structure-style path (with .value. insertion)
- const structPath = buildDebugPath(instanceName, fullFieldPath, { isStructureField: true })
- const structMatch = findDebugVariable(debugEntries, structPath)
- if (structMatch) {
- return { match: structMatch, usedStructureStyle: true }
- }
-
- return { match: null, usedStructureStyle: false }
-}
-
/**
* Resolve the index for a simple variable node.
*
@@ -127,24 +101,21 @@ export const resolveVariableIndex = (
)
}
- // Build the debug path - simple path for variables and FB instances (no .value.)
- const debugPath = buildDebugPath(instanceName, node.variablePath, {
- isStructureField: false,
- isArrayElement: false,
- })
-
- const match = findDebugVariable(debugEntries, debugPath)
+ // Use shared fallback function - tries FB-style first, then struct-style
+ const result = findDebugVariableWithFallback(debugEntries, instanceName, node.variablePath)
- if (match) {
- return match.index
+ if (result.match) {
+ return result.match.index
}
throw new OpcUaConfigError(
`${node.pouName}:${node.variablePath}`,
- debugPath,
+ result.matchedPath,
`Cannot resolve OPC-UA variable index.\n` +
` Variable: ${node.pouName}:${node.variablePath}\n` +
- ` Expected debug path: ${debugPath}\n` +
+ ` Tried paths:\n` +
+ ` - FB style: ${buildDebugPath(instanceName, node.variablePath, { isStructureField: false })}\n` +
+ ` - Struct style: ${buildDebugPath(instanceName, node.variablePath, { isStructureField: true })}\n` +
` This may happen if:\n` +
` - The PLC program was modified after configuring OPC-UA\n` +
` - The variable name is incorrect\n` +
@@ -218,12 +189,10 @@ export const resolveStructureIndices = (
debugPath = buildGlobalDebugPath(fullFieldPath)
match = findDebugVariable(debugEntries, debugPath)
} else {
- // Try both FB-style (no .value.) and structure-style (with .value.) paths
- const result = findWithFallback(debugEntries, instanceName!, fullFieldPath)
+ // Use shared fallback function - tries FB-style first, then struct-style
+ const result = findDebugVariableWithFallback(debugEntries, instanceName!, fullFieldPath)
match = result.match
- debugPath = result.usedStructureStyle
- ? buildDebugPath(instanceName!, fullFieldPath, { isStructureField: true })
- : buildDebugPath(instanceName!, fullFieldPath, { isStructureField: false })
+ debugPath = result.matchedPath
}
if (!match) {
From 491e46ef1989fce3cda3f4a14aa1254668101f1d Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 19 Jan 2026 16:54:27 -0500
Subject: [PATCH 12/21] fix: Add missing dependencies to useImperativeHandle
Move closeModal and submitAutocompletion declarations before
useImperativeHandle and add them to the dependency array to ensure
the triggerSubmit closure always captures the latest function references.
Addresses Copilot review feedback on PR #545.
Co-Authored-By: Claude Opus 4.5
---
.../graphical-editor/autocomplete/index.tsx | 24 +++++++++----------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx
index b8c83f32a..78196e149 100644
--- a/src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx
+++ b/src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx
@@ -84,6 +84,17 @@ export const GraphicalEditorAutocomplete = forwardRef variable !== undefined)
}, [variables, searchValue])
+ const closeModal = () => {
+ setAutocompleteFocus(false)
+ setSelectedVariable({ positionInArray: -1, variable: { id: '', name: '' } })
+ if (setIsOpen) setIsOpen(false)
+ }
+
+ const submitAutocompletion = ({ variable }: { variable: { id: string; name: string } }) => {
+ closeModal()
+ submit({ variable })
+ }
+
// @ts-expect-error - not all properties are used
useImperativeHandle(ref, () => {
return {
@@ -110,7 +121,7 @@ export const GraphicalEditorAutocomplete = forwardRef {
switch (keyDown) {
@@ -176,17 +187,6 @@ export const GraphicalEditorAutocomplete = forwardRef {
- setAutocompleteFocus(false)
- setSelectedVariable({ positionInArray: -1, variable: { id: '', name: '' } })
- if (setIsOpen) setIsOpen(false)
- }
-
- const submitAutocompletion = ({ variable }: { variable: { id: string; name: string } }) => {
- closeModal()
- submit({ variable })
- }
-
return (
From 9a4e0ba429b548bc46a7e2cc4b38414d84fb5d75 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 19 Jan 2026 18:44:50 -0500
Subject: [PATCH 13/21] feat: Add support for nested structures in OPC-UA
configuration
Add recursive field support to allow nested FB instances and structs
to appear as hierarchical OPC-UA Object nodes instead of flat fields.
Changes:
- Update OpcUaFieldConfig schema to support recursive nested fields
- Update ResolvedField interface to support nested fields with null index
- Add resolveFieldRecursively helper for hierarchical field resolution
- Update resolveStructure to convert nested fields to runtime format
- Update tests with required datatype field
Co-Authored-By: Claude Opus 4.5
---
.../components/variable-config-modal.tsx | 81 +++++-------
src/types/PLC/open-plc.ts | 28 +++-
.../__tests__/generate-opcua-config.test.ts | 12 ++
src/utils/opcua/generate-opcua-config.ts | 41 ++++--
src/utils/opcua/resolve-indices.ts | 125 +++++++++++-------
src/utils/opcua/types.ts | 8 +-
6 files changed, 180 insertions(+), 115 deletions(-)
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index 6020570b2..c23e86127 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -93,65 +93,44 @@ const getNodeType = (node: VariableTreeNode): 'variable' | 'structure' | 'array'
}
/**
- * Recursively collect all base-type leaf variables from a tree node.
- * Returns an array of { relativePath, displayName, variableType } for each leaf.
+ * Generate default field configs from a structure/FB/array node.
+ * Generates hierarchical nested field configs to preserve structure in OPC-UA.
+ * Complex types (FBs, nested structs) become fields with nested fields array.
*/
-const collectLeafVariables = (
+const generateDefaultFieldConfigs = (
node: VariableTreeNode,
- parentPath: string = '',
-): Array<{ relativePath: string; displayName: string; variableType: string }> => {
- const leaves: Array<{ relativePath: string; displayName: string; variableType: string }> = []
-
+ parentPermissions: OpcUaPermissions,
+): OpcUaFieldConfig[] => {
if (!node.children || node.children.length === 0) {
- // This node itself is a leaf (base type variable)
- if (node.type === 'variable' && node.isSelectable) {
- leaves.push({
- relativePath: parentPath || node.name,
- displayName: node.name,
- variableType: node.variableType || 'unknown',
- })
- }
- return leaves
+ return []
}
- // Recurse into children
- for (const child of node.children) {
- const childPath = parentPath ? `${parentPath}.${child.name}` : child.name
+ return node.children.map((child) => {
+ // Check if this is a leaf variable (base type) or a complex type
+ const isLeaf = child.type === 'variable' && (!child.children || child.children.length === 0)
- if (child.type === 'variable' && child.isSelectable) {
- // This is a base-type leaf
- leaves.push({
- relativePath: childPath,
+ if (isLeaf) {
+ // Leaf field - no nested fields
+ return {
+ fieldPath: child.name,
displayName: child.name,
- variableType: child.variableType || 'unknown',
- })
- } else if (child.children && child.children.length > 0) {
- // This is a complex type (structure, FB, or array) - recurse
- const childLeaves = collectLeafVariables(child, childPath)
- leaves.push(...childLeaves)
+ datatype: child.variableType || 'UNKNOWN',
+ initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
+ permissions: { ...parentPermissions },
+ }
+ } else {
+ // Complex type (FB, struct, array) - generate nested fields recursively
+ const nestedFields = generateDefaultFieldConfigs(child, parentPermissions)
+ return {
+ fieldPath: child.name,
+ displayName: child.name,
+ datatype: child.variableType || 'UNKNOWN',
+ initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
+ permissions: { ...parentPermissions },
+ fields: nestedFields.length > 0 ? nestedFields : undefined,
+ }
}
- }
-
- return leaves
-}
-
-/**
- * Generate default field configs from a structure/FB/array node.
- * Recursively expands to all base-type leaf variables.
- * The fieldPath is the full relative path from the parent (e.g., "FB1.Q" or "STRUCT.FIELD").
- */
-const generateDefaultFieldConfigs = (
- node: VariableTreeNode,
- parentPermissions: OpcUaPermissions,
-): OpcUaFieldConfig[] => {
- const leaves = collectLeafVariables(node)
-
- return leaves.map((leaf) => ({
- fieldPath: leaf.relativePath,
- displayName: leaf.displayName,
- initialValue: getDefaultInitialValue(leaf.variableType),
- permissions: { ...parentPermissions },
- }))
+ })
}
/**
diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts
index bdfb4cf32..b94ea456e 100644
--- a/src/types/PLC/open-plc.ts
+++ b/src/types/PLC/open-plc.ts
@@ -463,13 +463,27 @@ const OpcUaPermissionsSchema = z.object({
type OpcUaPermissions = z.infer
// OPC-UA Field Configuration Schema (for structure fields)
-const OpcUaFieldConfigSchema = z.object({
- fieldPath: z.string(),
- displayName: z.string(),
- initialValue: z.union([z.boolean(), z.number(), z.string()]),
- permissions: OpcUaPermissionsSchema,
-})
-type OpcUaFieldConfig = z.infer
+// Uses z.lazy() to support recursive nested fields for complex types (FBs, structs)
+interface OpcUaFieldConfig {
+ fieldPath: string
+ displayName: string
+ /** Data type of the field. Optional for backward compatibility with existing projects. */
+ datatype?: string
+ initialValue: boolean | number | string
+ permissions: OpcUaPermissions
+ /** Nested fields for complex types (FB instances, nested structs). Undefined for leaf fields. */
+ fields?: OpcUaFieldConfig[]
+}
+const OpcUaFieldConfigSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ fieldPath: z.string(),
+ displayName: z.string(),
+ datatype: z.string().optional(), // Optional for backward compatibility
+ initialValue: z.union([z.boolean(), z.number(), z.string()]),
+ permissions: OpcUaPermissionsSchema,
+ fields: z.array(OpcUaFieldConfigSchema).optional(),
+ }),
+)
// OPC-UA Node Configuration Schema (variable/structure/array)
const OpcUaNodeConfigSchema = z.object({
diff --git a/src/utils/opcua/__tests__/generate-opcua-config.test.ts b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
index 20ee626d5..8050c4f2a 100644
--- a/src/utils/opcua/__tests__/generate-opcua-config.test.ts
+++ b/src/utils/opcua/__tests__/generate-opcua-config.test.ts
@@ -235,12 +235,14 @@ describe('resolveStructureIndices', () => {
{
fieldPath: 'TEMP',
displayName: 'Temperature',
+ datatype: 'REAL',
initialValue: 0.0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: 'PRESSURE',
displayName: 'Pressure',
+ datatype: 'REAL',
initialValue: 0.0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
@@ -273,12 +275,14 @@ describe('resolveStructureIndices', () => {
{
fieldPath: 'ET',
displayName: 'Elapsed Time',
+ datatype: 'TIME',
initialValue: 0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: 'Q',
displayName: 'Output',
+ datatype: 'BOOL',
initialValue: false,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
@@ -310,12 +314,14 @@ describe('resolveStructureIndices', () => {
{
fieldPath: 'ET',
displayName: 'Elapsed Time',
+ datatype: 'TIME',
initialValue: 0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: 'Q',
displayName: 'Output',
+ datatype: 'BOOL',
initialValue: false,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
@@ -346,12 +352,14 @@ describe('resolveStructureIndices', () => {
{
fieldPath: 'INNER.VALUE1',
displayName: 'Inner Value 1',
+ datatype: 'INT',
initialValue: 0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: 'INNER.VALUE2',
displayName: 'Inner Value 2',
+ datatype: 'REAL',
initialValue: 0.0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
@@ -384,24 +392,28 @@ describe('resolveStructureIndices', () => {
{
fieldPath: '[0].ET',
displayName: '[0] Elapsed Time',
+ datatype: 'TIME',
initialValue: 0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: '[0].Q',
displayName: '[0] Output',
+ datatype: 'BOOL',
initialValue: false,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: '[1].ET',
displayName: '[1] Elapsed Time',
+ datatype: 'TIME',
initialValue: 0,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
{
fieldPath: '[1].Q',
displayName: '[1] Output',
+ datatype: 'BOOL',
initialValue: false,
permissions: { viewer: 'r' as const, operator: 'r' as const, engineer: 'rw' as const },
},
diff --git a/src/utils/opcua/generate-opcua-config.ts b/src/utils/opcua/generate-opcua-config.ts
index d0b0553ff..11dace858 100644
--- a/src/utils/opcua/generate-opcua-config.ts
+++ b/src/utils/opcua/generate-opcua-config.ts
@@ -74,8 +74,9 @@ interface RuntimeStructureField {
name: string
datatype: string
initial_value: boolean | number | string
- index: number
+ index: number | null // null for complex types that have nested fields
permissions: RuntimeVariablePermissions
+ fields?: RuntimeStructureField[] // Nested fields for complex types (FBs, structs)
}
interface RuntimeStructure {
@@ -206,7 +207,35 @@ const resolveVariable = (
}
/**
- * Resolve a structure and build runtime format with field indices
+ * Convert a resolved field (with possible nested fields) to runtime format recursively.
+ */
+const convertResolvedFieldToRuntime = (field: {
+ name: string
+ datatype: string
+ initialValue: boolean | number | string
+ index: number | null
+ permissions: { viewer: 'r' | 'w' | 'rw'; operator: 'r' | 'w' | 'rw'; engineer: 'r' | 'w' | 'rw' }
+ fields?: (typeof field)[]
+}): RuntimeStructureField => {
+ const runtimeField: RuntimeStructureField = {
+ name: field.name,
+ datatype: field.datatype,
+ initial_value: field.initialValue,
+ index: field.index,
+ permissions: convertPermissions(field.permissions),
+ }
+
+ // Add nested fields if present (for complex types like FB instances)
+ if (field.fields && field.fields.length > 0) {
+ runtimeField.fields = field.fields.map(convertResolvedFieldToRuntime)
+ }
+
+ return runtimeField
+}
+
+/**
+ * Resolve a structure and build runtime format with field indices.
+ * Supports nested fields for complex types (FBs within FBs, structs within structs).
*/
const resolveStructure = (
node: OpcUaNodeConfig,
@@ -220,13 +249,7 @@ const resolveStructure = (
browse_name: node.browseName,
display_name: node.displayName,
description: node.description,
- fields: resolvedFields.map((field) => ({
- name: field.name,
- datatype: field.datatype,
- initial_value: field.initialValue,
- index: field.index,
- permissions: convertPermissions(field.permissions),
- })),
+ fields: resolvedFields.map(convertResolvedFieldToRuntime),
}
}
diff --git a/src/utils/opcua/resolve-indices.ts b/src/utils/opcua/resolve-indices.ts
index 61e994346..7d249aa41 100644
--- a/src/utils/opcua/resolve-indices.ts
+++ b/src/utils/opcua/resolve-indices.ts
@@ -1,4 +1,4 @@
-import type { OpcUaNodeConfig } from '@root/types/PLC/open-plc'
+import type { OpcUaFieldConfig, OpcUaNodeConfig } from '@root/types/PLC/open-plc'
import {
buildDebugPath,
buildGlobalDebugPath,
@@ -124,13 +124,89 @@ export const resolveVariableIndex = (
)
}
+/**
+ * Resolve a single field, recursively handling nested fields for complex types.
+ *
+ * @param field - The field configuration
+ * @param parentPath - The full path to the parent (e.g., "IRRIGATION_MAIN_CONTROLLER0")
+ * @param pouName - The POU name (e.g., "MAIN" or "GVL")
+ * @param debugEntries - Converted debug variable entries
+ * @param instanceName - Instance name (null for global variables)
+ * @returns Resolved field with index and possibly nested fields
+ */
+const resolveFieldRecursively = (
+ field: OpcUaFieldConfig,
+ parentPath: string,
+ pouName: string,
+ debugEntries: DebugVariableEntry[],
+ instanceName: string | null,
+): ResolvedField => {
+ // Build the full path for this field
+ const fullFieldPath = `${parentPath}.${field.fieldPath}`
+
+ // If this field has nested fields, it's a complex type (FB or struct)
+ if (field.fields && field.fields.length > 0) {
+ // Recursively resolve nested fields
+ const nestedFields: ResolvedField[] = []
+ for (const nestedField of field.fields) {
+ nestedFields.push(resolveFieldRecursively(nestedField, fullFieldPath, pouName, debugEntries, instanceName))
+ }
+
+ // Complex types have null index - only leaf fields have indices
+ return {
+ name: field.fieldPath,
+ datatype: field.datatype || 'UNKNOWN',
+ initialValue: field.initialValue,
+ index: null,
+ permissions: field.permissions,
+ fields: nestedFields,
+ }
+ }
+
+ // This is a leaf field - resolve its index
+ let match: DebugVariableEntry | null = null
+ let debugPath: string = ''
+
+ if (pouName === 'GVL' || pouName === 'CONFIG') {
+ // Global structure/FB field
+ debugPath = buildGlobalDebugPath(fullFieldPath)
+ match = findDebugVariable(debugEntries, debugPath)
+ } else {
+ // Use shared fallback function - tries FB-style first, then struct-style
+ const result = findDebugVariableWithFallback(debugEntries, instanceName!, fullFieldPath)
+ match = result.match
+ debugPath = result.matchedPath
+ }
+
+ if (!match) {
+ throw new OpcUaConfigError(
+ `${pouName}:${fullFieldPath}`,
+ debugPath,
+ `Cannot resolve OPC-UA structure/FB field index.\n` +
+ ` Field path: ${fullFieldPath}\n` +
+ ` Tried paths:\n` +
+ ` - FB style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: false })}\n` +
+ ` - Struct style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: true })}`,
+ )
+ }
+
+ return {
+ name: field.fieldPath,
+ datatype: match.type ? debugTypeToIecType(match.type) : field.datatype || 'UNKNOWN',
+ initialValue: field.initialValue,
+ index: match.index,
+ permissions: field.permissions,
+ }
+}
+
/**
* Resolve indices for all fields in a structure or function block instance.
+ * Supports nested fields for complex types (FBs within FBs, structs within structs).
*
* @param node - The OPC-UA node configuration for the structure/FB
* @param debugVariables - Parsed debug variables from debug.c
* @param instances - Array of PLC instances from Resources configuration
- * @returns Array of resolved fields with indices
+ * @returns Array of resolved fields with indices (may be hierarchical)
* @throws OpcUaConfigError if any field cannot be resolved
*/
export const resolveStructureIndices = (
@@ -171,50 +247,7 @@ export const resolveStructureIndices = (
const resolvedFields: ResolvedField[] = []
for (const field of node.fields) {
- // Build the full path for this field
- // Handle case where field.fieldPath already contains the parent variablePath (legacy configs)
- let fullFieldPath: string
- if (field.fieldPath.toUpperCase().startsWith(node.variablePath.toUpperCase() + '.')) {
- // Field path already includes parent, use it directly
- fullFieldPath = field.fieldPath
- } else {
- fullFieldPath = `${node.variablePath}.${field.fieldPath}`
- }
-
- let match: DebugVariableEntry | null = null
- let debugPath: string = ''
-
- if (node.pouName === 'GVL' || node.pouName === 'CONFIG') {
- // Global structure/FB field
- debugPath = buildGlobalDebugPath(fullFieldPath)
- match = findDebugVariable(debugEntries, debugPath)
- } else {
- // Use shared fallback function - tries FB-style first, then struct-style
- const result = findDebugVariableWithFallback(debugEntries, instanceName!, fullFieldPath)
- match = result.match
- debugPath = result.matchedPath
- }
-
- if (!match) {
- throw new OpcUaConfigError(
- `${node.pouName}:${fullFieldPath}`,
- debugPath,
- `Cannot resolve OPC-UA structure/FB field index.\n` +
- ` Variable: ${node.pouName}:${node.variablePath}\n` +
- ` Field: ${field.fieldPath}\n` +
- ` Tried paths:\n` +
- ` - FB style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: false })}\n` +
- ` - Struct style: ${buildDebugPath(instanceName!, fullFieldPath, { isStructureField: true })}`,
- )
- }
-
- resolvedFields.push({
- name: field.fieldPath,
- datatype: match.type ? debugTypeToIecType(match.type) : node.variableType,
- initialValue: field.initialValue,
- index: match.index,
- permissions: field.permissions,
- })
+ resolvedFields.push(resolveFieldRecursively(field, node.variablePath, node.pouName, debugEntries, instanceName))
}
return resolvedFields
diff --git a/src/utils/opcua/types.ts b/src/utils/opcua/types.ts
index dea62c908..d3ce144e4 100644
--- a/src/utils/opcua/types.ts
+++ b/src/utils/opcua/types.ts
@@ -29,16 +29,20 @@ export interface DebugVariable {
}
/**
- * Resolved field information for structures
+ * Resolved field information for structures.
+ * Supports nested fields for complex types (FBs, structs within structs).
*/
export interface ResolvedField {
name: string
datatype: string
initialValue: boolean | number | string
- index: number
+ /** Index in debug_vars array. Null for complex types that have nested fields. */
+ index: number | null
permissions: {
viewer: 'r' | 'w' | 'rw'
operator: 'r' | 'w' | 'rw'
engineer: 'r' | 'w' | 'rw'
}
+ /** Nested fields for complex types (FB instances, nested structs) */
+ fields?: ResolvedField[]
}
From 9b41d73c2076f578b8210d8ae5ff9dc1a14803c7 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 19 Jan 2026 21:48:41 -0500
Subject: [PATCH 14/21] docs: Address Copilot review comments for nested
structures
- Clarify that fields property can be undefined or empty for leaf fields
- Add note explaining path construction is for new hierarchical configs only
Co-Authored-By: Claude Opus 4.5
---
src/types/PLC/open-plc.ts | 2 +-
src/utils/opcua/resolve-indices.ts | 5 ++++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts
index b94ea456e..4681e3c4f 100644
--- a/src/types/PLC/open-plc.ts
+++ b/src/types/PLC/open-plc.ts
@@ -471,7 +471,7 @@ interface OpcUaFieldConfig {
datatype?: string
initialValue: boolean | number | string
permissions: OpcUaPermissions
- /** Nested fields for complex types (FB instances, nested structs). Undefined for leaf fields. */
+ /** Nested fields for complex types (FB instances, nested structs). Undefined or empty for leaf fields. */
fields?: OpcUaFieldConfig[]
}
const OpcUaFieldConfigSchema: z.ZodType = z.lazy(() =>
diff --git a/src/utils/opcua/resolve-indices.ts b/src/utils/opcua/resolve-indices.ts
index 7d249aa41..92fddb206 100644
--- a/src/utils/opcua/resolve-indices.ts
+++ b/src/utils/opcua/resolve-indices.ts
@@ -141,7 +141,10 @@ const resolveFieldRecursively = (
debugEntries: DebugVariableEntry[],
instanceName: string | null,
): ResolvedField => {
- // Build the full path for this field
+ // Build the full path for this field.
+ // Note: This code path only processes new hierarchical configs where field.fieldPath
+ // contains just the field name (e.g., "TON0", "IN"). Legacy flat configs don't have
+ // nested `fields` arrays, so they won't reach this recursive function.
const fullFieldPath = `${parentPath}.${field.fieldPath}`
// If this field has nested fields, it's a complex type (FB or struct)
From db54841a8bda5231a9710bc0bb167555a7edf856 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?=
Date: Tue, 20 Jan 2026 16:44:45 -0300
Subject: [PATCH 15/21] feat: Replace SHA-256 with bcrypt for OPC-UA user
password hashing
Use bcryptjs library with cost factor 10 for secure password hashing
instead of SHA-256 via Web Crypto API. Bcrypt provides better protection
against brute-force attacks through its adaptive cost factor.
Co-Authored-By: Claude Opus 4.5
---
package-lock.json | 9 +++++++++
package.json | 1 +
.../server/opcua-server/components/user-modal.tsx | 12 +++++-------
3 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 75e039be1..fbff93255 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,7 @@
"@tanstack/react-table": "^8.10.7",
"@xyflow/react": "^12.0.1",
"auto-zustand-selectors-hook": "^2.0.0",
+ "bcryptjs": "^3.0.3",
"clsx": "^2.0.0",
"cva": "npm:class-variance-authority@^0.7.0",
"dompurify": "^3.2.4",
@@ -11661,6 +11662,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
diff --git a/package.json b/package.json
index 4f38500ad..135309810 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@tanstack/react-table": "^8.10.7",
"@xyflow/react": "^12.0.1",
"auto-zustand-selectors-hook": "^2.0.0",
+ "bcryptjs": "^3.0.3",
"clsx": "^2.0.0",
"cva": "npm:class-variance-authority@^0.7.0",
"dompurify": "^3.2.4",
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
index b7ed199de..0b1666a43 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer
import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
import type { OpcUaTrustedCertificate, OpcUaUser } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
+import * as bcrypt from 'bcryptjs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
@@ -29,14 +30,11 @@ const ROLE_OPTIONS: { value: UserRole; label: string; description: string }[] =
{ value: 'engineer', label: 'Engineer', description: 'Full administrative access' },
]
-// Simple password hashing using SHA-256 (Web Crypto API)
-// Note: For production, consider using bcrypt or similar
+// Password hashing using bcrypt with cost factor 10
+const BCRYPT_SALT_ROUNDS = 10
+
const hashPassword = async (password: string): Promise => {
- const encoder = new TextEncoder()
- const data = encoder.encode(password)
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
- const hashArray = Array.from(new Uint8Array(hashBuffer))
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
+ return bcrypt.hash(password, BCRYPT_SALT_ROUNDS)
}
export const UserModal = ({
From 6c08ce62292598154c3a2feac8d041b816eff856 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Fri, 23 Jan 2026 10:34:16 -0500
Subject: [PATCH 16/21] fix: Use PBKDF2 instead of bcrypt for OPC-UA password
hashing
Replace bcrypt with PBKDF2-HMAC-SHA256 for password hashing in OPC-UA
user configuration. This fixes authentication failures on Windows/MSYS2
where Python's bcrypt module cannot be built.
Changes:
- Replace bcryptjs with Web Crypto API PBKDF2 implementation
- Use format compatible with OpenPLC Runtime: pbkdf2:sha256:600000$salt$hash
- Remove bcryptjs dependency from package.json
The runtime already supports both bcrypt and PBKDF2 hash verification,
so this change maintains backward compatibility on Linux/macOS while
enabling Windows/MSYS2 support.
Co-Authored-By: Claude Opus 4.5
---
package.json | 1 -
.../opcua-server/components/user-modal.tsx | 42 ++++++++++++++++---
2 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index 135309810..4f38500ad 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,6 @@
"@tanstack/react-table": "^8.10.7",
"@xyflow/react": "^12.0.1",
"auto-zustand-selectors-hook": "^2.0.0",
- "bcryptjs": "^3.0.3",
"clsx": "^2.0.0",
"cva": "npm:class-variance-authority@^0.7.0",
"dompurify": "^3.2.4",
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
index 0b1666a43..bdf9025c2 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -4,7 +4,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer
import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal'
import type { OpcUaTrustedCertificate, OpcUaUser } from '@root/types/PLC/open-plc'
import { cn } from '@root/utils'
-import * as bcrypt from 'bcryptjs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
@@ -30,11 +29,44 @@ const ROLE_OPTIONS: { value: UserRole; label: string; description: string }[] =
{ value: 'engineer', label: 'Engineer', description: 'Full administrative access' },
]
-// Password hashing using bcrypt with cost factor 10
-const BCRYPT_SALT_ROUNDS = 10
-
+// PBKDF2 configuration - matches OpenPLC Runtime's expected format
+// Using PBKDF2 instead of bcrypt for cross-platform compatibility (MSYS2/Windows)
+const PBKDF2_ITERATIONS = 600000 // OWASP recommendation for SHA-256
+const PBKDF2_SALT_LENGTH = 16
+const PBKDF2_HASH_LENGTH = 32 // SHA-256 output length
+
+/**
+ * Hash a password using PBKDF2-HMAC-SHA256.
+ * Format: pbkdf2:sha256:iterations$base64_salt$base64_hash
+ * This format is compatible with OpenPLC Runtime on all platforms.
+ */
const hashPassword = async (password: string): Promise => {
- return bcrypt.hash(password, BCRYPT_SALT_ROUNDS)
+ // Generate random salt
+ const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_LENGTH))
+
+ // Import password as key material
+ const passwordKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [
+ 'deriveBits',
+ ])
+
+ // Derive hash using PBKDF2
+ const hashBuffer = await crypto.subtle.deriveBits(
+ {
+ name: 'PBKDF2',
+ salt: salt,
+ iterations: PBKDF2_ITERATIONS,
+ hash: 'SHA-256',
+ },
+ passwordKey,
+ PBKDF2_HASH_LENGTH * 8, // bits
+ )
+
+ // Convert to base64
+ const saltB64 = btoa(String.fromCharCode(...salt))
+ const hashB64 = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
+
+ // Return in format expected by OpenPLC Runtime
+ return `pbkdf2:sha256:${PBKDF2_ITERATIONS}$${saltB64}$${hashB64}`
}
export const UserModal = ({
From bb2c2f15feba30716c2b7c839201fdf3c0a6bdc2 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 26 Jan 2026 17:12:40 -0500
Subject: [PATCH 17/21] fix: Address code review issues for OPC-UA
implementation
- Fix CRLF handling in PEM certificate parsing for Windows compatibility
- Fix descendant cleanup when removing complex type nodes from address space
- Add infinite recursion protection for circular type references in findLeafVariables
- Fix nested field permission updates (applyPermissionsToAllFields and updateFieldPermission now handle nested fields recursively)
- Remove no-op replace statement in generateNodeId
- Ensure at least one auth method when switching from None to secure policy
- Support negative array indices in parseArrayDimension (IEC 61131-3 compliance)
Co-Authored-By: Claude Opus 4.5
---
.../components/address-space-tab.tsx | 28 ++++++--
.../components/certificate-modal.tsx | 4 +-
.../components/security-profile-modal.tsx | 7 +-
.../components/variable-config-modal.tsx | 67 +++++++++++++------
src/utils/debug-tree-traversal.ts | 5 +-
src/utils/pou-helpers.ts | 14 +++-
6 files changed, 91 insertions(+), 34 deletions(-)
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
index 2f6b3e8fa..a9683a853 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
@@ -158,15 +158,31 @@ export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressS
const node = config.addressSpace.nodes.find((n) => n.id === nodeId)
if (node) {
removeOpcUaNode(serverName, nodeId)
- setSelectedVariableIds((prev) => {
- const next = new Set(prev)
- next.delete(`${node.pouName}-${node.variablePath}`)
- return next
- })
+
+ // Find the tree node to check if it's a complex type with descendants
+ const nodeKey = `${node.pouName}-${node.variablePath}`
+ const treeNode = findTreeNodeById(projectVariables, nodeKey)
+
+ if (treeNode && isComplexType(treeNode)) {
+ // For complex types, also remove all descendant IDs from selection
+ const descendantIds = getSelectableDescendantIds(treeNode)
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(nodeKey)
+ descendantIds.forEach((id) => next.delete(id))
+ return next
+ })
+ } else {
+ setSelectedVariableIds((prev) => {
+ const next = new Set(prev)
+ next.delete(nodeKey)
+ return next
+ })
+ }
onConfigChange()
}
},
- [config.addressSpace.nodes, serverName, removeOpcUaNode, onConfigChange],
+ [config.addressSpace.nodes, serverName, removeOpcUaNode, onConfigChange, projectVariables],
)
return (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
index 12eb5f252..fc2b210ee 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
@@ -31,10 +31,10 @@ const parsePemCertificate = (pem: string): { valid: boolean; subject?: string; e
return { valid: false, error: 'Certificate must end with "-----END CERTIFICATE-----"' }
}
- // Extract the base64 content
+ // Extract the base64 content, normalizing CRLF line endings from Windows
const lines = trimmed.split('\n')
const contentLines = lines.slice(1, -1)
- const base64Content = contentLines.join('').trim()
+ const base64Content = contentLines.join('').replace(/\r/g, '').trim()
if (base64Content.length < 100) {
return { valid: false, error: 'Certificate content appears to be too short' }
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
index 361ebc89d..7440b3064 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
@@ -126,7 +126,12 @@ export const SecurityProfileModal = ({
})
} else {
// If switching from None to a real policy, remove Anonymous and set a valid mode
- setAuthMethods((prev) => prev.filter((m) => m !== 'Anonymous'))
+ setAuthMethods((prev) => {
+ const filtered = prev.filter((m) => m !== 'Anonymous')
+ // Ensure at least one auth method remains - default to Username if empty
+ if (filtered.length === 0) return ['Username']
+ return filtered
+ })
setSecurityMode((prev) => (prev === 'None' ? 'SignAndEncrypt' : prev))
}
}, [])
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index c23e86127..0e2e4ee73 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -30,7 +30,7 @@ type PermissionLevel = 'r' | 'w' | 'rw'
* Generate a default node ID based on the variable path
*/
const generateNodeId = (pouName: string, variablePath: string): string => {
- const cleanPath = variablePath.replace(/\./g, '.').replace(/\[/g, '_').replace(/\]/g, '')
+ const cleanPath = variablePath.replace(/\[/g, '_').replace(/\]/g, '')
return `PLC.${pouName}.${cleanPath}`
}
@@ -277,30 +277,55 @@ export const VariableConfigModal = ({
const isValid = validationErrors.length === 0
- // Apply parent permissions to all fields
- const applyPermissionsToAllFields = useCallback(() => {
- setFieldConfigs((prev) =>
- prev.map((field) => ({
+ // Helper to recursively apply permissions to all fields including nested ones
+ const applyPermissionsRecursively = useCallback(
+ (fields: OpcUaFieldConfig[], permissions: OpcUaPermissions): OpcUaFieldConfig[] => {
+ return fields.map((field) => ({
...field,
- permissions: {
- viewer: viewerPerm,
- operator: operatorPerm,
- engineer: engineerPerm,
- },
- })),
- )
- }, [viewerPerm, operatorPerm, engineerPerm])
-
- // Update a single field's permission
+ permissions: { ...permissions },
+ fields: field.fields ? applyPermissionsRecursively(field.fields, permissions) : undefined,
+ }))
+ },
+ [],
+ )
+
+ // Apply parent permissions to all fields (including nested)
+ const applyPermissionsToAllFields = useCallback(() => {
+ const permissions: OpcUaPermissions = {
+ viewer: viewerPerm,
+ operator: operatorPerm,
+ engineer: engineerPerm,
+ }
+ setFieldConfigs((prev) => applyPermissionsRecursively(prev, permissions))
+ }, [viewerPerm, operatorPerm, engineerPerm, applyPermissionsRecursively])
+
+ // Helper to recursively update a field's permission by path
+ const updateFieldPermissionRecursively = useCallback(
+ (
+ fields: OpcUaFieldConfig[],
+ fieldPath: string,
+ role: 'viewer' | 'operator' | 'engineer',
+ value: PermissionLevel,
+ ): OpcUaFieldConfig[] => {
+ return fields.map((field) => {
+ if (field.fieldPath === fieldPath) {
+ return { ...field, permissions: { ...field.permissions, [role]: value } }
+ }
+ if (field.fields) {
+ return { ...field, fields: updateFieldPermissionRecursively(field.fields, fieldPath, role, value) }
+ }
+ return field
+ })
+ },
+ [],
+ )
+
+ // Update a single field's permission (searches recursively)
const updateFieldPermission = useCallback(
(fieldPath: string, role: 'viewer' | 'operator' | 'engineer', value: PermissionLevel) => {
- setFieldConfigs((prev) =>
- prev.map((field) =>
- field.fieldPath === fieldPath ? { ...field, permissions: { ...field.permissions, [role]: value } } : field,
- ),
- )
+ setFieldConfigs((prev) => updateFieldPermissionRecursively(prev, fieldPath, role, value))
},
- [],
+ [updateFieldPermissionRecursively],
)
// Handle save
diff --git a/src/utils/debug-tree-traversal.ts b/src/utils/debug-tree-traversal.ts
index 72d913765..11b31ba03 100644
--- a/src/utils/debug-tree-traversal.ts
+++ b/src/utils/debug-tree-traversal.ts
@@ -96,10 +96,11 @@ function isFunctionBlock(typeName: string, projectPous: PLCPou[]): boolean {
}
/**
- * Parse array dimension string (e.g., "1..10") into start and end indices.
+ * Parse array dimension string (e.g., "1..10" or "-5..5") into start and end indices.
+ * IEC 61131-3 allows negative array indices.
*/
function parseArrayDimension(dimension: string): [number, number] | null {
- const match = dimension.match(/^(\d+)\.\.(\d+)$/)
+ const match = dimension.match(/^(-?\d+)\.\.(-?\d+)$/)
if (!match) return null
return [parseInt(match[1], 10), parseInt(match[2], 10)]
}
diff --git a/src/utils/pou-helpers.ts b/src/utils/pou-helpers.ts
index 780124842..093e10b0c 100644
--- a/src/utils/pou-helpers.ts
+++ b/src/utils/pou-helpers.ts
@@ -141,6 +141,7 @@ export interface LeafVariable {
* @param projectPous - Project POUs for looking up custom FBs
* @param dataTypes - Project data types for looking up structures
* @param pathPrefix - Current path prefix for building relative paths
+ * @param visited - Set of already visited type names to prevent infinite recursion on circular references
* @returns Array of leaf variables with their relative paths
*/
export const findLeafVariables = (
@@ -148,8 +149,17 @@ export const findLeafVariables = (
projectPous: PLCPou[],
dataTypes: PLCDataType[],
pathPrefix: string = '',
+ visited: Set = new Set(),
): LeafVariable[] => {
const leaves: LeafVariable[] = []
+ const typeNameNormalized = typeName.toLowerCase()
+
+ // Prevent infinite recursion on circular type references
+ if (visited.has(typeNameNormalized)) {
+ console.warn(`Circular type reference detected for type: ${typeName}`)
+ return leaves
+ }
+ visited.add(typeNameNormalized)
// Try to find as FB first
const fbVariables = findFunctionBlockVariables(typeName, projectPous)
@@ -165,7 +175,7 @@ export const findLeafVariables = (
// Skip for now as arrays need special handling
} else if (!isEnumerationType(varTypeName, dataTypes)) {
// Recurse into nested FBs or structures
- const nestedLeaves = findLeafVariables(varTypeName, projectPous, dataTypes, varPath)
+ const nestedLeaves = findLeafVariables(varTypeName, projectPous, dataTypes, varPath, new Set(visited))
leaves.push(...nestedLeaves)
}
}
@@ -183,7 +193,7 @@ export const findLeafVariables = (
leaves.push({ relativePath: fieldPath, typeName: fieldTypeName.toUpperCase() })
} else if (!isEnumerationType(fieldTypeName, dataTypes)) {
// Recurse into nested types
- const nestedLeaves = findLeafVariables(fieldTypeName, projectPous, dataTypes, fieldPath)
+ const nestedLeaves = findLeafVariables(fieldTypeName, projectPous, dataTypes, fieldPath, new Set(visited))
leaves.push(...nestedLeaves)
}
}
From b43a0bd31552d1118a4dc49b74cbccce2731870c Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 26 Jan 2026 19:02:03 -0500
Subject: [PATCH 18/21] fix: Use variablePath for fieldPath to ensure
uniqueness in nested fields
Nested structures can have duplicate field names at different levels
(e.g., "device.temperature" and "sensor.temperature"). Using only
child.name caused collisions where updateFieldPermissionRecursively
matched the first occurrence and React keys conflicted.
Now uses child.variablePath for unique identification while keeping
child.name for displayName.
Co-Authored-By: Claude Opus 4.5
---
.../opcua-server/components/variable-config-modal.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index 0e2e4ee73..d9275dc2c 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -111,8 +111,9 @@ const generateDefaultFieldConfigs = (
if (isLeaf) {
// Leaf field - no nested fields
+ // Use variablePath for unique identification, name for display
return {
- fieldPath: child.name,
+ fieldPath: child.variablePath,
displayName: child.name,
datatype: child.variableType || 'UNKNOWN',
initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
@@ -121,8 +122,9 @@ const generateDefaultFieldConfigs = (
} else {
// Complex type (FB, struct, array) - generate nested fields recursively
const nestedFields = generateDefaultFieldConfigs(child, parentPermissions)
+ // Use variablePath for unique identification, name for display
return {
- fieldPath: child.name,
+ fieldPath: child.variablePath,
displayName: child.name,
datatype: child.variableType || 'UNKNOWN',
initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
From 6bb53a01e6d80e87f5ce224324cf1164b2e4c0f3 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 26 Jan 2026 19:15:34 -0500
Subject: [PATCH 19/21] fix: Revert fieldPath to use child.name instead of
variablePath
The OPC-UA resolver builds the full path by combining parentPath + fieldPath
(resolve-indices.ts:148). Using variablePath caused path duplication because
variablePath already contains the parent path.
Example of the bug:
- parentPath = "STATE_DISPLAY0"
- fieldPath = "STATE_DISPLAY0.State" (using variablePath)
- Result: "STATE_DISPLAY0.STATE_DISPLAY0.State" (WRONG - duplicated)
The CodeRabbit concern about uniqueness was a false alarm - the hierarchical
structure of fieldConfigs means fields with the same name at different levels
are stored in different `fields` arrays, so there's no collision.
Co-Authored-By: Claude Opus 4.5
---
.../opcua-server/components/variable-config-modal.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index d9275dc2c..a1d7dc5bf 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -111,9 +111,10 @@ const generateDefaultFieldConfigs = (
if (isLeaf) {
// Leaf field - no nested fields
- // Use variablePath for unique identification, name for display
+ // Use name (not variablePath) as fieldPath - the resolver builds the full path
+ // by combining parent path + fieldPath. Using variablePath would duplicate the parent.
return {
- fieldPath: child.variablePath,
+ fieldPath: child.name,
displayName: child.name,
datatype: child.variableType || 'UNKNOWN',
initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
@@ -122,9 +123,9 @@ const generateDefaultFieldConfigs = (
} else {
// Complex type (FB, struct, array) - generate nested fields recursively
const nestedFields = generateDefaultFieldConfigs(child, parentPermissions)
- // Use variablePath for unique identification, name for display
+ // Use name (not variablePath) - see comment above
return {
- fieldPath: child.variablePath,
+ fieldPath: child.name,
displayName: child.name,
datatype: child.variableType || 'UNKNOWN',
initialValue: getDefaultInitialValue(child.variableType || 'UNKNOWN'),
From d678dd6a3544fa33aafa2d0b56b47bda144dcd2c Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 26 Jan 2026 19:33:19 -0500
Subject: [PATCH 20/21] fix: improve OPC-UA server configuration UI consistency
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Standardize font sizes across all form fields (text-cp-sm → text-xs)
- Replace emoji password icons (👁️/🙈) with proper SVG icons (ViewIcon/ViewHiddenIcon)
- Add ViewHiddenIcon component for password visibility toggle
- Disable password visibility toggle when field is empty (hashed passwords can't be shown)
- Remove inconsistent "!" icon from insecure security profile warning
- Update Modbus and S7Comm server editors for font consistency
Co-Authored-By: Claude Opus 4.5
---
src/renderer/assets/icons/index.ts | 1 +
.../assets/icons/interface/ViewHidden.tsx | 29 +++++++++++++++++
.../editor/server/modbus-server/index.tsx | 6 ++--
.../components/address-space-tab.tsx | 2 +-
.../components/certificate-modal.tsx | 2 +-
.../components/certificates-tab.tsx | 2 +-
.../components/security-profile-modal.tsx | 6 ++--
.../components/security-profiles-tab.tsx | 3 +-
.../opcua-server/components/user-modal.tsx | 31 +++++++++++++++----
.../components/variable-config-modal.tsx | 8 ++---
.../editor/server/opcua-server/index.tsx | 4 +--
.../editor/server/s7comm-server/index.tsx | 14 ++++-----
12 files changed, 78 insertions(+), 30 deletions(-)
create mode 100644 src/renderer/assets/icons/interface/ViewHidden.tsx
diff --git a/src/renderer/assets/icons/index.ts b/src/renderer/assets/icons/index.ts
index 3dd61fba7..993aa65d2 100644
--- a/src/renderer/assets/icons/index.ts
+++ b/src/renderer/assets/icons/index.ts
@@ -68,6 +68,7 @@ export * from './interface/Transfer'
export * from './interface/TrashCan'
export * from './interface/Video'
export * from './interface/View'
+export * from './interface/ViewHidden'
export * from './interface/Zap'
export * from './interface/ZoomInOut'
diff --git a/src/renderer/assets/icons/interface/ViewHidden.tsx b/src/renderer/assets/icons/interface/ViewHidden.tsx
new file mode 100644
index 000000000..ea68a3887
--- /dev/null
+++ b/src/renderer/assets/icons/interface/ViewHidden.tsx
@@ -0,0 +1,29 @@
+import { IconStyles } from '@process:renderer/data/constants/icon-styles'
+import { cn } from '@utils/cn'
+
+import { IIconProps } from '../Types/iconTypes'
+
+export default function ViewHiddenIcon(props: IIconProps) {
+ const { stroke, className, size = 'sm', ...res } = props
+ const sizeClasses = IconStyles.sizeClasses.small[size]
+
+ return (
+
+ {/* Eye shape */}
+
+ {/* Diagonal line through the eye */}
+
+
+ )
+}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
index 6f7fcd293..9c23d5022 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
@@ -89,7 +89,7 @@ const BufferInput = ({ label, value, onChange, onBlur, max, description }: Buffe
onBlur={onBlur}
min='0'
max={max.toString()}
- className='h-[28px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ className='h-[28px] 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'
/>
@@ -267,7 +267,7 @@ const ModbusServerEditor = () => {
)
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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'
if (protocol !== 'modbus-tcp') {
return (
@@ -322,7 +322,7 @@ const ModbusServerEditor = () => {
{DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
index a9683a853..67c2daec7 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
@@ -22,7 +22,7 @@ interface AddressSpaceTabProps {
}
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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'
export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressSpaceTabProps) => {
const {
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
index fc2b210ee..4107c40c3 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
@@ -13,7 +13,7 @@ interface CertificateModalProps {
}
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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 textareaStyles =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-mono text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
index 8323edd48..1eb8e2722 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
@@ -155,7 +155,7 @@ export const CertificatesTab = ({ config, serverName, onConfigChange }: Certific
{SECURITY_POLICIES.map((option) => (
@@ -258,7 +258,7 @@ export const SecurityProfileModal = ({
withIndicator
placeholder='Select mode'
className={cn(
- 'flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300',
+ 'flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-xs font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300',
securityPolicy === 'None' && 'cursor-not-allowed opacity-50',
)}
/>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
index 291832fa4..4fd82a564 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
@@ -197,8 +197,7 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu
{/* Warning for insecure profiles */}
{profile.securityPolicy === 'None' && profile.enabled && (
-
-
!
+
Warning: No encryption or authentication. Use only for development/testing.
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
index bdf9025c2..a9a98c6e6 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -1,3 +1,5 @@
+import ViewIcon from '@root/renderer/assets/icons/interface/View'
+import ViewHiddenIcon from '@root/renderer/assets/icons/interface/ViewHidden'
import { InputWithRef } from '@root/renderer/components/_atoms/input'
import { Label } from '@root/renderer/components/_atoms/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select'
@@ -21,7 +23,7 @@ interface UserModalProps {
}
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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 ROLE_OPTIONS: { value: UserRole; label: string; description: string }[] = [
{ value: 'viewer', label: 'Viewer', description: 'Read-only access to all variables' },
@@ -216,7 +218,7 @@ export const UserModal = ({
setShowPassword(!showPassword)}
- className='absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
+ disabled={!password}
+ className={cn(
+ 'absolute right-2 top-1/2 -translate-y-1/2',
+ password
+ ? 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
+ : 'cursor-not-allowed text-neutral-300 dark:text-neutral-600',
+ )}
+ title={
+ !password
+ ? 'Enter a password to toggle visibility'
+ : showPassword
+ ? 'Hide password'
+ : 'Show password'
+ }
>
- {showPassword ? '🙈' : '👁'}
+ {showPassword ? (
+
+ ) : (
+
+ )}
@@ -320,7 +339,7 @@ export const UserModal = ({
{availableCertificates.map((cert) => (
@@ -357,7 +376,7 @@ export const UserModal = ({
{ROLE_OPTIONS.map((option) => (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index a1d7dc5bf..0c4b3cef9 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -19,7 +19,7 @@ interface VariableConfigModalProps {
}
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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 textareaStyles =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-caption text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
@@ -496,7 +496,7 @@ export const VariableConfigModal = ({
(
@@ -330,7 +330,7 @@ const GeneralSettingsTab = ({ config, onServerUpdate, onCycleTimeUpdate }: Gener
{DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
index 48e8f5c54..e46563fc5 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
@@ -201,7 +201,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setDbNumber(e.target.value)}
min='1'
max='65535'
- className='h-[30px] w-32 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ className='h-[30px] w-32 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'
/>
@@ -213,7 +213,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setDescription(e.target.value)}
maxLength={128}
placeholder='Optional description'
- className='h-[30px] flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ className='h-[30px] flex-1 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'
/>
@@ -225,7 +225,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setSizeBytes(e.target.value)}
min='1'
max='65536'
- className='h-[30px] w-32 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ className='h-[30px] w-32 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'
/>
@@ -235,7 +235,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
{BUFFER_TYPE_OPTIONS.map((option) => (
@@ -264,7 +264,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setStartBuffer(e.target.value)}
min='0'
max='1023'
- className='h-[30px] w-32 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ className='h-[30px] w-32 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'
/>
@@ -497,7 +497,7 @@ const S7CommServerEditor = () => {
)
const inputStyles =
- 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
+ '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'
if (protocol !== 's7comm') {
return (
@@ -565,7 +565,7 @@ const S7CommServerEditor = () => {
{NETWORK_INTERFACE_OPTIONS.map((option) => (
From c830cb843f803ef79a5be66279c32668b8ad76a2 Mon Sep 17 00:00:00 2001
From: Thiago Alves
Date: Mon, 26 Jan 2026 20:33:55 -0500
Subject: [PATCH 21/21] fix: Force font-size with !important to override
@tailwindcss/forms
The @tailwindcss/forms plugin applies base font-size styling to form
inputs that was overriding Tailwind utility classes. Using !text-xs
ensures consistent font sizes across all server editor form fields.
Co-Authored-By: Claude Opus 4.5
---
.../editor/server/modbus-server/index.tsx | 6 +++---
.../opcua-server/components/address-space-tab.tsx | 2 +-
.../opcua-server/components/certificate-modal.tsx | 8 ++++----
.../opcua-server/components/certificates-tab.tsx | 10 +++++-----
.../components/security-profile-modal.tsx | 10 +++++-----
.../components/security-profiles-tab.tsx | 6 +++---
.../components/selected-variables-list.tsx | 4 ++--
.../server/opcua-server/components/user-modal.tsx | 12 ++++++------
.../server/opcua-server/components/users-tab.tsx | 6 +++---
.../components/variable-config-modal.tsx | 12 ++++++------
.../opcua-server/components/variable-tree.tsx | 2 +-
.../editor/server/opcua-server/index.tsx | 7 ++++---
.../editor/server/s7comm-server/index.tsx | 14 +++++++-------
13 files changed, 50 insertions(+), 49 deletions(-)
diff --git a/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
index 9c23d5022..a8935ba2b 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx
@@ -89,7 +89,7 @@ const BufferInput = ({ label, value, onChange, onBlur, max, description }: Buffe
onBlur={onBlur}
min='0'
max={max.toString()}
- className='h-[28px] 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'
+ className='h-[28px] 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'
/>
@@ -267,7 +267,7 @@ const ModbusServerEditor = () => {
)
const inputStyles =
- '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'
+ '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'
if (protocol !== 'modbus-tcp') {
return (
@@ -322,7 +322,7 @@ const ModbusServerEditor = () => {
{DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
index 67c2daec7..010331fe4 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx
@@ -22,7 +22,7 @@ interface AddressSpaceTabProps {
}
const inputStyles =
- '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'
+ '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'
export const AddressSpaceTab = ({ config, serverName, onConfigChange }: AddressSpaceTabProps) => {
const {
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
index 4107c40c3..2450d0e5f 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx
@@ -13,7 +13,7 @@ interface CertificateModalProps {
}
const inputStyles =
- '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'
+ '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 textareaStyles =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-mono text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
@@ -200,7 +200,7 @@ MIIEpDCCAowCCQC7...
Browse File...
@@ -241,7 +241,7 @@ MIIEpDCCAowCCQC7...
Cancel
@@ -250,7 +250,7 @@ MIIEpDCCAowCCQC7...
onClick={handleSave}
disabled={!isValid}
className={cn(
- 'h-[32px] rounded-md bg-brand px-4 font-caption text-xs font-medium text-white hover:bg-brand-medium-dark',
+ 'h-[32px] rounded-md bg-brand px-4 font-caption !text-xs font-medium text-white hover:bg-brand-medium-dark',
!isValid && 'cursor-not-allowed opacity-50',
)}
>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
index 1eb8e2722..b973319a7 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
@@ -155,7 +155,7 @@ export const CertificatesTab = ({ config, serverName, onConfigChange }: Certific
Browse File...
@@ -228,7 +228,7 @@ MIIEvgIBADANBg...
Browse File...
@@ -250,7 +250,7 @@ MIIEvgIBADANBg...
+
Add Trusted Certificate
@@ -284,7 +284,7 @@ MIIEvgIBADANBg...
handleDeleteCertificate(cert.id)}
- className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption !text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
>
Delete
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
index 11ffd3d39..23af6fc40 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx
@@ -21,7 +21,7 @@ interface SecurityProfileModalProps {
}
const inputStyles =
- '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'
+ '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 SECURITY_POLICIES: { value: SecurityPolicy; label: string }[] = [
{ value: 'None', label: 'None (No Security)' },
@@ -225,7 +225,7 @@ export const SecurityProfileModal = ({
{SECURITY_POLICIES.map((option) => (
@@ -258,7 +258,7 @@ export const SecurityProfileModal = ({
withIndicator
placeholder='Select mode'
className={cn(
- 'flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-xs font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300',
+ 'flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300',
securityPolicy === 'None' && 'cursor-not-allowed opacity-50',
)}
/>
@@ -366,7 +366,7 @@ export const SecurityProfileModal = ({
Cancel
@@ -375,7 +375,7 @@ export const SecurityProfileModal = ({
onClick={handleSave}
disabled={!isValid}
className={cn(
- 'h-[32px] rounded-md bg-brand px-4 font-caption text-xs font-medium text-white hover:bg-brand-medium-dark',
+ 'h-[32px] rounded-md bg-brand px-4 font-caption !text-xs font-medium text-white hover:bg-brand-medium-dark',
!isValid && 'cursor-not-allowed opacity-50',
)}
>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
index 4fd82a564..4a4e1f9f4 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
@@ -115,7 +115,7 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu
+
Add Security Profile
@@ -163,7 +163,7 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu
handleEditProfile(profile)}
- className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption !text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
>
Edit
@@ -172,7 +172,7 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu
onClick={() => handleDeleteProfile(profile.id)}
disabled={config.securityProfiles.length <= 1}
className={cn(
- 'h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950',
+ 'h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption !text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950',
config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
)}
>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
index 244211565..3efca3331 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
@@ -76,14 +76,14 @@ export const SelectedVariablesList = ({ nodes, onEdit, onRemove }: SelectedVaria
onEdit(node)}
- className='h-[24px] rounded-md border border-neutral-300 bg-white px-2 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ className='h-[24px] rounded-md border border-neutral-300 bg-white px-2 font-caption !text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
>
Edit
onRemove(node.id)}
- className='h-[24px] rounded-md border border-red-300 bg-white px-2 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ className='h-[24px] rounded-md border border-red-300 bg-white px-2 font-caption !text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
>
Remove
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
index a9a98c6e6..8a68075a0 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -23,7 +23,7 @@ interface UserModalProps {
}
const inputStyles =
- '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'
+ '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 ROLE_OPTIONS: { value: UserRole; label: string; description: string }[] = [
{ value: 'viewer', label: 'Viewer', description: 'Read-only access to all variables' },
@@ -218,7 +218,7 @@ export const UserModal = ({
{availableCertificates.map((cert) => (
@@ -376,7 +376,7 @@ export const UserModal = ({
{ROLE_OPTIONS.map((option) => (
@@ -433,7 +433,7 @@ export const UserModal = ({
Cancel
@@ -442,7 +442,7 @@ export const UserModal = ({
onClick={() => void handleSave()}
disabled={!isValid}
className={cn(
- 'h-[32px] rounded-md bg-brand px-4 font-caption text-xs font-medium text-white hover:bg-brand-medium-dark',
+ 'h-[32px] rounded-md bg-brand px-4 font-caption !text-xs font-medium text-white hover:bg-brand-medium-dark',
!isValid && 'cursor-not-allowed opacity-50',
)}
>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
index 93b7bc2b0..a812fffad 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx
@@ -129,7 +129,7 @@ export const UsersTab = ({ config, serverName, onConfigChange }: UsersTabProps)
+
Add User
@@ -173,14 +173,14 @@ export const UsersTab = ({ config, serverName, onConfigChange }: UsersTabProps)
handleEditUser(user)}
- className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
+ className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption !text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
>
Edit
handleDeleteUser(user.id)}
- className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
+ className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption !text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
>
Delete
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
index 0c4b3cef9..ecc2a1dfe 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx
@@ -19,7 +19,7 @@ interface VariableConfigModalProps {
}
const inputStyles =
- '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'
+ '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 textareaStyles =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-caption text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
@@ -496,7 +496,7 @@ export const VariableConfigModal = ({
Cancel
@@ -776,7 +776,7 @@ export const VariableConfigModal = ({
onClick={handleSave}
disabled={!isValid}
className={cn(
- 'h-[32px] rounded-md bg-brand px-4 font-caption text-xs font-medium text-white hover:bg-brand-medium-dark',
+ 'h-[32px] rounded-md bg-brand px-4 font-caption !text-xs font-medium text-white hover:bg-brand-medium-dark',
!isValid && 'cursor-not-allowed opacity-50',
)}
>
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
index f164847de..639025475 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx
@@ -162,7 +162,7 @@ const TreeNodeRow = ({ node, depth, selectedIds, expandedIds, onToggleExpand, on
{/* Node name */}
diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
index 792d0332f..0cc4cebff 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx
@@ -30,15 +30,16 @@ const DEFAULT_NETWORK_INTERFACE_OPTIONS = [
]
// Input styles matching Modbus server editor
+// Note: !text-xs forces font-size with !important to override @tailwindcss/forms base styles
const inputStyles =
- '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'
+ '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'
// Tab item component
const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => (
{DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
diff --git a/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
index e46563fc5..0be19cb03 100644
--- a/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
+++ b/src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx
@@ -201,7 +201,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setDbNumber(e.target.value)}
min='1'
max='65535'
- className='h-[30px] w-32 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'
+ className='h-[30px] w-32 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'
/>
@@ -213,7 +213,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setDescription(e.target.value)}
maxLength={128}
placeholder='Optional description'
- className='h-[30px] flex-1 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'
+ className='h-[30px] flex-1 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'
/>
@@ -225,7 +225,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setSizeBytes(e.target.value)}
min='1'
max='65536'
- className='h-[30px] w-32 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'
+ className='h-[30px] w-32 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'
/>
@@ -235,7 +235,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
{BUFFER_TYPE_OPTIONS.map((option) => (
@@ -264,7 +264,7 @@ const DataBlockModal = ({ isOpen, onClose, onSave, existingDbNumbers, editingBlo
onChange={(e) => setStartBuffer(e.target.value)}
min='0'
max='1023'
- className='h-[30px] w-32 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'
+ className='h-[30px] w-32 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'
/>
@@ -497,7 +497,7 @@ const S7CommServerEditor = () => {
)
const inputStyles =
- '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'
+ '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'
if (protocol !== 's7comm') {
return (
@@ -565,7 +565,7 @@ const S7CommServerEditor = () => {
{NETWORK_INTERFACE_OPTIONS.map((option) => (