Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d2a9c87
docs: Add OPC-UA server configuration design documentation
thiagoralves Jan 15, 2026
8fd81ec
feat: Implement Phase 1 OPC-UA server configuration
thiagoralves Jan 16, 2026
088765d
feat: Implement Phase 2 OPC-UA Security Profiles tab
thiagoralves Jan 16, 2026
ec1ec87
feat: Implement Phase 3 OPC-UA Users and Certificates tabs
thiagoralves Jan 16, 2026
02e4149
feat: Implement Phase 4 OPC-UA Address Space tab
thiagoralves Jan 16, 2026
ee8a5a7
feat: Implement Phases 5 and 6 OPC-UA complex types and compiler inte…
thiagoralves Jan 16, 2026
c2acc65
feat: Add shared debug variable utilities and enhance OPC-UA complex …
thiagoralves Jan 17, 2026
c9d270a
docs: Add debugger/OPC-UA shared utilities refactoring plan
thiagoralves Jan 17, 2026
9ee6a14
feat: Add array type parsing support for OPC-UA configuration
marconetsf Jan 19, 2026
30d325c
fix: Address Copilot review comments on array type parsing
marconetsf Jan 19, 2026
0199f69
Merge branch 'development' into feat/debugger-shared-utilities
thiagoralves Jan 19, 2026
44b44e7
Merge remote-tracking branch 'origin/development' into feat/debugger-…
thiagoralves Jan 19, 2026
76d28b4
refactor: Unify debugger and OPC-UA variable index resolution
thiagoralves Jan 19, 2026
491e46e
fix: Add missing dependencies to useImperativeHandle
thiagoralves Jan 19, 2026
8f375ad
Merge pull request #545 from Autonomy-Logic/feat/debugger-shared-util…
thiagoralves Jan 19, 2026
efb2cd4
Merge pull request #544 from Autonomy-Logic/feat/opcua-array-config-s…
thiagoralves Jan 19, 2026
9a4e0ba
feat: Add support for nested structures in OPC-UA configuration
thiagoralves Jan 19, 2026
9b41d73
docs: Address Copilot review comments for nested structures
thiagoralves Jan 20, 2026
d498575
Merge pull request #547 from Autonomy-Logic/feat/opcua-nested-structures
thiagoralves Jan 20, 2026
db54841
feat: Replace SHA-256 with bcrypt for OPC-UA user password hashing
marconetsf Jan 20, 2026
cbdbaca
Merge pull request #550 from Autonomy-Logic/feat/password-bcrypt-hash
thiagoralves Jan 20, 2026
be436ba
Merge branch 'development' into feat/opcua-server-configuration
thiagoralves Jan 23, 2026
6c08ce6
fix: Use PBKDF2 instead of bcrypt for OPC-UA password hashing
thiagoralves Jan 23, 2026
3a98497
Merge pull request #561 from Autonomy-Logic/fix/opcua-pbkdf2-password…
thiagoralves Jan 26, 2026
bb2c2f1
fix: Address code review issues for OPC-UA implementation
thiagoralves Jan 26, 2026
b43a0bd
fix: Use variablePath for fieldPath to ensure uniqueness in nested fi…
thiagoralves Jan 27, 2026
6bb53a0
fix: Revert fieldPath to use child.name instead of variablePath
thiagoralves Jan 27, 2026
d678dd6
fix: improve OPC-UA server configuration UI consistency
thiagoralves Jan 27, 2026
c830cb8
fix: Force font-size with !important to override @tailwindcss/forms
thiagoralves Jan 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
544 changes: 544 additions & 0 deletions docs/debugger-opcua-shared-utilities.md

Large diffs are not rendered by default.

414 changes: 414 additions & 0 deletions docs/opcua-server-configuration/01-design-overview.md

Large diffs are not rendered by default.

818 changes: 818 additions & 0 deletions docs/opcua-server-configuration/02-ui-screen-specifications.md

Large diffs are not rendered by default.

959 changes: 959 additions & 0 deletions docs/opcua-server-configuration/03-json-configuration-mapping.md

Large diffs are not rendered by default.

