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 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 */} +
+ + 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 | diff --git a/package-lock.json b/package-lock.json index 80e290f52..fbff93255 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": { @@ -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/src/main/modules/compiler/compiler-module.ts b/src/main/modules/compiler/compiler-module.ts index 0302091ff..3ba726d3d 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,74 @@ 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 = '' + } + + // 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, instances) + + 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 +1708,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/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/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/_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 ( 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/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/_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/modbus-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/modbus-server/index.tsx index 6f7fcd293..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-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 new file mode 100644 index 000000000..010331fe4 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/address-space-tab.tsx @@ -0,0 +1,283 @@ +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, + 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' + +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-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 { + 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() + } + + // 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) + 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) + + // 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, editingVariable, 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) + + // 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, projectVariables], + ) + + return ( +
+ {/* Header */} +
+

+ Select PLC variables to expose via OPC-UA. Variable indices are resolved automatically during project + compilation. +

+
+ + {/* 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/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx new file mode 100644 index 000000000..2450d0e5f --- /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-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' + +// 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, normalizing CRLF line endings from Windows + const lines = trimmed.split('\n') + const contentLines = lines.slice(1, -1) + const base64Content = contentLines.join('').replace(/\r/g, '').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 */} +
+ + 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 */} +
+ +