diff --git a/C_PYTHON_DATA_SHARING_PROPOSAL.md b/C_PYTHON_DATA_SHARING_PROPOSAL.md new file mode 100644 index 00000000..975311d8 --- /dev/null +++ b/C_PYTHON_DATA_SHARING_PROPOSAL.md @@ -0,0 +1,587 @@ +# Proposal: Robust C-Python Runtime Data Sharing + +## Executive Summary + +This document analyzes the current approach for sharing runtime functions and buffers between C and Python plugins in OpenPLC, identifies its weaknesses, and proposes a more robust and simplified architecture. + +--- + +## 1. Current Implementation Analysis + +### 1.1 How It Works Today + +The current system uses a monolithic C struct (`plugin_runtime_args_t`) that is: +1. Allocated and populated in C (`plugin_driver.c`) +2. Wrapped in a PyCapsule +3. Passed to Python plugins +4. Extracted using ctypes with a manually-maintained mirror struct + +``` +┌─────────────┐ PyCapsule ┌─────────────┐ ctypes ┌─────────────┐ +│ C Struct │ ──────────────> │ Capsule │ ──────────> │ Python Struct│ +│ (456 bytes) │ │ (pointer) │ │ (mirror) │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +### 1.2 Current Problems + +| Problem | Impact | Severity | +|---------|--------|----------| +| **Manual struct synchronization** | Any field order change in C requires manual Python update | Critical | +| **No version checking** | Incompatible changes cause silent memory corruption | Critical | +| **Complex nested pointers** | `IEC_BOOL *(*bool_input)[8]` is error-prone in ctypes | High | +| **Monolithic struct** | Adding one field requires updating entire struct on both sides | High | +| **No compile-time validation** | Mismatches only discovered at runtime (crashes) | High | +| **Tight coupling** | Python code depends on exact C memory layout | Medium | + +### 1.3 Root Cause of Recent Bug + +The crash was caused by field order mismatch: + +```c +// C struct order (plugin_types.h): +mutex_take +mutex_give +buffer_mutex // <-- Position 3 +get_var_list // <-- Position 4 +get_var_size +get_var_count +``` + +```python +# Python struct order (plugin_runtime_args.py) - WRONG: +mutex_take +mutex_give +get_var_list # <-- Position 3 (WRONG!) +get_var_size +get_var_count +buffer_mutex # <-- Position 6 (WRONG!) +``` + +This caused Python to read garbage values, leading to segfaults. + +--- + +## 2. Proposed Solution: Layered API Architecture + +### 2.1 Design Principles + +1. **Separation of Concerns**: Split the monolithic struct into logical groups +2. **Explicit Versioning**: Include version info for compatibility checking +3. **Simplified Interface**: Hide pointer complexity behind C helper functions +4. **Validation First**: Validate compatibility before any data access +5. **Single Source of Truth**: Generate Python bindings from C definitions + +### 2.2 Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ LAYER 3: Plugin API │ +│ High-level Python interface (SafeBufferAccess, OpcuaServer, etc.) │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ LAYER 2: C Bridge Functions │ +│ Simple C functions called via ctypes (no complex pointer passing) │ +│ - plc_read_variable(index) -> value │ +│ - plc_write_variable(index, value) -> success │ +│ - plc_get_var_info(index) -> {size, type, name} │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ LAYER 1: Minimal Bootstrap Struct │ +│ Only contains: version, function pointers to bridge, config path │ +│ Small, stable, rarely changes │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Detailed Implementation + +### 3.1 Layer 1: Minimal Bootstrap Struct + +Replace the large monolithic struct with a minimal bootstrap struct: + +```c +// plugin_api.h - NEW FILE + +#define PLUGIN_API_VERSION_MAJOR 2 +#define PLUGIN_API_VERSION_MINOR 0 + +// Simple struct with only essentials - STABLE, rarely changes +typedef struct { + // Version info for compatibility checking + uint32_t api_version_major; + uint32_t api_version_minor; + uint32_t struct_size; // For validation + + // Function pointers to bridge layer (Layer 2) + void* bridge_handle; // Opaque handle to bridge context + + // Essential function pointers + int (*read_variable)(void* handle, uint16_t index, void* value, size_t* size); + int (*write_variable)(void* handle, uint16_t index, const void* value, size_t size); + int (*get_variable_count)(void* handle, uint16_t* count); + int (*get_variable_info)(void* handle, uint16_t index, VariableInfo* info); + + // Logging (simple interface) + void (*log_message)(void* handle, int level, const char* message); + + // Config + char config_path[256]; + +} PluginBootstrap; + +typedef struct { + uint16_t index; + uint8_t type; // IEC type enum + uint8_t direction; // INPUT, OUTPUT, MEMORY + size_t size; // Size in bytes + char name[64]; // Variable name (optional) +} VariableInfo; +``` + +**Benefits:** +- Only 11 fields vs 25+ in current struct +- No complex nested pointers +- Version checking built-in +- `struct_size` allows runtime validation + +### 3.2 Layer 2: C Bridge Functions + +Implement simple C functions that hide the complexity: + +```c +// plugin_bridge.c - NEW FILE + +typedef struct { + plugin_driver_t* driver; + pthread_mutex_t* mutex; + // Internal state +} BridgeContext; + +// Read any variable by index - handles all types internally +int bridge_read_variable(void* handle, uint16_t index, void* value, size_t* size) { + BridgeContext* ctx = (BridgeContext*)handle; + + // Lock mutex + pthread_mutex_lock(ctx->mutex); + + // Get variable info + size_t var_size = ext_get_var_size(index); + void* var_addr = ext_get_var_addr(index); + + if (!var_addr || var_size == 0) { + pthread_mutex_unlock(ctx->mutex); + return PLUGIN_ERR_INVALID_INDEX; + } + + // Copy value + memcpy(value, var_addr, var_size); + *size = var_size; + + pthread_mutex_unlock(ctx->mutex); + return PLUGIN_OK; +} + +// Write any variable by index +int bridge_write_variable(void* handle, uint16_t index, const void* value, size_t size) { + BridgeContext* ctx = (BridgeContext*)handle; + + pthread_mutex_lock(ctx->mutex); + + size_t var_size = ext_get_var_size(index); + void* var_addr = ext_get_var_addr(index); + + if (!var_addr || var_size == 0 || size != var_size) { + pthread_mutex_unlock(ctx->mutex); + return PLUGIN_ERR_INVALID_INDEX; + } + + memcpy(var_addr, value, size); + + pthread_mutex_unlock(ctx->mutex); + return PLUGIN_OK; +} + +// Get variable metadata +int bridge_get_variable_info(void* handle, uint16_t index, VariableInfo* info) { + info->index = index; + info->size = ext_get_var_size(index); + info->type = determine_iec_type(index); // Internal helper + info->direction = determine_direction(index); + // name populated if available + return PLUGIN_OK; +} +``` + +**Benefits:** +- Mutex handling is internal - Python doesn't manage locks +- Type handling is internal - Python just passes bytes +- Error codes instead of crashes +- No pointer arithmetic in Python + +### 3.3 Layer 3: Python Simple Interface + +```python +# plugin_api.py - NEW FILE + +import ctypes +from enum import IntEnum + +class PluginError(IntEnum): + OK = 0 + INVALID_INDEX = 1 + INVALID_SIZE = 2 + MUTEX_ERROR = 3 + VERSION_MISMATCH = 4 + +class PluginBootstrap(ctypes.Structure): + """Minimal bootstrap struct - matches C exactly""" + _fields_ = [ + ("api_version_major", ctypes.c_uint32), + ("api_version_minor", ctypes.c_uint32), + ("struct_size", ctypes.c_uint32), + ("bridge_handle", ctypes.c_void_p), + ("read_variable", ctypes.CFUNCTYPE( + ctypes.c_int, # return + ctypes.c_void_p, # handle + ctypes.c_uint16, # index + ctypes.c_void_p, # value (output) + ctypes.POINTER(ctypes.c_size_t) # size (output) + )), + ("write_variable", ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_uint16, + ctypes.c_void_p, + ctypes.c_size_t + )), + ("get_variable_count", ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_uint16) + )), + ("get_variable_info", ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_uint16, + ctypes.c_void_p # VariableInfo* + )), + ("log_message", ctypes.CFUNCTYPE( + None, + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_char_p + )), + ("config_path", ctypes.c_char * 256), + ] + + +class PLCBridge: + """High-level Python interface to PLC runtime""" + + EXPECTED_VERSION_MAJOR = 2 + EXPECTED_VERSION_MINOR = 0 + + def __init__(self, capsule): + # Extract bootstrap struct + self._bootstrap = self._extract_bootstrap(capsule) + + # Validate version FIRST + self._validate_version() + + # Cache handle for function calls + self._handle = self._bootstrap.bridge_handle + + def _validate_version(self): + """Check API compatibility before any operations""" + major = self._bootstrap.api_version_major + minor = self._bootstrap.api_version_minor + size = self._bootstrap.struct_size + + if major != self.EXPECTED_VERSION_MAJOR: + raise RuntimeError( + f"API version mismatch: expected {self.EXPECTED_VERSION_MAJOR}.x, " + f"got {major}.{minor}" + ) + + expected_size = ctypes.sizeof(PluginBootstrap) + if size != expected_size: + raise RuntimeError( + f"Struct size mismatch: expected {expected_size}, got {size}. " + f"This indicates a build mismatch between C and Python." + ) + + def read_variable(self, index: int) -> tuple[bytes, int]: + """Read a PLC variable by index. Returns (value_bytes, error_code)""" + value_buffer = ctypes.create_string_buffer(8) # Max 64-bit + size = ctypes.c_size_t(0) + + result = self._bootstrap.read_variable( + self._handle, + ctypes.c_uint16(index), + ctypes.cast(value_buffer, ctypes.c_void_p), + ctypes.byref(size) + ) + + if result != PluginError.OK: + return None, result + + return value_buffer.raw[:size.value], PluginError.OK + + def write_variable(self, index: int, value: bytes) -> int: + """Write a PLC variable by index. Returns error_code""" + return self._bootstrap.write_variable( + self._handle, + ctypes.c_uint16(index), + value, + len(value) + ) + + def get_variable_count(self) -> tuple[int, int]: + """Get total number of variables. Returns (count, error_code)""" + count = ctypes.c_uint16(0) + result = self._bootstrap.get_variable_count( + self._handle, + ctypes.byref(count) + ) + return count.value, result + + def log(self, level: int, message: str): + """Log a message through the C runtime""" + self._bootstrap.log_message( + self._handle, + level, + message.encode('utf-8') + ) +``` + +--- + +## 4. Migration Path + +### Phase 1: Add Version Checking (Low Risk) + +Add version fields to existing struct without breaking compatibility: + +```c +// Add to beginning of existing plugin_runtime_args_t +typedef struct { + // NEW: Version info (add at START for easy access) + uint32_t api_version; // = 0x00010000 for v1.0 + uint32_t struct_size; // = sizeof(plugin_runtime_args_t) + + // ... existing fields unchanged ... +} plugin_runtime_args_t; +``` + +```python +# Update Python to check version first +def validate_struct(args): + if args.api_version != 0x00010000: + raise RuntimeError(f"Version mismatch: {args.api_version:#x}") + if args.struct_size != ctypes.sizeof(PluginRuntimeArgs): + raise RuntimeError(f"Size mismatch: {args.struct_size}") +``` + +### Phase 2: Add Bridge Functions (Medium Risk) + +Add new bridge functions alongside existing implementation: + +```c +// New bridge functions coexist with direct buffer access +// Plugins can choose which to use +``` + +### Phase 3: Deprecate Direct Buffer Access (Breaking Change) + +Once all plugins migrate to bridge functions, remove direct buffer pointers from the API. + +--- + +## 5. Alternative: Auto-Generated Bindings + +### 5.1 Generate Python from C Header + +Use a tool to automatically generate Python ctypes from C header: + +```bash +# Using ctypesgen (example) +ctypesgen -o plugin_types_generated.py plugin_types.h +``` + +### 5.2 Compile-Time Struct Validation + +Add a C program that validates struct layout at build time: + +```c +// validate_struct_layout.c - Run during build +#include "plugin_types.h" +#include +#include + +int main() { + printf("STRUCT_SIZE=%zu\n", sizeof(plugin_runtime_args_t)); + printf("OFFSET_mutex_take=%zu\n", offsetof(plugin_runtime_args_t, mutex_take)); + printf("OFFSET_mutex_give=%zu\n", offsetof(plugin_runtime_args_t, mutex_give)); + printf("OFFSET_buffer_mutex=%zu\n", offsetof(plugin_runtime_args_t, buffer_mutex)); + printf("OFFSET_get_var_list=%zu\n", offsetof(plugin_runtime_args_t, get_var_list)); + // ... etc + return 0; +} +``` + +Python can read this at runtime to validate: + +```python +def validate_offsets(): + """Compare expected vs actual field offsets""" + expected = read_offsets_from_build_output() + for field, offset in PluginRuntimeArgs._fields_: + actual = getattr(PluginRuntimeArgs, field).offset + if actual != expected[field]: + raise RuntimeError(f"Offset mismatch for {field}") +``` + +--- + +## 6. Comparison + +| Aspect | Current | Proposed (Bridge) | Proposed (Auto-gen) | +|--------|---------|-------------------|---------------------| +| **Complexity** | High | Low | Medium | +| **Maintenance** | Manual sync required | Minimal | Automated | +| **Performance** | Direct memory | Function call overhead | Direct memory | +| **Safety** | Crash on mismatch | Error codes | Validated at build | +| **Breaking Changes** | Silent corruption | Version check fails | Build fails | +| **Implementation Effort** | N/A | Medium | Low | + +--- + +## 7. Recommendation + +### Short Term (Immediate) +1. Add `api_version` and `struct_size` fields to existing struct +2. Add validation in Python before accessing any fields +3. Add build-time offset validation script + +### Medium Term (Next Release) +1. Implement bridge functions for variable access +2. Migrate plugins to use bridge functions +3. Add comprehensive error handling + +### Long Term (Future) +1. Deprecate direct buffer pointer access +2. Simplify bootstrap struct to minimal interface +3. Consider using a proper FFI library (cffi) instead of ctypes + +--- + +## 8. Code Examples + +### 8.1 Quick Fix: Add Version Validation + +```c +// plugin_types.h - Add at the BEGINNING of struct +#define PLUGIN_API_VERSION 0x00020001 // v2.0.1 + +typedef struct { + uint32_t api_version; // MUST be first field + uint32_t struct_size; // MUST be second field + + // ... rest of existing fields ... +} plugin_runtime_args_t; + +// plugin_driver.c - Set version when creating +args->api_version = PLUGIN_API_VERSION; +args->struct_size = sizeof(plugin_runtime_args_t); +``` + +```python +# plugin_runtime_args.py - Add validation +EXPECTED_API_VERSION = 0x00020001 + +class PluginRuntimeArgs(ctypes.Structure): + _fields_ = [ + ("api_version", ctypes.c_uint32), # NEW - must be first + ("struct_size", ctypes.c_uint32), # NEW - must be second + # ... rest unchanged ... + ] + + def validate(self): + if self.api_version != EXPECTED_API_VERSION: + raise RuntimeError( + f"API version mismatch: C={self.api_version:#x}, " + f"Python={EXPECTED_API_VERSION:#x}" + ) + expected_size = ctypes.sizeof(PluginRuntimeArgs) + if self.struct_size != expected_size: + raise RuntimeError( + f"Struct size mismatch: C={self.struct_size}, " + f"Python={expected_size}" + ) +``` + +### 8.2 Build-Time Validation Script + +```python +#!/usr/bin/env python3 +# scripts/validate_struct_layout.py + +import subprocess +import ctypes +import sys + +# Compile and run C validation program +result = subprocess.run( + ['./build/validate_struct_layout'], + capture_output=True, text=True +) + +# Parse C offsets +c_offsets = {} +for line in result.stdout.strip().split('\n'): + key, value = line.split('=') + c_offsets[key] = int(value) + +# Compare with Python +from plugin_runtime_args import PluginRuntimeArgs + +py_size = ctypes.sizeof(PluginRuntimeArgs) +if py_size != c_offsets['STRUCT_SIZE']: + print(f"ERROR: Size mismatch C={c_offsets['STRUCT_SIZE']} Python={py_size}") + sys.exit(1) + +# Check each field offset +for name, ctype in PluginRuntimeArgs._fields_: + field = getattr(PluginRuntimeArgs, name) + py_offset = field.offset + c_key = f'OFFSET_{name}' + if c_key in c_offsets: + if py_offset != c_offsets[c_key]: + print(f"ERROR: {name} offset mismatch C={c_offsets[c_key]} Python={py_offset}") + sys.exit(1) + +print("All struct validations passed!") +sys.exit(0) +``` + +--- + +## 9. Conclusion + +The current implementation's fragility stems from: +1. Manual synchronization of complex struct layouts +2. No version checking +3. Direct memory access without validation + +The proposed solution addresses these by: +1. Adding explicit version and size validation +2. Providing a simpler bridge API that hides complexity +3. Optionally auto-generating bindings from C headers + +**Recommended immediate action**: Add `api_version` and `struct_size` fields to catch mismatches early, preventing silent memory corruption and crashes. diff --git a/OPCUA_PLUGIN_FEATURES.md b/OPCUA_PLUGIN_FEATURES.md new file mode 100644 index 00000000..ea42b70b --- /dev/null +++ b/OPCUA_PLUGIN_FEATURES.md @@ -0,0 +1,511 @@ +# OPC UA Plugin - Funcionalidades Presentes em opcua_plugin.py + +## Visão Geral + +O arquivo `opcua_plugin.py` é a implementação funcional atual do plugin OPC UA para o OpenPLC Runtime. Ele implementa um servidor OPC UA completo com suporte a segurança, autenticação, sincronização bidirecional e gerenciamento de variáveis PLC. + +--- + +## 1. Arquitetura Geral + +### 1.1 Componentes Principais + +``` +opcua_plugin.py +├── Logging (log_info, log_warn, log_error) +├── OpenPLCUserManager (Autenticação) +├── OpcuaServer (Gerenciador do servidor) +├── Plugin Interface (init, start_loop, stop_loop, cleanup) +└── Thread Management (server_thread_main) +``` + +### 1.2 Fluxo de Inicialização + +1. **init()** - Extrai argumentos do runtime, cria buffer accessor, carrega configuração +2. **start_loop()** - Inicia thread do servidor +3. **server_thread_main()** - Executa setup, cria nós, inicia loops de sincronização +4. **stop_loop()** - Para o servidor gracefully +5. **cleanup()** - Libera recursos + +--- + +## 2. Funcionalidades de Logging + +### 2.1 Sistema de Logging Integrado + +```python +def log_info(message: str) -> None +def log_warn(message: str) -> None +def log_error(message: str) -> None +``` + +**Características:** +- Integração com sistema de logging do runtime via `SafeLoggingAccess` +- Fallback para stdout/stderr se logging do runtime não estiver disponível +- Prefixos de contexto: `(INFO)`, `(WARN)`, `(ERROR)` + +**Uso:** +```python +log_info("OPC-UA Plugin - Initializing...") +log_error(f"Failed to extract runtime args: {error_msg}") +``` + +--- + +## 3. Autenticação e Autorização + +### 3.1 OpenPLCUserManager + +Implementa a interface `UserManager` do asyncua com suporte a múltiplos métodos de autenticação. + +#### 3.1.1 Métodos de Autenticação Suportados + +1. **Username/Password** + - Validação com bcrypt (com fallback para comparação direta) + - Usuários configurados em `config.users` + +2. **Certificate-Based** + - Extração de fingerprint SHA256 do certificado + - Comparação com certificados confiáveis configurados + - Suporte a múltiplos certificados de cliente + +3. **Anonymous** + - Permitido apenas em perfis de segurança que o habilitam + - Mapeado para role "viewer" (read-only) + +#### 3.1.2 Mapeamento de Roles + +```python +ROLE_MAPPING = { + "viewer": UserRole.User, # Read-only + "operator": UserRole.User, # Read/write (controlado por callbacks) + "engineer": UserRole.Admin # Full access +} +``` + +#### 3.1.3 Resolução de Perfil de Segurança + +- Mapeia URI de política de segurança para perfil configurado +- Fallback automático se perfil não puder ser resolvido +- Valida que o método de autenticação é permitido no perfil + +**Métodos principais:** +- `get_user()` - Autentica usuário +- `_extract_cert_id()` - Extrai ID do certificado +- `_get_profile_for_session()` - Resolve perfil de segurança +- `_validate_password()` - Valida senha com bcrypt + +--- + +## 4. Gerenciamento do Servidor OPC UA + +### 4.1 Classe OpcuaServer + +Gerencia o ciclo de vida completo do servidor OPC UA. + +#### 4.1.1 Inicialização do Servidor + +```python +async def setup_server() -> bool +``` + +**Etapas:** +1. Cria instância do servidor com user manager +2. Configura endpoint URL (com normalização) +3. Define nome do servidor e URIs +4. Configura segurança (políticas, certificados) +5. Inicializa o servidor +6. Define build info +7. Registra namespace +8. Configura callbacks de auditoria + +#### 4.1.2 Configuração de Segurança + +```python +async def _setup_callbacks() -> None +``` + +**Callbacks implementados:** +- `_on_pre_read()` - Valida permissões de leitura +- `_on_pre_write()` - Valida permissões de escrita + +**Validação de Permissões:** +- Extrai role do usuário autenticado +- Verifica permissões configuradas para o nó +- Nega acesso se permissão não for concedida +- Log de tentativas de acesso negado + +--- + +## 5. Criação de Nós OPC UA + +### 5.1 Tipos de Nós Suportados + +#### 5.1.1 Variáveis Simples + +```python +async def _create_simple_variable(self, parent_node: Node, var: SimpleVariable) -> None +``` + +**Características:** +- Mapeamento de tipo PLC para OPC UA +- Conversão de valor inicial +- Configuração de atributos (DisplayName, Description) +- Aplicação de permissões de escrita + +#### 5.1.2 Estruturas (Structs) + +```python +async def _create_struct(self, parent_node: Node, struct: StructVariable) -> None +async def _create_struct_field(self, parent_node: Node, struct_node_id: str, field: VariableField) -> None +``` + +**Características:** +- Cria objeto OPC UA para a estrutura +- Cria variáveis para cada campo +- Suporta aninhamento de estruturas +- Permissões por campo + +#### 5.1.3 Arrays + +```python +async def _create_array(self, parent_node: Node, arr: ArrayVariable) -> None +``` + +**Características:** +- Cria nó com valor array +- Suporta arrays de qualquer tipo suportado +- Inicialização com valor padrão replicado +- Permissões de escrita para array completo + +### 5.2 Mapeamento de Tipos + +```python +def map_plc_to_opcua_type(plc_type: str) -> ua.VariantType +``` + +**Tipos suportados:** +- BOOL → Boolean +- BYTE → Byte +- INT → Int16 +- DINT/INT32 → Int32 +- LINT → Int64 +- FLOAT → Float +- STRING → String + +--- + +## 6. Sincronização Bidirecional + +### 6.1 PLC → OPC UA (Leitura de Valores) + +```python +async def update_variables_from_plc() -> None +``` + +**Dois modos de operação:** + +#### 6.1.1 Acesso Direto à Memória (Otimizado) + +```python +async def _update_via_direct_memory_access() -> None +``` + +**Vantagens:** +- Zero chamadas C por variável +- Acesso direto via endereço de memória +- Máxima performance +- Requer cache de metadados válido + +**Processo:** +1. Lê metadados do cache (endereço, tamanho) +2. Acessa memória diretamente com `read_memory_direct()` +3. Atualiza nó OPC UA + +#### 6.1.2 Operações em Lote (Fallback) + +```python +async def _update_via_batch_operations() -> None +``` + +**Características:** +- Uma única chamada C para todas as variáveis +- Muito mais eficiente que leitura individual +- Fallback automático se cache não disponível + +**Processo:** +1. Coleta índices de todas as variáveis +2. Chamada única `get_var_values_batch()` +3. Processa resultados e atualiza nós + +### 6.2 OPC UA → PLC (Escrita de Valores) + +```python +async def sync_opcua_to_runtime() -> None +``` + +**Características:** +- Filtra apenas nós com permissão de escrita +- Lê valores atuais dos nós OPC UA +- Converte para formato PLC +- Escreve em lote no PLC + +**Tratamento de Erros:** +- Continua com outras variáveis se uma falhar +- Log de falhas individuais (limitado para evitar spam) +- Resumo de falhas ao final + +### 6.3 Loops de Sincronização + +```python +async def run_update_loop() -> None # PLC → OPC UA +async def run_opcua_to_runtime_loop() -> None # OPC UA → PLC +``` + +**Características:** +- Ciclo configurável (padrão 100ms) +- Execução paralela de ambos os loops +- Tratamento de exceções com retry +- Pausa em caso de erro + +--- + +## 7. Conversão de Tipos + +### 7.1 Conversão PLC → OPC UA + +```python +def convert_value_for_opcua(datatype: str, value: Any) -> Any +``` + +**Conversões:** +- BOOL: Converte para boolean Python +- Inteiros: Clamping para range correto +- FLOAT: Desempacota representação inteira +- STRING: Converte para string + +### 7.2 Conversão OPC UA → PLC + +```python +def convert_value_for_plc(datatype: str, value: Any) -> Any +``` + +**Conversões:** +- BOOL: Converte para 0/1 +- Inteiros: Clamping para range correto +- FLOAT: Empacota como representação inteira +- STRING: Converte para string + +### 7.3 Tratamento de Erros + +- Try/catch em todas as conversões +- Retorna valor padrão seguro em caso de erro +- Log de falhas de conversão + +--- + +## 8. Gerenciamento de Certificados + +### 8.1 OpcuaSecurityManager + +Gerencia certificados e políticas de segurança. + +#### 8.1.1 Geração de Certificados + +```python +async def generate_server_certificate( + cert_path: str, + key_path: str, + common_name: str = "OpenPLC OPC-UA Server", + key_size: int = 2048, + valid_days: int = 365, + app_uri: str = None +) -> bool +``` + +**Características:** +- Gera certificado auto-assinado com SANs +- Inclui hostname do sistema +- Inclui URIs de aplicação +- Suporta múltiplos endereços IP + +#### 8.1.2 Validação de Certificados + +```python +def _validate_certificate_format() -> bool +``` + +**Validações:** +- Formato PEM/DER +- Expiração +- Extensões obrigatórias (SAN, Key Usage) +- Compatibilidade com OPC UA + +#### 8.1.3 Certificados de Cliente + +```python +def validate_client_certificate(client_cert_pem: str) -> bool +``` + +**Características:** +- Comparação de fingerprint SHA256 +- Suporte a múltiplos certificados confiáveis +- Modo "trust all" configurável + +--- + +## 9. Configuração + +### 9.1 Estrutura de Configuração + +```python +OpcuaMasterConfig +├── server +│ ├── name +│ ├── endpoint_url +│ ├── application_uri +│ ├── product_uri +│ └── security_profiles[] +├── security +│ ├── server_certificate_strategy +│ ├── server_certificate_custom +│ ├── server_private_key_custom +│ └── trusted_client_certificates[] +├── users[] +├── address_space +│ ├── namespace_uri +│ ├── variables[] +│ ├── structures[] +│ └── arrays[] +└── cycle_time_ms +``` + +### 9.2 Perfis de Segurança + +```python +SecurityProfile +├── name +├── enabled +├── security_policy (None, Basic256Sha256, etc) +├── security_mode (None, Sign, SignAndEncrypt) +└── auth_methods[] (Anonymous, Username, Certificate) +``` + +--- + +## 10. Cache de Metadados + +### 10.1 Inicialização do Cache + +```python +async def _initialize_variable_cache(self, indices: List[int]) -> None +``` + +**Dados Armazenados:** +- Índice da variável +- Endereço de memória +- Tamanho em bytes +- Tipo inferido + +### 10.2 Uso do Cache + +- Acesso direto à memória sem chamadas C +- Fallback automático se cache inválido +- Atualização sob demanda + +--- + +## 11. Tratamento de Erros e Exceções + +### 11.1 Estratégias de Tratamento + +1. **Inicialização**: Falha rápida com mensagens claras +2. **Loops de Sincronização**: Continua com retry após pausa +3. **Operações de Nó**: Continua com próximo nó +4. **Conversão de Tipos**: Retorna valor padrão seguro + +### 11.2 Logging de Erros + +- Mensagens descritivas com contexto +- Stack traces em casos críticos +- Limitação de spam em loops + +--- + +## 12. Endpoints e Conectividade + +### 12.1 Normalização de Endpoints + +```python +def normalize_endpoint_url(endpoint_url: str) -> str +``` + +- Substitui 0.0.0.0 por localhost para compatibilidade +- Preserva porta e caminho + +### 12.2 Sugestões de Endpoints para Cliente + +```python +def suggest_client_endpoints(server_endpoint: str) -> Dict[str, str] +``` + +**Variações sugeridas:** +- Local connection (localhost) +- Same machine (127.0.0.1) +- Network hostname +- Network IP + +--- + +## 13. Limpeza de Recursos + +### 13.1 Cleanup de Certificados Temporários + +```python +def _cleanup_temp_files(self) -> None +``` + +- Remove arquivos temporários de certificados +- Executado ao parar o servidor + +### 13.2 Shutdown Graceful + +- Aguarda threads com timeout +- Libera recursos do servidor +- Limpa referências globais + +--- + +## 14. Problemas Conhecidos e Limitações + +### 14.1 Código Inchado + +- Muita lógica em um único arquivo +- Difícil de testar componentes isoladamente +- Difícil de manter e debugar + +### 14.2 Falta de Modularização + +- Sem separação clara de responsabilidades +- Sem interfaces bem definidas +- Difícil de reutilizar componentes + +### 14.3 Testabilidade + +- Difícil de mockar dependências +- Sem injeção de dependência +- Acoplamento forte com asyncua + +--- + +## 15. Próximos Passos para Refatoração + +Ver documento `OPCUA_PLUGIN_REFACTORING.md` para instruções de reorganização em `plugin.py`. + +--- + +## Referências + +- **asyncua**: https://github.com/FreeOpcUa/opcua-asyncio +- **OPC UA Specification**: https://opcfoundation.org/ +- **IEC 61131-3**: Standard for PLC programming languages diff --git a/OPCUA_PLUGIN_REFACTORING.md b/OPCUA_PLUGIN_REFACTORING.md new file mode 100644 index 00000000..f2f54bb6 --- /dev/null +++ b/OPCUA_PLUGIN_REFACTORING.md @@ -0,0 +1,886 @@ +# OPC UA Plugin - Guia de Refatoração para plugin.py + +## Visão Geral + +Este documento fornece instruções detalhadas para reorganizar o código do `opcua_plugin.py` (implementação monolítica) em `plugin.py` (implementação modular) de forma que seja mais robusta a testes e erros. + +A refatoração segue princípios SOLID e padrões de design para melhorar testabilidade, manutenibilidade e robustez. + +--- + +## 1. Estratégia Geral de Refatoração + +### 1.1 Objetivos + +- **Modularização**: Separar responsabilidades em componentes independentes +- **Testabilidade**: Permitir testes unitários de cada componente +- **Robustez**: Melhorar tratamento de erros e recuperação +- **Manutenibilidade**: Código mais limpo e fácil de entender +- **Reutilização**: Componentes podem ser usados em outros contextos + +### 1.2 Princípios de Design + +1. **Single Responsibility Principle (SRP)**: Cada classe tem uma única responsabilidade +2. **Dependency Injection (DI)**: Dependências são injetadas, não criadas internamente +3. **Interface Segregation**: Interfaces pequenas e específicas +4. **Composition over Inheritance**: Preferir composição a herança +5. **Fail Fast**: Detectar erros cedo durante inicialização + +### 1.3 Estrutura de Diretórios Proposta + +``` +opcua/ +├── plugin.py # Entry point (thin wrapper) +├── config.py # Configuration loading +├── logging.py # Logging utilities +├── types/ +│ ├── __init__.py +│ ├── models.py # Data models +│ └── type_converter.py # Type conversion +├── security/ +│ ├── __init__.py +│ ├── user_manager.py # Authentication +│ ├── certificate_manager.py # Certificate handling +│ └── permission_ruleset.py # Authorization +├── server/ +│ ├── __init__.py +│ ├── server_manager.py # Server lifecycle +│ ├── address_space_builder.py # Node creation +│ └── sync_manager.py # Data synchronization +└── utils/ + ├── __init__.py + ├── memory_access.py # Direct memory access + └── type_mapping.py # Type utilities +``` + +--- + +## 2. Componentes a Extrair + +### 2.1 Logging Module (`logging.py`) + +**Status Atual**: Já existe, mas pode ser melhorado + +**Melhorias Necessárias**: +- Adicionar níveis de log (DEBUG, INFO, WARN, ERROR) +- Suportar formatação customizável +- Adicionar contexto de thread +- Implementar rotação de logs + +**Exemplo de Uso**: +```python +from .logging import get_logger + +logger = get_logger() +logger.info("Server started") +logger.error("Connection failed", exc_info=True) +``` + +### 2.2 Configuration Module (`config.py`) + +**Status Atual**: Existe, mas precisa de validação mais robusta + +**Melhorias Necessárias**: +- Validação de schema JSON +- Valores padrão para campos opcionais +- Suporte a variáveis de ambiente +- Detecção de configurações inválidas cedo + +**Exemplo de Uso**: +```python +from .config import load_config, validate_config + +config = load_config(config_path) +if not validate_config(config): + raise ConfigurationError("Invalid configuration") +``` + +### 2.3 Type System (`types/`) + +**Componentes**: + +#### 2.3.1 Data Models (`types/models.py`) + +Extrair todas as dataclasses de `opcua_plugin.py`: + +```python +from dataclasses import dataclass +from enum import Enum +from typing import Optional, List + +class AccessMode(Enum): + READ_ONLY = "readonly" + READ_WRITE = "readwrite" + +@dataclass +class NodePermissions: + viewer: str = "r" + operator: str = "r" + engineer: str = "rw" + + def can_read(self, role: str) -> bool: + perm = getattr(self, role, "") + return "r" in perm + + def can_write(self, role: str) -> bool: + perm = getattr(self, role, "") + return "w" in perm + +@dataclass +class VariableNode: + node: Any + plc_index: int + datatype: str + access_mode: AccessMode + permissions: NodePermissions + node_id: str = "" + is_array: bool = False + array_length: int = 0 +``` + +#### 2.3.2 Type Converter (`types/type_converter.py`) + +Extrair conversão de tipos: + +```python +from asyncua import ua +from typing import Any, Union + +class TypeConverter: + IEC_TO_OPCUA = { + "BOOL": ua.VariantType.Boolean, + "BYTE": ua.VariantType.Byte, + "INT": ua.VariantType.Int16, + "DINT": ua.VariantType.Int32, + # ... mais tipos + } + + @classmethod + def to_opcua_type(cls, iec_type: str) -> ua.VariantType: + """Convert IEC type to OPC UA type.""" + return cls.IEC_TO_OPCUA.get(iec_type.upper()) + + @classmethod + def to_opcua_value(cls, iec_type: str, value: Any) -> Any: + """Convert PLC value to OPC UA format.""" + # Implementação + pass + + @classmethod + def to_plc_value(cls, iec_type: str, value: Any) -> Any: + """Convert OPC UA value to PLC format.""" + # Implementação + pass +``` + +### 2.4 Security Module (`security/`) + +#### 2.4.1 User Manager (`security/user_manager.py`) + +Extrair `OpenPLCUserManager`: + +```python +from asyncua.server.user_managers import UserManager +from typing import Optional, Dict + +class OpenPLCUserManager(UserManager): + """Manages user authentication and authorization.""" + + def __init__(self, config: dict): + super().__init__() + self.config = config + self._users: Dict[str, dict] = {} + self._load_users() + + def get_user(self, iserver, username=None, password=None, certificate=None): + """Authenticate user.""" + # Implementação + pass + + def _load_users(self) -> None: + """Load users from configuration.""" + # Implementação + pass +``` + +#### 2.4.2 Certificate Manager (`security/certificate_manager.py`) + +Extrair gerenciamento de certificados: + +```python +from pathlib import Path +from typing import Optional, Tuple + +class CertificateManager: + """Manages server and client certificates.""" + + def __init__(self, certs_dir: Path, app_uri: str): + self.certs_dir = certs_dir + self.app_uri = app_uri + + async def setup_server_security(self, server, security_profiles: list) -> None: + """Setup security policies and certificates.""" + # Implementação + pass + + async def setup_client_validation(self, server, trusted_certs: list) -> None: + """Setup client certificate validation.""" + # Implementação + pass +``` + +#### 2.4.3 Permission Ruleset (`security/permission_ruleset.py`) + +Gerenciar permissões de nós: + +```python +from typing import Dict, Optional + +class OpenPLCPermissionRuleset: + """Manages node permissions and access control.""" + + def __init__(self): + self._node_permissions: Dict[str, NodePermissions] = {} + + def register_node_permissions(self, node_id: str, permissions: NodePermissions) -> None: + """Register permissions for a node.""" + self._node_permissions[node_id] = permissions + + def check_read_permission(self, node_id: str, user_role: str) -> bool: + """Check if user can read node.""" + # Implementação + pass + + def check_write_permission(self, node_id: str, user_role: str) -> bool: + """Check if user can write node.""" + # Implementação + pass +``` + +### 2.5 Server Module (`server/`) + +#### 2.5.1 Server Manager (`server/server_manager.py`) + +Gerenciar ciclo de vida do servidor: + +```python +from asyncua import Server +from typing import Optional, Any + +class OpcuaServerManager: + """Manages OPC UA server lifecycle.""" + + def __init__(self, config: dict, buffer_accessor: Any, plugin_dir: str): + self.config = config + self.buffer_accessor = buffer_accessor + self.plugin_dir = plugin_dir + self.server: Optional[Server] = None + self._running = False + + async def run(self) -> None: + """Run the server.""" + try: + await self._setup_components() + async with self.server: + await self._run_sync_loops() + finally: + await self._cleanup() + + async def stop(self) -> None: + """Stop the server.""" + self._running = False + + async def _setup_components(self) -> None: + """Setup all server components.""" + # Implementação + pass + + async def _run_sync_loops(self) -> None: + """Run synchronization loops.""" + # Implementação + pass + + async def _cleanup(self) -> None: + """Cleanup resources.""" + # Implementação + pass +``` + +#### 2.5.2 Address Space Builder (`server/address_space_builder.py`) + +Criar nós OPC UA: + +```python +from asyncua import Server +from typing import Dict, Optional + +class AddressSpaceBuilder: + """Builds OPC UA address space from configuration.""" + + def __init__(self, server: Server, namespace_uri: str, permission_ruleset=None): + self.server = server + self.namespace_uri = namespace_uri + self.permission_ruleset = permission_ruleset + self.variable_nodes: Dict[int, VariableNode] = {} + + async def initialize(self) -> bool: + """Initialize address space builder.""" + # Implementação + pass + + async def build_from_config(self, address_space_config: dict) -> Dict[int, VariableNode]: + """Build address space from configuration.""" + # Implementação + pass + + async def _create_variable(self, parent, config: dict) -> Optional[VariableNode]: + """Create a simple variable node.""" + # Implementação + pass + + async def _create_struct(self, parent, config: dict) -> None: + """Create a struct object.""" + # Implementação + pass + + async def _create_array(self, parent, config: dict) -> Optional[VariableNode]: + """Create an array variable.""" + # Implementação + pass +``` + +#### 2.5.3 Sync Manager (`server/sync_manager.py`) + +Sincronizar dados PLC ↔ OPC UA: + +```python +from typing import Dict, Any, Optional + +class SyncManager: + """Manages bidirectional synchronization between PLC and OPC UA.""" + + def __init__(self, variable_nodes: Dict[int, VariableNode], buffer_accessor: Any, cycle_time_ms: int = 100): + self.variable_nodes = variable_nodes + self.buffer_accessor = buffer_accessor + self.cycle_time_ms = cycle_time_ms + self._running = False + + async def start(self) -> None: + """Start synchronization.""" + self._running = True + + async def stop(self) -> None: + """Stop synchronization.""" + self._running = False + + async def run_plc_to_opcua_loop(self) -> None: + """Sync PLC values to OPC UA.""" + while self._running: + await self._sync_plc_to_opcua() + await asyncio.sleep(self.cycle_time_ms / 1000.0) + + async def run_opcua_to_plc_loop(self) -> None: + """Sync OPC UA values to PLC.""" + while self._running: + await self._sync_opcua_to_plc() + await asyncio.sleep(self.cycle_time_ms / 1000.0) + + async def _sync_plc_to_opcua(self) -> None: + """Synchronize PLC values to OPC UA nodes.""" + # Implementação + pass + + async def _sync_opcua_to_plc(self) -> None: + """Synchronize OPC UA values to PLC.""" + # Implementação + pass +``` + +### 2.6 Utilities Module (`utils/`) + +#### 2.6.1 Memory Access (`utils/memory_access.py`) + +Acesso direto à memória: + +```python +from typing import Any, Dict, List +import ctypes + +class MemoryAccessor: + """Provides direct memory access for performance optimization.""" + + @staticmethod + def read_direct(address: int, size: int) -> Any: + """Read value directly from memory.""" + # Implementação + pass + + @staticmethod + def initialize_cache(buffer_accessor, indices: List[int]) -> Dict[int, Any]: + """Initialize metadata cache for direct memory access.""" + # Implementação + pass +``` + +#### 2.6.2 Type Mapping (`utils/type_mapping.py`) + +Utilitários de mapeamento de tipos: + +```python +from asyncua import ua +from typing import Any + +class TypeMapper: + """Utility functions for type mapping and conversion.""" + + @staticmethod + def map_plc_to_opcua_type(plc_type: str) -> ua.VariantType: + """Map PLC type to OPC UA type.""" + # Implementação + pass + + @staticmethod + def infer_var_type(size: int) -> str: + """Infer variable type from size.""" + # Implementação + pass +``` + +--- + +## 3. Plugin Entry Point (`plugin.py`) + +O `plugin.py` deve ser um thin wrapper que orquestra os componentes: + +```python +import sys +import os +import asyncio +import threading +from typing import Optional + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from shared import ( + SafeBufferAccess, + SafeLoggingAccess, + safe_extract_runtime_args_from_capsule, +) + +from .logging import get_logger, log_info, log_warn, log_error +from .config import load_config +from .server import OpcuaServerManager + +# Plugin state +_runtime_args = None +_buffer_accessor: Optional[SafeBufferAccess] = None +_config: Optional[dict] = None +_server_manager: Optional[OpcuaServerManager] = None +_server_thread: Optional[threading.Thread] = None +_stop_event = threading.Event() + + +def init(args_capsule) -> bool: + """Initialize the OPC UA plugin.""" + global _runtime_args, _buffer_accessor, _config, _server_manager + + log_info("OPC UA Plugin initializing...") + + try: + # Extract runtime arguments + _runtime_args, error_msg = safe_extract_runtime_args_from_capsule(args_capsule) + if not _runtime_args: + log_error(f"Failed to extract runtime args: {error_msg}") + return False + + # Initialize logging + logging_accessor = SafeLoggingAccess(_runtime_args) + if logging_accessor.is_valid: + get_logger().initialize(logging_accessor) + log_info("Logging initialized") + + # Create buffer accessor + _buffer_accessor = SafeBufferAccess(_runtime_args) + if not _buffer_accessor.is_valid: + log_error(f"Failed to create buffer accessor: {_buffer_accessor.error_msg}") + return False + + # Load configuration + config_path, config_error = _buffer_accessor.get_config_path() + if not config_path: + log_error(f"Failed to get config path: {config_error}") + return False + + _config = load_config(config_path) + if not _config: + log_error("Failed to load configuration") + return False + + # Create server manager + plugin_dir = os.path.dirname(__file__) + _server_manager = OpcuaServerManager(_config, _buffer_accessor, plugin_dir) + + log_info("OPC UA Plugin initialized successfully") + return True + + except Exception as e: + log_error(f"Initialization error: {e}") + return False + + +def start_loop() -> bool: + """Start the OPC UA server.""" + global _server_thread + + log_info("Starting OPC UA server...") + + try: + if not _server_manager: + log_error("Plugin not initialized") + return False + + _stop_event.clear() + + _server_thread = threading.Thread( + target=_run_server_thread, + daemon=True, + name="opcua-server" + ) + _server_thread.start() + + log_info("OPC UA server thread started") + return True + + except Exception as e: + log_error(f"Failed to start server: {e}") + return False + + +def stop_loop() -> bool: + """Stop the OPC UA server.""" + global _server_thread + + log_info("Stopping OPC UA server...") + + try: + _stop_event.set() + + if _server_thread and _server_thread.is_alive(): + _server_thread.join(timeout=5.0) + + if _server_thread.is_alive(): + log_warn("Server thread did not stop within timeout") + else: + log_info("Server thread stopped") + + _server_thread = None + log_info("OPC UA server stopped") + return True + + except Exception as e: + log_error(f"Error stopping server: {e}") + return False + + +def cleanup() -> bool: + """Clean up plugin resources.""" + global _runtime_args, _buffer_accessor, _config, _server_manager, _server_thread + + log_info("Cleaning up OPC UA plugin...") + + try: + stop_loop() + + _runtime_args = None + _buffer_accessor = None + _config = None + _server_manager = None + _server_thread = None + + log_info("Cleanup completed") + return True + + except Exception as e: + log_error(f"Cleanup error: {e}") + return False + + +def _run_server_thread() -> None: + """Server thread main function.""" + global _server_manager + + async def _run_with_stop_check(): + """Run server with stop event monitoring.""" + async def _monitor_stop(): + while not _stop_event.is_set(): + await asyncio.sleep(0.1) + + if _server_manager: + await _server_manager.stop() + + monitor_task = asyncio.create_task(_monitor_stop()) + + try: + await _server_manager.run() + except asyncio.CancelledError: + pass + finally: + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass + + try: + asyncio.run(_run_with_stop_check()) + except Exception as e: + log_error(f"Server thread error: {e}") + + +__all__ = ['init', 'start_loop', 'stop_loop', 'cleanup'] +``` + +--- + +## 4. Estratégia de Testes + +### 4.1 Testes Unitários + +Cada componente deve ter testes unitários: + +```python +# tests/test_type_converter.py +import pytest +from opcua.types.type_converter import TypeConverter + +def test_bool_conversion(): + assert TypeConverter.to_opcua_value("BOOL", 1) == True + assert TypeConverter.to_opcua_value("BOOL", 0) == False + +def test_int_conversion(): + assert TypeConverter.to_opcua_value("DINT", 42) == 42 + assert TypeConverter.to_opcua_value("DINT", -42) == -42 + +def test_type_mapping(): + from asyncua import ua + assert TypeConverter.to_opcua_type("BOOL") == ua.VariantType.Boolean + assert TypeConverter.to_opcua_type("DINT") == ua.VariantType.Int32 +``` + +### 4.2 Testes de Integração + +Testar componentes juntos: + +```python +# tests/test_server_manager.py +import pytest +from opcua.server.server_manager import OpcuaServerManager + +@pytest.mark.asyncio +async def test_server_initialization(mock_config, mock_buffer_accessor): + manager = OpcuaServerManager(mock_config, mock_buffer_accessor, "/tmp") + assert await manager.run() is not None +``` + +### 4.3 Mocking de Dependências + +Usar pytest-mock para mockar dependências: + +```python +@pytest.fixture +def mock_buffer_accessor(mocker): + mock = mocker.MagicMock() + mock.is_valid = True + mock.get_var_values_batch.return_value = ([(42, "Success")], "Success") + return mock +``` + +--- + +## 5. Tratamento de Erros Robusto + +### 5.1 Validação de Entrada + +```python +def validate_config(config: dict) -> bool: + """Validate configuration structure.""" + required_keys = ["server", "address_space"] + + for key in required_keys: + if key not in config: + log_error(f"Missing required key: {key}") + return False + + return True +``` + +### 5.2 Recuperação de Erros + +```python +async def run_with_retry(coro, max_retries=3, delay=1.0): + """Run coroutine with retry logic.""" + for attempt in range(max_retries): + try: + return await coro + except Exception as e: + if attempt == max_retries - 1: + raise + log_warn(f"Attempt {attempt + 1} failed: {e}, retrying...") + await asyncio.sleep(delay) +``` + +### 5.3 Cleanup Garantido + +```python +async def run_with_cleanup(setup_coro, run_coro, cleanup_coro): + """Run with guaranteed cleanup.""" + try: + await setup_coro + await run_coro + finally: + await cleanup_coro +``` + +--- + +## 6. Checklist de Refatoração + +### Fase 1: Preparação +- [ ] Criar estrutura de diretórios +- [ ] Criar arquivos vazios com docstrings +- [ ] Configurar imports + +### Fase 2: Tipos e Utilitários +- [ ] Extrair `types/models.py` +- [ ] Extrair `types/type_converter.py` +- [ ] Extrair `utils/memory_access.py` +- [ ] Extrair `utils/type_mapping.py` + +### Fase 3: Segurança +- [ ] Extrair `security/user_manager.py` +- [ ] Extrair `security/certificate_manager.py` +- [ ] Extrair `security/permission_ruleset.py` + +### Fase 4: Servidor +- [ ] Extrair `server/address_space_builder.py` +- [ ] Extrair `server/sync_manager.py` +- [ ] Extrair `server/server_manager.py` + +### Fase 5: Integração +- [ ] Atualizar `plugin.py` como thin wrapper +- [ ] Atualizar `__init__.py` +- [ ] Testar imports + +### Fase 6: Testes +- [ ] Criar testes unitários +- [ ] Criar testes de integração +- [ ] Validar cobertura de testes + +### Fase 7: Validação +- [ ] Testar com configuração real +- [ ] Validar sincronização de dados +- [ ] Testar autenticação +- [ ] Testar tratamento de erros + +--- + +## 7. Benefícios da Refatoração + +### 7.1 Testabilidade +- Componentes podem ser testados isoladamente +- Fácil mockar dependências +- Testes mais rápidos e confiáveis + +### 7.2 Manutenibilidade +- Código mais limpo e organizado +- Responsabilidades bem definidas +- Fácil encontrar e corrigir bugs + +### 7.3 Robustez +- Melhor tratamento de erros +- Recuperação automática de falhas +- Validação mais rigorosa + +### 7.4 Extensibilidade +- Fácil adicionar novos tipos de nós +- Fácil adicionar novos métodos de autenticação +- Fácil adicionar novos perfis de segurança + +--- + +## 8. Migração Gradual + +### 8.1 Estratégia de Transição + +1. **Manter ambos os arquivos** durante a transição +2. **Testar `plugin.py` em paralelo** com `opcua_plugin.py` +3. **Migrar gradualmente** componentes +4. **Validar funcionalidade** em cada etapa +5. **Remover `opcua_plugin.py`** quando `plugin.py` estiver completo + +### 8.2 Compatibilidade + +- Ambos os arquivos devem exportar as mesmas funções +- Mesma interface de configuração +- Mesma interface de logging + +--- + +## 9. Documentação + +### 9.1 Docstrings + +Cada classe e função deve ter docstring clara: + +```python +def get_user(self, iserver, username=None, password=None, certificate=None): + """ + Authenticate a user. + + Args: + iserver: Internal server session + username: Username for password authentication + password: Password for password authentication + certificate: Client certificate for certificate authentication + + Returns: + AuthenticatedUser if successful, None otherwise + + Raises: + ValueError: If authentication method is not supported + """ +``` + +### 9.2 Type Hints + +Usar type hints em todas as funções: + +```python +def load_config(config_path: str) -> Optional[dict]: + """Load configuration from file.""" + pass + +async def run_server(config: dict, buffer_accessor: SafeBufferAccess) -> None: + """Run the OPC UA server.""" + pass +``` + +--- + +## 10. Próximas Etapas + +1. Revisar este documento com a equipe +2. Criar branch de feature para refatoração +3. Implementar componentes na ordem sugerida +4. Adicionar testes para cada componente +5. Validar funcionalidade completa +6. Fazer merge quando tudo estiver funcionando + +--- + +## Referências + +- **SOLID Principles**: https://en.wikipedia.org/wiki/SOLID +- **Dependency Injection**: https://en.wikipedia.org/wiki/Dependency_injection +- **Python Testing**: https://docs.pytest.org/ +- **asyncio**: https://docs.python.org/3/library/asyncio.html diff --git a/core/src/CMakeLists.txt b/core/src/CMakeLists.txt index 7cd2d893..c8543ef6 100644 --- a/core/src/CMakeLists.txt +++ b/core/src/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(plc_main ${CMAKE_SOURCE_DIR}/core/src/plc_app/scan_cycle_manager.c ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_driver.c ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_config.c + ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_utils.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/unix_socket.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_handler.c ) diff --git a/core/src/drivers/plugin_driver.c b/core/src/drivers/plugin_driver.c index f7cd7fd8..c7511dd1 100644 --- a/core/src/drivers/plugin_driver.c +++ b/core/src/drivers/plugin_driver.c @@ -18,6 +18,7 @@ #include "../plc_app/utils/log.h" #include "plugin_config.h" #include "plugin_driver.h" +#include "plugin_utils.h" #include #include #include @@ -504,10 +505,19 @@ int plugin_driver_stop(plugin_driver_t *driver) // Signal all plugins to stop for (int i = 0; i < driver->plugin_count; i++) { + plugin_instance_t *plugin = &driver->plugins[i]; + + // Skip disabled plugins + if (!plugin->config.enabled) + { + printf("[PLUGIN]: Skipping disabled plugin during stop: %s\n", plugin->config.name); + continue; + } + printf("[PLUGIN]: Stopping plugin %d/%d: %s\n", i + 1, driver->plugin_count, driver->plugins[i].config.name); - if (driver->plugins[i].python_plugin && driver->plugins[i].python_plugin->pFuncStop && - driver->plugins[i].running) + if (plugin->python_plugin && plugin->python_plugin->pFuncStop && + plugin->running) { plugin_instance_t *plugin = &driver->plugins[i]; if (plugin->config.enabled == 0) @@ -531,10 +541,9 @@ int plugin_driver_stop(plugin_driver_t *driver) plugin->running = 0; } - else if (driver->plugins[i].native_plugin && driver->plugins[i].native_plugin->stop && - driver->plugins[i].running) + else if (plugin->native_plugin && plugin->native_plugin->stop && + plugin->running) { - plugin_instance_t *plugin = &driver->plugins[i]; plugin->native_plugin->stop(); printf("[PLUGIN]: Native plugin %s stopped successfully.\n", plugin->config.name); plugin->running = 0; @@ -733,6 +742,9 @@ void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t * // Initialize mutex functions args->mutex_take = plugin_mutex_take; args->mutex_give = plugin_mutex_give; + args->get_var_list = get_var_list; + args->get_var_size = get_var_size; + args->get_var_count = get_var_count; // Set buffer mutex from driver args->buffer_mutex = &driver->buffer_mutex; @@ -1143,9 +1155,8 @@ void plugin_driver_cycle_end(plugin_driver_t *driver) // Cleanup Python plugin static void python_plugin_cleanup(plugin_instance_t *plugin) { - (void)plugin; // Suppress unused parameter warning // Cleanup Python resources - if (plugin && plugin->python_plugin) + if (plugin && plugin->python_plugin && plugin->config.enabled) { // Call cleanup function if available if (plugin->python_plugin->pFuncCleanup) diff --git a/core/src/drivers/plugin_types.h b/core/src/drivers/plugin_types.h index ea5933ac..dddb8730 100644 --- a/core/src/drivers/plugin_types.h +++ b/core/src/drivers/plugin_types.h @@ -17,6 +17,7 @@ #include "../lib/iec_types.h" #include +#include /** * @brief Logging function pointer types @@ -87,6 +88,11 @@ typedef struct int (*mutex_give)(pthread_mutex_t *mutex); pthread_mutex_t *buffer_mutex; + /* Variable access functions */ + void (*get_var_list)(size_t num_vars, size_t *indexes, void **result); + size_t (*get_var_size)(size_t idx); + uint16_t (*get_var_count)(void); + /* Plugin configuration */ char plugin_specific_config_file_path[256]; diff --git a/core/src/drivers/plugin_utils.c b/core/src/drivers/plugin_utils.c new file mode 100644 index 00000000..6eedd883 --- /dev/null +++ b/core/src/drivers/plugin_utils.c @@ -0,0 +1,59 @@ +#include "plugin_utils.h" +#include "../plc_app/image_tables.h" +#include +#include +#include + +// Wrapper function to get list of variable addresses +// Returns NULL for all addresses if no PLC program is loaded +void get_var_list(size_t num_vars, size_t *indexes, void **result) +{ + // Validate input parameters + if (!indexes || !result || num_vars == 0) + { + return; + } + + // Check if PLC program is loaded (function pointers are set) + if (!ext_get_var_count || !ext_get_var_addr) + { + for (size_t i = 0; i < num_vars; i++) + { + result[i] = NULL; + } + return; + } + + for (size_t i = 0; i < num_vars; i++) + { + size_t idx = indexes[i]; + if (idx >= ext_get_var_count()) + { + result[i] = NULL; + } + else + { + result[i] = ext_get_var_addr(idx); + } + } +} + +// Returns 0 if no PLC program is loaded +size_t get_var_size(size_t idx) +{ + if (!ext_get_var_size) + { + return 0; + } + return ext_get_var_size(idx); +} + +// Returns 0 if no PLC program is loaded +uint16_t get_var_count(void) +{ + if (!ext_get_var_count) + { + return 0; + } + return ext_get_var_count(); +} \ No newline at end of file diff --git a/core/src/drivers/plugin_utils.h b/core/src/drivers/plugin_utils.h new file mode 100644 index 00000000..52a6ebfc --- /dev/null +++ b/core/src/drivers/plugin_utils.h @@ -0,0 +1,11 @@ +#ifndef PLUGIN_UTILS_H +#define PLUGIN_UTILS_H + +#include +#include + +void get_var_list(size_t num_vars, size_t *indexes, void **result); +size_t get_var_size(size_t idx); +uint16_t get_var_count(void); + +#endif // PLUGIN_UTILS_H \ No newline at end of file diff --git a/core/src/drivers/plugins/python/opcua/SUBSCRIPTION_IMPLEMENTATION.md b/core/src/drivers/plugins/python/opcua/SUBSCRIPTION_IMPLEMENTATION.md new file mode 100644 index 00000000..2b26a949 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/SUBSCRIPTION_IMPLEMENTATION.md @@ -0,0 +1,85 @@ +# OPC-UA Subscription Implementation Plan + +## Overview + +This document outlines the implementation plan for adding OPC-UA subscription support to the OpenPLC OPC-UA plugin. Subscriptions enable push-based data updates, replacing inefficient polling with server-initiated notifications. + +## Current State (UPDATED) + +The plugin now supports subscriptions via asyncua's built-in subscription handling: +1. Reads PLC memory every `cycle_time_ms` (default 100ms) +2. Updates OPC-UA node values using `write_attribute_value()` with DataValue +3. Clients can create subscriptions and receive push notifications on value changes +4. Proper timestamps (SourceTimestamp, ServerTimestamp) are included + +## Implementation Status + +### Phase 1: Enable Native Subscription Support - COMPLETED +- [x] Verified asyncua server subscription handling works +- [x] Updated `_update_opcua_node()` to use `write_attribute_value()` with DataValue +- [x] Server reference passed to SynchronizationManager + +### Phase 2: Optimize Value Updates - COMPLETED +- [x] Using `write_attribute_value()` with proper DataValue objects +- [x] SourceTimestamp set to PLC cycle time (when value was read) +- [x] ServerTimestamp set to processing time +- [x] StatusCode set to Good for valid values + +### Phase 3: Subscription Configuration - PENDING +- [ ] Add subscription-related settings to config +- [ ] Configure default publishing intervals +- [ ] Set limits on max subscriptions/monitored items + +### Phase 4: Advanced Features - PENDING +- [ ] Deadband filtering for analog values +- [ ] Queue size configuration +- [ ] Sampling interval limits + +## Key asyncua APIs + +### Server Value Updates (IMPLEMENTED) +```python +# Our implementation in synchronization.py: +from datetime import datetime, timezone +from asyncua import ua + +# Create DataValue with timestamps +data_value = ua.DataValue( + Value=ua.Variant(opcua_value, expected_type), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=self._cycle_timestamp, # PLC cycle time + ServerTimestamp=datetime.now(timezone.utc) +) + +# Use write_attribute_value for optimal subscription triggering +await self.server.write_attribute_value( + var_node.node.nodeid, + data_value +) +``` + +This approach: +- Triggers data change notifications for subscribed clients +- Is faster than `write_value()` (fewer validation checks) +- Includes proper timestamps for audit trail +- Bypasses PreWrite callbacks (server-internal operation) + +### Subscription Parameters +- **PublishingInterval**: How often server sends notifications (ms) +- **LifetimeCount**: Number of publishing intervals before subscription expires +- **MaxKeepAliveCount**: Max intervals without notification before keep-alive +- **MaxNotificationsPerPublish**: Limit notifications per publish response +- **Priority**: Relative priority among subscriptions + +## Testing Strategy + +1. **Unit Tests**: Mock asyncua server, verify notification triggers +2. **Integration Tests**: Real server with Python client +3. **Manual Testing**: UAExpert, Prosys OPC UA Browser +4. **Performance Tests**: Compare bandwidth with polling vs subscriptions + +## References + +- [asyncua Documentation](https://opcua-asyncio.readthedocs.io/) +- [OPC UA Part 4: Services - Subscription Services](https://reference.opcfoundation.org/Core/Part4/) +- [OPC UA Part 5: Information Model - Subscription](https://reference.opcfoundation.org/Core/Part5/) diff --git a/core/src/drivers/plugins/python/opcua/__init__.py b/core/src/drivers/plugins/python/opcua/__init__.py new file mode 100644 index 00000000..3dff2d64 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/__init__.py @@ -0,0 +1,31 @@ +""" +OpenPLC OPC UA Plugin. + +This package implements an OPC UA server for the OpenPLC runtime, +providing industrial-grade connectivity using the asyncua library. + +Architecture: + - plugin.py: Entry point with init/start_loop/stop_loop/cleanup + - config.py: Configuration loading and validation + - opcua_logging.py: Centralized logging singleton + - server.py: OpcuaServerManager (main orchestrator) + - address_space.py: AddressSpaceBuilder (node creation) + - synchronization.py: SynchronizationManager (bidirectional sync) + - user_manager.py: OpenPLCUserManager (authentication) + - callbacks.py: PermissionCallbackHandler (access control) + - opcua_types.py: Type definitions (VariableNode, VariableMetadata) + - opcua_utils.py: Utility functions (type mapping, conversion) + - opcua_security.py: OpcuaSecurityManager (certificates, policies) + - opcua_memory.py: Direct memory access utilities + - opcua_endpoints_config.py: Endpoint URL utilities + +Usage: + The plugin is loaded by the OpenPLC runtime plugin system. + Configuration is provided via JSON file specified in plugins.conf. +""" + +# Re-export plugin interface for runtime compatibility +from .plugin import init, start_loop, stop_loop, cleanup + +__version__ = "2.0.0" +__all__ = ['init', 'start_loop', 'stop_loop', 'cleanup'] diff --git a/core/src/drivers/plugins/python/opcua/address_space.py b/core/src/drivers/plugins/python/opcua/address_space.py new file mode 100644 index 00000000..caae1154 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/address_space.py @@ -0,0 +1,422 @@ +""" +OPC UA Address Space Builder. + +This module provides the AddressSpaceBuilder class that creates OPC-UA nodes +from configuration. It handles simple variables, structures, and arrays. +""" + +import os +import sys +import traceback +from typing import Dict, Any, Optional + +from asyncua import Server, ua +from asyncua.common.node import Node + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import local modules (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_warn, log_error + from .opcua_types import VariableNode + from .opcua_utils import map_plc_to_opcua_type, convert_value_for_opcua +except ImportError: + from opcua_logging import log_info, log_warn, log_error + from opcua_types import VariableNode + from opcua_utils import map_plc_to_opcua_type, convert_value_for_opcua + +from shared.plugin_config_decode.opcua_config_model import ( + OpcuaConfig, + SimpleVariable, + StructVariable, + VariableField, + ArrayVariable, + VariablePermissions, +) + + +class AddressSpaceBuilder: + """ + Builds OPC-UA address space from configuration. + + Creates: + - Simple variable nodes + - Struct objects with field variables + - Array variable nodes + + After building, provides mappings for: + - variable_nodes: Dict[int, VariableNode] - index to node mapping + - node_permissions: Dict[str, VariablePermissions] - node_id to permissions + - nodeid_to_variable: Dict[Any, str] - NodeId to variable name mapping + """ + + def __init__( + self, + server: Server, + namespace_idx: int, + config: OpcuaConfig + ): + """ + Initialize the address space builder. + + Args: + server: The asyncua Server instance + namespace_idx: Namespace index for created nodes + config: Typed OpcuaConfig instance + """ + self.server = server + self.namespace_idx = namespace_idx + self.config = config + + # Output mappings (populated during build) + self.variable_nodes: Dict[int, VariableNode] = {} + self.node_permissions: Dict[str, VariablePermissions] = {} + self.nodeid_to_variable: Dict[Any, str] = {} + + async def build(self) -> bool: + """ + Create all nodes from configuration. + + Returns: + True if all nodes created successfully, False on error + """ + try: + # Get the Objects folder as parent + objects = self.server.get_objects_node() + + # Create simple variables + for var in self.config.address_space.variables: + try: + await self._create_simple_variable(objects, var) + except Exception as e: + log_error(f"Error creating variable {var.node_id}: {e}") + traceback.print_exc() + + # Create structures + for struct in self.config.address_space.structures: + try: + await self._create_struct(objects, struct) + except Exception as e: + log_error(f"Error creating struct {struct.node_id}: {e}") + traceback.print_exc() + + # Create arrays + for arr in self.config.address_space.arrays: + try: + await self._create_array(objects, arr) + except Exception as e: + log_error(f"Error creating array {arr.node_id}: {e}") + traceback.print_exc() + + log_info(f"Created {len(self.variable_nodes)} variable nodes") + return True + + except Exception as e: + log_error(f"Failed to create address space: {e}") + traceback.print_exc() + return False + + async def _create_simple_variable( + self, + parent_node: Node, + var: SimpleVariable + ) -> None: + """ + Create a simple OPC-UA variable node. + + Args: + parent_node: Parent node (typically Objects folder) + var: SimpleVariable configuration + """ + opcua_type = map_plc_to_opcua_type(var.datatype) + initial_value = convert_value_for_opcua(var.datatype, var.initial_value) + + # Create the variable node + node = await parent_node.add_variable( + self.namespace_idx, + var.browse_name, + ua.Variant(initial_value, opcua_type), + datatype=opcua_type + ) + + # Set display name and description + await node.write_attribute( + ua.AttributeIds.DisplayName, + ua.DataValue(ua.Variant( + ua.LocalizedText(var.display_name), + ua.VariantType.LocalizedText + )) + ) + await node.write_attribute( + ua.AttributeIds.Description, + ua.DataValue(ua.Variant( + ua.LocalizedText(var.description), + ua.VariantType.LocalizedText + )) + ) + + # Set writable if any role has write permission + has_write_permission = self._check_write_permission(var.permissions) + if has_write_permission: + await node.set_writable() + log_info(f"Node {var.node_id} set as writable") + else: + log_info(f"Node {var.node_id} set as read-only") + + # Store node mapping + access_mode = "readwrite" if has_write_permission else "readonly" + var_node = VariableNode( + node=node, + debug_var_index=var.index, + datatype=var.datatype, + access_mode=access_mode, + is_array_element=False + ) + + self.variable_nodes[var.index] = var_node + self.node_permissions[var.node_id] = var.permissions + self.nodeid_to_variable[node.nodeid] = var.node_id + + log_info(f"Created variable {var.node_id} (index: {var.index})") + + async def _create_struct( + self, + parent_node: Node, + struct: StructVariable + ) -> None: + """ + Create an OPC-UA struct (object with fields). + + Args: + parent_node: Parent node (typically Objects folder) + struct: StructVariable configuration + """ + # Create parent object for the struct + struct_obj = await parent_node.add_object( + self.namespace_idx, + struct.browse_name + ) + + # Set display name and description + await struct_obj.write_attribute( + ua.AttributeIds.DisplayName, + ua.DataValue(ua.Variant( + ua.LocalizedText(struct.display_name), + ua.VariantType.LocalizedText + )) + ) + await struct_obj.write_attribute( + ua.AttributeIds.Description, + ua.DataValue(ua.Variant( + ua.LocalizedText(struct.description), + ua.VariantType.LocalizedText + )) + ) + + # Create fields + for field in struct.fields: + await self._create_struct_field(struct_obj, struct.node_id, field) + + log_info(f"Created struct {struct.node_id} with {len(struct.fields)} fields") + + async def _create_struct_field( + self, + parent_node: Node, + struct_node_id: str, + field: VariableField + ) -> None: + """ + Create a field within a struct. + + Supports nested fields for complex types (FB instances, nested structs). + When a field has nested fields, creates an Object node and recursively + creates child fields. Leaf fields (no nested fields) create Variable nodes. + + Args: + parent_node: Parent struct object node + struct_node_id: Parent struct's node_id for building field path + field: VariableField configuration + """ + field_node_id = f"{struct_node_id}.{field.name}" + + # Check if this is a complex type with nested fields + if field.fields and len(field.fields) > 0: + # Create an Object node for complex types (FB instances, nested structs) + field_obj = await parent_node.add_object( + self.namespace_idx, + field.name + ) + + # Set display name + await field_obj.write_attribute( + ua.AttributeIds.DisplayName, + ua.DataValue(ua.Variant( + ua.LocalizedText(field.name), + ua.VariantType.LocalizedText + )) + ) + + # Recursively create nested fields + for nested_field in field.fields: + await self._create_struct_field(field_obj, field_node_id, nested_field) + + log_info(f"Created nested object {field_node_id} with {len(field.fields)} fields") + return + + # This is a leaf field - create a Variable node + opcua_type = map_plc_to_opcua_type(field.datatype) + initial_value = convert_value_for_opcua(field.datatype, field.initial_value) + + # Create the variable node + node = await parent_node.add_variable( + self.namespace_idx, + field.name, + ua.Variant(initial_value, opcua_type), + datatype=opcua_type + ) + + # Set display name + await node.write_attribute( + ua.AttributeIds.DisplayName, + ua.DataValue(ua.Variant( + ua.LocalizedText(field.name), + ua.VariantType.LocalizedText + )) + ) + + # Set writable if any role has write permission + has_write_permission = self._check_write_permission(field.permissions) + if has_write_permission: + await node.set_writable() + + # Store node mapping (only for leaf fields with valid indices) + if field.index is not None: + access_mode = "readwrite" if has_write_permission else "readonly" + var_node = VariableNode( + node=node, + debug_var_index=field.index, + datatype=field.datatype, + access_mode=access_mode, + is_array_element=False + ) + + self.variable_nodes[field.index] = var_node + self.node_permissions[field_node_id] = field.permissions + self.nodeid_to_variable[node.nodeid] = field_node_id + + log_info(f"Created field {field_node_id} (index: {field.index})") + else: + # Complex types (FBs, nested structs) have null indices - only leaf fields have indices + log_info(f"Field {field_node_id} is a complex type (no index) - skipping node mapping") + + async def _create_array( + self, + parent_node: Node, + arr: ArrayVariable + ) -> None: + """ + Create an OPC-UA array variable. + + Args: + parent_node: Parent node (typically Objects folder) + arr: ArrayVariable configuration + """ + opcua_type = map_plc_to_opcua_type(arr.datatype) + initial_value = convert_value_for_opcua(arr.datatype, arr.initial_value) + + # Create array with initial values + array_values = [initial_value] * arr.length + array_variant = ua.Variant(array_values, opcua_type) + + # Create the variable node + node = await parent_node.add_variable( + self.namespace_idx, + arr.browse_name, + array_variant, + datatype=opcua_type + ) + + # Set array dimensions - critical for OPC-UA clients to recognize this as an array + # ValueRank = 1 means OneDimension (one-dimensional array) + # ArrayDimensions specifies the size of each dimension + await node.write_attribute( + ua.AttributeIds.ValueRank, + ua.DataValue(ua.Variant(1, ua.VariantType.Int32)) + ) + await node.write_attribute( + ua.AttributeIds.ArrayDimensions, + ua.DataValue(ua.Variant([arr.length], ua.VariantType.UInt32)) + ) + + # Set display name + await node.write_attribute( + ua.AttributeIds.DisplayName, + ua.DataValue(ua.Variant( + ua.LocalizedText(arr.display_name), + ua.VariantType.LocalizedText + )) + ) + + # Set writable if any role has write permission + has_write_permission = self._check_write_permission(arr.permissions) + if has_write_permission: + await node.set_writable() + + # Store node mapping + access_mode = "readwrite" if has_write_permission else "readonly" + var_node = VariableNode( + node=node, + debug_var_index=arr.index, + datatype=arr.datatype, + access_mode=access_mode, + is_array_element=False, + array_length=arr.length + ) + + self.variable_nodes[arr.index] = var_node + self.node_permissions[arr.node_id] = arr.permissions + self.nodeid_to_variable[node.nodeid] = arr.node_id + + log_info(f"Created array {arr.node_id}[{arr.length}] (index: {arr.index})") + + def _check_write_permission(self, permissions: VariablePermissions) -> bool: + """ + Check if any role has write permission. + + Args: + permissions: VariablePermissions object + + Returns: + True if any role has write permission + """ + try: + if not permissions: + log_warn("No permissions object provided, defaulting to read-only") + return False + + # Check each role for write permission + viewer_perm = getattr(permissions, 'viewer', '') + operator_perm = getattr(permissions, 'operator', '') + engineer_perm = getattr(permissions, 'engineer', '') + + has_write = ( + (viewer_perm and 'w' in str(viewer_perm)) or + (operator_perm and 'w' in str(operator_perm)) or + (engineer_perm and 'w' in str(engineer_perm)) + ) + + return bool(has_write) + + except (AttributeError, TypeError) as e: + log_warn(f"Invalid permissions object: {e}, defaulting to read-only") + return False + + def get_variable_indices(self) -> list: + """Get list of all variable indices for memory cache initialization.""" + return list(self.variable_nodes.keys()) diff --git a/core/src/drivers/plugins/python/opcua/callbacks.py b/core/src/drivers/plugins/python/opcua/callbacks.py new file mode 100644 index 00000000..cec7fe03 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/callbacks.py @@ -0,0 +1,378 @@ +""" +OPC UA Permission Callback Handler. + +This module provides role-based access control for OPC-UA server operations +via PreRead and PreWrite callbacks. +""" + +import os +import sys +from typing import Dict, Any, Optional + +from asyncua import Server, ua +from asyncua.server.internal_server import InternalServer +from asyncua.common.callback import CallbackType + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import logging (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_warn, log_error, log_debug +except ImportError: + from opcua_logging import log_info, log_warn, log_error, log_debug + +from shared.plugin_config_decode.opcua_config_model import VariablePermissions + + +class PermissionCallbackHandler: + """ + Handles OPC-UA read/write permission callbacks. + + Uses PreRead and PreWrite callbacks to enforce role-based + access control on variable nodes. + + Role permissions: + - 'r' = read allowed + - 'w' = write allowed + - 'rw' = read and write allowed + + Usage: + handler = PermissionCallbackHandler(node_permissions, nodeid_to_variable) + await handler.register(server) + """ + + def __init__( + self, + node_permissions: Dict[str, VariablePermissions], + nodeid_to_variable: Dict[Any, str] + ): + """ + Initialize the permission callback handler. + + Args: + node_permissions: Dict mapping node_id string to VariablePermissions + nodeid_to_variable: Dict mapping NodeId to variable name string + """ + self.node_permissions = node_permissions + self.nodeid_to_variable = nodeid_to_variable + + async def register(self, server: Server) -> bool: + """ + Register callbacks with the server. + + Must be called AFTER server.init() but BEFORE server.start(). + + Args: + server: The asyncua Server instance + + Returns: + True if callbacks registered successfully + """ + log_info("=== REGISTERING PERMISSION CALLBACKS ===") + + try: + if server.iserver is None: + log_warn("Server iserver is None, cannot register callbacks") + return False + + # Register PreWrite callback (synchronous method) + log_debug("Registering PreWrite callback...") + server.iserver.subscribe_server_callback( + CallbackType.PreWrite, + self._on_pre_write + ) + log_info("PreWrite callback registered successfully") + + # Register PreRead callback (synchronous method) + log_debug("Registering PreRead callback...") + server.iserver.subscribe_server_callback( + CallbackType.PreRead, + self._on_pre_read + ) + log_info("PreRead callback registered successfully") + + log_info(f"Permission callbacks registered for {len(self.node_permissions)} nodes") + return True + + except Exception as e: + log_error(f"Failed to register callbacks: {e}") + + # Try alternative callback registration method + log_info("Trying alternative callback registration...") + try: + if hasattr(server, 'subscribe_server_callback'): + server.subscribe_server_callback( + CallbackType.PreWrite, + self._on_pre_write + ) + server.subscribe_server_callback( + CallbackType.PreRead, + self._on_pre_read + ) + log_info("Alternative callback registration successful") + return True + else: + log_error("No callback registration method found") + return False + except Exception as e2: + log_error(f"Alternative callback registration also failed: {e2}") + return False + + async def _on_pre_read(self, event: Any, dispatcher: Any) -> None: + """ + Callback for pre-read operations with permission enforcement. + + Checks if the user has read permission ('r') for the requested node. + Raises ua.UaError to deny access. + + Args: + event: The callback event containing user and request params + dispatcher: The event dispatcher + """ + # Extract user from event + user = getattr(event, 'user', None) + + # The event contains request_params with ReadValueIds + if not hasattr(event, 'request_params'): + return + if not hasattr(event.request_params, 'NodesToRead'): + return + + # Process each node being read + for read_value_id in event.request_params.NodesToRead: + node_id = read_value_id.NodeId + simple_node_id = self._resolve_node_id(node_id) + + if not simple_node_id: + continue + + # Get permissions for this node + permissions = self._get_permissions_for_node(simple_node_id) + if not permissions: + continue + + # Check user's read permission + if user and hasattr(user, 'openplc_role'): + user_role = self._normalize_role(user.openplc_role) + username = getattr(user, 'name', 'unknown') + role_permission = getattr(permissions, user_role, "") + + if "r" not in str(role_permission): + log_warn(f"DENY read for user {username} " + f"(role: {user_role}) on node {simple_node_id}") + raise ua.UaError("Access denied: insufficient read permissions") + else: + # Anonymous or unauthenticated client - check viewer permissions + viewer_perm = getattr(permissions, 'viewer', '') + if "r" not in str(viewer_perm): + log_warn(f"DENY read for anonymous client on node {simple_node_id}") + raise ua.UaError("Access denied: anonymous read not allowed") + + async def _on_pre_write(self, event: Any, dispatcher: Any) -> None: + """ + Callback for pre-write operations with permission enforcement. + + Checks if the user has write permission ('w') for the requested node. + Raises ua.UaError to deny access. + + Server-internal writes (is_external=False) are allowed without + permission checks as they are privileged runtime operations. + + Args: + event: The callback event containing user and request params + dispatcher: The event dispatcher + """ + # Check if this is an internal server operation (runtime sync) + # ServerItemCallback has is_external=False for internal operations + is_external = getattr(event, 'is_external', True) + if not is_external: + # log_debug("Internal server write operation - bypassing permission check") + return + + # Extract user from event + user = getattr(event, 'user', None) + + # The event contains request_params with WriteValues + if not hasattr(event, 'request_params'): + return + if not hasattr(event.request_params, 'NodesToWrite'): + return + + # Process each node being written + for write_value in event.request_params.NodesToWrite: + node_id = write_value.NodeId + value = write_value.Value.Value if hasattr(write_value, 'Value') else None + + simple_node_id = self._resolve_node_id(node_id) + + if not simple_node_id: + # Log for debugging + log_debug(f"NodeId {node_id} not found in mapping") + continue + + # Get permissions for this node + permissions = self._get_permissions_for_node(simple_node_id) + + # Check user's write permission + if user and hasattr(user, 'openplc_role'): + user_role = self._normalize_role(user.openplc_role) + username = getattr(user, 'name', 'unknown') + + if permissions: + role_permission = getattr(permissions, user_role, "") + + if "w" not in str(role_permission): + log_warn(f"DENY write for user {username} " + f"(role: {user_role}) on node {simple_node_id}: {value}") + raise ua.UaError("Access denied: insufficient write permissions") + else: + log_info(f"ALLOW write for user {username} " + f"(role: {user_role}) on node {simple_node_id}: {value}") + else: + # No permissions configured - deny by default (fail-closed) + log_warn(f"DENY write for user {username} " + f"(role: {user_role}) on node {simple_node_id}: {value} " + f"(no permissions configured)") + raise ua.UaError("Access denied: no permissions configured for this node") + else: + # Anonymous external client user + if permissions: + viewer_perm = getattr(permissions, 'viewer', '') + if "w" not in str(viewer_perm): + log_warn(f"DENY write for anonymous client on node {simple_node_id}") + raise ua.UaError("Access denied: anonymous write not allowed") + log_info(f"ALLOW write for anonymous client on node {simple_node_id}: {value}") + else: + # No permissions configured - deny by default (fail-closed) + log_warn(f"DENY write for anonymous client on node {simple_node_id}: {value} " + f"(no permissions configured)") + raise ua.UaError("Access denied: no permissions configured for this node") + + def _resolve_node_id(self, node_id: Any) -> Optional[str]: + """ + Resolve NodeId to variable name string. + + Tries multiple resolution strategies: + 1. Direct lookup in nodeid_to_variable mapping + 2. String comparison of NodeId + 3. Parse NodeId string format + + Args: + node_id: The NodeId to resolve + + Returns: + Variable name string or None if not found + """ + # Try direct lookup in mapping + for mapped_node, var_name in self.nodeid_to_variable.items(): + if node_id == mapped_node: + return var_name + if str(node_id) == str(mapped_node): + return var_name + + # Try to parse NodeId string format + node_id_str = str(node_id) + + if node_id_str.startswith("ns=") and ";" in node_id_str: + # Format: ns=2;s=VariableName or ns=2;i=1234 + node_parts = node_id_str.split(";")[-1] + if "=" in node_parts: + simple_node_id = node_parts.split("=", 1)[-1] + else: + simple_node_id = node_parts + + # Check if this matches any stored node_id + for stored_node_id in self.node_permissions.keys(): + if stored_node_id == simple_node_id: + return simple_node_id + if stored_node_id.endswith(simple_node_id): + return stored_node_id + + return simple_node_id + + # Handle NodeId object with Identifier attribute + if hasattr(node_id, 'Identifier') and hasattr(node_id, 'NamespaceIndex'): + return f"ns={node_id.NamespaceIndex};i={node_id.Identifier}" + + return None + + def _get_permissions_for_node(self, simple_node_id: str) -> Optional[VariablePermissions]: + """ + Get permissions for a node. + + Args: + simple_node_id: The resolved node ID string + + Returns: + VariablePermissions or None if not configured + """ + # Direct lookup + if simple_node_id in self.node_permissions: + return self.node_permissions[simple_node_id] + + # Try suffix match for struct fields + for stored_node_id, perms in self.node_permissions.items(): + if stored_node_id == simple_node_id: + return perms + if stored_node_id.endswith(simple_node_id): + return perms + + return None + + def _normalize_role(self, role: Any) -> str: + """ + Normalize role to OpenPLC role string format. + + Handles UserRole enum, string, and other formats. + Maps asyncua UserRole enum to OpenPLC roles: + - UserRole.Admin -> "engineer" + - UserRole.User -> "viewer" + + Args: + role: The role value (enum, string, or other) + + Returns: + OpenPLC role string: "viewer", "operator", or "engineer" + """ + # Handle string roles directly + if isinstance(role, str): + role_lower = role.lower() + # Check if it's a valid OpenPLC role + if role_lower in ("viewer", "operator", "engineer"): + return role_lower + # Handle asyncua role string representations + if "admin" in role_lower: + return "engineer" + if "user" in role_lower: + return "viewer" + return role_lower + + # Handle enum with name attribute (like asyncua UserRole) + if hasattr(role, 'name'): + role_name = role.name.lower() + # Map asyncua UserRole to OpenPLC roles + if role_name == "admin": + return "engineer" + if role_name == "user": + return "viewer" + # Check if it's already an OpenPLC role name + if role_name in ("viewer", "operator", "engineer"): + return role_name + return role_name + + # Fallback: convert to string and try to extract role + role_str = str(role).lower() + if "admin" in role_str: + return "engineer" + if "user" in role_str: + return "viewer" + + # Default to viewer for unknown roles (most restrictive) + log_warn(f"Unknown role format '{role}', defaulting to 'viewer'") + return "viewer" diff --git a/core/src/drivers/plugins/python/opcua/config.py b/core/src/drivers/plugins/python/opcua/config.py new file mode 100644 index 00000000..f4422808 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/config.py @@ -0,0 +1,179 @@ +""" +OPC UA plugin configuration loader. + +This module provides configuration loading for the OPC UA plugin, +returning typed OpcuaConfig dataclass for type safety and IDE support. +""" + +import json +import sys +import os +from pathlib import Path +from typing import Any, Optional + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import logging (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_error, log_warn +except ImportError: + from opcua_logging import log_info, log_error, log_warn + +from shared.plugin_config_decode.opcua_config_model import ( + OpcuaConfig, + OpcuaMasterConfig, + ServerConfig, + SecurityConfig, + AddressSpace, + SecurityProfile, + User, +) + + +def load_config(config_path: str) -> Optional[OpcuaConfig]: + """ + Load OPC UA configuration from JSON file. + + Args: + config_path: Path to configuration file + + Returns: + OpcuaConfig instance or None if loading fails + """ + try: + path = Path(config_path) + if not path.exists(): + log_error(f"Configuration file not found: {config_path}") + return None + + # Use OpcuaMasterConfig to load and parse configuration + master_config = OpcuaMasterConfig() + master_config.import_config_from_file(config_path) + master_config.validate() + + if not master_config.plugins: + log_error("No OPC-UA plugins configured") + return None + + # Return first plugin's config (single-server approach) + config = master_config.plugins[0].config + + log_info(f"Configuration loaded from {config_path}") + log_info(f"Server: {config.server.name}") + log_info(f"Endpoint: {config.server.endpoint_url}") + log_info(f"Variables: {len(config.address_space.variables)}") + log_info(f"Structures: {len(config.address_space.structures)}") + log_info(f"Arrays: {len(config.address_space.arrays)}") + + return config + + except json.JSONDecodeError as e: + log_error(f"Invalid JSON in configuration file: {e}") + return None + except ValueError as e: + log_error(f"Configuration validation error: {e}") + return None + except Exception as e: + log_error(f"Failed to load configuration: {e}") + return None + + +def load_config_from_dict(raw_config: dict) -> Optional[OpcuaConfig]: + """ + Load OPC UA configuration from a dictionary. + + Useful for testing or when config is provided programmatically. + + Args: + raw_config: Configuration dictionary + + Returns: + OpcuaConfig instance or None if parsing fails + """ + try: + # Normalize to single-server format + config_dict = _normalize_config(raw_config) + + # Parse to typed dataclass + config = OpcuaConfig.from_dict(config_dict) + + return config + + except ValueError as e: + log_error(f"Configuration parsing error: {e}") + return None + except Exception as e: + log_error(f"Failed to parse configuration: {e}") + return None + + +def _normalize_config(raw_config: Any) -> dict: + """ + Normalize configuration to single-server format. + + Handles both: + - Old format: List of plugin configurations + - New format: Single server configuration dictionary + """ + # If it's a list (old format), extract first plugin's config + if isinstance(raw_config, list): + if not raw_config: + return {} + + first_plugin = raw_config[0] + if "config" in first_plugin: + return first_plugin["config"] + return first_plugin + + # If it's already a dict with "config" key (wrapper format) + if isinstance(raw_config, dict) and "config" in raw_config: + return raw_config["config"] + + # Already in new format + return raw_config + + +def get_default_config() -> OpcuaConfig: + """ + Get default configuration for development/testing. + + Returns: + Default OpcuaConfig instance + """ + default_dict = { + "server": { + "name": "OpenPLC OPC-UA Server", + "application_uri": "urn:autonomy-logic:openplc:opcua:server", + "product_uri": "urn:autonomy-logic:openplc", + "endpoint_url": "opc.tcp://0.0.0.0:4840", + "security_profiles": [ + { + "name": "insecure", + "enabled": True, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "trusted_client_certificates": [] + }, + "users": [], + "address_space": { + "namespace_uri": "urn:openplc:opcua", + "variables": [], + "structures": [], + "arrays": [] + }, + "cycle_time_ms": 100 + } + + return OpcuaConfig.from_dict(default_dict) diff --git a/core/src/drivers/plugins/python/opcua/opcua.json b/core/src/drivers/plugins/python/opcua/opcua.json new file mode 100644 index 00000000..a01b12e9 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua.json @@ -0,0 +1,119 @@ +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "OpenPLC OPC UA Server", + "application_uri": "urn:freeopcua:python:server", + "product_uri": "urn:openplc:runtime:product", + "endpoint_url": "opc.tcp://localhost:4840/openplc/opcua", + "security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": [ + "Anonymous" + ] + }, + { + "name": "signed", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "Sign", + "auth_methods": [ + "Username", + "Certificate" + ] + }, + { + "name": "signed_encrypted", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": [ + "Username", + "Certificate" + ] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [ + { + "id": "engineer_client", + "pem": "-----BEGIN CERTIFICATE-----\nMIIE8jCCA9qgAwIBAgIEaTk+bjANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCQ0ExEzARBgNVBAcMCkNhbGlmb3JuaWExFzAVBgNVBAoMDkF1\ndG9ub215IExvZ2ljMSEwHwYDVQQDDBhVYUV4cGVydEBERVNLVE9QLUpNSEg1S0cw\nHhcNMjUxMjEwMDkzMzM0WhcNMzAxMjA5MDkzMzM0WjBrMQswCQYDVQQGEwJVUzEL\nMAkGA1UECAwCQ0ExEzARBgNVBAcMCkNhbGlmb3JuaWExFzAVBgNVBAoMDkF1dG9u\nb215IExvZ2ljMSEwHwYDVQQDDBhVYUV4cGVydEBERVNLVE9QLUpNSEg1S0cwggEi\nMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9/Z4YnBVA4oe5gJM2a6+MNa64\nMNujhmeh2jXHG0TsVS5j4vrsY7+/HPSRlXFfmytRknPmLWuYC+v+QJkhUv/qcBUS\nua/TFcKarNospTVhk/uUzM3jWVbOk+ObIBpw1ifbPHLZgYIuV8olVloNLpmSGw6X\niZG6n4Gz8UN5X2vxoysu7BcPnjhIG0ZnypgWv1d4O3R3cam/3k1XvOdDzU4xdhn0\nMFGrimUtNrRRpnWmpqD4WUSzlunUOaNYpq8CNZCPYX4mW3CaJQoBCZkyhmRBXtSQ\nLcqjs8fVnZ1ZHCbwz9AxWvdhKXgA0WHF4h+WI+bScrlzM7vAzBNf1hw2BabXAgMB\nAAGjggGcMIIBmDAMBgNVHRMBAf8EAjAAMFAGCWCGSAGG+EIBDQRDFkEiR2VuZXJh\ndGVkIHdpdGggVW5pZmllZCBBdXRvbWF0aW9uIFVBIEJhc2UgTGlicmFyeSB1c2lu\nZyBPcGVuU1NMIjAdBgNVHQ4EFgQU0W1mOjdLGtwpb5CZtI09aNkfJJswgZgGA1Ud\nIwSBkDCBjYAU0W1mOjdLGtwpb5CZtI09aNkfJJuhb6RtMGsxCzAJBgNVBAYTAlVT\nMQswCQYDVQQIDAJDQTETMBEGA1UEBwwKQ2FsaWZvcm5pYTEXMBUGA1UECgwOQXV0\nb25vbXkgTG9naWMxITAfBgNVBAMMGFVhRXhwZXJ0QERFU0tUT1AtSk1ISDVLR4IE\naTk+bjAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsG\nAQUFBwMCMEoGA1UdEQRDMEGGLnVybjpERVNLVE9QLUpNSEg1S0c6VW5pZmllZEF1\ndG9tYXRpb246VWFFeHBlcnSCD0RFU0tUT1AtSk1ISDVLRzANBgkqhkiG9w0BAQsF\nAAOCAQEARGmGmGGiajucyEvMZwixnG6gz09AWpbEJRbMSoftiC0kMKHbzV2nGPXs\nLZfxxSE2L7bnsN40RhdhbuPgynPzDrJ5MzQ79tPDZc5ZpCGaeZCJ6WHVV7TWJX1y\ngEyFtgb9TjvVOCNtGp7HmpY06kIEuma/XZ/DPBm35kxISsnCUDBMWjyeGJ+WoZ8s\neWGpW0FmWUMBbkwT02U/k1qtAP+NdW8HKtWETadlJMFxbCJ2lJ7pCWnix1TQXaQb\nUIgxVoOm3qB2iT4OU8QQ//ZA0fwM8E2rEC/C+8rPnN+ry6dQ8TB7EqYnaH+9Ooiv\nBLUG9tEDf3ibN4i30Ht3KMlFUFClTQ==\n-----END CERTIFICATE-----" + } + + ] + }, + "users": [ + { + "type": "password", + "username": "viewer", + "password_hash": "$2b$12$aa...", + "role": "viewer" + }, + { + "type": "password", + "username": "operator", + "password_hash": "$2b$12$bb...", + "role": "operator" + }, + { + "type": "password", + "username": "engineer", + "password_hash": "$2b$12$cc...", + "role": "engineer" + }, + { + "type": "certificate", + "certificate_id": "engineer_client", + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:openplc:opcua:runtime", + "namespace_index": 2, + "variables": [ + { + "node_id": "PLC.Outputs.Pulse", + "browse_name": "Pulse", + "display_name": "Pulse", + "datatype": "BOOL", + "initial_value": false, + "description": "Comando de pulso de contagem", + "index": 0, + "permissions": { + "viewer": "r", + "operator": "rw", + "engineer": "rw" + } + }, + { + "node_id": "PLC.Variables.count", + "browse_name": "count", + "display_name": "Count", + "datatype": "INT", + "initial_value": 0, + "description": "Contador de eventos", + "index": 13, + "permissions": { + "viewer": "r", + "operator": "r", + "engineer": "rw" + } + } + ], + "structures": [], + "arrays": [] + } + } + } +] \ No newline at end of file diff --git a/core/src/drivers/plugins/python/opcua/opcua_config_template.json b/core/src/drivers/plugins/python/opcua/opcua_config_template.json new file mode 100644 index 00000000..65a610a0 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_config_template.json @@ -0,0 +1,230 @@ +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "OpenPLC OPC UA Server", + "application_uri": "urn:freeopcua:python:server", + "product_uri": "urn:openplc:runtime:product", + "endpoint_url": "opc.tcp://localhost:4840/openplc/opcua", + "security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": [ + "Anonymous" + ] + }, + { + "name": "signed", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "Sign", + "auth_methods": [ + "Username", + "Certificate" + ] + }, + { + "name": "signed_encrypted", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": [ + "Username", + "Certificate" + ] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [ + { + "id": "example_client", + "pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" + } + ] + }, + "users": [ + { + "type": "password", + "username": "viewer", + "password_hash": "$2b$12$...", + "role": "viewer" + }, + { + "type": "password", + "username": "operator", + "password_hash": "$2b$12$...", + "role": "operator" + }, + { + "type": "password", + "username": "engineer", + "password_hash": "$2b$12$...", + "role": "engineer" + }, + { + "type": "certificate", + "certificate_id": "example_client", + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:openplc:opcua:namespace", + "variables": [ + { + "node_id": "PLC.Example.bool_var", + "browse_name": "bool_var", + "display_name": "Boolean Variable", + "datatype": "BOOL", + "initial_value": false, + "description": "Example boolean variable", + "index": 0, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.int_var", + "browse_name": "int_var", + "display_name": "Integer Variable", + "datatype": "INT", + "initial_value": 0, + "description": "Example 16-bit signed integer", + "index": 1, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.dint_var", + "browse_name": "dint_var", + "display_name": "Double Integer Variable", + "datatype": "DINT", + "initial_value": 0, + "description": "Example 32-bit signed integer", + "index": 2, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.real_var", + "browse_name": "real_var", + "display_name": "Real Variable", + "datatype": "REAL", + "initial_value": 0.0, + "description": "Example 32-bit floating point", + "index": 3, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.string_var", + "browse_name": "string_var", + "display_name": "String Variable", + "datatype": "STRING", + "initial_value": "", + "description": "Example string variable", + "index": 4, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.readonly_counter", + "browse_name": "readonly_counter", + "display_name": "Read-Only Counter", + "datatype": "DINT", + "initial_value": 0, + "description": "Example read-only variable", + "index": 5, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + }, + { + "node_id": "PLC.Example.cycle_time", + "browse_name": "cycle_time", + "display_name": "Cycle Time", + "datatype": "TIME", + "initial_value": 0, + "description": "PLC scan cycle time (TIME type, represented as milliseconds in OPC-UA)", + "index": 6, + "permissions": {"viewer": "r", "operator": "r", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.timer_preset", + "browse_name": "timer_preset", + "display_name": "Timer Preset", + "datatype": "TIME", + "initial_value": 0, + "description": "Timer preset value (TIME type)", + "index": 7, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.time_of_day", + "browse_name": "time_of_day", + "display_name": "Time of Day", + "datatype": "TOD", + "initial_value": 0, + "description": "Current time of day (TOD type, mapped to OPC-UA DateTime)", + "index": 8, + "permissions": {"viewer": "r", "operator": "r", "engineer": "rw"} + } + ], + "structures": [ + { + "node_id": "PLC.Example.Structures.sensor_data", + "browse_name": "sensor_data", + "display_name": "Sensor Data", + "description": "Example sensor data structure", + "fields": [ + { + "name": "sensor_id", + "datatype": "INT", + "initial_value": 1, + "index": 10, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "value", + "datatype": "REAL", + "initial_value": 0.0, + "index": 11, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "is_valid", + "datatype": "BOOL", + "initial_value": false, + "index": 12, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ] + } + ], + "arrays": [ + { + "node_id": "PLC.Example.Arrays.int_array", + "browse_name": "int_array", + "display_name": "Integer Array", + "datatype": "INT", + "length": 5, + "initial_value": 0, + "index": 20, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Example.Arrays.real_array", + "browse_name": "real_array", + "display_name": "Real Array", + "datatype": "REAL", + "length": 4, + "initial_value": 0.0, + "index": 25, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + ] + } + } + } +] diff --git a/core/src/drivers/plugins/python/opcua/opcua_endpoints_config.py b/core/src/drivers/plugins/python/opcua/opcua_endpoints_config.py new file mode 100644 index 00000000..a3a71489 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_endpoints_config.py @@ -0,0 +1,245 @@ +""" +Configuration helper for OPC-UA endpoints to handle connectivity issues. +This module provides utilities to configure endpoints that work with different clients. +""" +import socket +from urllib.parse import urlparse +from typing import List, Dict, Optional + +try: + import psutil + PSUTIL_AVAILABLE = True +except ImportError: + PSUTIL_AVAILABLE = False + + +def _is_docker_interface(interface_name: str) -> bool: + """Check if interface name looks like a Docker/container internal interface.""" + docker_prefixes = ('docker', 'br-', 'veth', 'cni', 'flannel', 'cali', 'weave') + return interface_name.lower().startswith(docker_prefixes) + + +def _is_docker_ip(ip: str) -> bool: + """ + Check if IP is in a Docker internal network range. + + Docker typically uses: + - 172.17.0.0/16 for default bridge + - 172.18-31.0.0/16 for user-defined networks + - We filter the entire 172.16.0.0/12 range (172.16.x.x - 172.31.x.x) + """ + if ip.startswith('172.'): + try: + second_octet = int(ip.split('.')[1]) + # 172.16.0.0/12 covers 172.16.x.x through 172.31.x.x + if 16 <= second_octet <= 31: + return True + except (ValueError, IndexError): + pass + return False + + +def _get_ips_from_psutil() -> List[str]: + """Get non-loopback, non-Docker IPs using psutil (preferred method).""" + if not PSUTIL_AVAILABLE: + return [] + + try: + non_loopback_ips = [] + for interface_name, addresses in psutil.net_if_addrs().items(): + # Skip Docker/container internal interfaces by name + if _is_docker_interface(interface_name): + continue + + for addr in addresses: + # Only consider IPv4 addresses + if addr.family == socket.AF_INET: + ip = addr.address + # Skip loopback and Docker IP ranges + if not ip.startswith('127.') and not _is_docker_ip(ip): + non_loopback_ips.append(ip) + return non_loopback_ips + except Exception: + return [] + + +def _get_ips_from_socket() -> List[str]: + """ + Get non-loopback, non-Docker IPs using socket (fallback, no network access required). + + Uses gethostbyname_ex and getaddrinfo to enumerate IPs associated + with the machine's hostname. Works on Windows MSYS2 and air-gapped systems. + """ + non_loopback_ips = [] + + try: + # Method 1: gethostbyname_ex returns (hostname, aliaslist, ipaddrlist) + hostname = socket.gethostname() + _, _, ip_list = socket.gethostbyname_ex(hostname) + for ip in ip_list: + if (not ip.startswith('127.') and + not _is_docker_ip(ip) and + ip not in non_loopback_ips): + non_loopback_ips.append(ip) + except Exception: + pass + + try: + # Method 2: getaddrinfo can find additional addresses + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None, socket.AF_INET): + ip = info[4][0] + if (not ip.startswith('127.') and + not _is_docker_ip(ip) and + ip not in non_loopback_ips): + non_loopback_ips.append(ip) + except Exception: + pass + + return non_loopback_ips + + +def _get_ip_from_external_connection() -> Optional[str]: + """ + Get IP by connecting to external address (last resort, requires network). + + This method determines which interface would be used to reach the internet, + but requires network connectivity. + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(1) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + s.close() + if not ip.startswith('127.') and not _is_docker_ip(ip): + return ip + except Exception: + pass + return None + + +def get_local_ip() -> Optional[str]: + """ + Get the local IP address of the machine. + + Strategy (in order of preference): + 1. psutil interface enumeration (no network access, most reliable) + 2. socket-based hostname resolution (no network access, works on MSYS2) + 3. External connection test (requires network, determines default route) + + Returns: + The local IP address string, or None if detection fails + """ + # Try psutil first (most reliable, no network access required) + ips = _get_ips_from_psutil() + if ips: + return ips[0] + + # Fallback to socket-based detection (no network access, works on MSYS2) + ips = _get_ips_from_socket() + if ips: + return ips[0] + + # Last resort: external connection (requires network) + return _get_ip_from_external_connection() + + +def get_available_hostnames() -> List[str]: + """Get list of available hostnames/IPs for the server.""" + hostnames = ["localhost", "127.0.0.1"] + + try: + # Add actual hostname + hostname = socket.gethostname() + if hostname not in hostnames: + hostnames.append(hostname) + + # Add FQDN if different + fqdn = socket.getfqdn() + if fqdn not in hostnames: + hostnames.append(fqdn) + + # Add local IP address + local_ip = get_local_ip() + if local_ip and local_ip not in hostnames: + hostnames.append(local_ip) + + except Exception: + pass + + return hostnames + + +def normalize_endpoint_url(endpoint_url: str) -> str: + """ + Normalize endpoint URL for better client compatibility. + + When 0.0.0.0 is used (bind to all interfaces), we need to replace it + with an actual resolvable address for OPC-UA clients. The priority is: + 1. Network IP (for remote client access) + 2. Hostname (fallback) + 3. localhost (last resort, only works for local clients) + """ + parsed = urlparse(endpoint_url) + + # If using 0.0.0.0, replace with a resolvable address + if parsed.hostname == "0.0.0.0": + # Try to get the network IP first (best for remote access) + network_ip = get_local_ip() + if network_ip: + return f"{parsed.scheme}://{network_ip}:{parsed.port}{parsed.path}" + + # Fallback to hostname + try: + hostname = socket.gethostname() + if hostname and hostname != "localhost": + return f"{parsed.scheme}://{hostname}:{parsed.port}{parsed.path}" + except Exception: + pass + + # Last resort: use localhost (only works for local clients) + return f"{parsed.scheme}://localhost:{parsed.port}{parsed.path}" + + return endpoint_url + + +def create_multiple_endpoints(base_endpoint: str) -> List[str]: + """Create multiple endpoint variations for better connectivity.""" + parsed = urlparse(base_endpoint) + endpoints = [] + + hostnames = get_available_hostnames() + + for hostname in hostnames: + endpoint = f"{parsed.scheme}://{hostname}:{parsed.port}{parsed.path}" + if endpoint not in endpoints: + endpoints.append(endpoint) + + return endpoints + + +def suggest_client_endpoints(server_endpoint: str) -> Dict[str, str]: + """Suggest different endpoint URLs for different client scenarios.""" + parsed = urlparse(server_endpoint) + + return { + "local_connection": f"opc.tcp://localhost:{parsed.port}{parsed.path}", + "same_machine": f"opc.tcp://127.0.0.1:{parsed.port}{parsed.path}", + "network_hostname": f"opc.tcp://{socket.gethostname()}:{parsed.port}{parsed.path}", + "network_ip": f"opc.tcp://{get_local_ip()}:{parsed.port}{parsed.path}" if get_local_ip() else None + } + + +def validate_endpoint_format(endpoint_url: str) -> bool: + """Validate if endpoint URL has correct OPC-UA format.""" + try: + parsed = urlparse(endpoint_url) + return ( + parsed.scheme == "opc.tcp" and + parsed.hostname is not None and + parsed.port is not None and + len(parsed.path) > 0 + ) + except: + return False \ No newline at end of file diff --git a/core/src/drivers/plugins/python/opcua/opcua_logging.py b/core/src/drivers/plugins/python/opcua/opcua_logging.py new file mode 100644 index 00000000..ed58350b --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_logging.py @@ -0,0 +1,128 @@ +""" +Centralized logging module for OPC UA plugin. + +This module provides a singleton logger that integrates with the OpenPLC +runtime logging system while providing fallback to standard output. +""" + +from typing import Optional, Callable +import sys + + +class OpcuaLogger: + """ + Singleton logger for OPC UA plugin. + + Integrates with OpenPLC runtime logging when available, + falls back to stdout/stderr otherwise. + """ + + _instance: Optional['OpcuaLogger'] = None + + def __init__(self): + self._log_info_fn: Optional[Callable[[str], None]] = None + self._log_warn_fn: Optional[Callable[[str], None]] = None + self._log_error_fn: Optional[Callable[[str], None]] = None + self._log_debug_fn: Optional[Callable[[str], None]] = None + self._initialized = False + + @classmethod + def get_instance(cls) -> 'OpcuaLogger': + """Get the singleton logger instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def reset(cls) -> None: + """Reset the singleton instance. Useful for testing.""" + cls._instance = None + + def initialize(self, logging_accessor) -> bool: + """ + Initialize logger with OpenPLC runtime logging accessor. + + Args: + logging_accessor: SafeLoggingAccess instance from runtime + + Returns: + True if initialization successful, False otherwise + """ + if logging_accessor is None: + return False + + if not getattr(logging_accessor, 'is_valid', False): + return False + + self._log_info_fn = getattr(logging_accessor, 'log_info', None) + self._log_warn_fn = getattr(logging_accessor, 'log_warn', None) + self._log_error_fn = getattr(logging_accessor, 'log_error', None) + self._log_debug_fn = getattr(logging_accessor, 'log_debug', None) + self._initialized = True + return True + + def info(self, message: str) -> None: + """Log an informational message.""" + if self._initialized and self._log_info_fn: + try: + self._log_info_fn(message) + return + except Exception: + pass + print(f"[OPCUA INFO] {message}", file=sys.stdout) + + def warn(self, message: str) -> None: + """Log a warning message.""" + if self._initialized and self._log_warn_fn: + try: + self._log_warn_fn(message) + return + except Exception: + pass + print(f"[OPCUA WARN] {message}", file=sys.stderr) + + def error(self, message: str) -> None: + """Log an error message.""" + if self._initialized and self._log_error_fn: + try: + self._log_error_fn(message) + return + except Exception: + pass + print(f"[OPCUA ERROR] {message}", file=sys.stderr) + + def debug(self, message: str) -> None: + """Log a debug message.""" + if self._initialized and self._log_debug_fn: + try: + self._log_debug_fn(message) + return + except Exception: + pass + print(f"[OPCUA DEBUG] {message}", file=sys.stdout) + + +# Module-level convenience functions +def get_logger() -> OpcuaLogger: + """Get the singleton logger instance.""" + return OpcuaLogger.get_instance() + + +def log_info(message: str) -> None: + """Log an informational message.""" + get_logger().info(message) + + +def log_warn(message: str) -> None: + """Log a warning message.""" + get_logger().warn(message) + + +def log_error(message: str) -> None: + """Log an error message.""" + get_logger().error(message) + + +def log_debug(message: str) -> None: + """Log a debug message.""" + get_logger().debug(message) diff --git a/core/src/drivers/plugins/python/opcua/opcua_memory.py b/core/src/drivers/plugins/python/opcua/opcua_memory.py new file mode 100644 index 00000000..5f8696f5 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_memory.py @@ -0,0 +1,304 @@ +"""OPC-UA plugin memory access utilities.""" + +import ctypes +import os +import sys +from typing import Any, List, Dict + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) + +# Import local modules (handle both package and direct loading) +try: + from .opcua_types import VariableMetadata + from .opcua_logging import log_info, log_warn, log_error +except ImportError: + from opcua_types import VariableMetadata + from opcua_logging import log_info, log_warn, log_error + + +# IEC 61131-3 STRING constants (must match iec_types.h) +STR_MAX_LEN = 126 +STR_LEN_SIZE = 1 # sizeof(__strlen_t) = sizeof(int8_t) = 1 +STRING_TOTAL_SIZE = STR_LEN_SIZE + STR_MAX_LEN # 127 bytes + +# IEC 61131-3 TIME/DATE constants (must match iec_types.h) +TIMESPEC_SIZE = 8 # sizeof(IEC_TIMESPEC) = 2 * sizeof(int32_t) = 8 bytes + +# TIME-related datatypes that use IEC_TIMESPEC structure +TIME_DATATYPES = frozenset(["TIME", "DATE", "TOD", "DT"]) + + +def _validate_memory_address(address: int, size: int = 1) -> None: + """ + Validate a memory address before access. + + Args: + address: Memory address to validate + size: Size of data to be accessed (for bounds context) + + Raises: + ValueError: If address is invalid (NULL, negative, or suspiciously small) + """ + if address is None: + raise ValueError("Memory address is None") + if not isinstance(address, int): + raise ValueError(f"Memory address must be an integer, got {type(address).__name__}") + if address == 0: + raise ValueError("Memory address is NULL (0)") + if address < 0: + raise ValueError(f"Memory address is negative: {address}") + # Addresses below 4096 are typically reserved/unmapped on most systems + if address < 4096: + raise ValueError(f"Memory address {address} is in reserved memory region (< 4096)") + + +class IEC_TIMESPEC(ctypes.Structure): + """ + ctypes structure matching IEC_TIMESPEC from iec_types.h. + + typedef struct { + int32_t tv_sec; // Seconds + int32_t tv_nsec; // Nanoseconds + } IEC_TIMESPEC; + + Used for TIME, DATE, TOD, and DT types. + """ + + _fields_ = [ + ("tv_sec", ctypes.c_int32), + ("tv_nsec", ctypes.c_int32), + ] + + +class IEC_STRING(ctypes.Structure): + """ + ctypes structure matching IEC_STRING from iec_types.h. + + typedef struct { + __strlen_t len; // int8_t, 1 byte + uint8_t body[126]; // 126 bytes + } IEC_STRING; + """ + _fields_ = [ + ("len", ctypes.c_int8), + ("body", ctypes.c_uint8 * STR_MAX_LEN), + ] + + +def read_memory_direct(address: int, size: int, datatype: str = None) -> Any: + """ + Read value directly from memory using cached address. + + Args: + address: Memory address to read from + size: Size of the variable in bytes + datatype: Optional datatype hint for ambiguous sizes (e.g., TIME vs LINT) + + Returns: + Value read from memory: + - int for numeric types + - str for STRING + - tuple(tv_sec, tv_nsec) for TIME/DATE/TOD/DT + + Raises: + RuntimeError: If memory access fails + ValueError: If size is not supported or address is invalid + """ + # Validate address before any memory access + _validate_memory_address(address, size) + + try: + if size == 1: + ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) + return ptr.contents.value + elif size == 2: + ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) + return ptr.contents.value + elif size == 4: + ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) + return ptr.contents.value + elif size == 8: + # Check if this is a TIME-related type + if datatype and datatype.upper() in TIME_DATATYPES: + return read_timespec_direct(address) + ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) + return ptr.contents.value + elif size == STRING_TOTAL_SIZE: + # STRING type: read IEC_STRING structure and decode to Python string + return read_string_direct(address) + else: + raise ValueError(f"Unsupported variable size: {size}") + except Exception as e: + raise RuntimeError(f"Memory access error: {e}") + + +def read_string_direct(address: int) -> str: + """ + Read an IEC_STRING directly from memory. + + Args: + address: Memory address of the IEC_STRING structure + + Returns: + Python string decoded from the IEC_STRING + + Raises: + ValueError: If address is invalid + RuntimeError: If memory access fails + """ + _validate_memory_address(address, STRING_TOTAL_SIZE) + + try: + ptr = ctypes.cast(address, ctypes.POINTER(IEC_STRING)) + iec_string = ptr.contents + + # Get the actual length (clamped to valid range) + str_len = max(0, min(iec_string.len, STR_MAX_LEN)) + + if str_len == 0: + return "" + + # Extract bytes from body array and decode + raw_bytes = bytes(iec_string.body[:str_len]) + return raw_bytes.decode('utf-8', errors='replace') + + except Exception as e: + raise RuntimeError(f"String memory access error: {e}") + + +def write_string_direct(address: int, value: str) -> bool: + """ + Write a Python string to an IEC_STRING in memory. + + Args: + address: Memory address of the IEC_STRING structure + value: Python string to write + + Returns: + True if successful + + Raises: + ValueError: If address is invalid + RuntimeError: If memory access fails + """ + _validate_memory_address(address, STRING_TOTAL_SIZE) + + try: + ptr = ctypes.cast(address, ctypes.POINTER(IEC_STRING)) + iec_string = ptr.contents + + # Encode string to bytes and truncate if necessary + encoded = value.encode('utf-8', errors='replace') + str_len = min(len(encoded), STR_MAX_LEN) + + # Set length + iec_string.len = str_len + + # Copy bytes to body + for i in range(str_len): + iec_string.body[i] = encoded[i] + + # Zero-fill remainder (optional, for cleanliness) + for i in range(str_len, STR_MAX_LEN): + iec_string.body[i] = 0 + + return True + + except Exception as e: + raise RuntimeError(f"String memory write error: {e}") + + +def read_timespec_direct(address: int) -> tuple[int, int]: + """ + Read an IEC_TIMESPEC directly from memory. + + Args: + address: Memory address of the IEC_TIMESPEC structure + + Returns: + Tuple of (tv_sec, tv_nsec) + + Raises: + ValueError: If address is invalid + RuntimeError: If memory access fails + """ + _validate_memory_address(address, TIMESPEC_SIZE) + + try: + ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) + timespec = ptr.contents + return (timespec.tv_sec, timespec.tv_nsec) + except Exception as e: + raise RuntimeError(f"Timespec memory access error: {e}") + + +def write_timespec_direct(address: int, tv_sec: int, tv_nsec: int) -> bool: + """ + Write an IEC_TIMESPEC to memory. + + Args: + address: Memory address of the IEC_TIMESPEC structure + tv_sec: Seconds value (int32) + tv_nsec: Nanoseconds value (int32) + + Returns: + True if successful + + Raises: + ValueError: If address is invalid + RuntimeError: If memory access fails + """ + _validate_memory_address(address, TIMESPEC_SIZE) + + try: + ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) + ptr.contents.tv_sec = ctypes.c_int32(tv_sec).value + ptr.contents.tv_nsec = ctypes.c_int32(tv_nsec).value + return True + except Exception as e: + raise RuntimeError(f"Timespec memory write error: {e}") + + +def initialize_variable_cache(sba, indices: List[int]) -> Dict[int, VariableMetadata]: + """Initialize metadata cache for direct memory access.""" + try: + # Try relative imports first (when used as package) + from .opcua_utils import infer_var_type + except ImportError: + # Fallback to absolute imports (when run standalone) + from opcua_utils import infer_var_type + + try: + # Batch: get addresses + addresses, addr_msg = sba.get_var_list(indices) + if addr_msg != "Success": + log_warn(f"Failed to cache addresses: {addr_msg}") + return {} + + # Batch: get sizes + sizes, size_msg = sba.get_var_sizes_batch(indices) + if size_msg != "Success": + log_warn(f"Failed to cache sizes: {size_msg}") + return {} + + # Create cache + cache = {} + for i, var_index in enumerate(indices): + if addresses[i] is not None and sizes[i] > 0: + metadata = VariableMetadata( + index=var_index, + address=addresses[i], + size=sizes[i], + inferred_type=infer_var_type(sizes[i]) + ) + cache[var_index] = metadata + + log_info(f"Cached metadata for {len(cache)} variables") + return cache + + except Exception as e: + log_warn(f"Failed to initialize variable cache: {e}") + return {} diff --git a/core/src/drivers/plugins/python/opcua/opcua_security.py b/core/src/drivers/plugins/python/opcua/opcua_security.py new file mode 100644 index 00000000..f92fa73a --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_security.py @@ -0,0 +1,1188 @@ +""" +OPC-UA Security Utilities + +This module provides utilities for handling OPC-UA security features including: +- Auto-generation of server certificates +- Certificate loading and validation +- Security policy and mode mapping +- Client trust list management +""" + +import datetime +import hashlib +import ipaddress +import os +import shutil +import socket +import ssl +import sys +import tempfile +from pathlib import Path +from typing import List, Optional, Set, Tuple +from urllib.parse import urlparse + +from asyncua import ua +from asyncua.crypto.permission_rules import PermissionRuleset +from asyncua.crypto.security_policies import ( + SecurityPolicyAes128Sha256RsaOaep, + SecurityPolicyAes256Sha256RsaPss, + SecurityPolicyBasic256Sha256, +) +from asyncua.crypto.truststore import TrustStore +from asyncua.crypto.validator import CertificateValidator +from asyncua.server.user_managers import UserRole +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) + +# Import logging (handle both package and direct loading) +try: + from .opcua_logging import log_error, log_info, log_warn +except ImportError: + from opcua_logging import log_error, log_info, log_warn + + +# ioctl constants for network interface enumeration (Linux) +_SIOCGIFCONF = 0x8912 # ioctl request code to get interface configuration +_SIZEOF_IFREQ = 40 # sizeof(struct ifreq) on 64-bit Linux +_MAX_INTERFACES = 128 # Maximum number of network interfaces to query + + +def get_local_ip_addresses() -> Set[str]: + """ + Get all local IP addresses of the machine. + + Returns: + Set of IP address strings (both IPv4 and IPv6) + """ + ip_addresses = set() + + # Always include localhost addresses + ip_addresses.add("127.0.0.1") + ip_addresses.add("::1") + + try: + # Method 1: Get IPs from all network interfaces + hostname = socket.gethostname() + try: + # Get all addresses associated with hostname + for info in socket.getaddrinfo(hostname, None): + ip = info[4][0] + # Filter out link-local addresses using ipaddress module + try: + addr = ipaddress.ip_address(ip) + if not addr.is_link_local: + ip_addresses.add(ip) + except ValueError: + pass + except socket.gaierror: + pass + + # Method 2: Connect to external address to find default interface IP + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + # Doesn't actually connect, just determines route + s.connect(("8.8.8.8", 80)) + ip_addresses.add(s.getsockname()[0]) + except Exception: + pass + + # Method 3: Try to get all interface IPs using netifaces-like approach + try: + import array + import fcntl + import struct + + # Get list of network interfaces + buf_size = _MAX_INTERFACES * _SIZEOF_IFREQ + buf = array.array("B", b"\0" * buf_size) + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + result = fcntl.ioctl( + s.fileno(), + _SIOCGIFCONF, + struct.pack("iL", buf_size, buf.buffer_info()[0]), + ) + out_bytes = struct.unpack("iL", result)[0] + + # Parse the buffer for interface addresses + offset = 0 + while offset < out_bytes: + # Interface name is 16 bytes, then sockaddr (unused, skip it) + # Skip to IP address (offset 20 from start of entry) + ip_offset = offset + 20 + if ip_offset + 4 <= len(buf): + ip_bytes = buf[ip_offset : ip_offset + 4].tobytes() + ip = socket.inet_ntoa(ip_bytes) + if ip != "0.0.0.0": + ip_addresses.add(ip) + offset += _SIZEOF_IFREQ + except Exception: + pass + + except Exception as e: + log_warn(f"Error getting local IP addresses: {e}") + + return ip_addresses + + +def generate_certificate_with_sans( + cert_path: Path, + key_path: Path, + app_uri: str, + dns_names: List[str], + ip_addresses: List[str], + common_name: str = "OpenPLC OPC-UA Server", + organization: str = "Autonomy Logic", + country: str = "US", + state: str = "CA", + locality: str = "California", + key_size: int = 2048, + valid_days: int = 3650, +) -> bool: + """ + Generate a self-signed certificate with multiple Subject Alternative Names. + + This function creates a certificate suitable for OPC-UA servers with proper + SAN extensions including multiple DNS names, IP addresses, and URIs. + + The default validity period is 10 years (3650 days) to minimize certificate + renewal overhead in industrial/embedded environments where PLCs may run + for extended periods without maintenance. + + Args: + cert_path: Path where certificate will be saved (PEM format) + key_path: Path where private key will be saved (PEM format) + app_uri: Application URI for the certificate + dns_names: List of DNS names to include in SAN + ip_addresses: List of IP addresses to include in SAN + common_name: Certificate common name + organization: Organization name + country: Country code + state: State/Province + locality: City/Locality + key_size: RSA key size (default 2048) + valid_days: Certificate validity in days (default 3650 = 10 years) + + Returns: + bool: True if certificate generated successfully + """ + try: + # Generate RSA private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=key_size, + ) + + # Build subject name + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state), + x509.NameAttribute(NameOID.LOCALITY_NAME, locality), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + + # Build Subject Alternative Names + san_entries = [] + + # Add URI (required for OPC-UA) + san_entries.append(x509.UniformResourceIdentifier(app_uri)) + + # Add DNS names + for dns_name in dns_names: + if dns_name: # Skip empty strings + san_entries.append(x509.DNSName(dns_name)) + + # Add IP addresses + for ip_str in ip_addresses: + if ip_str: # Skip empty strings + try: + ip_obj = ipaddress.ip_address(ip_str) + san_entries.append(x509.IPAddress(ip_obj)) + except ValueError as e: + log_warn(f"Invalid IP address '{ip_str}' for SAN: {e}") + + # Build certificate + now = datetime.datetime.now(datetime.timezone.utc) + cert_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=valid_days)) + .add_extension( + x509.SubjectAlternativeName(san_entries), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=True, # nonRepudiation - required by OPC-UA + data_encipherment=True, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), + critical=False, + ) + ) + + # Sign the certificate + certificate = cert_builder.sign(private_key, hashes.SHA256()) + + # Write private key to file with restricted permissions (PKCS8 format required by asyncua) + key_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + # Write certificate to file + cert_path.parent.mkdir(parents=True, exist_ok=True) + with open(cert_path, "wb") as f: + f.write(certificate.public_bytes(serialization.Encoding.PEM)) + + log_info(f"Generated certificate with {len(san_entries)} SAN entries") + log_info(f" DNS names: {dns_names}") + log_info(f" IP addresses: {ip_addresses}") + log_info(f" URI: {app_uri}") + + return True + + except Exception as e: + log_error(f"Failed to generate certificate: {e}") + return False + + +class OpenPLCRoleRuleset(PermissionRuleset): + """ + Custom permission ruleset for OpenPLC OPC-UA server. + + Extends the standard SimpleRoleRuleset to allow regular users to + modify subscription parameters (publish interval, etc.). + + The default asyncua SimpleRoleRuleset is missing ModifySubscriptionRequest + from the USER_TYPES list, which prevents non-admin users from modifying + subscription parameters via SCADA clients like UAExpert. + """ + + # Operations that require Admin role + ADMIN_TYPES = [ + ua.ObjectIds.RegisterServerRequest_Encoding_DefaultBinary, + ua.ObjectIds.RegisterServer2Request_Encoding_DefaultBinary, + ua.ObjectIds.AddNodesRequest_Encoding_DefaultBinary, + ua.ObjectIds.DeleteNodesRequest_Encoding_DefaultBinary, + ua.ObjectIds.AddReferencesRequest_Encoding_DefaultBinary, + ua.ObjectIds.DeleteReferencesRequest_Encoding_DefaultBinary, + ] + + # Operations allowed for regular User role (includes ModifySubscription) + USER_TYPES = [ + ua.ObjectIds.CreateSessionRequest_Encoding_DefaultBinary, + ua.ObjectIds.CloseSessionRequest_Encoding_DefaultBinary, + ua.ObjectIds.ActivateSessionRequest_Encoding_DefaultBinary, + ua.ObjectIds.ReadRequest_Encoding_DefaultBinary, + ua.ObjectIds.WriteRequest_Encoding_DefaultBinary, + ua.ObjectIds.BrowseRequest_Encoding_DefaultBinary, + ua.ObjectIds.GetEndpointsRequest_Encoding_DefaultBinary, + ua.ObjectIds.FindServersRequest_Encoding_DefaultBinary, + ua.ObjectIds.TranslateBrowsePathsToNodeIdsRequest_Encoding_DefaultBinary, + ua.ObjectIds.CreateSubscriptionRequest_Encoding_DefaultBinary, + ua.ObjectIds.ModifySubscriptionRequest_Encoding_DefaultBinary, # Added for SCADA clients + ua.ObjectIds.DeleteSubscriptionsRequest_Encoding_DefaultBinary, + ua.ObjectIds.CreateMonitoredItemsRequest_Encoding_DefaultBinary, + ua.ObjectIds.ModifyMonitoredItemsRequest_Encoding_DefaultBinary, + ua.ObjectIds.DeleteMonitoredItemsRequest_Encoding_DefaultBinary, + ua.ObjectIds.HistoryReadRequest_Encoding_DefaultBinary, + ua.ObjectIds.PublishRequest_Encoding_DefaultBinary, + ua.ObjectIds.RepublishRequest_Encoding_DefaultBinary, + ua.ObjectIds.CloseSecureChannelRequest_Encoding_DefaultBinary, + ua.ObjectIds.CallRequest_Encoding_DefaultBinary, + ua.ObjectIds.SetMonitoringModeRequest_Encoding_DefaultBinary, + ua.ObjectIds.SetPublishingModeRequest_Encoding_DefaultBinary, + ua.ObjectIds.RegisterNodesRequest_Encoding_DefaultBinary, + ua.ObjectIds.UnregisterNodesRequest_Encoding_DefaultBinary, + ua.ObjectIds.TransferSubscriptionsRequest_Encoding_DefaultBinary, # Added for session transfer + ] + + def __init__(self): + """Initialize the permission ruleset with role-based permissions.""" + admin_ids = list(map(ua.NodeId, self.ADMIN_TYPES)) + user_ids = list(map(ua.NodeId, self.USER_TYPES)) + self._permission_dict = { + UserRole.Admin: set().union(admin_ids, user_ids), + UserRole.User: set(user_ids), + UserRole.Anonymous: set(), # Anonymous users have no permissions by default + } + + def check_validity(self, user, action_type_id, body): + """ + Check if user has permission for the given action. + + Args: + user: User object with role attribute + action_type_id: NodeId of the requested operation + body: Request body (unused, for future extensions) + + Returns: + True if user has permission, False otherwise + """ + if action_type_id in self._permission_dict.get(user.role, set()): + return True + return False + + +class OpcuaSecurityManager: + """Manages OPC-UA security configuration and certificates.""" + + # Mapping from config strings to opcua-asyncio security policies + SECURITY_POLICY_MAPPING = { + "None": None, + "Basic256Sha256": SecurityPolicyBasic256Sha256, + "Aes128_Sha256_RsaOaep": SecurityPolicyAes128Sha256RsaOaep, + "Aes256_Sha256_RsaPss": SecurityPolicyAes256Sha256RsaPss, + } + + # Mapping from config strings to opcua-asyncio message security modes + SECURITY_MODE_MAPPING = { + "None": 1, # MessageSecurityMode.None + "Sign": 2, # MessageSecurityMode.Sign + "SignAndEncrypt": 3, # MessageSecurityMode.SignAndEncrypt + } + + # Mapping from (policy, mode) to SecurityPolicyType for asyncua Server + POLICY_TYPE_MAPPING = { + ("None", "None"): ua.SecurityPolicyType.NoSecurity, + ("Basic256Sha256", "Sign"): ua.SecurityPolicyType.Basic256Sha256_Sign, + ("Basic256Sha256", "SignAndEncrypt"): ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt, + ("Basic256", "Sign"): ua.SecurityPolicyType.Basic256_Sign, + ("Basic256", "SignAndEncrypt"): ua.SecurityPolicyType.Basic256_SignAndEncrypt, + ("Basic128Rsa15", "Sign"): ua.SecurityPolicyType.Basic128Rsa15_Sign, + ("Basic128Rsa15", "SignAndEncrypt"): ua.SecurityPolicyType.Basic128Rsa15_SignAndEncrypt, + ("Aes128_Sha256_RsaOaep", "Sign"): ua.SecurityPolicyType.Aes128Sha256RsaOaep_Sign, + ( + "Aes128_Sha256_RsaOaep", + "SignAndEncrypt", + ): ua.SecurityPolicyType.Aes128Sha256RsaOaep_SignAndEncrypt, + ("Aes256_Sha256_RsaPss", "Sign"): ua.SecurityPolicyType.Aes256Sha256RsaPss_Sign, + ( + "Aes256_Sha256_RsaPss", + "SignAndEncrypt", + ): ua.SecurityPolicyType.Aes256Sha256RsaPss_SignAndEncrypt, + } + + CERTS_DIR = "certs" + SERVER_CERT_FILE = "server_cert.pem" + SERVER_KEY_FILE = "server_key.pem" + + def __init__(self, config, plugin_dir: str = None): + """ + Initialize security manager with configuration. + + Args: + config: OpcuaConfig instance with security settings + plugin_dir: Directory where certificates are stored (defaults to plugin directory) + """ + self.config = config + self.plugin_dir = plugin_dir or os.path.dirname(__file__) + self.certs_dir = os.path.join(self.plugin_dir, self.CERTS_DIR) + self.certificate_data = None + self.private_key_data = None + self.security_policy = None + self.security_mode = None + self.trusted_certificates = [] # List of trusted client certificates + self._trust_store_temp_dir = None # Track temp dir for cleanup + + async def initialize_security(self) -> bool: + """ + Initialize security settings based on configuration. + + Returns: + bool: True if security initialized successfully + """ + try: + # Map security policy + self.security_policy = self.SECURITY_POLICY_MAPPING.get(self.config.security_policy) + if self.config.security_policy != "None" and self.security_policy is None: + log_error(f"Unsupported security policy: {self.config.security_policy}") + return False + + # Map security mode + self.security_mode = self.SECURITY_MODE_MAPPING.get(self.config.security_mode) + if self.security_mode is None: + log_error(f"Unsupported security mode: {self.config.security_mode}") + return False + + # Load certificates if required + if self.config.security_policy != "None" or self.config.security_mode != "None": + if not await self._ensure_server_certificates(): + return False + + # Load trusted client certificates + if self.config.client_auth.enabled: + if not self._load_trusted_certificates(): + return False + + log_info( + f"Security initialized: policy={self.config.security_policy}, mode={self.config.security_mode}" + ) + return True + + except Exception as e: + log_error(f"Failed to initialize security: {e}") + return False + + def _is_certificate_valid(self, cert_path: str) -> bool: + """ + Check if a certificate file exists and is still valid (not expired). + + Args: + cert_path: Path to the certificate file + + Returns: + bool: True if certificate exists and is valid, False otherwise + """ + if not os.path.exists(cert_path): + return False + + try: + with open(cert_path, "rb") as f: + cert_data = f.read() + + cert = x509.load_pem_x509_certificate(cert_data) + + # Use timezone-aware datetime for comparison + now_utc = datetime.datetime.now(datetime.timezone.utc) + + # Get certificate validity dates (prefer UTC versions if available) + not_valid_after = getattr(cert, "not_valid_after_utc", None) + if not_valid_after is None: + not_valid_after = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + + not_valid_before = getattr(cert, "not_valid_before_utc", None) + if not_valid_before is None: + not_valid_before = cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) + + # Check if certificate is not yet valid + if not_valid_before > now_utc: + log_warn(f"Certificate {cert_path} is not yet valid") + return False + + # Check if certificate has expired + if not_valid_after < now_utc: + log_warn(f"Certificate {cert_path} has expired") + return False + + # Certificate is valid + days_until_expiry = (not_valid_after - now_utc).days + log_info(f"Certificate {cert_path} is valid for {days_until_expiry} more days") + return True + + except Exception as e: + log_warn(f"Failed to validate certificate {cert_path}: {e}") + return False + + def _remove_certificate_files(self, cert_path: str, key_path: str) -> None: + """ + Remove existing certificate and key files. + + Args: + cert_path: Path to the certificate file + key_path: Path to the private key file + """ + for file_path in [cert_path, key_path]: + if os.path.exists(file_path): + try: + os.remove(file_path) + log_info(f"Removed expired certificate file: {file_path}") + except Exception as e: + log_warn(f"Failed to remove file {file_path}: {e}") + + async def _ensure_server_certificates(self) -> bool: + """ + Ensure server certificates exist and are valid, generate if missing or expired. + + Returns: + bool: True if certificates are available + """ + try: + # Create certs directory if it doesn't exist + os.makedirs(self.certs_dir, exist_ok=True) + + cert_path = os.path.join(self.certs_dir, self.SERVER_CERT_FILE) + key_path = os.path.join(self.certs_dir, self.SERVER_KEY_FILE) + + # Check if certificates already exist and are valid + if os.path.exists(cert_path) and os.path.exists(key_path): + if self._is_certificate_valid(cert_path): + log_info(f"Found valid server certificates in {self.certs_dir}") + else: + log_info("Server certificate is expired or invalid, regenerating") + self._remove_certificate_files(cert_path, key_path) + if not await self.generate_server_certificate(cert_path, key_path): + return False + else: + log_info(f"Server certificates not found, generating new ones in {self.certs_dir}") + if not await self.generate_server_certificate(cert_path, key_path): + return False + + # Load the certificates + return self._load_certificates(cert_path, key_path) + + except Exception as e: + log_error(f"Failed to ensure server certificates: {e}") + return False + + def _load_certificates(self, cert_path: str, key_path: str) -> bool: + """ + Load certificate and private key files. + + Returns: + bool: True if certificates loaded successfully + """ + try: + # Load certificate + with open(cert_path, "rb") as cert_file: + self.certificate_data = cert_file.read() + + # Load private key + with open(key_path, "rb") as key_file: + self.private_key_data = key_file.read() + + # Validate certificate format (basic check) + if not self._validate_certificate_format(): + return False + + log_info(f"Server certificates loaded from {cert_path}") + return True + + except FileNotFoundError as e: + log_error(f"Certificate file not found: {e}") + return False + except Exception as e: + log_error(f"Failed to load certificates: {e}") + return False + + def _validate_certificate_format(self) -> bool: + """ + Perform comprehensive validation of certificate format and extensions. + + Returns: + bool: True if certificate format and extensions are valid + """ + try: + # Try to load certificate with ssl module for basic validation + ssl.PEM_cert_to_DER_cert(self.certificate_data.decode("utf-8")) + + # Enhanced validation using cryptography library + try: + cert = x509.load_pem_x509_certificate(self.certificate_data) + + # Use timezone-aware datetime for comparison + now_utc = datetime.datetime.now(datetime.timezone.utc) + + # Get certificate validity dates (prefer UTC versions if available) + not_valid_after = getattr(cert, "not_valid_after_utc", None) + if not_valid_after is None: + not_valid_after = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + + not_valid_before = getattr(cert, "not_valid_before_utc", None) + if not_valid_before is None: + not_valid_before = cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) + + # Check if certificate is not yet valid + if not_valid_before > now_utc: + log_warn("Certificate is not yet valid") + return False + + # Check expiration + if not_valid_after < now_utc: + log_warn("Certificate has expired") + return False + + # Check if certificate will expire soon (within 30 days) + days_until_expiry = (not_valid_after - now_utc).days + if days_until_expiry < 30: + log_warn(f"Certificate expires in {days_until_expiry} days") + + # Check for Subject Alternative Name extension + try: + san_ext = cert.extensions.get_extension_for_oid( + x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME + ) + san_names = san_ext.value + + # Log SAN entries for debugging + dns_names = [name.value for name in san_names if isinstance(name, x509.DNSName)] + ip_addresses = [ + name.value.compressed + for name in san_names + if isinstance(name, x509.IPAddress) + ] + uris = [ + name.value + for name in san_names + if isinstance(name, x509.UniformResourceIdentifier) + ] + + log_info(f"Certificate SAN DNS names: {dns_names}") + log_info(f"Certificate SAN IP addresses: {ip_addresses}") + log_info(f"Certificate SAN URIs: {uris}") + + # Check if we have expected entries + system_hostname = socket.gethostname() + if system_hostname not in dns_names and system_hostname != "localhost": + log_warn( + f"System hostname '{system_hostname}' not found in certificate DNS SANs" + ) + + # Check for application URI + expected_uri = "urn:autonomy-logic:openplc:opcua:server" + if expected_uri not in uris: + log_warn( + f"Expected application URI '{expected_uri}' not found in certificate" + ) + + except x509.ExtensionNotFound: + log_warn("Certificate missing Subject Alternative Name extension") + + # Check key usage extensions + try: + key_usage = cert.extensions.get_extension_for_oid( + x509.ExtensionOID.KEY_USAGE + ).value + if not key_usage.digital_signature: + log_warn("Certificate lacks digital signature key usage") + if not key_usage.key_encipherment: + log_warn("Certificate lacks key encipherment usage") + except x509.ExtensionNotFound: + log_warn("Certificate missing key usage extension") + + log_info("Certificate format and extensions validated") + return True + + except ImportError: + log_warn("cryptography library not available for enhanced validation") + return True # Fall back to basic validation + + except Exception: + try: + # Try as DER format + ssl.DER_cert_to_PEM_cert(self.certificate_data) + log_info("Certificate validated as DER format") + return True + except Exception as e: + log_error(f"Invalid certificate format: {e}") + return False + + def _load_trusted_certificates(self) -> bool: + """ + Load trusted client certificates from configuration. + + Returns: + bool: True if trusted certificates loaded successfully + """ + try: + self.trusted_certificates = [] + + if not self.config.client_auth.trusted_certificates_pem: + if not self.config.client_auth.trust_all_clients: + log_warn("Client authentication enabled but no trusted certificates configured") + return True + + # Parse and validate each certificate + for i, cert_pem in enumerate(self.config.client_auth.trusted_certificates_pem): + try: + # Basic validation - check if it's a valid PEM certificate + cert_der = ssl.PEM_cert_to_DER_cert(cert_pem) + cert_hash = hashlib.sha256(cert_der).hexdigest()[:16] # Short hash for logging + + self.trusted_certificates.append( + {"pem": cert_pem, "der": cert_der, "hash": cert_hash} + ) + + log_info(f"Loaded trusted certificate {i + 1} (SHA256: {cert_hash})") + + except Exception as e: + log_error(f"Invalid trusted certificate {i + 1}: {e}") + return False + + log_info(f"Loaded {len(self.trusted_certificates)} trusted client certificates") + return True + + except Exception as e: + log_error(f"Failed to load trusted certificates: {e}") + return False + + def validate_client_certificate(self, client_cert_pem: str) -> bool: + """ + Validate if a client certificate is in the trust list. + + Args: + client_cert_pem: Client certificate in PEM format + + Returns: + bool: True if client certificate is trusted + """ + if not self.config.client_auth.enabled: + return True # No authentication required + + if self.config.client_auth.trust_all_clients: + return True # Trust all clients + + if not self.trusted_certificates: + log_warn("Client authentication enabled but no trusted certificates loaded") + return False + + try: + # Convert client certificate to DER for comparison + client_cert_der = ssl.PEM_cert_to_DER_cert(client_cert_pem) + client_hash = hashlib.sha256(client_cert_der).hexdigest()[:16] + + # Check if client certificate matches any trusted certificate + for trusted_cert in self.trusted_certificates: + if trusted_cert["der"] == client_cert_der: + log_info(f"Client certificate trusted (SHA256: {client_hash})") + return True + + log_error(f"Client certificate not trusted (SHA256: {client_hash})") + return False + + except Exception as e: + log_error(f"Error validating client certificate: {e}") + return False + + def get_security_settings( + self, + ) -> Tuple[Optional[object], int, Optional[bytes], Optional[bytes]]: + """ + Get security settings for opcua-asyncio server. + + Returns: + Tuple of (security_policy_class, security_mode, certificate_data, private_key_data) + """ + return ( + self.security_policy, + self.security_mode, + self.certificate_data, + self.private_key_data, + ) + + async def generate_server_certificate( + self, + cert_path: str, + key_path: str, + common_name: str = "OpenPLC OPC-UA Server", + key_size: int = 2048, + valid_days: int = 3650, + app_uri: str = None, + ) -> bool: + """ + Generate a self-signed certificate for the server with proper SAN extensions. + + This method auto-detects local IP addresses and includes them in the + certificate's Subject Alternative Names (SANs) to prevent hostname + validation errors when connecting via IP address. + + Args: + cert_path: Path where certificate will be saved + key_path: Path where private key will be saved + common_name: Common name for the certificate + key_size: RSA key size + valid_days: Certificate validity period + app_uri: Application URI for the certificate (from config) + + Returns: + bool: True if certificate generated successfully + """ + try: + # Get system hostname for proper certificate validation + system_hostname = socket.gethostname() + + # Extract hostname from endpoint if available + endpoint_hostname = "localhost" # default + if hasattr(self.config, "endpoint") and self.config.endpoint: + try: + # Convert opc.tcp:// to http:// for parsing + endpoint_url = self.config.endpoint.replace("opc.tcp://", "http://") + parsed = urlparse(endpoint_url) + if parsed.hostname and parsed.hostname != "0.0.0.0": + endpoint_hostname = parsed.hostname + except Exception as e: + log_warn(f"Could not parse endpoint hostname: {e}") + + # Use provided app_uri or fallback to default + if not app_uri: + app_uri = "urn:autonomy-logic:openplc:opcua:server" + + # Collect all possible hostnames for SAN DNS entries + dns_names = [] + # Add system hostname + if system_hostname and system_hostname != "localhost": + dns_names.append(system_hostname) + # Add endpoint hostname if different + if endpoint_hostname and endpoint_hostname not in dns_names: + dns_names.append(endpoint_hostname) + # Always include localhost + if "localhost" not in dns_names: + dns_names.append("localhost") + + # Auto-detect all local IP addresses for SAN + local_ips = get_local_ip_addresses() + ip_addresses = list(local_ips) + + log_info(f"Generating certificate with DNS SANs: {dns_names}") + log_info(f"Generating certificate with IP SANs: {ip_addresses}") + log_info(f"Application URI: {app_uri}") + + # Use custom certificate generation with multiple SANs + success = generate_certificate_with_sans( + cert_path=Path(cert_path), + key_path=Path(key_path), + app_uri=app_uri, + dns_names=dns_names, + ip_addresses=ip_addresses, + common_name=common_name, + key_size=key_size, + valid_days=valid_days, + ) + + if success: + log_info(f"Server certificate generated with proper SANs: {cert_path}") + return success + + except Exception as e: + log_error(f"Failed to generate server certificate: {e}") + return False + + async def setup_server_security(self, server, security_profiles, app_uri: str = None) -> None: + """Setup security policies and certificates for asyncua Server. + + Args: + server: asyncua Server instance + security_profiles: List of security profiles from config + app_uri: Application URI for the certificate (from config) + """ + # Setup security policies + security_policies = [] + + for profile in security_profiles: + if not profile.enabled: + continue + + policy_key = (profile.security_policy, profile.security_mode) + policy_type = self.POLICY_TYPE_MAPPING.get(policy_key) + + if policy_type is not None: + security_policies.append(policy_type) + log_info( + f"Added security profile '{profile.name}': {profile.security_policy}/{profile.security_mode} -> {policy_type}" + ) + else: + log_warn( + f"Unsupported security policy/mode combination '{profile.security_policy}/{profile.security_mode}' for profile '{profile.name}', skipping" + ) + + # Create custom permission ruleset that allows ModifySubscription for users + permission_ruleset = OpenPLCRoleRuleset() + + if security_policies: + log_info("=== SECURITY MANAGER DEBUG ===") + log_info(f"Setting {len(security_policies)} security policies: {security_policies}") + server.set_security_policy(security_policies, permission_ruleset=permission_ruleset) + log_info("Security policies applied to server successfully") + log_info("Using OpenPLCRoleRuleset for subscription permission support") + log_info("=== END SECURITY MANAGER DEBUG ===") + else: + # Default to no security if no profiles enabled + log_warn("No security profiles enabled, defaulting to NoSecurity") + server.set_security_policy( + [ua.SecurityPolicyType.NoSecurity], permission_ruleset=permission_ruleset + ) + + # Setup server certificates if needed + log_info("=== CERTIFICATE SETUP DEBUG ===") + await self._setup_server_certificates_for_asyncua(server, app_uri) + log_info("=== END CERTIFICATE SETUP DEBUG ===") + + async def _setup_server_certificates_for_asyncua(self, server, app_uri: str = None) -> None: + """Setup server certificates for asyncua Server. + + Args: + server: asyncua Server instance + app_uri: Application URI for the certificate (from config) + """ + if ( + hasattr(self.config, "security") + and self.config.security.server_certificate_strategy == "auto_self_signed" + ): + # Generate self-signed certificate in persistent directory + cert_dir = Path(self.plugin_dir) / "certs" + cert_dir.mkdir(parents=True, exist_ok=True) + + key_file = cert_dir / "server_key.pem" + cert_file = cert_dir / "server_cert.pem" + + hostname = socket.gethostname() + # Use provided app_uri or fallback to config value + if not app_uri: + app_uri = getattr( + self.config.server, "application_uri", "urn:autonomy-logic:openplc:opcua:server" + ) + + # Check if we need to generate new certificates + need_generation = False + if not cert_file.exists() or not key_file.exists(): + log_info("Certificate files not found, will generate new ones") + need_generation = True + elif not self._is_certificate_valid(str(cert_file)): + log_info("Certificate is expired or invalid, will regenerate") + self._remove_certificate_files(str(cert_file), str(key_file)) + need_generation = True + + if need_generation: + log_info(f"Generating new self-signed certificate in {cert_dir}") + log_info(f"Certificate will be created for app_uri: {app_uri}") + log_info(f"Certificate will be created for hostname: {hostname}") + + # Collect DNS names for SAN + dns_names = [hostname] + if hostname != "localhost": + dns_names.append("localhost") + + # Auto-detect all local IP addresses for SAN + local_ips = get_local_ip_addresses() + ip_addresses = list(local_ips) + + log_info(f"Certificate DNS SANs: {dns_names}") + log_info(f"Certificate IP SANs: {ip_addresses}") + + # Use custom certificate generation with multiple SANs + success = generate_certificate_with_sans( + cert_path=cert_file, + key_path=key_file, + app_uri=app_uri, + dns_names=dns_names, + ip_addresses=ip_addresses, + common_name="OpenPLC OPC-UA Server", + ) + + # Verify files were created + if not success or not cert_file.exists() or not key_file.exists(): + log_error( + f"Certificate files not created: cert={cert_file.exists()}, key={key_file.exists()}" + ) + return + + log_info(f"Certificate files created successfully: {cert_file}, {key_file}") + else: + log_info(f"Using existing valid certificate files: {cert_file}, {key_file}") + + # Load and convert certificate from PEM to DER + log_info(f"Loading server certificate from: {cert_file}") + with open(cert_file, "rb") as f: + cert_pem_data = f.read() + log_info(f"Certificate PEM loaded: {len(cert_pem_data)} bytes") + + # Load private key + log_info(f"Loading server private key from: {key_file}") + with open(key_file, "rb") as f: + key_pem_data = f.read() + + # Convert certificate and key from PEM to DER for asyncua compatibility + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + ) + + try: + # Convert certificate PEM to DER + cert_obj = x509.load_pem_x509_certificate(cert_pem_data) + cert_der_data = cert_obj.public_bytes(serialization.Encoding.DER) + log_info(f"Certificate converted to DER: {len(cert_der_data)} bytes") + + # Convert private key PEM to DER + private_key = load_pem_private_key(key_pem_data, password=None) + key_der_data = private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + log_info(f"Private key converted to DER: {len(key_der_data)} bytes") + + # Load certificate and key into server (both in DER format) + log_info(f"Loading certificate into asyncua server: {len(cert_der_data)} bytes DER") + await server.load_certificate(cert_der_data) + log_info(f"Loading private key into asyncua server: {len(key_der_data)} bytes DER") + await server.load_private_key(key_der_data) + + except Exception as e: + log_error(f"Failed to load certificate/key into asyncua server: {e}") + raise + + log_info("Self-signed server certificate loaded successfully into asyncua server") + + elif hasattr(self.config, "security") and self.config.security.server_certificate_custom: + cert_path = self.config.security.server_certificate_custom + key_path = self.config.security.server_private_key_custom + if cert_path and key_path: + try: + # Carregar certificado + with open(cert_path, "rb") as f: + cert_data = f.read() + + # Carregar e converter chave privada de PEM para DER + with open(key_path, "rb") as f: + pem_key_data = f.read() + + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + ) + + private_key = load_pem_private_key(pem_key_data, password=None) + der_key_data = private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + await server.load_certificate(cert_data) + await server.load_private_key(der_key_data) + log_info("Custom server certificate loaded (PEM cert + DER key)") + except Exception as e: + log_error(f"Failed to load custom certificate: {e}") + + elif self.certificate_data and self.private_key_data: + await server.load_certificate(self.certificate_data) + await server.load_private_key(self.private_key_data) + log_info("SecurityManager certificates loaded into server") + + async def create_trust_store(self, trusted_certificates: List[str]) -> Optional[TrustStore]: + """Create and configure TrustStore with trusted client certificates. + + Args: + trusted_certificates: List of PEM certificate strings + + Returns: + TrustStore instance or None if failed + """ + if not trusted_certificates: + return None + + try: + # Create temporary directory for certificate files + temp_dir = tempfile.mkdtemp(prefix="opcua_trust_") + self._trust_store_temp_dir = temp_dir # Store for cleanup + cert_files = [] + + for i, cert_pem in enumerate(trusted_certificates): + try: + # Load and validate certificate using cryptography + cert = x509.load_pem_x509_certificate(cert_pem.encode()) + + # Convert to DER format and save to temporary file + cert_der = cert.public_bytes(encoding=serialization.Encoding.DER) + + cert_file = os.path.join(temp_dir, f"trusted_cert_{i}.der") + with open(cert_file, "wb") as f: + f.write(cert_der) + + cert_files.append(cert_file) + log_info(f"Added trusted certificate {i + 1} to trust store") + + except Exception as e: + log_warn(f"Failed to process trusted certificate {i + 1}: {e}") + + if cert_files: + # Create TrustStore with certificate files + trust_store = TrustStore(cert_files, []) + await trust_store.load() + log_info(f"TrustStore created with {len(cert_files)} certificates") + return trust_store + else: + log_warn("No valid trusted certificates processed") + return None + + except Exception as e: + log_error(f"Failed to create TrustStore: {e}") + return None + + def cleanup(self) -> None: + """Clean up resources including temporary directories. + + Should be called when the server is shutting down. + """ + if self._trust_store_temp_dir and os.path.exists(self._trust_store_temp_dir): + try: + shutil.rmtree(self._trust_store_temp_dir) + log_info(f"Cleaned up trust store temp directory: {self._trust_store_temp_dir}") + self._trust_store_temp_dir = None + except Exception as e: + log_warn(f"Failed to clean up trust store temp directory: {e}") + + async def setup_certificate_validation(self, server, trusted_certificates) -> None: + """Setup certificate validation for asyncua Server. + + Args: + server: asyncua Server instance + trusted_certificates: List of certificate dictionaries with 'id' and 'pem' keys + """ + if not trusted_certificates: + return + + try: + # Handle both List[str] and List[Dict[str, str]] formats + cert_pems = [] + if trusted_certificates and isinstance(trusted_certificates[0], dict): + # Extract PEM strings from certificate dictionaries + cert_pems = [cert_info["pem"] for cert_info in trusted_certificates] + else: + # Already a list of PEM strings + cert_pems = trusted_certificates + + # Create trust store + trust_store = await self.create_trust_store(cert_pems) + if not trust_store: + log_error("Could not create trust store") + return + + # Create certificate validator + cert_validator = CertificateValidator(trust_store=trust_store) + + # Set validator on server + server.set_certificate_validator(cert_validator) + log_info("Certificate validation configured") + + except Exception as e: + log_error(f"Failed to setup certificate validation: {e}") diff --git a/core/src/drivers/plugins/python/opcua/opcua_types.py b/core/src/drivers/plugins/python/opcua/opcua_types.py new file mode 100644 index 00000000..75bf5cf3 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_types.py @@ -0,0 +1,26 @@ +"""OPC-UA plugin type definitions.""" + +from dataclasses import dataclass +from typing import Optional, Any +from asyncua.common.node import Node + + +@dataclass +class VariableNode: + """Represents an OPC-UA node mapped to a PLC debug variable.""" + node: Node + debug_var_index: int + datatype: str + access_mode: str + is_array_element: bool = False + array_index: Optional[int] = None + array_length: Optional[int] = None # Length of array (for array nodes only) + + +@dataclass +class VariableMetadata: + """Metadata cache for direct memory access.""" + index: int + address: int + size: int + inferred_type: str diff --git a/core/src/drivers/plugins/python/opcua/opcua_utils.py b/core/src/drivers/plugins/python/opcua/opcua_utils.py new file mode 100644 index 00000000..6b0c9eea --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/opcua_utils.py @@ -0,0 +1,351 @@ +"""OPC-UA plugin utility functions.""" + +import ctypes +import os +import sys +import struct +from typing import Any +from asyncua import ua + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) + +# Import logging (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_warn, log_error +except ImportError: + from opcua_logging import log_info, log_warn, log_error + + +# TIME-related datatypes that use IEC_TIMESPEC structure +TIME_DATATYPES = frozenset(["TIME", "DATE", "TOD", "DT"]) + + +def map_plc_to_opcua_type(plc_type: str) -> ua.VariantType: + """Map plc datatype to OPC-UA VariantType.""" + type_mapping = { + "BOOL": ua.VariantType.Boolean, + "BYTE": ua.VariantType.Byte, + "INT": ua.VariantType.Int16, + "INT32": ua.VariantType.Int32, + "DINT": ua.VariantType.Int32, + "LINT": ua.VariantType.Int64, + "FLOAT": ua.VariantType.Float, + "REAL": ua.VariantType.Float, # IEC 61131-3 REAL = 32-bit float + "STRING": ua.VariantType.String, + # TIME-related types + "TIME": ua.VariantType.Int64, # Duration in milliseconds + "TOD": ua.VariantType.DateTime, # Time of day as DateTime (current date + time) + "DATE": ua.VariantType.DateTime, # Date as DateTime (date only, time set to 00:00:00) + "DT": ua.VariantType.DateTime, # Date and Time as OPC-UA DateTime + } + mapped_type = type_mapping.get(plc_type.upper(), ua.VariantType.Variant) + return mapped_type + + +def timespec_to_milliseconds(tv_sec: int, tv_nsec: int) -> int: + """ + Convert IEC_TIMESPEC (tv_sec, tv_nsec) to milliseconds. + + Args: + tv_sec: Seconds component + tv_nsec: Nanoseconds component + + Returns: + Total time in milliseconds + """ + return (tv_sec * 1000) + (tv_nsec // 1_000_000) + + +def milliseconds_to_timespec(ms: int) -> tuple[int, int]: + """ + Convert milliseconds to IEC_TIMESPEC format (tv_sec, tv_nsec). + + Args: + ms: Time in milliseconds + + Returns: + Tuple of (tv_sec, tv_nsec) + """ + tv_sec = ms // 1000 + tv_nsec = (ms % 1000) * 1_000_000 + return (tv_sec, tv_nsec) + + +def convert_value_for_opcua(datatype: str, value: Any) -> Any: + """Convert PLC debug variable value to OPC-UA compatible format.""" + # The debug utils return raw integer values based on variable size + # Convert to appropriate OPC-UA types based on config datatype + try: + if datatype.upper() in ["BOOL", "Bool"]: + # Ensure BOOL values are proper Python booleans + if isinstance(value, bool): + return value + elif isinstance(value, (int, float)): + return bool(value != 0) + else: + return bool(value) + + elif datatype.upper() in ["BYTE", "Byte"]: + # Ensure proper uint8 type for OPC-UA compatibility + return ctypes.c_uint8(max(0, min(255, int(value)))).value + + elif datatype.upper() in ["INT", "Int"]: + # Ensure proper int16 type - critical for OPC-UA compatibility + clamped_value = max(-32768, min(32767, int(value))) + return ctypes.c_int16(clamped_value).value + + elif datatype.upper() in ["DINT", "Dint", "INT32", "Int32"]: + # Ensure proper int32 type for OPC-UA compatibility + clamped_value = max(-2147483648, min(2147483647, int(value))) + return ctypes.c_int32(clamped_value).value + + elif datatype.upper() in ["LINT", "Lint"]: + return int(value) # int64 + + elif datatype.upper() in ["FLOAT", "REAL"]: + # Float/Real values are stored as integers in debug variables + # Convert back to float if it's an integer representation + if isinstance(value, int): + try: + return struct.unpack('f', struct.pack('I', value))[0] + except: + return float(value) + return float(value) + + elif datatype.upper() in ["STRING", "String"]: + return str(value) + + elif datatype.upper() == "TIME": + # TIME values are stored as IEC_TIMESPEC (tv_sec, tv_nsec) + # Convert to milliseconds for OPC-UA Int64 representation + if isinstance(value, tuple) and len(value) == 2: + tv_sec, tv_nsec = value + return timespec_to_milliseconds(tv_sec, tv_nsec) + elif isinstance(value, int): + # If already an integer, assume it's milliseconds + return value + return 0 + + elif datatype.upper() == "TOD": + # TOD (Time of Day) - use current date + time from timespec + # IEC_TIMESPEC stores seconds since midnight for TOD + from datetime import datetime, timezone + + if isinstance(value, tuple) and len(value) == 2: + tv_sec, tv_nsec = value + # tv_sec contains seconds since midnight + hours = tv_sec // 3600 + minutes = (tv_sec % 3600) // 60 + seconds = tv_sec % 60 + microseconds = tv_nsec // 1000 + + # Use current date (today) + time from timespec + today = datetime.now(timezone.utc).date() + try: + dt = datetime( + today.year, today.month, today.day, + hours, minutes, seconds, microseconds, + tzinfo=timezone.utc + ) + return dt + except (ValueError, OverflowError) as e: + # Invalid time, return today at midnight + log_warn(f"Invalid TOD value (hours={hours}), using midnight: {e}") + return datetime(today.year, today.month, today.day, tzinfo=timezone.utc) + elif isinstance(value, datetime): + return value + # Default: today at midnight + today = datetime.now(timezone.utc).date() + return datetime(today.year, today.month, today.day, tzinfo=timezone.utc) + + elif datatype.upper() == "DATE": + # DATE - use date from timespec, set time to 00:00:00 + # IEC_TIMESPEC stores seconds since epoch (1970-01-01) + from datetime import datetime, timezone + + if isinstance(value, tuple) and len(value) == 2: + tv_sec, tv_nsec = value + try: + # Convert to datetime and extract date only + dt = datetime.fromtimestamp(tv_sec, tz=timezone.utc) + # Set time to 00:00:00 (ignore time portion) + dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) + return dt + except (OSError, OverflowError, ValueError): + return datetime(1970, 1, 1, tzinfo=timezone.utc) + elif isinstance(value, datetime): + # Zero out time portion + return value.replace(hour=0, minute=0, second=0, microsecond=0) + return datetime(1970, 1, 1, tzinfo=timezone.utc) + + elif datatype.upper() == "DT": + # DT (Date and Time) - full DateTime conversion + from datetime import datetime, timezone + + if isinstance(value, tuple) and len(value) == 2: + tv_sec, tv_nsec = value + try: + dt = datetime.fromtimestamp(tv_sec, tz=timezone.utc) + dt = dt.replace(microsecond=tv_nsec // 1000) + return dt + except (OSError, OverflowError, ValueError): + return datetime(1970, 1, 1, tzinfo=timezone.utc) + elif isinstance(value, datetime): + return value + return datetime(1970, 1, 1, tzinfo=timezone.utc) + + else: + return value + + except (ValueError, TypeError, OverflowError) as e: + # If conversion fails, return a safe default + log_warn(f"Failed to convert value {value} to OPC-UA format for {datatype}: {e}") + if datatype.upper() == "BOOL": + return False + elif datatype.upper() in ["FLOAT", "REAL"]: + return 0.0 + elif datatype.upper() == "STRING": + return "" + elif datatype.upper() in TIME_DATATYPES: + return 0 + else: + return 0 + + +def convert_value_for_plc(datatype: str, value: Any) -> Any: + """Convert OPC-UA value to PLC debug variable format.""" + # Handle different OPC-UA value types more robustly + try: + if datatype.upper() in ["BOOL", "Bool"]: + # Convert any value to boolean, then to int (0/1) + if isinstance(value, bool): + return int(value) + elif isinstance(value, (int, float)): + return 1 if value != 0 else 0 + elif isinstance(value, str): + return 1 if value.lower() in ['true', '1', 'yes', 'on'] else 0 + else: + return int(bool(value)) + + elif datatype.upper() in ["BYTE", "Byte"]: + # Ensure proper uint8 type for PLC compatibility + return ctypes.c_uint8(max(0, min(255, int(value)))).value + + elif datatype.upper() in ["INT", "Int"]: + # Ensure proper int16 type for PLC compatibility + clamped_value = max(-32768, min(32767, int(value))) + return ctypes.c_int16(clamped_value).value + + elif datatype.upper() in ["DINT", "Dint", "INT32", "Int32"]: + # Ensure proper int32 type for PLC compatibility + clamped_value = max(-2147483648, min(2147483647, int(value))) + return ctypes.c_int32(clamped_value).value + + elif datatype.upper() in ["LINT", "Lint"]: + return int(value) # int64 + + elif datatype.upper() in ["FLOAT", "REAL"]: + # Convert float to int representation for storage + if isinstance(value, float): + try: + return struct.unpack('I', struct.pack('f', value))[0] + except: + return int(value) + else: + return int(float(value)) + + elif datatype.upper() in ["STRING", "String"]: + return str(value) + + elif datatype.upper() == "TIME": + # Convert OPC-UA milliseconds (Int64) to IEC_TIMESPEC tuple + ms = int(value) + return milliseconds_to_timespec(ms) + + elif datatype.upper() == "TOD": + # TOD (Time of Day) - extract time portion only (seconds since midnight) + from datetime import datetime, timezone + + if isinstance(value, datetime): + # Calculate seconds since midnight + tv_sec = value.hour * 3600 + value.minute * 60 + value.second + tv_nsec = value.microsecond * 1000 + return (tv_sec, tv_nsec) + elif isinstance(value, (int, float)): + # Assume it's seconds since midnight + return (int(value), 0) + return (0, 0) + + elif datatype.upper() == "DATE": + # DATE - extract date only, set time to 00:00:00 + from datetime import datetime, timezone + + if isinstance(value, datetime): + # Create datetime at midnight for the date, then get timestamp + dt_midnight = value.replace(hour=0, minute=0, second=0, microsecond=0) + tv_sec = int(dt_midnight.timestamp()) + return (tv_sec, 0) + elif isinstance(value, (int, float)): + # Assume it's a timestamp, zero out time portion + dt = datetime.fromtimestamp(int(value), tz=timezone.utc) + dt_midnight = dt.replace(hour=0, minute=0, second=0, microsecond=0) + return (int(dt_midnight.timestamp()), 0) + return (0, 0) + + elif datatype.upper() == "DT": + # DT (Date and Time) - full DateTime conversion + from datetime import datetime, timezone + + if isinstance(value, datetime): + tv_sec = int(value.timestamp()) + tv_nsec = value.microsecond * 1000 + return (tv_sec, tv_nsec) + elif isinstance(value, (int, float)): + return (int(value), 0) + return (0, 0) + + else: + # For unknown types, try to preserve the value + return value + + except (ValueError, TypeError, OverflowError) as e: + # If conversion fails, log and return a safe default + log_warn(f"Failed to convert value {value} to {datatype}, using default: {e}") + if datatype.upper() == "BOOL": + return 0 + elif datatype.upper() in ["FLOAT", "REAL"]: + return 0 + elif datatype.upper() == "STRING": + return "" + elif datatype.upper() in TIME_DATATYPES: + return (0, 0) + else: + return 0 + + +def infer_var_type(size: int) -> str: + """ + Infer variable type from size. + + Args: + size: Size of the variable in bytes + + Returns: + String indicating the inferred type or type category + """ + if size == 1: + return "BOOL_OR_SINT" + elif size == 2: + return "UINT16" + elif size == 4: + return "UINT32_OR_TIME" + elif size == 8: + return "UINT64_OR_TIME" + elif size == 127: + # IEC_STRING: 1 byte len + 126 bytes body = 127 bytes + return "STRING" + else: + return "UNKNOWN" diff --git a/core/src/drivers/plugins/python/opcua/plugin.py b/core/src/drivers/plugins/python/opcua/plugin.py new file mode 100644 index 00000000..41962f0e --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/plugin.py @@ -0,0 +1,261 @@ +""" +OPC UA Plugin Entry Point. + +This module provides the plugin interface required by the OpenPLC runtime: +- init(args_capsule): Initialize the plugin +- start_loop(): Start the OPC UA server +- stop_loop(): Stop the OPC UA server +- cleanup(): Clean up resources + +This is a thin entry point that delegates to the modular components. +""" + +import sys +import os +import asyncio +import threading +from typing import Optional + +# Add current and parent directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) + +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import shared modules +from shared import ( + SafeBufferAccess, + SafeLoggingAccess, + safe_extract_runtime_args_from_capsule, +) +from shared.plugin_config_decode.opcua_config_model import OpcuaConfig + +# Import local modules (use absolute imports for runtime compatibility) +try: + # Try relative imports first (when loaded as package) + from .opcua_logging import get_logger, log_info, log_warn, log_error + from .config import load_config + from .server import OpcuaServerManager +except ImportError: + # Fall back to absolute imports (when loaded directly by runtime) + from opcua_logging import get_logger, log_info, log_warn, log_error + from config import load_config + from server import OpcuaServerManager + + +# Plugin state +_runtime_args = None +_buffer_accessor: Optional[SafeBufferAccess] = None +_config: Optional[OpcuaConfig] = None +_server_manager: Optional[OpcuaServerManager] = None +_server_thread: Optional[threading.Thread] = None +_stop_event = threading.Event() + + +def init(args_capsule) -> bool: + """ + Initialize the OPC UA plugin. + + Called once when the plugin is loaded by the runtime. + + Args: + args_capsule: PyCapsule containing runtime arguments + + Returns: + True if initialization successful, False otherwise + """ + global _runtime_args, _buffer_accessor, _config, _server_manager + + log_info("OPC UA Plugin initializing...") + + try: + # Extract runtime arguments + _runtime_args, error_msg = safe_extract_runtime_args_from_capsule(args_capsule) + if not _runtime_args: + log_error(f"Failed to extract runtime args: {error_msg}") + return False + + # Initialize logging with runtime accessor + logging_accessor = SafeLoggingAccess(_runtime_args) + if logging_accessor.is_valid: + get_logger().initialize(logging_accessor) + log_info("Logging initialized with runtime accessor") + + # Create buffer accessor + _buffer_accessor = SafeBufferAccess(_runtime_args) + if not _buffer_accessor.is_valid: + log_error(f"Failed to create buffer accessor: {_buffer_accessor.error_msg}") + return False + + log_info("Buffer accessor created") + + # Load configuration + config_path, config_error = _buffer_accessor.get_config_path() + if not config_path: + log_error(f"Failed to get config path: {config_error}") + return False + + _config = load_config(config_path) + if not _config: + log_error("Failed to load configuration") + return False + + # Create server manager + plugin_dir = os.path.dirname(__file__) + _server_manager = OpcuaServerManager(_config, _buffer_accessor, plugin_dir) + + log_info("OPC UA Plugin initialized successfully") + return True + + except Exception as e: + log_error(f"Initialization error: {e}") + return False + + +def start_loop() -> bool: + """ + Start the OPC UA server. + + Called after successful initialization to start the server. + + Returns: + True if server started successfully, False otherwise + """ + global _server_thread + + log_info("Starting OPC UA server...") + + try: + if not _server_manager: + log_error("Plugin not initialized") + return False + + # Reset stop event + _stop_event.clear() + + # Start server in background thread + _server_thread = threading.Thread( + target=_run_server_thread, + daemon=True, + name="opcua-server" + ) + _server_thread.start() + + log_info("OPC UA server thread started") + return True + + except Exception as e: + log_error(f"Failed to start server: {e}") + return False + + +def stop_loop() -> bool: + """ + Stop the OPC UA server. + + Called when the plugin needs to be stopped. + + Returns: + True if server stopped successfully, False otherwise + """ + global _server_thread + + log_info("Stopping OPC UA server...") + + try: + # Signal stop + _stop_event.set() + + # Wait for thread to finish + if _server_thread and _server_thread.is_alive(): + _server_thread.join(timeout=5.0) + + if _server_thread.is_alive(): + log_warn("Server thread did not stop within timeout") + else: + log_info("Server thread stopped") + + _server_thread = None + log_info("OPC UA server stopped") + return True + + except Exception as e: + log_error(f"Error stopping server: {e}") + return False + + +def cleanup() -> bool: + """ + Clean up plugin resources. + + Called when the plugin is being unloaded. + + Returns: + True if cleanup successful, False otherwise + """ + global _runtime_args, _buffer_accessor, _config, _server_manager, _server_thread + + log_info("Cleaning up OPC UA plugin...") + + try: + # Stop server if running + stop_loop() + + # Clear references + _runtime_args = None + _buffer_accessor = None + _config = None + _server_manager = None + _server_thread = None + + log_info("Cleanup completed") + return True + + except Exception as e: + log_error(f"Cleanup error: {e}") + return False + + +def _run_server_thread() -> None: + """ + Server thread main function. + + Runs the async server in a new event loop. + """ + async def _run_with_stop_check(): + """Run server with stop event monitoring.""" + # Create stop monitoring task + async def _monitor_stop(): + while not _stop_event.is_set(): + await asyncio.sleep(0.1) + + # Stop requested + if _server_manager: + await _server_manager.stop() + + # Run server and stop monitor concurrently + monitor_task = asyncio.create_task(_monitor_stop()) + + try: + await _server_manager.run() + except asyncio.CancelledError: + pass + finally: + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass + + try: + asyncio.run(_run_with_stop_check()) + except Exception as e: + log_error(f"Server thread error: {e}") + + +# For backwards compatibility, also export as module-level functions +# that match the old plugin interface +__all__ = ['init', 'start_loop', 'stop_loop', 'cleanup'] diff --git a/core/src/drivers/plugins/python/opcua/requirements.txt b/core/src/drivers/plugins/python/opcua/requirements.txt new file mode 100644 index 00000000..e520990d --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/requirements.txt @@ -0,0 +1,24 @@ +# OPC-UA Plugin Dependencies for OpenPLC Runtime +# Main OPC-UA library for async server implementation +asyncua==1.1.8 + +# System monitoring for network interface detection (optional on MSYS2/Cygwin) +# The code has socket-based fallbacks when psutil is unavailable (see opcua_endpoints_config.py) +psutil==7.2.1; sys_platform != 'cygwin' + +# Password hashing for user authentication +# On MSYS2/Cygwin, bcrypt cannot be built (Rust/pyo3 linking issues). +# The code falls back to PBKDF2-HMAC-SHA256 (Python stdlib) when bcrypt is unavailable. +bcrypt>=4.0.0; sys_platform != 'cygwin' + +# Core dependencies (automatically installed with asyncua) +# cryptography>=3.4.8 # For OPC-UA security features +# python-dateutil>=2.8.0 # For datetime handling in OPC-UA +# aiofiles>=0.8.0 # For async file operations +# pytz>=2021.1 # Timezone support + +# Development and testing dependencies (optional) +# Uncomment if running tests +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.10.0 diff --git a/core/src/drivers/plugins/python/opcua/server.py b/core/src/drivers/plugins/python/opcua/server.py new file mode 100644 index 00000000..cd637454 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/server.py @@ -0,0 +1,449 @@ +""" +OPC UA Server Manager. + +This module provides the main server orchestration for the OPC UA plugin. +It coordinates all server components and manages the server lifecycle. +""" + +import asyncio +import os +import sys +import traceback +from datetime import datetime +from typing import Optional, Dict, Any + +from asyncua import Server, ua + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import local modules (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_warn, log_error, log_debug + from .opcua_security import OpcuaSecurityManager + from .opcua_endpoints_config import normalize_endpoint_url, suggest_client_endpoints + from .address_space import AddressSpaceBuilder + from .synchronization import SynchronizationManager + from .user_manager import OpenPLCUserManager + from .callbacks import PermissionCallbackHandler + from .opcua_types import VariableNode +except ImportError: + from opcua_logging import log_info, log_warn, log_error, log_debug + from opcua_security import OpcuaSecurityManager + from opcua_endpoints_config import normalize_endpoint_url, suggest_client_endpoints + from address_space import AddressSpaceBuilder + from synchronization import SynchronizationManager + from user_manager import OpenPLCUserManager + from callbacks import PermissionCallbackHandler + from opcua_types import VariableNode + +from shared import SafeBufferAccess +from shared.plugin_config_decode.opcua_config_model import OpcuaConfig + + +class OpcuaServerManager: + """ + Manages the OPC-UA server lifecycle and coordinates components. + + Responsibilities: + - Server initialization and configuration + - Security setup (certificates, policies) + - Component orchestration (address space, sync, callbacks) + - Main run loop + - Graceful shutdown + + Usage: + manager = OpcuaServerManager(config, buffer_accessor, plugin_dir) + await manager.run() # Blocks until stop() is called + await manager.stop() + """ + + def __init__( + self, + config: OpcuaConfig, + buffer_accessor: SafeBufferAccess, + plugin_dir: str + ): + """ + Initialize the server manager. + + Args: + config: Typed OpcuaConfig instance + buffer_accessor: SafeBufferAccess for PLC memory operations + plugin_dir: Directory path for certificates and resources + """ + self.config = config + self.buffer_accessor = buffer_accessor + self.plugin_dir = plugin_dir + + # Server state + self.server: Optional[Server] = None + self.running = False + self.namespace_idx: Optional[int] = None + + # Security manager + self.security_manager = OpcuaSecurityManager(config, plugin_dir) + + # User manager for authentication + self.user_manager = OpenPLCUserManager(config) + + # Client endpoint suggestions (populated after setup) + self._client_endpoints: Dict[str, str] = {} + + # Address space builder (initialized in _create_address_space) + self.address_space_builder: Optional[AddressSpaceBuilder] = None + + # Node mappings (populated by address space builder) + self.variable_nodes: Dict[int, VariableNode] = {} + self.node_permissions: Dict[str, Any] = {} + self.nodeid_to_variable: Dict[Any, str] = {} + + # Synchronization manager (initialized after address space) + self.sync_manager: Optional[SynchronizationManager] = None + + # Permission callback handler (initialized after address space) + self.callback_handler: Optional[PermissionCallbackHandler] = None + + async def run(self) -> None: + """ + Initialize, start, and run the server. + + This is the main entry point that: + 1. Sets up the server with security + 2. Creates address space (Phase 2) + 3. Starts the server + 4. Runs the sync loop (Phase 3) + + Blocks until stop() is called or an error occurs. + """ + try: + log_info("OpcuaServerManager starting...") + + # Setup server + if not await self._setup_server(): + log_error("Failed to setup server") + return + + # Create address space (nodes) + if not await self._create_address_space(): + log_error("Failed to create address space") + return + + # Register permission callbacks (AFTER address space, BEFORE start) + if not await self._register_callbacks(): + log_warn("Failed to register permission callbacks - continuing without access control") + + # Initialize synchronization manager + if not await self._initialize_sync_manager(): + log_error("Failed to initialize sync manager") + return + + # Start server + if not await self._start_server(): + log_error("Failed to start server") + return + + # Run main loop + log_info("Server running, entering main loop...") + await self._main_loop() + + except Exception as e: + log_error(f"Error in server manager: {e}") + traceback.print_exc() + finally: + await self._cleanup() + + async def stop(self) -> None: + """ + Stop the server gracefully. + + Sets running flag to False and waits for main loop to exit. + """ + log_info("Stop requested...") + self.running = False + + async def _setup_server(self) -> bool: + """ + Configure and initialize the asyncua Server. + + Order of operations (critical for asyncua): + 1. Create Server instance + 2. Set endpoint URL (BEFORE init) + 3. Set server name (BEFORE init) + 4. Configure security (BEFORE init) + 5. Call server.init() + 6. Register namespace (AFTER init) + 7. Set build info (AFTER init) + + Returns: + True if setup successful, False otherwise + """ + try: + # Create server with user manager for authentication + self.server = Server(user_manager=self.user_manager) + + # Normalize and set endpoint URL (BEFORE init) + try: + normalized_endpoint = normalize_endpoint_url( + self.config.server.endpoint_url + ) + self.server.set_endpoint(normalized_endpoint) + self._client_endpoints = suggest_client_endpoints(normalized_endpoint) + log_info(f"Server endpoint set to: {normalized_endpoint}") + except Exception as e: + log_warn(f"Endpoint normalization failed, using raw URL: {e}") + self.server.set_endpoint(self.config.server.endpoint_url) + + # Set server name and URIs (BEFORE init) + self.server.set_server_name(self.config.server.name) + self.server.application_uri = self.config.server.application_uri + + # Configure security (BEFORE init) + await self.security_manager.setup_server_security( + self.server, + self.config.server.security_profiles, + app_uri=self.config.server.application_uri + ) + + # Setup certificate validation for client certificates (BEFORE init) + if self.config.security.trusted_client_certificates: + await self.security_manager.setup_certificate_validation( + self.server, + self.config.security.trusted_client_certificates + ) + + # Initialize the server + await self.server.init() + log_info("OPC-UA server initialized") + + # Register namespace (AFTER init) + self.namespace_idx = await self.server.register_namespace( + self.config.address_space.namespace_uri + ) + log_info( + f"Registered namespace: {self.config.address_space.namespace_uri} " + f"(index: {self.namespace_idx})" + ) + + # Set build info (AFTER init) + await self.server.set_build_info( + product_uri=self.config.server.product_uri, + manufacturer_name="Autonomy Logic", + product_name="OpenPLC Runtime", + software_version="1.0.0", + build_number="1.0.0.0", + build_date=datetime.now() + ) + + log_info("OPC-UA server setup completed successfully") + return True + + except Exception as e: + log_error(f"Failed to setup OPC-UA server: {e}") + traceback.print_exc() + return False + + async def _start_server(self) -> bool: + """ + Start the OPC-UA server. + + Returns: + True if server started successfully, False otherwise + """ + try: + if not self.server: + log_error("Server not initialized") + return False + + await self.server.start() + self.running = True + + log_info(f"OPC-UA server started on {self.config.server.endpoint_url}") + + # Print alternative endpoints for client connection + if self._client_endpoints: + log_info("Alternative client endpoints:") + for scenario, endpoint in self._client_endpoints.items(): + if endpoint: + log_info(f" {scenario}: {endpoint}") + + return True + + except Exception as e: + log_error(f"Failed to start OPC-UA server: {e}") + traceback.print_exc() + return False + + async def _main_loop(self) -> None: + """ + Main server loop with bidirectional synchronization. + + Runs the sync manager's unified sync loop which handles: + 1. OPC-UA → Runtime (client writes to PLC) + 2. Runtime → OPC-UA (PLC values to clients) + """ + cycle_time = self.config.cycle_time_ms / 1000.0 + + if self.sync_manager: + # Run the sync manager's loop (blocks until stopped) + await self.sync_manager.run( + is_running=lambda: self.running, + cycle_time_seconds=cycle_time + ) + else: + # Fallback: simple keepalive loop (no sync) + log_warn("No sync manager - running without synchronization") + while self.running: + try: + await asyncio.sleep(cycle_time) + except asyncio.CancelledError: + log_info("Main loop cancelled") + break + + async def _cleanup(self) -> None: + """ + Clean up resources. + + Stops the server and releases resources. + """ + try: + if self.server and self.running: + await self.server.stop() + log_info("OPC-UA server stopped") + + self.running = False + self.server = None + + # Clean up security manager resources (temp directories, etc.) + if self.security_manager: + self.security_manager.cleanup() + + except Exception as e: + log_error(f"Error during cleanup: {e}") + + # ------------------------------------------------------------------------- + # Address Space Creation + # ------------------------------------------------------------------------- + + async def _create_address_space(self) -> bool: + """ + Create OPC-UA nodes from configuration. + + Uses AddressSpaceBuilder to create all variable nodes + and stores the resulting mappings for synchronization. + + Returns: + True if address space created successfully + """ + try: + self.address_space_builder = AddressSpaceBuilder( + self.server, + self.namespace_idx, + self.config + ) + + if not await self.address_space_builder.build(): + return False + + # Copy mappings from builder for easy access + self.variable_nodes = self.address_space_builder.variable_nodes + self.node_permissions = self.address_space_builder.node_permissions + self.nodeid_to_variable = self.address_space_builder.nodeid_to_variable + + log_info(f"Address space created with {len(self.variable_nodes)} nodes") + return True + + except Exception as e: + log_error(f"Failed to create address space: {e}") + traceback.print_exc() + return False + + async def _initialize_sync_manager(self) -> bool: + """ + Initialize the synchronization manager. + + Creates the sync manager and initializes its metadata cache + for optimized memory access. Passes server reference for + optimized subscription notifications via write_attribute_value. + + Returns: + True if initialization successful + """ + try: + self.sync_manager = SynchronizationManager( + buffer_accessor=self.buffer_accessor, + variable_nodes=self.variable_nodes, + server=self.server # Pass server for subscription support + ) + + if not await self.sync_manager.initialize(): + log_warn("Sync manager initialization failed - sync may be limited") + # Don't fail completely - sync manager can still work with batch ops + + log_info("Synchronization manager initialized") + return True + + except Exception as e: + log_error(f"Failed to initialize sync manager: {e}") + traceback.print_exc() + return False + + async def _register_callbacks(self) -> bool: + """ + Register permission callbacks for access control. + + Creates the callback handler and registers it with the server. + Must be called AFTER address space creation and BEFORE server start. + + Returns: + True if callbacks registered successfully + """ + try: + # Only register callbacks if we have nodes with permissions + if not self.node_permissions: + log_info("No node permissions configured - skipping callback registration") + return True + + self.callback_handler = PermissionCallbackHandler( + node_permissions=self.node_permissions, + nodeid_to_variable=self.nodeid_to_variable + ) + + if not await self.callback_handler.register(self.server): + log_warn("Callback registration returned False") + return False + + log_info("Permission callback handler initialized") + return True + + except Exception as e: + log_error(f"Failed to register callbacks: {e}") + traceback.print_exc() + return False + + # ------------------------------------------------------------------------- + # Debug/Diagnostic Methods + # ------------------------------------------------------------------------- + + async def debug_endpoints(self) -> None: + """Debug method to verify endpoint configuration.""" + try: + log_info("=== ENDPOINT VERIFICATION ===") + endpoints = await self.server.get_endpoints() + log_info(f"Total endpoints created: {len(endpoints)}") + + for i, endpoint in enumerate(endpoints): + log_info(f"Endpoint {i+1}:") + log_info(f" URL: {endpoint.EndpointUrl}") + log_info(f" Security Policy: {endpoint.SecurityPolicyUri}") + log_info(f" Security Mode: {endpoint.SecurityMode}") + log_info(f" User Tokens: {len(endpoint.UserIdentityTokens)}") + + log_info("=== END ENDPOINT VERIFICATION ===") + except Exception as e: + log_error(f"Error during endpoint verification: {e}") diff --git a/core/src/drivers/plugins/python/opcua/synchronization.py b/core/src/drivers/plugins/python/opcua/synchronization.py new file mode 100644 index 00000000..57758291 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/synchronization.py @@ -0,0 +1,646 @@ +""" +OPC UA Synchronization Manager. + +This module provides bidirectional data synchronization between +OPC-UA server nodes and PLC runtime variables. + +Sync Directions: +1. OPC-UA → Runtime: Client writes propagated to PLC +2. Runtime → OPC-UA: PLC values published to clients + +Subscription Support: +- Uses write_attribute_value() with DataValue for optimal notification triggering +- Includes SourceTimestamp from PLC cycle and ServerTimestamp for audit trail +- Automatically triggers data change notifications for subscribed clients +""" + +import asyncio +import os +import sys +from datetime import datetime, timezone +from typing import Dict, Any, Optional, Callable + +from asyncua import ua, Server + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import local modules (handle both package and direct loading) +try: + from .opcua_logging import log_info, log_warn, log_error, log_debug + from .opcua_types import VariableNode, VariableMetadata + from .opcua_utils import ( + map_plc_to_opcua_type, + convert_value_for_opcua, + convert_value_for_plc, + TIME_DATATYPES, + ) + from .opcua_memory import ( + read_memory_direct, + initialize_variable_cache, + write_timespec_direct, + TIME_DATATYPES as MEM_TIME_DATATYPES, + ) +except ImportError: + from opcua_logging import log_info, log_warn, log_error, log_debug + from opcua_types import VariableNode, VariableMetadata + from opcua_utils import ( + map_plc_to_opcua_type, + convert_value_for_opcua, + convert_value_for_plc, + TIME_DATATYPES, + ) + from opcua_memory import ( + read_memory_direct, + initialize_variable_cache, + write_timespec_direct, + TIME_DATATYPES as MEM_TIME_DATATYPES, + ) + +from shared import SafeBufferAccess + + +class SynchronizationManager: + """ + Manages bidirectional data synchronization between OPC-UA and PLC runtime. + + Features: + - Unified sync loop (both directions in single cycle) + - Change detection to minimize writes + - Direct memory access optimization when available + - Batch operations for efficiency + - Subscription support with proper timestamps + + Usage: + sync_mgr = SynchronizationManager(buffer_accessor, variable_nodes, server) + await sync_mgr.initialize() + await sync_mgr.run(is_running_callback, cycle_time) + """ + + def __init__( + self, + buffer_accessor: SafeBufferAccess, + variable_nodes: Dict[int, VariableNode], + server: Optional[Server] = None + ): + """ + Initialize the synchronization manager. + + Args: + buffer_accessor: SafeBufferAccess for PLC memory operations + variable_nodes: Dict mapping variable index to VariableNode + server: Optional Server instance for optimized write_attribute_value + """ + self.buffer_accessor = buffer_accessor + self.variable_nodes = variable_nodes + self.server = server + + # Optimization: metadata cache for direct memory access + self.variable_metadata: Dict[int, VariableMetadata] = {} + self._direct_memory_access_enabled = False + + # Change detection cache (var_index -> last_value) + self.opcua_value_cache: Dict[int, Any] = {} + + # Readwrite nodes (filtered from variable_nodes) + self._readwrite_nodes: Dict[int, VariableNode] = {} + + # Cycle timestamp for subscription notifications + self._cycle_timestamp: Optional[datetime] = None + + # Track if we've logged the "no PLC" warning to avoid log spam + self._logged_no_plc_warning: bool = False + + async def initialize(self) -> bool: + """ + Initialize the synchronization manager. + + Sets up: + - Filters readwrite nodes + - Initializes metadata cache for direct memory access (including array elements) + + Returns: + True if initialization successful + """ + try: + # Filter readwrite nodes + self._readwrite_nodes = { + var_index: var_node + for var_index, var_node in self.variable_nodes.items() + if var_node.access_mode == "readwrite" + } + + log_info(f"Sync manager: {len(self._readwrite_nodes)} readwrite nodes, " + f"{len(self.variable_nodes) - len(self._readwrite_nodes)} readonly nodes") + + # Initialize metadata cache for direct memory access + if self.variable_nodes: + # Collect all indices including array elements + var_indices = [] + for var_index, var_node in self.variable_nodes.items(): + if var_node.array_length and var_node.array_length > 0: + # For arrays, add all element indices + for i in range(var_node.array_length): + var_indices.append(var_index + i) + else: + var_indices.append(var_index) + + self.variable_metadata = initialize_variable_cache( + self.buffer_accessor, + var_indices + ) + self._direct_memory_access_enabled = bool(self.variable_metadata) + + if self._direct_memory_access_enabled: + log_info(f"Direct memory access enabled for {len(self.variable_metadata)} indices") + else: + log_info("Using batch operations for sync") + + return True + + except Exception as e: + log_error(f"Failed to initialize sync manager: {e}") + return False + + async def _reinitialize_metadata(self) -> None: + """ + Reinitialize metadata cache when PLC program becomes available. + + Called when transitioning from no-PLC to PLC-loaded state. + """ + try: + if not self.variable_nodes: + return + + # Collect all indices including array elements + var_indices = [] + for var_index, var_node in self.variable_nodes.items(): + if var_node.array_length and var_node.array_length > 0: + for i in range(var_node.array_length): + var_indices.append(var_index + i) + else: + var_indices.append(var_index) + + self.variable_metadata = initialize_variable_cache( + self.buffer_accessor, + var_indices + ) + self._direct_memory_access_enabled = bool(self.variable_metadata) + + if self._direct_memory_access_enabled: + log_info(f"Direct memory access enabled for {len(self.variable_metadata)} indices") + else: + log_info("Using batch operations for sync") + + # Clear value cache to force full sync on next cycle + self.opcua_value_cache.clear() + + except Exception as e: + log_error(f"Failed to reinitialize metadata: {e}") + + async def run( + self, + is_running: Callable[[], bool], + cycle_time_seconds: float + ) -> None: + """ + Run the unified synchronization loop. + + Executes both sync directions sequentially in a single cycle: + 1. OPC-UA → Runtime (client writes to PLC) + 2. Runtime → OPC-UA (PLC values to clients) + + Args: + is_running: Callback that returns False when loop should stop + cycle_time_seconds: Time between sync cycles in seconds + """ + log_info(f"Starting sync loop (cycle time: {cycle_time_seconds*1000:.0f}ms)") + + while is_running(): + try: + # Check if PLC program is loaded by checking variable count + # When no program is loaded, get_var_count returns 0 + var_count, _ = self.buffer_accessor.get_var_count() + if var_count == 0: + # No PLC program loaded, skip syncing + if not self._logged_no_plc_warning: + log_info("No PLC program loaded, sync paused (waiting for program)") + self._logged_no_plc_warning = True + await asyncio.sleep(cycle_time_seconds) + continue + + # Reset warning flag when PLC is loaded + if self._logged_no_plc_warning: + log_info("PLC program detected, resuming sync") + self._logged_no_plc_warning = False + # Re-initialize metadata cache now that PLC is loaded + await self._reinitialize_metadata() + + # Capture cycle timestamp for subscription notifications + self._cycle_timestamp = datetime.now(timezone.utc) + + # Direction 1: OPC-UA → Runtime + await self.sync_opcua_to_runtime() + + # Direction 2: Runtime → OPC-UA + await self.sync_runtime_to_opcua() + + # Wait for next cycle + await asyncio.sleep(cycle_time_seconds) + + except asyncio.CancelledError: + log_info("Sync loop cancelled") + break + except Exception as e: + log_error(f"Error in sync loop: {e}") + await asyncio.sleep(0.1) # Brief pause on error + + log_info("Sync loop stopped") + + async def sync_opcua_to_runtime(self) -> None: + """ + Synchronize values from OPC-UA readwrite nodes to PLC runtime. + + Only syncs changed values to minimize PLC writes. + TIME values are written via direct memory access. + """ + try: + if not self._readwrite_nodes: + return + + # Collect values to write (only changed values) + # Separate TIME values (need direct memory access) from regular values + values_to_write = [] + indices_to_write = [] + time_writes = [] # List of (var_index, tv_sec, tv_nsec) tuples + + for var_index, var_node in self._readwrite_nodes.items(): + try: + # Read current value from OPC-UA node + opcua_value = await var_node.node.read_value() + + # Extract actual value + actual_value = self._extract_opcua_value(opcua_value) + if actual_value is None: + continue + + is_time_type = var_node.datatype.upper() in TIME_DATATYPES + + # Check if this is an array node + if var_node.array_length and var_node.array_length > 0: + # Handle array: value should be a list + if isinstance(actual_value, (list, tuple)): + for i, elem_value in enumerate(actual_value): + elem_index = var_index + i + plc_value = convert_value_for_plc(var_node.datatype, elem_value) + + # Check if element has changed + if self._has_value_changed(elem_index, plc_value): + if is_time_type and isinstance(plc_value, tuple): + tv_sec, tv_nsec = plc_value + time_writes.append((elem_index, tv_sec, tv_nsec)) + else: + values_to_write.append(plc_value) + indices_to_write.append(elem_index) + self.opcua_value_cache[elem_index] = plc_value + continue + + # Handle scalar value + plc_value = convert_value_for_plc(var_node.datatype, actual_value) + + # Check if value has changed + if self._has_value_changed(var_index, plc_value): + if is_time_type and isinstance(plc_value, tuple): + # TIME values need direct memory access + tv_sec, tv_nsec = plc_value + time_writes.append((var_index, tv_sec, tv_nsec)) + else: + values_to_write.append(plc_value) + indices_to_write.append(var_index) + + # Update cache + self.opcua_value_cache[var_index] = plc_value + + except Exception as e: + log_error(f"Error reading OPC-UA variable {var_index}: {e}") + continue + + # Batch write to PLC if we have changed values + if values_to_write: + await self._write_to_plc_batch(indices_to_write, values_to_write) + + # Write TIME values via direct memory access + if time_writes and self._direct_memory_access_enabled: + for var_index, tv_sec, tv_nsec in time_writes: + try: + metadata = self.variable_metadata.get(var_index) + if metadata: + write_timespec_direct(metadata.address, tv_sec, tv_nsec) + else: + log_warn(f"No metadata for TIME variable {var_index}, skipping write") + except Exception as e: + log_error(f"Failed to write TIME variable {var_index}: {e}") + + except Exception as e: + log_error(f"Error in OPC-UA to runtime sync: {e}") + + async def sync_runtime_to_opcua(self) -> None: + """ + Synchronize values from PLC runtime to OPC-UA nodes. + + Uses direct memory access when available, falls back to batch operations. + """ + try: + if not self.variable_nodes: + return + + if self._direct_memory_access_enabled and self.variable_metadata: + await self._update_via_direct_memory_access() + else: + await self._update_via_batch_operations() + + except Exception as e: + log_error(f"Error in runtime to OPC-UA sync: {e}") + + async def _update_via_direct_memory_access(self) -> None: + """ + Update OPC-UA nodes using direct memory access. + + This is the optimized path - zero C calls per variable. + """ + for var_index, metadata in self.variable_metadata.items(): + try: + var_node = self.variable_nodes.get(var_index) + if not var_node: + continue + + # Direct memory read - pass datatype for TIME handling + value = read_memory_direct( + metadata.address, + metadata.size, + datatype=var_node.datatype + ) + + await self._update_opcua_node(var_node, value) + + except Exception as e: + log_error(f"Direct memory access failed for var {var_index}: {e}") + + async def _update_via_batch_operations(self) -> None: + """ + Update OPC-UA nodes using batch operations. + + Fallback when direct memory access is not available. + """ + var_indices = list(self.variable_nodes.keys()) + + # Single batch call for all values + results, msg = self.buffer_accessor.get_var_values_batch(var_indices) + + # Check for actual errors (exceptions during batch operation) + # "Batch read completed" is the success message, not "Success" + if "Exception" in msg or "Error" in msg: + log_error(f"Batch read failed: {msg}") + return + + # If no results returned, nothing to do (may happen when no PLC is loaded) + if not results: + return + + # Process results - individual items may have failed (e.g., no PLC loaded) + for i, (value, var_msg) in enumerate(results): + var_index = var_indices[i] + var_node = self.variable_nodes.get(var_index) + + if var_msg == "Success" and value is not None and var_node: + await self._update_opcua_node(var_node, value) + + async def _update_opcua_node(self, var_node: VariableNode, value: Any) -> None: + """ + Update an OPC-UA node with a new value. + + Uses write_attribute_value() with DataValue for optimal subscription support. + This approach: + - Triggers data change notifications for subscribed clients + - Includes SourceTimestamp (PLC cycle time) and ServerTimestamp + - Bypasses PreWrite callbacks (server-internal operation) + - Is faster than write_value() for server-side updates + + Args: + var_node: The VariableNode to update + value: Raw value from PLC memory (single value, not used for arrays) + """ + try: + # Check if this is an array node + if var_node.array_length and var_node.array_length > 0: + await self._update_array_node(var_node) + return + + # Convert to OPC-UA format + opcua_value = convert_value_for_opcua(var_node.datatype, value) + + # Get expected OPC-UA type + expected_type = map_plc_to_opcua_type(var_node.datatype) + + # Create Variant with explicit type + variant = ua.Variant(opcua_value, expected_type) + + # Create DataValue with timestamps for subscription notifications + # SourceTimestamp: When the value was read from PLC (cycle time) + # ServerTimestamp: When the server processed it (now) + data_value = ua.DataValue( + Value=variant, + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=self._cycle_timestamp, + ServerTimestamp=datetime.now(timezone.utc) + ) + + # Use write_attribute_value for optimal subscription triggering + # This is faster than write_value and properly triggers notifications + if self.server: + await self.server.write_attribute_value( + var_node.node.nodeid, + data_value + ) + else: + # Fallback to write_value if no server reference + await var_node.node.write_value(variant) + + except Exception as e: + log_error(f"Failed to update OPC-UA node {var_node.debug_var_index}: {e}") + + async def _update_array_node(self, var_node: VariableNode) -> None: + """ + Update an OPC-UA array node by reading all elements from PLC memory. + + Arrays in PLC have consecutive indices starting from debug_var_index. + Uses DataValue with timestamps for subscription support. + + Args: + var_node: The VariableNode representing the array + """ + try: + base_index = var_node.debug_var_index + array_length = var_node.array_length + + # Read all array elements from PLC + element_indices = list(range(base_index, base_index + array_length)) + + # Try direct memory access first for array elements + array_values = [] + if self._direct_memory_access_enabled: + for idx in element_indices: + metadata = self.variable_metadata.get(idx) + if metadata: + # Pass datatype for TIME handling + raw_value = read_memory_direct( + metadata.address, + metadata.size, + datatype=var_node.datatype + ) + opcua_value = convert_value_for_opcua(var_node.datatype, raw_value) + array_values.append(opcua_value) + else: + # Fallback: read via buffer accessor + val, msg = self.buffer_accessor.get_var_value(idx) + if msg == "Success" and val is not None: + opcua_value = convert_value_for_opcua(var_node.datatype, val) + array_values.append(opcua_value) + else: + # Use default value + array_values.append(self._get_default_value(var_node.datatype)) + else: + # Use batch operation + results, batch_msg = self.buffer_accessor.get_var_values_batch(element_indices) + for val, msg in results: + if msg == "Success" and val is not None: + opcua_value = convert_value_for_opcua(var_node.datatype, val) + array_values.append(opcua_value) + else: + array_values.append(self._get_default_value(var_node.datatype)) + + # Get expected OPC-UA type + expected_type = map_plc_to_opcua_type(var_node.datatype) + + # Create array Variant + variant = ua.Variant(array_values, expected_type) + + # Create DataValue with timestamps for subscription notifications + data_value = ua.DataValue( + Value=variant, + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=self._cycle_timestamp, + ServerTimestamp=datetime.now(timezone.utc) + ) + + # Use write_attribute_value for subscription support + if self.server: + await self.server.write_attribute_value( + var_node.node.nodeid, + data_value + ) + else: + # Fallback to write_value if no server reference + await var_node.node.write_value(variant) + + except Exception as e: + log_error(f"Failed to update array node {var_node.debug_var_index}: {e}") + + def _get_default_value(self, datatype: str) -> Any: + """Get default value for a datatype.""" + dtype = datatype.upper() + if dtype == "BOOL": + return False + elif dtype in ["FLOAT", "REAL"]: + return 0.0 + elif dtype == "STRING": + return "" + elif dtype in TIME_DATATYPES: + return 0 # TIME is represented as milliseconds (Int64) in OPC-UA + else: + return 0 + + async def _write_to_plc_batch( + self, + indices: list, + values: list + ) -> None: + """ + Write values to PLC using batch operation. + + Args: + indices: List of variable indices + values: List of values to write + """ + try: + # Combine into tuples as expected by the API + index_value_pairs = list(zip(indices, values)) + results, msg = self.buffer_accessor.set_var_values_batch(index_value_pairs) + + if msg not in ["Success", "Batch write completed"]: + log_error(f"Batch write to PLC failed: {msg}") + return + + # Check individual results + failed_count = sum(1 for success, _ in results if not success) + if failed_count > 0: + log_error(f"Batch write: {failed_count}/{len(results)} failures") + else: + log_debug(f"Successfully wrote {len(results)} values to PLC") + + except Exception as e: + log_error(f"Error in batch write: {e}") + + def _has_value_changed(self, var_index: int, new_value: Any) -> bool: + """ + Check if a value has changed compared to cached value. + + Args: + var_index: Variable index + new_value: New value to compare + + Returns: + True if value has changed + """ + if var_index not in self.opcua_value_cache: + return True + + cached_value = self.opcua_value_cache[var_index] + + # Float comparison with tolerance + if isinstance(new_value, float) and isinstance(cached_value, float): + return abs(new_value - cached_value) > 1e-6 + + # Tuple comparison for TIME types (tv_sec, tv_nsec) + if isinstance(new_value, tuple) and isinstance(cached_value, tuple): + return new_value != cached_value + + # Exact comparison for other types + return new_value != cached_value + + def _extract_opcua_value(self, opcua_value: Any) -> Any: + """ + Extract actual value from OPC-UA response. + + Args: + opcua_value: Value from OPC-UA node read + + Returns: + Extracted value or None on error + """ + try: + # If it's a DataValue with Value attribute, extract it + if hasattr(opcua_value, "Value"): + return opcua_value.Value + + # Already a plain value + return opcua_value + + except Exception as e: + log_error(f"Failed to extract OPC-UA value: {e}") + return None diff --git a/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_nested_structures.json b/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_nested_structures.json new file mode 100644 index 00000000..e293a745 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_nested_structures.json @@ -0,0 +1,174 @@ +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "endpoint": "opc.tcp://0.0.0.0:4840/freeopcua/server/", + "server_name": "OpenPLC OPC-UA Server", + "security_policy": "None", + "security_mode": "None", + "certificate": "", + "private_key": "", + "cycle_time_ms": 100, + "namespace": "OpenPLC", + "variables": [ + { + "node_name": "temperature", + "datatype": "Float", + "index": 0, + "access": "readwrite" + }, + { + "node_name": "status", + "datatype": "Bool", + "index": 1, + "access": "readonly" + }, + { + "node_name": "person", + "type": "STRUCT", + "members": [ + { + "name": "name", + "datatype": "String", + "index": 2, + "access": "readwrite" + }, + { + "name": "age", + "datatype": "Int32", + "index": 3, + "access": "readwrite" + } + ] + }, + { + "node_name": "sensor_values", + "type": "ARRAY", + "members": [ + { + "name": "[0]", + "datatype": "Float", + "index": 4, + "access": "readwrite" + }, + { + "name": "[1]", + "datatype": "Float", + "index": 5, + "access": "readwrite" + }, + { + "name": "[2]", + "datatype": "Float", + "index": 6, + "access": "readwrite" + } + ] + }, + { + "node_name": "complex_data", + "type": "STRUCT", + "members": [ + { + "name": "simple_field", + "datatype": "Float", + "index": 7, + "access": "readwrite" + }, + { + "name": "nested_struct", + "type": "STRUCT", + "members": [ + { + "name": "field1", + "datatype": "Int", + "index": 8, + "access": "readwrite" + }, + { + "name": "field2", + "datatype": "Bool", + "index": 9, + "access": "readonly" + }, + { + "name": "deep_nested", + "type": "STRUCT", + "members": [ + { + "name": "value", + "datatype": "Float", + "index": 10, + "access": "readwrite" + } + ] + } + ] + }, + { + "name": "array_in_struct", + "type": "ARRAY", + "members": [ + { + "name": "[0]", + "datatype": "Int32", + "index": 11, + "access": "readwrite" + }, + { + "name": "[1]", + "datatype": "Int32", + "index": 12, + "access": "readwrite" + } + ] + } + ] + }, + { + "node_name": "struct_array", + "type": "ARRAY", + "members": [ + { + "name": "[0]", + "type": "STRUCT", + "members": [ + { + "name": "x", + "datatype": "Float", + "index": 13, + "access": "readwrite" + }, + { + "name": "y", + "datatype": "Float", + "index": 14, + "access": "readwrite" + } + ] + }, + { + "name": "[1]", + "type": "STRUCT", + "members": [ + { + "name": "x", + "datatype": "Float", + "index": 15, + "access": "readwrite" + }, + { + "name": "y", + "datatype": "Float", + "index": 16, + "access": "readwrite" + } + ] + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_simple_var.json b/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_simple_var.json new file mode 100644 index 00000000..b561b090 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/test_configs/opcua_config_def_simple_var.json @@ -0,0 +1,24 @@ +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "endpoint": "opc.tcp://0.0.0.0:4840/freeopcua/server/", + "server_name": "OpenPLC OPC-UA Server", + "security_policy": "None", + "security_mode": "None", + "certificate": "", + "private_key": "", + "cycle_time_ms": 100, + "namespace": "OpenPLC", + "variables": [ + { + "node_name": "Led", + "datatype": "Bool", + "index": 21, + "access": "readonly" + } + ] + } + } +] \ No newline at end of file diff --git a/core/src/drivers/plugins/python/opcua/tests/test_server_standalone.py b/core/src/drivers/plugins/python/opcua/tests/test_server_standalone.py new file mode 100644 index 00000000..a893be75 --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/tests/test_server_standalone.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +Standalone OPC-UA Test Server. + +This script runs a minimal OPC-UA server to test subscription functionality +without requiring the full PLC runtime. + +Usage: + python test_server_standalone.py + +The server will: +1. Create test variables that change periodically +2. Accept client connections +3. Support subscriptions with data change notifications +""" + +import asyncio +import sys +import os +from datetime import datetime, timezone +from typing import Dict, Any + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from asyncua import Server, ua + + +class TestOpcuaServer: + """ + Standalone test server for subscription verification. + """ + + def __init__(self, endpoint: str = "opc.tcp://0.0.0.0:4840/openplc/opcua"): + self.endpoint = endpoint + self.server: Server = None + self.namespace_idx: int = None + self.running = False + + # Test variables + self.test_nodes: Dict[str, Any] = {} + self.counter = 0 + + async def setup(self) -> bool: + """Initialize the server.""" + try: + self.server = Server() + self.server.set_endpoint(self.endpoint) + self.server.set_server_name("OpenPLC Test Server") + + # Disable security for testing + self.server.set_security_policy([ua.SecurityPolicyType.NoSecurity]) + + await self.server.init() + + # Register namespace + self.namespace_idx = await self.server.register_namespace( + "urn:openplc:opcua:datatype:test" + ) + + print(f"Namespace registered at index {self.namespace_idx}") + + # Create test variables + await self._create_test_variables() + + return True + + except Exception as e: + print(f"Setup failed: {e}") + import traceback + traceback.print_exc() + return False + + async def _create_test_variables(self): + """Create test variables for subscription testing.""" + objects = self.server.get_objects_node() + + # Create a folder for test variables + test_folder = await objects.add_folder( + self.namespace_idx, + "TestVariables" + ) + + # Create various test variables + test_vars = [ + ("counter", 0, ua.VariantType.Int32), + ("temperature", 25.0, ua.VariantType.Float), + ("pressure", 101.325, ua.VariantType.Float), + ("motor_running", False, ua.VariantType.Boolean), + ("status_message", "Initializing", ua.VariantType.String), + ] + + for name, initial_value, var_type in test_vars: + node = await test_folder.add_variable( + self.namespace_idx, + name, + initial_value, + var_type + ) + await node.set_writable() + self.test_nodes[name] = node + print(f" Created variable: {name} = {initial_value}") + + # Create an array variable + array_node = await test_folder.add_variable( + self.namespace_idx, + "sensor_array", + [0.0, 0.0, 0.0, 0.0], + ua.VariantType.Float + ) + await array_node.set_writable() + self.test_nodes["sensor_array"] = array_node + print(f" Created array: sensor_array = [0.0, 0.0, 0.0, 0.0]") + + async def start(self): + """Start the server.""" + await self.server.start() + self.running = True + print(f"\nServer started at: {self.endpoint}") + print("=" * 60) + + async def update_values(self): + """ + Periodically update test values to trigger subscriptions. + + This simulates PLC value changes. + """ + import math + import random + + while self.running: + try: + self.counter += 1 + now = datetime.now(timezone.utc) + + # Update counter + counter_node = self.test_nodes["counter"] + data_value = ua.DataValue( + Value=ua.Variant(self.counter, ua.VariantType.Int32), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + counter_node.nodeid, + data_value + ) + + # Update temperature (sinusoidal + noise) + temp = 25.0 + 5.0 * math.sin(self.counter / 10.0) + random.uniform(-0.5, 0.5) + temp_node = self.test_nodes["temperature"] + data_value = ua.DataValue( + Value=ua.Variant(round(temp, 2), ua.VariantType.Float), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + temp_node.nodeid, + data_value + ) + + # Update pressure (slowly varying) + pressure = 101.325 + 0.5 * math.sin(self.counter / 50.0) + pressure_node = self.test_nodes["pressure"] + data_value = ua.DataValue( + Value=ua.Variant(round(pressure, 3), ua.VariantType.Float), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + pressure_node.nodeid, + data_value + ) + + # Toggle motor every 10 cycles + motor_running = (self.counter // 10) % 2 == 1 + motor_node = self.test_nodes["motor_running"] + data_value = ua.DataValue( + Value=ua.Variant(motor_running, ua.VariantType.Boolean), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + motor_node.nodeid, + data_value + ) + + # Update status message periodically + if self.counter % 5 == 0: + status = f"Running - Cycle {self.counter}" + status_node = self.test_nodes["status_message"] + data_value = ua.DataValue( + Value=ua.Variant(status, ua.VariantType.String), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + status_node.nodeid, + data_value + ) + + # Update sensor array + array_values = [ + round(random.uniform(0, 100), 2), + round(random.uniform(0, 100), 2), + round(random.uniform(0, 100), 2), + round(random.uniform(0, 100), 2), + ] + array_node = self.test_nodes["sensor_array"] + data_value = ua.DataValue( + Value=ua.Variant(array_values, ua.VariantType.Float), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=now, + ServerTimestamp=now + ) + await self.server.write_attribute_value( + array_node.nodeid, + data_value + ) + + # Print status every 10 cycles + if self.counter % 10 == 0: + print(f"[{now.strftime('%H:%M:%S')}] Cycle {self.counter}: " + f"temp={temp:.1f}, motor={'ON' if motor_running else 'OFF'}") + + await asyncio.sleep(0.1) # 100ms cycle time + + except asyncio.CancelledError: + break + except Exception as e: + print(f"Error updating values: {e}") + await asyncio.sleep(1) + + async def stop(self): + """Stop the server.""" + self.running = False + if self.server: + await self.server.stop() + print("\nServer stopped") + + async def run(self): + """Main run loop.""" + if not await self.setup(): + return + + await self.start() + + print("\nServer is running. Test variables are being updated every 100ms.") + print("Connect with a client to test subscriptions.") + print("Press Ctrl+C to stop.\n") + + # Run the update loop + try: + await self.update_values() + except asyncio.CancelledError: + pass + finally: + await self.stop() + + +async def main(): + """Main entry point.""" + print("=" * 60) + print("OPC-UA Subscription Test Server") + print("=" * 60) + + server = TestOpcuaServer() + + try: + await server.run() + except KeyboardInterrupt: + print("\nShutdown requested...") + await server.stop() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nExiting...") diff --git a/core/src/drivers/plugins/python/opcua/tests/test_subscription_client.py b/core/src/drivers/plugins/python/opcua/tests/test_subscription_client.py new file mode 100644 index 00000000..e289c90a --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/tests/test_subscription_client.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +OPC-UA Subscription Test Client. + +This script tests subscription functionality by connecting to the OpenPLC +OPC-UA server and subscribing to data changes. + +Usage: + python test_subscription_client.py [endpoint_url] + +Default endpoint: opc.tcp://localhost:4840/openplc/opcua +""" + +import asyncio +import sys +from datetime import datetime + +from asyncua import Client, ua + + +class SubscriptionHandler: + """ + Handler for subscription notifications. + + This class receives callbacks when subscribed values change. + """ + + def __init__(self): + self.notification_count = 0 + self.last_notification_time = None + + def datachange_notification(self, node, val, data): + """ + Called when a subscribed data value changes. + + Args: + node: The Node that changed + val: The new value + data: DataChangeNotification with full details + """ + self.notification_count += 1 + self.last_notification_time = datetime.now() + + # Extract source timestamp if available + source_ts = None + if hasattr(data, 'monitored_item') and data.monitored_item: + if hasattr(data.monitored_item, 'Value') and data.monitored_item.Value: + source_ts = data.monitored_item.Value.SourceTimestamp + + print(f"[{self.last_notification_time.strftime('%H:%M:%S.%f')[:-3]}] " + f"Data Change #{self.notification_count}") + print(f" Node: {node}") + print(f" Value: {val}") + if source_ts: + print(f" Source Timestamp: {source_ts}") + print() + + def event_notification(self, event): + """Called when an event is received.""" + print(f"Event received: {event}") + + +async def test_subscriptions(endpoint_url: str): + """ + Test OPC-UA subscriptions. + + Args: + endpoint_url: The server endpoint URL + """ + print(f"Connecting to: {endpoint_url}") + print("-" * 60) + + client = Client(url=endpoint_url) + + try: + await client.connect() + print("Connected successfully!") + print() + + # Get namespace index for our server + namespace_uri = "urn:openplc:opcua:datatype:test" + try: + ns_idx = await client.get_namespace_index(namespace_uri) + print(f"Found namespace '{namespace_uri}' at index {ns_idx}") + except Exception as e: + print(f"Could not find namespace, using index 2: {e}") + ns_idx = 2 + + # Browse for available nodes + print() + print("Browsing Objects folder...") + objects_node = client.get_objects_node() + + test_nodes = [] + + async def find_variables(node, depth=0, max_depth=3): + """Recursively find variables in the address space.""" + if depth > max_depth: + return + + try: + children = await node.get_children() + for child in children: + try: + node_class = await child.read_node_class() + name = await child.read_browse_name() + + if node_class == ua.NodeClass.Variable: + try: + value = await child.read_value() + print(f"{' ' * depth}[VAR] {name.Name} = {value}") + test_nodes.append(child) + except Exception as e: + print(f"{' ' * depth}[VAR] {name.Name} (unreadable: {e})") + + elif node_class == ua.NodeClass.Object: + print(f"{' ' * depth}[DIR] {name.Name}/") + await find_variables(child, depth + 1, max_depth) + + except Exception: + pass + except Exception: + pass + + await find_variables(objects_node) + + if not test_nodes: + print() + print("ERROR: No variables found to subscribe to!") + print("Make sure the OPC-UA server is running and has variables configured.") + return + + # Create subscription + print() + print("=" * 60) + print("Creating subscription...") + + handler = SubscriptionHandler() + + # Create subscription with 100ms publishing interval + subscription = await client.create_subscription( + period=100, # Publishing interval in ms + handler=handler + ) + + print(f"Subscription created with publishing interval: 100ms") + + # Subscribe to found nodes + handles = [] + for node in test_nodes: + try: + handle = await subscription.subscribe_data_change(node) + handles.append(handle) + name = await node.read_browse_name() + print(f" Subscribed to: {name.Name}") + except Exception as e: + print(f" Failed to subscribe to node: {e}") + + if not handles: + print("ERROR: Could not subscribe to any variables!") + return + + print() + print("=" * 60) + print("Waiting for data changes...") + print("(Change PLC values or wait for sync loop updates)") + print("Press Ctrl+C to stop") + print("=" * 60) + print() + + # Wait and monitor for changes + start_time = datetime.now() + last_count = 0 + + try: + while True: + await asyncio.sleep(1) + + # Print status every 5 seconds if no notifications + elapsed = (datetime.now() - start_time).total_seconds() + if handler.notification_count == last_count and int(elapsed) % 5 == 0: + print(f"[{datetime.now().strftime('%H:%M:%S')}] " + f"Waiting... ({handler.notification_count} notifications so far)") + + last_count = handler.notification_count + + except asyncio.CancelledError: + pass + + except KeyboardInterrupt: + print() + print("=" * 60) + print("Test stopped by user") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + finally: + # Cleanup + print() + print("Disconnecting...") + try: + await client.disconnect() + print("Disconnected") + except Exception: + pass + + # Print summary + if 'handler' in locals(): + print() + print("=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"Total notifications received: {handler.notification_count}") + if handler.last_notification_time: + print(f"Last notification at: {handler.last_notification_time}") + + +async def main(): + """Main entry point.""" + # Default endpoint + endpoint_url = "opc.tcp://localhost:4840/openplc/opcua" + + # Allow override via command line + if len(sys.argv) > 1: + endpoint_url = sys.argv[1] + + await test_subscriptions(endpoint_url) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nExiting...") diff --git a/core/src/drivers/plugins/python/opcua/user_manager.py b/core/src/drivers/plugins/python/opcua/user_manager.py new file mode 100644 index 00000000..bd005fde --- /dev/null +++ b/core/src/drivers/plugins/python/opcua/user_manager.py @@ -0,0 +1,717 @@ +""" +OPC UA User Manager. + +This module provides authentication and user management for the OPC-UA server. +It supports password authentication, certificate authentication, and anonymous access. +Includes brute-force protection with rate limiting. +""" + +import base64 +import hashlib +import os +import sys +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from asyncua.crypto.permission_rules import User +from asyncua.server.user_managers import UserManager, UserRole + +# Import bcrypt with fallback to PBKDF2 (Python stdlib) +try: + import bcrypt + + _bcrypt_available = True +except ImportError: + _bcrypt_available = False + +# PBKDF2 configuration (used when bcrypt is unavailable, e.g., on MSYS2/Cygwin) +PBKDF2_ITERATIONS = 600000 # OWASP recommendation for SHA256 +PBKDF2_HASH_NAME = "sha256" +PBKDF2_SALT_LENGTH = 16 + + +def _pbkdf2_hash_password(password: str) -> str: + """ + Hash a password using PBKDF2-HMAC-SHA256. + + Format: pbkdf2:sha256:iterations$salt$hash + where salt and hash are base64-encoded. + + Args: + password: Plain text password + + Returns: + PBKDF2 hash string + """ + salt = os.urandom(PBKDF2_SALT_LENGTH) + hash_bytes = hashlib.pbkdf2_hmac( + PBKDF2_HASH_NAME, password.encode("utf-8"), salt, PBKDF2_ITERATIONS + ) + salt_b64 = base64.b64encode(salt).decode("ascii") + hash_b64 = base64.b64encode(hash_bytes).decode("ascii") + return f"pbkdf2:{PBKDF2_HASH_NAME}:{PBKDF2_ITERATIONS}${salt_b64}${hash_b64}" + + +def _pbkdf2_verify_password(password: str, password_hash: str) -> bool: + """ + Verify a password against a PBKDF2 hash. + + Args: + password: Plain text password + password_hash: PBKDF2 hash string (format: pbkdf2:sha256:iterations$salt$hash) + + Returns: + True if password matches + """ + try: + # Parse the hash format: pbkdf2:sha256:iterations$salt$hash + if not password_hash.startswith("pbkdf2:"): + return False + + # Split into method part and data part + method_part, data_part = password_hash.split("$", 1) + # method_part = "pbkdf2:sha256:iterations" + # data_part = "salt$hash" + + parts = method_part.split(":") + if len(parts) != 3: + return False + + _, hash_name, iterations_str = parts + iterations = int(iterations_str) + + salt_b64, hash_b64 = data_part.split("$") + salt = base64.b64decode(salt_b64) + expected_hash = base64.b64decode(hash_b64) + + # Compute hash with same parameters + computed_hash = hashlib.pbkdf2_hmac( + hash_name, password.encode("utf-8"), salt, iterations + ) + + # Use constant-time comparison to prevent timing attacks + import hmac + + return hmac.compare_digest(computed_hash, expected_hash) + except Exception: + return False + + +def hash_password(password: str) -> str: + """ + Hash a password using the best available method. + + Uses bcrypt if available (Linux, macOS), falls back to PBKDF2 (MSYS2/Cygwin). + + Args: + password: Plain text password + + Returns: + Hashed password string + """ + if _bcrypt_available: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + else: + return _pbkdf2_hash_password(password) + +# Add directories to path for module access +_current_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_current_dir) +if _current_dir not in sys.path: + sys.path.insert(0, _current_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +# Import logging (handle both package and direct loading) +try: + from .opcua_logging import log_error, log_info, log_warn +except ImportError: + from opcua_logging import log_info, log_warn, log_error + +from shared.plugin_config_decode.opcua_config_model import OpcuaConfig # noqa: E402 + +# Rate limiting constants +DEFAULT_MAX_ATTEMPTS = 5 +DEFAULT_LOCKOUT_DURATION_SECONDS = 300 # 5 minutes +DEFAULT_ATTEMPT_WINDOW_SECONDS = 60 # 1 minute window for counting attempts + + +@dataclass +class AuthAttemptTracker: + """Tracks authentication attempts for rate limiting.""" + + attempts: int = 0 + first_attempt_time: float = 0.0 + lockout_until: float = 0.0 + + +@dataclass +class RateLimitConfig: + """Configuration for rate limiting.""" + + max_attempts: int = DEFAULT_MAX_ATTEMPTS + lockout_duration_seconds: float = DEFAULT_LOCKOUT_DURATION_SECONDS + attempt_window_seconds: float = DEFAULT_ATTEMPT_WINDOW_SECONDS + + +class RateLimiter: + """ + Rate limiter for authentication attempts. + + Tracks failed authentication attempts per identifier (username or IP) + and enforces lockout after too many failures. + """ + + def __init__(self, config: Optional[RateLimitConfig] = None): + """ + Initialize the rate limiter. + + Args: + config: Rate limit configuration. Uses defaults if not provided. + """ + self.config = config or RateLimitConfig() + self._trackers: Dict[str, AuthAttemptTracker] = {} + + def is_locked_out(self, identifier: str) -> bool: + """ + Check if an identifier is currently locked out. + + Args: + identifier: The identifier to check (username, IP, etc.) + + Returns: + True if locked out, False otherwise + """ + tracker = self._trackers.get(identifier) + if not tracker: + return False + + current_time = time.time() + + # Check if lockout has expired + if tracker.lockout_until > 0 and current_time < tracker.lockout_until: + return True + + # Reset if lockout expired + if tracker.lockout_until > 0 and current_time >= tracker.lockout_until: + self._reset_tracker(identifier) + return False + + return False + + def get_lockout_remaining(self, identifier: str) -> float: + """ + Get remaining lockout time in seconds. + + Args: + identifier: The identifier to check + + Returns: + Remaining lockout time in seconds, or 0 if not locked out + """ + tracker = self._trackers.get(identifier) + if not tracker or tracker.lockout_until <= 0: + return 0.0 + + remaining = tracker.lockout_until - time.time() + return max(0.0, remaining) + + def record_attempt(self, identifier: str, success: bool) -> None: + """ + Record an authentication attempt. + + Args: + identifier: The identifier (username, IP, etc.) + success: Whether the attempt was successful + """ + current_time = time.time() + + if success: + # Reset on successful authentication + self._reset_tracker(identifier) + return + + # Get or create tracker + if identifier not in self._trackers: + self._trackers[identifier] = AuthAttemptTracker( + attempts=0, first_attempt_time=current_time, lockout_until=0.0 + ) + + tracker = self._trackers[identifier] + + # Reset attempt count if window has expired + if current_time - tracker.first_attempt_time > self.config.attempt_window_seconds: + tracker.attempts = 0 + tracker.first_attempt_time = current_time + + # Increment attempt count + tracker.attempts += 1 + + # Check if lockout threshold reached + if tracker.attempts >= self.config.max_attempts: + tracker.lockout_until = current_time + self.config.lockout_duration_seconds + + def _reset_tracker(self, identifier: str) -> None: + """Reset the tracker for an identifier.""" + if identifier in self._trackers: + del self._trackers[identifier] + + def cleanup_expired(self) -> int: + """ + Clean up expired trackers to prevent memory growth. + + Returns: + Number of trackers removed + """ + current_time = time.time() + expired = [] + + for identifier, tracker in self._trackers.items(): + # Remove if lockout expired and no recent attempts + if tracker.lockout_until > 0 and current_time >= tracker.lockout_until: + expired.append(identifier) + # Remove if attempt window expired and not locked out + elif ( + tracker.lockout_until <= 0 + and current_time - tracker.first_attempt_time > self.config.attempt_window_seconds + ): + expired.append(identifier) + + for identifier in expired: + del self._trackers[identifier] + + return len(expired) + + +class OpenPLCUserManager(UserManager): + """ + Custom user manager for OpenPLC authentication. + + Supports: + - Password authentication (bcrypt or PBKDF2 hashed) + - Certificate authentication (fingerprint matching) + - Anonymous access + - Brute-force protection with rate limiting + + Password hashing: + - bcrypt: Used on Linux/macOS where bcrypt is available + - PBKDF2-HMAC-SHA256: Used on MSYS2/Cygwin where bcrypt cannot be built + + Maps OpenPLC roles to asyncua UserRole enum: + - viewer -> UserRole.User (read-only) + - operator -> UserRole.User (read/write via callbacks) + - engineer -> UserRole.Admin (full access) + + Returns asyncua User objects for proper integration with the asyncua library. + """ + + # Map OpenPLC roles to asyncua UserRole enum + ROLE_MAPPING = { + "viewer": UserRole.User, # Read-only access + "operator": UserRole.User, # Read/write access (controlled by callbacks) + "engineer": UserRole.Admin, # Full access + } + + def __init__(self, config: OpcuaConfig, rate_limit_config: Optional[RateLimitConfig] = None): + """ + Initialize the user manager. + + Args: + config: OpcuaConfig instance with users and security profiles + rate_limit_config: Optional rate limiting configuration. + Uses defaults if not provided. + """ + super().__init__() + self.config = config + + # Initialize rate limiter for brute-force protection + self.rate_limiter = RateLimiter(rate_limit_config) + + # Build user dictionaries + self.users = {user.username: user for user in config.users if user.type == "password"} + self.cert_users = { + user.certificate_id: user for user in config.users if user.type == "certificate" + } + + # Store OpenPLC roles separately to avoid modifying config objects + self._user_roles: Dict[str, str] = {} + for user in config.users: + if user.type == "password" and user.username: + self._user_roles[user.username] = str(user.role) + elif user.type == "certificate" and user.certificate_id: + self._user_roles[f"cert:{user.certificate_id}"] = str(user.role) + + log_info( + f"UserManager initialized: {len(self.users)} password users, " + f"{len(self.cert_users)} certificate users, rate limiting enabled" + ) + + def get_user( + self, + iserver, + username: Optional[str] = None, + password: Optional[str] = None, + certificate: Optional[Any] = None, + ) -> Optional[User]: + """ + Authenticate user with security profile enforcement and rate limiting. + + Note: asyncua passes InternalServer as the first argument, not a session. + The security policy URI is not available at this level, so we select + the security profile based on the authentication method being used. + + Rate limiting is applied to prevent brute-force attacks. After too many + failed attempts, the user/identifier is locked out for a configurable period. + + Args: + iserver: The internal server object (passed by asyncua) + username: Username for password authentication + password: Password for password authentication + certificate: Certificate for certificate authentication + + Returns: + asyncua User object with role attribute, or None if authentication fails + """ + # Detect authentication method from provided credentials + auth_method = self._detect_auth_method(username, password, certificate) + + # Determine rate limit identifier based on auth method + rate_limit_id = self._get_rate_limit_identifier(username, certificate) + + # Check rate limiting (skip for anonymous) + if auth_method != "Anonymous" and rate_limit_id: + if self.rate_limiter.is_locked_out(rate_limit_id): + remaining = self.rate_limiter.get_lockout_remaining(rate_limit_id) + log_warn( + f"Authentication blocked for '{rate_limit_id}': " + f"locked out for {remaining:.0f} more seconds" + ) + return None + + log_info(f"Authentication attempt: method={auth_method}") + + # Find a security profile that supports this authentication method + profile = self._find_profile_by_auth_method(auth_method) + + if not profile: + log_error( + f"No security profile found that supports authentication method '{auth_method}'" + ) + # Record failed attempt for rate limiting + if rate_limit_id: + self.rate_limiter.record_attempt(rate_limit_id, success=False) + return None + + log_info(f"Using security profile '{profile.name}' for {auth_method} authentication") + + # Authenticate based on method + user = None + openplc_role = None + + if auth_method == "Username" and username and password: + user, openplc_role = self._authenticate_password(username, password) + + elif auth_method == "Certificate" and certificate: + user, openplc_role = self._authenticate_certificate(certificate) + + elif auth_method == "Anonymous": + user, openplc_role = self._authenticate_anonymous(profile) + + # Record attempt result for rate limiting (skip anonymous) + if auth_method != "Anonymous" and rate_limit_id: + self.rate_limiter.record_attempt(rate_limit_id, success=(user is not None)) + + if user: + # Store OpenPLC role as attribute for permission callbacks + user.openplc_role = openplc_role + log_info( + f"User '{user.name or 'anonymous'}' authenticated successfully " + f"using '{auth_method}' method for profile '{profile.name}' " + f"(role: {openplc_role})" + ) + return user + else: + log_warn( + f"Authentication failed for method '{auth_method}' on profile '{profile.name}'" + ) + return None + + def _get_rate_limit_identifier( + self, username: Optional[str], certificate: Optional[Any] + ) -> Optional[str]: + """ + Get identifier for rate limiting. + + Args: + username: Username if provided + certificate: Certificate if provided + + Returns: + Identifier string for rate limiting, or None + """ + if username: + return f"user:{username}" + elif certificate: + fingerprint = self._cert_to_fingerprint(certificate) + if fingerprint: + return f"cert:{fingerprint[:32]}" # Use first 32 chars of fingerprint + return None + + def _authenticate_password( + self, username: str, password: str + ) -> tuple[Optional[User], Optional[str]]: + """ + Authenticate using username and password. + + Args: + username: The username + password: The password + + Returns: + Tuple of (asyncua User object, OpenPLC role string) or (None, None) + """ + if username not in self.users: + log_warn(f"User '{username}' not found in configuration") + return None, None + + config_user = self.users[username] + if not self._validate_password(password, config_user.password_hash): + log_warn(f"Password validation failed for user '{username}'") + return None, None + + # Get OpenPLC role and map to asyncua role + openplc_role = self._user_roles.get(username, "viewer") + asyncua_role = self.ROLE_MAPPING.get(openplc_role, UserRole.User) + + # Return asyncua User object + return User(role=asyncua_role, name=username), openplc_role + + def _authenticate_certificate(self, certificate: Any) -> tuple[Optional[User], Optional[str]]: + """ + Authenticate using certificate. + + Args: + certificate: The client certificate + + Returns: + Tuple of (asyncua User object, OpenPLC role string) or (None, None) + """ + cert_id = self._extract_cert_id(certificate) + if not cert_id or cert_id not in self.cert_users: + log_warn(f"Certificate not found in trusted certificates (cert_id={cert_id})") + return None, None + + # Get OpenPLC role and map to asyncua role + openplc_role = self._user_roles.get(f"cert:{cert_id}", "viewer") + asyncua_role = self.ROLE_MAPPING.get(openplc_role, UserRole.User) + + log_info(f"Certificate authenticated as user with role '{openplc_role}'") + + # Return asyncua User object + return User(role=asyncua_role, name=f"cert:{cert_id}"), openplc_role + + def _authenticate_anonymous(self, profile: Any) -> tuple[Optional[User], Optional[str]]: + """ + Authenticate as anonymous user. + + Args: + profile: The security profile + + Returns: + Tuple of (asyncua User object, OpenPLC role string) or (None, None) + """ + if "Anonymous" not in profile.auth_methods: + log_warn("Anonymous authentication not allowed for this profile") + return None, None + + # Anonymous users get viewer role (read-only) + openplc_role = "viewer" + + # Return asyncua User object + return User(role=UserRole.User, name="anonymous"), openplc_role + + def _extract_cert_id(self, certificate: Any) -> Optional[str]: + """ + Extract certificate ID using fingerprint matching. + + Args: + certificate: The client certificate + + Returns: + Certificate ID if found in trusted list, None otherwise + """ + try: + # Convert session certificate to fingerprint + client_fingerprint = self._cert_to_fingerprint(certificate) + if not client_fingerprint: + return None + + # Compare with configured certificate fingerprints + for cert_info in self.config.security.trusted_client_certificates: + config_fingerprint = self._pem_to_fingerprint(cert_info["pem"]) + if config_fingerprint and client_fingerprint == config_fingerprint: + log_info( + f"Certificate matched: {cert_info['id']} " + f"(fingerprint: {client_fingerprint[:16]}...)" + ) + return cert_info["id"] + + log_warn( + f"Certificate not found in trusted list (fingerprint: {client_fingerprint[:16]}...)" + ) + except Exception as e: + log_error(f"Certificate fingerprint extraction failed: {e}") + + return None + + def _cert_to_fingerprint(self, certificate: Any) -> Optional[str]: + """ + Convert certificate object to SHA256 fingerprint. + + Args: + certificate: Certificate object (various formats supported) + + Returns: + Fingerprint string (colon-separated hex) or None + """ + try: + if hasattr(certificate, "der"): + # Certificate object with der attribute + cert_der = certificate.der + elif hasattr(certificate, "data"): + # Certificate object with data attribute + cert_der = certificate.data + elif isinstance(certificate, bytes): + # Raw certificate data + cert_der = certificate + else: + # Try to convert to string and then decode + cert_str = str(certificate) + if "-----BEGIN CERTIFICATE-----" in cert_str: + # PEM format - extract base64 content + cert_lines = cert_str.split("\n") + cert_b64 = "".join( + [line for line in cert_lines if not line.startswith("-----")] + ) + cert_der = base64.b64decode(cert_b64) + else: + log_warn(f"Unknown certificate format: {type(certificate)}") + return None + + # Calculate SHA256 fingerprint + fingerprint = hashlib.sha256(cert_der).hexdigest().upper() + return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2)) + except Exception as e: + log_error(f"Failed to extract certificate fingerprint: {e}") + return None + + def _pem_to_fingerprint(self, pem_str: str) -> Optional[str]: + """ + Convert PEM certificate string to SHA256 fingerprint. + + Args: + pem_str: PEM-encoded certificate string + + Returns: + Fingerprint string (colon-separated hex) or None + """ + try: + # Extract base64 content from PEM + pem_lines = pem_str.strip().split("\n") + cert_b64 = "".join([line for line in pem_lines if not line.startswith("-----")]) + cert_der = base64.b64decode(cert_b64) + + # Calculate SHA256 fingerprint + fingerprint = hashlib.sha256(cert_der).hexdigest().upper() + return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2)) + except Exception as e: + log_error(f"Failed to convert PEM to fingerprint: {e}") + return None + + def _detect_auth_method( + self, username: Optional[str], password: Optional[str], certificate: Optional[Any] + ) -> str: + """ + Detect which authentication method is being used. + + Priority order: + 1. Username/Password - explicit user credentials take precedence + 2. Certificate - used when no credentials provided but cert present + 3. Anonymous - fallback when nothing provided + + Note: Certificate is always present on secure connections (TLS), so we must + check for username/password first to avoid incorrectly detecting certificate + auth when the user intended to use credentials. + + Args: + username: Username if provided + password: Password if provided + certificate: Certificate if provided + + Returns: + Authentication method: "Certificate", "Username", or "Anonymous" + """ + if username and password: + return "Username" + elif certificate: + return "Certificate" + else: + return "Anonymous" + + def _find_profile_by_auth_method(self, auth_method: str) -> Optional[Any]: + """ + Find a security profile that supports the given authentication method. + + Args: + auth_method: The authentication method to find + + Returns: + Security profile object or None + """ + for profile in self.config.server.security_profiles: + if not profile.enabled: + continue + if auth_method in profile.auth_methods: + log_info(f"Found profile '{profile.name}' supporting {auth_method}") + return profile + + log_warn(f"No enabled profile found supporting authentication method: {auth_method}") + return None + + def _validate_password(self, password: str, password_hash: str) -> bool: + """ + Validate password against hash using bcrypt or PBKDF2. + + Automatically detects the hash type: + - bcrypt hashes start with $2a$, $2b$, or $2y$ + - PBKDF2 hashes start with pbkdf2: + + Args: + password: Plain text password + password_hash: Bcrypt or PBKDF2 hash + + Returns: + True if password matches + """ + # Detect hash type and validate accordingly + if password_hash.startswith("pbkdf2:"): + # PBKDF2 hash - use stdlib (works everywhere) + return _pbkdf2_verify_password(password, password_hash) + elif password_hash.startswith(("$2a$", "$2b$", "$2y$")): + # bcrypt hash + if _bcrypt_available: + try: + return bcrypt.checkpw(password.encode(), password_hash.encode()) + except Exception as e: + log_error(f"bcrypt validation error: {e}") + return False + else: + log_error( + "bcrypt hash detected but bcrypt not available. " + "Re-hash password with PBKDF2 or install bcrypt." + ) + return False + else: + log_error(f"Unknown password hash format: {password_hash[:10]}...") + return False diff --git a/core/src/drivers/plugins/python/shared/API_SPECIFICATION.md b/core/src/drivers/plugins/python/shared/API_SPECIFICATION.md new file mode 100644 index 00000000..7b27d1b8 --- /dev/null +++ b/core/src/drivers/plugins/python/shared/API_SPECIFICATION.md @@ -0,0 +1,154 @@ +# SafeBufferAccess API Specification + +## Overview +This document specifies the complete public API of the `SafeBufferAccess` class that must be maintained for backward compatibility during refactoring. + +## Class Structure + +### Constructor +```python +SafeBufferAccess(runtime_args: PluginRuntimeArgs) +``` + +**Parameters:** +- `runtime_args`: `PluginRuntimeArgs` instance + +**Attributes:** +- `is_valid: bool` - Whether the instance is properly initialized +- `error_msg: str` - Error message if initialization failed + +### Public Methods + +#### Mutex Management +```python +acquire_mutex() -> (bool, str) +release_mutex() -> (bool, str) +``` + +#### Boolean Buffer Operations +```python +read_bool_input(buffer_idx: int, bit_idx: int, thread_safe: bool = True) -> (bool, str) +read_bool_output(buffer_idx: int, bit_idx: int, thread_safe: bool = True) -> (bool, str) +write_bool_output(buffer_idx: int, bit_idx: int, value: bool, thread_safe: bool = True) -> (bool, str) +``` + +#### Byte Buffer Operations +```python +read_byte_input(buffer_idx: int, thread_safe: bool = True) -> (int, str) +read_byte_output(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_byte_output(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +``` + +#### Integer Buffer Operations (16-bit) +```python +read_int_input(buffer_idx: int, thread_safe: bool = True) -> (int, str) +read_int_output(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_int_output(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +read_int_memory(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_int_memory(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +``` + +#### Double Integer Buffer Operations (32-bit) +```python +read_dint_input(buffer_idx: int, thread_safe: bool = True) -> (int, str) +read_dint_output(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_dint_output(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +read_dint_memory(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_dint_memory(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +``` + +#### Long Integer Buffer Operations (64-bit) +```python +read_lint_input(buffer_idx: int, thread_safe: bool = True) -> (int, str) +read_lint_output(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_lint_output(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +read_lint_memory(buffer_idx: int, thread_safe: bool = True) -> (int, str) +write_lint_memory(buffer_idx: int, value: int, thread_safe: bool = True) -> (bool, str) +``` + +#### Batch Operations +```python +batch_read_values(operations: List[Tuple]) -> (List[Tuple], str) +batch_write_values(operations: List[Tuple]) -> (List[Tuple], str) +batch_mixed_operations(read_operations: List[Tuple], write_operations: List[Tuple]) -> (Dict, str) +``` + +#### Debug/Variable Operations +```python +get_var_list(indexes: List[int]) -> (List[int], str) +get_var_size(index: int) -> (int, str) +get_var_value(index: int) -> (Any, str) +set_var_value(index: int, value: Any) -> (bool, str) +get_var_count() -> (int, str) +get_var_info(index: int) -> (Dict, str) +``` + +#### Configuration Operations +```python +get_config_path() -> (str, str) +get_config_file_args_as_map() -> (Dict, str) +``` + +## Parameter Details + +### Common Parameters +- `buffer_idx: int` - Buffer index (0-based) +- `bit_idx: int` - Bit index within buffer (for boolean operations) +- `value: int/bool` - Value to write +- `thread_safe: bool = True` - Whether to use mutex for thread-safe access + +### Value Ranges +- `bool`: `True`/`False` +- `byte`: `0-255` +- `int`: `0-65535` (16-bit unsigned) +- `dint`: `0-4294967295` (32-bit unsigned) +- `lint`: `0-18446744073709551615` (64-bit unsigned) + +### Return Values +- **Read operations**: `(value, error_message: str)` +- **Write operations**: `(success: bool, error_message: str)` +- **Batch operations**: `(results: List/Dict, error_message: str)` + +## Error Handling +- Invalid buffer/bit indices return appropriate error messages +- Out-of-range values return validation errors +- Mutex acquisition failures return error messages +- All operations return consistent `(result, message)` tuples + +## Thread Safety +- Default behavior uses mutex for thread-safe access +- `thread_safe=False` bypasses mutex (for manual control) +- Mutex operations: `acquire_mutex()`/`release_mutex()` + +## Batch Operations Format + +### Read Operations +```python +[ + ('buffer_type', buffer_idx, bit_idx), # for bool operations + ('buffer_type', buffer_idx), # for other types + # ... +] +``` + +### Write Operations +```python +[ + ('buffer_type', buffer_idx, value, bit_idx), # for bool operations + ('buffer_type', buffer_idx, value), # for other types + # ... +] +``` + +### Buffer Types +- `'bool_input'`, `'bool_output'` +- `'byte_input'`, `'byte_output'` +- `'int_input'`, `'int_output'`, `'int_memory'` +- `'dint_input'`, `'dint_output'`, `'dint_memory'` +- `'lint_input'`, `'lint_output'`, `'lint_memory'` + +## Compatibility Requirements +- All existing tests must pass without modification +- All existing plugins must continue to work +- API signatures must remain identical +- Behavior must be preserved exactly diff --git a/core/src/drivers/plugins/python/shared/plugin_config_decode/modbus_master_config_model.py b/core/src/drivers/plugins/python/shared/plugin_config_decode/modbus_master_config_model.py index ff0a8942..fc7ce42e 100644 --- a/core/src/drivers/plugins/python/shared/plugin_config_decode/modbus_master_config_model.py +++ b/core/src/drivers/plugins/python/shared/plugin_config_decode/modbus_master_config_model.py @@ -1,5 +1,4 @@ import re -from typing import List, Dict, Any import json from dataclasses import dataclass from typing import Optional, Literal, List, Dict, Any diff --git a/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py b/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py new file mode 100644 index 00000000..3d00347d --- /dev/null +++ b/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py @@ -0,0 +1,527 @@ +from typing import List, Dict, Any, Optional, Literal +from dataclasses import dataclass +import json +import os + +try: + from .plugin_config_contact import PluginConfigContract +except ImportError: + # For direct execution + from plugin_config_contact import PluginConfigContract + +# Permission types for variables +PermissionType = Literal["r", "w", "rw"] + +# Valid datatypes for OPC-UA variables (IEC 61131-3 base types) +# This list must match the base types supported by openplc-editor +VALID_DATATYPES = frozenset([ + # Boolean + "BOOL", + # Signed integers + "SINT", "INT", "DINT", "LINT", + # Unsigned integers + "USINT", "UINT", "UDINT", "ULINT", + # Floating point + "REAL", "LREAL", + # Bit strings + "BYTE", "WORD", "DWORD", "LWORD", + # String + "STRING", + # Time-related types + "TIME", "DATE", "TOD", "DT", + # Legacy/alternative names (for backward compatibility) + "INT32", "FLOAT", +]) + + +@dataclass +class SecurityProfile: + """Configuration for a security profile/endpoint.""" + name: str + enabled: bool + security_policy: str + security_mode: str + auth_methods: List[str] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SecurityProfile': + """Creates a SecurityProfile instance from a dictionary.""" + try: + name = data["name"] + enabled = data["enabled"] + security_policy = data["security_policy"] + security_mode = data["security_mode"] + auth_methods = data["auth_methods"] + except KeyError as e: + raise ValueError(f"Missing required field in security profile: {e}") + + return cls( + name=name, + enabled=enabled, + security_policy=security_policy, + security_mode=security_mode, + auth_methods=auth_methods + ) + +@dataclass +class ServerConfig: + """OPC-UA server basic configuration.""" + name: str + application_uri: str + product_uri: str + endpoint_url: str + security_profiles: List[SecurityProfile] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ServerConfig': + """Creates a ServerConfig instance from a dictionary.""" + try: + name = data["name"] + application_uri = data["application_uri"] + product_uri = data["product_uri"] + endpoint_url = data["endpoint_url"] + security_profiles_data = data["security_profiles"] + except KeyError as e: + raise ValueError(f"Missing required field in server config: {e}") + + security_profiles = [SecurityProfile.from_dict(sp) for sp in security_profiles_data] + + return cls( + name=name, + application_uri=application_uri, + product_uri=product_uri, + endpoint_url=endpoint_url, + security_profiles=security_profiles + ) + +@dataclass +class SecurityConfig: + """Security configuration for certificates and trust.""" + server_certificate_strategy: str + server_certificate_custom: Optional[str] + server_private_key_custom: Optional[str] + trusted_client_certificates: List[Dict[str, str]] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SecurityConfig': + """Creates a SecurityConfig instance from a dictionary.""" + server_certificate_strategy = data.get("server_certificate_strategy", "auto_self_signed") + server_certificate_custom = data.get("server_certificate_custom") + server_private_key_custom = data.get("server_private_key_custom") + trusted_client_certificates = data.get("trusted_client_certificates", []) + + return cls( + server_certificate_strategy=server_certificate_strategy, + server_certificate_custom=server_certificate_custom, + server_private_key_custom=server_private_key_custom, + trusted_client_certificates=trusted_client_certificates + ) + +@dataclass +class User: + """User configuration for authentication.""" + type: str + username: Optional[str] + password_hash: Optional[str] + certificate_id: Optional[str] + role: str + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'User': + """Creates a User instance from a dictionary.""" + try: + user_type = data["type"] + role = data["role"] + except KeyError as e: + raise ValueError(f"Missing required field in user config: {e}") + + username = data.get("username") + password_hash = data.get("password_hash") + certificate_id = data.get("certificate_id") + + return cls( + type=user_type, + username=username, + password_hash=password_hash, + certificate_id=certificate_id, + role=role + ) + +@dataclass +class VariablePermissions: + """Permissions for a variable per role.""" + viewer: PermissionType + operator: PermissionType + engineer: PermissionType + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'VariablePermissions': + """Creates a VariablePermissions instance from a dictionary.""" + viewer = data.get("viewer", "r") + operator = data.get("operator", "r") + engineer = data.get("engineer", "rw") + + return cls( + viewer=viewer, + operator=operator, + engineer=engineer + ) + +@dataclass +class VariableField: + """ + Field within a struct variable. + + Supports nested fields for complex types (FB instances, nested structs). + When a field has nested fields, its index will be None since only leaf + fields have actual debug variable indices. + """ + name: str + datatype: str + initial_value: Any + index: Optional[int] # None for complex types that have nested fields + permissions: VariablePermissions + fields: Optional[List['VariableField']] = None # Nested fields for complex types + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'VariableField': + """Creates a VariableField instance from a dictionary.""" + try: + name = data["name"] + datatype = data["datatype"] + initial_value = data["initial_value"] + index = data["index"] # Can be None for complex types + permissions_data = data["permissions"] + except KeyError as e: + raise ValueError(f"Missing required field in variable field: {e}") + + permissions = VariablePermissions.from_dict(permissions_data) + + # Parse nested fields if present (recursive) + nested_fields = None + if "fields" in data and data["fields"]: + nested_fields = [VariableField.from_dict(f) for f in data["fields"]] + + return cls( + name=name, + datatype=datatype, + initial_value=initial_value, + index=index, + permissions=permissions, + fields=nested_fields + ) + +@dataclass +class StructVariable: + """Struct variable configuration.""" + node_id: str + browse_name: str + display_name: str + description: str + fields: List[VariableField] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'StructVariable': + """Creates a StructVariable instance from a dictionary.""" + try: + node_id = data["node_id"] + browse_name = data["browse_name"] + display_name = data["display_name"] + description = data["description"] + fields_data = data["fields"] + except KeyError as e: + raise ValueError(f"Missing required field in struct variable: {e}") + + fields = [VariableField.from_dict(field) for field in fields_data] + + return cls( + node_id=node_id, + browse_name=browse_name, + display_name=display_name, + description=description, + fields=fields + ) + +@dataclass +class ArrayVariable: + """Array variable configuration.""" + node_id: str + browse_name: str + display_name: str + datatype: str + length: int + initial_value: Any + index: int + permissions: VariablePermissions + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ArrayVariable': + """Creates an ArrayVariable instance from a dictionary.""" + try: + node_id = data["node_id"] + browse_name = data["browse_name"] + display_name = data["display_name"] + datatype = data["datatype"] + length = data["length"] + initial_value = data["initial_value"] + index = data["index"] + permissions_data = data["permissions"] + except KeyError as e: + raise ValueError(f"Missing required field in array variable: {e}") + + permissions = VariablePermissions.from_dict(permissions_data) + + return cls( + node_id=node_id, + browse_name=browse_name, + display_name=display_name, + datatype=datatype, + length=length, + initial_value=initial_value, + index=index, + permissions=permissions + ) + +@dataclass +class SimpleVariable: + """Simple variable configuration.""" + node_id: str + browse_name: str + display_name: str + datatype: str + initial_value: Any + description: str + index: int + permissions: VariablePermissions + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SimpleVariable': + """Creates a SimpleVariable instance from a dictionary.""" + try: + node_id = data["node_id"] + browse_name = data["browse_name"] + display_name = data["display_name"] + datatype = data["datatype"] + initial_value = data["initial_value"] + description = data["description"] + index = data["index"] + permissions_data = data["permissions"] + except KeyError as e: + raise ValueError(f"Missing required field in simple variable: {e}") + + permissions = VariablePermissions.from_dict(permissions_data) + + return cls( + node_id=node_id, + browse_name=browse_name, + display_name=display_name, + datatype=datatype, + initial_value=initial_value, + description=description, + index=index, + permissions=permissions + ) + +@dataclass +class AddressSpace: + """Address space configuration.""" + namespace_uri: str + variables: List[SimpleVariable] + structures: List[StructVariable] + arrays: List[ArrayVariable] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'AddressSpace': + """Creates an AddressSpace instance from a dictionary.""" + try: + namespace_uri = data["namespace_uri"] + variables_data = data.get("variables", []) + structures_data = data.get("structures", []) + arrays_data = data.get("arrays", []) + except KeyError as e: + raise ValueError(f"Missing required field in address space: {e}") + + variables = [SimpleVariable.from_dict(var) for var in variables_data] + structures = [StructVariable.from_dict(struct) for struct in structures_data] + arrays = [ArrayVariable.from_dict(arr) for arr in arrays_data] + + return cls( + namespace_uri=namespace_uri, + variables=variables, + structures=structures, + arrays=arrays + ) + +@dataclass +class OpcuaConfig: + """Complete OPC-UA configuration.""" + server: ServerConfig + security: SecurityConfig + users: List[User] + address_space: AddressSpace + cycle_time_ms: int = 100 # Default cycle time in milliseconds + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'OpcuaConfig': + """Creates an OpcuaConfig instance from a dictionary.""" + try: + server_data = data["server"] + security_data = data["security"] + users_data = data["users"] + address_space_data = data["address_space"] + except KeyError as e: + raise ValueError(f"Missing required section in OPC-UA config: {e}") + + server = ServerConfig.from_dict(server_data) + security = SecurityConfig.from_dict(security_data) + users = [User.from_dict(user) for user in users_data] + address_space = AddressSpace.from_dict(address_space_data) + cycle_time_ms = data.get("cycle_time_ms", 100) # Default 100ms if not specified + + return cls( + server=server, + security=security, + users=users, + address_space=address_space, + cycle_time_ms=cycle_time_ms + ) + +@dataclass +class OpcuaPluginConfig: + """Represents a single OPC-UA plugin configuration.""" + name: str + protocol: str + config: OpcuaConfig + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'OpcuaPluginConfig': + """Creates an OpcuaPluginConfig instance from a dictionary.""" + try: + name = data["name"] + protocol = data["protocol"] + config_data = data["config"] + except KeyError as e: + raise ValueError(f"Missing required field in OPC-UA plugin config: {e}") + + config = OpcuaConfig.from_dict(config_data) + + return cls(name=name, protocol=protocol, config=config) + +class OpcuaMasterConfig(PluginConfigContract): + """ + OPC-UA Master configuration model. + """ + def __init__(self): + super().__init__() + self.plugins: List[OpcuaPluginConfig] = [] + + def import_config_from_file(self, file_path: str): + """Read config from a JSON file.""" + with open(file_path, 'r') as f: + raw_config = json.load(f) + + # Clear any existing plugins + self.plugins = [] + + # Parse each plugin configuration + for i, plugin_config in enumerate(raw_config): + try: + plugin = OpcuaPluginConfig.from_dict(plugin_config) + self.plugins.append(plugin) + except Exception as e: + raise ValueError(f"Failed to parse plugin configuration #{i+1}: {e}") + + def validate(self) -> None: + """Validates the configuration.""" + if not self.plugins: + raise ValueError("No plugins configured. At least one OPC-UA plugin must be defined.") + + # Validate each plugin + for i, plugin in enumerate(self.plugins): + if plugin.protocol != "OPC-UA": + raise ValueError(f"Invalid protocol for plugin #{i+1}: {plugin.protocol}. Expected 'OPC-UA'") + + if not plugin.name: + raise ValueError(f"Plugin #{i+1} has empty name") + + # Validate address space + address_space = plugin.config.address_space + + # Check for duplicate node_ids + all_node_ids = [] + all_node_ids.extend([var.node_id for var in address_space.variables]) + all_node_ids.extend([struct.node_id for struct in address_space.structures]) + all_node_ids.extend([arr.node_id for arr in address_space.arrays]) + + if len(all_node_ids) != len(set(all_node_ids)): + raise ValueError(f"Duplicate node_ids found in plugin '{plugin.name}'") + + # Check for duplicate indices + # Helper to collect indices recursively from nested fields + def collect_field_indices(fields: List[VariableField]) -> List[int]: + indices = [] + for field in fields: + if field.index is not None: # Skip None indices (complex types) + indices.append(field.index) + if field.fields: # Recurse into nested fields + indices.extend(collect_field_indices(field.fields)) + return indices + + all_indices = [] + all_indices.extend([var.index for var in address_space.variables]) + for struct in address_space.structures: + all_indices.extend(collect_field_indices(struct.fields)) + all_indices.extend([arr.index for arr in address_space.arrays]) + + if len(all_indices) != len(set(all_indices)): + raise ValueError(f"Duplicate indices found in plugin '{plugin.name}'") + + # Validate datatypes + # Helper to validate datatypes recursively for nested fields + # Only leaf fields (those without nested children) are validated + def validate_field_datatypes( + fields: List[VariableField], + struct_node_id: str, + plugin_name: str, + path: str = "" + ) -> None: + for field in fields: + # Build full path for better error messages + current_path = f"{path}.{field.name}" if path else field.name + if field.fields: + # Complex type with nested fields - recurse into children + # Don't validate the parent's datatype (e.g., TON, TOF, custom FB) + validate_field_datatypes( + field.fields, struct_node_id, plugin_name, current_path + ) + else: + # Leaf field - validate its datatype + if not field.datatype or field.datatype.upper() not in VALID_DATATYPES: + raise ValueError( + f"Invalid datatype '{field.datatype}' for field '{current_path}' " + f"in struct '{struct_node_id}' in plugin '{plugin_name}'. " + f"Valid types: {sorted(VALID_DATATYPES)}" + ) + + for var in address_space.variables: + if var.datatype.upper() not in VALID_DATATYPES: + raise ValueError( + f"Invalid datatype '{var.datatype}' for variable '{var.node_id}' " + f"in plugin '{plugin.name}'. Valid types: {sorted(VALID_DATATYPES)}" + ) + for struct in address_space.structures: + validate_field_datatypes(struct.fields, struct.node_id, plugin.name) + for arr in address_space.arrays: + if arr.datatype.upper() not in VALID_DATATYPES: + raise ValueError( + f"Invalid datatype '{arr.datatype}' for array '{arr.node_id}' " + f"in plugin '{plugin.name}'. Valid types: {sorted(VALID_DATATYPES)}" + ) + + # Check for duplicate plugin names + plugin_names = [plugin.name for plugin in self.plugins] + if len(plugin_names) != len(set(plugin_names)): + raise ValueError("Duplicate plugin names found. Each plugin must have a unique name.") + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(plugins={len(self.plugins)})" diff --git a/core/src/drivers/plugins/python/shared/plugin_runtime_args.py b/core/src/drivers/plugins/python/shared/plugin_runtime_args.py index a091e055..110ff985 100644 --- a/core/src/drivers/plugins/python/shared/plugin_runtime_args.py +++ b/core/src/drivers/plugins/python/shared/plugin_runtime_args.py @@ -41,6 +41,10 @@ class PluginRuntimeArgs(ctypes.Structure): ("mutex_take", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)), ("mutex_give", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)), ("buffer_mutex", ctypes.c_void_p), + # Variable access functions + ("get_var_list", ctypes.CFUNCTYPE(None, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(ctypes.c_void_p))), + ("get_var_size", ctypes.CFUNCTYPE(ctypes.c_size_t, ctypes.c_size_t)), + ("get_var_count", ctypes.CFUNCTYPE(ctypes.c_uint16)), ("plugin_specific_config_file_path", ctypes.c_char * 256), # Buffer size information ("buffer_size", ctypes.c_int), diff --git a/core/src/plc_app/plc_main.c b/core/src/plc_app/plc_main.c index 646e1ebf..5b106ecf 100644 --- a/core/src/plc_app/plc_main.c +++ b/core/src/plc_app/plc_main.c @@ -1,3 +1,6 @@ +#define PY_SSIZE_T_CLEAN +#include + #include #include #include @@ -97,6 +100,15 @@ int main(int argc, char *argv[]) else { log_error("[PLUGIN]: Failed to load plugin configuration"); + // Release the Python GIL if Python was initialized during plugin loading. + // This prevents a deadlock where the main thread holds the GIL forever + // while sleeping, blocking other threads (like the unix socket thread) + // from using Python when handling commands like START. + if (Py_IsInitialized()) + { + PyEval_SaveThread(); + log_info("[PLUGIN]: Released Python GIL after failed plugin load"); + } } } diff --git a/docs/opcua/OPCUA_AUTHENTICATION_REVIEW.md b/docs/opcua/OPCUA_AUTHENTICATION_REVIEW.md new file mode 100644 index 00000000..155274c6 --- /dev/null +++ b/docs/opcua/OPCUA_AUTHENTICATION_REVIEW.md @@ -0,0 +1,182 @@ +# OPC-UA Plugin Authentication Implementation Report + +**Date:** 2026-01-22 +**Branch:** RTOP-100-OPC-UA +**asyncua Version:** 1.1.8 + +## Executive Summary + +The OpenPLC OPC-UA plugin's username/password authentication implementation **is correctly aligned with asyncua 1.1.8 patterns**. The implementation follows the recommended approach from the asyncua library documentation and community examples. + +--- + +## Comparison Table: OpenPLC vs asyncua 1.1.8 + +| Aspect | asyncua 1.1.8 Pattern | OpenPLC Implementation | Status | +|--------|----------------------|------------------------|--------| +| **UserManager Interface** | Extends `UserManager` base class | `OpenPLCUserManager(UserManager)` | Correct | +| **get_user signature** | `get_user(self, iserver, username=None, password=None, certificate=None)` | Exact same signature at `user_manager.py:88-94` | Correct | +| **Return value** | `User` object with `role` attribute, or `None` | Returns user object with `role` (UserRole enum) or `None` | Correct | +| **Server integration** | `Server(user_manager=UserManager())` | `Server(user_manager=self.user_manager)` at `server.py:188` | Correct | +| **UserRole enum** | `from asyncua.server.user_managers import UserRole` | Same import at `user_manager.py:15` | Correct | +| **Password storage** | No specific requirement | bcrypt hashes (industry standard) | Good | + +--- + +## Detailed Analysis + +### 1. UserManager Class Implementation (`user_manager.py`) + +**Correct Implementation:** +```python +# Line 15: Correct import from asyncua +from asyncua.server.user_managers import UserManager, UserRole + +# Line 41: Proper inheritance +class OpenPLCUserManager(UserManager): + ... + +# Lines 88-94: Correct method signature +def get_user( + self, + iserver, + username: Optional[str] = None, + password: Optional[str] = None, + certificate: Optional[Any] = None +) -> Optional[Any]: +``` + +This matches the asyncua documentation exactly: +```python +# asyncua pattern: +class UserManager: + def get_user(self, iserver, username=None, password=None, certificate=None): + raise NotImplementedError +``` + +### 2. Server Integration (`server.py:188`) + +**Correct Implementation:** +```python +# Line 188: Passes user_manager to Server constructor +self.server = Server(user_manager=self.user_manager) +``` + +This aligns with asyncua's recommended pattern: +```python +# asyncua documentation: +server = Server(user_manager=UserManager()) +``` + +### 3. Role Mapping (`user_manager.py:56-61`) + +**Implementation:** +```python +ROLE_MAPPING = { + "viewer": UserRole.User, # Read-only access + "operator": UserRole.User, # Read/write via callbacks + "engineer": UserRole.Admin # Full access +} +``` + +This is consistent with asyncua's `UserRole` enum which has `User` and `Admin` levels. + +### 4. Password Validation (`user_manager.py:369-389`) + +**Strengths:** +- Uses bcrypt for password hashing (industry standard) +- Fails securely if bcrypt is unavailable +- No plaintext password storage + +**Implementation:** +```python +def _validate_password(self, password: str, password_hash: str) -> bool: + if _bcrypt_available: + try: + return bcrypt.checkpw(password.encode(), password_hash.encode()) + except Exception as e: + log_error(f"bcrypt validation error: {e}") + return False + else: + log_error("bcrypt not available - password authentication disabled for security") + return False +``` + +--- + +## Minor Observations (Not Issues) + +| Item | Current State | asyncua Default | Impact | +|------|--------------|-----------------|--------| +| User return type | `SimpleNamespace` / config `User` | `User` from `asyncua.crypto.permission_rules` | Works correctly - asyncua only checks for `role` attribute | +| Anonymous users | `SimpleNamespace()` with `role` | `User(role=UserRole.User)` | Functionally equivalent | + +The implementation returns user objects that have the required `role` attribute, which is all asyncua needs for authorization decisions. + +--- + +## Test Coverage Gap + +**Finding:** No unit tests exist for the `OpenPLCUserManager` class. + +**Recommendation:** Consider adding tests for: +- Password authentication success/failure +- Certificate authentication success/failure +- Anonymous authentication with profile restrictions +- Role mapping verification + +--- + +## Configuration Validation + +The current config file (`opcua.json`) shows proper usage: + +```json +{ + "users": [ + { + "type": "certificate", + "certificate_id": "engineer_cert", + "role": "engineer" + }, + { + "type": "password", + "username": "operator", + "password_hash": "$2b$10$Y/WT4Z8ku9hObwSPk1bmY...", + "role": "operator" + } + ] +} +``` + +--- + +## Conclusion + +**The implementation is healthy and correctly follows asyncua 1.1.8 patterns.** + +No changes are required for core functionality. The implementation: +1. Uses the correct `UserManager` interface +2. Has the correct `get_user()` signature +3. Integrates properly with asyncua `Server` +4. Uses appropriate security practices (bcrypt hashing) + +--- + +## Optional Improvements (Not Required) + +| Priority | Improvement | Rationale | +|----------|-------------|-----------| +| Low | Add unit tests for `OpenPLCUserManager` | Increase confidence in auth logic | +| Low | Return asyncua's `User` class directly | Closer adherence to asyncua patterns (not required for functionality) | +| Low | Add rate limiting on auth attempts | Security hardening against brute force | + +--- + +## References + +- [Server set User with Password - GitHub Discussion #1386](https://github.com/FreeOpcUa/opcua-asyncio/discussions/1386) +- [Server with Authentication (user/password) and Encryption - GitHub Discussion #934](https://github.com/FreeOpcUa/opcua-asyncio/discussions/934) +- [asyncua PyPI](https://pypi.org/project/asyncua/) +- [asyncua server.py](https://github.com/FreeOpcUa/opcua-asyncio/blob/master/asyncua/server/server.py) +- [asyncua server-with-encryption.py example](https://github.com/FreeOpcUa/opcua-asyncio/blob/master/examples/server-with-encryption.py) diff --git a/docs/opcua/OPCUA_CONFIGURATION_GUIDE.md b/docs/opcua/OPCUA_CONFIGURATION_GUIDE.md new file mode 100644 index 00000000..805abb0c --- /dev/null +++ b/docs/opcua/OPCUA_CONFIGURATION_GUIDE.md @@ -0,0 +1,564 @@ +# OPC-UA Plugin Configuration Guide + +This document explains each field in the `opcua.json` configuration file and how it influences the OPC-UA server behavior. + +## Table of Contents + +- [Overview](#overview) +- [Top-Level Structure](#top-level-structure) +- [Server Configuration](#server-configuration) +- [Security Configuration](#security-configuration) +- [Users Configuration](#users-configuration) +- [Cycle Time](#cycle-time) +- [Address Space](#address-space) + - [Variables](#variables) + - [Structures](#structures) + - [Arrays](#arrays) +- [Data Types Reference](#data-types-reference) +- [Permissions Reference](#permissions-reference) +- [Complete Example](#complete-example) + +--- + +## Overview + +The `opcua.json` file configures the OPC-UA server plugin for OpenPLC Runtime. It defines: + +- **Server identity and network settings** +- **Security policies and authentication methods** +- **User accounts and their access roles** +- **PLC variables exposed to OPC-UA clients** + +The configuration is stored as a JSON array, allowing multiple OPC-UA server instances (though typically only one is used). + +--- + +## Top-Level Structure + +```json +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { ... } + } +] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Unique identifier for this server instance. Used in logs and internal references. | +| `protocol` | string | Must be `"OPC-UA"` to identify this as an OPC-UA configuration. | +| `config` | object | Contains all server configuration (detailed below). | + +--- + +## Server Configuration + +The `config.server` section defines the OPC-UA server identity and network settings. + +```json +"server": { + "name": "OpenPLC OPC UA Server", + "application_uri": "urn:freeopcua:python:server", + "product_uri": "urn:openplc:runtime:product", + "endpoint_url": "opc.tcp://localhost:4840/openplc/opcua", + "security_profiles": [ ... ] +} +``` + +### Server Identity Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Human-readable server name displayed to OPC-UA clients during discovery. | +| `application_uri` | string | Unique URI identifying this application. Used for certificate validation and client matching. Format: `urn:domain:application:instance` | +| `product_uri` | string | URI identifying the product type. Helps clients identify the server software. | +| `endpoint_url` | string | Network address where the server listens. Format: `opc.tcp://hostname:port/path` | + +### Endpoint URL Components + +- **Protocol**: Always `opc.tcp://` for OPC-UA binary protocol +- **Hostname**: Use `localhost` for local-only access, `0.0.0.0` or specific IP for network access +- **Port**: Default OPC-UA port is `4840`. Use a different port to avoid conflicts. +- **Path**: Optional path segment (e.g., `/openplc/opcua`) + +### Security Profiles + +Each security profile defines a connection method with specific security requirements. + +```json +"security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + }, + { + "name": "signed", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "Sign", + "auth_methods": ["Username", "Certificate"] + }, + { + "name": "signed_encrypted", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": ["Username", "Certificate"] + } +] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Profile identifier for logging and reference. | +| `enabled` | boolean | Set `false` to disable this profile without removing it. | +| `security_policy` | string | Cryptographic algorithm suite (see table below). | +| `security_mode` | string | Message protection level (see table below). | +| `auth_methods` | array | Allowed authentication methods for this profile. | + +#### Security Policy Values + +| Value | Description | Recommendation | +|-------|-------------|----------------| +| `"None"` | No encryption or signing | Development/testing only | +| `"Basic256Sha256"` | AES-256 encryption, SHA-256 signatures | Recommended for production | +| `"Aes128_Sha256_RsaOaep"` | AES-128 encryption, SHA-256 signatures | Good balance of security/performance | +| `"Aes256_Sha256_RsaPss"` | Latest algorithm suite | Highest security | + +#### Security Mode Values + +| Value | Description | +|-------|-------------| +| `"None"` | Messages are neither signed nor encrypted | +| `"Sign"` | Messages are signed (integrity protection) but not encrypted | +| `"SignAndEncrypt"` | Messages are both signed and encrypted (confidentiality + integrity) | + +#### Authentication Methods + +| Value | Description | +|-------|-------------| +| `"Anonymous"` | No credentials required. Use only with `security_policy: "None"` | +| `"Username"` | Username and password authentication | +| `"Certificate"` | X.509 client certificate authentication | + +--- + +## Security Configuration + +The `config.security` section manages certificates and trusted clients. + +```json +"security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [ ... ] +} +``` + +### Certificate Strategy + +| Field | Type | Description | +|-------|------|-------------| +| `server_certificate_strategy` | string | How the server obtains its certificate. | +| `server_certificate_custom` | string/null | PEM-encoded certificate (when strategy is `"custom"`). | +| `server_private_key_custom` | string/null | PEM-encoded private key (when strategy is `"custom"`). | + +#### Strategy Values + +| Value | Description | +|-------|-------------| +| `"auto_self_signed"` | Server generates a self-signed certificate automatically. Easiest setup. | +| `"custom"` | Use certificates provided in `server_certificate_custom` and `server_private_key_custom`. | + +### Trusted Client Certificates + +For certificate-based authentication, add trusted client certificates here: + +```json +"trusted_client_certificates": [ + { + "id": "engineer_client", + "pem": "-----BEGIN CERTIFICATE-----\nMIIE8jCCA9qg...\n-----END CERTIFICATE-----" + } +] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique identifier referenced in user configuration. | +| `pem` | string | Full PEM-encoded X.509 certificate (with newlines as `\n`). | + +--- + +## Users Configuration + +The `config.users` array defines accounts that can connect to the server. + +### Password-Based Users + +```json +{ + "type": "password", + "username": "operator", + "password_hash": "$2b$12$bb...", + "role": "operator" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | Must be `"password"` for username/password authentication. | +| `username` | string | Login username. | +| `password_hash` | string | Bcrypt-hashed password. Generate with `python -c "import bcrypt; print(bcrypt.hashpw(b'password', bcrypt.gensalt()).decode())"` | +| `role` | string | Permission role: `"viewer"`, `"operator"`, or `"engineer"`. | + +### Certificate-Based Users + +```json +{ + "type": "certificate", + "certificate_id": "engineer_client", + "role": "engineer" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | Must be `"certificate"` for X.509 authentication. | +| `certificate_id` | string | References an `id` in `trusted_client_certificates`. | +| `role` | string | Permission role: `"viewer"`, `"operator"`, or `"engineer"`. | + +### User Roles + +| Role | Description | Typical Use | +|------|-------------|-------------| +| `viewer` | Read-only access to variables with `"r"` permission | Monitoring dashboards, HMI displays | +| `operator` | Read/write access for operational variables | Machine operators, shift supervisors | +| `engineer` | Full access to all variables | System integrators, maintenance | + +--- + +## Cycle Time + +```json +"cycle_time_ms": 100 +``` + +| Field | Type | Description | +|-------|------|-------------| +| `cycle_time_ms` | integer | How often (in milliseconds) the plugin synchronizes data with the PLC. | + +**Behavior:** +- Lower values = faster updates but higher CPU usage +- Higher values = slower updates but lower resource consumption +- Recommended range: 50-500ms depending on application requirements +- This is independent of the PLC scan cycle time + +--- + +## Address Space + +The `config.address_space` section defines what PLC data is exposed to OPC-UA clients. + +```json +"address_space": { + "namespace_uri": "urn:openplc:opcua:datatype:test", + "variables": [ ... ], + "structures": [ ... ], + "arrays": [ ... ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `namespace_uri` | string | Unique URI for this address space. Clients use this to identify your variables. The namespace index is assigned automatically by the server. | +| `variables` | array | Simple scalar variables. | +| `structures` | array | Grouped variables (like PLC structs). | +| `arrays` | array | Array variables with multiple elements. | + +--- + +### Variables + +Simple scalar variables are the most common type. Each variable maps to a single PLC memory location. + +```json +{ + "node_id": "PLC.Test.simple_int", + "browse_name": "simple_int", + "display_name": "Simple Int", + "datatype": "INT", + "initial_value": 0, + "description": "16-bit signed integer test variable", + "index": 4, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `node_id` | string | Unique OPC-UA node identifier. Use dot notation for hierarchy (e.g., `PLC.Motor.Speed`). | +| `browse_name` | string | Short name for browsing the address space. Usually the last segment of `node_id`. | +| `display_name` | string | Human-readable name shown in OPC-UA clients. Can include spaces. | +| `datatype` | string | PLC/OPC-UA data type (see [Data Types Reference](#data-types-reference)). | +| `initial_value` | varies | Value used when PLC starts. Type must match `datatype`. | +| `description` | string | Documentation shown to OPC-UA clients. | +| `index` | integer | **Critical**: Maps to the PLC variable buffer index. Must be unique across all variables. | +| `permissions` | object | Access rights per role (see [Permissions Reference](#permissions-reference)). | + +#### Node ID Best Practices + +- Use hierarchical naming: `PLC.Area.Device.Variable` +- Avoid special characters except dots and underscores +- Keep names consistent with PLC program variable names + +#### Index Mapping + +The `index` field is crucial for data exchange with the PLC: + +- Each index corresponds to a position in the shared memory buffer +- Indices must be unique across variables, structures, and arrays +- Plan your index allocation to leave room for expansion +- Arrays consume consecutive indices (e.g., an 8-element array starting at index 50 uses indices 50-57) + +--- + +### Structures + +Structures group related variables together, appearing as folders in OPC-UA clients. + +```json +{ + "node_id": "PLC.Test.Structures.sensor1", + "browse_name": "sensor1", + "display_name": "Sensor 1", + "description": "Sensor data structure instance 1", + "fields": [ + { + "name": "sensor_id", + "datatype": "INT", + "initial_value": 1, + "index": 20, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "value", + "datatype": "REAL", + "initial_value": 0.0, + "index": 21, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "is_valid", + "datatype": "BOOL", + "initial_value": false, + "index": 22, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ] +} +``` + +#### Structure-Level Fields + +| Field | Type | Description | +|-------|------|-------------| +| `node_id` | string | Unique identifier for the structure node. | +| `browse_name` | string | Short name for browsing. | +| `display_name` | string | Human-readable name. | +| `description` | string | Documentation for the structure. | +| `fields` | array | Array of field definitions (each similar to a variable). | + +#### Field-Level Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Field name within the structure. | +| `datatype` | string | Data type of this field. | +| `initial_value` | varies | Initial value when PLC starts. | +| `index` | integer | PLC buffer index for this field. | +| `permissions` | object | Access rights per role. | + +**Note:** Structure fields can have different permissions, allowing some fields to be read-only while others are writable. + +--- + +### Arrays + +Arrays expose multiple values under a single variable, useful for buffers, sensor banks, or I/O modules. + +```json +{ + "node_id": "PLC.Test.Arrays.int_array", + "browse_name": "int_array", + "display_name": "Int Array", + "datatype": "INT", + "length": 5, + "initial_value": 0, + "index": 58, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `node_id` | string | Unique identifier for the array node. | +| `browse_name` | string | Short name for browsing. | +| `display_name` | string | Human-readable name. | +| `datatype` | string | Data type of each element. | +| `length` | integer | Number of elements in the array. | +| `initial_value` | varies | Initial value applied to ALL elements. | +| `index` | integer | Starting PLC buffer index. Elements use `index`, `index+1`, ..., `index+length-1`. | +| `permissions` | object | Access rights (applies to all elements). | + +**Index Allocation Example:** +An array with `"index": 58` and `"length": 5` uses indices 58, 59, 60, 61, 62. + +--- + +## Data Types Reference + +| Type | OPC-UA Type | Size | Range | Initial Value Example | +|------|-------------|------|-------|----------------------| +| `BOOL` | Boolean | 1 bit | `true` / `false` | `false` | +| `BYTE` | Byte | 8 bits | 0 to 255 | `0` | +| `INT` | Int16 | 16 bits | -32,768 to 32,767 | `0` | +| `DINT` | Int32 | 32 bits | -2,147,483,648 to 2,147,483,647 | `0` | +| `LINT` | Int64 | 64 bits | -9.2e18 to 9.2e18 | `0` | +| `REAL` | Float | 32 bits | IEEE 754 single precision | `0.0` | +| `STRING` | String | Variable | UTF-8 text | `""` | + +--- + +## Permissions Reference + +Permissions are defined per role using a simple string notation: + +| Permission | Meaning | +|------------|---------| +| `"r"` | Read-only access | +| `"rw"` | Read and write access | +| `""` or omitted | No access | + +### Example Permission Configurations + +**Status variable (read-only for everyone):** +```json +"permissions": {"viewer": "r", "operator": "r", "engineer": "r"} +``` + +**Setpoint (operators and engineers can modify):** +```json +"permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} +``` + +**Configuration parameter (engineers only):** +```json +"permissions": {"viewer": "", "operator": "r", "engineer": "rw"} +``` + +--- + +## Complete Example + +Here's a minimal but complete configuration: + +```json +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "My PLC OPC-UA Server", + "application_uri": "urn:mycompany:plc:server", + "product_uri": "urn:mycompany:plc:product", + "endpoint_url": "opc.tcp://0.0.0.0:4840/plc", + "security_profiles": [ + { + "name": "secure", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": ["Username"] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [] + }, + "users": [ + { + "type": "password", + "username": "admin", + "password_hash": "$2b$12$...", + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:mycompany:plc:variables", + "variables": [ + { + "node_id": "PLC.Motor.Speed", + "browse_name": "Speed", + "display_name": "Motor Speed", + "datatype": "REAL", + "initial_value": 0.0, + "description": "Current motor speed in RPM", + "index": 0, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + }, + { + "node_id": "PLC.Motor.Setpoint", + "browse_name": "Setpoint", + "display_name": "Speed Setpoint", + "datatype": "REAL", + "initial_value": 0.0, + "description": "Target motor speed in RPM", + "index": 1, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + ], + "structures": [], + "arrays": [] + } + } + } +] +``` + +--- + +## Troubleshooting + +### Common Issues + +1. **Client cannot connect** + - Verify `endpoint_url` is reachable from the client + - Check firewall rules for the configured port + - Ensure at least one `security_profile` is enabled + +2. **Authentication fails** + - Verify password hash is valid bcrypt format + - For certificate auth, ensure certificate is in `trusted_client_certificates` + - Check that `auth_methods` includes the method the client is using + +3. **Variables not updating** + - Verify `index` values match PLC program memory locations + - Check `cycle_time_ms` is appropriate for your update rate needs + - Ensure the PLC program is running + +4. **Permission denied errors** + - Check user's `role` matches required permissions + - Verify variable `permissions` include the user's role + - Ensure the operation (read/write) is allowed for that role diff --git a/docs/opcua/OPCUA_SECURITY_MODE_INSUFFICIENT_ANALYSIS.md b/docs/opcua/OPCUA_SECURITY_MODE_INSUFFICIENT_ANALYSIS.md new file mode 100644 index 00000000..73eca55b --- /dev/null +++ b/docs/opcua/OPCUA_SECURITY_MODE_INSUFFICIENT_ANALYSIS.md @@ -0,0 +1,146 @@ +# OPC-UA BadSecurityModeInsufficient Error Analysis + +**Date:** 2026-01-22 +**Context:** Username/password authentication over insecure (unencrypted) endpoint + +## Summary + +When using only the insecure security profile with Username authentication, OPC-UA clients display the error: + +> Error 'BadSecurityModeInsufficient' was returned during ActivateSession, press 'Ignore' to suppress the error and continue connecting. If you ignore the error it is possible that the password is being sent in clear text. + +This is **NOT a bug** - it's a security feature defined in the OPC-UA specification. + +--- + +## What's Happening + +This error is a **security feature** defined in the OPC-UA specification (Part 4, Section 7.36). + +**The error comes from the OPC-UA client** (like UAExpert), not the server. When you configure: +- Security Policy: `None` +- Security Mode: `None` +- Auth Method: `Username` (password authentication) + +The client detects that it would send the password **in plain text** over the network and warns the user. This is intentional behavior to protect against accidentally exposing credentials. + +--- + +## OPC-UA Security Architecture + +OPC-UA has **two separate security layers**: + +| Layer | Purpose | Description | +|-------|---------|-------------| +| **Channel Security** | Encrypts communication between client/server | Configured via `security_policy` and `security_mode` | +| **Token Security** | Can encrypt user credentials separately | Can have its own SecurityPolicyUri | + +When both are "None", passwords travel unencrypted over the network. + +### Security Policy Options + +| Policy | Mode | Result | +|--------|------|--------| +| `None` | `None` | No encryption (plaintext) | +| `Basic256Sha256` | `Sign` | Messages are signed (integrity) | +| `Basic256Sha256` | `SignAndEncrypt` | Full encryption (confidentiality + integrity) | + +--- + +## Configuration Options + +### Option 1: Use Encrypted Security Profile (Recommended) + +Keep the `SignAndEncrypt` profile enabled alongside the insecure one: + +```json +"security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + }, + { + "name": "SignAndEncrypt", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": ["Username", "Certificate"] + } +] +``` + +This configuration: +- Allows Anonymous access on the insecure endpoint +- Requires encryption for Username/password authentication +- Follows OPC-UA security best practices + +### Option 2: Accept the Risk (Click "Ignore") + +If you're on a trusted local network (like a lab environment), clicking "Ignore" in the OPC-UA client will: +- Send the password in plaintext +- Connection will work normally +- **Only use this in isolated/trusted networks** + +**Warning:** This exposes credentials to network sniffing attacks. + +### Option 3: Token-Level Encryption (Advanced) + +OPC-UA allows the UserIdentityToken to have its own security policy, even when channel security is "None". This means you could theoretically: +- Use `None` for channel security (no message encryption) +- Use `Basic256Sha256` for token security (password is encrypted) + +This requires additional configuration in asyncua and is not currently implemented. + +--- + +## Recommendations + +| Environment | Recommendation | +|-------------|----------------| +| **Production / Industrial** | Use Option 1 - require encryption for password auth | +| **Development / Testing** | Option 2 is acceptable on isolated networks | +| **Internet-facing** | Always use SignAndEncrypt with certificates | + +--- + +## Technical Details + +### Error Code + +- **Status Code:** `BadSecurityModeInsufficient` (0x80E60000) +- **Meaning:** "The operation is not permitted over the current secure channel" + +### Where the Check Occurs + +The security check happens during the `ActivateSession` phase: +1. Client connects to server (OpenSecureChannel) +2. Client creates session (CreateSession) +3. Client activates session with credentials (ActivateSession) - **Error occurs here** + +The client library checks if sending credentials over the current security mode is safe before transmitting. + +### asyncua Behavior + +The asyncua library includes logic to: +1. Warn when creating open endpoints alongside encrypted ones +2. Try to find an encrypting policy for password transmission +3. Log warnings when no encrypting policy is available + +From `asyncua/server/server.py`: +```python +# try to avoid plaintext password, find first policy with encryption +# ... +# No encrypting policy available, password may get transferred in plaintext +``` + +--- + +## References + +- [OPC UA Part 4: Services - 7.37 UserTokenPolicy](https://reference.opcfoundation.org/Core/Part4/v104/docs/7.37) +- [Server with Authentication (user/password) and Encryption - GitHub Discussion #934](https://github.com/FreeOpcUa/opcua-asyncio/discussions/934) +- [Server set User with Password - GitHub Discussion #1386](https://github.com/FreeOpcUa/opcua-asyncio/discussions/1386) +- [asyncua PyPI](https://pypi.org/project/asyncua/) diff --git a/docs/plans/TIME_TYPE_SUPPORT_PLAN.md b/docs/plans/TIME_TYPE_SUPPORT_PLAN.md new file mode 100644 index 00000000..20c1c708 --- /dev/null +++ b/docs/plans/TIME_TYPE_SUPPORT_PLAN.md @@ -0,0 +1,533 @@ +# Development Plan: OPC-UA TIME Type Support + +## Executive Summary + +The current OPC-UA plugin implementation does not support IEC 61131-3 TIME type variables. This document outlines the development and test plan to introduce TIME type support. + +## Current State Analysis + +### IEC 61131-3 TIME Structure (from `core/src/lib/iec_types.h`) + +```c +typedef struct { + int32_t tv_sec; // Seconds + int32_t tv_nsec; // Nanoseconds +} IEC_TIMESPEC; + +typedef IEC_TIMESPEC IEC_TIME; // Duration type +typedef IEC_TIMESPEC IEC_DATE; // Date type +typedef IEC_TIMESPEC IEC_DT; // Date and Time type +typedef IEC_TIMESPEC IEC_TOD; // Time of Day type +``` + +**Key characteristics:** +- Total size: 8 bytes +- Represents duration/time as seconds + nanoseconds +- Same underlying structure for TIME, DATE, DT, and TOD + +### Current Type Support (from `opcua_utils.py`) + +| PLC Type | OPC-UA Type | Size | +|----------|-------------|------| +| BOOL | Boolean | 1 byte | +| BYTE | Byte | 1 byte | +| INT | Int16 | 2 bytes | +| DINT/INT32 | Int32 | 4 bytes | +| LINT | Int64 | 8 bytes | +| FLOAT/REAL | Float | 4 bytes | +| STRING | String | 127 bytes | + +**Missing types:** TIME, DATE, TOD, DT, LREAL, WORD, DWORD, LWORD, UINT, UDINT, ULINT, SINT, USINT + +### Gap Analysis + +1. **Type Mapping**: `map_plc_to_opcua_type()` has no TIME mapping +2. **Memory Access**: `opcua_memory.py` reads 8-byte values as `c_uint64`, not as TIME struct +3. **Value Conversion**: `convert_value_for_opcua()` and `convert_value_for_plc()` have no TIME handling +4. **Configuration**: No TIME examples in config templates or documentation + +--- + +## Development Plan + +### Phase 1: Core Type Support + +#### Task 1.1: Define IEC_TIMESPEC ctypes Structure + +**File:** `core/src/drivers/plugins/python/opcua/opcua_memory.py` + +Add a ctypes structure matching the C definition: + +```python +class IEC_TIMESPEC(ctypes.Structure): + """ + ctypes structure matching IEC_TIMESPEC from iec_types.h. + + typedef struct { + int32_t tv_sec; // Seconds + int32_t tv_nsec; // Nanoseconds + } IEC_TIMESPEC; + """ + _fields_ = [ + ("tv_sec", ctypes.c_int32), + ("tv_nsec", ctypes.c_int32), + ] + +TIMESPEC_SIZE = 8 # sizeof(IEC_TIMESPEC) +``` + +#### Task 1.2: Add TIME Type Mapping + +**File:** `core/src/drivers/plugins/python/opcua/opcua_utils.py` + +Update `map_plc_to_opcua_type()`: + +```python +def map_plc_to_opcua_type(plc_type: str) -> ua.VariantType: + """Map plc datatype to OPC-UA VariantType.""" + type_mapping = { + # Existing types... + "BOOL": ua.VariantType.Boolean, + "BYTE": ua.VariantType.Byte, + "INT": ua.VariantType.Int16, + "INT32": ua.VariantType.Int32, + "DINT": ua.VariantType.Int32, + "LINT": ua.VariantType.Int64, + "FLOAT": ua.VariantType.Float, + "REAL": ua.VariantType.Float, + "STRING": ua.VariantType.String, + # New TIME types - represented as Int64 (milliseconds) + "TIME": ua.VariantType.Int64, + "DATE": ua.VariantType.DateTime, + "TOD": ua.VariantType.Int64, # Milliseconds since midnight + "DT": ua.VariantType.DateTime, + } + return type_mapping.get(plc_type.upper(), ua.VariantType.Variant) +``` + +**Design Decision: TIME Representation in OPC-UA** + +| Option | OPC-UA Type | Pros | Cons | +|--------|-------------|------|------| +| A. Int64 (ms) | Int64 | Simple, standard duration format | Loss of nanosecond precision | +| B. Double (seconds) | Double | Good precision, human readable | Floating point quirks | +| C. Custom Struct | ExtensionObject | Full precision preserved | Complex, non-standard | + +**Recommendation:** Option A (Int64 milliseconds) for TIME/TOD types, and DateTime for DATE/DT types. + +#### Task 1.3: Implement TIME Conversion Functions + +**File:** `core/src/drivers/plugins/python/opcua/opcua_utils.py` + +```python +def timespec_to_milliseconds(tv_sec: int, tv_nsec: int) -> int: + """Convert IEC_TIMESPEC to milliseconds.""" + return (tv_sec * 1000) + (tv_nsec // 1_000_000) + +def milliseconds_to_timespec(ms: int) -> tuple[int, int]: + """Convert milliseconds to (tv_sec, tv_nsec) tuple.""" + tv_sec = ms // 1000 + tv_nsec = (ms % 1000) * 1_000_000 + return (tv_sec, tv_nsec) +``` + +Update `convert_value_for_opcua()`: + +```python +elif datatype.upper() == "TIME": + # TIME values are stored as IEC_TIMESPEC (tv_sec, tv_nsec) + # Convert to milliseconds for OPC-UA Int64 representation + if isinstance(value, tuple) and len(value) == 2: + tv_sec, tv_nsec = value + return timespec_to_milliseconds(tv_sec, tv_nsec) + elif isinstance(value, int): + # Already in raw format, interpret as packed 64-bit value + tv_sec = value & 0xFFFFFFFF + tv_nsec = (value >> 32) & 0xFFFFFFFF + return timespec_to_milliseconds(tv_sec, tv_nsec) + return 0 +``` + +Update `convert_value_for_plc()`: + +```python +elif datatype.upper() == "TIME": + # Convert OPC-UA milliseconds to IEC_TIMESPEC format + ms = int(value) + tv_sec, tv_nsec = milliseconds_to_timespec(ms) + # Return as tuple for memory writing + return (tv_sec, tv_nsec) +``` + +#### Task 1.4: Implement TIME Memory Read/Write + +**File:** `core/src/drivers/plugins/python/opcua/opcua_memory.py` + +```python +def read_timespec_direct(address: int) -> tuple[int, int]: + """ + Read an IEC_TIMESPEC directly from memory. + + Returns: + Tuple of (tv_sec, tv_nsec) + """ + ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) + timespec = ptr.contents + return (timespec.tv_sec, timespec.tv_nsec) + +def write_timespec_direct(address: int, tv_sec: int, tv_nsec: int) -> bool: + """ + Write an IEC_TIMESPEC to memory. + """ + ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) + ptr.contents.tv_sec = tv_sec + ptr.contents.tv_nsec = tv_nsec + return True +``` + +Update `read_memory_direct()` to handle TIME size: + +```python +def read_memory_direct(address: int, size: int, datatype: str = None) -> Any: + """Read value from memory with optional datatype hint.""" + # ... existing code ... + elif size == 8: + if datatype and datatype.upper() in ["TIME", "DATE", "TOD", "DT"]: + return read_timespec_direct(address) + else: + ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) + return ptr.contents.value +``` + +### Phase 2: Synchronization Integration + +#### Task 2.1: Update Address Space Creation + +**File:** `core/src/drivers/plugins/python/opcua/address_space.py` + +Ensure TIME variables are created with proper OPC-UA type and initial value conversion. + +#### Task 2.2: Update Synchronization Logic + +**File:** `core/src/drivers/plugins/python/opcua/synchronization.py` + +Modify the sync functions to pass datatype information for proper TIME handling: + +- `_sync_single_var_from_runtime()`: Pass datatype to memory read +- `_sync_single_var_to_runtime()`: Handle TIME tuple values for memory write + +### Phase 3: Configuration and Validation + +#### Task 3.1: Update Configuration Model + +**File:** `core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py` + +Add validation for TIME datatype: + +```python +VALID_DATATYPES = ["BOOL", "BYTE", "INT", "DINT", "LINT", "FLOAT", "REAL", + "STRING", "TIME", "DATE", "TOD", "DT"] +``` + +#### Task 3.2: Update Type Inference + +**File:** `core/src/drivers/plugins/python/opcua/opcua_utils.py` + +Update `infer_var_type()` to better handle ambiguous sizes when datatype is known: + +```python +def infer_var_type(size: int, configured_type: str = None) -> str: + """Infer variable type from size and optional configured type.""" + if configured_type: + return configured_type.upper() + # ... existing inference logic ... +``` + +#### Task 3.3: Update Configuration Templates + +**File:** `core/src/drivers/plugins/python/opcua/opcua_config_template.json` + +Add TIME variable examples: + +```json +{ + "node_id": "ns=2;s=CycleTime", + "browse_name": "CycleTime", + "display_name": "Cycle Time", + "datatype": "TIME", + "initial_value": 0, + "description": "PLC scan cycle time", + "index": 10, + "permissions": { + "viewer": "r", + "operator": "r", + "engineer": "rw" + } +} +``` + +--- + +## Test Plan + +### Unit Tests + +#### Test Suite 1: Type Conversion (test_time_conversion.py) + +```python +class TestTimeConversion: + def test_timespec_to_milliseconds_basic(self): + """Test basic conversion: 1 second = 1000 ms""" + assert timespec_to_milliseconds(1, 0) == 1000 + + def test_timespec_to_milliseconds_with_nanoseconds(self): + """Test conversion with nanoseconds: 1.5 sec = 1500 ms""" + assert timespec_to_milliseconds(1, 500_000_000) == 1500 + + def test_milliseconds_to_timespec_basic(self): + """Test reverse conversion""" + assert milliseconds_to_timespec(1500) == (1, 500_000_000) + + def test_roundtrip_conversion(self): + """Test roundtrip preserves value""" + original = (5, 250_000_000) + ms = timespec_to_milliseconds(*original) + result = milliseconds_to_timespec(ms) + assert result == original + + def test_zero_time(self): + """Test zero value handling""" + assert timespec_to_milliseconds(0, 0) == 0 + assert milliseconds_to_timespec(0) == (0, 0) + + def test_large_time_values(self): + """Test large values (hours/days)""" + # 24 hours in seconds = 86400 + ms = timespec_to_milliseconds(86400, 0) + assert ms == 86_400_000 +``` + +#### Test Suite 2: Type Mapping (test_time_mapping.py) + +```python +class TestTimeTypeMapping: + def test_time_maps_to_int64(self): + """TIME should map to Int64""" + assert map_plc_to_opcua_type("TIME") == ua.VariantType.Int64 + + def test_time_case_insensitive(self): + """Mapping should be case-insensitive""" + assert map_plc_to_opcua_type("time") == ua.VariantType.Int64 + assert map_plc_to_opcua_type("Time") == ua.VariantType.Int64 + + def test_date_maps_to_datetime(self): + """DATE should map to DateTime""" + assert map_plc_to_opcua_type("DATE") == ua.VariantType.DateTime +``` + +#### Test Suite 3: Memory Access (test_time_memory.py) + +```python +class TestTimeMemoryAccess: + def test_read_timespec_structure(self): + """Test reading IEC_TIMESPEC from memory""" + # Create test memory with known values + test_struct = IEC_TIMESPEC() + test_struct.tv_sec = 10 + test_struct.tv_nsec = 500_000_000 + + address = ctypes.addressof(test_struct) + result = read_timespec_direct(address) + assert result == (10, 500_000_000) + + def test_write_timespec_structure(self): + """Test writing IEC_TIMESPEC to memory""" + test_struct = IEC_TIMESPEC() + address = ctypes.addressof(test_struct) + + write_timespec_direct(address, 5, 250_000_000) + + assert test_struct.tv_sec == 5 + assert test_struct.tv_nsec == 250_000_000 +``` + +### Integration Tests + +#### Test Suite 4: End-to-End TIME Variable Sync (test_time_sync_integration.py) + +```python +class TestTimeVariableSync: + @pytest.fixture + def time_variable_config(self): + """Configuration with TIME variable""" + return { + "node_id": "ns=2;s=TestTime", + "browse_name": "TestTime", + "display_name": "Test Time Variable", + "datatype": "TIME", + "initial_value": 0, + "description": "Test TIME variable", + "index": 100, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + + async def test_time_variable_created_in_address_space(self, server, config): + """Verify TIME variable is created with correct OPC-UA type""" + # Create variable + # Check node exists and has Int64 data type + pass + + async def test_time_value_sync_plc_to_opcua(self, server, config): + """Test syncing TIME value from PLC to OPC-UA""" + # Set PLC memory to specific TIME value + # Trigger sync + # Verify OPC-UA node has correct milliseconds value + pass + + async def test_time_value_sync_opcua_to_plc(self, server, config): + """Test syncing TIME value from OPC-UA to PLC""" + # Write milliseconds value to OPC-UA node + # Trigger sync + # Verify PLC memory has correct tv_sec/tv_nsec + pass +``` + +#### Test Suite 5: Configuration Validation (test_time_config_validation.py) + +```python +class TestTimeConfigValidation: + def test_time_datatype_accepted(self): + """TIME datatype should be valid in config""" + config = {"datatype": "TIME", ...} + # Should not raise + SimpleVariable.from_dict(config) + + def test_time_initial_value_formats(self): + """Various initial value formats for TIME""" + # Integer milliseconds + config1 = {"datatype": "TIME", "initial_value": 5000, ...} + # String format "T#5s" + config2 = {"datatype": "TIME", "initial_value": "T#5s", ...} +``` + +### System Tests + +#### Test Suite 6: OPC-UA Client Interaction (test_time_client.py) + +```python +class TestTimeWithOpcuaClient: + async def test_read_time_value_with_uaexpert(self): + """Verify TIME value can be read by standard OPC-UA client""" + # Start server with TIME variable + # Connect with asyncua client + # Read value, verify it's Int64 type with correct value + pass + + async def test_write_time_value_with_uaexpert(self): + """Verify TIME value can be written by standard OPC-UA client""" + # Write Int64 value representing milliseconds + # Verify PLC memory updated correctly + pass + + async def test_time_subscription_updates(self): + """Verify TIME variable changes trigger subscriptions""" + # Subscribe to TIME node + # Change PLC value + # Verify subscription callback received + pass +``` + +### Performance Tests + +#### Test Suite 7: TIME Sync Performance (test_time_performance.py) + +```python +class TestTimePerformance: + def test_time_conversion_performance(self): + """Conversion should be fast""" + import timeit + time_taken = timeit.timeit( + lambda: timespec_to_milliseconds(12345, 678_000_000), + number=100_000 + ) + assert time_taken < 1.0 # 100k conversions under 1 second + + def test_time_sync_latency(self): + """Measure sync latency for TIME variables""" + # Time the complete sync cycle + pass +``` + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `core/src/drivers/plugins/python/opcua/opcua_utils.py` | Add TIME mapping, conversion functions | +| `core/src/drivers/plugins/python/opcua/opcua_memory.py` | Add IEC_TIMESPEC struct, read/write functions | +| `core/src/drivers/plugins/python/opcua/address_space.py` | Handle TIME in variable creation | +| `core/src/drivers/plugins/python/opcua/synchronization.py` | Pass datatype for TIME handling | +| `core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py` | Add TIME validation | +| `core/src/drivers/plugins/python/opcua/opcua_config_template.json` | Add TIME examples | +| `core/src/drivers/plugins/python/opcua/docs/` | Update documentation | + +## New Files to Create + +| File | Purpose | +|------|---------| +| `tests/plugins/opcua/test_time_conversion.py` | Unit tests for conversion | +| `tests/plugins/opcua/test_time_mapping.py` | Unit tests for type mapping | +| `tests/plugins/opcua/test_time_memory.py` | Unit tests for memory access | +| `tests/plugins/opcua/test_time_sync_integration.py` | Integration tests | + +--- + +## Implementation Priority + +1. **High Priority (Core Functionality)** + - Task 1.1: IEC_TIMESPEC ctypes structure + - Task 1.2: Type mapping + - Task 1.3: Conversion functions + - Task 1.4: Memory read/write + +2. **Medium Priority (Integration)** + - Task 2.1: Address space creation + - Task 2.2: Synchronization logic + +3. **Lower Priority (Polish)** + - Task 3.1: Configuration validation + - Task 3.2: Type inference update + - Task 3.3: Template and documentation updates + +--- + +## Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Precision loss (ns to ms) | Low | Document limitation; sufficient for most PLC applications | +| Breaking existing configs | Medium | TIME is opt-in via explicit datatype | +| Memory alignment issues | High | Use ctypes Structure with matching C layout | +| OPC-UA client compatibility | Medium | Use standard Int64 type; test with multiple clients | + +--- + +## Acceptance Criteria + +1. TIME variables can be configured in opcua.json +2. TIME values sync correctly PLC -> OPC-UA (ms representation) +3. TIME values sync correctly OPC-UA -> PLC (timespec structure) +4. Standard OPC-UA clients can read/write TIME values +5. All unit and integration tests pass +6. No regression in existing type support +7. Documentation updated with TIME examples + +--- + +## Future Considerations + +- **LTIME support**: IEC 61131-3 LTIME (64-bit time) may use different structure +- **DATE/DT/TOD types**: Can use same IEC_TIMESPEC structure with DateTime mapping +- **LREAL support**: Similar pattern (8-byte, needs struct unpacking) +- **Array of TIME**: Extend array support to handle TIME arrays diff --git a/docs/pr-reviews/PR_REVIEW_CHECKLIST.md b/docs/pr-reviews/PR_REVIEW_CHECKLIST.md new file mode 100644 index 00000000..679ac7e1 --- /dev/null +++ b/docs/pr-reviews/PR_REVIEW_CHECKLIST.md @@ -0,0 +1,574 @@ +# Pull Request Review Checklist + +This document standardizes the review process for OpenPLC Runtime pull requests. Use this checklist to ensure code quality, prevent technical debt, and avoid runtime errors. + +## Quick Checklist + +Before approving any PR, verify: + +- [ ] Pre-commit hooks pass (`pre-commit run --all-files`) +- [ ] All tests pass (`pytest tests/`) +- [ ] No compiler warnings with strict flags +- [ ] Memory management is correct (no leaks) +- [ ] Thread safety verified (mutex usage correct) +- [ ] Security considerations addressed +- [ ] Platform compatibility maintained + +--- + +## 1. Code Style and Formatting + +### C/C++ Code +- [ ] 4-space indentation, no tabs +- [ ] 100-character line limit +- [ ] `snake_case` for functions and variables +- [ ] `snake_case_t` for type definitions +- [ ] `UPPER_CASE` for macros and constants +- [ ] Allman brace style for functions +- [ ] Clang-Format validates: `clang-format --style=file --dry-run -Werror *.c *.h` + +### Python Code +- [ ] Black formatter passes +- [ ] isort import ordering correct +- [ ] Ruff linter passes +- [ ] Type hints on function signatures +- [ ] 100-character line limit +- [ ] Double quotes for strings + +### General +- [ ] No emojis in code, comments, or documentation +- [ ] No trailing whitespace +- [ ] Files end with newline +- [ ] No files larger than 500KB + +--- + +## 2. Architecture and Design + +### Dual-Process Architecture +- [ ] Changes respect process boundaries (Python REST API vs C/C++ Runtime) +- [ ] IPC protocol compatibility maintained (`/run/runtime/plc_runtime.socket`) +- [ ] Socket message format unchanged or versioned properly +- [ ] Log socket protocol compatible (`/run/runtime/log_runtime.socket`) + +### State Machine Integrity +``` +EMPTY -> INIT -> RUNNING <-> STOPPED -> ERROR +``` +- [ ] State transitions are atomic (mutex held) +- [ ] No invalid state transitions introduced +- [ ] State changes logged appropriately +- [ ] Error states handled with recovery path + +### Plugin System +- [ ] Plugin interface contract maintained (`init`, `start_loop`, `stop_loop`, `cycle_start`, `cycle_end`, `cleanup`) +- [ ] `plugins.conf` format compatible +- [ ] Dynamic loading error handling present (`dlopen`/`dlsym` checks) +- [ ] Plugin cleanup called on errors +- [ ] No resource leaks when plugins fail to load + +--- + +## 3. Memory Management + +### C/C++ Memory +- [ ] Every `malloc()`/`calloc()` has corresponding `free()` +- [ ] Memory freed in error paths (early returns) +- [ ] Pointers set to `NULL` after `free()` +- [ ] `calloc()` preferred over `malloc()` (zeroed memory) +- [ ] No buffer overflows: `strncpy()`, `snprintf()` used with correct sizes +- [ ] String buffers null-terminated after `strncpy()` + +### Dynamic Loading +- [ ] `dlopen()` result checked for NULL +- [ ] `dlsym()` errors handled +- [ ] `dlclose()` called on cleanup +- [ ] Error messages include `dlerror()` + +### Python Memory +- [ ] Context managers (`with`) used for files/sockets +- [ ] Large buffers explicitly cleaned up +- [ ] No circular references preventing GC +- [ ] Thread-safe data structures where needed + +--- + +## 4. Thread Safety and Concurrency + +### Mutex Usage +- [ ] Lock/unlock pairs are symmetric (no double-lock) +- [ ] No potential deadlocks (consistent lock ordering) +- [ ] Priority inheritance used for real-time mutexes on Linux +- [ ] Graceful fallback for non-Linux platforms + +### Critical Sections +- [ ] `state_mutex` held during PLC state changes +- [ ] `buffer_mutex` held during image table access +- [ ] Minimal time spent holding locks +- [ ] No blocking operations while holding mutex + +### Thread Lifecycle +- [ ] Threads properly joined on shutdown +- [ ] No thread leaks (count remains deterministic) +- [ ] Thread-local storage cleaned up +- [ ] Signal handlers are async-signal-safe + +### Real-Time Considerations (Linux) +- [ ] `SCHED_FIFO` scheduling preserved for PLC thread +- [ ] `mlockall()` called to prevent page faults +- [ ] No dynamic memory allocation in scan cycle +- [ ] Deterministic timing maintained + +--- + +## 5. Error Handling + +### C Error Patterns +- [ ] Return codes checked (0 = success, -1 = failure) +- [ ] `log_error()` called with context on failures +- [ ] No uninitialized variables +- [ ] All heap allocations checked for NULL +- [ ] Error paths clean up resources + +### Python Error Patterns +- [ ] Specific exceptions caught (not bare `except:`) +- [ ] Exceptions logged with context: `logger.error("msg", exc_info=True)` +- [ ] JSON parsing uses `json.JSONDecodeError` +- [ ] Socket errors caught: `socket.error`, `OSError` +- [ ] No swallowing exceptions without logging + +### Graceful Degradation +- [ ] Platform-specific features have fallbacks +- [ ] Missing optional dependencies handled +- [ ] Network timeouts don't crash the system +- [ ] Plugin failures don't crash the runtime + +--- + +## 6. Security + +### Input Validation +- [ ] All user input validated before use +- [ ] Buffer bounds checked before access +- [ ] Socket commands validated against whitelist +- [ ] Debug frame sizes checked: `MAX_DEBUG_FRAME - 7` +- [ ] Variable indices bounds-checked + +### File Operations +- [ ] Path traversal prevented (validate against base directory) +- [ ] Disallowed extensions rejected: `.exe`, `.dll`, `.sh`, `.bat`, `.js`, `.vbs`, `.scr` +- [ ] ZIP extraction uses `safe_extract()` +- [ ] Compression ratio checked (zip bomb prevention) +- [ ] File size limits enforced (10MB per file, 50MB total) + +### Authentication +- [ ] Protected endpoints use `@jwt_required()` +- [ ] Token expiration handled +- [ ] Tokens blacklisted on logout +- [ ] No secrets in version control + +### Password Handling +- [ ] PBKDF2-SHA256 with 600,000 iterations +- [ ] Cryptographic pepper applied +- [ ] Passwords never logged +- [ ] Constant-time comparison used + +### Network Security +- [ ] Hostname validation prevents injection +- [ ] IP addresses parsed via standard library +- [ ] TLS certificates validated (or self-signed with warning) +- [ ] Socket permissions restrict access + +### Plugin Security +- [ ] Plugins use journal API (not direct buffer access) +- [ ] Plugin configuration validated +- [ ] No arbitrary code execution paths + +--- + +## 7. Performance + +### Scan Cycle +- [ ] No regressions in cycle timing (~50ms default) +- [ ] No blocking I/O in scan cycle thread +- [ ] Journal buffer entries within limit (1024 max) +- [ ] Mutex contention minimized + +### Memory +- [ ] No unnecessary allocations in hot paths +- [ ] Buffer sizes appropriate +- [ ] Circular log buffer size reasonable (2MB) + +### Network +- [ ] Socket timeouts appropriate (1.0s default) +- [ ] WebSocket debug overhead acceptable +- [ ] No busy-waiting loops + +--- + +## 8. Testing + +### Test Coverage +- [ ] New features have tests +- [ ] Bug fixes include regression tests +- [ ] Edge cases tested +- [ ] Error paths tested + +### Test Quality +- [ ] Tests use proper mocking (`@patch`) +- [ ] Fixtures clean up state (`reset_globals()`) +- [ ] No test interdependencies +- [ ] Tests are deterministic + +### Running Tests +```bash +sudo bash scripts/setup-tests-env.sh +pytest tests/ +``` + +--- + +## 9. Build System + +### CMake +- [ ] New source files added to `CMakeLists.txt` +- [ ] Include paths correct +- [ ] Link dependencies specified +- [ ] Compiles without warnings: `-Wall -Werror -Wextra` + +### Compiler Flags +Required flags preserved: +``` +-Wall -Werror -Wextra -fstack-protector-strong +-D_FORTIFY_SOURCE=2 -O2 -Werror=format-security -fPIC -fPIE +``` + +### CI/CD +- [ ] GitHub workflows pass +- [ ] Docker image builds for all platforms (amd64, arm64, arm/v7) +- [ ] Pre-commit hooks configured + +--- + +## 10. Platform Compatibility + +### Linux (Full Support) +- [ ] Real-time scheduling works (`SCHED_FIFO`) +- [ ] Memory locking works (`mlockall`) +- [ ] Priority inheritance enabled + +### Windows/Cygwin/MSYS2 (Graceful Fallback) +- [ ] Compiles without real-time features +- [ ] Warning suppression for Python header conflicts +- [ ] No priority inheritance (falls back to regular mutex) + +### Docker +- [ ] Capabilities documented: `--cap-add=SYS_NICE --cap-add=SYS_RESOURCE` +- [ ] Multi-arch build works + +### ARM (arm64, arm/v7) +- [ ] Cross-compilation works +- [ ] No x86-specific code + +--- + +## 11. Documentation + +### Code Documentation +- [ ] Complex logic has comments explaining "why" +- [ ] Public APIs have docstrings +- [ ] Magic numbers explained or named + +### Project Documentation +- [ ] `CLAUDE.md` updated if architecture changes +- [ ] `README.md` updated for user-facing changes +- [ ] API changes documented + +--- + +## 12. Backward Compatibility + +### Protocol Compatibility +- [ ] Unix socket command protocol unchanged +- [ ] WebSocket debug protocol unchanged +- [ ] REST API endpoints backward compatible + +### Configuration +- [ ] `plugins.conf` format unchanged +- [ ] Environment variables unchanged +- [ ] Database schema migrations provided if needed + +### Plugin API +- [ ] Plugin function signatures unchanged +- [ ] Image table access patterns unchanged +- [ ] Journal API unchanged + +--- + +## Review Categories by Change Type + +### Bug Fixes +Focus on: +- Root cause identified +- Fix addresses root cause (not symptoms) +- Regression test added +- No side effects introduced + +### New Features +Focus on: +- Architecture fits existing patterns +- Error handling comprehensive +- Tests cover happy path and edge cases +- Documentation updated + +### Refactoring +Focus on: +- Behavior unchanged (tests pass) +- No performance regression +- Code cleaner/more maintainable +- No unnecessary changes bundled + +### Security Fixes +Focus on: +- Vulnerability fully addressed +- No new attack vectors +- Regression test prevents reintroduction +- Coordinated disclosure if needed + +### Performance Improvements +Focus on: +- Benchmark results provided +- No correctness regressions +- Edge cases still handled +- Memory usage acceptable + +--- + +## Common Issues to Watch For + +### Memory Leaks +```c +// BAD: Leak on error +char *buf = malloc(size); +if (condition) { + return -1; // buf leaked +} + +// GOOD: Free before return +char *buf = malloc(size); +if (condition) { + free(buf); + return -1; +} +``` + +### Race Conditions +```c +// BAD: Check-then-act race +if (state == RUNNING) { + // Another thread could change state here + do_something(); +} + +// GOOD: Hold mutex +pthread_mutex_lock(&state_mutex); +if (state == RUNNING) { + do_something(); +} +pthread_mutex_unlock(&state_mutex); +``` + +### Buffer Overflows +```c +// BAD: No bounds check +strcpy(dest, src); + +// GOOD: Bounded copy +strncpy(dest, src, sizeof(dest) - 1); +dest[sizeof(dest) - 1] = '\0'; +``` + +### Exception Swallowing +```python +# BAD: Silent failure +try: + do_something() +except Exception: + pass + +# GOOD: Log the error +try: + do_something() +except SpecificError as e: + logger.error("Operation failed: %s", e) + raise +``` + +### Path Traversal +```python +# BAD: Trusts user input +path = os.path.join(base_dir, user_input) + +# GOOD: Validate path +path = os.path.join(base_dir, user_input) +if not os.path.realpath(path).startswith(os.path.realpath(base_dir)): + raise ValueError("Invalid path") +``` + +--- + +## Technical Debt Indicators + +Watch for these patterns that indicate growing technical debt: + +1. **Copy-pasted code** - Should be refactored to shared function +2. **Magic numbers** - Should be named constants +3. **TODO/FIXME comments** - Should have associated issues +4. **Disabled tests** - Should be fixed or removed +5. **Suppressed warnings** - Should be investigated +6. **Platform-specific #ifdefs proliferating** - Consider abstraction layer +7. **Growing function length** - Should be split +8. **Deep nesting** - Should be flattened +9. **Tight coupling** - Should use interfaces/callbacks +10. **Missing error handling** - Should be added + +--- + +## File Reference + +| Component | Location | +|-----------|----------| +| PLC Runtime Core | `core/src/plc_app/` | +| REST API Server | `webserver/` | +| Plugin System | `core/src/drivers/` | +| Build Configuration | `CMakeLists.txt` | +| Code Style (C) | `.clang-format` | +| Code Style (Python) | `pyproject.toml` | +| Pre-commit Config | `.pre-commit-config.yaml` | +| Tests | `tests/pytest/` | +| Documentation | `docs/` | +| CI/CD Workflows | `.github/workflows/` | + +--- + +## Post-Review Actions + +After completing the review, always communicate findings directly on the PR. + +### Step 1: Create Review Document + +Save detailed review to `docs/pr-reviews/PR__REVIEW.md`: + +```markdown +# PR # Review: + +**Reviewer:** +**Date:** +**Author:** @ + +## Summary + + +## Quick Checklist +| Check | Status | Notes | +|-------|--------|-------| +| Pre-commit hooks pass | :white_check_mark: / :x: | ... | +... + +## Issues Found +### Critical +### Major +### Minor + +## Final Assessment +:white_check_mark: **APPROVE** / :x: **REQUEST CHANGES** +``` + +### Step 2: Post Summary Comment on PR + +Add an overall review comment: + +```bash +gh pr review --comment --body "## PR Review Summary + + + +**Overall:** :white_check_mark: **APPROVED** / :x: **CHANGES REQUESTED** + +See full review: \`docs/pr-reviews/PR__REVIEW.md\`" +``` + +### Step 3: Post Issues as PR Comment + +**Always** add a separate comment listing all issues found with file locations and suggestions: + +```bash +gh pr comment --body "### Issues Found () + +--- + +**1. ** +- **File:** \`path/to/file.py:\` +- **Issue:** +- **Suggestion:** +\`\`\`python + +\`\`\` + +--- + +**2. ** +... + +--- + +Full review: \`docs/pr-reviews/PR__REVIEW.md\`" +``` + +### Comment Format Guidelines + +1. **Be specific**: Always include file path and line number +2. **Be constructive**: Provide code suggestions when possible +3. **Categorize severity**: Critical, Major, Minor, or Suggestion +4. **Indicate blocking status**: Clearly state if issues block merge +5. **Reference documentation**: Link to the full review document + +### Example Comment Structure + +```markdown +### Minor Issues Found (Non-blocking) + +These are suggestions for future improvement - not blocking merge. + +--- + +**1. Duplicate constant definition** +- **File:** `core/src/module.py:22` +- **Issue:** `CONSTANT` is also defined in `other_module.py:54` +- **Suggestion:** Import from a single location: +\`\`\`python +from .other_module import CONSTANT +\`\`\` + +--- + +**2. Type hint could be more precise** +- **File:** `core/src/utils.py:199` +- **Function:** `some_function` +- **Suggestion:** Use `tuple[int, int]` instead of `tuple`: +\`\`\`python +def some_function(arg: int) -> tuple[int, int]: +\`\`\` + +--- + +Full review: `docs/pr-reviews/PR_123_REVIEW.md` +``` + +### Why Post Comments on PR? + +1. **Visibility**: Authors see feedback immediately in GitHub notifications +2. **Traceability**: Comments are linked to the PR permanently +3. **Discussion**: Authors can reply and discuss specific issues +4. **History**: Future reviewers can see past feedback patterns +5. **Accountability**: Clear record of what was reviewed and approved diff --git a/install.sh b/install.sh index e2c3b043..c969f900 100755 --- a/install.sh +++ b/install.sh @@ -246,6 +246,10 @@ install_deps_msys2() { # Update package database (but don't do full system upgrade to avoid breaking frozen bundles) pacman -Sy --noconfirm # Install required packages + # Note: python-cryptography is installed via pacman because pip cannot build + # Rust-based packages on MSYS2/Cygwin. + # Plugin venvs use --system-site-packages to access these pre-built packages. + # bcrypt is skipped on MSYS2 - the OPC-UA plugin uses PBKDF2 fallback (Python stdlib). pacman -S --noconfirm --needed \ base-devel \ gcc \ @@ -255,6 +259,7 @@ install_deps_msys2() { python \ python-pip \ python-setuptools \ + python-cryptography \ git \ sqlite3 } diff --git a/plugins.conf b/plugins.conf index dae07e3f..9fbc01c2 100644 --- a/plugins.conf +++ b/plugins.conf @@ -1,3 +1,4 @@ modbus_slave,./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py,0,0,./core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json,./venvs/modbus_slave modbus_master,./core/src/drivers/plugins/python/modbus_master/modbus_master_plugin.py,0,0,./core/src/drivers/plugins/python/modbus_master/modbus_master.json,./venvs/modbus_master +opcua,./core/src/drivers/plugins/python/opcua/plugin.py,1,0,./core/src/drivers/plugins/python/opcua/opcua.json,./venvs/opcua s7comm,./build/plugins/libs7comm_plugin.so,0,1,./core/src/drivers/plugins/native/s7comm/s7comm_config.json, diff --git a/scripts/manage_plugin_venvs.sh b/scripts/manage_plugin_venvs.sh index f1d65669..c1ce59fc 100755 --- a/scripts/manage_plugin_venvs.sh +++ b/scripts/manage_plugin_venvs.sh @@ -9,6 +9,18 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" VENVS_DIR="$PROJECT_ROOT/venvs" PLUGINS_DIR="$PROJECT_ROOT/core/src/drivers/plugins/python" +# Detect if running on MSYS2/MinGW/Cygwin (Windows) +is_msys2() { + case "$(uname -s)" in + MSYS*|MINGW*|CYGWIN*) + return 0 + ;; + *) + return 1 + ;; + esac +} + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -78,8 +90,15 @@ create_plugin_venv() { fi # Create virtual environment + # On MSYS2/Cygwin, use --system-site-packages to access pre-built packages like + # cryptography that cannot be built from source on this platform log_info "Creating Python virtual environment at: $venv_path" - python3 -m venv "$venv_path" + if is_msys2; then + log_info "MSYS2 detected: using --system-site-packages for pre-built package access" + python3 -m venv --system-site-packages "$venv_path" + else + python3 -m venv "$venv_path" + fi # Upgrade pip log_info "Upgrading pip..." diff --git a/tests/pytest/plugins/__init__.py b/tests/pytest/plugins/__init__.py new file mode 100644 index 00000000..1c175459 --- /dev/null +++ b/tests/pytest/plugins/__init__.py @@ -0,0 +1,5 @@ +""" +Plugin tests package. + +Contains tests for OpenPLC Runtime plugins. +""" diff --git a/tests/pytest/plugins/opcua/__init__.py b/tests/pytest/plugins/opcua/__init__.py new file mode 100644 index 00000000..e5e2f0e8 --- /dev/null +++ b/tests/pytest/plugins/opcua/__init__.py @@ -0,0 +1,7 @@ +""" +OPC-UA plugin test package. + +Contains tests for: +- Type conversion functions (test_type_conversions.py) +- Data type handling (test_data_types.py) +""" diff --git a/tests/pytest/plugins/opcua/conftest.py b/tests/pytest/plugins/opcua/conftest.py new file mode 100644 index 00000000..45be37bc --- /dev/null +++ b/tests/pytest/plugins/opcua/conftest.py @@ -0,0 +1,400 @@ +""" +Pytest fixtures for OPC-UA plugin tests. + +Provides: +- Mock SafeBufferAccess for simulating PLC memory +- Configuration loading fixtures +- OPC-UA server/client fixtures for integration tests +""" + +import pytest +import asyncio +import json +import os +import sys +import threading +from pathlib import Path +from typing import Dict, Any, List +from unittest.mock import MagicMock, patch + +# Add plugin paths for imports +_test_dir = Path(__file__).parent +_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +_opcua_dir = _plugin_dir / "opcua" +_shared_dir = _plugin_dir / "shared" + +sys.path.insert(0, str(_plugin_dir)) +sys.path.insert(0, str(_opcua_dir)) +sys.path.insert(0, str(_shared_dir)) + + +# Test configuration path +TEST_CONFIG_PATH = _test_dir / "test_project" / "opcua_datatype_test.json" + + +class MockDebugVariable: + """Represents a single debug variable in mock PLC memory.""" + + def __init__(self, index: int, datatype: str, initial_value: Any = None): + self.index = index + self.datatype = datatype.upper() + self._value = initial_value if initial_value is not None else self._default_value() + + def _default_value(self) -> Any: + """Get default value based on datatype.""" + defaults = { + "BOOL": False, + "BYTE": 0, + "INT": 0, + "DINT": 0, + "INT32": 0, + "LINT": 0, + "REAL": 0.0, + "FLOAT": 0.0, + "STRING": "", + } + return defaults.get(self.datatype, 0) + + @property + def value(self) -> Any: + return self._value + + @value.setter + def value(self, val: Any): + self._value = val + + +class MockSafeBufferAccess: + """ + Mock SafeBufferAccess that simulates PLC debug variable memory. + + Provides the same interface as the real SafeBufferAccess for testing + OPC-UA synchronization without a running PLC. + """ + + def __init__(self, config_path: str = None): + self.is_valid = True + self.error_msg = "" + self._lock = threading.RLock() + self._variables: Dict[int, MockDebugVariable] = {} + self._config_path = config_path or str(TEST_CONFIG_PATH) + + # Initialize variables from config if available + if config_path and os.path.exists(config_path): + self._load_variables_from_config(config_path) + + def _load_variables_from_config(self, config_path: str): + """Load variable definitions from opcua.json config.""" + try: + with open(config_path, 'r') as f: + config = json.load(f) + + if isinstance(config, list) and len(config) > 0: + address_space = config[0].get("config", {}).get("address_space", {}) + + # Load simple variables + for var in address_space.get("variables", []): + idx = var.get("index", -1) + if idx >= 0: + self._variables[idx] = MockDebugVariable( + index=idx, + datatype=var.get("datatype", "INT"), + initial_value=var.get("initial_value") + ) + + # Load structure fields + for struct in address_space.get("structures", []): + for field in struct.get("fields", []): + idx = field.get("index", -1) + if idx >= 0: + self._variables[idx] = MockDebugVariable( + index=idx, + datatype=field.get("datatype", "INT"), + initial_value=field.get("initial_value") + ) + + # Load array variables (single index per array in this model) + for arr in address_space.get("arrays", []): + idx = arr.get("index", -1) + if idx >= 0: + self._variables[idx] = MockDebugVariable( + index=idx, + datatype=arr.get("datatype", "INT"), + initial_value=arr.get("initial_value") + ) + except Exception as e: + print(f"Warning: Failed to load config: {e}") + + def get_config_path(self) -> str: + """Return path to opcua.json configuration.""" + return self._config_path + + def acquire_mutex(self): + """Acquire lock for thread-safe access.""" + self._lock.acquire() + + def release_mutex(self): + """Release lock.""" + try: + self._lock.release() + except RuntimeError: + pass + + def lock(self): + """Alias for acquire_mutex.""" + self.acquire_mutex() + + def unlock(self): + """Alias for release_mutex.""" + self.release_mutex() + + def validate_pointers(self) -> tuple: + """Validate that buffer pointers are valid.""" + return (True, "") + + def get_var_value(self, index: int) -> tuple: + """ + Get value of a debug variable by index. + + Returns: + Tuple of (value, error_message) + """ + if index not in self._variables: + # Auto-create variable if not exists + self._variables[index] = MockDebugVariable(index, "INT", 0) + + return (self._variables[index].value, "Success") + + def set_var_value(self, index: int, value: Any) -> tuple: + """ + Set value of a debug variable by index. + + Returns: + Tuple of (success, error_message) + """ + if index not in self._variables: + self._variables[index] = MockDebugVariable(index, "INT", value) + else: + self._variables[index].value = value + + return (True, "Success") + + def get_var_values_batch(self, indices: List[int]) -> Dict[int, Any]: + """Get multiple variable values at once.""" + result = {} + for idx in indices: + val, _ = self.get_var_value(idx) + result[idx] = val + return result + + def set_var_values_batch(self, values: Dict[int, Any]) -> tuple: + """Set multiple variable values at once.""" + for idx, val in values.items(): + self.set_var_value(idx, val) + return (True, "Success") + + def get_variable(self, index: int) -> MockDebugVariable: + """Get MockDebugVariable instance for direct manipulation in tests.""" + if index not in self._variables: + self._variables[index] = MockDebugVariable(index, "INT", 0) + return self._variables[index] + + def set_variable_type(self, index: int, datatype: str, initial_value: Any = None): + """Set up a variable with specific type for testing.""" + self._variables[index] = MockDebugVariable(index, datatype, initial_value) + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_config_path(): + """Return path to test configuration file.""" + return str(TEST_CONFIG_PATH) + + +@pytest.fixture +def test_config_dict(): + """Load and return test configuration as dictionary.""" + with open(TEST_CONFIG_PATH, 'r') as f: + return json.load(f) + + +@pytest.fixture +def mock_buffer_access(test_config_path): + """ + Create a MockSafeBufferAccess instance initialized with test config. + """ + return MockSafeBufferAccess(test_config_path) + + +@pytest.fixture +def mock_buffer_access_empty(): + """ + Create an empty MockSafeBufferAccess for unit tests. + """ + return MockSafeBufferAccess() + + +@pytest.fixture +def opcua_config(test_config_path): + """ + Load OpcuaConfig from test configuration file. + """ + from config import load_config + return load_config(test_config_path) + + +@pytest.fixture +def event_loop(): + """Create event loop for async tests.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +# ============================================================================ +# Variable Index Constants (matching debug.c) +# ============================================================================ + +class VarIndices: + """Constants for debug variable indices from the test program.""" + + # Simple BOOL variables + SIMPLE_BOOL = 0 + SIMPLE_BOOL_TRUE = 1 + + # Simple BYTE variables + SIMPLE_BYTE = 2 + SIMPLE_BYTE_MAX = 3 + + # Simple INT variables + SIMPLE_INT = 4 + SIMPLE_INT_NEGATIVE = 5 + SIMPLE_INT_MAX = 6 + SIMPLE_INT_MIN = 7 + + # Simple DINT variables + SIMPLE_DINT = 8 + SIMPLE_DINT_LARGE = 9 + SIMPLE_DINT_NEGATIVE = 10 + + # Simple LINT variables + SIMPLE_LINT = 11 + SIMPLE_LINT_LARGE = 12 + + # Simple REAL variables + SIMPLE_REAL = 13 + SIMPLE_REAL_PI = 14 + SIMPLE_REAL_NEGATIVE = 15 + + # Simple STRING variables + SIMPLE_STRING = 16 + SIMPLE_STRING_HELLO = 17 + + # Structure: sensor1 + SENSOR1_SENSOR_ID = 20 + SENSOR1_VALUE = 21 + SENSOR1_IS_VALID = 22 + + # Structure: sensor2 + SENSOR2_SENSOR_ID = 26 + SENSOR2_VALUE = 27 + SENSOR2_IS_VALID = 28 + + # Structure: robot_position + ROBOT_POSITION_X = 32 + ROBOT_POSITION_Y = 33 + ROBOT_POSITION_Z = 34 + + # Structure: target_position + TARGET_POSITION_X = 38 + TARGET_POSITION_Y = 39 + TARGET_POSITION_Z = 40 + + # Structure: plc_status + PLC_STATUS_DEVICE_NAME = 44 + PLC_STATUS_ERROR_CODE = 45 + PLC_STATUS_TEMPERATURE = 46 + PLC_STATUS_IS_ONLINE = 47 + PLC_STATUS_UPTIME_SECONDS = 48 + + # Arrays + BOOL_ARRAY_START = 50 # 50-57 + INT_ARRAY_START = 58 # 58-62 + REAL_ARRAY_START = 63 # 63-66 + DINT_ARRAY_START = 67 # 67-69 + + # Working variables + CYCLE_COUNTER = 70 + TOGGLE_OUTPUT = 71 + + +@pytest.fixture +def var_indices(): + """Provide variable indices constants.""" + return VarIndices + + +# ============================================================================ +# Data Type Test Values +# ============================================================================ + +class TestValues: + """Test values for each data type with boundary cases.""" + + BOOL = { + "zero": False, + "one": True, + } + + BYTE = { + "zero": 0, + "mid": 128, + "max": 255, + } + + INT = { + "zero": 0, + "positive": 1000, + "negative": -1000, + "max": 32767, + "min": -32768, + } + + DINT = { + "zero": 0, + "positive": 100000, + "negative": -100000, + "max": 2147483647, + "min": -2147483648, + } + + LINT = { + "zero": 0, + "positive": 1000000000, + "negative": -1000000000, + "large": 9223372036854775807, + } + + REAL = { + "zero": 0.0, + "positive": 3.14159, + "negative": -273.15, + "small": 0.000001, + "large": 1000000.5, + } + + STRING = { + "empty": "", + "hello": "Hello OPC-UA", + "special": "Test!@#$%", + "unicode": "Test Unicode", + } + + +@pytest.fixture +def test_values(): + """Provide test values for each data type.""" + return TestValues diff --git a/tests/pytest/plugins/opcua/test_callbacks.py b/tests/pytest/plugins/opcua/test_callbacks.py new file mode 100644 index 00000000..dcbc1512 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_callbacks.py @@ -0,0 +1,218 @@ +""" +Unit tests for OPC-UA permission callback handler. + +Tests the PermissionCallbackHandler class in callbacks.py: +- Role normalization (_normalize_role) +- Permission checking for read/write operations +""" + +import pytest +import sys +from pathlib import Path +from enum import Enum +from types import SimpleNamespace + +# Add plugin path for imports +_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +sys.path.insert(0, str(_plugin_dir / "opcua")) +sys.path.insert(0, str(_plugin_dir / "shared")) + +from callbacks import PermissionCallbackHandler +from shared.plugin_config_decode.opcua_config_model import VariablePermissions + + +# Mock asyncua UserRole enum for testing +class MockUserRole(Enum): + """Mock of asyncua.server.user_managers.UserRole.""" + User = 1 + Admin = 2 + + +class TestNormalizeRole: + """Tests for the _normalize_role method.""" + + @pytest.fixture + def handler(self): + """Create a PermissionCallbackHandler for testing.""" + return PermissionCallbackHandler({}, {}) + + # String role tests + def test_normalize_viewer_string(self, handler): + """String 'viewer' should return 'viewer'.""" + assert handler._normalize_role("viewer") == "viewer" + + def test_normalize_operator_string(self, handler): + """String 'operator' should return 'operator'.""" + assert handler._normalize_role("operator") == "operator" + + def test_normalize_engineer_string(self, handler): + """String 'engineer' should return 'engineer'.""" + assert handler._normalize_role("engineer") == "engineer" + + def test_normalize_uppercase_roles(self, handler): + """Uppercase role strings should be normalized to lowercase.""" + assert handler._normalize_role("VIEWER") == "viewer" + assert handler._normalize_role("OPERATOR") == "operator" + assert handler._normalize_role("ENGINEER") == "engineer" + + def test_normalize_mixed_case_roles(self, handler): + """Mixed case role strings should be normalized.""" + assert handler._normalize_role("Viewer") == "viewer" + assert handler._normalize_role("Operator") == "operator" + assert handler._normalize_role("Engineer") == "engineer" + + # asyncua UserRole enum tests + def test_normalize_userrole_admin_enum(self, handler): + """UserRole.Admin enum should map to 'engineer'.""" + assert handler._normalize_role(MockUserRole.Admin) == "engineer" + + def test_normalize_userrole_user_enum(self, handler): + """UserRole.User enum should map to 'viewer'.""" + assert handler._normalize_role(MockUserRole.User) == "viewer" + + # String representations of asyncua enums (the bug case) + def test_normalize_userrole_admin_string(self, handler): + """String 'UserRole.Admin' should map to 'engineer'.""" + assert handler._normalize_role("UserRole.Admin") == "engineer" + + def test_normalize_userrole_user_string(self, handler): + """String 'UserRole.User' should map to 'viewer'.""" + assert handler._normalize_role("UserRole.User") == "viewer" + + def test_normalize_lowercase_userrole_admin(self, handler): + """String 'userrole.admin' should map to 'engineer'.""" + assert handler._normalize_role("userrole.admin") == "engineer" + + def test_normalize_lowercase_userrole_user(self, handler): + """String 'userrole.user' should map to 'viewer'.""" + assert handler._normalize_role("userrole.user") == "viewer" + + # Partial matches + def test_normalize_admin_string(self, handler): + """String 'admin' should map to 'engineer'.""" + assert handler._normalize_role("admin") == "engineer" + + def test_normalize_user_string(self, handler): + """String 'user' should map to 'viewer'.""" + assert handler._normalize_role("user") == "viewer" + + # Edge cases + def test_normalize_none_like_string(self, handler): + """Unknown role should default to 'viewer'.""" + result = handler._normalize_role("unknown_role") + assert result == "unknown_role" or result == "viewer" + + def test_normalize_custom_enum_with_name(self, handler): + """Enum with name attribute containing 'admin' should map to 'engineer'.""" + class CustomRole(Enum): + CustomAdmin = 1 + + assert handler._normalize_role(CustomRole.CustomAdmin) == "customadmin" + + +class TestPermissionCallbackHandlerInit: + """Tests for PermissionCallbackHandler initialization.""" + + def test_init_empty_permissions(self): + """Handler should initialize with empty permissions.""" + handler = PermissionCallbackHandler({}, {}) + assert handler.node_permissions == {} + assert handler.nodeid_to_variable == {} + + def test_init_with_permissions(self): + """Handler should store provided permissions.""" + permissions = { + "PLC.Test.var1": VariablePermissions(viewer="r", operator="rw", engineer="rw") + } + nodeid_map = { + "ns=2;s=PLC.Test.var1": "PLC.Test.var1" + } + handler = PermissionCallbackHandler(permissions, nodeid_map) + assert "PLC.Test.var1" in handler.node_permissions + assert len(handler.nodeid_to_variable) == 1 + + +class TestGetPermissionsForNode: + """Tests for the _get_permissions_for_node method.""" + + @pytest.fixture + def handler_with_permissions(self): + """Create handler with sample permissions.""" + permissions = { + "PLC.Test.simple_int": VariablePermissions(viewer="r", operator="rw", engineer="rw"), + "PLC.Test.readonly_var": VariablePermissions(viewer="r", operator="r", engineer="r"), + } + return PermissionCallbackHandler(permissions, {}) + + def test_direct_lookup(self, handler_with_permissions): + """Should find permissions by direct node_id match.""" + perms = handler_with_permissions._get_permissions_for_node("PLC.Test.simple_int") + assert perms is not None + assert perms.viewer == "r" + assert perms.operator == "rw" + + def test_not_found(self, handler_with_permissions): + """Should return None for unknown node_id.""" + perms = handler_with_permissions._get_permissions_for_node("PLC.Test.unknown") + assert perms is None + + +class TestRolePermissionMapping: + """Integration tests for role-to-permission mapping.""" + + @pytest.fixture + def handler(self): + """Create handler with sample permissions.""" + permissions = { + "PLC.Test.var": VariablePermissions(viewer="r", operator="rw", engineer="rw") + } + return PermissionCallbackHandler(permissions, {}) + + def test_viewer_can_read(self, handler): + """Viewer role should have read permission.""" + role = handler._normalize_role("viewer") + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "r" in role_perm + + def test_viewer_cannot_write(self, handler): + """Viewer role should not have write permission.""" + role = handler._normalize_role("viewer") + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "w" not in role_perm + + def test_operator_can_write(self, handler): + """Operator role should have write permission.""" + role = handler._normalize_role("operator") + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "w" in role_perm + + def test_engineer_can_write(self, handler): + """Engineer role should have write permission.""" + role = handler._normalize_role("engineer") + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "w" in role_perm + + def test_userrole_admin_maps_to_engineer_permissions(self, handler): + """asyncua UserRole.Admin should get engineer permissions.""" + # Simulate the problematic case + role = handler._normalize_role("userrole.admin") + assert role == "engineer" + + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "r" in role_perm + assert "w" in role_perm + + def test_userrole_user_maps_to_viewer_permissions(self, handler): + """asyncua UserRole.User should get viewer permissions.""" + role = handler._normalize_role("userrole.user") + assert role == "viewer" + + perms = handler._get_permissions_for_node("PLC.Test.var") + role_perm = getattr(perms, role, "") + assert "r" in role_perm + assert "w" not in role_perm diff --git a/tests/pytest/plugins/opcua/test_data_types.py b/tests/pytest/plugins/opcua/test_data_types.py new file mode 100644 index 00000000..83fe0eb7 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_data_types.py @@ -0,0 +1,497 @@ +""" +Integration tests for OPC-UA data type handling. + +Tests: +- Simple variable creation and initial values +- Structure (struct) variable handling +- Array variable handling +- Read/write operations for all data types +- Boundary value testing +""" + +import pytest +import asyncio +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, AsyncMock + +# Add plugin paths +_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +sys.path.insert(0, str(_plugin_dir / "opcua")) +sys.path.insert(0, str(_plugin_dir / "shared")) + +from asyncua import Server, ua +from config import load_config +from opcua_utils import map_plc_to_opcua_type, convert_value_for_opcua + + +class TestConfigLoading: + """Tests for loading and parsing the test configuration.""" + + def test_config_loads_successfully(self, test_config_path): + """Configuration should load without errors.""" + config = load_config(test_config_path) + assert config is not None + + def test_config_has_server_settings(self, test_config_path): + """Configuration should have server settings.""" + config = load_config(test_config_path) + assert config.server is not None + assert config.server.name == "OpenPLC OPC-UA DataType Test Server" + assert "4840" in config.server.endpoint_url + + def test_config_has_security_profiles(self, test_config_path): + """Configuration should have security profiles.""" + config = load_config(test_config_path) + assert len(config.server.security_profiles) == 3 + + # Check insecure profile + insecure = next(p for p in config.server.security_profiles if p.name == "insecure") + assert insecure.enabled is True + assert insecure.security_policy == "None" + + def test_config_has_users(self, test_config_path): + """Configuration should have user definitions.""" + config = load_config(test_config_path) + assert len(config.users) == 3 + + usernames = [u.username for u in config.users] + assert "viewer" in usernames + assert "operator" in usernames + assert "engineer" in usernames + + def test_config_has_simple_variables(self, test_config_path): + """Configuration should have simple variables.""" + config = load_config(test_config_path) + assert len(config.address_space.variables) == 20 + + def test_config_has_structures(self, test_config_path): + """Configuration should have structure definitions.""" + config = load_config(test_config_path) + assert len(config.address_space.structures) == 5 + + struct_names = [s.browse_name for s in config.address_space.structures] + assert "sensor1" in struct_names + assert "sensor2" in struct_names + assert "robot_position" in struct_names + assert "target_position" in struct_names + assert "plc_status" in struct_names + + def test_config_has_arrays(self, test_config_path): + """Configuration should have array definitions.""" + config = load_config(test_config_path) + assert len(config.address_space.arrays) == 4 + + array_names = [a.browse_name for a in config.address_space.arrays] + assert "bool_array" in array_names + assert "int_array" in array_names + assert "real_array" in array_names + assert "dint_array" in array_names + + +class TestSimpleVariables: + """Tests for simple variable definitions in configuration.""" + + def test_bool_variables_defined(self, test_config_path): + """BOOL variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + # simple_bool + assert "simple_bool" in vars_by_name + var = vars_by_name["simple_bool"] + assert var.datatype == "BOOL" + assert var.index == 0 + assert var.initial_value is False + + # simple_bool_true + assert "simple_bool_true" in vars_by_name + var = vars_by_name["simple_bool_true"] + assert var.datatype == "BOOL" + assert var.index == 1 + assert var.initial_value is True + + def test_byte_variables_defined(self, test_config_path): + """BYTE variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + # simple_byte + assert "simple_byte" in vars_by_name + var = vars_by_name["simple_byte"] + assert var.datatype == "BYTE" + assert var.index == 2 + assert var.initial_value == 0 + + # simple_byte_max + assert "simple_byte_max" in vars_by_name + var = vars_by_name["simple_byte_max"] + assert var.datatype == "BYTE" + assert var.index == 3 + assert var.initial_value == 255 + + def test_int_variables_defined(self, test_config_path): + """INT variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + # simple_int + assert "simple_int" in vars_by_name + var = vars_by_name["simple_int"] + assert var.datatype == "INT" + assert var.index == 4 + + # simple_int_max + assert "simple_int_max" in vars_by_name + var = vars_by_name["simple_int_max"] + assert var.initial_value == 32767 + + # simple_int_min + assert "simple_int_min" in vars_by_name + var = vars_by_name["simple_int_min"] + assert var.initial_value == -32768 + + def test_dint_variables_defined(self, test_config_path): + """DINT variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + assert "simple_dint" in vars_by_name + var = vars_by_name["simple_dint"] + assert var.datatype == "DINT" + assert var.index == 8 + + assert "simple_dint_large" in vars_by_name + var = vars_by_name["simple_dint_large"] + assert var.initial_value == 100000 + + def test_lint_variables_defined(self, test_config_path): + """LINT variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + assert "simple_lint" in vars_by_name + var = vars_by_name["simple_lint"] + assert var.datatype == "LINT" + assert var.index == 11 + + assert "simple_lint_large" in vars_by_name + var = vars_by_name["simple_lint_large"] + assert var.initial_value == 1000000000 + + def test_real_variables_defined(self, test_config_path): + """REAL variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + assert "simple_real" in vars_by_name + var = vars_by_name["simple_real"] + assert var.datatype == "REAL" + assert var.index == 13 + + assert "simple_real_pi" in vars_by_name + var = vars_by_name["simple_real_pi"] + assert abs(var.initial_value - 3.14159) < 0.0001 + + assert "simple_real_negative" in vars_by_name + var = vars_by_name["simple_real_negative"] + assert abs(var.initial_value - (-273.15)) < 0.01 + + def test_string_variables_defined(self, test_config_path): + """STRING variables should be defined correctly.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + assert "simple_string" in vars_by_name + var = vars_by_name["simple_string"] + assert var.datatype == "STRING" + assert var.index == 16 + assert var.initial_value == "" + + assert "simple_string_hello" in vars_by_name + var = vars_by_name["simple_string_hello"] + assert var.initial_value == "Hello OPC-UA" + + +class TestStructureVariables: + """Tests for structure variable definitions.""" + + def test_sensor_structure_defined(self, test_config_path): + """Sensor structures should be defined with correct fields.""" + config = load_config(test_config_path) + structs_by_name = {s.browse_name: s for s in config.address_space.structures} + + # sensor1 + assert "sensor1" in structs_by_name + sensor1 = structs_by_name["sensor1"] + assert len(sensor1.fields) == 3 + + fields_by_name = {f.name: f for f in sensor1.fields} + assert "sensor_id" in fields_by_name + assert fields_by_name["sensor_id"].datatype == "INT" + assert fields_by_name["sensor_id"].index == 20 + + assert "value" in fields_by_name + assert fields_by_name["value"].datatype == "REAL" + assert fields_by_name["value"].index == 21 + + assert "is_valid" in fields_by_name + assert fields_by_name["is_valid"].datatype == "BOOL" + assert fields_by_name["is_valid"].index == 22 + + def test_position_structure_defined(self, test_config_path): + """Position structures should be defined with x, y, z fields.""" + config = load_config(test_config_path) + structs_by_name = {s.browse_name: s for s in config.address_space.structures} + + # robot_position + assert "robot_position" in structs_by_name + pos = structs_by_name["robot_position"] + assert len(pos.fields) == 3 + + fields_by_name = {f.name: f for f in pos.fields} + assert "x" in fields_by_name + assert "y" in fields_by_name + assert "z" in fields_by_name + + # All should be REAL + for name in ["x", "y", "z"]: + assert fields_by_name[name].datatype == "REAL" + + def test_plc_status_structure_defined(self, test_config_path): + """PLC status structure should have mixed types.""" + config = load_config(test_config_path) + structs_by_name = {s.browse_name: s for s in config.address_space.structures} + + assert "plc_status" in structs_by_name + status = structs_by_name["plc_status"] + assert len(status.fields) == 5 + + fields_by_name = {f.name: f for f in status.fields} + + assert fields_by_name["device_name"].datatype == "STRING" + assert fields_by_name["error_code"].datatype == "DINT" + assert fields_by_name["temperature"].datatype == "REAL" + assert fields_by_name["is_online"].datatype == "BOOL" + assert fields_by_name["uptime_seconds"].datatype == "LINT" + + +class TestArrayVariables: + """Tests for array variable definitions.""" + + def test_bool_array_defined(self, test_config_path): + """BOOL array should be defined correctly.""" + config = load_config(test_config_path) + arrays_by_name = {a.browse_name: a for a in config.address_space.arrays} + + assert "bool_array" in arrays_by_name + arr = arrays_by_name["bool_array"] + assert arr.datatype == "BOOL" + assert arr.length == 8 + assert arr.index == 50 + + def test_int_array_defined(self, test_config_path): + """INT array should be defined correctly.""" + config = load_config(test_config_path) + arrays_by_name = {a.browse_name: a for a in config.address_space.arrays} + + assert "int_array" in arrays_by_name + arr = arrays_by_name["int_array"] + assert arr.datatype == "INT" + assert arr.length == 5 + assert arr.index == 58 + + def test_real_array_defined(self, test_config_path): + """REAL array should be defined correctly.""" + config = load_config(test_config_path) + arrays_by_name = {a.browse_name: a for a in config.address_space.arrays} + + assert "real_array" in arrays_by_name + arr = arrays_by_name["real_array"] + assert arr.datatype == "REAL" + assert arr.length == 4 + assert arr.index == 63 + + def test_dint_array_defined(self, test_config_path): + """DINT array should be defined correctly.""" + config = load_config(test_config_path) + arrays_by_name = {a.browse_name: a for a in config.address_space.arrays} + + assert "dint_array" in arrays_by_name + arr = arrays_by_name["dint_array"] + assert arr.datatype == "DINT" + assert arr.length == 3 + assert arr.index == 67 + + +class TestVariablePermissions: + """Tests for variable permission definitions.""" + + def test_readwrite_permissions(self, test_config_path): + """Variables with rw permissions should allow writes.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + var = vars_by_name["simple_int"] + assert var.permissions.viewer == "r" + assert var.permissions.operator == "rw" + assert var.permissions.engineer == "rw" + + def test_readonly_permissions(self, test_config_path): + """Readonly variables should have r-only permissions.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + var = vars_by_name["cycle_counter"] + assert var.permissions.viewer == "r" + assert var.permissions.operator == "r" + assert var.permissions.engineer == "r" + + def test_structure_field_permissions(self, test_config_path): + """Structure fields should have correct permissions.""" + config = load_config(test_config_path) + structs_by_name = {s.browse_name: s for s in config.address_space.structures} + + sensor1 = structs_by_name["sensor1"] + fields_by_name = {f.name: f for f in sensor1.fields} + + # is_valid is readonly + is_valid = fields_by_name["is_valid"] + assert is_valid.permissions.viewer == "r" + assert is_valid.permissions.operator == "r" + assert is_valid.permissions.engineer == "r" + + # sensor_id is readwrite + sensor_id = fields_by_name["sensor_id"] + assert sensor_id.permissions.operator == "rw" + + +class TestMockBufferAccess: + """Tests for the MockSafeBufferAccess fixture.""" + + def test_mock_loads_config(self, mock_buffer_access, var_indices): + """Mock should load variable definitions from config.""" + # Check a known variable exists + val, err = mock_buffer_access.get_var_value(var_indices.SIMPLE_INT) + assert err == "Success" + + def test_mock_get_set_value(self, mock_buffer_access): + """Mock should support get/set operations.""" + # Set a value + success, err = mock_buffer_access.set_var_value(100, 42) + assert success is True + assert err == "Success" + + # Get the value back + val, err = mock_buffer_access.get_var_value(100) + assert val == 42 + assert err == "Success" + + def test_mock_batch_operations(self, mock_buffer_access): + """Mock should support batch get/set operations.""" + # Set multiple values + values = {100: 1, 101: 2, 102: 3} + success, err = mock_buffer_access.set_var_values_batch(values) + assert success is True + + # Get multiple values + result = mock_buffer_access.get_var_values_batch([100, 101, 102]) + assert result[100] == 1 + assert result[101] == 2 + assert result[102] == 3 + + def test_mock_thread_safety(self, mock_buffer_access): + """Mock should support locking operations.""" + mock_buffer_access.acquire_mutex() + mock_buffer_access.release_mutex() + + mock_buffer_access.lock() + mock_buffer_access.unlock() + + def test_mock_config_path(self, mock_buffer_access, test_config_path): + """Mock should return config path.""" + assert mock_buffer_access.get_config_path() == test_config_path + + +class TestTypeMapping: + """Tests for PLC to OPC-UA type mapping.""" + + @pytest.mark.parametrize("plc_type,expected_opcua_type", [ + ("BOOL", ua.VariantType.Boolean), + ("BYTE", ua.VariantType.Byte), + ("INT", ua.VariantType.Int16), + ("DINT", ua.VariantType.Int32), + ("INT32", ua.VariantType.Int32), + ("LINT", ua.VariantType.Int64), + ("FLOAT", ua.VariantType.Float), + ("REAL", ua.VariantType.Float), + ("STRING", ua.VariantType.String), + ]) + def test_type_mapping(self, plc_type, expected_opcua_type): + """Each PLC type should map to correct OPC-UA type.""" + assert map_plc_to_opcua_type(plc_type) == expected_opcua_type + + +class TestValueConversion: + """Tests for value conversion to OPC-UA format.""" + + @pytest.mark.parametrize("datatype,input_value,expected_type", [ + ("BOOL", True, bool), + ("BOOL", False, bool), + ("BYTE", 128, int), + ("INT", 1000, int), + ("DINT", 100000, int), + ("LINT", 1000000000, int), + ("FLOAT", 3.14, float), + ("STRING", "test", str), + ]) + def test_conversion_returns_correct_type(self, datatype, input_value, expected_type): + """Converted values should have correct Python type.""" + result = convert_value_for_opcua(datatype, input_value) + assert isinstance(result, expected_type) + + +class TestIndexMapping: + """Tests verifying correct index mapping from debug.c.""" + + def test_simple_variable_indices(self, test_config_path, var_indices): + """Simple variable indices should match debug.c.""" + config = load_config(test_config_path) + vars_by_name = {v.browse_name: v for v in config.address_space.variables} + + assert vars_by_name["simple_bool"].index == var_indices.SIMPLE_BOOL + assert vars_by_name["simple_byte"].index == var_indices.SIMPLE_BYTE + assert vars_by_name["simple_int"].index == var_indices.SIMPLE_INT + assert vars_by_name["simple_dint"].index == var_indices.SIMPLE_DINT + assert vars_by_name["simple_lint"].index == var_indices.SIMPLE_LINT + assert vars_by_name["simple_real"].index == var_indices.SIMPLE_REAL + assert vars_by_name["simple_string"].index == var_indices.SIMPLE_STRING + + def test_structure_field_indices(self, test_config_path, var_indices): + """Structure field indices should match debug.c.""" + config = load_config(test_config_path) + structs_by_name = {s.browse_name: s for s in config.address_space.structures} + + # sensor1 + sensor1 = structs_by_name["sensor1"] + fields = {f.name: f for f in sensor1.fields} + assert fields["sensor_id"].index == var_indices.SENSOR1_SENSOR_ID + assert fields["value"].index == var_indices.SENSOR1_VALUE + assert fields["is_valid"].index == var_indices.SENSOR1_IS_VALID + + # robot_position + robot = structs_by_name["robot_position"] + fields = {f.name: f for f in robot.fields} + assert fields["x"].index == var_indices.ROBOT_POSITION_X + assert fields["y"].index == var_indices.ROBOT_POSITION_Y + assert fields["z"].index == var_indices.ROBOT_POSITION_Z + + def test_array_indices(self, test_config_path, var_indices): + """Array indices should match debug.c.""" + config = load_config(test_config_path) + arrays_by_name = {a.browse_name: a for a in config.address_space.arrays} + + assert arrays_by_name["bool_array"].index == var_indices.BOOL_ARRAY_START + assert arrays_by_name["int_array"].index == var_indices.INT_ARRAY_START + assert arrays_by_name["real_array"].index == var_indices.REAL_ARRAY_START + assert arrays_by_name["dint_array"].index == var_indices.DINT_ARRAY_START diff --git a/tests/pytest/plugins/opcua/test_memory.py b/tests/pytest/plugins/opcua/test_memory.py new file mode 100644 index 00000000..f53ad602 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_memory.py @@ -0,0 +1,469 @@ +""" +Unit tests for OPC-UA memory access functions. + +Tests the functions in opcua_memory.py: +- read_memory_direct() +- read_string_direct() +- write_string_direct() +- IEC_STRING structure +""" + +import pytest +import ctypes +import sys +from pathlib import Path + +# Add plugin path for imports +_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +sys.path.insert(0, str(_plugin_dir / "opcua")) + +from opcua_memory import ( + IEC_STRING, + IEC_TIMESPEC, + STR_MAX_LEN, + STRING_TOTAL_SIZE, + TIMESPEC_SIZE, + TIME_DATATYPES, + read_memory_direct, + read_string_direct, + write_string_direct, + read_timespec_direct, + write_timespec_direct, +) + + +class TestIECStringStructure: + """Tests for the IEC_STRING ctypes structure.""" + + def test_structure_size(self): + """IEC_STRING should be 127 bytes (1 byte len + 126 bytes body).""" + assert ctypes.sizeof(IEC_STRING) == STRING_TOTAL_SIZE + assert ctypes.sizeof(IEC_STRING) == 127 + + def test_str_max_len_constant(self): + """STR_MAX_LEN should be 126.""" + assert STR_MAX_LEN == 126 + + def test_structure_fields(self): + """IEC_STRING should have len and body fields.""" + iec_string = IEC_STRING() + assert hasattr(iec_string, 'len') + assert hasattr(iec_string, 'body') + + def test_structure_initialization(self): + """IEC_STRING should initialize with zeros.""" + iec_string = IEC_STRING() + assert iec_string.len == 0 + assert all(b == 0 for b in iec_string.body) + + def test_structure_len_field(self): + """len field should accept int8 values.""" + iec_string = IEC_STRING() + iec_string.len = 10 + assert iec_string.len == 10 + + iec_string.len = 126 + assert iec_string.len == 126 + + def test_structure_body_field(self): + """body field should accept byte values.""" + iec_string = IEC_STRING() + iec_string.body[0] = ord('H') + iec_string.body[1] = ord('i') + assert iec_string.body[0] == ord('H') + assert iec_string.body[1] == ord('i') + + +class TestReadStringDirect: + """Tests for read_string_direct function using simulated memory.""" + + def _create_iec_string_in_memory(self, text: str) -> tuple: + """ + Create an IEC_STRING in memory and return (address, struct). + + Args: + text: String to store + + Returns: + Tuple of (memory_address, IEC_STRING_instance) + """ + iec_string = IEC_STRING() + + # Encode and truncate + encoded = text.encode('utf-8')[:STR_MAX_LEN] + iec_string.len = len(encoded) + + # Copy bytes + for i, b in enumerate(encoded): + iec_string.body[i] = b + + # Get address + address = ctypes.addressof(iec_string) + return address, iec_string + + def test_read_empty_string(self): + """Should read empty string correctly.""" + address, iec_string = self._create_iec_string_in_memory("") + result = read_string_direct(address) + assert result == "" + + def test_read_short_string(self): + """Should read short string correctly.""" + address, iec_string = self._create_iec_string_in_memory("Hello") + result = read_string_direct(address) + assert result == "Hello" + + def test_read_medium_string(self): + """Should read medium-length string correctly.""" + text = "Hello OPC-UA World!" + address, iec_string = self._create_iec_string_in_memory(text) + result = read_string_direct(address) + assert result == text + + def test_read_max_length_string(self): + """Should read maximum length string correctly.""" + text = "A" * STR_MAX_LEN + address, iec_string = self._create_iec_string_in_memory(text) + result = read_string_direct(address) + assert result == text + assert len(result) == STR_MAX_LEN + + def test_read_string_with_spaces(self): + """Should handle strings with spaces.""" + text = "Hello World Test" + address, iec_string = self._create_iec_string_in_memory(text) + result = read_string_direct(address) + assert result == text + + def test_read_string_with_numbers(self): + """Should handle strings with numbers.""" + text = "Value: 12345" + address, iec_string = self._create_iec_string_in_memory(text) + result = read_string_direct(address) + assert result == text + + +class TestWriteStringDirect: + """Tests for write_string_direct function.""" + + def _create_empty_iec_string(self) -> tuple: + """Create an empty IEC_STRING and return (address, struct).""" + iec_string = IEC_STRING() + address = ctypes.addressof(iec_string) + return address, iec_string + + def test_write_empty_string(self): + """Should write empty string correctly.""" + address, iec_string = self._create_empty_iec_string() + result = write_string_direct(address, "") + assert result is True + assert iec_string.len == 0 + + def test_write_short_string(self): + """Should write short string correctly.""" + address, iec_string = self._create_empty_iec_string() + result = write_string_direct(address, "Test") + assert result is True + assert iec_string.len == 4 + assert bytes(iec_string.body[:4]) == b"Test" + + def test_write_max_length_string(self): + """Should write maximum length string correctly.""" + address, iec_string = self._create_empty_iec_string() + text = "B" * STR_MAX_LEN + result = write_string_direct(address, text) + assert result is True + assert iec_string.len == STR_MAX_LEN + + def test_write_truncates_long_string(self): + """Should truncate strings longer than STR_MAX_LEN.""" + address, iec_string = self._create_empty_iec_string() + text = "C" * (STR_MAX_LEN + 50) + result = write_string_direct(address, text) + assert result is True + assert iec_string.len == STR_MAX_LEN + + def test_write_then_read_roundtrip(self): + """Should support write then read roundtrip.""" + address, iec_string = self._create_empty_iec_string() + original = "OpenPLC Runtime" + + write_string_direct(address, original) + result = read_string_direct(address) + + assert result == original + + +class TestReadMemoryDirectWithString: + """Tests for read_memory_direct with STRING type (size 127).""" + + def _create_iec_string_in_memory(self, text: str) -> tuple: + """Create an IEC_STRING in memory.""" + iec_string = IEC_STRING() + encoded = text.encode('utf-8')[:STR_MAX_LEN] + iec_string.len = len(encoded) + for i, b in enumerate(encoded): + iec_string.body[i] = b + address = ctypes.addressof(iec_string) + return address, iec_string + + def test_read_memory_direct_string_size(self): + """read_memory_direct should handle size 127 as STRING.""" + address, iec_string = self._create_iec_string_in_memory("Direct Test") + result = read_memory_direct(address, STRING_TOTAL_SIZE) + assert result == "Direct Test" + + def test_read_memory_direct_string_empty(self): + """read_memory_direct should handle empty STRING.""" + address, iec_string = self._create_iec_string_in_memory("") + result = read_memory_direct(address, STRING_TOTAL_SIZE) + assert result == "" + + +class TestReadMemoryDirectNumeric: + """Tests for read_memory_direct with numeric types.""" + + def test_read_uint8(self): + """Should read 1-byte value correctly.""" + value = ctypes.c_uint8(42) + address = ctypes.addressof(value) + result = read_memory_direct(address, 1) + assert result == 42 + + def test_read_uint16(self): + """Should read 2-byte value correctly.""" + value = ctypes.c_uint16(1000) + address = ctypes.addressof(value) + result = read_memory_direct(address, 2) + assert result == 1000 + + def test_read_uint32(self): + """Should read 4-byte value correctly.""" + value = ctypes.c_uint32(100000) + address = ctypes.addressof(value) + result = read_memory_direct(address, 4) + assert result == 100000 + + def test_read_uint64(self): + """Should read 8-byte value correctly.""" + value = ctypes.c_uint64(1000000000) + address = ctypes.addressof(value) + result = read_memory_direct(address, 8) + assert result == 1000000000 + + def test_unsupported_size_raises(self): + """Should raise ValueError for unsupported sizes.""" + value = ctypes.c_uint8(0) + address = ctypes.addressof(value) + + with pytest.raises(RuntimeError) as exc_info: + read_memory_direct(address, 3) + assert "Unsupported variable size" in str(exc_info.value) + + with pytest.raises(RuntimeError) as exc_info: + read_memory_direct(address, 16) + assert "Unsupported variable size" in str(exc_info.value) + + +class TestIECTimespecStructure: + """Tests for the IEC_TIMESPEC ctypes structure.""" + + def test_structure_size(self): + """IEC_TIMESPEC should be 8 bytes (2 x int32).""" + assert ctypes.sizeof(IEC_TIMESPEC) == TIMESPEC_SIZE + assert ctypes.sizeof(IEC_TIMESPEC) == 8 + + def test_timespec_size_constant(self): + """TIMESPEC_SIZE should be 8.""" + assert TIMESPEC_SIZE == 8 + + def test_structure_fields(self): + """IEC_TIMESPEC should have tv_sec and tv_nsec fields.""" + timespec = IEC_TIMESPEC() + assert hasattr(timespec, 'tv_sec') + assert hasattr(timespec, 'tv_nsec') + + def test_structure_initialization(self): + """IEC_TIMESPEC should initialize with zeros.""" + timespec = IEC_TIMESPEC() + assert timespec.tv_sec == 0 + assert timespec.tv_nsec == 0 + + def test_structure_tv_sec_field(self): + """tv_sec field should accept int32 values.""" + timespec = IEC_TIMESPEC() + timespec.tv_sec = 3600 + assert timespec.tv_sec == 3600 + + timespec.tv_sec = -100 + assert timespec.tv_sec == -100 + + def test_structure_tv_nsec_field(self): + """tv_nsec field should accept int32 values.""" + timespec = IEC_TIMESPEC() + timespec.tv_nsec = 500_000_000 + assert timespec.tv_nsec == 500_000_000 + + +class TestReadTimespecDirect: + """Tests for read_timespec_direct function.""" + + def _create_timespec_in_memory(self, tv_sec: int, tv_nsec: int) -> tuple: + """ + Create an IEC_TIMESPEC in memory and return (address, struct). + """ + timespec = IEC_TIMESPEC() + timespec.tv_sec = tv_sec + timespec.tv_nsec = tv_nsec + address = ctypes.addressof(timespec) + return address, timespec + + def test_read_zero_time(self): + """Should read zero time correctly.""" + address, timespec = self._create_timespec_in_memory(0, 0) + result = read_timespec_direct(address) + assert result == (0, 0) + + def test_read_seconds_only(self): + """Should read time with only seconds.""" + address, timespec = self._create_timespec_in_memory(100, 0) + result = read_timespec_direct(address) + assert result == (100, 0) + + def test_read_with_nanoseconds(self): + """Should read time with nanoseconds.""" + address, timespec = self._create_timespec_in_memory(1, 500_000_000) + result = read_timespec_direct(address) + assert result == (1, 500_000_000) + + def test_read_large_time(self): + """Should read large time values (hours/days).""" + # 24 hours + address, timespec = self._create_timespec_in_memory(86400, 0) + result = read_timespec_direct(address) + assert result == (86400, 0) + + def test_read_negative_seconds(self): + """Should handle negative seconds (for negative time intervals).""" + address, timespec = self._create_timespec_in_memory(-10, 0) + result = read_timespec_direct(address) + assert result == (-10, 0) + + +class TestWriteTimespecDirect: + """Tests for write_timespec_direct function.""" + + def _create_empty_timespec(self) -> tuple: + """Create an empty IEC_TIMESPEC and return (address, struct).""" + timespec = IEC_TIMESPEC() + address = ctypes.addressof(timespec) + return address, timespec + + def test_write_zero_time(self): + """Should write zero time correctly.""" + address, timespec = self._create_empty_timespec() + result = write_timespec_direct(address, 0, 0) + assert result is True + assert timespec.tv_sec == 0 + assert timespec.tv_nsec == 0 + + def test_write_seconds_only(self): + """Should write time with only seconds.""" + address, timespec = self._create_empty_timespec() + result = write_timespec_direct(address, 100, 0) + assert result is True + assert timespec.tv_sec == 100 + assert timespec.tv_nsec == 0 + + def test_write_with_nanoseconds(self): + """Should write time with nanoseconds.""" + address, timespec = self._create_empty_timespec() + result = write_timespec_direct(address, 1, 500_000_000) + assert result is True + assert timespec.tv_sec == 1 + assert timespec.tv_nsec == 500_000_000 + + def test_write_large_time(self): + """Should write large time values.""" + address, timespec = self._create_empty_timespec() + result = write_timespec_direct(address, 86400, 999_000_000) + assert result is True + assert timespec.tv_sec == 86400 + assert timespec.tv_nsec == 999_000_000 + + def test_write_then_read_roundtrip(self): + """Should support write then read roundtrip.""" + address, timespec = self._create_empty_timespec() + + write_timespec_direct(address, 3600, 250_000_000) + result = read_timespec_direct(address) + + assert result == (3600, 250_000_000) + + +class TestReadMemoryDirectWithTimeDatatype: + """Tests for read_memory_direct with TIME datatype hint.""" + + def _create_timespec_in_memory(self, tv_sec: int, tv_nsec: int) -> tuple: + """Create an IEC_TIMESPEC in memory.""" + timespec = IEC_TIMESPEC() + timespec.tv_sec = tv_sec + timespec.tv_nsec = tv_nsec + address = ctypes.addressof(timespec) + return address, timespec + + def test_read_memory_direct_time_with_datatype(self): + """read_memory_direct should return tuple for TIME datatype.""" + address, timespec = self._create_timespec_in_memory(10, 500_000_000) + result = read_memory_direct(address, 8, datatype="TIME") + assert result == (10, 500_000_000) + + def test_read_memory_direct_tod_with_datatype(self): + """read_memory_direct should return tuple for TOD datatype.""" + address, timespec = self._create_timespec_in_memory(3600, 0) + result = read_memory_direct(address, 8, datatype="TOD") + assert result == (3600, 0) + + def test_read_memory_direct_date_with_datatype(self): + """read_memory_direct should return tuple for DATE datatype.""" + address, timespec = self._create_timespec_in_memory(86400, 0) + result = read_memory_direct(address, 8, datatype="DATE") + assert result == (86400, 0) + + def test_read_memory_direct_dt_with_datatype(self): + """read_memory_direct should return tuple for DT datatype.""" + address, timespec = self._create_timespec_in_memory(1000000, 123_000_000) + result = read_memory_direct(address, 8, datatype="DT") + assert result == (1000000, 123_000_000) + + def test_read_memory_direct_8bytes_without_datatype(self): + """read_memory_direct should return uint64 for 8 bytes without datatype hint.""" + value = ctypes.c_uint64(1000000000) + address = ctypes.addressof(value) + result = read_memory_direct(address, 8) + assert result == 1000000000 + assert isinstance(result, int) + + def test_read_memory_direct_time_case_insensitive(self): + """read_memory_direct should handle case-insensitive datatype.""" + address, timespec = self._create_timespec_in_memory(5, 100_000_000) + result = read_memory_direct(address, 8, datatype="time") + assert result == (5, 100_000_000) + + result = read_memory_direct(address, 8, datatype="Time") + assert result == (5, 100_000_000) + + +class TestTimeDatatypesConstantMemory: + """Tests for TIME_DATATYPES constant in memory module.""" + + def test_time_datatypes_contains_all_time_types(self): + """TIME_DATATYPES should contain all time-related types.""" + assert "TIME" in TIME_DATATYPES + assert "DATE" in TIME_DATATYPES + assert "TOD" in TIME_DATATYPES + assert "DT" in TIME_DATATYPES + + def test_time_datatypes_is_frozen(self): + """TIME_DATATYPES should be immutable.""" + assert isinstance(TIME_DATATYPES, frozenset) diff --git a/tests/pytest/plugins/opcua/test_project/__init__.py b/tests/pytest/plugins/opcua/test_project/__init__.py new file mode 100644 index 00000000..6ef18c27 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_project/__init__.py @@ -0,0 +1,7 @@ +""" +Test project files for OPC-UA plugin tests. + +Contains: +- opcua_datatype_test.st: Structured Text PLC program with all data types +- opcua_datatype_test.json: OPC-UA configuration mapping to PLC variables +""" diff --git a/tests/pytest/plugins/opcua/test_project/client_certs/uaexpert.der b/tests/pytest/plugins/opcua/test_project/client_certs/uaexpert.der new file mode 100644 index 00000000..66b0c5bc Binary files /dev/null and b/tests/pytest/plugins/opcua/test_project/client_certs/uaexpert.der differ diff --git a/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.json b/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.json new file mode 100644 index 00000000..176110e4 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.json @@ -0,0 +1,473 @@ +[ + { + "name": "opcua_datatype_test_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "OpenPLC OPC-UA DataType Test Server", + "application_uri": "urn:openplc:opcua:datatype:test", + "product_uri": "urn:openplc:runtime:test", + "endpoint_url": "opc.tcp://localhost:4840/openplc/opcua/test", + "security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + }, + { + "name": "signed", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "Sign", + "auth_methods": ["Username", "Certificate"] + }, + { + "name": "signed_encrypted", + "enabled": true, + "security_policy": "Basic256Sha256", + "security_mode": "SignAndEncrypt", + "auth_methods": ["Username", "Certificate"] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [] + }, + "users": [ + { + "type": "password", + "username": "viewer", + "password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4P.yrGOsjw.5aYTK", + "role": "viewer" + }, + { + "type": "password", + "username": "operator", + "password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4P.yrGOsjw.5aYTK", + "role": "operator" + }, + { + "type": "password", + "username": "engineer", + "password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4P.yrGOsjw.5aYTK", + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:openplc:opcua:datatype:test", + "namespace_index": 2, + "variables": [ + { + "node_id": "PLC.Test.simple_bool", + "browse_name": "simple_bool", + "display_name": "Simple Bool", + "datatype": "BOOL", + "initial_value": false, + "description": "Boolean test variable", + "index": 0, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_bool_true", + "browse_name": "simple_bool_true", + "display_name": "Simple Bool True", + "datatype": "BOOL", + "initial_value": true, + "description": "Boolean test variable initialized true", + "index": 1, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_byte", + "browse_name": "simple_byte", + "display_name": "Simple Byte", + "datatype": "BYTE", + "initial_value": 0, + "description": "Byte test variable (0-255)", + "index": 2, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_byte_max", + "browse_name": "simple_byte_max", + "display_name": "Simple Byte Max", + "datatype": "BYTE", + "initial_value": 255, + "description": "Byte test variable at max value", + "index": 3, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_int", + "browse_name": "simple_int", + "display_name": "Simple Int", + "datatype": "INT", + "initial_value": 0, + "description": "16-bit signed integer test variable", + "index": 4, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_int_negative", + "browse_name": "simple_int_negative", + "display_name": "Simple Int Negative", + "datatype": "INT", + "initial_value": -100, + "description": "16-bit signed integer negative value", + "index": 5, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_int_max", + "browse_name": "simple_int_max", + "display_name": "Simple Int Max", + "datatype": "INT", + "initial_value": 32767, + "description": "16-bit signed integer at max value", + "index": 6, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_int_min", + "browse_name": "simple_int_min", + "display_name": "Simple Int Min", + "datatype": "INT", + "initial_value": -32768, + "description": "16-bit signed integer at min value", + "index": 7, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_dint", + "browse_name": "simple_dint", + "display_name": "Simple DInt", + "datatype": "DINT", + "initial_value": 0, + "description": "32-bit signed integer test variable", + "index": 8, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_dint_large", + "browse_name": "simple_dint_large", + "display_name": "Simple DInt Large", + "datatype": "DINT", + "initial_value": 100000, + "description": "32-bit signed integer large value", + "index": 9, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_dint_negative", + "browse_name": "simple_dint_negative", + "display_name": "Simple DInt Negative", + "datatype": "DINT", + "initial_value": -100000, + "description": "32-bit signed integer negative value", + "index": 10, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_lint", + "browse_name": "simple_lint", + "display_name": "Simple LInt", + "datatype": "LINT", + "initial_value": 0, + "description": "64-bit signed integer test variable", + "index": 11, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_lint_large", + "browse_name": "simple_lint_large", + "display_name": "Simple LInt Large", + "datatype": "LINT", + "initial_value": 1000000000, + "description": "64-bit signed integer large value", + "index": 12, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_real", + "browse_name": "simple_real", + "display_name": "Simple Real", + "datatype": "REAL", + "initial_value": 0.0, + "description": "32-bit floating point test variable", + "index": 13, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_real_pi", + "browse_name": "simple_real_pi", + "display_name": "Simple Real Pi", + "datatype": "REAL", + "initial_value": 3.14159, + "description": "32-bit floating point pi value", + "index": 14, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_real_negative", + "browse_name": "simple_real_negative", + "display_name": "Simple Real Negative", + "datatype": "REAL", + "initial_value": -273.15, + "description": "32-bit floating point negative value", + "index": 15, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_string", + "browse_name": "simple_string", + "display_name": "Simple String", + "datatype": "STRING", + "initial_value": "", + "description": "String test variable empty", + "index": 16, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.simple_string_hello", + "browse_name": "simple_string_hello", + "display_name": "Simple String Hello", + "datatype": "STRING", + "initial_value": "Hello OPC-UA", + "description": "String test variable with content", + "index": 17, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.cycle_counter", + "browse_name": "cycle_counter", + "display_name": "Cycle Counter", + "datatype": "DINT", + "initial_value": 0, + "description": "Internal cycle counter (readonly)", + "index": 70, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + }, + { + "node_id": "PLC.Test.toggle_output", + "browse_name": "toggle_output", + "display_name": "Toggle Output", + "datatype": "BOOL", + "initial_value": false, + "description": "Toggle output for visual feedback (readonly)", + "index": 71, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ], + "structures": [ + { + "node_id": "PLC.Test.Structures.sensor1", + "browse_name": "sensor1", + "display_name": "Sensor 1", + "description": "Sensor data structure instance 1", + "fields": [ + { + "name": "sensor_id", + "datatype": "INT", + "initial_value": 1, + "index": 20, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "value", + "datatype": "REAL", + "initial_value": 0.0, + "index": 21, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "is_valid", + "datatype": "BOOL", + "initial_value": false, + "index": 22, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ] + }, + { + "node_id": "PLC.Test.Structures.sensor2", + "browse_name": "sensor2", + "display_name": "Sensor 2", + "description": "Sensor data structure instance 2", + "fields": [ + { + "name": "sensor_id", + "datatype": "INT", + "initial_value": 2, + "index": 26, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "value", + "datatype": "REAL", + "initial_value": 25.5, + "index": 27, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "is_valid", + "datatype": "BOOL", + "initial_value": true, + "index": 28, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ] + }, + { + "node_id": "PLC.Test.Structures.robot_position", + "browse_name": "robot_position", + "display_name": "Robot Position", + "description": "3D position structure for robot", + "fields": [ + { + "name": "x", + "datatype": "REAL", + "initial_value": 0.0, + "index": 32, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "y", + "datatype": "REAL", + "initial_value": 0.0, + "index": 33, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "z", + "datatype": "REAL", + "initial_value": 0.0, + "index": 34, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + ] + }, + { + "node_id": "PLC.Test.Structures.target_position", + "browse_name": "target_position", + "display_name": "Target Position", + "description": "3D position structure for target", + "fields": [ + { + "name": "x", + "datatype": "REAL", + "initial_value": 100.0, + "index": 38, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "y", + "datatype": "REAL", + "initial_value": 50.0, + "index": 39, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "z", + "datatype": "REAL", + "initial_value": 25.0, + "index": 40, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + ] + }, + { + "node_id": "PLC.Test.Structures.plc_status", + "browse_name": "plc_status", + "display_name": "PLC Status", + "description": "Device status structure with mixed types", + "fields": [ + { + "name": "device_name", + "datatype": "STRING", + "initial_value": "OpenPLC", + "index": 44, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "error_code", + "datatype": "DINT", + "initial_value": 0, + "index": 45, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "temperature", + "datatype": "REAL", + "initial_value": 45.5, + "index": 46, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "name": "is_online", + "datatype": "BOOL", + "initial_value": true, + "index": 47, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + }, + { + "name": "uptime_seconds", + "datatype": "LINT", + "initial_value": 0, + "index": 48, + "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} + } + ] + } + ], + "arrays": [ + { + "node_id": "PLC.Test.Arrays.bool_array", + "browse_name": "bool_array", + "display_name": "Bool Array", + "datatype": "BOOL", + "length": 8, + "initial_value": false, + "index": 50, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.Arrays.int_array", + "browse_name": "int_array", + "display_name": "Int Array", + "datatype": "INT", + "length": 5, + "initial_value": 0, + "index": 58, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.Arrays.real_array", + "browse_name": "real_array", + "display_name": "Real Array", + "datatype": "REAL", + "length": 4, + "initial_value": 0.0, + "index": 63, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + }, + { + "node_id": "PLC.Test.Arrays.dint_array", + "browse_name": "dint_array", + "display_name": "DInt Array", + "datatype": "DINT", + "length": 3, + "initial_value": 0, + "index": 67, + "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} + } + ] + } + } + } +] diff --git a/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.st b/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.st new file mode 100644 index 00000000..4af25923 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_project/opcua_datatype_test.st @@ -0,0 +1,166 @@ +(* + * OPC-UA Data Type Test Program + * + * This program declares variables of all supported data types + * for testing OPC-UA plugin bidirectional synchronization. + * + * Data Types Tested: + * - Simple: BOOL, BYTE, INT, DINT, LINT, REAL, STRING + * - Structures: Custom TYPE with mixed fields + * - Arrays: Fixed-size arrays of various types + *) + +(* ============================================ *) +(* TYPE DEFINITIONS - Structures *) +(* ============================================ *) + +TYPE + (* Simple structure with basic types *) + SensorData : STRUCT + sensor_id : INT; + value : REAL; + is_valid : BOOL; + END_STRUCT; + + (* Structure for coordinate/position data *) + Position3D : STRUCT + x : REAL; + y : REAL; + z : REAL; + END_STRUCT; + + (* Complex structure with multiple field types *) + DeviceStatus : STRUCT + device_name : STRING; + error_code : DINT; + temperature : REAL; + is_online : BOOL; + uptime_seconds : LINT; + END_STRUCT; +END_TYPE + +(* ============================================ *) +(* PROGRAM - Main test program *) +(* ============================================ *) + +PROGRAM OpcuaDataTypeTest +VAR + (* ---------------------------------------- *) + (* SIMPLE VARIABLES - All basic data types *) + (* ---------------------------------------- *) + + (* Boolean - 1 bit logical value *) + simple_bool : BOOL := FALSE; + simple_bool_true : BOOL := TRUE; + + (* Byte - 8-bit unsigned integer (0 to 255) *) + simple_byte : BYTE := 0; + simple_byte_max : BYTE := 255; + + (* Int - 16-bit signed integer (-32768 to 32767) *) + simple_int : INT := 0; + simple_int_negative : INT := -100; + simple_int_max : INT := 32767; + simple_int_min : INT := -32768; + + (* DInt - 32-bit signed integer *) + simple_dint : DINT := 0; + simple_dint_large : DINT := 100000; + simple_dint_negative : DINT := -100000; + + (* LInt - 64-bit signed integer *) + simple_lint : LINT := 0; + simple_lint_large : LINT := 1000000000; + + (* Real/Float - 32-bit floating point *) + simple_real : REAL := 0.0; + simple_real_pi : REAL := 3.14159; + simple_real_negative : REAL := -273.15; + + (* String - Variable length text *) + simple_string : STRING := ''; + simple_string_hello : STRING := 'Hello OPC-UA'; + + (* ---------------------------------------- *) + (* STRUCTURE INSTANCES *) + (* ---------------------------------------- *) + + (* Sensor data structure instance *) + sensor1 : SensorData := (sensor_id := 1, value := 0.0, is_valid := FALSE); + sensor2 : SensorData := (sensor_id := 2, value := 25.5, is_valid := TRUE); + + (* Position structure instance *) + robot_position : Position3D := (x := 0.0, y := 0.0, z := 0.0); + target_position : Position3D := (x := 100.0, y := 50.0, z := 25.0); + + (* Device status structure instance *) + plc_status : DeviceStatus := ( + device_name := 'OpenPLC', + error_code := 0, + temperature := 45.5, + is_online := TRUE, + uptime_seconds := 0 + ); + + (* ---------------------------------------- *) + (* ARRAY VARIABLES *) + (* ---------------------------------------- *) + + (* Array of booleans - digital inputs simulation *) + bool_array : ARRAY[0..7] OF BOOL := [FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE]; + + (* Array of integers - analog values *) + int_array : ARRAY[0..4] OF INT := [0, 0, 0, 0, 0]; + + (* Array of reals - sensor readings *) + real_array : ARRAY[0..3] OF REAL := [0.0, 0.0, 0.0, 0.0]; + + (* Array of DInts - counters *) + dint_array : ARRAY[0..2] OF DINT := [0, 0, 0]; + + (* ---------------------------------------- *) + (* WORKING VARIABLES (for logic) *) + (* ---------------------------------------- *) + + (* Counter for uptime tracking *) + cycle_counter : DINT := 0; + + (* Toggle for testing write operations *) + toggle_output : BOOL := FALSE; + +END_VAR + +(* ============================================ *) +(* PROGRAM LOGIC *) +(* ============================================ *) + +(* Increment cycle counter each scan *) +cycle_counter := cycle_counter + 1; + +(* Update PLC uptime in status structure (assuming 100ms cycle) *) +IF cycle_counter >= 10 THEN + plc_status.uptime_seconds := plc_status.uptime_seconds + 1; + cycle_counter := 0; +END_IF; + +(* Toggle output every second for visual feedback *) +IF plc_status.uptime_seconds MOD 2 = 0 THEN + toggle_output := TRUE; +ELSE + toggle_output := FALSE; +END_IF; + +(* Update sensor1 validity based on value range *) +IF sensor1.value >= -100.0 AND sensor1.value <= 100.0 THEN + sensor1.is_valid := TRUE; +ELSE + sensor1.is_valid := FALSE; +END_IF; + +(* Copy first bool_array element to simple_bool for sync testing *) +simple_bool := bool_array[0]; + +(* Calculate average of real_array and store in simple_real *) +simple_real := (real_array[0] + real_array[1] + real_array[2] + real_array[3]) / 4.0; + +END_PROGRAM diff --git a/tests/pytest/plugins/opcua/test_project/uploaded_project (2).zip b/tests/pytest/plugins/opcua/test_project/uploaded_project (2).zip new file mode 100644 index 00000000..160218f9 Binary files /dev/null and b/tests/pytest/plugins/opcua/test_project/uploaded_project (2).zip differ diff --git a/tests/pytest/plugins/opcua/test_subscription_integration.py b/tests/pytest/plugins/opcua/test_subscription_integration.py new file mode 100644 index 00000000..853c9c36 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_subscription_integration.py @@ -0,0 +1,484 @@ +""" +Integration tests for OPC-UA subscription functionality. + +These tests create actual OPC-UA server and client instances to verify +subscription functionality works end-to-end. + +Tests: +- Client subscription creation/deletion +- Data change notifications received by client +- Multiple subscriptions +- Subscription with different publishing intervals +""" + +import pytest +import pytest_asyncio +import asyncio +from datetime import datetime, timezone +from typing import List, Any +import sys +from pathlib import Path + +# Add plugin paths +_test_dir = Path(__file__).parent +_plugin_dir = _test_dir.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +_opcua_dir = _plugin_dir / "opcua" + +sys.path.insert(0, str(_plugin_dir)) +sys.path.insert(0, str(_opcua_dir)) + +from asyncua import Server, Client, ua + + +class NotificationCollector: + """Collects data change notifications for testing.""" + + def __init__(self): + self.notifications: List[dict] = [] + self.notification_event = asyncio.Event() + + def datachange_notification(self, node, val, data): + """Called when a subscribed value changes.""" + self.notifications.append({ + "node": node, + "value": val, + "data": data, + "timestamp": datetime.now(timezone.utc) + }) + self.notification_event.set() + + def clear(self): + """Clear collected notifications.""" + self.notifications.clear() + self.notification_event.clear() + + async def wait_for_notification(self, timeout: float = 2.0) -> bool: + """Wait for a notification with timeout.""" + try: + await asyncio.wait_for(self.notification_event.wait(), timeout) + return True + except asyncio.TimeoutError: + return False + + +@pytest_asyncio.fixture +async def opcua_server(): + """Create and start an OPC-UA server for testing.""" + server = Server() + await server.init() + + server.set_endpoint("opc.tcp://127.0.0.1:14840/test") + server.set_server_name("Test Server") + server.set_security_policy([ua.SecurityPolicyType.NoSecurity]) + + # Register namespace + ns_idx = await server.register_namespace("urn:test:opcua") + + # Create test folder + objects = server.get_objects_node() + test_folder = await objects.add_folder(ns_idx, "TestVariables") + + # Create test variables + test_vars = {} + test_vars["int_var"] = await test_folder.add_variable( + ns_idx, "IntVar", 0, ua.VariantType.Int32 + ) + test_vars["float_var"] = await test_folder.add_variable( + ns_idx, "FloatVar", 0.0, ua.VariantType.Float + ) + test_vars["bool_var"] = await test_folder.add_variable( + ns_idx, "BoolVar", False, ua.VariantType.Boolean + ) + + # Make variables writable + for var in test_vars.values(): + await var.set_writable() + + # Start server + await server.start() + + yield {"server": server, "variables": test_vars, "ns_idx": ns_idx} + + # Cleanup + await server.stop() + + +@pytest_asyncio.fixture +async def opcua_client(opcua_server): + """Create and connect an OPC-UA client.""" + client = Client("opc.tcp://127.0.0.1:14840/test") + await client.connect() + + yield client + + await client.disconnect() + + +# ============================================================================ +# Integration Tests +# ============================================================================ + +class TestSubscriptionCreation: + """Test subscription creation and deletion.""" + + @pytest.mark.asyncio + async def test_create_subscription(self, opcua_server, opcua_client): + """Test creating a subscription.""" + handler = NotificationCollector() + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + assert subscription is not None + + # Cleanup + await subscription.delete() + + @pytest.mark.asyncio + async def test_subscribe_to_variable(self, opcua_server, opcua_client): + """Test subscribing to a variable's data changes.""" + handler = NotificationCollector() + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + # Subscribe to int variable + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + handle = await subscription.subscribe_data_change(int_node) + + assert handle is not None + + # Cleanup + await subscription.delete() + + @pytest.mark.asyncio + async def test_unsubscribe_from_variable(self, opcua_server, opcua_client): + """Test unsubscribing from a variable.""" + handler = NotificationCollector() + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + handle = await subscription.subscribe_data_change(int_node) + + # Unsubscribe + await subscription.unsubscribe(handle) + + # Cleanup + await subscription.delete() + + @pytest.mark.asyncio + async def test_delete_subscription(self, opcua_server, opcua_client): + """Test deleting a subscription.""" + handler = NotificationCollector() + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + # Delete subscription + await subscription.delete() + + # Verify it's deleted (should not raise) + + +class TestDataChangeNotifications: + """Test data change notification delivery.""" + + @pytest.mark.asyncio + async def test_receive_initial_value(self, opcua_server, opcua_client): + """Test receiving initial value notification on subscribe.""" + handler = NotificationCollector() + server_vars = opcua_server["variables"] + + # Set initial value (use Variant to ensure correct type) + await server_vars["int_var"].write_value(ua.Variant(42, ua.VariantType.Int32)) + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await subscription.subscribe_data_change(int_node) + + # Wait for initial notification + received = await handler.wait_for_notification(timeout=2.0) + + assert received, "Should receive initial value notification" + assert len(handler.notifications) >= 1 + assert handler.notifications[0]["value"] == 42 + + await subscription.delete() + + @pytest.mark.asyncio + async def test_receive_value_change_notification(self, opcua_server, opcua_client): + """Test receiving notification when value changes.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await subscription.subscribe_data_change(int_node) + + # Wait for initial notification + await handler.wait_for_notification(timeout=1.0) + handler.clear() + + # Change value on server using write_attribute_value (like SynchronizationManager) + new_value = 100 + data_value = ua.DataValue( + Value=ua.Variant(new_value, ua.VariantType.Int32), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=datetime.now(timezone.utc), + ServerTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(server_vars["int_var"].nodeid, data_value) + + # Wait for change notification + received = await handler.wait_for_notification(timeout=2.0) + + assert received, "Should receive value change notification" + assert any(n["value"] == new_value for n in handler.notifications) + + await subscription.delete() + + @pytest.mark.asyncio + async def test_multiple_value_changes(self, opcua_server, opcua_client): + """Test receiving multiple value change notifications.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=50, # Fast publishing + handler=handler + ) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await subscription.subscribe_data_change(int_node) + + # Wait for initial + await handler.wait_for_notification(timeout=1.0) + initial_count = len(handler.notifications) + + # Send multiple changes + for i in range(5): + data_value = ua.DataValue( + Value=ua.Variant(i * 10, ua.VariantType.Int32), + SourceTimestamp=datetime.now(timezone.utc), + ServerTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(server_vars["int_var"].nodeid, data_value) + await asyncio.sleep(0.1) + + # Wait for notifications + await asyncio.sleep(0.5) + + # Should have received additional notifications + assert len(handler.notifications) > initial_count + + await subscription.delete() + + +class TestMultipleSubscriptions: + """Test multiple simultaneous subscriptions.""" + + @pytest.mark.asyncio + async def test_subscribe_multiple_variables(self, opcua_server, opcua_client): + """Test subscribing to multiple variables.""" + handler = NotificationCollector() + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + # Subscribe to all variables + handles = [] + for name, var in server_vars.items(): + node = opcua_client.get_node(var.nodeid) + handle = await subscription.subscribe_data_change(node) + handles.append(handle) + + assert len(handles) == 3 + + # Wait for initial notifications + await asyncio.sleep(0.5) + + # Should receive notifications for all variables + assert len(handler.notifications) >= 3 + + await subscription.delete() + + @pytest.mark.asyncio + async def test_multiple_subscriptions_same_variable(self, opcua_server, opcua_client): + """Test multiple subscriptions to the same variable.""" + handler1 = NotificationCollector() + handler2 = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + sub1 = await opcua_client.create_subscription(period=100, handler=handler1) + sub2 = await opcua_client.create_subscription(period=100, handler=handler2) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await sub1.subscribe_data_change(int_node) + await sub2.subscribe_data_change(int_node) + + # Wait for initial + await asyncio.sleep(0.3) + handler1.clear() + handler2.clear() + + # Change value + data_value = ua.DataValue( + Value=ua.Variant(999, ua.VariantType.Int32), + SourceTimestamp=datetime.now(timezone.utc), + ServerTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(server_vars["int_var"].nodeid, data_value) + + # Wait for notifications + await asyncio.sleep(0.3) + + # Both handlers should receive notification + assert len(handler1.notifications) >= 1 + assert len(handler2.notifications) >= 1 + + await sub1.delete() + await sub2.delete() + + +class TestSubscriptionTimestamps: + """Test timestamp handling in subscriptions.""" + + @pytest.mark.asyncio + async def test_source_timestamp_preserved(self, opcua_server, opcua_client): + """Test that SourceTimestamp is preserved in notifications.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription( + period=100, + handler=handler + ) + + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await subscription.subscribe_data_change(int_node) + + # Wait for initial + await handler.wait_for_notification(timeout=1.0) + handler.clear() + + # Write with specific source timestamp + source_ts = datetime.now(timezone.utc) + data_value = ua.DataValue( + Value=ua.Variant(777, ua.VariantType.Int32), + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=source_ts, + ServerTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(server_vars["int_var"].nodeid, data_value) + + # Wait for notification + received = await handler.wait_for_notification(timeout=2.0) + assert received + + # Check notification has timestamp info + notification = handler.notifications[-1] + assert notification["data"] is not None + + await subscription.delete() + + +class TestDifferentDataTypes: + """Test subscriptions with different data types.""" + + @pytest.mark.asyncio + async def test_int_subscription(self, opcua_server, opcua_client): + """Test subscription to integer variable.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription(period=100, handler=handler) + int_node = opcua_client.get_node(server_vars["int_var"].nodeid) + await subscription.subscribe_data_change(int_node) + + await handler.wait_for_notification(timeout=1.0) + handler.clear() + + # Change to new int value + data_value = ua.DataValue(Value=ua.Variant(12345, ua.VariantType.Int32)) + await server.write_attribute_value(server_vars["int_var"].nodeid, data_value) + + received = await handler.wait_for_notification(timeout=2.0) + assert received + assert any(n["value"] == 12345 for n in handler.notifications) + + await subscription.delete() + + @pytest.mark.asyncio + async def test_float_subscription(self, opcua_server, opcua_client): + """Test subscription to float variable.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription(period=100, handler=handler) + float_node = opcua_client.get_node(server_vars["float_var"].nodeid) + await subscription.subscribe_data_change(float_node) + + await handler.wait_for_notification(timeout=1.0) + handler.clear() + + # Change to new float value + data_value = ua.DataValue(Value=ua.Variant(3.14159, ua.VariantType.Float)) + await server.write_attribute_value(server_vars["float_var"].nodeid, data_value) + + received = await handler.wait_for_notification(timeout=2.0) + assert received + + await subscription.delete() + + @pytest.mark.asyncio + async def test_bool_subscription(self, opcua_server, opcua_client): + """Test subscription to boolean variable.""" + handler = NotificationCollector() + server = opcua_server["server"] + server_vars = opcua_server["variables"] + + subscription = await opcua_client.create_subscription(period=100, handler=handler) + bool_node = opcua_client.get_node(server_vars["bool_var"].nodeid) + await subscription.subscribe_data_change(bool_node) + + await handler.wait_for_notification(timeout=1.0) + handler.clear() + + # Change to True + data_value = ua.DataValue(Value=ua.Variant(True, ua.VariantType.Boolean)) + await server.write_attribute_value(server_vars["bool_var"].nodeid, data_value) + + received = await handler.wait_for_notification(timeout=2.0) + assert received + assert any(n["value"] is True for n in handler.notifications) + + await subscription.delete() diff --git a/tests/pytest/plugins/opcua/test_subscription_performance.py b/tests/pytest/plugins/opcua/test_subscription_performance.py new file mode 100644 index 00000000..80b07f61 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_subscription_performance.py @@ -0,0 +1,488 @@ +""" +Performance benchmarks comparing polling vs subscription approaches. + +These tests measure and compare: +- Network traffic (number of read operations) +- Latency (time from value change to notification) +- CPU/resource usage patterns +- Throughput under load + +Run with: pytest test_subscription_performance.py -v -s +""" + +import pytest +import pytest_asyncio +import asyncio +import time +from datetime import datetime, timezone +from typing import List +import sys +from pathlib import Path + +# Add plugin paths +_test_dir = Path(__file__).parent +_plugin_dir = _test_dir.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +_opcua_dir = _plugin_dir / "opcua" + +sys.path.insert(0, str(_plugin_dir)) +sys.path.insert(0, str(_opcua_dir)) + +from asyncua import Server, Client, ua + + +class PerformanceMetrics: + """Collects performance metrics for comparison.""" + + def __init__(self): + self.read_count = 0 + self.notification_count = 0 + self.latencies: List[float] = [] + self.start_time = None + self.end_time = None + + def record_read(self): + self.read_count += 1 + + def record_notification(self, latency_ms: float = None): + self.notification_count += 1 + if latency_ms is not None: + self.latencies.append(latency_ms) + + def start(self): + self.start_time = time.perf_counter() + + def stop(self): + self.end_time = time.perf_counter() + + @property + def duration(self) -> float: + if self.start_time and self.end_time: + return self.end_time - self.start_time + return 0 + + @property + def avg_latency(self) -> float: + if self.latencies: + return sum(self.latencies) / len(self.latencies) + return 0 + + @property + def max_latency(self) -> float: + return max(self.latencies) if self.latencies else 0 + + @property + def min_latency(self) -> float: + return min(self.latencies) if self.latencies else 0 + + def report(self, name: str) -> dict: + return { + "name": name, + "duration_s": round(self.duration, 3), + "read_count": self.read_count, + "notification_count": self.notification_count, + "avg_latency_ms": round(self.avg_latency, 2), + "min_latency_ms": round(self.min_latency, 2), + "max_latency_ms": round(self.max_latency, 2), + } + + +class SubscriptionHandler: + """Handler that tracks notification timing.""" + + def __init__(self, metrics: PerformanceMetrics): + self.metrics = metrics + self.last_change_time = None + self.received_values = [] + + def set_change_time(self): + """Call this when value is changed on server.""" + self.last_change_time = time.perf_counter() + + def datachange_notification(self, node, val, data): + """Called when subscribed value changes.""" + receive_time = time.perf_counter() + + if self.last_change_time: + latency_ms = (receive_time - self.last_change_time) * 1000 + self.metrics.record_notification(latency_ms) + else: + self.metrics.record_notification() + + self.received_values.append(val) + + +@pytest_asyncio.fixture +async def perf_server(): + """Create server with multiple test variables for performance testing.""" + server = Server() + await server.init() + + server.set_endpoint("opc.tcp://127.0.0.1:14841/perftest") + server.set_server_name("Performance Test Server") + server.set_security_policy([ua.SecurityPolicyType.NoSecurity]) + + ns_idx = await server.register_namespace("urn:test:perf") + + objects = server.get_objects_node() + test_folder = await objects.add_folder(ns_idx, "PerfVars") + + # Create multiple test variables + variables = [] + for i in range(10): + var = await test_folder.add_variable( + ns_idx, f"Var{i}", 0, ua.VariantType.Int32 + ) + await var.set_writable() + variables.append(var) + + await server.start() + + yield {"server": server, "variables": variables, "ns_idx": ns_idx} + + await server.stop() + + +@pytest_asyncio.fixture +async def perf_client(perf_server): + """Create client for performance testing.""" + client = Client("opc.tcp://127.0.0.1:14841/perftest") + await client.connect() + + yield client + + await client.disconnect() + + +# ============================================================================ +# Performance Benchmarks +# ============================================================================ + +class TestPollingVsSubscription: + """Compare polling approach vs subscription approach.""" + + @pytest.mark.asyncio + async def test_polling_approach(self, perf_server, perf_client): + """ + Measure performance of polling approach. + + Polling: Client repeatedly reads values at fixed interval. + """ + metrics = PerformanceMetrics() + server = perf_server["server"] + server_vars = perf_server["variables"] + + # Get client nodes + client_nodes = [ + perf_client.get_node(var.nodeid) for var in server_vars + ] + + num_iterations = 50 + poll_interval = 0.1 # 100ms polling + + metrics.start() + + for iteration in range(num_iterations): + # Simulate server updating values (use explicit Int32 type) + for i, var in enumerate(server_vars): + await var.write_value(ua.Variant(iteration * 10 + i, ua.VariantType.Int32)) + + # Poll all values (this is what polling clients do) + for node in client_nodes: + _ = await node.read_value() + metrics.record_read() + + await asyncio.sleep(poll_interval) + + metrics.stop() + + report = metrics.report("Polling") + print(f"\n{'='*60}") + print(f"POLLING PERFORMANCE:") + print(f" Duration: {report['duration_s']}s") + print(f" Read operations: {report['read_count']}") + print(f" Reads/second: {report['read_count'] / report['duration_s']:.1f}") + print(f"{'='*60}") + + # Verify polling performed expected number of reads + expected_reads = num_iterations * len(server_vars) + assert metrics.read_count == expected_reads + + @pytest.mark.asyncio + async def test_subscription_approach(self, perf_server, perf_client): + """ + Measure performance of subscription approach. + + Subscription: Server pushes changes to client. + """ + metrics = PerformanceMetrics() + handler = SubscriptionHandler(metrics) + server = perf_server["server"] + server_vars = perf_server["variables"] + + # Create subscription + subscription = await perf_client.create_subscription( + period=50, # 50ms publishing interval + handler=handler + ) + + # Subscribe to all variables + client_nodes = [ + perf_client.get_node(var.nodeid) for var in server_vars + ] + for node in client_nodes: + await subscription.subscribe_data_change(node) + + # Wait for initial notifications + await asyncio.sleep(0.5) + initial_notifications = metrics.notification_count + + num_iterations = 50 + + metrics.start() + + for iteration in range(num_iterations): + handler.set_change_time() + + # Server updates values (triggers notifications) + for i, var in enumerate(server_vars): + data_value = ua.DataValue( + Value=ua.Variant(iteration * 10 + i, ua.VariantType.Int32), + SourceTimestamp=datetime.now(timezone.utc), + ServerTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(var.nodeid, data_value) + + # Brief wait for notifications to arrive + await asyncio.sleep(0.1) + + metrics.stop() + + await subscription.delete() + + report = metrics.report("Subscription") + print(f"\n{'='*60}") + print(f"SUBSCRIPTION PERFORMANCE:") + print(f" Duration: {report['duration_s']}s") + print(f" Read operations: {report['read_count']} (should be 0)") + print(f" Notifications: {report['notification_count'] - initial_notifications}") + print(f" Avg latency: {report['avg_latency_ms']}ms") + print(f" Min latency: {report['min_latency_ms']}ms") + print(f" Max latency: {report['max_latency_ms']}ms") + print(f"{'='*60}") + + # Verify no polling reads were needed + assert metrics.read_count == 0 + + # Verify notifications were received + assert metrics.notification_count > initial_notifications + + @pytest.mark.asyncio + async def test_compare_network_efficiency(self, perf_server, perf_client): + """ + Compare network efficiency: polling vs subscription. + + Key metric: Number of network operations needed to detect N changes. + """ + server = perf_server["server"] + server_vars = perf_server["variables"] + num_changes = 20 + + # --- Polling simulation --- + polling_reads = 0 + client_nodes = [perf_client.get_node(var.nodeid) for var in server_vars] + + for _ in range(num_changes): + # Each poll cycle reads all variables + for node in client_nodes: + await node.read_value() + polling_reads += 1 + await asyncio.sleep(0.05) + + # --- Subscription simulation --- + metrics = PerformanceMetrics() + handler = SubscriptionHandler(metrics) + + subscription = await perf_client.create_subscription( + period=50, + handler=handler + ) + + for node in client_nodes: + await subscription.subscribe_data_change(node) + + await asyncio.sleep(0.3) # Wait for initial + + for change_num in range(num_changes): + for i, var in enumerate(server_vars): + data_value = ua.DataValue( + Value=ua.Variant(change_num * 100 + i, ua.VariantType.Int32) + ) + await server.write_attribute_value(var.nodeid, data_value) + await asyncio.sleep(0.05) + + await asyncio.sleep(0.3) # Wait for notifications + + await subscription.delete() + + # Calculate efficiency + subscription_reads = 0 # Subscriptions don't poll + + print(f"\n{'='*60}") + print(f"NETWORK EFFICIENCY COMPARISON:") + print(f" Changes to detect: {num_changes} iterations x {len(server_vars)} vars") + print(f" Polling reads required: {polling_reads}") + print(f" Subscription reads required: {subscription_reads}") + print(f" Network reduction: {((polling_reads - subscription_reads) / polling_reads * 100):.1f}%") + print(f"{'='*60}") + + # Subscription should require far fewer network operations + assert subscription_reads < polling_reads + + +class TestSubscriptionLatency: + """Test and measure subscription notification latency.""" + + @pytest.mark.asyncio + async def test_single_value_latency(self, perf_server, perf_client): + """Measure latency for single value change notification.""" + metrics = PerformanceMetrics() + handler = SubscriptionHandler(metrics) + server = perf_server["server"] + test_var = perf_server["variables"][0] + + subscription = await perf_client.create_subscription( + period=10, # Fast publishing + handler=handler + ) + + node = perf_client.get_node(test_var.nodeid) + await subscription.subscribe_data_change(node) + + await asyncio.sleep(0.2) # Wait for initial + + # Measure latency for 20 changes + for i in range(20): + handler.set_change_time() + + data_value = ua.DataValue( + Value=ua.Variant(i * 1000, ua.VariantType.Int32), + SourceTimestamp=datetime.now(timezone.utc) + ) + await server.write_attribute_value(test_var.nodeid, data_value) + + await asyncio.sleep(0.05) + + await subscription.delete() + + print(f"\n{'='*60}") + print(f"LATENCY BENCHMARK (single variable):") + print(f" Samples: {len(metrics.latencies)}") + print(f" Average: {metrics.avg_latency:.2f}ms") + print(f" Min: {metrics.min_latency:.2f}ms") + print(f" Max: {metrics.max_latency:.2f}ms") + print(f"{'='*60}") + + # Latency should be reasonable (< 100ms for local) + assert metrics.avg_latency < 100, f"Average latency too high: {metrics.avg_latency}ms" + + @pytest.mark.asyncio + async def test_burst_update_handling(self, perf_server, perf_client): + """Test handling of rapid burst updates.""" + metrics = PerformanceMetrics() + handler = SubscriptionHandler(metrics) + server = perf_server["server"] + test_var = perf_server["variables"][0] + + subscription = await perf_client.create_subscription( + period=10, + handler=handler + ) + + node = perf_client.get_node(test_var.nodeid) + await subscription.subscribe_data_change(node) + + await asyncio.sleep(0.2) + initial_count = metrics.notification_count + + # Burst: 50 rapid updates + burst_size = 50 + for i in range(burst_size): + data_value = ua.DataValue( + Value=ua.Variant(i, ua.VariantType.Int32) + ) + await server.write_attribute_value(test_var.nodeid, data_value) + # No sleep - rapid fire + + # Wait for notifications to arrive + await asyncio.sleep(1.0) + + await subscription.delete() + + notifications_received = metrics.notification_count - initial_count + + print(f"\n{'='*60}") + print(f"BURST UPDATE HANDLING:") + print(f" Updates sent: {burst_size}") + print(f" Notifications received: {notifications_received}") + print(f" Delivery rate: {(notifications_received / burst_size * 100):.1f}%") + print(f"{'='*60}") + + # Should receive most notifications (some may be coalesced) + assert notifications_received > 0 + + +class TestScalability: + """Test subscription scalability with many variables.""" + + @pytest.mark.asyncio + async def test_many_subscriptions(self, perf_server, perf_client): + """Test performance with subscriptions to all variables.""" + metrics = PerformanceMetrics() + handler = SubscriptionHandler(metrics) + server = perf_server["server"] + server_vars = perf_server["variables"] + + subscription = await perf_client.create_subscription( + period=50, + handler=handler + ) + + # Subscribe to all 10 variables + handles = [] + for var in server_vars: + node = perf_client.get_node(var.nodeid) + handle = await subscription.subscribe_data_change(node) + handles.append(handle) + + await asyncio.sleep(0.3) + initial_count = metrics.notification_count + + metrics.start() + + # Update all variables 10 times + for iteration in range(10): + for i, var in enumerate(server_vars): + data_value = ua.DataValue( + Value=ua.Variant(iteration * 100 + i, ua.VariantType.Int32) + ) + await server.write_attribute_value(var.nodeid, data_value) + + await asyncio.sleep(0.1) + + metrics.stop() + + await subscription.delete() + + total_updates = 10 * len(server_vars) + notifications = metrics.notification_count - initial_count + + print(f"\n{'='*60}") + print(f"SCALABILITY TEST ({len(server_vars)} variables):") + print(f" Total updates: {total_updates}") + print(f" Notifications received: {notifications}") + print(f" Duration: {metrics.duration:.2f}s") + print(f" Updates/second: {total_updates / metrics.duration:.1f}") + print(f"{'='*60}") + + # Should receive notifications for all updates + assert notifications > 0 diff --git a/tests/pytest/plugins/opcua/test_subscriptions.py b/tests/pytest/plugins/opcua/test_subscriptions.py new file mode 100644 index 00000000..b32486ea --- /dev/null +++ b/tests/pytest/plugins/opcua/test_subscriptions.py @@ -0,0 +1,382 @@ +""" +Unit tests for OPC-UA subscription functionality. + +Tests: +- Subscription creation and deletion +- Data change notifications +- Timestamp handling for subscriptions +- SynchronizationManager subscription support +""" + +import pytest +import asyncio +from datetime import datetime, timezone +from unittest.mock import MagicMock, AsyncMock, patch +import sys +from pathlib import Path + +# Add plugin paths +_test_dir = Path(__file__).parent +_plugin_dir = _test_dir.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +_opcua_dir = _plugin_dir / "opcua" + +sys.path.insert(0, str(_plugin_dir)) +sys.path.insert(0, str(_opcua_dir)) + +from asyncua import ua + + +class MockNode: + """Mock OPC-UA node for testing.""" + + def __init__(self, nodeid="ns=2;i=1", value=0): + self.nodeid = nodeid + self._value = value + self._datavalue = None + + async def read_value(self): + return self._value + + async def write_value(self, value): + self._value = value + + def set_value(self, value): + self._value = value + + +class MockServer: + """Mock OPC-UA server for testing subscription features.""" + + def __init__(self): + self.written_values = [] + self.write_attribute_calls = [] + + async def write_attribute_value(self, nodeid, datavalue): + """Record write_attribute_value calls for verification.""" + self.write_attribute_calls.append({ + "nodeid": nodeid, + "datavalue": datavalue, + "timestamp": datetime.now(timezone.utc) + }) + + +class MockVariableNode: + """Mock VariableNode for testing.""" + + def __init__(self, index, datatype="INT", access_mode="readonly", array_length=None): + self.debug_var_index = index + self.datatype = datatype + self.access_mode = access_mode + self.array_length = array_length + self.node = MockNode(nodeid=f"ns=2;i={index}", value=0) + + +class MockBufferAccess: + """Mock SafeBufferAccess for testing.""" + + def __init__(self): + self._values = {} + + def get_var_value(self, index): + return (self._values.get(index, 0), "Success") + + def get_var_values_batch(self, indices): + """Returns (results, msg) where results is list of (value, message) tuples.""" + results = [] + for idx in indices: + val = self._values.get(idx, 0) + results.append((val, "Success")) + return (results, "Success") + + def set_var_values_batch(self, pairs): + results = [] + for idx, val in pairs: + self._values[idx] = val + results.append((True, "Success")) + return (results, "Batch write completed") + + def set_value(self, index, value): + self._values[index] = value + + +# ============================================================================ +# Unit Tests for Subscription Support +# ============================================================================ + +class TestDataValueTimestamps: + """Test that DataValue timestamps are properly set for subscriptions.""" + + @pytest.mark.asyncio + async def test_datavalue_has_source_timestamp(self): + """Verify DataValue includes SourceTimestamp from PLC cycle.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, 42) + + var_nodes = { + 0: MockVariableNode(0, "INT", "readonly") + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + + # Set cycle timestamp + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + # Trigger sync + await sync_mgr.sync_runtime_to_opcua() + + # Verify write_attribute_value was called with DataValue + assert len(mock_server.write_attribute_calls) == 1 + call = mock_server.write_attribute_calls[0] + datavalue = call["datavalue"] + + assert datavalue.SourceTimestamp is not None + assert datavalue.ServerTimestamp is not None + + @pytest.mark.asyncio + async def test_datavalue_has_good_status(self): + """Verify DataValue has Good status code.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, 100) + + var_nodes = { + 0: MockVariableNode(0, "INT", "readonly") + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + call = mock_server.write_attribute_calls[0] + datavalue = call["datavalue"] + + assert datavalue.StatusCode_.value == ua.StatusCodes.Good + + +class TestWriteAttributeValue: + """Test that write_attribute_value is used for subscription notifications.""" + + @pytest.mark.asyncio + async def test_uses_write_attribute_value_with_server(self): + """Verify write_attribute_value is called when server is provided.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, 50) + + var_nodes = { + 0: MockVariableNode(0, "INT", "readonly") + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + # Should use write_attribute_value + assert len(mock_server.write_attribute_calls) == 1 + + @pytest.mark.asyncio + async def test_fallback_to_write_value_without_server(self): + """Verify fallback to write_value when no server provided.""" + from synchronization import SynchronizationManager + + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, 75) + + mock_node = MockNode() + var_node = MockVariableNode(0, "INT", "readonly") + var_node.node = mock_node + + var_nodes = {0: var_node} + + # No server provided + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, None) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + # Value should be written via write_value fallback + # (MockNode stores value directly) + + +class TestArraySubscriptionSupport: + """Test subscription support for array variables.""" + + @pytest.mark.asyncio + async def test_array_uses_write_attribute_value(self): + """Verify arrays also use write_attribute_value for subscriptions.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + + # Set array element values + for i in range(5): + mock_buffer.set_value(10 + i, i * 10) + + var_nodes = { + 10: MockVariableNode(10, "INT", "readonly", array_length=5) + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + # Should have one call for the array + assert len(mock_server.write_attribute_calls) == 1 + + call = mock_server.write_attribute_calls[0] + datavalue = call["datavalue"] + + # Array should have proper timestamps + assert datavalue.SourceTimestamp is not None + assert datavalue.ServerTimestamp is not None + + +class TestSubscriptionCycleTimestamp: + """Test cycle timestamp handling for subscription accuracy.""" + + @pytest.mark.asyncio + async def test_cycle_timestamp_updated_each_cycle(self): + """Verify cycle timestamp is updated for each sync cycle.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, 1) + + var_nodes = { + 0: MockVariableNode(0, "INT", "readonly") + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + + # First cycle + ts1 = datetime.now(timezone.utc) + sync_mgr._cycle_timestamp = ts1 + await sync_mgr.sync_runtime_to_opcua() + + call1_ts = mock_server.write_attribute_calls[0]["datavalue"].SourceTimestamp + + # Small delay + await asyncio.sleep(0.01) + + # Second cycle with new timestamp + ts2 = datetime.now(timezone.utc) + sync_mgr._cycle_timestamp = ts2 + await sync_mgr.sync_runtime_to_opcua() + + call2_ts = mock_server.write_attribute_calls[1]["datavalue"].SourceTimestamp + + # Timestamps should be different + assert call1_ts != call2_ts + assert call2_ts > call1_ts + + +class TestMultipleVariableSubscriptions: + """Test subscription support with multiple variables.""" + + @pytest.mark.asyncio + async def test_multiple_variables_get_notifications(self): + """Verify all variables trigger subscription notifications.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + + # Set up multiple variables + for i in range(5): + mock_buffer.set_value(i, i * 100) + + var_nodes = { + i: MockVariableNode(i, "INT", "readonly") + for i in range(5) + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + # All 5 variables should have been updated + assert len(mock_server.write_attribute_calls) == 5 + + @pytest.mark.asyncio + async def test_mixed_readonly_readwrite_variables(self): + """Verify both readonly and readwrite variables work with subscriptions.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + + mock_buffer.set_value(0, 10) + mock_buffer.set_value(1, 20) + + var_nodes = { + 0: MockVariableNode(0, "INT", "readonly"), + 1: MockVariableNode(1, "INT", "readwrite"), + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + # Both variables should be updated + assert len(mock_server.write_attribute_calls) == 2 + + +class TestDataTypeSupport: + """Test subscription support for different data types.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("datatype,value", [ + ("BOOL", True), + ("BOOL", False), + ("INT", 12345), + ("INT", -12345), + ("DINT", 2147483647), + ("REAL", 3.14159), + ("BYTE", 255), + ]) + async def test_datatype_subscription_notification(self, datatype, value): + """Verify different data types work with subscription notifications.""" + from synchronization import SynchronizationManager + + mock_server = MockServer() + mock_buffer = MockBufferAccess() + mock_buffer.set_value(0, value) + + var_nodes = { + 0: MockVariableNode(0, datatype, "readonly") + } + + sync_mgr = SynchronizationManager(mock_buffer, var_nodes, mock_server) + await sync_mgr.initialize() + sync_mgr._cycle_timestamp = datetime.now(timezone.utc) + + await sync_mgr.sync_runtime_to_opcua() + + assert len(mock_server.write_attribute_calls) == 1 + call = mock_server.write_attribute_calls[0] + + # Verify DataValue structure + assert call["datavalue"].Value is not None + assert call["datavalue"].SourceTimestamp is not None diff --git a/tests/pytest/plugins/opcua/test_type_conversions.py b/tests/pytest/plugins/opcua/test_type_conversions.py new file mode 100644 index 00000000..1e2a730c --- /dev/null +++ b/tests/pytest/plugins/opcua/test_type_conversions.py @@ -0,0 +1,604 @@ +""" +Unit tests for OPC-UA type conversion functions. + +Tests the functions in opcua_utils.py: +- map_plc_to_opcua_type() +- convert_value_for_opcua() +- convert_value_for_plc() +- infer_var_type() +""" + +import pytest +import struct +import sys +from pathlib import Path + +# Add plugin path for imports +_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" +sys.path.insert(0, str(_plugin_dir / "opcua")) + +from opcua_utils import ( + map_plc_to_opcua_type, + convert_value_for_opcua, + convert_value_for_plc, + infer_var_type, + timespec_to_milliseconds, + milliseconds_to_timespec, + TIME_DATATYPES, +) +from asyncua import ua + + +class TestMapPlcToOpcuaType: + """Tests for map_plc_to_opcua_type function.""" + + def test_bool_mapping(self): + """BOOL should map to Boolean.""" + assert map_plc_to_opcua_type("BOOL") == ua.VariantType.Boolean + assert map_plc_to_opcua_type("bool") == ua.VariantType.Boolean + assert map_plc_to_opcua_type("Bool") == ua.VariantType.Boolean + + def test_byte_mapping(self): + """BYTE should map to Byte.""" + assert map_plc_to_opcua_type("BYTE") == ua.VariantType.Byte + assert map_plc_to_opcua_type("byte") == ua.VariantType.Byte + + def test_int_mapping(self): + """INT should map to Int16.""" + assert map_plc_to_opcua_type("INT") == ua.VariantType.Int16 + assert map_plc_to_opcua_type("int") == ua.VariantType.Int16 + + def test_dint_mapping(self): + """DINT should map to Int32.""" + assert map_plc_to_opcua_type("DINT") == ua.VariantType.Int32 + assert map_plc_to_opcua_type("dint") == ua.VariantType.Int32 + + def test_int32_mapping(self): + """INT32 should map to Int32.""" + assert map_plc_to_opcua_type("INT32") == ua.VariantType.Int32 + assert map_plc_to_opcua_type("int32") == ua.VariantType.Int32 + + def test_lint_mapping(self): + """LINT should map to Int64.""" + assert map_plc_to_opcua_type("LINT") == ua.VariantType.Int64 + assert map_plc_to_opcua_type("lint") == ua.VariantType.Int64 + + def test_float_mapping(self): + """FLOAT should map to Float.""" + assert map_plc_to_opcua_type("FLOAT") == ua.VariantType.Float + assert map_plc_to_opcua_type("float") == ua.VariantType.Float + + def test_real_mapping(self): + """REAL should map to Float (IEC 61131-3 REAL = 32-bit float).""" + assert map_plc_to_opcua_type("REAL") == ua.VariantType.Float + assert map_plc_to_opcua_type("real") == ua.VariantType.Float + + def test_string_mapping(self): + """STRING should map to String.""" + assert map_plc_to_opcua_type("STRING") == ua.VariantType.String + assert map_plc_to_opcua_type("string") == ua.VariantType.String + + def test_unknown_type_mapping(self): + """Unknown types should map to Variant.""" + assert map_plc_to_opcua_type("UNKNOWN") == ua.VariantType.Variant + assert map_plc_to_opcua_type("CUSTOM") == ua.VariantType.Variant + + # TIME type mappings + def test_time_mapping(self): + """TIME should map to Int64 (milliseconds).""" + assert map_plc_to_opcua_type("TIME") == ua.VariantType.Int64 + assert map_plc_to_opcua_type("time") == ua.VariantType.Int64 + assert map_plc_to_opcua_type("Time") == ua.VariantType.Int64 + + def test_tod_mapping(self): + """TOD (Time of Day) should map to DateTime (current date + time).""" + assert map_plc_to_opcua_type("TOD") == ua.VariantType.DateTime + assert map_plc_to_opcua_type("tod") == ua.VariantType.DateTime + + def test_date_mapping(self): + """DATE should map to DateTime.""" + assert map_plc_to_opcua_type("DATE") == ua.VariantType.DateTime + assert map_plc_to_opcua_type("date") == ua.VariantType.DateTime + + def test_dt_mapping(self): + """DT (Date and Time) should map to DateTime.""" + assert map_plc_to_opcua_type("DT") == ua.VariantType.DateTime + assert map_plc_to_opcua_type("dt") == ua.VariantType.DateTime + + +class TestConvertValueForOpcua: + """Tests for convert_value_for_opcua function.""" + + # BOOL conversions + def test_bool_from_true(self): + """True values should convert to True.""" + assert convert_value_for_opcua("BOOL", True) is True + assert convert_value_for_opcua("BOOL", 1) is True + assert convert_value_for_opcua("BOOL", 100) is True + + def test_bool_from_false(self): + """False/zero values should convert to False.""" + assert convert_value_for_opcua("BOOL", False) is False + assert convert_value_for_opcua("BOOL", 0) is False + + # BYTE conversions + def test_byte_normal_values(self): + """Normal byte values should pass through.""" + assert convert_value_for_opcua("BYTE", 0) == 0 + assert convert_value_for_opcua("BYTE", 128) == 128 + assert convert_value_for_opcua("BYTE", 255) == 255 + + def test_byte_clamping(self): + """Byte values should be clamped to 0-255.""" + assert convert_value_for_opcua("BYTE", -1) == 0 + assert convert_value_for_opcua("BYTE", 256) == 255 + assert convert_value_for_opcua("BYTE", 1000) == 255 + + # INT conversions + def test_int_normal_values(self): + """Normal INT values should pass through.""" + assert convert_value_for_opcua("INT", 0) == 0 + assert convert_value_for_opcua("INT", 1000) == 1000 + assert convert_value_for_opcua("INT", -1000) == -1000 + + def test_int_boundary_values(self): + """INT boundary values should be preserved.""" + assert convert_value_for_opcua("INT", 32767) == 32767 + assert convert_value_for_opcua("INT", -32768) == -32768 + + def test_int_clamping(self): + """INT values outside range should be clamped.""" + assert convert_value_for_opcua("INT", 40000) == 32767 + assert convert_value_for_opcua("INT", -40000) == -32768 + + # DINT conversions + def test_dint_normal_values(self): + """Normal DINT values should pass through.""" + assert convert_value_for_opcua("DINT", 0) == 0 + assert convert_value_for_opcua("DINT", 100000) == 100000 + assert convert_value_for_opcua("DINT", -100000) == -100000 + + def test_dint_boundary_values(self): + """DINT boundary values should be preserved.""" + assert convert_value_for_opcua("DINT", 2147483647) == 2147483647 + assert convert_value_for_opcua("DINT", -2147483648) == -2147483648 + + def test_int32_alias(self): + """INT32 should behave same as DINT.""" + assert convert_value_for_opcua("INT32", 100000) == 100000 + assert convert_value_for_opcua("Int32", -100000) == -100000 + + # LINT conversions + def test_lint_normal_values(self): + """Normal LINT values should pass through.""" + assert convert_value_for_opcua("LINT", 0) == 0 + assert convert_value_for_opcua("LINT", 1000000000) == 1000000000 + assert convert_value_for_opcua("LINT", -1000000000) == -1000000000 + + def test_lint_large_values(self): + """Large LINT values should be preserved.""" + assert convert_value_for_opcua("LINT", 9223372036854775807) == 9223372036854775807 + + # FLOAT/REAL conversions + def test_float_from_float(self): + """Float values should pass through.""" + result = convert_value_for_opcua("FLOAT", 3.14159) + assert abs(result - 3.14159) < 0.0001 + + def test_float_from_int_representation(self): + """Float stored as int representation should be unpacked.""" + # Pack 3.14159 as int representation + int_repr = struct.unpack('I', struct.pack('f', 3.14159))[0] + result = convert_value_for_opcua("FLOAT", int_repr) + assert abs(result - 3.14159) < 0.0001 + + def test_float_zero(self): + """Zero float should work correctly.""" + assert convert_value_for_opcua("FLOAT", 0.0) == 0.0 + assert convert_value_for_opcua("FLOAT", 0) == 0.0 + + def test_float_negative(self): + """Negative floats should work correctly.""" + result = convert_value_for_opcua("FLOAT", -273.15) + assert abs(result - (-273.15)) < 0.01 + + # REAL conversions (IEC 61131-3 REAL = 32-bit float) + def test_real_from_float(self): + """REAL values should pass through as float.""" + result = convert_value_for_opcua("REAL", 3.14159) + assert abs(result - 3.14159) < 0.0001 + + def test_real_from_int_representation(self): + """REAL stored as int representation should be unpacked.""" + # Pack 3.14159 as int representation + int_repr = struct.unpack('I', struct.pack('f', 3.14159))[0] + result = convert_value_for_opcua("REAL", int_repr) + assert abs(result - 3.14159) < 0.0001 + + # STRING conversions + def test_string_normal(self): + """String values should pass through.""" + assert convert_value_for_opcua("STRING", "Hello") == "Hello" + assert convert_value_for_opcua("STRING", "") == "" + + def test_string_from_other_types(self): + """Non-string values should be converted to string.""" + assert convert_value_for_opcua("STRING", 123) == "123" + + # TIME conversions + def test_time_from_tuple(self): + """TIME from tuple (tv_sec, tv_nsec) should convert to milliseconds.""" + # 1.5 seconds = 1500 ms + assert convert_value_for_opcua("TIME", (1, 500_000_000)) == 1500 + # 0 seconds + assert convert_value_for_opcua("TIME", (0, 0)) == 0 + # 10.25 seconds = 10250 ms + assert convert_value_for_opcua("TIME", (10, 250_000_000)) == 10250 + + def test_time_from_int(self): + """TIME from int should be treated as already milliseconds.""" + assert convert_value_for_opcua("TIME", 1500) == 1500 + assert convert_value_for_opcua("TIME", 0) == 0 + + def test_tod_from_tuple(self): + """TOD from tuple should convert to DateTime with current date + time.""" + from datetime import datetime, timezone + + # Capture the date before conversion to avoid flakiness across midnight + today_before = datetime.now(timezone.utc).date() + + # 1 hour = 3600 seconds since midnight -> 01:00:00 + result = convert_value_for_opcua("TOD", (3600, 0)) + assert isinstance(result, datetime) + assert result.hour == 1 + assert result.minute == 0 + assert result.second == 0 + # Date should correspond to the current date at the time of conversion, + # allowing for the possibility that midnight passes during the test. + today_after = datetime.now(timezone.utc).date() + assert today_before <= result.date() <= today_after + + # 1 hour + 30 minutes + 45 seconds = 5445 seconds + result2 = convert_value_for_opcua("TOD", (5445, 500_000_000)) + assert result2.hour == 1 + assert result2.minute == 30 + assert result2.second == 45 + assert result2.microsecond == 500000 # 500ms = 500000 microseconds + + def test_time_large_values(self): + """TIME should handle large values (hours/days).""" + # 24 hours = 86400 seconds = 86400000 ms + assert convert_value_for_opcua("TIME", (86400, 0)) == 86400000 + # 1 day + 1 hour + 1 minute + 1.5 seconds + tv_sec = 86400 + 3600 + 60 + 1 + assert convert_value_for_opcua("TIME", (tv_sec, 500_000_000)) == (tv_sec * 1000 + 500) + + +class TestConvertValueForPlc: + """Tests for convert_value_for_plc function.""" + + # BOOL conversions + def test_bool_from_python_bool(self): + """Python bool should convert to int 0/1.""" + assert convert_value_for_plc("BOOL", True) == 1 + assert convert_value_for_plc("BOOL", False) == 0 + + def test_bool_from_int(self): + """Integer should convert to 0/1.""" + assert convert_value_for_plc("BOOL", 1) == 1 + assert convert_value_for_plc("BOOL", 0) == 0 + assert convert_value_for_plc("BOOL", 100) == 1 + + def test_bool_from_string(self): + """String bool representations should convert.""" + assert convert_value_for_plc("BOOL", "true") == 1 + assert convert_value_for_plc("BOOL", "false") == 0 + assert convert_value_for_plc("BOOL", "1") == 1 + assert convert_value_for_plc("BOOL", "0") == 0 + + # BYTE conversions + def test_byte_normal_values(self): + """Normal byte values should pass through.""" + assert convert_value_for_plc("BYTE", 0) == 0 + assert convert_value_for_plc("BYTE", 128) == 128 + assert convert_value_for_plc("BYTE", 255) == 255 + + def test_byte_clamping(self): + """Byte values should be clamped to 0-255.""" + assert convert_value_for_plc("BYTE", -1) == 0 + assert convert_value_for_plc("BYTE", 256) == 255 + + # INT conversions + def test_int_normal_values(self): + """Normal INT values should pass through.""" + assert convert_value_for_plc("INT", 0) == 0 + assert convert_value_for_plc("INT", 1000) == 1000 + assert convert_value_for_plc("INT", -1000) == -1000 + + def test_int_clamping(self): + """INT values outside range should be clamped.""" + assert convert_value_for_plc("INT", 40000) == 32767 + assert convert_value_for_plc("INT", -40000) == -32768 + + # DINT conversions + def test_dint_normal_values(self): + """Normal DINT values should pass through.""" + assert convert_value_for_plc("DINT", 0) == 0 + assert convert_value_for_plc("DINT", 100000) == 100000 + assert convert_value_for_plc("DINT", -100000) == -100000 + + # LINT conversions + def test_lint_normal_values(self): + """Normal LINT values should pass through.""" + assert convert_value_for_plc("LINT", 0) == 0 + assert convert_value_for_plc("LINT", 1000000000) == 1000000000 + + # FLOAT conversions + def test_float_to_int_representation(self): + """Float should be packed to int representation for PLC storage.""" + result = convert_value_for_plc("FLOAT", 3.14159) + # Verify by unpacking back + unpacked = struct.unpack('f', struct.pack('I', result))[0] + assert abs(unpacked - 3.14159) < 0.0001 + + def test_float_zero(self): + """Zero float should pack correctly.""" + result = convert_value_for_plc("FLOAT", 0.0) + unpacked = struct.unpack('f', struct.pack('I', result))[0] + assert unpacked == 0.0 + + # REAL conversions (IEC 61131-3 REAL = 32-bit float) + def test_real_to_int_representation(self): + """REAL should be packed to int representation for PLC storage.""" + result = convert_value_for_plc("REAL", 3.14159) + # Verify by unpacking back + unpacked = struct.unpack('f', struct.pack('I', result))[0] + assert abs(unpacked - 3.14159) < 0.0001 + + # STRING conversions + def test_string_normal(self): + """String values should pass through.""" + assert convert_value_for_plc("STRING", "Hello") == "Hello" + assert convert_value_for_plc("STRING", "") == "" + + # TIME conversions (OPC-UA milliseconds -> PLC timespec tuple) + def test_time_to_tuple(self): + """TIME milliseconds should convert to (tv_sec, tv_nsec) tuple.""" + # 1500 ms = 1.5 seconds + assert convert_value_for_plc("TIME", 1500) == (1, 500_000_000) + # 0 ms + assert convert_value_for_plc("TIME", 0) == (0, 0) + # 10250 ms = 10.25 seconds + assert convert_value_for_plc("TIME", 10250) == (10, 250_000_000) + + def test_tod_to_tuple(self): + """TOD DateTime should convert to (tv_sec, tv_nsec) tuple (seconds since midnight).""" + from datetime import datetime, timezone + + # 01:00:00 = 3600 seconds since midnight + dt1 = datetime(2025, 6, 15, 1, 0, 0, tzinfo=timezone.utc) + assert convert_value_for_plc("TOD", dt1) == (3600, 0) + + # 01:30:45.500000 = 5445 seconds + 500000 microseconds + dt2 = datetime(2025, 6, 15, 1, 30, 45, 500000, tzinfo=timezone.utc) + result = convert_value_for_plc("TOD", dt2) + assert result[0] == 5445 # seconds since midnight + assert result[1] == 500_000_000 # nanoseconds + + # Midnight = 0 seconds + dt3 = datetime(2025, 6, 15, 0, 0, 0, tzinfo=timezone.utc) + assert convert_value_for_plc("TOD", dt3) == (0, 0) + + def test_time_large_values_to_tuple(self): + """TIME should handle large milliseconds values.""" + # 86400000 ms = 24 hours + assert convert_value_for_plc("TIME", 86400000) == (86400, 0) + + +class TestInferVarType: + """Tests for infer_var_type function.""" + + def test_size_1_byte(self): + """1-byte variables could be BOOL or SINT.""" + assert infer_var_type(1) == "BOOL_OR_SINT" + + def test_size_2_bytes(self): + """2-byte variables are likely UINT16/INT.""" + assert infer_var_type(2) == "UINT16" + + def test_size_4_bytes(self): + """4-byte variables could be UINT32, DINT, or TIME.""" + assert infer_var_type(4) == "UINT32_OR_TIME" + + def test_size_8_bytes(self): + """8-byte variables could be UINT64, LINT, or TIME.""" + assert infer_var_type(8) == "UINT64_OR_TIME" + + def test_size_127_bytes(self): + """127-byte variables are IEC_STRING (1 byte len + 126 bytes body).""" + assert infer_var_type(127) == "STRING" + + def test_unknown_size(self): + """Unknown sizes should return UNKNOWN.""" + assert infer_var_type(3) == "UNKNOWN" + assert infer_var_type(16) == "UNKNOWN" + assert infer_var_type(0) == "UNKNOWN" + + +class TestRoundTripConversions: + """ + Tests that verify values can be converted from PLC -> OPC-UA -> PLC + without loss of data (within type constraints). + """ + + def test_bool_roundtrip(self): + """BOOL values should survive round-trip conversion.""" + for val in [True, False]: + opcua_val = convert_value_for_opcua("BOOL", int(val)) + plc_val = convert_value_for_plc("BOOL", opcua_val) + assert plc_val == int(val) + + def test_byte_roundtrip(self): + """BYTE values should survive round-trip conversion.""" + for val in [0, 1, 127, 128, 255]: + opcua_val = convert_value_for_opcua("BYTE", val) + plc_val = convert_value_for_plc("BYTE", opcua_val) + assert plc_val == val + + def test_int_roundtrip(self): + """INT values should survive round-trip conversion.""" + for val in [0, 1, -1, 1000, -1000, 32767, -32768]: + opcua_val = convert_value_for_opcua("INT", val) + plc_val = convert_value_for_plc("INT", opcua_val) + assert plc_val == val + + def test_dint_roundtrip(self): + """DINT values should survive round-trip conversion.""" + for val in [0, 100000, -100000, 2147483647, -2147483648]: + opcua_val = convert_value_for_opcua("DINT", val) + plc_val = convert_value_for_plc("DINT", opcua_val) + assert plc_val == val + + def test_lint_roundtrip(self): + """LINT values should survive round-trip conversion.""" + for val in [0, 1000000000, -1000000000]: + opcua_val = convert_value_for_opcua("LINT", val) + plc_val = convert_value_for_plc("LINT", opcua_val) + assert plc_val == val + + def test_float_roundtrip(self): + """FLOAT values should survive round-trip conversion (with float precision).""" + for val in [0.0, 3.14159, -273.15, 1000000.5]: + # First convert float to int representation (as stored in PLC) + int_repr = struct.unpack('I', struct.pack('f', val))[0] + # Convert to OPC-UA + opcua_val = convert_value_for_opcua("FLOAT", int_repr) + # Convert back to PLC + plc_val = convert_value_for_plc("FLOAT", opcua_val) + # Unpack and compare + result = struct.unpack('f', struct.pack('I', plc_val))[0] + assert abs(result - val) < 0.0001 + + def test_real_roundtrip(self): + """REAL values should survive round-trip conversion (same as FLOAT).""" + for val in [0.0, 3.14159, -273.15, 1000000.5]: + # First convert float to int representation (as stored in PLC) + int_repr = struct.unpack('I', struct.pack('f', val))[0] + # Convert to OPC-UA + opcua_val = convert_value_for_opcua("REAL", int_repr) + # Convert back to PLC + plc_val = convert_value_for_plc("REAL", opcua_val) + # Unpack and compare + result = struct.unpack('f', struct.pack('I', plc_val))[0] + assert abs(result - val) < 0.0001 + + def test_string_roundtrip(self): + """STRING values should survive round-trip conversion.""" + for val in ["", "Hello", "Test!@#$%", "OpenPLC Runtime"]: + opcua_val = convert_value_for_opcua("STRING", val) + plc_val = convert_value_for_plc("STRING", opcua_val) + assert plc_val == val + + def test_time_roundtrip(self): + """TIME values should survive round-trip conversion (PLC tuple -> OPC-UA ms -> PLC tuple).""" + test_values = [ + (0, 0), # Zero + (1, 0), # 1 second + (1, 500_000_000), # 1.5 seconds + (10, 250_000_000), # 10.25 seconds + (3600, 0), # 1 hour + (86400, 0), # 24 hours + ] + for tv_sec, tv_nsec in test_values: + # Convert PLC tuple to OPC-UA milliseconds + opcua_val = convert_value_for_opcua("TIME", (tv_sec, tv_nsec)) + # Convert back to PLC tuple + plc_val = convert_value_for_plc("TIME", opcua_val) + # Compare (note: nanosecond precision is truncated to milliseconds) + expected_sec = tv_sec + expected_nsec = (tv_nsec // 1_000_000) * 1_000_000 # Truncate to ms precision + assert plc_val == (expected_sec, expected_nsec) + + def test_tod_roundtrip(self): + """TOD values should survive round-trip conversion.""" + test_values = [ + (0, 0), # Midnight + (3600, 0), # 1:00 AM + (43200, 0), # Noon + (43200, 500_000_000), # Noon + 500ms + ] + for tv_sec, tv_nsec in test_values: + opcua_val = convert_value_for_opcua("TOD", (tv_sec, tv_nsec)) + plc_val = convert_value_for_plc("TOD", opcua_val) + expected_sec = tv_sec + expected_nsec = (tv_nsec // 1_000_000) * 1_000_000 + assert plc_val == (expected_sec, expected_nsec) + + +class TestTimespecConversionHelpers: + """Tests for TIME conversion helper functions.""" + + def test_timespec_to_milliseconds_basic(self): + """Basic conversion: 1 second = 1000 ms.""" + assert timespec_to_milliseconds(1, 0) == 1000 + assert timespec_to_milliseconds(0, 0) == 0 + assert timespec_to_milliseconds(10, 0) == 10000 + + def test_timespec_to_milliseconds_with_nanoseconds(self): + """Conversion with nanoseconds: 1.5 sec = 1500 ms.""" + assert timespec_to_milliseconds(1, 500_000_000) == 1500 + assert timespec_to_milliseconds(0, 100_000_000) == 100 + assert timespec_to_milliseconds(2, 750_000_000) == 2750 + + def test_timespec_to_milliseconds_truncates_submillisecond(self): + """Sub-millisecond nanoseconds should be truncated.""" + # 999999 ns = 0.999999 ms, should truncate to 0 ms + assert timespec_to_milliseconds(0, 999_999) == 0 + # 1000000 ns = 1 ms + assert timespec_to_milliseconds(0, 1_000_000) == 1 + + def test_milliseconds_to_timespec_basic(self): + """Basic reverse conversion.""" + assert milliseconds_to_timespec(1000) == (1, 0) + assert milliseconds_to_timespec(0) == (0, 0) + assert milliseconds_to_timespec(10000) == (10, 0) + + def test_milliseconds_to_timespec_with_remainder(self): + """Conversion with fractional seconds.""" + assert milliseconds_to_timespec(1500) == (1, 500_000_000) + assert milliseconds_to_timespec(100) == (0, 100_000_000) + assert milliseconds_to_timespec(2750) == (2, 750_000_000) + + def test_roundtrip_conversion(self): + """Roundtrip conversion should preserve millisecond precision.""" + for ms in [0, 1, 100, 999, 1000, 1500, 10000, 86400000]: + tv_sec, tv_nsec = milliseconds_to_timespec(ms) + result = timespec_to_milliseconds(tv_sec, tv_nsec) + assert result == ms + + def test_large_time_values(self): + """Large values should work correctly.""" + # 24 hours in seconds = 86400 + assert timespec_to_milliseconds(86400, 0) == 86_400_000 + assert milliseconds_to_timespec(86_400_000) == (86400, 0) + + # 1 week in milliseconds + week_ms = 7 * 24 * 60 * 60 * 1000 + tv_sec, tv_nsec = milliseconds_to_timespec(week_ms) + assert tv_sec == 7 * 24 * 60 * 60 + assert tv_nsec == 0 + + +class TestTimeDatatypesConstant: + """Tests for TIME_DATATYPES constant.""" + + def test_time_datatypes_contains_all_time_types(self): + """TIME_DATATYPES should contain all time-related types.""" + assert "TIME" in TIME_DATATYPES + assert "DATE" in TIME_DATATYPES + assert "TOD" in TIME_DATATYPES + assert "DT" in TIME_DATATYPES + + def test_time_datatypes_is_frozen(self): + """TIME_DATATYPES should be immutable.""" + assert isinstance(TIME_DATATYPES, frozenset) diff --git a/tests/pytest/plugins/opcua/test_user_manager.py b/tests/pytest/plugins/opcua/test_user_manager.py new file mode 100644 index 00000000..57e786b5 --- /dev/null +++ b/tests/pytest/plugins/opcua/test_user_manager.py @@ -0,0 +1,681 @@ +""" +Unit tests for OPC-UA User Manager. + +Tests cover: +- Password authentication success/failure +- Certificate authentication success/failure +- Anonymous authentication with profile restrictions +- Role mapping verification +- Rate limiting / brute-force protection +""" + +import time +from dataclasses import dataclass +from typing import List, Optional +from unittest.mock import MagicMock, patch + +import pytest +from asyncua.crypto.permission_rules import User +from asyncua.server.user_managers import UserRole + +# Import after path setup in conftest +from user_manager import ( + DEFAULT_ATTEMPT_WINDOW_SECONDS, + DEFAULT_LOCKOUT_DURATION_SECONDS, + DEFAULT_MAX_ATTEMPTS, + OpenPLCUserManager, + RateLimitConfig, + RateLimiter, +) + +# ============================================================================ +# Mock Configuration Classes +# ============================================================================ + + +@dataclass +class MockSecurityProfile: + """Mock security profile for testing.""" + + name: str + enabled: bool + security_policy: str + security_mode: str + auth_methods: List[str] + + +@dataclass +class MockUser: + """Mock user configuration.""" + + type: str + username: Optional[str] + password_hash: Optional[str] + certificate_id: Optional[str] + role: str + + +@dataclass +class MockServerConfig: + """Mock server configuration.""" + + security_profiles: List[MockSecurityProfile] + + +@dataclass +class MockSecurityConfig: + """Mock security configuration.""" + + trusted_client_certificates: List[dict] + + +@dataclass +class MockOpcuaConfig: + """Mock OpcuaConfig for testing.""" + + server: MockServerConfig + security: MockSecurityConfig + users: List[MockUser] + + +def create_test_config( + users: Optional[List[MockUser]] = None, + profiles: Optional[List[MockSecurityProfile]] = None, + trusted_certs: Optional[List[dict]] = None, +) -> MockOpcuaConfig: + """Create a mock config for testing.""" + if users is None: + users = [] + if profiles is None: + profiles = [ + MockSecurityProfile( + name="insecure", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username", "Anonymous"], + ) + ] + if trusted_certs is None: + trusted_certs = [] + + return MockOpcuaConfig( + server=MockServerConfig(security_profiles=profiles), + security=MockSecurityConfig(trusted_client_certificates=trusted_certs), + users=users, + ) + + +# ============================================================================ +# Rate Limiter Tests +# ============================================================================ + + +class TestRateLimiter: + """Tests for the RateLimiter class.""" + + def test_init_default_config(self): + """Test RateLimiter initializes with default config.""" + limiter = RateLimiter() + assert limiter.config.max_attempts == DEFAULT_MAX_ATTEMPTS + assert limiter.config.lockout_duration_seconds == DEFAULT_LOCKOUT_DURATION_SECONDS + assert limiter.config.attempt_window_seconds == DEFAULT_ATTEMPT_WINDOW_SECONDS + + def test_init_custom_config(self): + """Test RateLimiter initializes with custom config.""" + config = RateLimitConfig( + max_attempts=3, lockout_duration_seconds=60, attempt_window_seconds=30 + ) + limiter = RateLimiter(config) + assert limiter.config.max_attempts == 3 + assert limiter.config.lockout_duration_seconds == 60 + assert limiter.config.attempt_window_seconds == 30 + + def test_not_locked_out_initially(self): + """Test that new identifiers are not locked out.""" + limiter = RateLimiter() + assert limiter.is_locked_out("user:test") is False + + def test_lockout_after_max_attempts(self): + """Test lockout is triggered after max failed attempts.""" + config = RateLimitConfig(max_attempts=3, lockout_duration_seconds=60) + limiter = RateLimiter(config) + + identifier = "user:attacker" + + # Record failed attempts up to max + for i in range(3): + assert limiter.is_locked_out(identifier) is False + limiter.record_attempt(identifier, success=False) + + # Should now be locked out + assert limiter.is_locked_out(identifier) is True + + def test_successful_auth_resets_tracker(self): + """Test that successful auth resets the attempt counter.""" + config = RateLimitConfig(max_attempts=3) + limiter = RateLimiter(config) + + identifier = "user:test" + + # Record some failed attempts + limiter.record_attempt(identifier, success=False) + limiter.record_attempt(identifier, success=False) + + # Successful auth + limiter.record_attempt(identifier, success=True) + + # Should not be locked out + assert limiter.is_locked_out(identifier) is False + + # Should be able to fail again without immediate lockout + limiter.record_attempt(identifier, success=False) + assert limiter.is_locked_out(identifier) is False + + def test_lockout_remaining_time(self): + """Test getting remaining lockout time.""" + config = RateLimitConfig(max_attempts=1, lockout_duration_seconds=10) + limiter = RateLimiter(config) + + identifier = "user:test" + + # Not locked out initially + assert limiter.get_lockout_remaining(identifier) == 0.0 + + # Trigger lockout + limiter.record_attempt(identifier, success=False) + + # Should have remaining time + remaining = limiter.get_lockout_remaining(identifier) + assert 9 <= remaining <= 10 + + def test_lockout_expires(self): + """Test that lockout expires after duration.""" + config = RateLimitConfig( + max_attempts=1, + lockout_duration_seconds=0.1, # 100ms for fast test + ) + limiter = RateLimiter(config) + + identifier = "user:test" + + # Trigger lockout + limiter.record_attempt(identifier, success=False) + assert limiter.is_locked_out(identifier) is True + + # Wait for lockout to expire + time.sleep(0.15) + + # Should no longer be locked out + assert limiter.is_locked_out(identifier) is False + + def test_attempt_window_reset(self): + """Test that attempt count resets after window expires.""" + config = RateLimitConfig(max_attempts=3, attempt_window_seconds=0.1) # 100ms window + limiter = RateLimiter(config) + + identifier = "user:test" + + # Record 2 failed attempts + limiter.record_attempt(identifier, success=False) + limiter.record_attempt(identifier, success=False) + + # Wait for window to expire + time.sleep(0.15) + + # Record more attempts - should reset count + limiter.record_attempt(identifier, success=False) + limiter.record_attempt(identifier, success=False) + + # Should not be locked out (only 2 attempts in new window) + assert limiter.is_locked_out(identifier) is False + + def test_cleanup_expired(self): + """Test cleanup of expired trackers.""" + config = RateLimitConfig( + max_attempts=1, lockout_duration_seconds=0.05, attempt_window_seconds=0.05 + ) + limiter = RateLimiter(config) + + # Create some trackers + limiter.record_attempt("user:a", success=False) + limiter.record_attempt("user:b", success=False) + + # Wait for expiration + time.sleep(0.1) + + # Cleanup should remove expired entries + removed = limiter.cleanup_expired() + assert removed == 2 + assert len(limiter._trackers) == 0 + + +# ============================================================================ +# OpenPLCUserManager Tests +# ============================================================================ + + +class TestOpenPLCUserManager: + """Tests for the OpenPLCUserManager class.""" + + def test_init_with_password_users(self): + """Test initialization with password users.""" + users = [ + MockUser( + type="password", + username="operator", + password_hash="$2b$10$hash", + certificate_id=None, + role="operator", + ), + MockUser( + type="password", + username="engineer", + password_hash="$2b$10$hash2", + certificate_id=None, + role="engineer", + ), + ] + config = create_test_config(users=users) + + manager = OpenPLCUserManager(config) + + assert len(manager.users) == 2 + assert "operator" in manager.users + assert "engineer" in manager.users + assert manager._user_roles["operator"] == "operator" + assert manager._user_roles["engineer"] == "engineer" + + def test_init_with_certificate_users(self): + """Test initialization with certificate users.""" + users = [ + MockUser( + type="certificate", + username=None, + password_hash=None, + certificate_id="cert1", + role="engineer", + ), + ] + config = create_test_config(users=users) + + manager = OpenPLCUserManager(config) + + assert len(manager.cert_users) == 1 + assert "cert1" in manager.cert_users + assert manager._user_roles["cert:cert1"] == "engineer" + + def test_init_with_rate_limit_config(self): + """Test initialization with custom rate limit config.""" + config = create_test_config() + rate_config = RateLimitConfig(max_attempts=10) + + manager = OpenPLCUserManager(config, rate_limit_config=rate_config) + + assert manager.rate_limiter.config.max_attempts == 10 + + +class TestPasswordAuthentication: + """Tests for password authentication.""" + + @pytest.fixture + def manager_with_user(self): + """Create manager with a test user.""" + # BCrypt hash for "testpass123" + password_hash = "$2b$10$rJrPxLxGxNzVzqZqxZqZqO1234567890123456789012345678901234" + + users = [ + MockUser( + type="password", + username="testuser", + password_hash=password_hash, + certificate_id=None, + role="operator", + ), + ] + profiles = [ + MockSecurityProfile( + name="test", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ) + ] + config = create_test_config(users=users, profiles=profiles) + return OpenPLCUserManager(config) + + def test_auth_fails_user_not_found(self, manager_with_user): + """Test authentication fails for unknown user.""" + user = manager_with_user.get_user(None, username="unknown", password="password") + assert user is None + + def test_auth_fails_wrong_password(self, manager_with_user): + """Test authentication fails for wrong password.""" + with patch("user_manager.bcrypt") as mock_bcrypt: + mock_bcrypt.checkpw.return_value = False + + user = manager_with_user.get_user(None, username="testuser", password="wrongpass") + assert user is None + + def test_auth_success_returns_user_object(self, manager_with_user): + """Test successful authentication returns asyncua User object.""" + with patch("user_manager.bcrypt") as mock_bcrypt: + mock_bcrypt.checkpw.return_value = True + + user = manager_with_user.get_user(None, username="testuser", password="testpass123") + + assert user is not None + assert isinstance(user, User) + assert user.name == "testuser" + assert user.role == UserRole.User # operator maps to User + assert user.openplc_role == "operator" + + def test_engineer_role_maps_to_admin(self): + """Test engineer role maps to UserRole.Admin.""" + password_hash = "$2b$10$test" + + users = [ + MockUser( + type="password", + username="admin", + password_hash=password_hash, + certificate_id=None, + role="engineer", + ), + ] + profiles = [ + MockSecurityProfile( + name="test", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ) + ] + config = create_test_config(users=users, profiles=profiles) + manager = OpenPLCUserManager(config) + + with patch("user_manager.bcrypt") as mock_bcrypt: + mock_bcrypt.checkpw.return_value = True + + user = manager.get_user(None, username="admin", password="pass") + + assert user is not None + assert user.role == UserRole.Admin + assert user.openplc_role == "engineer" + + +class TestAnonymousAuthentication: + """Tests for anonymous authentication.""" + + def test_anonymous_allowed_when_profile_supports(self): + """Test anonymous auth succeeds when profile allows it.""" + profiles = [ + MockSecurityProfile( + name="insecure", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Anonymous"], + ) + ] + config = create_test_config(profiles=profiles) + manager = OpenPLCUserManager(config) + + user = manager.get_user(None) # No credentials = anonymous + + assert user is not None + assert isinstance(user, User) + assert user.name == "anonymous" + assert user.role == UserRole.User + assert user.openplc_role == "viewer" + + def test_anonymous_denied_when_profile_disallows(self): + """Test anonymous auth fails when profile doesn't allow it.""" + profiles = [ + MockSecurityProfile( + name="secure", + enabled=True, + security_policy="Basic256Sha256", + security_mode="SignAndEncrypt", + auth_methods=["Username", "Certificate"], # No Anonymous + ) + ] + config = create_test_config(profiles=profiles) + manager = OpenPLCUserManager(config) + + user = manager.get_user(None) # No credentials = anonymous + + assert user is None + + +class TestRateLimitingIntegration: + """Tests for rate limiting in authentication.""" + + def test_lockout_blocks_authentication(self): + """Test that locked out users cannot authenticate.""" + password_hash = "$2b$10$test" + + users = [ + MockUser( + type="password", + username="testuser", + password_hash=password_hash, + certificate_id=None, + role="operator", + ), + ] + profiles = [ + MockSecurityProfile( + name="test", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ) + ] + config = create_test_config(users=users, profiles=profiles) + + # Use low max_attempts for testing + rate_config = RateLimitConfig(max_attempts=2, lockout_duration_seconds=60) + manager = OpenPLCUserManager(config, rate_limit_config=rate_config) + + with patch("user_manager.bcrypt") as mock_bcrypt: + mock_bcrypt.checkpw.return_value = False + + # First two attempts should fail but not lock out + manager.get_user(None, username="testuser", password="wrong") + result = manager.get_user(None, username="testuser", password="wrong") + assert result is None + + # Third attempt should be blocked by rate limiting + mock_bcrypt.checkpw.return_value = True # Even correct password + result = manager.get_user(None, username="testuser", password="correct") + assert result is None # Blocked by lockout + + def test_successful_auth_resets_rate_limit(self): + """Test successful authentication resets rate limit counter.""" + password_hash = "$2b$10$test" + + users = [ + MockUser( + type="password", + username="testuser", + password_hash=password_hash, + certificate_id=None, + role="operator", + ), + ] + profiles = [ + MockSecurityProfile( + name="test", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ) + ] + config = create_test_config(users=users, profiles=profiles) + + rate_config = RateLimitConfig(max_attempts=3) + manager = OpenPLCUserManager(config, rate_limit_config=rate_config) + + with patch("user_manager.bcrypt") as mock_bcrypt: + # Two failed attempts + mock_bcrypt.checkpw.return_value = False + manager.get_user(None, username="testuser", password="wrong") + manager.get_user(None, username="testuser", password="wrong") + + # Successful auth + mock_bcrypt.checkpw.return_value = True + result = manager.get_user(None, username="testuser", password="correct") + assert result is not None + + # Should be able to fail again without lockout + mock_bcrypt.checkpw.return_value = False + manager.get_user(None, username="testuser", password="wrong") + manager.get_user(None, username="testuser", password="wrong") + + # Still not locked out (counter was reset) + mock_bcrypt.checkpw.return_value = True + result = manager.get_user(None, username="testuser", password="correct") + assert result is not None + + +class TestAuthMethodDetection: + """Tests for authentication method detection.""" + + def test_detect_username_method(self): + """Test detection of username/password method.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + method = manager._detect_auth_method("user", "pass", None) + assert method == "Username" + + def test_detect_certificate_method(self): + """Test detection of certificate method.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + mock_cert = MagicMock() + method = manager._detect_auth_method(None, None, mock_cert) + assert method == "Certificate" + + def test_detect_anonymous_method(self): + """Test detection of anonymous method.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + method = manager._detect_auth_method(None, None, None) + assert method == "Anonymous" + + def test_username_takes_precedence_over_certificate(self): + """Test that username/password takes precedence over certificate.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + mock_cert = MagicMock() + method = manager._detect_auth_method("user", "pass", mock_cert) + assert method == "Username" + + +class TestRoleMappings: + """Tests for role mapping.""" + + def test_viewer_maps_to_user(self): + """Test viewer role maps to UserRole.User.""" + assert OpenPLCUserManager.ROLE_MAPPING["viewer"] == UserRole.User + + def test_operator_maps_to_user(self): + """Test operator role maps to UserRole.User.""" + assert OpenPLCUserManager.ROLE_MAPPING["operator"] == UserRole.User + + def test_engineer_maps_to_admin(self): + """Test engineer role maps to UserRole.Admin.""" + assert OpenPLCUserManager.ROLE_MAPPING["engineer"] == UserRole.Admin + + +class TestProfileMatching: + """Tests for security profile matching.""" + + def test_finds_enabled_profile(self): + """Test finding an enabled profile that supports auth method.""" + profiles = [ + MockSecurityProfile( + name="disabled", + enabled=False, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ), + MockSecurityProfile( + name="enabled", + enabled=True, + security_policy="None", + security_mode="None", + auth_methods=["Username"], + ), + ] + config = create_test_config(profiles=profiles) + manager = OpenPLCUserManager(config) + + profile = manager._find_profile_by_auth_method("Username") + + assert profile is not None + assert profile.name == "enabled" + + def test_returns_none_when_no_matching_profile(self): + """Test returns None when no profile supports auth method.""" + profiles = [ + MockSecurityProfile( + name="cert_only", + enabled=True, + security_policy="Basic256Sha256", + security_mode="SignAndEncrypt", + auth_methods=["Certificate"], + ), + ] + config = create_test_config(profiles=profiles) + manager = OpenPLCUserManager(config) + + profile = manager._find_profile_by_auth_method("Username") + + assert profile is None + + +class TestRateLimitIdentifier: + """Tests for rate limit identifier generation.""" + + def test_identifier_for_username(self): + """Test identifier generation for username auth.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + identifier = manager._get_rate_limit_identifier("testuser", None) + assert identifier == "user:testuser" + + def test_identifier_for_certificate(self): + """Test identifier generation for certificate auth.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + # Mock certificate with bytes data + mock_cert = b"certificate_data" + + with patch.object(manager, "_cert_to_fingerprint") as mock_fp: + mock_fp.return_value = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90" + + identifier = manager._get_rate_limit_identifier(None, mock_cert) + + assert identifier is not None + assert identifier.startswith("cert:") + + def test_identifier_none_for_anonymous(self): + """Test no identifier for anonymous auth.""" + config = create_test_config() + manager = OpenPLCUserManager(config) + + identifier = manager._get_rate_limit_identifier(None, None) + assert identifier is None