973 changes: 973 additions & 0 deletions docs/opcua-server-configuration/04-implementation-phases.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions docs/opcua-server-configuration/README.md
Original file line number Diff line number Diff line change
@@ -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 |
13 changes: 11 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions src/main/modules/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void> {
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,
Expand Down Expand Up @@ -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...',
Expand Down
7 changes: 0 additions & 7 deletions src/main/modules/ipc/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/renderer/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
29 changes: 29 additions & 0 deletions src/renderer/assets/icons/interface/ViewHidden.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
viewBox='0 0 15 12'
stroke={stroke}
xmlns='http://www.w3.org/2000/svg'
className={cn(`${sizeClasses}`, className)}
{...res}
>
{/* Eye shape */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M7.79818 10.60216C10.7081 10.66758 13.2826 8.56807 14.5769 7.28818C15.1342 6.73709 15.1529 5.9064 14.6209 5.33081C13.3855 3.99405 10.9079 1.780938 7.99799 1.715512C5.08809 1.650085 2.51353 3.7496 1.21925 5.02948C0.661948 5.58058 0.64327 6.41127 1.17524 6.98686C2.41069 8.32362 4.88828 10.53673 7.79818 10.60216ZM7.85812 7.93616C8.9024 7.95964 9.76684 7.18294 9.78891 6.20135C9.81098 5.21976 8.98232 4.40498 7.93804 4.3815C6.89377 4.35803 6.02932 5.13473 6.00725 6.11632C5.98518 7.09791 6.81384 7.91268 7.85812 7.93616Z'
fill={stroke || '#0464FB'}
/>
{/* Diagonal line through the eye */}
<line x1='2' y1='11' x2='13' y2='1' stroke={stroke || '#0464FB'} strokeWidth='1.5' strokeLinecap='round' />
</svg>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export const GraphicalEditorAutocomplete = forwardRef<HTMLDivElement, GraphicalE
].filter((variable) => 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 })
}
Comment on lines +87 to +96
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functions closeModal and submitAutocompletion are defined inside the component but not wrapped in useCallback. The dependency array at line 124 includes these functions, which will cause the useImperativeHandle to re-run on every render. Wrap these functions in useCallback with proper dependencies.

Copilot uses AI. Check for mistakes.

// @ts-expect-error - not all properties are used
useImperativeHandle(ref, () => {
return {
Expand All @@ -110,7 +121,7 @@ export const GraphicalEditorAutocomplete = forwardRef<HTMLDivElement, GraphicalE
}
},
}
}, [selectedVariable, selectableValues, popoverRef, autocompleteFocus])
}, [selectedVariable, selectableValues, popoverRef, autocompleteFocus, submitAutocompletion, closeModal])

useEffect(() => {
switch (keyDown) {
Expand Down Expand Up @@ -176,17 +187,6 @@ export const GraphicalEditorAutocomplete = forwardRef<HTMLDivElement, GraphicalE
}
}

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 })
}

return (
<Popover.Root open={isOpen ? isOpen : false}>
<Popover.Trigger />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using String() constructor for number-to-string conversion. For consistency with the codebase (lines 363-364 use .toString()), consider using (remoteDevice.modbusTcpConfig.slaveId ?? 1).toString() instead.

Suggested change
setSlaveId(String(remoteDevice.modbusTcpConfig.slaveId ?? 1))
setSlaveId((remoteDevice.modbusTcpConfig.slaveId ?? 1).toString())

Copilot uses AI. Check for mistakes.
} else {
setHost('127.0.0.1')
setPort('502')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { ModbusServerEditor } from './modbus-server'
export { OpcUaServerEditor } from './opcua-server'
export { S7CommServerEditor } from './s7comm-server'
Original file line number Diff line number Diff line change
Expand Up @@ -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'
/>
</div>
<span className='text-xs text-neutral-500 dark:text-neutral-400'>
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -322,7 +322,7 @@ const ModbusServerEditor = () => {
<SelectTrigger
withIndicator
placeholder='Select network interface'
className='flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
className='flex h-[30px] w-full items-center justify-between gap-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none data-[state=open]:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300'
/>
<SelectContent className='h-fit max-h-[200px] w-[--radix-select-trigger-width] overflow-y-auto rounded-lg border border-neutral-300 bg-white outline-none drop-shadow-lg dark:border-brand-medium-dark dark:bg-neutral-950'>
{DEFAULT_NETWORK_INTERFACE_OPTIONS.map((option) => (
Expand Down
Loading