diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..01d36195 --- /dev/null +++ b/.clang-format @@ -0,0 +1,8 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +UseTab: Never +ColumnLimit: 100 +BreakBeforeBraces: Allman # (ou Attach, Linux, etc.) +AllowShortFunctionsOnASingleLine: Empty +SpaceBeforeParens: ControlStatements +AlignConsecutiveAssignments: true \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..1828c119 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,42 @@ +name: Build and Publish Multi-Arch Docker Image + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main, development ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.GHCR_TOKEN }} + + - name: Build and Push Multi-Arch Image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: | + ghcr.io/autonomy-logic/openplc-runtime:latest + ghcr.io/autonomy-logic/openplc-runtime:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..663e2838 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Ignore all files in the build output directory +/build*/ +/core/generated/ +/memory-bank + +.vscode/ +#.*/ +/venvs/ +.venv/ +__pycache__/ +.clinerules + +# Temporary files +*.txt +*.log +*.env +*.pem +*.db +*.socket +*.installed +*.egg-info +*.pytest_cache/ + +# Ignore all object files and shared libraries +*.o +*.so diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7ba3f4de --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,52 @@ +repos: + - repo: https://github.com/pycqa/pylint + rev: v3.3.8 + hooks: + - id: pylint + # args: [--fail-on=F,E] + args: [ + "--disable=C0114,C0411,C0115,C0116,C0412,E0401,W0718,R1702,R0911,R0912,R0915,R1705,W0404,W0603,W0613,W0511", + ] # example: disable missing-docstring + additional_dependencies: + [flask, flask-sqlalchemy, flask_jwt_extended, werkzeug] + entry: pylint + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + args: ["--profile=black", "--filter-files"] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.11 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + args: [] + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.17.1 + # hooks: + # - id: mypy + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v21.1.0 # Let autoupdate find the latest + hooks: + - id: clang-format + files: \.(c|h)$ + args: [--style=file] # Assumes a .clang-format file is present + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 # Use this specific modern version + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + args: ["--maxkb=500"] + # The following hooks are very stable and widely available + - id: check-ast # Check that files contain valid syntax (for any language) + - id: check-case-conflict # Check for files that would conflict in case-insensitive filesystems + - id: check-json # Check JSON files for validity + - id: check-yaml # Check YAML files for validity + - id: destroyed-symlinks # Check for destroyed symlinks + - id: detect-private-key # Detect the presence of private keys diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d0719a38 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "files.associations": { + "sched.h": "c", + "scan_cycle_manager.h": "c", + "dlfcn.h": "c", + "utils.h": "c", + "plc_state_manager.h": "c", + "unix_socket.h": "c", + "plugin_driver.h": "c" + }, + "editor.rulers": [ + 80 + ], + "[c]": { + "editor.defaultFormatter": "xaver.clang-format" + }, + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..1de9359e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.10) +project(plc_project C) + +# add_subdirectory(./core/generated) +add_subdirectory(./core/src) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5528a3ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM debian:bookworm-slim + +WORKDIR /workdir + +# Copy source code +COPY . . + +# Setup runtime directory and permissions +RUN mkdir -p /var/run/runtime && \ + chmod +x install.sh scripts/* start_openplc.sh + +# Clean any existing build artifacts to ensure clean Docker build +RUN rm -rf build/ venvs/ .venv/ 2>/dev/null || true + +# Run installation script +RUN ./install.sh + +# Expose webserver port +EXPOSE 8443 + +# Default execution - Start OpenPLC Runtime +CMD ["bash", "./start_openplc.sh"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..eff806cb --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,19 @@ +# Dockerfile +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + python3 python3-venv python3-pip bash \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workdir +COPY . . +RUN mkdir -p /var/run/runtime +# Clean any existing build artifacts to ensure clean Docker build +RUN rm -rf build/ venvs/ 2>/dev/null || true +RUN chmod +x install.sh scripts/* start_openplc.sh +RUN ./install.sh + +EXPOSE 8443 + +CMD ["bash", "./scripts/setup-tests-env.sh"] diff --git a/README.md b/README.md index 51743c0c..e8218515 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,192 @@ -# openplc-runtime +# OpenPLC Runtime v4 + OpenPLC Runtime v4 designed to run programs built on OpenPLC Editor v4 + +## Prerequisites + +### Linux + +- GCC compiler +- Make +- CMake +- Python (for xml2st tool) + +### Windows + +- Python (required to run xml2st) +- Docker Desktop (contains GCC and Make internally) + +## Run project + +### Docker + +1. Build image + +``` + sudo bash ./scripts/build-docker-image.sh +``` + +2. Run image + +``` + sudo bash ./scripts/run-image.sh +``` + +### Linux + +1. Installation + +``` + sudo bash ./install.sh +``` + +2. Start + +``` + sudo bash ./start_openplc.sh +``` + +## Unit Tests + +1. Run locally + +``` + sudo bash ./scripts/setup-tests-env.sh +``` + + or + +2. Build development docker image + +``` + sudo bash ./scripts/build-docker-image-dev.sh +``` + +3. Run development docker image + +``` + sudo bash ./scripts/run-image-dev.sh +``` + +## Pre-commit setup + +1. Install + +``` + pip install pre-commit + pre-commit install +``` + +2. Normal commit + + ``` + git add . + git commit -m "Your commit message" # Pre-commit checks run automatically + + ``` + +3. Skip pre-commit checks + + ``` + git commit --no-verify -m "Your commit message" + ``` + +Use the --no-verify flag for quick fixes or when pre-commit checks aren't necessary. This bypasses all pre-commit validation for that specific commit. + +## πŸ› οΈ Build Instructions + +### Step 1: Build Application in OpenPLC IDE + +1. Create your PLC program in the **OpenPLC IDE** +2. **Important**: Your project must include **Location Variables (IEC 61131-3)** to compile successfully +3. Click **Compile** in the IDE +4. This will generate a `LOCATED_VARIABLES.h` file in `{projectfolder}/build/OpenPLC Runtime/src/` + +### Step 2: Generate glueVars.c with XML2ST + +1. Clone and setup xml2st: + + ```bash + git clone https://github.com/Autonomy-Logic/xml2st.git + cd xml2st + git switch development + ``` + +2. Run xml2st with the path to your LOCATED_VARIABLES.h: + + ```bash + xml2st --generate-gluevars /path/to/LOCATED_VARIABLES.h + ``` + +3. Copy **all generated files** from `{projectfolder}/build/OpenPLC Runtime/src/` to `core/generated/` directory in this project + +### Step 3: Build Runtime + +#### 🐧 Linux Build + +```bash +# Build the runtime core +mkdir build +cd build +cmake .. +make +``` + +#### πŸͺŸ Windows Build (Docker) + +1. **Build Docker image** (run from project root): + + ```bash + docker build -t openplc-runtime . + ``` + +2. **Run Docker container** in interactive mode: + + ```bash + docker run -it --rm -v ${PWD}:/workspace openplc-runtime bash + ``` + +3. **Inside the Docker container**, build the project: + + ```bash + mkdir build + cd build + cmake .. + make + ``` + +## πŸš€ Running the Application + +After successful compilation, you can run the OpenPLC Runtime: + +```bash +./plc_main +``` + +## πŸ“ Project Structure + +``` +openplc-runtime/ +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ src/ # Core runtime source files +β”‚ └── generated/ # Generated files from OpenPLC IDE (copy here) +β”œβ”€β”€ scripts/ # Build scripts +β”œβ”€β”€ Dockerfile # Docker configuration for Windows builds +└── CMakeLists.txt # CMake build configuration +``` + +## πŸ”§ Troubleshooting + +### Common Issues + +- **Missing Location Variables**: Ensure your OpenPLC IDE project includes IEC 61131-3 location variables +- **Docker not found**: Install Docker Desktop on Windows +- **Build failures**: Verify all generated files are copied to `core/generated/` + +### File Requirements + +The `core/generated/` directory should contain: + +- `glueVars.c` (generated by xml2st) +- `LOCATED_VARIABLES.h` (from OpenPLC IDE compilation) +- Other generated files from OpenPLC IDE build process diff --git a/core/src/CMakeLists.txt b/core/src/CMakeLists.txt new file mode 100644 index 00000000..078914de --- /dev/null +++ b/core/src/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.10) +project(plc_application C) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Find Python development libraries +find_package(PkgConfig REQUIRED) +pkg_check_modules(PYTHON REQUIRED python3-embed) + +# Include directories +include_directories(${CMAKE_SOURCE_DIR}/core/src/plc_app + ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils + ${CMAKE_SOURCE_DIR}/core/generated/plc_lib + ${CMAKE_SOURCE_DIR}/core/generated/plc_lib/lib + ${PYTHON_INCLUDE_DIRS}) + +# Compiler options +add_compile_options(-Wall -Werror -Wextra -fstack-protector-strong + -D_FORTIFY_SOURCE=2 -O2 -Wformat + -Werror=format-security -fPIC -fPIE) + + +# Step 3: Build the executable and link against the shared library +add_executable(plc_main + ${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_main.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/log.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/utils.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/watchdog.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/image_tables.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_state_manager.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/plcapp_manager.c + ${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/plc_app/unix_socket.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_handler.c +) + +# Link against shared library +target_link_libraries(plc_main + dl + pthread + ${PYTHON_LIBRARIES} +) + +# Ensure executable can find shared library at runtime +set_target_properties(plc_main PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR} +) diff --git a/core/src/drivers/README.md b/core/src/drivers/README.md new file mode 100644 index 00000000..c50f2c69 --- /dev/null +++ b/core/src/drivers/README.md @@ -0,0 +1,641 @@ +# OpenPLC Runtime Plugin System + +This directory contains the OpenPLC Runtime plugin system, which allows extending the runtime with custom drivers and communication protocols. **Currently, the system actively supports Python plugins. Support for native C plugins is planned for a future release.** + +## Overview + +The plugin system provides a flexible architecture for integrating external hardware drivers, communication protocols, and custom logic into the OpenPLC Runtime. It offers thread-safe access to OpenPLC I/O buffers. + +**Current Status:** +* **Supported:** Python plugins (`.py` files) are fully supported and operational. +* **Planned:** Native C plugins (`.so` files) are part of the design and API but are not yet implemented or functional. All references to native C plugins in this document describe the intended future functionality. + +## Architecture + +### Core Components + +``` +core/src/drivers/ +β”œβ”€β”€ plugin_driver.c/h # Main plugin driver system +β”œβ”€β”€ plugin_config.c/h # Configuration file parsing +β”œβ”€β”€ python_plugin_bridge.c/h # Python plugin integration +β”œβ”€β”€ CMakeLists.txt # Build configuration +β”œβ”€β”€ plugins/python/ # Python plugin implementations +β”‚ β”œβ”€β”€ examples/ # Python plugin examples +β”‚ β”œβ”€β”€ modbus_slave/ # Modbus TCP slave Python plugin +β”‚ └── shared/ # Shared Python modules (e.g., type definitions) +└── *.py # Standalone Python plugin files (if any) +``` + +### Plugin Types + +1. **Python Plugins** (`PLUGIN_TYPE_PYTHON = 0`) - **Currently Supported** + * Python scripts (`.py` files). + * Embedded Python interpreter. + * Easier development and debugging. + * Enhanced type safety and buffer access with `python_plugin_types.py`. + +2. **Native C Plugins** (`PLUGIN_TYPE_NATIVE = 1`) - **Future Support** + * Compiled shared libraries (`.so` files). + * Direct C function calls. + * Maximum performance (intended). + * *Note: This plugin type is defined in the API but not yet implemented.* + +## Plugin Interface + +### Required Functions + +All plugins must implement these core functions. The lifecycle (`init`, `start_loop`, `stop_loop`, `cleanup`) is managed by the plugin driver. + +#### Python Plugins (Currently Supported) +```python +def init(runtime_args_capsule): + """ + Initialize plugin with runtime arguments. + Args: + runtime_args_capsule: PyCapsule containing plugin_runtime_args_t. + Returns: + bool: True if initialization successful, False otherwise. + """ + pass + +# Optional functions, but recommended for full lifecycle management +def start_loop(): + """Called when plugin should start its main operations (e.g., start a server).""" + pass + +def stop_loop(): + """Called when plugin should stop its main operations (e.g., stop a server).""" + pass + +def cleanup(): + """Called when plugin is being unloaded; release all resources.""" + pass +``` + +#### Native C Plugins (Future Support - API Defined) +```c +// Mandatory initialization function +int init(plugin_runtime_args_t *args); + +// Optional lifecycle functions +void start_loop(void); +void stop_loop(void); +void run_cycle(void); + +// Mandatory cleanup function +void cleanup(void); +``` +*Note: The C API is defined but the loading and execution mechanism for native plugins is not yet implemented.* + +### Runtime Arguments Structure + +Plugins receive access to OpenPLC buffers through the `plugin_runtime_args_t` structure, passed as a PyCapsule to Python plugins: + +```c +typedef struct { + // I/O Buffer pointers + IEC_BOOL *(*bool_input)[8]; // Digital inputs + IEC_BOOL *(*bool_output)[8]; // Digital outputs + IEC_BYTE **byte_input; // Byte inputs + IEC_BYTE **byte_output; // Byte outputs + IEC_UINT **int_input; // 16-bit integer inputs + IEC_UINT **int_output; // 16-bit integer outputs + IEC_UDINT **dint_input; // 32-bit integer inputs + IEC_UDINT **dint_output; // 32-bit integer outputs + IEC_ULINT **lint_input; // 64-bit integer inputs + IEC_ULINT **lint_output; // 64-bit integer outputs + IEC_UINT **int_memory; // Internal memory (16-bit) + IEC_UDINT **dint_memory; // Internal memory (32-bit) + IEC_ULINT **lint_memory; // Internal memory (64-bit) + + // Thread synchronization + int (*mutex_take)(pthread_mutex_t *mutex); + int (*mutex_give)(pthread_mutex_t *mutex); + pthread_mutex_t *buffer_mutex; + + // Buffer metadata + int buffer_size; // Number of buffers + int bits_per_buffer; // Bits per boolean buffer (typically 8) +} plugin_runtime_args_t; +``` + +## Thread-Safe Buffer Access + +### Enhanced Python SafeBufferAccess (Recommended) + +The `plugins/python/shared/python_plugin_types.py` module provides a `SafeBufferAccess` wrapper class for robust and safe buffer operations. This is the recommended way to interact with OpenPLC buffers. + +```python +from plugins.python.shared.python_plugin_types import SafeBufferAccess, safe_extract_runtime_args_from_capsule + +def init(runtime_args_capsule): + # Safely extract runtime arguments from the PyCapsule + runtime_args, error_msg = safe_extract_runtime_args_from_capsule(runtime_args_capsule) + if runtime_args is None: + print(f"Failed to extract runtime args: {error_msg}") + return False + + # Create safe buffer access wrapper + safe_buffer = SafeBufferAccess(runtime_args) + if not safe_buffer.is_valid: + print(f"Failed to create SafeBufferAccess: {safe_buffer.error_msg}") + return False + + global safe_access + safe_access = safe_buffer + return True + +def example_use(): + # Safe read operation from a boolean output buffer + value, error_msg = safe_access.safe_read_bool_output(buffer_idx=0, bit_idx=0) + if error_msg == "Success": + print(f"Read value: {value}") + else: + print(f"Read error: {error_msg}") + + # Safe write operation to a boolean output buffer + success, error_msg = safe_access.safe_write_bool_output(buffer_idx=0, bit_idx=0, True) + if error_msg == "Success": + print("Write successful") + else: + print(f"Write error: {error_msg}") +``` + +### Manual Python Example (Legacy/For Understanding) +While `SafeBufferAccess` is recommended, understanding the underlying mutex operations is useful: +```python +def manual_safe_read_output(runtime_args, buffer_idx, bit_pos): + """Manually and safely read a boolean output.""" + try: + if runtime_args.mutex_take(runtime_args.buffer_mutex) == 0: + value = runtime_args.bool_output[buffer_idx][bit_pos].contents.value + return bool(value) + finally: + runtime_args.mutex_give(runtime_args.buffer_mutex) + return False # Default or error value + +def manual_safe_write_output(runtime_args, buffer_idx, bit_pos, value): + """Manually and safely write a boolean output.""" + try: + if runtime_args.mutex_take(runtime_args.buffer_mutex) == 0: + runtime_args.bool_output[buffer_idx][bit_pos].contents.value = 1 if value else 0 + return True + finally: + runtime_args.mutex_give(runtime_args.buffer_mutex) + return False +``` + +## Configuration + +### Plugin Configuration File Format (e.g., `plugins.conf`) + +Plugins are configured via a text file, typically `plugins.conf`, located in the project root. Each line defines a plugin: + +``` +# Format: name,path,enabled,type,plugin_related_config_path +# Example for a Python Modbus Slave plugin: +modbus_slave,./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py,1,0,./core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json +# Example for a custom Python plugin: +my_custom_plugin,./core/src/drivers/plugins/python/examples/my_custom_plugin.py,1,0,./my_custom_plugin_config.ini +# Example for a future Native C plugin (not yet supported): +# future_native_plugin,./plugins/native/example.so,1,1,./config/example.conf +``` + +**Fields:** +* `name`: A unique identifier for the plugin. +* `path`: Path to the plugin file (`.py` for Python, `.so` for native C). +* `enabled`: `1` for enabled, `0` for disabled. +* `type`: `0` for Python (`PLUGIN_TYPE_PYTHON`), `1` for Native C (`PLUGIN_TYPE_NATIVE`). **Currently, only `0` is functional.** +* `plugin_related_config_path`: (Optional) Path to a plugin-specific configuration file (e.g., `.ini`, `.json`, `.conf`). + +### Loading Configuration in Code +The configuration is loaded and managed by the plugin driver: +```c +#include "plugin_driver.h" + +// Create, load config, initialize, and start the plugin system +plugin_driver_t *driver = plugin_driver_create(); +if (driver) { + if (plugin_driver_load_config(driver, "plugins.conf") == 0) { + if (plugin_driver_init(driver) == 0) { + plugin_driver_start(driver); // Starts enabled plugins + } + } + // Remember to stop and destroy the driver when shutting down + // plugin_driver_stop(driver); + // plugin_driver_destroy(driver); +} +``` + +## Examples + +### 1. Basic Python Plugin Template + +See `plugins/python/examples/example_python_plugin.py` for a foundational template demonstrating: +* Plugin initialization with `safe_extract_runtime_args_from_capsule`. +* Using `SafeBufferAccess` for I/O. +* Basic lifecycle management (`init`, `cleanup`). + +### 2. Modbus TCP Slave (Python) + +The `plugins/python/modbus_slave/simple_modbus.py` provides a comprehensive implementation of a Modbus TCP slave server, mapping OpenPLC I/O points to Modbus registers/coils. + +**Features:** +* Maps `bool_input`/`bool_output` to Modbus Discrete Inputs and Coils. +* Maps `int_input`/`int_output` (and potentially `dint_*`, `lint_*`) to Modbus Input and Holding Registers. +* Supports standard Modbus function codes (01, 02, 03, 04, 05, 06, 0F, 10). +* Full asynchronous operation using `pymodbus` and `asyncio`. +* Thread-safe buffer access via `SafeBufferAccess`. +* Configurable via a JSON file (e.g., `modbus_slave_config.json`). + +**Configuration Example (`modbus_slave_config.json`):** +```json +{ + "host": "0.0.0.0", + "port": 5020, + "max_coils": 8000, + "max_discrete_inputs": 8000, + "max_holding_registers": 8000, + "max_input_registers": 8000, + "buffer_mappings": { + "coils_start_buffer": 0, + "coils_start_bit": 0, + "discrete_inputs_start_buffer": 0, + "discrete_inputs_start_bit": 0, + "holding_registers_start_buffer": 0, + "input_registers_start_buffer": 0 + } +} +``` + +**Usage:** +The plugin is typically loaded and started automatically by the OpenPLC runtime if configured in `plugins.conf`. +For standalone testing: +```bash +python3 ./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py +``` + +## Python Plugin Type System and Safety + +### Enhanced Type Safety with `python_plugin_types.py` + +The `plugins/python/shared/python_plugin_types.py` module is crucial for developing robust Python plugins. It provides: + +#### Key Components + +1. **`PluginRuntimeArgs` (ctypes Structure)** + * An exact `ctypes` mapping of the C `plugin_runtime_args_t` structure. + * Includes a `safe_access_buffer_size()` method for validated buffer size retrieval. + +2. **`SafeBufferAccess` Wrapper** + * The primary interface for thread-safe I/O buffer operations. + * Handles `mutex_take`/`mutex_give` automatically. + * Provides clear error messages for invalid access attempts (e.g., out of bounds, null pointers). + +3. **`PluginStructureValidator`** + * Utilities for debugging, such as `print_structure_info()` to verify `ctypes` structure alignment and sizes against the C definitions. + +4. **`safe_extract_runtime_args_from_capsule(runtime_args_capsule)`** + * The **recommended** function to extract the `plugin_runtime_args_t` pointer from the `PyCapsule` passed to the `init` function. + * Performs comprehensive error checking (capsule validity, name, null pointer) and returns a tuple `(runtime_args_ptr, error_message)`. + +#### Usage Example (Reiterated from SafeBufferAccess) +```python +from plugins.python.shared.python_plugin_types import ( + PluginRuntimeArgs, # For type hinting or direct use if extraction is manual + safe_extract_runtime_args_from_capsule, + SafeBufferAccess, + PluginStructureValidator +) + +def init(runtime_args_capsule): + global _safe_buffer_access + + # Optional: Print structure info for debugging during development + # PluginStructureValidator.print_structure_info() + + # Safely extract runtime args from capsule + runtime_args, error_msg = safe_extract_runtime_args_from_capsule(runtime_args_capsule) + if runtime_args is None: + print(f"[Plugin Error] Initialization failed: {error_msg}") + return False + + # Create the safe buffer access wrapper + _safe_buffer_access = SafeBufferAccess(runtime_args) + if not _safe_buffer_access.is_valid: + print(f"[Plugin Error] Failed to initialize SafeBufferAccess: {_safe_buffer_access.error_msg}") + return False + + print("Plugin initialized successfully.") + return True + +# ... other functions (start_loop, run_cycle, cleanup) can use _safe_buffer_access ... +``` + +## Development Guide + +### Creating a Python Plugin + +1. **Create your plugin file**, e.g., `my_driver.py`, in a suitable location like `plugins/python/` or a project-specific subdirectory. + ```python + #!/usr/bin/env python3 + from plugins.python.shared.python_plugin_types import ( + safe_extract_runtime_args_from_capsule, + SafeBufferAccess + ) + + _safe_buffer_access = None + + def init(runtime_args_capsule): + global _safe_buffer_access + print("MyDriver: Initializing...") + + runtime_args, error_msg = safe_extract_runtime_args_from_capsule(runtime_args_capsule) + if runtime_args is None: + print(f"MyDriver Error: {error_msg}") + return False + + _safe_buffer_access = SafeBufferAccess(runtime_args) + if not _safe_buffer_access.is_valid: + print(f"MyDriver Error: SafeBufferAccess init failed: {_safe_buffer_access.error_msg}") + return False + + # Perform other initializations, e.g., loading config, setting up hardware + print("MyDriver: Initialized successfully.") + return True + + def start_loop(): + """Start plugin operations, e.g., a communication thread or server.""" + print("MyDriver: Starting operations...") + # Example: if your plugin runs a server, start it here. + # self.server_thread = threading.Thread(target=self._run_server) + # self.server_thread.daemon = True + # self.server_thread.start() + pass + + def stop_loop(): + """Stop plugin operations.""" + print("MyDriver: Stopping operations...") + # Example: signal your server/thread to stop and join it. + pass + + def run_cycle(): + """Periodic task, if needed. Called by OpenPLC's main loop.""" + # Example: read sensors, update internal state, write to outputs + # if _safe_buffer_access: + # sensor_val = read_hardware_sensor() + # _safe_buffer_access.safe_write_int_output(buffer_idx=0, value=sensor_val) + pass + + def cleanup(): + """Release all resources held by the plugin.""" + print("MyDriver: Cleaning up...") + # Ensure threads are stopped, files are closed, etc. + # stop_loop() # Ensure loop is stopped if not already + pass + ``` + +2. **Add your plugin to `plugins.conf`:** + ``` + my_driver,./path/to/my_driver.py,1,0,./my_driver_config.json + ``` + +3. **Test your plugin:** + * Ensure OpenPLC is compiled with Python plugin support. + * Place `my_driver.py` and its config (if any) at the specified paths. + * Run OpenPLC. Check logs for "MyDriver: Initializing..." and "MyDriver: Initialized successfully." + * Test the functionality of your driver. + +### Creating a Native C Plugin (Future Guide) + +*This section outlines the intended process for when native C plugin support is implemented.* + +1. **Implement required functions** in a C file (e.g., `my_native_plugin.c`): + ```c + #include "plugin_driver.h" // Assuming this header will define the interface + #include + #include + + static plugin_runtime_args_t *g_runtime_args = NULL; + + int init(plugin_runtime_args_t *args) { + if (!args) return -1; // Error + g_runtime_args = args; + printf("Native C Plugin: Initialized\n"); + // Perform hardware initialization, etc. + return 0; // Success + } + + void start_loop(void) { + printf("Native C Plugin: Starting operations\n"); + // Start threads, timers, etc. + } + + void stop_loop(void) { + printf("Native C Plugin: Stopping operations\n"); + // Signal threads to stop, etc. + } + + void run_cycle(void) { + // Example: safely read an input and write to an output + if (g_runtime_args && g_runtime_args->buffer_mutex) { + g_runtime_args->mutex_take(g_runtime_args->buffer_mutex); + // Assuming buffer_size > 0 and buffers are valid + IEC_UINT input_val = g_runtime_args->int_input[0][0]; // Read first word of first input buffer + g_runtime_args->int_output[0][0] = input_val; // Write to first word of first output buffer + g_runtime_args->mutex_give(g_runtime_args->buffer_mutex); + } + } + + void cleanup(void) { + printf("Native C Plugin: Cleaning up\n"); + // Release resources, close files, destroy threads. + g_runtime_args = NULL; + } + ``` + +2. **Compile as a shared library:** + ```bash + gcc -shared -fPIC -I -o my_native_plugin.so my_native_plugin.c + ``` + (The exact include paths and dependencies will be defined when native plugin support is developed). + +3. **Add to `plugins.conf` (for future use):** + ``` + my_native_plugin,./plugins/my_native_plugin.so,1,1,./config/my_native_plugin.conf + ``` + +## Buffer Mapping + +The OpenPLC I/O memory is organized into buffers accessible by plugins. + +### Boolean Buffers +* `bool_input[BUFFER_SIZE][8]`: Digital inputs. Plugins typically read from these. +* `bool_output[BUFFER_SIZE][8]`: Digital outputs. Plugins can read and write to these. +* Each `bool_input[i]` or `bool_output[i]` is an array of 8 `IEC_BOOL` values. +* Total boolean I/O capacity: `BUFFER_SIZE * 8`. +* Access: `bool_output[buffer_index][bit_index]` where `bit_index` is 0-7. + +### Integer Buffers +* `int_input/int_output`: 16-bit unsigned integers (`IEC_UINT`). +* `dint_input/dint_output`: 32-bit unsigned integers (`IEC_UDINT`). +* `lint_input/lint_output`: 64-bit unsigned integers (`IEC_ULINT`). +* `*_memory`: Internal memory areas of corresponding types. +* Access: `int_output[buffer_index]` (accesses one `IEC_UINT` value). + +### Modbus Mapping Example (as used in `simple_modbus.py`) +``` +Modbus Coils (Function Code 0x01, 0x05, 0x0F) -> bool_output[buffer_start + coil_offset / 8][coil_offset % 8] +Modbus Discrete Inputs (Function Code 0x02) -> bool_input[buffer_start + di_offset / 8][di_offset % 8] +Modbus Holding Registers (Function Code 0x03, 0x06, 0x10) -> int_output/dint_output/lint_output[buffer_start + register_offset] +Modbus Input Registers (Function Code 0x04) -> int_input/dint_input/lint_input[buffer_start + register_offset] +``` +The exact mapping (`buffer_start`, data type sizing for registers) is configurable within plugins like the Modbus slave. + +## API Reference (C Library) + +These functions are part of the core plugin driver (`plugin_driver.c/h`). + +### Plugin Driver Lifecycle + +```c +// Create a new plugin driver instance +plugin_driver_t *plugin_driver_create(void); + +// Load plugin configurations from a file +// Returns 0 on success, non-zero on error +int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file); + +// Initialize loaded plugins (calls their 'init' function) +// Returns 0 on success, non-zero if one or more plugins failed to init +int plugin_driver_init(plugin_driver_t *driver); + +// Start initialized plugins (calls their 'start_loop' function) +// Returns 0 on success +int plugin_driver_start(plugin_driver_t *driver); + +// Stop running plugins (calls their 'stop_loop' function) +// Returns 0 on success +int plugin_driver_stop(plugin_driver_t *driver); + +// Destroy the plugin driver and free resources (calls 'cleanup' on plugins) +void plugin_driver_destroy(plugin_driver_t *driver); +``` + +*Note: Functions specific to generating arguments for native plugins (`generate_structured_args_with_driver`) and getting symbols from them (`python_plugin_get_symbols` are internal or for future use.)* + +## Error Handling + +### Common Issues + +1. **Plugin initialization fails (`init` returns `False`):** + * Check `plugins.conf` for correct paths and filenames. + * Verify Python syntax of your plugin file (`python3 -m py_compile your_plugin.py`). + * Check OpenPLC logs for error messages printed by your plugin or the Python bridge. + * Ensure `python_plugin_types.py` is accessible (usually in `plugins/python/shared/`). + +2. **Buffer access errors (e.g., "Index out of bounds", "Null pointer"):** + * Always use the `SafeBufferAccess` wrapper. + * Ensure `buffer_idx` and `bit_idx` are within valid ranges (0 to `buffer_size-1` and 0-7 respectively for booleans). + * The `SafeBufferAccess` methods return error messages; log and handle them. + +3. **Python import errors within a plugin:** + * Ensure all required Python packages are installed in the environment used by OpenPLC. + * If using local modules, ensure `PYTHONPATH` or relative imports are correct. The plugin system adds the plugin's directory to `sys.path`. + +### Debugging Tips + +1. **Enable debug output in your plugin:** + ```python + import sys + print(f"[MyPlugin Debug] __file__: {__file__}", file=sys.stderr) # Or to OpenPLC logs + print(f"[MyPlugin Debug] sys.path: {sys.path}", file=sys.stderr) + ``` + +2. **Check Python plugin symbols (for advanced debugging):** + ```bash + # After starting OpenPLC, check if the plugin module loads + python3 -c " + import sys + sys.path.append('./core/src/drivers/plugins/python/modbus_slave') # Example path + import simple_modbus # Example plugin name + print(dir(simple_modbus)) + " + ``` + +3. **Monitor buffer access:** + ```python + # Inside your plugin, wrap SafeBufferAccess calls with debug prints + def debug_read_buffer(safe_access, b_idx, bit_idx=None): + if bit_idx is not None: + val, err = safe_access.safe_read_bool_output(b_idx, bit_idx) + print(f"[DEBUG] Read bool_output[{b_idx}][{bit_idx}] = {val}, Err: {err}") + else: + val, err = safe_access.safe_read_int_output(b_idx) + print(f"[DEBUG] Read int_output[{b_idx}] = {val}, Err: {err}") + return val, err + ``` + +## Performance Considerations + +1. **Minimize Mutex Lock Time:** + * The `SafeBufferAccess` wrapper is designed for quick operations. + * Avoid performing lengthy computations, I/O operations (like network calls or file access), or complex logic while holding the buffer mutex implicitly through `SafeBufferAccess`. Read/write what you need from/to the buffers quickly, then process the data outside the direct buffer access call. + +2. **Plugin Lifecycle Management:** + * `init()`: Perform one-time setup (load config, initialize data structures). + * `start_loop()`: Start long-running tasks (servers, periodic threads). + * `run_cycle()`: Keep this function lightweight if called frequently by the main OpenPLC loop. + * `cleanup()`: Reliably release all resources (stop threads, close connections, free memory). + +3. **Memory Management (Python):** + * Python's garbage collector handles memory. However, explicitly close files, sockets, or release other external resources in `cleanup()`. + +## Dependencies + +### Required for Python Plugins +* OpenPLC Runtime core (compiled with Python support). +* Python 3.x development headers (for building the Python bridge). +* Python 3.x interpreter at runtime. +* `pthread` library (for mutex operations). + +### Optional for Python Plugins +* `pymodbus`: Required for the `simple_modbus.py` plugin. +* Other Python packages: As needed by specific custom plugins. + +### For Future Native C Plugins +* C Compiler (e.g., GCC). +* Standard C library. +* Potentially other system-level libraries depending on the plugin's purpose. + +## License + +This plugin system is part of the OpenPLC Runtime project and follows the same licensing terms (typically GPLv3 or later). + +## Contributing + +When contributing new plugins: + +1. **Python Plugins:** + * Follow the established interface (`init`, `start_loop`, etc.). + * Use `python_plugin_types.py` for type safety and buffer access. + * Include comprehensive error handling and logging. + * Document configuration options (e.g., via example `.ini` or `.json` files). + * Provide clear usage examples in comments or a separate `README`. + * Test your plugin thoroughly with various OpenPLC programs and I/O configurations. + * Ensure thread safety for all OpenPLC buffer interactions. +2. **Future Native C Plugins:** + * Adhere to the C API once it's fully implemented and documented. + * Pay close attention to memory management and thread safety. + +## See Also + +* `plugins/python/examples/example_python_plugin.py` - Basic Python plugin template. +* `plugins/python/modbus_slave/simple_modbus.py` - Advanced Modbus TCP slave implementation. +* `plugins/python/shared/python_plugin_types.py` - Core type definitions and safety utilities. +* `plugins.conf` - Example active plugin configuration file. +* `core/src/drivers/plugin_driver.h` - C API for the plugin system (internal/future facing). +* `core/src/drivers/python_plugin_bridge.h` - C interface for Python integration. +* `docs/PLUGIN_VENV_GUIDE.md` - Guide on managing Python virtual environments for plugins. +* OpenPLC Runtime main documentation. diff --git a/core/src/drivers/plugin_config.c b/core/src/drivers/plugin_config.c new file mode 100644 index 00000000..e7deb528 --- /dev/null +++ b/core/src/drivers/plugin_config.c @@ -0,0 +1,103 @@ +#include "plugin_config.h" +#include +#include +#include + +// Helper function to remove newline characters from string +static void remove_newline(char *str) +{ + if (!str) + { + return; + } + + // Remove \n, \r characters from the end of string + char *end = str + strlen(str) - 1; + while (end >= str && (*end == '\n' || *end == '\r' || *end == ' ' || *end == '\t')) + { + *end = '\0'; + end--; + } +} + +int parse_plugin_config(const char *config_file, plugin_config_t *configs, int max_configs) +{ + FILE *file = fopen(config_file, "r"); + if (!file) + { + return -1; + } + + char line[512]; + int config_count = 0; + + while (fgets(line, sizeof(line), file) && config_count < max_configs) + { + // Skip comments and empty lines + if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') + { + continue; + } + + // Parse plugin configuration: name,path,enabled,type,plugin_related_config_path + // Parsing name + char *token = strtok(line, ","); + if (!token) + continue; + strncpy(configs[config_count].name, token, sizeof(configs[config_count].name) - 1); + configs[config_count].name[sizeof(configs[config_count].name) - 1] = '\0'; + remove_newline(configs[config_count].name); + + // Parsing path + token = strtok(NULL, ","); + if (!token) + continue; + strncpy(configs[config_count].path, token, sizeof(configs[config_count].path) - 1); + configs[config_count].path[sizeof(configs[config_count].path) - 1] = '\0'; + remove_newline(configs[config_count].path); + + // Parsing enabled + token = strtok(NULL, ","); + if (!token) + continue; + configs[config_count].enabled = atoi(token); + + // Parsing type + token = strtok(NULL, ","); + if (!token) + continue; + configs[config_count].type = atoi(token); + + // parsing plugin_related_config_path + token = strtok(NULL, ","); + if (!token) + continue; + strncpy(configs[config_count].plugin_related_config_path, token, + sizeof(configs[config_count].plugin_related_config_path) - 1); + configs[config_count] + .plugin_related_config_path[sizeof(configs[config_count].plugin_related_config_path) - + 1] = '\0'; + remove_newline(configs[config_count].plugin_related_config_path); + + // parsing venv_path (optional field) + token = strtok(NULL, ",\n\r"); + if (token) + { + strncpy(configs[config_count].venv_path, token, + sizeof(configs[config_count].venv_path) - 1); + configs[config_count].venv_path[sizeof(configs[config_count].venv_path) - 1] = '\0'; + remove_newline(configs[config_count].venv_path); + } + else + { + // No venv_path specified, use empty string + configs[config_count].venv_path[0] = '\0'; + } + + // Incrementing index to target next config + config_count++; + } + + fclose(file); + return config_count; +} diff --git a/core/src/drivers/plugin_config.h b/core/src/drivers/plugin_config.h new file mode 100644 index 00000000..5a88c517 --- /dev/null +++ b/core/src/drivers/plugin_config.h @@ -0,0 +1,19 @@ +#ifndef PLUGIN_CONFIG_H +#define PLUGIN_CONFIG_H + +#define MAX_PLUGIN_NAME_LEN 64 +#define MAX_PLUGIN_PATH_LEN 256 + +typedef struct +{ + char name[MAX_PLUGIN_NAME_LEN]; + char path[MAX_PLUGIN_PATH_LEN]; + int enabled; + int type; // 0 = native, 1 = python + char plugin_related_config_path[MAX_PLUGIN_PATH_LEN]; + char venv_path[MAX_PLUGIN_PATH_LEN]; // Path to virtual environment +} plugin_config_t; + +int parse_plugin_config(const char *config_file, plugin_config_t *configs, int max_configs); + +#endif // PLUGIN_CONFIG_H diff --git a/core/src/drivers/plugin_driver.c b/core/src/drivers/plugin_driver.c new file mode 100644 index 00000000..34325a46 --- /dev/null +++ b/core/src/drivers/plugin_driver.c @@ -0,0 +1,649 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include "../plc_app/image_tables.h" +#include "plugin_config.h" +#include "plugin_driver.h" +#include +#include +#include +#include + +// External buffer declarations from image_tables.c +extern IEC_BOOL *bool_input[BUFFER_SIZE][8]; +extern IEC_BOOL *bool_output[BUFFER_SIZE][8]; +extern IEC_BYTE *byte_input[BUFFER_SIZE]; +extern IEC_BYTE *byte_output[BUFFER_SIZE]; +extern IEC_UINT *int_input[BUFFER_SIZE]; +extern IEC_UINT *int_output[BUFFER_SIZE]; +extern IEC_UDINT *dint_input[BUFFER_SIZE]; +extern IEC_UDINT *dint_output[BUFFER_SIZE]; +extern IEC_ULINT *lint_input[BUFFER_SIZE]; +extern IEC_ULINT *lint_output[BUFFER_SIZE]; +extern IEC_UINT *int_memory[BUFFER_SIZE]; +extern IEC_UDINT *dint_memory[BUFFER_SIZE]; +extern IEC_ULINT *lint_memory[BUFFER_SIZE]; +static PyThreadState *main_tstate = NULL; +static PyGILState_STATE gstate; + +// Prototypes +static void python_plugin_cleanup(plugin_instance_t *plugin); + +// Driver management functions +plugin_driver_t *plugin_driver_create(void) +{ + plugin_driver_t *driver = calloc(1, sizeof(plugin_driver_t)); + if (!driver) + { + return NULL; + } + + // Initialize mutex + if (pthread_mutex_init(&driver->buffer_mutex, NULL) != 0) + { + free(driver); + return NULL; + } + + return driver; +} + +// Mutex helper functions for plugins +int plugin_mutex_take(pthread_mutex_t *mutex) +{ + return pthread_mutex_lock(mutex); +} + +int plugin_mutex_give(pthread_mutex_t *mutex) +{ + return pthread_mutex_unlock(mutex); +} + +// Python capsule destructor for runtime args +// Breakpoint here to debug capsule issues +static void plugin_runtime_args_capsule_destructor(PyObject *capsule) +{ + plugin_runtime_args_t *args = + (plugin_runtime_args_t *)PyCapsule_GetPointer(capsule, "openplc_runtime_args"); + if (args) + { + free_structured_args(args); + } +} + +// Create Python capsule with runtime arguments +static PyObject *create_python_runtime_args_capsule(plugin_runtime_args_t *args) +{ + if (!args) + { + return NULL; + } + + // Create a capsule containing the runtime args pointer + PyObject *capsule = + PyCapsule_New(args, "openplc_runtime_args", plugin_runtime_args_capsule_destructor); + if (!capsule) + { + // If capsule creation fails, we need to free the args manually + free_structured_args(args); + return NULL; + } + + return capsule; +} + +int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file) +{ + if (!driver || !config_file) + { + return -1; + } + + plugin_config_t configs[MAX_PLUGINS]; + int config_count = parse_plugin_config(config_file, configs, MAX_PLUGINS); + if (config_count < 0) + { + return -1; + } + + driver->plugin_count = config_count; + for (int w = 0; w < config_count; w++) + { + memcpy(&driver->plugins[w].config, &configs[w], sizeof(plugin_config_t)); + } + + // Agora leio todos os simbolos que preciso (init, start, stop, cycle, cleanup) e adiciono na + // struct plugin_instance_t para cada plugin. + for (int i = 0; i < driver->plugin_count; i++) + { + plugin_instance_t *plugin = &driver->plugins[i]; + + if (plugin->config.type == PLUGIN_TYPE_PYTHON) + { + if (python_plugin_get_symbols(plugin) != 0) + { + fprintf(stderr, "Failed to get Python plugin symbols for: %s\n", + plugin->config.path); + plugin_manager_destroy(plugin->manager); + return -1; + } + } + } + + return 0; +} + +// Send to plugin init function all args +int plugin_driver_init(plugin_driver_t *driver) +{ + if (!driver) + { + return -1; + } + + // #chamdo a funΓ§Γ£o init de cada plugin aqui + for (int i = 0; i < driver->plugin_count; i++) + { + plugin_instance_t *plugin = &driver->plugins[i]; + if (plugin->config.type == PLUGIN_TYPE_PYTHON && plugin->python_plugin && + plugin->python_plugin->pFuncInit) + { + // Generate structured args for Python plugin + PyObject *args = + (PyObject *)generate_structured_args_with_driver(PLUGIN_TYPE_PYTHON, driver, i); + if (!args) + { + fprintf(stderr, "Failed to generate runtime args for plugin: %s\n", + plugin->config.name); + return -1; + } + // Call the Python init function with proper capsule + PyObject *result = + PyObject_CallFunctionObjArgs(plugin->python_plugin->pFuncInit, args, NULL); + + // Store the capsule reference for the lifetime of the plugin + plugin->python_plugin->args_capsule = args; + + if (!result) + { + PyErr_Print(); + fprintf(stderr, "Python init function failed for plugin: %s\n", + plugin->config.name); + return -1; + } + Py_DECREF(result); + } + else if (plugin->config.type == PLUGIN_TYPE_NATIVE && plugin->manager) + { + // TODO: Implement native plugin initialization + } + } + + return 0; +} + +// Call the thread function for each plugin +int plugin_driver_start(plugin_driver_t *driver) +{ + if (!driver) + { + return -1; + } + + main_tstate = PyEval_SaveThread(); + gstate = PyGILState_Ensure(); + + for (int i = 0; i < driver->plugin_count; i++) + { + plugin_instance_t *plugin = &driver->plugins[i]; + switch (plugin->config.type) + { + case PLUGIN_TYPE_PYTHON: + { + // Python plugins run asynchronously in their own threads. + // NOTE: The thread is created python-side + if (plugin->python_plugin && plugin->python_plugin->pFuncStart) + { + PyObject *res = PyObject_CallNoArgs(plugin->python_plugin->pFuncStart); + if (!res) + { + PyErr_Print(); + fprintf(stderr, "Python start call failed for plugin: %s\n", + plugin->config.name); + } + else + { + printf("[PLUGIN]: Plugin %s started successfully.\n", plugin->config.name); + } + Py_DECREF( + res); // There's no problem in calling DECREF here because it only + // handles the returned object from start_loop, not the function itself + + plugin->running = 1; + } + else + { + fprintf(stderr, "Python plugin %s does not have a start_loop function.\n", + plugin->config.name); + } + } + break; + + case PLUGIN_TYPE_NATIVE: + { + // TODO: Implement native plugin start logic + } + break; + + default: + break; + } + } + PyGILState_Release(gstate); + return 0; +} + +int plugin_driver_stop(plugin_driver_t *driver) +{ + printf("[PLUGIN]: Stopping all plugins...\n"); + if (!driver) + { + return -1; + } + + // Signal all plugins to stop + for (int i = 0; i < driver->plugin_count; i++) + { + 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) + { + plugin_instance_t *plugin = &driver->plugins[i]; + + PyObject *res = PyObject_CallNoArgs(driver->plugins[i].python_plugin->pFuncStop); + if (!res) + { + PyErr_Print(); + fprintf(stderr, "Python stop call failed for plugin: %s\n", plugin->config.name); + } + else + { + printf("[PLUGIN]: Plugin %s stopped successfully.\n", plugin->config.name); + } + Py_DECREF(res); + + plugin->running = 0; + } + + printf("[PLUGIN]: Plugin %s stopped...\n", driver->plugins[i].config.name); + // Plugin manager only handles destruction, not stopping + // TODO: Implement native plugin stop logic if needed + } + + return 0; +} + +void plugin_driver_destroy(plugin_driver_t *driver) +{ + if (!driver) + { + return; + } + + gstate = PyGILState_Ensure(); + + plugin_driver_stop(driver); + + for (int i = 0; i < driver->plugin_count; i++) + { + plugin_instance_t *plugin = &driver->plugins[i]; + if (plugin->manager) + { + plugin_manager_destroy(plugin->manager); + plugin->manager = NULL; + } + if (plugin->python_plugin) + { + python_plugin_cleanup(plugin); + } + } + + PyGILState_Release(gstate); + PyEval_RestoreThread(main_tstate); + Py_FinalizeEx(); + pthread_mutex_destroy(&driver->buffer_mutex); + + free(driver); +} + +// Runtime arguments generation functions + +/** + * @brief Generate structured arguments for plugin initialization + * + * This function creates a structured argument containing all runtime buffers, + * mutex functions, and metadata needed by external plugins. + * + * @param type Type of plugin (PLUGIN_TYPE_PYTHON or PLUGIN_TYPE_NATIVE) + * @param driver Pointer to plugin driver (for buffer mutex) + * @return Pointer to allocated structure/capsule, or NULL on error + * + * For PLUGIN_TYPE_NATIVE: Returns plugin_runtime_args_t* + * For PLUGIN_TYPE_PYTHON: Returns PyObject* (PyCapsule containing plugin_runtime_args_t*) + */ +void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t *driver, + int plugin_index) +{ + printf("[PLUGIN]: Generating structured args for plugin type %d\n", type); + + if (!driver) + { + fprintf(stderr, "[PLUGIN]: Error - driver is NULL\n"); + return NULL; + } + + plugin_runtime_args_t *args = malloc(sizeof(plugin_runtime_args_t)); + if (!args) + { + fprintf(stderr, "[PLUGIN]: Error - failed to allocate memory for runtime args\n"); + return NULL; + } + + printf("[PLUGIN]: Allocated runtime args structure (size: %zu bytes)\n", + sizeof(plugin_runtime_args_t)); + + // Initialize all buffer pointers + args->bool_input = bool_input; + args->bool_output = bool_output; + args->byte_input = byte_input; + args->byte_output = byte_output; + args->int_input = int_input; + args->int_output = int_output; + args->dint_input = dint_input; + args->dint_output = dint_output; + args->lint_input = lint_input; + args->lint_output = lint_output; + args->int_memory = int_memory; + args->dint_memory = dint_memory; + args->lint_memory = lint_memory; + + // Initialize mutex functions + args->mutex_take = plugin_mutex_take; + args->mutex_give = plugin_mutex_give; + // Set buffer mutex from driver + args->buffer_mutex = &driver->buffer_mutex; + + // Initialize plugin specific config path as empty + memset(args->plugin_specific_config_file_path, '\0', + sizeof(args->plugin_specific_config_file_path)); + + memcpy(args->plugin_specific_config_file_path, + driver->plugins[plugin_index].config.plugin_related_config_path, + sizeof(driver->plugins[plugin_index].config.plugin_related_config_path)); + + // Initialize buffer size info + args->buffer_size = BUFFER_SIZE; + args->bits_per_buffer = 8; + + // printf("[PLUGIN]: Runtime args initialized:\n"); + // printf("[PLUGIN]: buffer_size = %d\n", args->buffer_size); + // printf("[PLUGIN]: bits_per_buffer = %d\n", args->bits_per_buffer); + // printf("[PLUGIN]: buffer_mutex = %p\n", (void *)args->buffer_mutex); + // printf("[PLUGIN]: bool_input = %p\n", (void *)args->bool_input); + // printf("[PLUGIN]: mutex_take = %p\n", (void *)args->mutex_take); + // printf("[PLUGIN]: mutex_give = %p\n", (void *)args->mutex_give); + + // Validate critical pointers + if (!args->buffer_mutex) + { + fprintf(stderr, "[PLUGIN]: Error - buffer_mutex is NULL\n"); + free(args); + return NULL; + } + + if (!args->mutex_take || !args->mutex_give) + { + fprintf(stderr, "[PLUGIN]: Error - mutex function pointers are NULL\n"); + free(args); + return NULL; + } + + switch (type) + { + case PLUGIN_TYPE_NATIVE: + printf("[PLUGIN]: Returning native plugin args\n"); + // For native plugins, return the structure directly + return args; + + case PLUGIN_TYPE_PYTHON: + printf("[PLUGIN]: Creating Python capsule for args\n"); + // For Python plugins, wrap in a PyCapsule + PyObject *capsule = create_python_runtime_args_capsule(args); + if (!capsule) + { + fprintf(stderr, "[PLUGIN]: Error - failed to create Python capsule\n"); + // Note: create_python_runtime_args_capsule already freed args on failure + return NULL; + } + printf("[PLUGIN]: Python capsule created successfully\n"); + return capsule; + + default: + fprintf(stderr, "[PLUGIN]: Error - unknown plugin type: %d\n", type); + // Unknown type, clean up and return NULL + free(args); + return NULL; + } +} + +// Free structured arguments +void free_structured_args(plugin_runtime_args_t *args) +{ + if (args) + { + // No dynamic allocations inside the structure to free + // Just free the main structure + free(args); + } +} + +int python_plugin_get_symbols(plugin_instance_t *plugin) +{ + if (!plugin || plugin->config.path[0] == '\0') + { + return -1; + } + + // Allocate python binds structure + python_binds_t *py_binds = calloc(1, sizeof(python_binds_t)); + if (!py_binds) + { + return -1; + } + + // Initialize Python if not already initialized + if (!Py_IsInitialized()) + { + Py_Initialize(); + } + + // Extract module name from plugin path + // Remove .py extension and directory path if present + char module_name[256]; + const char *filename = strrchr(plugin->config.path, '/'); + if (filename) + { + filename++; // Skip the '/' + } + else + { + filename = plugin->config.path; + } + + // Copy filename without .py extension + strncpy(module_name, filename, sizeof(module_name) - 1); + module_name[sizeof(module_name) - 1] = '\0'; + char *dot = strrchr(module_name, '.'); + if (dot && strcmp(dot, ".py") == 0) + { + *dot = '\0'; + } + + // Add plugin directory to Python path + char python_path_cmd[512]; + const char *plugin_dir = strrchr(plugin->config.path, '/'); + if (plugin_dir) + { + int dir_len = plugin_dir - plugin->config.path; + char dir_path[256]; + strncpy(dir_path, plugin->config.path, dir_len); + dir_path[dir_len] = '\0'; + snprintf(python_path_cmd, sizeof(python_path_cmd), "import sys; sys.path.insert(0, '%s')", + dir_path); + } + else + { + snprintf(python_path_cmd, sizeof(python_path_cmd), "import sys; sys.path.insert(0, '.')"); + } + + PyRun_SimpleString(python_path_cmd); + + // Setup virtual environment if specified + if (strlen(plugin->config.venv_path) > 0) + { + // Construct the venv site-packages path + char venv_site_packages[512]; + snprintf(venv_site_packages, sizeof(venv_site_packages), "%s/lib/python%d.%d/site-packages", + plugin->config.venv_path, PY_MAJOR_VERSION, PY_MINOR_VERSION); + // Get sys.path + PyObject *sys_path = PySys_GetObject("path"); + if (sys_path && PyList_Check(sys_path)) + { + PyObject *venv_path_obj = PyUnicode_FromString(venv_site_packages); + int found = PySequence_Contains(sys_path, venv_path_obj); + if (found == 0) + { // Not found + if (PyList_Insert(sys_path, 0, venv_path_obj) != 0) + { + fprintf(stderr, "Failed to insert venv path into sys.path for plugin: %s\n", + plugin->config.name); + Py_DECREF(venv_path_obj); + free(py_binds); + return -1; + } + } + Py_DECREF(venv_path_obj); + } + else + { + fprintf(stderr, "Failed to get sys.path for plugin: %s\n", plugin->config.name); + free(py_binds); + return -1; + } + printf("[PLUGIN] Using venv for %s: %s\n", plugin->config.name, venv_site_packages); + } + + // Load the Python module + py_binds->pModule = PyImport_ImportModule(module_name); + if (!py_binds->pModule) + { + fprintf(stderr, "Failed to load Python module '%s' from path '%s'\n", module_name, + plugin->config.path); + PyErr_Print(); + free(py_binds); + return -1; + } + + // Get function references based on python_binds_t structure + py_binds->pFuncInit = PyObject_GetAttrString(py_binds->pModule, "init"); + if (!py_binds->pFuncInit || !PyCallable_Check(py_binds->pFuncInit)) + { + fprintf(stderr, + "Error: 'init' function not found or not callable in module '%s' - this function " + "is required\n", + module_name); + Py_XDECREF(py_binds->pModule); + free(py_binds); + return -1; + } + + py_binds->pFuncStart = PyObject_GetAttrString(py_binds->pModule, "start_loop"); + if (!py_binds->pFuncStart || !PyCallable_Check(py_binds->pFuncStart)) + { + // start_loop is optional + Py_XDECREF(py_binds->pFuncStart); + py_binds->pFuncStart = NULL; + } + + py_binds->pFuncStop = PyObject_GetAttrString(py_binds->pModule, "stop_loop"); + if (!py_binds->pFuncStop || !PyCallable_Check(py_binds->pFuncStop)) + { + // stop_loop is optional + Py_XDECREF(py_binds->pFuncStop); + py_binds->pFuncStop = NULL; + } + + py_binds->pFuncCleanup = PyObject_GetAttrString(py_binds->pModule, "cleanup"); + if (!py_binds->pFuncCleanup || !PyCallable_Check(py_binds->pFuncCleanup)) + { + // cleanup is optional + Py_XDECREF(py_binds->pFuncCleanup); + py_binds->pFuncCleanup = NULL; + } + + // Store the python binds in the plugin instance + plugin->python_plugin = py_binds; + + printf("Python plugin '%s' symbols loaded successfully\n", module_name); + printf(" - init: %s\n", py_binds->pFuncInit ? "OK" : "Failed"); + printf(" - start_loop: %s\n", py_binds->pFuncStart ? "OK" : "Failed"); + printf(" - stop_loop: %s\n", py_binds->pFuncStop ? "OK" : "Failed"); + printf(" - cleanup: %s\n", py_binds->pFuncCleanup ? "OK" : "Failed"); + + return 0; +} + +// Python plugin cycle function +void python_plugin_cycle(plugin_instance_t *plugin) +{ + (void)plugin; // Suppress unused parameter warning + // In a real implementation, you'd retrieve the python_plugin_t structure + // and call the cycle function +} + +// 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) + { + // Call cleanup function if available + if (plugin->python_plugin->pFuncCleanup) + { + PyObject *res = PyObject_CallFunctionObjArgs(plugin->python_plugin->pFuncCleanup, NULL); + if (!res) + { + PyErr_Print(); + fprintf(stderr, "Python cleanup call failed for plugin: %s\n", plugin->config.name); + } + else + { + printf("[PLUGIN]: Plugin %s cleaned up successfully.\n", plugin->config.name); + } + Py_DECREF(res); + } + + // Decrement references to Python objects + Py_XDECREF(plugin->python_plugin->pFuncInit); + Py_XDECREF(plugin->python_plugin->pFuncStart); + Py_XDECREF(plugin->python_plugin->pFuncStop); + Py_XDECREF(plugin->python_plugin->pFuncCleanup); + Py_XDECREF(plugin->python_plugin->pModule); + Py_XDECREF(plugin->python_plugin->args_capsule); + + free(plugin->python_plugin); + plugin->python_plugin = NULL; + } +} diff --git a/core/src/drivers/plugin_driver.h b/core/src/drivers/plugin_driver.h new file mode 100644 index 00000000..c63637c6 --- /dev/null +++ b/core/src/drivers/plugin_driver.h @@ -0,0 +1,99 @@ +#ifndef PLUGIN_DRIVER_H +#define PLUGIN_DRIVER_H + +#include "../lib/iec_types.h" +#include "../plc_app/plcapp_manager.h" +#include "plugin_config.h" +#include "python_plugin_bridge.h" +#include + +// Maximum number of plugins +#define MAX_PLUGINS 16 + +typedef enum +{ + PLUGIN_TYPE_PYTHON, + PLUGIN_TYPE_NATIVE +} plugin_type_t; + +typedef int (*plugin_init_func_t)(void *); +typedef void (*plugin_start_loop_func_t)(); +typedef void (*plugin_stop_loop_func_t)(); +typedef void (*plugin_run_cycle_func_t)(); +typedef void (*plugin_cleanup_func_t)(); + +typedef struct +{ + plugin_init_func_t init; + plugin_start_loop_func_t start; + plugin_stop_loop_func_t stop; + plugin_run_cycle_func_t run_cycle; + plugin_cleanup_func_t cleanup; +} plugin_funct_bundle_t; + +// Runtime buffer access structure for plugins +typedef struct +{ + // Buffer pointers + IEC_BOOL *(*bool_input)[8]; + IEC_BOOL *(*bool_output)[8]; + IEC_BYTE **byte_input; + IEC_BYTE **byte_output; + IEC_UINT **int_input; + IEC_UINT **int_output; + IEC_UDINT **dint_input; + IEC_UDINT **dint_output; + IEC_ULINT **lint_input; + IEC_ULINT **lint_output; + IEC_UINT **int_memory; + IEC_UDINT **dint_memory; + IEC_ULINT **lint_memory; + + // Mutex functions + int (*mutex_take)(pthread_mutex_t *mutex); + int (*mutex_give)(pthread_mutex_t *mutex); + pthread_mutex_t *buffer_mutex; + char plugin_specific_config_file_path[256]; + + // Buffer size information + int buffer_size; + int bits_per_buffer; +} plugin_runtime_args_t; + +// Plugin instance structure +typedef struct plugin_instance_s +{ + PluginManager *manager; + python_binds_t *python_plugin; + // pthread_t thread; + int running; + plugin_config_t config; +} plugin_instance_t; + +// Driver structure +typedef struct +{ + plugin_instance_t plugins[MAX_PLUGINS]; + int plugin_count; + pthread_mutex_t buffer_mutex; +} plugin_driver_t; + +// Driver management functions +plugin_driver_t *plugin_driver_create(void); +int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file); +int plugin_driver_init(plugin_driver_t *driver); +int plugin_driver_start(plugin_driver_t *driver); +int plugin_driver_stop(plugin_driver_t *driver); +void plugin_driver_destroy(plugin_driver_t *driver); +int plugin_mutex_take(pthread_mutex_t *mutex); +int plugin_mutex_give(pthread_mutex_t *mutex); + +// Python plugin functions +int python_plugin_get_symbols(plugin_instance_t *plugin); + +// Runtime arguments generation +void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t *driver, + int plugin_index); +void free_structured_args(plugin_runtime_args_t *args); + +#endif // PLUGIN_DRIVER_H diff --git a/core/src/drivers/plugins/python/examples/buffer_access_example.py b/core/src/drivers/plugins/python/examples/buffer_access_example.py new file mode 100644 index 00000000..484cddd2 --- /dev/null +++ b/core/src/drivers/plugins/python/examples/buffer_access_example.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Example demonstrating comprehensive buffer access with the enhanced SafeBufferAccess class +This example shows how to use all the new read functions and batch operations for optimized mutex usage. +""" + +import sys +import os + +# Add the shared directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'shared')) + +from python_plugin_types import ( + PluginRuntimeArgs, + SafeBufferAccess, + safe_extract_runtime_args_from_capsule, + PluginStructureValidator +) + +def demonstrate_individual_operations(buffer_access): + """ + Demonstrate individual read/write operations with thread-safe parameter + """ + print("\n=== Individual Operations Demo ===") + + # Boolean operations + print("\n1. Boolean Operations:") + success, msg = buffer_access.write_bool_output(0, 0, True, thread_safe=True) + print(f"Write bool_output[0][0] = True: {success} - {msg}") + + value, msg = buffer_access.read_bool_output(0, 0, thread_safe=True) + print(f"Read bool_output[0][0]: {value} - {msg}") + + value, msg = buffer_access.read_bool_input(0, 0, thread_safe=True) + print(f"Read bool_input[0][0]: {value} - {msg}") + + # Byte operations + print("\n2. Byte Operations:") + success, msg = buffer_access.write_byte_output(0, 42, thread_safe=True) + print(f"Write byte_output[0] = 42: {success} - {msg}") + + value, msg = buffer_access.read_byte_output(0, thread_safe=True) + print(f"Read byte_output[0]: {value} - {msg}") + + value, msg = buffer_access.read_byte_input(0, thread_safe=True) + print(f"Read byte_input[0]: {value} - {msg}") + + # Int operations (16-bit) + print("\n3. Int Operations (16-bit):") + success, msg = buffer_access.write_int_output(0, 1234, thread_safe=True) + print(f"Write int_output[0] = 1234: {success} - {msg}") + + value, msg = buffer_access.read_int_output(0, thread_safe=True) + print(f"Read int_output[0]: {value} - {msg}") + + value, msg = buffer_access.read_int_input(0, thread_safe=True) + print(f"Read int_input[0]: {value} - {msg}") + + # Memory operations + print("\n4. Memory Operations:") + success, msg = buffer_access.write_int_memory(0, 5678, thread_safe=True) + print(f"Write int_memory[0] = 5678: {success} - {msg}") + + value, msg = buffer_access.read_int_memory(0, thread_safe=True) + print(f"Read int_memory[0]: {value} - {msg}") + + # Dint operations (32-bit) + print("\n5. Dint Operations (32-bit):") + success, msg = buffer_access.write_dint_output(0, 123456789, thread_safe=True) + print(f"Write dint_output[0] = 123456789: {success} - {msg}") + + value, msg = buffer_access.read_dint_output(0, thread_safe=True) + print(f"Read dint_output[0]: {value} - {msg}") + + value, msg = buffer_access.read_dint_input(0, thread_safe=True) + print(f"Read dint_input[0]: {value} - {msg}") + + success, msg = buffer_access.write_dint_memory(0, 987654321, thread_safe=True) + print(f"Write dint_memory[0] = 987654321: {success} - {msg}") + + value, msg = buffer_access.read_dint_memory(0, thread_safe=True) + print(f"Read dint_memory[0]: {value} - {msg}") + + # Lint operations (64-bit) + print("\n6. Lint Operations (64-bit):") + success, msg = buffer_access.write_lint_output(0, 1234567890123456789, thread_safe=True) + print(f"Write lint_output[0] = 1234567890123456789: {success} - {msg}") + + value, msg = buffer_access.read_lint_output(0, thread_safe=True) + print(f"Read lint_output[0]: {value} - {msg}") + + value, msg = buffer_access.read_lint_input(0, thread_safe=True) + print(f"Read lint_input[0]: {value} - {msg}") + + success, msg = buffer_access.write_lint_memory(0, 9876543210987654321, thread_safe=True) + print(f"Write lint_memory[0] = 9876543210987654321: {success} - {msg}") + + value, msg = buffer_access.read_lint_memory(0, thread_safe=True) + print(f"Read lint_memory[0]: {value} - {msg}") + +def demonstrate_batch_operations(buffer_access): + """ + Demonstrate batch operations for optimized mutex usage + """ + print("\n=== Batch Operations Demo ===") + + # Batch read operations + print("\n1. Batch Read Operations:") + read_operations = [ + ('bool_output', 0, 0), # Read bool_output[0][0] + ('byte_output', 0), # Read byte_output[0] + ('int_output', 0), # Read int_output[0] + ('dint_output', 0), # Read dint_output[0] + ('lint_output', 0), # Read lint_output[0] + ('int_memory', 0), # Read int_memory[0] + ('dint_memory', 0), # Read dint_memory[0] + ('lint_memory', 0), # Read lint_memory[0] + ] + + results, msg = buffer_access.batch_read_values(read_operations) + print(f"Batch read result: {msg}") + for i, (success, value, error_msg) in enumerate(results): + op = read_operations[i] + print(f" {op[0]}[{op[1]}{',' + str(op[2]) if len(op) > 2 else ''}]: {success} - Value: {value} - {error_msg}") + + # Batch write operations + print("\n2. Batch Write Operations:") + write_operations = [ + ('bool_output', 1, True, 0), # Write bool_output[1][0] = True + ('byte_output', 1, 100), # Write byte_output[1] = 100 + ('int_output', 1, 2000), # Write int_output[1] = 2000 + ('dint_output', 1, 300000000), # Write dint_output[1] = 300000000 + ('lint_output', 1, 4000000000000000000), # Write lint_output[1] = 4000000000000000000 + ('int_memory', 1, 1500), # Write int_memory[1] = 1500 + ('dint_memory', 1, 250000000), # Write dint_memory[1] = 250000000 + ('lint_memory', 1, 3000000000000000000), # Write lint_memory[1] = 3000000000000000000 + ] + + results, msg = buffer_access.batch_write_values(write_operations) + print(f"Batch write result: {msg}") + for i, (success, error_msg) in enumerate(results): + op = write_operations[i] + print(f" {op[0]}[{op[1]}{',' + str(op[3]) if len(op) > 3 else ''}] = {op[2]}: {success} - {error_msg}") + + # Mixed batch operations + print("\n3. Mixed Batch Operations:") + read_ops = [ + ('bool_output', 1, 0), # Read the boolean we just wrote + ('byte_output', 1), # Read the byte we just wrote + ('int_output', 1), # Read the int we just wrote + ] + + write_ops = [ + ('bool_output', 2, False, 0), # Write bool_output[2][0] = False + ('byte_output', 2, 200), # Write byte_output[2] = 200 + ('int_output', 2, 3000), # Write int_output[2] = 3000 + ] + + results, msg = buffer_access.batch_mixed_operations(read_ops, write_ops) + print(f"Mixed batch result: {msg}") + + if 'reads' in results: + print(" Read results:") + for i, (success, value, error_msg) in enumerate(results['reads']): + op = read_ops[i] + print(f" {op[0]}[{op[1]}{',' + str(op[2]) if len(op) > 2 else ''}]: {success} - Value: {value} - {error_msg}") + + if 'writes' in results: + print(" Write results:") + for i, (success, error_msg) in enumerate(results['writes']): + op = write_ops[i] + print(f" {op[0]}[{op[1]}{',' + str(op[3]) if len(op) > 3 else ''}] = {op[2]}: {success} - {error_msg}") + +def demonstrate_manual_mutex_control(buffer_access): + """ + Demonstrate manual mutex control for custom operations + """ + print("\n=== Manual Mutex Control Demo ===") + + # Acquire mutex manually + success, msg = buffer_access.acquire_mutex() + print(f"Manual mutex acquisition: {success} - {msg}") + + if success: + try: + # Perform multiple operations without individual mutex overhead + print("Performing operations with manual mutex control:") + + # Read some values (thread_safe=False since we already have the mutex) + value, msg = buffer_access.read_bool_output(0, 0, thread_safe=False) + print(f" Read bool_output[0][0]: {value} - {msg}") + + value, msg = buffer_access.read_byte_output(0, thread_safe=False) + print(f" Read byte_output[0]: {value} - {msg}") + + # Write some values (thread_safe=False since we already have the mutex) + success, msg = buffer_access.write_bool_output(3, 0, True, thread_safe=False) + print(f" Write bool_output[3][0] = True: {success} - {msg}") + + success, msg = buffer_access.write_byte_output(3, 255, thread_safe=False) + print(f" Write byte_output[3] = 255: {success} - {msg}") + + finally: + # Always release the mutex + success, msg = buffer_access.release_mutex() + print(f"Manual mutex release: {success} - {msg}") + +def demonstrate_thread_safe_parameter(buffer_access): + """ + Demonstrate the thread_safe parameter usage + """ + print("\n=== Thread-Safe Parameter Demo ===") + + print("1. Operations with thread_safe=True (default):") + value, msg = buffer_access.read_byte_output(0, thread_safe=True) + print(f" Read with mutex: {value} - {msg}") + + print("2. Operations with thread_safe=False (manual mutex control):") + # This would normally be used when you've manually acquired the mutex + # For demo purposes, we'll show it works but note it's not thread-safe + value, msg = buffer_access.read_byte_output(0, thread_safe=False) + print(f" Read without mutex: {value} - {msg}") + print(" Note: thread_safe=False should only be used with manual mutex control!") + +def main(): + """ + Main demonstration function + Note: This is a demonstration of the API. In a real plugin, you would receive + the runtime_args from the OpenPLC runtime via the plugin interface. + """ + print("OpenPLC Python Plugin Buffer Access Demonstration") + print("=" * 60) + + # Print structure information + print("\nStructure Validation:") + PluginStructureValidator.print_structure_info() + + print("\n" + "=" * 60) + print("IMPORTANT NOTE:") + print("This is a demonstration of the SafeBufferAccess API.") + print("In a real plugin, you would receive the runtime_args structure") + print("from the OpenPLC runtime via the plugin interface.") + print("The following demonstrations show the API usage patterns.") + print("=" * 60) + + # In a real plugin, you would get runtime_args from the plugin interface + # For demonstration purposes, we'll show the API patterns + print("\nAPI Usage Patterns:") + + print("\n1. Extracting runtime args from capsule:") + print(" runtime_args, error = safe_extract_runtime_args_from_capsule(capsule)") + print(" if runtime_args is None:") + print(" print(f'Failed to extract runtime args: {error}')") + print(" return") + + print("\n2. Creating SafeBufferAccess instance:") + print(" buffer_access = SafeBufferAccess(runtime_args)") + print(" if not buffer_access.is_valid:") + print(" print(f'Invalid buffer access: {buffer_access.error_msg}')") + print(" return") + + print("\n3. Individual operations:") + print(" # Read operations") + print(" value, msg = buffer_access.read_bool_input(0, 0)") + print(" value, msg = buffer_access.read_byte_input(0)") + print(" value, msg = buffer_access.read_int_input(0)") + print(" value, msg = buffer_access.read_dint_input(0)") + print(" value, msg = buffer_access.read_lint_input(0)") + print(" value, msg = buffer_access.read_int_memory(0)") + print(" value, msg = buffer_access.read_dint_memory(0)") + print(" value, msg = buffer_access.read_lint_memory(0)") + + print("\n # Write operations") + print(" success, msg = buffer_access.write_bool_output(0, 0, True)") + print(" success, msg = buffer_access.write_byte_output(0, 255)") + print(" success, msg = buffer_access.write_int_output(0, 65535)") + print(" success, msg = buffer_access.write_dint_output(0, 4294967295)") + print(" success, msg = buffer_access.write_lint_output(0, 18446744073709551615)") + print(" success, msg = buffer_access.write_int_memory(0, 1000)") + print(" success, msg = buffer_access.write_dint_memory(0, 2000000)") + print(" success, msg = buffer_access.write_lint_memory(0, 3000000000)") + + print("\n4. Batch operations for optimized mutex usage:") + print(" # Batch reads") + print(" read_ops = [('bool_input', 0, 0), ('byte_input', 0), ('int_input', 0)]") + print(" results, msg = buffer_access.batch_read_values(read_ops)") + + print("\n # Batch writes") + print(" write_ops = [('bool_output', 0, True, 0), ('byte_output', 0, 100)]") + print(" results, msg = buffer_access.batch_write_values(write_ops)") + + print("\n # Mixed batch operations") + print(" results, msg = buffer_access.batch_mixed_operations(read_ops, write_ops)") + + print("\n5. Manual mutex control:") + print(" success, msg = buffer_access.acquire_mutex()") + print(" try:") + print(" # Multiple operations with thread_safe=False") + print(" value, msg = buffer_access.read_byte_input(0, thread_safe=False)") + print(" success, msg = buffer_access.write_byte_output(0, 50, thread_safe=False)") + print(" finally:") + print(" success, msg = buffer_access.release_mutex()") + + print("\n6. Thread-safe parameter usage:") + print(" # Default behavior (thread_safe=True)") + print(" value, msg = buffer_access.read_byte_input(0)") + print(" # Manual mutex control (thread_safe=False)") + print(" value, msg = buffer_access.read_byte_input(0, thread_safe=False)") + + print("\n" + "=" * 60) + print("Key Benefits:") + print("- Complete read/write access to all IEC buffer types") + print("- Optional thread-safe parameter for all operations") + print("- Batch operations for optimized mutex usage") + print("- Manual mutex control for custom operation sequences") + print("- Comprehensive error handling and validation") + print("- Maximum flexibility for plugin developers") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/core/src/drivers/plugins/python/examples/example_python_plugin.py b/core/src/drivers/plugins/python/examples/example_python_plugin.py new file mode 100644 index 00000000..2a34e496 --- /dev/null +++ b/core/src/drivers/plugins/python/examples/example_python_plugin.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Example plugin for testing the updated python_plugin_get_symbols function +This demonstrates the expected functions that should be present in a Python plugin +""" + +from concurrent.futures import thread +import time +import ctypes +from ctypes import * +import threading +import sys +import os +# Add the parent directory to Python path to find shared module +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +# Import the correct type definitions +from shared.python_plugin_types import ( + PluginRuntimeArgs, + safe_extract_runtime_args_from_capsule, + SafeBufferAccess, + PluginStructureValidator +) + +# Global variable to track initialization +_initialized = False +_runtime_args = None +_safe_buffer_access = None +_mainthread = None +_stop = threading.Event() + +def init(runtime_args_capsule): + """ + Plugin initialization function + Called once when the plugin is loaded + + Args: + runtime_args_capsule: PyCapsule containing plugin_runtime_args_t structure + """ + global _initialized, _runtime_args, _safe_buffer_access + + print("Python plugin 'example_plugin' initializing...") + + try: + # Print structure validation info for debugging + print("Validating plugin structure alignment...") + PluginStructureValidator.print_structure_info() + + # Extract runtime args from capsule using safe method + runtime_args, error_msg = safe_extract_runtime_args_from_capsule(runtime_args_capsule) + if runtime_args is None: + print(f"βœ— Failed to extract runtime args: {error_msg}") + return False + + print(f"βœ“ Runtime arguments extracted successfully") + + # Safely access buffer size using validation + buffer_size, size_error = runtime_args.safe_access_buffer_size() + if buffer_size == -1: + print(f"βœ— Failed to access buffer size: {size_error}") + return False + + print(f" Buffer size: {buffer_size}") + print(f" Bits per buffer: {runtime_args.bits_per_buffer}") + print(f" Structure details: {runtime_args}") + + # Create safe buffer access wrapper + _safe_buffer_access = SafeBufferAccess(runtime_args) + if not _safe_buffer_access.is_valid: + print(f"βœ— Failed to create safe buffer access: {_safe_buffer_access.error_msg}") + return False + + # Store runtime args for later use + _runtime_args = runtime_args + + print("βœ“ Plugin initialized successfully") + return True + + except Exception as e: + print(f"βœ— Plugin initialization failed: {e}") + import traceback + traceback.print_exc() + return False + +def start_loop(): + """ + Called when the plugin loop should start + Optional function - not all plugins need this + """ + def loop(): + global _runtime_args, _stop + print("Plugin start_loop called") + while not _stop.is_set(): + time.sleep(0.1) + addr = ctypes.addressof(_runtime_args.bool_output[0][0]) + value, msg = _safe_buffer_access.read_bool_output(0,0, thread_safe=True) + print(f"Value at address 0x{addr:x}: {value} ({msg})") + + global _mainthread + _mainthread = threading.Thread(target=loop, daemon=True) + _mainthread.start() + return 0 + +def stop_loop(): + """ + Called when the plugin loop should stop + Optional function - not all plugins need this + """ + print("Plugin stop_loop called") + global _mainthread + if _mainthread is not None: + print("Stopping main thread...") + # In a real implementation, you would signal the thread to stop gracefully + _stop.set() + _mainthread.join() + _mainthread = None + print("βœ“ Main thread stopped") + +def cleanup(): + """ + Plugin cleanup function + Called when the plugin is being unloaded + Optional function - use for cleanup tasks + """ + global _initialized, _runtime_args + + print("Plugin cleanup called") + + _initialized = False + _runtime_args = None + + print("βœ“ Plugin cleaned up successfully") + +if __name__ == "__main__": + print("This is an example Python plugin for OpenPLC Runtime") + print("Expected functions:") + print(" - init(runtime_args_capsule) -> bool") + print(" - start_loop() -> None (optional)") + print(" - stop_loop() -> None (optional)") + print(" - run_cycle() -> None (optional)") + print(" - cleanup() -> None (optional)") + print() + print("This file should be loaded by the OpenPLC plugin system") diff --git a/core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json b/core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json new file mode 100644 index 00000000..984972e0 --- /dev/null +++ b/core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json @@ -0,0 +1,21 @@ +{ + "_comment": "Modbus Slave Plugin Configuration Example - JSON format", + "_description": "This file shows how to configure the Modbus slave plugin", + "plugin_modbus_slave": { + "type": "PLUGIN_TYPE_PYTHON", + "path": "/home/marcone/Documents/Github/openplc-runtime/core/src/drivers/modbus_slave.py", + "enabled": true, + "description": "Modbus TCP Slave server that exposes OpenPLC buffers" + }, + "network_configuration": { + "host": "172.29.65.104", + "port": 5024 + }, + "buffer_mapping": { + "_comment": "Buffer mapping configuration", + "max_coils": 8000, + "max_discrete_inputs": 8000, + "max_holding_registers": 1000, + "max_input_registers": 1000 + } +} \ No newline at end of file diff --git a/core/src/drivers/plugins/python/modbus_slave/requirements.txt b/core/src/drivers/plugins/python/modbus_slave/requirements.txt new file mode 100644 index 00000000..3c90e4c5 --- /dev/null +++ b/core/src/drivers/plugins/python/modbus_slave/requirements.txt @@ -0,0 +1,2 @@ +pymodbus==3.11.2 +asyncio-mqtt==0.16.2 \ No newline at end of file diff --git a/core/src/drivers/plugins/python/modbus_slave/simple_modbus.py b/core/src/drivers/plugins/python/modbus_slave/simple_modbus.py new file mode 100644 index 00000000..81dab08a --- /dev/null +++ b/core/src/drivers/plugins/python/modbus_slave/simple_modbus.py @@ -0,0 +1,543 @@ +import asyncio +import ctypes +from operator import add +import threading +import time +import sys +import os +import json +from pymodbus.server import StartAsyncTcpServer, ServerStop +from pymodbus.datastore import ( + ModbusSparseDataBlock, + ModbusDeviceContext, + ModbusServerContext, +) + +MAX_BITS = 8 + +# Add the parent directory to Python path to find shared module +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +# Import the correct type definitions +from shared.python_plugin_types import ( + PluginRuntimeArgs, + safe_extract_runtime_args_from_capsule, + SafeBufferAccess, + PluginStructureValidator +) + +class OpenPLCCoilsDataBlock(ModbusSparseDataBlock): + """Custom Modbus coils data block that mirrors OpenPLC bool_output using SafeBufferAccess""" + + def __init__(self, runtime_args, num_coils=64): + self.runtime_args = runtime_args + self.num_coils = num_coils + + # Create safe buffer access wrapper + self.safe_buffer_access = SafeBufferAccess(runtime_args) + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Warning: Failed to create safe buffer access for coils: {self.safe_buffer_access.error_msg}") + + # Initialize with zeros + super().__init__([0] * num_coils) + + def getValues(self, address, count=1): + address = address - 1 # Modbus addresses are 0-based + + """Get coil values from OpenPLC bool_output using SafeBufferAccess""" + print(f"[MODBUS] Coils getValues called: address={address}, count={count}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return [0] * count + + # Ensure thread-safe access + self.safe_buffer_access.acquire_mutex() + + values = [] + for i in range(count): + coil_addr = address + i + + if coil_addr < self.num_coils: + # Map coil address to buffer and bit indices + buffer_idx = coil_addr // MAX_BITS # 8 bits per buffer + bit_idx = coil_addr % MAX_BITS # bit within buffer + + value, error_msg = self.safe_buffer_access.read_bool_output(buffer_idx, bit_idx, thread_safe=False) + if error_msg == "Success": + values.append(1 if value else 0) + print(f"[MODBUS] Read coil {coil_addr} (buf:{buffer_idx}, bit:{bit_idx}): {value}") + else: + print(f"[MODBUS] Error reading coil {coil_addr}: {error_msg}") + values.append(0) + else: + values.append(0) + + # Release mutex after access + self.safe_buffer_access.release_mutex() + + return values + + def setValues(self, address, values): + address = address - 1 # Modbus addresses are 0-based + """Set coil values to OpenPLC bool_output using SafeBufferAccess""" + print(f"[MODBUS] Coils setValues called: address={address}, values={values}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return + + # Ensure thread-safe access + self.safe_buffer_access.acquire_mutex() + + for i, value in enumerate(values): + coil_addr = address + i + + if coil_addr < self.num_coils: + # Map coil address to buffer and bit indices + buffer_idx = coil_addr // MAX_BITS # 8 bits per buffer + bit_idx = coil_addr % MAX_BITS # bit within buffer + + success, error_msg = self.safe_buffer_access.write_bool_output(buffer_idx, bit_idx, bool(value), thread_safe=False) + if error_msg == "Success": + print(f"[MODBUS] Set coil {coil_addr} (buf:{buffer_idx}, bit:{bit_idx}): {bool(value)}") + else: + print(f"[MODBUS] Error setting coil {coil_addr}: {error_msg}") + + # Release mutex after access + self.safe_buffer_access.release_mutex() + + +class OpenPLCDiscreteInputsDataBlock(ModbusSparseDataBlock): + """Custom Modbus discrete inputs data block that mirrors OpenPLC bool_input using SafeBufferAccess""" + + def __init__(self, runtime_args, num_inputs=64): + self.runtime_args = runtime_args + self.num_inputs = num_inputs + + # Create safe buffer access wrapper + self.safe_buffer_access = SafeBufferAccess(runtime_args) + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Warning: Failed to create safe buffer access for discrete inputs: {self.safe_buffer_access.error_msg}") + + # Initialize with zeros + super().__init__([0] * num_inputs) + + def getValues(self, address, count=1): + address = address - 1 # Modbus addresses are 0-based + """Get discrete input values from OpenPLC bool_input using SafeBufferAccess""" + print(f"[MODBUS] Discrete Inputs getValues called: address={address}, count={count}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return [0] * count + + # Ensure thread-safe access + self.safe_buffer_access.acquire_mutex() + + values = [] + for i in range(count): + input_addr = address + i + + if input_addr < self.num_inputs: + # Map input address to buffer and bit indices + buffer_idx = input_addr // MAX_BITS # 8 bits per buffer + bit_idx = input_addr % MAX_BITS # bit within buffer + + value, error_msg = self.safe_buffer_access.read_bool_input(buffer_idx, bit_idx, thread_safe=False) + if error_msg == "Success": + values.append(1 if value else 0) + print(f"[MODBUS] Read discrete input {input_addr} (buf:{buffer_idx}, bit:{bit_idx}): {value}") + else: + print(f"[MODBUS] Error reading discrete input {input_addr}: {error_msg}") + values.append(0) + else: + values.append(0) + + # Release mutex after access + self.safe_buffer_access.release_mutex() + + return values + + def setValues(self, address, values): + """Discrete inputs are read-only, this method should not be called""" + print(f"[MODBUS] Warning: Attempt to write to read-only discrete inputs at address {address}") + + +class OpenPLCInputRegistersDataBlock(ModbusSparseDataBlock): + """Custom Modbus input registers data block that mirrors OpenPLC analog inputs using SafeBufferAccess""" + + def __init__(self, runtime_args, num_registers=32): + self.runtime_args = runtime_args + self.num_registers = num_registers + + # Create safe buffer access wrapper + self.safe_buffer_access = SafeBufferAccess(runtime_args) + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Warning: Failed to create safe buffer access for input registers: {self.safe_buffer_access.error_msg}") + + # Initialize with zeros + super().__init__([0] * num_registers) + + def getValues(self, address, count=1): + address = address - 1 # Modbus addresses are 0-based + """Get input register values from OpenPLC int_input using SafeBufferAccess""" + print(f"[MODBUS] Input Registers getValues called: address={address}, count={count}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return [0] * count + + # Ensure buffer mutex + self.safe_buffer_access.acquire_mutex() + + values = [] + for i in range(count): + reg_addr = address + i + + if reg_addr < self.num_registers: + value, error_msg = self.safe_buffer_access.read_int_input(reg_addr, thread_safe=False) + if error_msg == "Success": + values.append(value) + print(f"[MODBUS] Read input register {reg_addr}: {value}") + else: + print(f"[MODBUS] Error reading input register {reg_addr}: {error_msg}") + values.append(0) + else: + values.append(0) + + # Release mutex after access + self.safe_buffer_access.release_mutex() + + return values + + def setValues(self, address, values): + """Input registers are read-only, this method should not be called""" + print(f"[MODBUS] Warning: Attempt to write to read-only input registers at address {address}") + + +class OpenPLCHoldingRegistersDataBlock(ModbusSparseDataBlock): + """Custom Modbus holding registers data block that mirrors OpenPLC analog outputs using SafeBufferAccess""" + + def __init__(self, runtime_args, num_registers=32): + self.runtime_args = runtime_args + self.num_registers = num_registers + + # Create safe buffer access wrapper + self.safe_buffer_access = SafeBufferAccess(runtime_args) + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Warning: Failed to create safe buffer access for holding registers: {self.safe_buffer_access.error_msg}") + + # Initialize with zeros + super().__init__([0] * num_registers) + + def getValues(self, address, count=1): + address = address - 1 # Modbus addresses are 0-based + """Get holding register values from OpenPLC int_output using SafeBufferAccess""" + print(f"[MODBUS] Holding Registers getValues called: address={address}, count={count}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return [0] * count + + # Ensure buffer mutex + self.safe_buffer_access.acquire_mutex() + + values = [] + for i in range(count): + reg_addr = address + i + + if reg_addr < self.num_registers: + value, error_msg = self.safe_buffer_access.read_int_output(reg_addr, thread_safe=False) + if error_msg == "Success": + values.append(value) + print(f"[MODBUS] Read holding register {reg_addr}: {value}") + else: + print(f"[MODBUS] Error reading holding register {reg_addr}: {error_msg}") + values.append(0) + else: + values.append(0) + + # Release mutex after access + self.safe_buffer_access.release_mutex() + return values + + def setValues(self, address, values): + address = address - 1 # Modbus addresses are 0-based + """Set holding register values to OpenPLC int_output using SafeBufferAccess""" + print(f"[MODBUS] Holding Registers setValues called: address={address}, values={values}") + + if not self.safe_buffer_access.is_valid: + print(f"[MODBUS] Error: Safe buffer access not valid: {self.safe_buffer_access.error_msg}") + return + + # Ensure buffer mutex + self.safe_buffer_access.acquire_mutex() + + for i, value in enumerate(values): + reg_addr = address + i + + if reg_addr < self.num_registers: + success, error_msg = self.safe_buffer_access.write_int_output(reg_addr, value, thread_safe=False) + if error_msg == "Success": + print(f"[MODBUS] Set holding register {reg_addr}: {value}") + else: + print(f"[MODBUS] Error setting holding register {reg_addr}: {error_msg}") + + # Release mutex after access + self.safe_buffer_access.release_mutex() + +# Global variables for plugin lifecycle +server_task = None +server_context = None +runtime_args = None +running = False +gIp = "172.29.65.104" # Default values +gPort = 5020 + +def init(args_capsule): + """Initialize the Modbus plugin""" + global runtime_args, server_context, gIp, gPort + + print("[MODBUS] Python plugin 'simple_modbus' initializing...") + + try: + # Print structure validation info for debugging + print("[MODBUS] Validating plugin structure alignment...") + # PluginStructureValidator.print_structure_info() + + # Extract runtime args from capsule using safe method + if hasattr(args_capsule, '__class__') and 'PyCapsule' in str(type(args_capsule)): + # This is a PyCapsule from C - use safe extraction + runtime_args, error_msg = safe_extract_runtime_args_from_capsule(args_capsule) + if runtime_args is None: + print(f"[MODBUS] Failed to extract runtime args: {error_msg}") + return False + + print(f"[MODBUS] Runtime arguments extracted successfully") + else: + # This is a direct object (for testing) + runtime_args = args_capsule + print(f"[MODBUS] Using direct runtime args for testing") + + # Try to load configuration from plugin_specific_config_file_path + try: + config_map, status = SafeBufferAccess(runtime_args).get_config_file_args_as_map() + if status == "Success" and config_map: + # Try to extract network configuration + network_config = config_map.get('network_configuration', {}) + if network_config and 'host' in network_config and 'port' in network_config: + gIp = str(network_config['host']) + gPort = int(network_config['port']) + print(f"[MODBUS] Configuration loaded - Host: {gIp}, Port: {gPort}") + else: + print(f"[MODBUS] Config file loaded but network_configuration section missing or incomplete - using defaults") + print(f"[MODBUS] Available config sections: {list(config_map.keys())}") + else: + print(f"[MODBUS] Failed to load configuration file: {status} - using defaults") + except Exception as config_error: + print(f"[MODBUS] Exception while loading config: {config_error} - using defaults") + import traceback + traceback.print_exc() + + # Safely access buffer size using validation + buffer_size, size_error = runtime_args.safe_access_buffer_size() + if buffer_size == -1: + print(f"[MODBUS] Failed to access buffer size: {size_error}") + return False + + # print(f"[MODBUS] Buffer size: {buffer_size}") + # print(f"[MODBUS] Bits per buffer: {runtime_args.bits_per_buffer}") + # print(f"[MODBUS] Structure details: {runtime_args}") + + # Create OpenPLC-connected data blocks for all Modbus types + coils_block = OpenPLCCoilsDataBlock(runtime_args, num_coils=64) + discrete_inputs_block = OpenPLCDiscreteInputsDataBlock(runtime_args, num_inputs=64) + input_registers_block = OpenPLCInputRegistersDataBlock(runtime_args, num_registers=32) + holding_registers_block = OpenPLCHoldingRegistersDataBlock(runtime_args, num_registers=32) + + # Create device context with all OpenPLC-connected data blocks + # print(f"[MODBUS] Created data blocks:") + # print(f"[MODBUS] - Coils (bool_output): {coils_block.num_coils} coils") + # print(f"[MODBUS] - Discrete Inputs (bool_input): {discrete_inputs_block.num_inputs} inputs") + # print(f"[MODBUS] - Input Registers (int_input): {input_registers_block.num_registers} registers") + # print(f"[MODBUS] - Holding Registers (int_output): {holding_registers_block.num_registers} registers") + + device = ModbusDeviceContext( + di=discrete_inputs_block, # Discrete Inputs -> bool_input + co=coils_block, # Coils -> bool_output + ir=input_registers_block, # Input Registers -> int_input + hr=holding_registers_block # Holding Registers -> int_output + ) + server_context = ModbusServerContext(devices={1: device}, single=False) + + print(f"[MODBUS] Plugin initialized successfully - Host: {gIp}, Port: {gPort}") + return True + + except Exception as e: + print(f"[MODBUS] Plugin initialization failed: {e}") + import traceback + traceback.print_exc() + return False + +def start_loop(): + """Start the Modbus server""" + global server_task, running, gIp, gPort + + if server_context is None: + print("[MODBUS] Error: Plugin not initialized") + return False + + print("[MODBUS] Server context is valid, proceeding with startup...") + print(f"[MODBUS] Server context created successfully") + + running = True + + # Start server in separate thread with proper asyncio handling + def run_server(): + try: + print("[MODBUS] Creating new event loop for server thread...") + # Create new event loop for this thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + print("[MODBUS] Event loop created successfully") + + # Start the server and keep it running + async def start_server(): + try: + print(f"[MODBUS] Attempting to start TCP server on {gIp}:{gPort}...") + try: + server = await StartAsyncTcpServer( + context=server_context, + address=(gIp, gPort) + ) + print(f"[MODBUS] Server successfully bound to {gIp}:{gPort}") + except Exception as bind_error: + print(f"[MODBUS] Failed to bind to {gIp}:{gPort}: {bind_error}") + print(f"[MODBUS] Attempting to bind to 0.0.0.0:{gPort} as fallback...") + server = await StartAsyncTcpServer( + context=server_context, + address=("0.0.0.0", gPort) + ) + print(f"[MODBUS] Server successfully bound to 0.0.0.0:{gPort} (fallback)") + + # Keep the server running + try: + print("[MODBUS] Server is now running and accepting connections") + while running: + await asyncio.sleep(1) + except asyncio.CancelledError: + print("[MODBUS] Server cancelled") + finally: + print("[MODBUS] Shutting down server...") + if hasattr(server, 'close'): + server.close() + if hasattr(server, 'wait_closed'): + await server.wait_closed() + print("[MODBUS] Server shutdown complete") + + except Exception as server_error: + print(f"[MODBUS] Error in start_server async function: {server_error}") + import traceback + print(f"[MODBUS] Traceback: {traceback.format_exc()}") + raise + + # Run the server + print("[MODBUS] Running server event loop...") + loop.run_until_complete(start_server()) + + except Exception as e: + print(f"[MODBUS] Error in run_server thread: {e}") + import traceback + print(f"[MODBUS] Full traceback: {traceback.format_exc()}") + finally: + print("[MODBUS] Closing event loop...") + loop.close() + print("[MODBUS] Event loop closed") + + server_task = threading.Thread(target=run_server, daemon=False) + server_task.start() + + print(f"[MODBUS] Server thread started on {gIp}:{gPort}") + return True + +def stop_loop(): + """Stop the Modbus server""" + global server_task, running + + running = False + + if server_task: + # Stop the asyncio server + try: + asyncio.run(ServerStop()) + except: + pass + + server_task.join(timeout=2.0) + server_task = None + + print("[MODBUS] Server stopped") + return True + +def cleanup(): + """Cleanup plugin resources""" + global server_context, runtime_args + + server_context = None + runtime_args = None + + print("[MODBUS] Plugin cleaned up") + return True + +async def main(): + """Standalone server for testing""" + # Create a proper mock runtime args that inherits from PluginRuntimeArgs + import ctypes + + # Create a mock that has the required methods + class MockArgs: + def __init__(self): + self.buffer_size = 1 + self.bits_per_buffer = 8 + # Create simple boolean list for testing + self.bool_data = [[False] * 8] # 1 buffer, 8 booleans + self.bool_output = self.bool_data # Simple reference + self.mutex_take = None + self.mutex_give = None + self.buffer_mutex = None + + def safe_access_buffer_size(self): + """Mock implementation of safe_access_buffer_size""" + return self.buffer_size, "Success" + + def validate_pointers(self): + """Mock implementation of validate_pointers""" + return True, "Mock validation passed" + + def __str__(self): + return f"MockArgs(buffer_size={self.buffer_size}, bits_per_buffer={self.bits_per_buffer})" + + mock_args = MockArgs() + + # Initialize and start + if init(mock_args): + if start_loop(): + print(f"Modbus server running on {gIp}:{gPort}") + print("Press Ctrl+C to stop...") + + try: + # Keep server running + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + print("\nStopping server...") + stop_loop() + cleanup() + else: + print("Failed to start server") + else: + print("Failed to initialize plugin") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/core/src/drivers/plugins/python/shared/__init__.py b/core/src/drivers/plugins/python/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/src/drivers/plugins/python/shared/python_plugin_types.py b/core/src/drivers/plugins/python/shared/python_plugin_types.py new file mode 100644 index 00000000..46a61a2d --- /dev/null +++ b/core/src/drivers/plugins/python/shared/python_plugin_types.py @@ -0,0 +1,1591 @@ +#!/usr/bin/env python3 +""" +Shared type definitions for OpenPLC Python plugins +This module provides correct ctypes mappings for the plugin_runtime_args_t structure +""" + +import ctypes +from ctypes import * +import json +import sys + +# IEC type mappings based on iec_types.h +# These must match exactly with the C definitions +IEC_BOOL = ctypes.c_uint8 # typedef uint8_t IEC_BOOL; +IEC_BYTE = ctypes.c_uint8 # typedef uint8_t IEC_BYTE; +IEC_UINT = ctypes.c_uint16 # typedef uint16_t IEC_UINT; +IEC_UDINT = ctypes.c_uint32 # typedef uint32_t IEC_UDINT; +IEC_ULINT = ctypes.c_uint64 # typedef uint64_t IEC_ULINT; + +class PluginRuntimeArgs(ctypes.Structure): + """ + Python ctypes structure matching plugin_runtime_args_t from plugin_driver.h + + CRITICAL: This structure must match the C definition exactly to prevent + segmentation faults and memory corruption. + """ + _fields_ = [ + # Buffer arrays - these are pointers to arrays of pointers + # C: IEC_BOOL *(*bool_input)[8] means pointer to array of 8 pointers + ("bool_input", ctypes.POINTER(ctypes.POINTER(IEC_BOOL) * 8)), + ("bool_output", ctypes.POINTER(ctypes.POINTER(IEC_BOOL) * 8)), + ("byte_input", ctypes.POINTER(ctypes.POINTER(IEC_BYTE))), + ("byte_output", ctypes.POINTER(ctypes.POINTER(IEC_BYTE))), + ("int_input", ctypes.POINTER(ctypes.POINTER(IEC_UINT))), + ("int_output", ctypes.POINTER(ctypes.POINTER(IEC_UINT))), + ("dint_input", ctypes.POINTER(ctypes.POINTER(IEC_UDINT))), + ("dint_output", ctypes.POINTER(ctypes.POINTER(IEC_UDINT))), + ("lint_input", ctypes.POINTER(ctypes.POINTER(IEC_ULINT))), + ("lint_output", ctypes.POINTER(ctypes.POINTER(IEC_ULINT))), + ("int_memory", ctypes.POINTER(ctypes.POINTER(IEC_UINT))), + ("dint_memory", ctypes.POINTER(ctypes.POINTER(IEC_UDINT))), + ("lint_memory", ctypes.POINTER(ctypes.POINTER(IEC_ULINT))), + + # Mutex function pointers + ("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), + ("plugin_specific_config_file_path", ctypes.c_char * 256), + + # Buffer size information + ("buffer_size", ctypes.c_int), + ("bits_per_buffer", ctypes.c_int), + ] + + def validate_pointers(self): + """ + Validate that critical pointers are not NULL + Returns: (bool, str) - (is_valid, error_message) + """ + try: + # Check buffer mutex + if not self.buffer_mutex: + return False, "buffer_mutex is NULL" + + # Check mutex functions + if not self.mutex_take: + return False, "mutex_take function pointer is NULL" + if not self.mutex_give: + return False, "mutex_give function pointer is NULL" + + # Check buffer size is reasonable + if self.buffer_size <= 0 or self.buffer_size > 10000: + return False, f"buffer_size is invalid: {self.buffer_size}" + + if self.bits_per_buffer <= 0 or self.bits_per_buffer > 64: + return False, f"bits_per_buffer is invalid: {self.bits_per_buffer}" + + return True, "All pointers valid" + + except (AttributeError, TypeError) as e: + return False, f"Structure access error during validation: {e}" + except (ValueError, OverflowError) as e: + return False, f"Value validation error: {e}" + except OSError as e: + return False, f"System error during validation: {e}" + + def safe_access_buffer_size(self): + """ + Safely access buffer_size with validation + Returns: (int, str) - (buffer_size, error_message) + """ + try: + is_valid, msg = self.validate_pointers() + if not is_valid: + return -1, f"Validation failed: {msg}" + + size = self.buffer_size + if size <= 0 or size > 10000: + return -1, f"Invalid buffer size: {size}" + + return size, "Success" + + except (AttributeError, TypeError) as e: + return -1, f"Structure access error: {e}" + except (ValueError, OverflowError) as e: + return -1, f"Value validation error: {e}" + except OSError as e: + return -1, f"System error accessing buffer_size: {e}" + + def __str__(self): + """Debug representation of the structure""" + try: + return (f"PluginRuntimeArgs(\n" + f" bool_input=0x{ctypes.addressof(self.bool_input.contents) if self.bool_input else 0:x},\n" + f" bool_output=0x{ctypes.addressof(self.bool_output.contents) if self.bool_output else 0:x},\n" + f" byte_input=0x{ctypes.addressof(self.byte_input.contents) if self.byte_input else 0:x},\n" + f" byte_output=0x{ctypes.addressof(self.byte_output.contents) if self.byte_output else 0:x},\n" + f" int_input=0x{ctypes.addressof(self.int_input.contents) if self.int_input else 0:x},\n" + f" int_output=0x{ctypes.addressof(self.int_output.contents) if self.int_output else 0:x},\n" + f" dint_input=0x{ctypes.addressof(self.dint_input.contents) if self.dint_input else 0:x},\n" + f" dint_output=0x{ctypes.addressof(self.dint_output.contents) if self.dint_output else 0:x},\n" + f" lint_input=0x{ctypes.addressof(self.lint_input.contents) if self.lint_input else 0:x},\n" + f" lint_output=0x{ctypes.addressof(self.lint_output.contents) if self.lint_output else 0:x},\n" + f" int_memory=0x{ctypes.addressof(self.int_memory.contents) if self.int_memory else 0:x},\n" + f" buffer_size={self.buffer_size},\n" + f" bits_per_buffer={self.bits_per_buffer},\n" + f" buffer_mutex=0x{self.buffer_mutex or 0:x},\n" + f" mutex_take={'valid' if self.mutex_take else 'NULL'},\n" + f" mutex_give={'valid' if self.mutex_give else 'NULL'}\n" + f")") + except: + return "PluginRuntimeArgs(corrupted or invalid)" + +class PluginStructureValidator: + """Validates structure alignment and provides debugging tools""" + + @staticmethod + def validate_structure_alignment(): + """ + Validates that the Python ctypes structure has the expected size and alignment + Returns: (bool, str, dict) - (is_valid, message, debug_info) + """ + try: + # Calculate expected structure size + # This is platform-dependent but we can do basic checks + struct_size = ctypes.sizeof(PluginRuntimeArgs) + + debug_info = { + 'structure_size': struct_size, + 'pointer_size': ctypes.sizeof(ctypes.c_void_p), + 'int_size': ctypes.sizeof(ctypes.c_int), + 'platform': sys.platform, + 'architecture': sys.maxsize > 2**32 and '64-bit' or '32-bit' + } + + # Basic sanity checks + expected_min_size = ( + 13 * ctypes.sizeof(ctypes.c_void_p) + # 13 buffer pointers + 2 * ctypes.sizeof(ctypes.c_void_p) + # 2 function pointers + 1 * ctypes.sizeof(ctypes.c_void_p) + # 1 mutex pointer + 2 * ctypes.sizeof(ctypes.c_int) # 2 integers + ) + + if struct_size < expected_min_size: + return False, f"Structure too small: {struct_size} < {expected_min_size}", debug_info + + # Check field offsets make sense + buffer_size_offset = PluginRuntimeArgs.buffer_size.offset + bits_per_buffer_offset = PluginRuntimeArgs.bits_per_buffer.offset + + if bits_per_buffer_offset <= buffer_size_offset: + return False, "Field offsets are incorrect", debug_info + + debug_info['buffer_size_offset'] = buffer_size_offset + debug_info['bits_per_buffer_offset'] = bits_per_buffer_offset + + return True, "Structure validation passed", debug_info + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, f"Exception during validation: {e}", {} + + @staticmethod + def print_structure_info(): + """Print detailed structure information for debugging""" + is_valid, msg, debug_info = PluginStructureValidator.validate_structure_alignment() + + print("=== Plugin Structure Validation ===") + print(f"Status: {'VALID' if is_valid else 'INVALID'}") + print(f"Message: {msg}") + print("\nStructure Details:") + for key, value in debug_info.items(): + print(f" {key}: {value}") + + print(f"\nField Offsets:") + try: + for field_name, field_type in PluginRuntimeArgs._fields_: + offset = getattr(PluginRuntimeArgs, field_name).offset + print(f" {field_name}: offset {offset}") + except (AttributeError, TypeError) as e: + print(f" Error getting field offsets: {e}") + +class SafeBufferAccess: + """Wrapper class for safe buffer operations with mutex handling""" + + def __init__(self, runtime_args): + """ + Initialize with validated runtime args + Args: + runtime_args: PluginRuntimeArgs instance + """ + self.args = runtime_args + self.is_valid, self.error_msg = runtime_args.validate_pointers() + + @staticmethod + def _handle_buffer_exception(exception, operation_name): + """ + Centralized exception handling for buffer operations + Args: + exception: The caught exception + operation_name: Name of the operation that failed + Returns: + str: Formatted error message + """ + if isinstance(exception, (AttributeError, TypeError)): + return f"Structure access error during {operation_name}: {exception}" + elif isinstance(exception, (ValueError, OverflowError)): + return f"Value validation error during {operation_name}: {exception}" + elif isinstance(exception, OSError): + return f"System error during {operation_name}: {exception}" + elif isinstance(exception, MemoryError): + return f"Memory error during {operation_name}: {exception}" + else: + return f"Unexpected error during {operation_name}: {exception}" + + def read_bool_input(self, buffer_idx, bit_idx, thread_safe=True): + """ + Safely read a boolean input with optional mutex handling + Args: + buffer_idx: Buffer index + bit_idx: Bit index within buffer + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (value, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + return False, f"Invalid bit index: {bit_idx}" + + # Access the value - read from the actual value, not the pointer + value = bool(self.args.bool_input[buffer_idx][bit_idx].contents.value) + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def read_bool_output(self, buffer_idx, bit_idx, thread_safe=True): + """ + Safely read a boolean output with optional mutex handling + Args: + buffer_idx: Buffer index + bit_idx: Bit index within buffer + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (value, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + return False, f"Invalid bit index: {bit_idx}" + + # Access the value - read from the actual value, not the pointer + value = bool(self.args.bool_output[buffer_idx][bit_idx].contents.value) + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def write_bool_output(self, buffer_idx, bit_idx, value, thread_safe=True): + """ + Safely write a boolean output with optional mutex handling + Args: + buffer_idx: Buffer index + bit_idx: Bit index within buffer + value: Boolean value to write + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + return False, f"Invalid bit index: {bit_idx}" + + # Set the value - access the actual value, not the pointer + self.args.bool_output[buffer_idx][bit_idx].contents.value = 1 if value else 0 + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + # Byte buffer access functions + def read_byte_input(self, buffer_idx, thread_safe=True): + """ + Safely read a byte input with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.byte_input[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_byte_output(self, buffer_idx, value, thread_safe=True): + """ + Safely write a byte output with optional mutex handling + Args: + buffer_idx: Buffer index + value: Byte value to write (0-255) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 255): + return False, f"Invalid byte value: {value} (must be 0-255)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.byte_output[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def read_byte_output(self, buffer_idx, thread_safe=True): + """ + Safely read a byte output with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.byte_output[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + # Int buffer access functions (IEC_UINT - 16-bit) + def read_int_input(self, buffer_idx, thread_safe=True): + """ + Safely read an int input with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.int_input[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_int_output(self, buffer_idx, value, thread_safe=True): + """ + Safely write an int output with optional mutex handling + Args: + buffer_idx: Buffer index + value: Int value to write (0-65535) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 65535): + return False, f"Invalid int value: {value} (must be 0-65535)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.int_output[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def read_int_output(self, buffer_idx, thread_safe=True): + """ + Safely read an int output with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.int_output[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + # Dint buffer access functions (IEC_UDINT - 32-bit) + def read_dint_input(self, buffer_idx, thread_safe=True): + """ + Safely read a dint input with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.dint_input[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_dint_output(self, buffer_idx, value, thread_safe=True): + """ + Safely write a dint output with optional mutex handling + Args: + buffer_idx: Buffer index + value: Dint value to write (0-4294967295) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 4294967295): + return False, f"Invalid dint value: {value} (must be 0-4294967295)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.dint_output[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def read_dint_output(self, buffer_idx, thread_safe=True): + """ + Safely read a dint output with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.dint_output[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + # Lint buffer access functions (IEC_ULINT - 64-bit) + def read_lint_input(self, buffer_idx, thread_safe=True): + """ + Safely read a lint input with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.lint_input[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_lint_output(self, buffer_idx, value, thread_safe=True): + """ + Safely write a lint output with optional mutex handling + Args: + buffer_idx: Buffer index + value: Lint value to write (0-18446744073709551615) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 18446744073709551615): + return False, f"Invalid lint value: {value} (must be 0-18446744073709551615)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.lint_output[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + def read_lint_output(self, buffer_idx, thread_safe=True): + """ + Safely read a lint output with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.lint_output[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + # Memory buffer access functions (IEC_UINT - 16-bit) + def read_int_memory(self, buffer_idx, thread_safe=True): + """ + Safely read an int memory with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.int_memory[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_int_memory(self, buffer_idx, value, thread_safe=True): + """ + Safely write an int memory with optional mutex handling + Args: + buffer_idx: Buffer index + value: Int value to write (0-65535) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 65535): + return False, f"Invalid int value: {value} (must be 0-65535)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.int_memory[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + # Memory buffer access functions (IEC_UDINT - 32-bit) + def read_dint_memory(self, buffer_idx, thread_safe=True): + """ + Safely read a dint memory with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.dint_memory[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_dint_memory(self, buffer_idx, value, thread_safe=True): + """ + Safely write a dint memory with optional mutex handling + Args: + buffer_idx: Buffer index + value: Dint value to write (0-4294967295) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 4294967295): + return False, f"Invalid dint value: {value} (must be 0-4294967295)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.dint_memory[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + # Memory buffer access functions (IEC_ULINT - 64-bit) + def read_lint_memory(self, buffer_idx, thread_safe=True): + """ + Safely read a lint memory with optional mutex handling + Args: + buffer_idx: Buffer index + thread_safe: If True, uses mutex for thread-safe access + Returns: (int, str) - (value, error_message) + """ + if not self.is_valid: + return 0, f"Invalid runtime args: {self.error_msg}" + + try: + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return 0, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return 0, f"Invalid buffer index: {buffer_idx}" + + # Access the value + value = self.args.lint_memory[buffer_idx].contents.value + return value, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return 0, self._handle_buffer_exception(e, "buffer access") + + def write_lint_memory(self, buffer_idx, value, thread_safe=True): + """ + Safely write a lint memory with optional mutex handling + Args: + buffer_idx: Buffer index + value: Lint value to write (0-18446744073709551615) + thread_safe: If True, uses mutex for thread-safe access + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + # Validate value range + if not (0 <= value <= 18446744073709551615): + return False, f"Invalid lint value: {value} (must be 0-18446744073709551615)" + + # Take mutex only if thread_safe is True + mutex_acquired = False + if thread_safe: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + mutex_acquired = True + + try: + # Validate index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + return False, f"Invalid buffer index: {buffer_idx}" + + # Set the value + self.args.lint_memory[buffer_idx].contents.value = value + return True, "Success" + + finally: + # Release mutex only if it was acquired + if mutex_acquired: + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, self._handle_buffer_exception(e, "buffer access") + + # Mutex API functions for manual control + def acquire_mutex(self): + """ + Manually acquire the buffer mutex + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return False, "Failed to acquire mutex" + return True, "Mutex acquired successfully" + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, f"Exception during mutex acquisition: {e}" + + def release_mutex(self): + """ + Manually release the buffer mutex + Returns: (bool, str) - (success, error_message) + """ + if not self.is_valid: + return False, f"Invalid runtime args: {self.error_msg}" + + try: + if self.args.mutex_give(self.args.buffer_mutex) != 0: + return False, "Failed to release mutex" + return True, "Mutex released successfully" + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return False, f"Exception during mutex release: {e}" + + # Batch operations for optimized mutex usage + def batch_read_values(self, operations): + """ + Perform multiple read operations with a single mutex acquisition + Args: + operations: List of tuples describing read operations + Format: [('buffer_type', buffer_idx, bit_idx), ...] + buffer_type can be: 'bool_input', 'bool_output', 'byte_input', 'byte_output', + 'int_input', 'int_output', 'dint_input', 'dint_output', + 'lint_input', 'lint_output', 'int_memory', 'dint_memory', 'lint_memory' + bit_idx is only required for bool operations + Returns: (list, str) - (results, error_message) + results format: [(success, value, error_msg), ...] + """ + if not self.is_valid: + return [], f"Invalid runtime args: {self.error_msg}" + + if not operations: + return [], "No operations provided" + + results = [] + + try: + # Acquire mutex once for all operations + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return [], "Failed to acquire mutex" + + try: + for operation in operations: + try: + if len(operation) < 2: + results.append((False, None, "Invalid operation format")) + continue + + buffer_type = operation[0] + buffer_idx = operation[1] + + # Handle boolean operations (require bit_idx) + if buffer_type in ['bool_input', 'bool_output']: + if len(operation) < 3: + results.append((False, None, "Boolean operations require bit_idx")) + continue + bit_idx = operation[2] + + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + results.append((False, None, f"Invalid buffer index: {buffer_idx}")) + continue + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + results.append((False, None, f"Invalid bit index: {bit_idx}")) + continue + + if buffer_type == 'bool_input': + value = bool(self.args.bool_input[buffer_idx][bit_idx].contents.value) + else: # bool_output + value = bool(self.args.bool_output[buffer_idx][bit_idx].contents.value) + + results.append((True, value, "Success")) + + # Handle other buffer types + else: + # Validate buffer index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + results.append((False, None, f"Invalid buffer index: {buffer_idx}")) + continue + + if buffer_type == 'byte_input': + value = self.args.byte_input[buffer_idx].contents.value + elif buffer_type == 'byte_output': + value = self.args.byte_output[buffer_idx].contents.value + elif buffer_type == 'int_input': + value = self.args.int_input[buffer_idx].contents.value + elif buffer_type == 'int_output': + value = self.args.int_output[buffer_idx].contents.value + elif buffer_type == 'dint_input': + value = self.args.dint_input[buffer_idx].contents.value + elif buffer_type == 'dint_output': + value = self.args.dint_output[buffer_idx].contents.value + elif buffer_type == 'lint_input': + value = self.args.lint_input[buffer_idx].contents.value + elif buffer_type == 'lint_output': + value = self.args.lint_output[buffer_idx].contents.value + elif buffer_type == 'int_memory': + value = self.args.int_memory[buffer_idx].contents.value + elif buffer_type == 'dint_memory': + value = self.args.dint_memory[buffer_idx].contents.value + elif buffer_type == 'lint_memory': + value = self.args.lint_memory[buffer_idx].contents.value + else: + results.append((False, None, f"Unknown buffer type: {buffer_type}")) + continue + + results.append((True, value, "Success")) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + results.append((False, None, f"Exception during operation: {e}")) + + return results, "Batch read completed" + + finally: + # Always release mutex + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return [], f"Exception during batch read: {e}" + + def batch_write_values(self, operations): + """ + Perform multiple write operations with a single mutex acquisition + Args: + operations: List of tuples describing write operations + Format: [('buffer_type', buffer_idx, value, bit_idx), ...] + buffer_type can be: 'bool_output', 'byte_output', 'int_output', 'dint_output', + 'lint_output', 'int_memory', 'dint_memory', 'lint_memory' + bit_idx is only required for bool operations (last parameter) + Returns: (list, str) - (results, error_message) + results format: [(success, error_msg), ...] + """ + if not self.is_valid: + return [], f"Invalid runtime args: {self.error_msg}" + + if not operations: + return [], "No operations provided" + + results = [] + + try: + # Acquire mutex once for all operations + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return [], "Failed to acquire mutex" + + try: + for operation in operations: + try: + if len(operation) < 3: + results.append((False, "Invalid operation format")) + continue + + buffer_type = operation[0] + buffer_idx = operation[1] + value = operation[2] + + # Handle boolean operations (require bit_idx) + if buffer_type == 'bool_output': + if len(operation) < 4: + results.append((False, "Boolean operations require bit_idx")) + continue + bit_idx = operation[3] + + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + results.append((False, f"Invalid buffer index: {buffer_idx}")) + continue + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + results.append((False, f"Invalid bit index: {bit_idx}")) + continue + + self.args.bool_output[buffer_idx][bit_idx].contents.value = 1 if value else 0 + results.append((True, "Success")) + + # Handle other buffer types + else: + # Validate buffer index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + results.append((False, f"Invalid buffer index: {buffer_idx}")) + continue + + # Validate value ranges and write + if buffer_type == 'byte_output': + if not (0 <= value <= 255): + results.append((False, f"Invalid byte value: {value} (must be 0-255)")) + continue + self.args.byte_output[buffer_idx].contents.value = value + elif buffer_type == 'int_output': + if not (0 <= value <= 65535): + results.append((False, f"Invalid int value: {value} (must be 0-65535)")) + continue + self.args.int_output[buffer_idx].contents.value = value + elif buffer_type == 'dint_output': + if not (0 <= value <= 4294967295): + results.append((False, f"Invalid dint value: {value} (must be 0-4294967295)")) + continue + self.args.dint_output[buffer_idx].contents.value = value + elif buffer_type == 'lint_output': + if not (0 <= value <= 18446744073709551615): + results.append((False, f"Invalid lint value: {value} (must be 0-18446744073709551615)")) + continue + self.args.lint_output[buffer_idx].contents.value = value + elif buffer_type == 'int_memory': + if not (0 <= value <= 65535): + results.append((False, f"Invalid int value: {value} (must be 0-65535)")) + continue + self.args.int_memory[buffer_idx].contents.value = value + elif buffer_type == 'dint_memory': + if not (0 <= value <= 4294967295): + results.append((False, f"Invalid dint value: {value} (must be 0-4294967295)")) + continue + self.args.dint_memory[buffer_idx].contents.value = value + elif buffer_type == 'lint_memory': + if not (0 <= value <= 18446744073709551615): + results.append((False, f"Invalid lint value: {value} (must be 0-18446744073709551615)")) + continue + self.args.lint_memory[buffer_idx].contents.value = value + else: + results.append((False, f"Unknown buffer type: {buffer_type}")) + continue + + results.append((True, "Success")) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + results.append((False, f"Exception during operation: {e}")) + + return results, "Batch write completed" + + finally: + # Always release mutex + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return [], f"Exception during batch write: {e}" + + def batch_mixed_operations(self, read_operations, write_operations): + """ + Perform mixed read and write operations with a single mutex acquisition + Args: + read_operations: List of read operation tuples (same format as batch_read_values) + write_operations: List of write operation tuples (same format as batch_write_values) + Returns: (dict, str) - (results, error_message) + results format: {'reads': [(success, value, error_msg), ...], 'writes': [(success, error_msg), ...]} + """ + if not self.is_valid: + return {}, f"Invalid runtime args: {self.error_msg}" + + if not read_operations and not write_operations: + return {}, "No operations provided" + + read_results = [] + write_results = [] + + try: + # Acquire mutex once for all operations + if self.args.mutex_take(self.args.buffer_mutex) != 0: + return {}, "Failed to acquire mutex" + + try: + # Perform read operations first (typically safer) + if read_operations: + for operation in read_operations: + try: + if len(operation) < 2: + read_results.append((False, None, "Invalid operation format")) + continue + + buffer_type = operation[0] + buffer_idx = operation[1] + + # Handle boolean operations (require bit_idx) + if buffer_type in ['bool_input', 'bool_output']: + if len(operation) < 3: + read_results.append((False, None, "Boolean operations require bit_idx")) + continue + bit_idx = operation[2] + + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + read_results.append((False, None, f"Invalid buffer index: {buffer_idx}")) + continue + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + read_results.append((False, None, f"Invalid bit index: {bit_idx}")) + continue + + if buffer_type == 'bool_input': + value = bool(self.args.bool_input[buffer_idx][bit_idx].contents.value) + else: # bool_output + value = bool(self.args.bool_output[buffer_idx][bit_idx].contents.value) + + read_results.append((True, value, "Success")) + + # Handle other buffer types + else: + # Validate buffer index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + read_results.append((False, None, f"Invalid buffer index: {buffer_idx}")) + continue + + if buffer_type == 'byte_input': + value = self.args.byte_input[buffer_idx].contents.value + elif buffer_type == 'byte_output': + value = self.args.byte_output[buffer_idx].contents.value + elif buffer_type == 'int_input': + value = self.args.int_input[buffer_idx].contents.value + elif buffer_type == 'int_output': + value = self.args.int_output[buffer_idx].contents.value + elif buffer_type == 'dint_input': + value = self.args.dint_input[buffer_idx].contents.value + elif buffer_type == 'dint_output': + value = self.args.dint_output[buffer_idx].contents.value + elif buffer_type == 'lint_input': + value = self.args.lint_input[buffer_idx].contents.value + elif buffer_type == 'lint_output': + value = self.args.lint_output[buffer_idx].contents.value + elif buffer_type == 'int_memory': + value = self.args.int_memory[buffer_idx].contents.value + elif buffer_type == 'dint_memory': + value = self.args.dint_memory[buffer_idx].contents.value + elif buffer_type == 'lint_memory': + value = self.args.lint_memory[buffer_idx].contents.value + else: + read_results.append((False, None, f"Unknown buffer type: {buffer_type}")) + continue + + read_results.append((True, value, "Success")) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + read_results.append((False, None, f"Exception during read operation: {e}")) + + # Perform write operations + if write_operations: + for operation in write_operations: + try: + if len(operation) < 3: + write_results.append((False, "Invalid operation format")) + continue + + buffer_type = operation[0] + buffer_idx = operation[1] + value = operation[2] + + # Handle boolean operations (require bit_idx) + if buffer_type == 'bool_output': + if len(operation) < 4: + write_results.append((False, "Boolean operations require bit_idx")) + continue + bit_idx = operation[3] + + # Validate indices + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + write_results.append((False, f"Invalid buffer index: {buffer_idx}")) + continue + if bit_idx < 0 or bit_idx >= self.args.bits_per_buffer: + write_results.append((False, f"Invalid bit index: {bit_idx}")) + continue + + self.args.bool_output[buffer_idx][bit_idx].contents.value = 1 if value else 0 + write_results.append((True, "Success")) + + # Handle other buffer types + else: + # Validate buffer index + if buffer_idx < 0 or buffer_idx >= self.args.buffer_size: + write_results.append((False, f"Invalid buffer index: {buffer_idx}")) + continue + + # Validate value ranges and write + if buffer_type == 'byte_output': + if not (0 <= value <= 255): + write_results.append((False, f"Invalid byte value: {value} (must be 0-255)")) + continue + self.args.byte_output[buffer_idx].contents.value = value + elif buffer_type == 'int_output': + if not (0 <= value <= 65535): + write_results.append((False, f"Invalid int value: {value} (must be 0-65535)")) + continue + self.args.int_output[buffer_idx].contents.value = value + elif buffer_type == 'dint_output': + if not (0 <= value <= 4294967295): + write_results.append((False, f"Invalid dint value: {value} (must be 0-4294967295)")) + continue + self.args.dint_output[buffer_idx].contents.value = value + elif buffer_type == 'lint_output': + if not (0 <= value <= 18446744073709551615): + write_results.append((False, f"Invalid lint value: {value} (must be 0-18446744073709551615)")) + continue + self.args.lint_output[buffer_idx].contents.value = value + elif buffer_type == 'int_memory': + if not (0 <= value <= 65535): + write_results.append((False, f"Invalid int value: {value} (must be 0-65535)")) + continue + self.args.int_memory[buffer_idx].contents.value = value + elif buffer_type == 'dint_memory': + if not (0 <= value <= 4294967295): + write_results.append((False, f"Invalid dint value: {value} (must be 0-4294967295)")) + continue + self.args.dint_memory[buffer_idx].contents.value = value + elif buffer_type == 'lint_memory': + if not (0 <= value <= 18446744073709551615): + write_results.append((False, f"Invalid lint value: {value} (must be 0-18446744073709551615)")) + continue + self.args.lint_memory[buffer_idx].contents.value = value + else: + write_results.append((False, f"Unknown buffer type: {buffer_type}")) + continue + + write_results.append((True, "Success")) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + write_results.append((False, f"Exception during write operation: {e}")) + + return {'reads': read_results, 'writes': write_results}, "Batch mixed operations completed" + + finally: + # Always release mutex + self.args.mutex_give(self.args.buffer_mutex) + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return {}, f"Exception during batch mixed operations: {e}" + + def get_config_path(self): + """ + Retrieve the plugin-specific configuration file path + Returns: (str, str) - (config_path, error_message) + """ + if not self.is_valid: + return "", f"Invalid runtime args: {self.error_msg}" + + try: + config_path_bytes = self.args.plugin_specific_config_file_path + + # Handle different types of C char arrays + if isinstance(config_path_bytes, (bytes, bytearray)): + config_path = config_path_bytes.decode('utf-8').rstrip('\x00') + elif hasattr(config_path_bytes, 'value'): + config_path = config_path_bytes.value.decode('utf-8').rstrip('\x00') + elif hasattr(config_path_bytes, 'raw'): + config_path = config_path_bytes.raw.decode('utf-8').rstrip('\x00') + else: + # Try to convert to bytes first + config_path = bytes(config_path_bytes).decode('utf-8').rstrip('\x00') + + # Clean up the path - remove all whitespace and control characters + config_path = config_path.strip() + + return config_path, "Success" + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return "", f"Exception retrieving config path: {e}" + + def get_config_file_args_as_map(self): + """ + Parse the plugin-specific configuration file as a key-value map + Supports JSON format for flexibility + Returns: (dict, str) - (config_map, error_message) + """ + import os + + config_path, err_msg = self.get_config_path() + if not config_path: + return {}, f"Failed to get config path: {err_msg}" + + # Debug information + debug_info = f"Original path: '{config_path}', CWD: '{os.getcwd()}'" + + try: + with open(config_path, 'r') as config_file: + config_data = json.load(config_file) + if not isinstance(config_data, dict): + return {}, "Configuration file must contain a JSON object at the top level" + return config_data, "Success" + except FileNotFoundError: + return {}, f"Configuration file not found: {config_path}" + except json.JSONDecodeError as e: + return {}, f"JSON parsing error in config file {config_path}: {e}" + except (OSError, MemoryError) as e: + return {}, f"Exception reading config file {config_path}: {e}" + + +def safe_extract_runtime_args_from_capsule(capsule): + """ + Enhanced capsule extraction with comprehensive validation + Args: + capsule: PyCapsule containing plugin_runtime_args_t structure + Returns: + (PluginRuntimeArgs, str) - (runtime_args, error_message) + """ + try: + # Validate capsule type + if not hasattr(capsule, '__class__') or capsule.__class__.__name__ != 'PyCapsule': + return None, f"Expected PyCapsule object, got {type(capsule)}" + + # Set up the Python API function signatures + ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object, ctypes.c_char_p] + ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p + + # Get the pointer from the capsule + ptr = ctypes.pythonapi.PyCapsule_GetPointer(capsule, b"openplc_runtime_args") + if not ptr: + return None, "Failed to extract pointer from capsule - invalid capsule name or corrupted data" + + # Cast the pointer to our structure type + args_ptr = ctypes.cast(ptr, ctypes.POINTER(PluginRuntimeArgs)) + if not args_ptr: + return None, "Failed to cast pointer to PluginRuntimeArgs structure" + + runtime_args = args_ptr.contents + + # Validate the extracted structure + is_valid, validation_msg = runtime_args.validate_pointers() + if not is_valid: + return None, f"Structure validation failed: {validation_msg}" + + return runtime_args, "Success" + + except (AttributeError, TypeError, ValueError, OverflowError, OSError, MemoryError) as e: + return None, f"Exception during capsule extraction: {e}" + +if __name__ == "__main__": + # Self-test when run directly + print("OpenPLC Python Plugin Types - Self Test") + print("=" * 50) + + # Test structure validation + # PluginStructureValidator.print_structure_info() + + print(f"\nIEC Type Sizes:") + print(f" IEC_BOOL: {ctypes.sizeof(IEC_BOOL)} bytes") + print(f" IEC_BYTE: {ctypes.sizeof(IEC_BYTE)} bytes") + print(f" IEC_UINT: {ctypes.sizeof(IEC_UINT)} bytes") + print(f" IEC_UDINT: {ctypes.sizeof(IEC_UDINT)} bytes") + print(f" IEC_ULINT: {ctypes.sizeof(IEC_ULINT)} bytes") diff --git a/core/src/drivers/python_plugin_bridge.h b/core/src/drivers/python_plugin_bridge.h new file mode 100644 index 00000000..b0442e0f --- /dev/null +++ b/core/src/drivers/python_plugin_bridge.h @@ -0,0 +1,21 @@ +#ifndef __PYTHON_PLUGIN_BRIDGE_H +#define __PYTHON_PLUGIN_BRIDGE_H + +#define PY_SSIZE_T_CLEAN +#include + +// Forward declaration +struct plugin_instance_s; + +// Python plugin bridge structure +typedef struct +{ + PyObject *pModule; + PyObject *pFuncInit; // Driver Init function + PyObject *pFuncStart; + PyObject *pFuncStop; + PyObject *pFuncCleanup; + PyObject *args_capsule; // Capsule containing plugin_runtime_args_t for lifetime management +} python_binds_t; + +#endif // __PYTHON_PLUGIN_BRIDGE_H diff --git a/core/src/lib/iec_types.h b/core/src/lib/iec_types.h new file mode 100644 index 00000000..609e5b65 --- /dev/null +++ b/core/src/lib/iec_types.h @@ -0,0 +1,83 @@ +#ifndef IEC_TYPES_H +#define IEC_TYPES_H + +#include +#include +#include + +/*********************/ +/* IEC Types defs */ +/*********************/ + +typedef uint8_t IEC_BOOL; + +typedef int8_t IEC_SINT; +typedef int16_t IEC_INT; +typedef int32_t IEC_DINT; +typedef int64_t IEC_LINT; + +typedef uint8_t IEC_USINT; +typedef uint16_t IEC_UINT; +typedef uint32_t IEC_UDINT; +typedef uint64_t IEC_ULINT; + +typedef uint8_t IEC_BYTE; +typedef uint16_t IEC_WORD; +typedef uint32_t IEC_DWORD; +typedef uint64_t IEC_LWORD; + +typedef float IEC_REAL; +typedef double IEC_LREAL; + +/* WARNING: When editing the definition of IEC_TIMESPEC, take note that + * if the order of the two elements 'tv_sec' and 'tv_nsec' is changed, then the macros + * __time_to_timespec() and __tod_to_timespec() will need to be changed accordingly. + * (these macros may be found in iec_std_lib.h) + */ +typedef struct { + int32_t tv_sec; /* Seconds. */ + int32_t tv_nsec; /* Nanoseconds. */ +} /* __attribute__((packed)) */ IEC_TIMESPEC; /* packed is gcc specific! */ + +typedef IEC_TIMESPEC IEC_TIME; +typedef IEC_TIMESPEC IEC_DATE; +typedef IEC_TIMESPEC IEC_DT; +typedef IEC_TIMESPEC IEC_TOD; + +#ifndef STR_MAX_LEN +#define STR_MAX_LEN 126 +#endif + +#ifndef STR_LEN_TYPE +#define STR_LEN_TYPE int8_t +#endif + +#define __INIT_REAL 0 +#define __INIT_LREAL 0 +#define __INIT_SINT 0 +#define __INIT_INT 0 +#define __INIT_DINT 0 +#define __INIT_LINT 0 +#define __INIT_USINT 0 +#define __INIT_UINT 0 +#define __INIT_UDINT 0 +#define __INIT_ULINT 0 +#define __INIT_TIME (TIME){0,0} +#define __INIT_BOOL 0 +#define __INIT_BYTE 0 +#define __INIT_WORD 0 +#define __INIT_DWORD 0 +#define __INIT_LWORD 0 +#define __INIT_STRING (STRING){0,""} +//#define __INIT_WSTRING +#define __INIT_DATE (DATE){0,0} +#define __INIT_TOD (TOD){0,0} +#define __INIT_DT (DT){0,0} + +typedef STR_LEN_TYPE __strlen_t; +typedef struct { + __strlen_t len; + uint8_t body[STR_MAX_LEN]; +} /* __attribute__((packed)) */ IEC_STRING; /* packed is gcc specific! */ + +#endif /*IEC_TYPES_H*/ diff --git a/core/src/plc_app/debug_handler.c b/core/src/plc_app/debug_handler.c new file mode 100644 index 00000000..84f41d6a --- /dev/null +++ b/core/src/plc_app/debug_handler.c @@ -0,0 +1,267 @@ +#include "debug_handler.h" +#include "image_tables.h" +#include "utils/log.h" +#include "utils/utils.h" +#include + +#define MAX_DEBUG_FRAME 4096 + +#define MB_FC_DEBUG_INFO 0x41 +#define MB_FC_DEBUG_SET 0x42 +#define MB_FC_DEBUG_GET 0x43 +#define MB_FC_DEBUG_GET_LIST 0x44 +#define MB_FC_DEBUG_GET_MD5 0x45 + +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 + +#define SAME_ENDIANNESS 0 +#define REVERSE_ENDIANNESS 1 + +#define VARIDX_SIZE 256 + +static void debugInfo(uint8_t *frame, size_t *frame_len) +{ + uint16_t variableCount = ext_get_var_count(); + *frame_len = 3; + frame[0] = MB_FC_DEBUG_INFO; + frame[1] = (uint8_t)(variableCount >> 8); + frame[2] = (uint8_t)(variableCount & 0xFF); +} + +static void debugSetTrace(uint8_t *frame, size_t *frame_len, uint16_t varidx, uint8_t flag, + uint16_t len, void *value) +{ + uint16_t variableCount = ext_get_var_count(); + if (varidx >= variableCount || len > (MAX_DEBUG_FRAME - 7)) + { + *frame_len = 2; + frame[0] = MB_FC_DEBUG_SET; + frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + ext_set_trace((size_t)varidx, (bool)flag, value); + + *frame_len = 2; + frame[0] = MB_FC_DEBUG_SET; + frame[1] = MB_DEBUG_SUCCESS; +} + +static void debugGetTrace(uint8_t *frame, size_t *frame_len, uint16_t startidx, uint16_t endidx) +{ + uint16_t variableCount = ext_get_var_count(); + if (startidx >= variableCount || endidx >= variableCount || startidx > endidx) + { + *frame_len = 2; + frame[0] = MB_FC_DEBUG_GET; + frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint16_t lastVarIdx = startidx; + size_t responseSize = 0; + uint8_t *responsePtr = &(frame[10]); + + for (uint16_t varidx = startidx; varidx <= endidx; varidx++) + { + size_t varSize = ext_get_var_size(varidx); + if ((responseSize + 10) + varSize <= MAX_DEBUG_FRAME) + { + void *varAddr = ext_get_var_addr(varidx); + + memcpy(responsePtr, varAddr, varSize); + + responsePtr += varSize; + responseSize += varSize; + + lastVarIdx = varidx; + } + else + { + break; + } + } + + *frame_len = 10 + responseSize; + frame[0] = MB_FC_DEBUG_GET; + frame[1] = MB_DEBUG_SUCCESS; + frame[2] = (uint8_t)(lastVarIdx >> 8); + frame[3] = (uint8_t)(lastVarIdx & 0xFF); + frame[4] = (uint8_t)((tick__ >> 24) & 0xFF); + frame[5] = (uint8_t)((tick__ >> 16) & 0xFF); + frame[6] = (uint8_t)((tick__ >> 8) & 0xFF); + frame[7] = (uint8_t)(tick__ & 0xFF); + frame[8] = (uint8_t)(responseSize >> 8); + frame[9] = (uint8_t)(responseSize & 0xFF); +} + +static void debugGetTraceList(uint8_t *frame, size_t *frame_len, uint16_t numIndexes, + uint8_t *indexArray) +{ + uint16_t response_idx = 10; + uint16_t responseSize = 0; + uint16_t lastVarIdx = 0; + uint16_t variableCount = ext_get_var_count(); + + uint16_t varidx_array[VARIDX_SIZE]; + + if (numIndexes > VARIDX_SIZE) + { + *frame_len = 2; + frame[0] = MB_FC_DEBUG_GET_LIST; + frame[1] = MB_DEBUG_ERROR_OUT_OF_MEMORY; + return; + } + + for (uint16_t i = 0; i < numIndexes; i++) + { + varidx_array[i] = (uint16_t)indexArray[i * 2] << 8 | indexArray[i * 2 + 1]; + } + + for (uint16_t i = 0; i < numIndexes; i++) + { + if (varidx_array[i] >= variableCount) + { + *frame_len = 2; + frame[0] = MB_FC_DEBUG_GET_LIST; + frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + size_t varSize = ext_get_var_size(varidx_array[i]); + + if (response_idx + varSize <= MAX_DEBUG_FRAME) + { + void *varAddr = ext_get_var_addr(varidx_array[i]); + memcpy(&frame[response_idx], varAddr, varSize); + response_idx += varSize; + responseSize += varSize; + + lastVarIdx = varidx_array[i]; + } + else + { + break; + } + } + + *frame_len = response_idx; + frame[0] = MB_FC_DEBUG_GET_LIST; + frame[1] = MB_DEBUG_SUCCESS; + frame[2] = (uint8_t)(lastVarIdx >> 8); + frame[3] = (uint8_t)(lastVarIdx & 0xFF); + frame[4] = (uint8_t)((tick__ >> 24) & 0xFF); + frame[5] = (uint8_t)((tick__ >> 16) & 0xFF); + frame[6] = (uint8_t)((tick__ >> 8) & 0xFF); + frame[7] = (uint8_t)(tick__ & 0xFF); + frame[8] = (uint8_t)(responseSize >> 8); + frame[9] = (uint8_t)(responseSize & 0xFF); +} + +static void debugGetMd5(uint8_t *frame, size_t *frame_len, void *endianness) +{ + uint16_t endian_check = 0; + memcpy(&endian_check, endianness, 2); + if (endian_check == 0xDEAD) + { + ext_set_endianness(SAME_ENDIANNESS); + } + else if (endian_check == 0xADDE) + { + ext_set_endianness(REVERSE_ENDIANNESS); + } + else + { + *frame_len = 2; + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = MB_DEBUG_SUCCESS; + + int md5_len = 0; + for (md5_len = 0; ext_plc_program_md5[md5_len] != '\0'; md5_len++) + { + frame[md5_len + 2] = ext_plc_program_md5[md5_len]; + } + + *frame_len = md5_len + 2; +} + +size_t process_debug_data(uint8_t *data, size_t length) +{ + if (length < 1) + { + log_error("Debug data too short"); + return 0; + } + + uint8_t fcode = data[0]; + uint16_t field1 = 0; + uint16_t field2 = 0; + uint8_t flag = 0; + uint16_t len = 0; + void *value = NULL; + void *endianness_check = NULL; + + if (length >= 3) + { + field1 = (uint16_t)data[1] << 8 | (uint16_t)data[2]; + } + if (length >= 5) + { + field2 = (uint16_t)data[3] << 8 | (uint16_t)data[4]; + } + if (length >= 4) + { + flag = data[3]; + } + if (length >= 6) + { + len = (uint16_t)data[4] << 8 | (uint16_t)data[5]; + } + if (length >= 7) + { + value = &data[6]; + } + if (length >= 2) + { + endianness_check = &data[1]; + } + + size_t response_len = 0; + + switch (fcode) + { + case MB_FC_DEBUG_INFO: + debugInfo(data, &response_len); + break; + + case MB_FC_DEBUG_GET: + debugGetTrace(data, &response_len, field1, field2); + break; + + case MB_FC_DEBUG_GET_LIST: + debugGetTraceList(data, &response_len, field1, &data[3]); + break; + + case MB_FC_DEBUG_SET: + debugSetTrace(data, &response_len, field1, flag, len, value); + break; + + case MB_FC_DEBUG_GET_MD5: + debugGetMd5(data, &response_len, endianness_check); + break; + + default: + log_error("Unknown debug function code: 0x%02X", fcode); + return 0; + } + + log_debug("Processed debug function 0x%02X, response length: %zu", fcode, response_len); + return response_len; +} diff --git a/core/src/plc_app/debug_handler.h b/core/src/plc_app/debug_handler.h new file mode 100644 index 00000000..86f5a7e0 --- /dev/null +++ b/core/src/plc_app/debug_handler.h @@ -0,0 +1,11 @@ +#ifndef DEBUG_HANDLER_H +#define DEBUG_HANDLER_H + +#include +#include +#include +#include + +size_t process_debug_data(uint8_t *data, size_t length); + +#endif // DEBUG_HANDLER_H diff --git a/core/src/plc_app/image_tables.c b/core/src/plc_app/image_tables.c new file mode 100644 index 00000000..7542a862 --- /dev/null +++ b/core/src/plc_app/image_tables.c @@ -0,0 +1,112 @@ +#include +#include + +#include "image_tables.h" +#include "log.h" +#include "utils.h" + +// Internal buffers for I/O and memory. +// Booleans +IEC_BOOL *bool_input[BUFFER_SIZE][8]; +IEC_BOOL *bool_output[BUFFER_SIZE][8]; + +// Bytes +IEC_BYTE *byte_input[BUFFER_SIZE]; +IEC_BYTE *byte_output[BUFFER_SIZE]; + +// Analog I/O +IEC_UINT *int_input[BUFFER_SIZE]; +IEC_UINT *int_output[BUFFER_SIZE]; + +// 32bit I/O +IEC_UDINT *dint_input[BUFFER_SIZE]; +IEC_UDINT *dint_output[BUFFER_SIZE]; + +// 64bit I/O +IEC_ULINT *lint_input[BUFFER_SIZE]; +IEC_ULINT *lint_output[BUFFER_SIZE]; + +// Memory +IEC_UINT *int_memory[BUFFER_SIZE]; +IEC_UDINT *dint_memory[BUFFER_SIZE]; +IEC_ULINT *lint_memory[BUFFER_SIZE]; + +void (*ext_config_run__)(unsigned long tick); +void (*ext_config_init__)(void); +void (*ext_glueVars)(void); +void (*ext_updateTime)(void); +void (*ext_setBufferPointers)( + IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], + IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], + IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], + IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], + IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], + IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], + IEC_ULINT *lint_memory[BUFFER_SIZE]); + + +// Debug +void (*ext_set_endianness)(uint8_t value); +uint16_t (*ext_get_var_count)(void); +size_t (*ext_get_var_size)(size_t idx); +void *(*ext_get_var_addr)(size_t idx); +void (*ext_set_trace)(size_t idx, bool forced, void *val); + + +int symbols_init(PluginManager *pm) +{ + // Get pointer to external functions + *(void **)(&ext_config_run__) = + plugin_manager_get_func(pm, void (*)(unsigned long), "config_run__"); + + *(void **)(&ext_config_init__) = + plugin_manager_get_func(pm, void (*)(unsigned long), "config_init__"); + + *(void **)(&ext_glueVars) = + plugin_manager_get_func(pm, void (*)(unsigned long), "glueVars"); + + *(void **)(&ext_updateTime) = + plugin_manager_get_func(pm, void (*)(unsigned long), "updateTime"); + + *(void **)(&ext_setBufferPointers) = + plugin_manager_get_func(pm, void (*)(unsigned long), "setBufferPointers"); + + *(void **)(&ext_common_ticktime__) = + plugin_manager_get_func(pm, void (*)(unsigned long), "common_ticktime__"); + + *(void **)(&ext_plc_program_md5) = + plugin_manager_get_func(pm, char *(*)(unsigned long), "plc_program_md5"); + + *(void **)(&ext_set_endianness) = + plugin_manager_get_func(pm, void (*)(unsigned long), "set_endianness"); + + *(void **)(&ext_get_var_count) = + plugin_manager_get_func(pm, uint16_t (*)(uint16_t), "get_var_count"); + + *(void **)(&ext_get_var_size) = + plugin_manager_get_func(pm, size_t (*)(size_t), "get_var_size"); + + *(void **)(&ext_get_var_addr) = + plugin_manager_get_func(pm, void *(*)(unsigned long), "get_var_addr"); + + *(void **)(&ext_set_trace) = + plugin_manager_get_func(pm, void (*)(unsigned long), "set_trace"); + + // Check if all symbols were loaded successfully + if (!ext_config_run__ || !ext_config_init__ || !ext_glueVars || + !ext_updateTime || !ext_setBufferPointers || !ext_common_ticktime__ || + !ext_plc_program_md5 || !ext_set_endianness || !ext_get_var_count || + !ext_get_var_size || !ext_get_var_addr || !ext_set_trace) + { + log_error("Failed to load all symbols"); + return -1; + } + + // Send buffer pointers to .so + ext_setBufferPointers(bool_input, bool_output, byte_input, byte_output, + int_input, int_output, dint_input, dint_output, + lint_input, lint_output, int_memory, dint_memory, + lint_memory); + + return 0; +} diff --git a/core/src/plc_app/image_tables.h b/core/src/plc_app/image_tables.h new file mode 100644 index 00000000..6b6ed5e3 --- /dev/null +++ b/core/src/plc_app/image_tables.h @@ -0,0 +1,85 @@ +#ifndef IMAGE_TABLES_H +#define IMAGE_TABLES_H + +#include "../lib/iec_types.h" +#include "plcapp_manager.h" + +#define BUFFER_SIZE 1024 +#define libplc_build_dir "./build" + +// Internal buffers for I/O and memory. +// Booleans +extern IEC_BOOL *bool_input[BUFFER_SIZE][8]; +extern IEC_BOOL *bool_output[BUFFER_SIZE][8]; + +// Bytes +extern IEC_BYTE *byte_input[BUFFER_SIZE]; +extern IEC_BYTE *byte_output[BUFFER_SIZE]; + +// Analog I/O +extern IEC_UINT *int_input[BUFFER_SIZE]; +extern IEC_UINT *int_output[BUFFER_SIZE]; + +// 32bit I/O +extern IEC_UDINT *dint_input[BUFFER_SIZE]; +extern IEC_UDINT *dint_output[BUFFER_SIZE]; + +// 64bit I/O +extern IEC_ULINT *lint_input[BUFFER_SIZE]; +extern IEC_ULINT *lint_output[BUFFER_SIZE]; + +// Memory +extern IEC_UINT *int_memory[BUFFER_SIZE]; +extern IEC_UDINT *dint_memory[BUFFER_SIZE]; +extern IEC_ULINT *lint_memory[BUFFER_SIZE]; + +/** + * @brief Set the buffer pointers for the plugin manager + * + * @param[in] IEC The IEC data types + */ +extern void (*ext_setBufferPointers)( + IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], + IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], + IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], + IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], + IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], + IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], + IEC_ULINT *lint_memory[BUFFER_SIZE]); + +/** + * @brief Common ticktime variable from the PLC program + * + * @param[in] tick The current tick value + */ +extern void (*ext_config_run__)(unsigned long tick); + +/** + * @brief Initialize the configuration + */ +extern void (*ext_config_init__)(void); + +/** + * @brief Glue variables together + */ +extern void (*ext_glueVars)(void); +extern void (*ext_updateTime)(void); + +/** + * @brief Debug functions + */ +extern void (*ext_set_endianness)(uint8_t value); +extern uint16_t (*ext_get_var_count)(void); +extern size_t (*ext_get_var_size)(size_t idx); +extern void *(*ext_get_var_addr)(size_t idx); +extern void (*ext_set_trace)(size_t idx, bool forced, void *val); + +/** + * @brief Initialize symbols for the plugin manager + * + * @param[in] pm The plugin manager to initialize symbols for + * @return 0 on success, -1 on failure + */ +int symbols_init(PluginManager *pm); + +#endif // IMAGE_TABLES_H diff --git a/core/src/plc_app/plc_main.c b/core/src/plc_app/plc_main.c new file mode 100644 index 00000000..b2f163f8 --- /dev/null +++ b/core/src/plc_app/plc_main.c @@ -0,0 +1,163 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../drivers/plugin_driver.h" +#include "image_tables.h" +#include "plc_state_manager.h" +#include "plcapp_manager.h" +#include "scan_cycle_manager.h" +#include "unix_socket.h" +#include "utils/log.h" +#include "utils/utils.h" +#include "utils/watchdog.h" + +extern PLCState plc_state; +volatile sig_atomic_t keep_running = 1; +extern plc_timing_stats_t plc_timing_stats; +plugin_driver_t *plugin_driver = NULL; +extern bool print_logs; + +void handle_sigint(int sig) +{ + (void)sig; + keep_running = 0; +} + +void *print_stats_thread(void *arg) +{ + (void)arg; + while (keep_running) + { + /* + if (bool_output[0][0]) + { + log_debug("bool_output[0][0]: %d", *bool_output[0][0]); + } + else + { + log_debug("bool_output[0][0] is NULL"); + } + */ + + log_info("Scan Count: %lu", plc_timing_stats.scan_count); + log_info("Scan Time - Min: %ld us, Max: %ld us, Avg: %ld us", + plc_timing_stats.scan_time_min, plc_timing_stats.scan_time_max, + plc_timing_stats.scan_time_avg); + log_info("Cycle Time - Min: %lu us, Max: %lu us, Avg: %ld us", + plc_timing_stats.cycle_time_min, plc_timing_stats.cycle_time_max, + plc_timing_stats.cycle_time_avg); + log_info("Cycle Latency - Min: %ld us, Max: %ld us, Avg: %ld us", + plc_timing_stats.cycle_latency_min, plc_timing_stats.cycle_latency_max, + plc_timing_stats.cycle_latency_avg); + log_info("Overruns: %lu", plc_timing_stats.overruns); + + // Print every 5 seconds + sleep(5); + } + return NULL; +} + +int main(int argc, char *argv[]) +{ + // Check for --print-logs argument + for (int i = 1; i < argc; i++) + { + if (strcmp(argv[i], "--print-logs") == 0) + { + print_logs = true; + break; + } + } + + // Initialize logging system + log_set_level(LOG_LEVEL_DEBUG); + + if (log_init(LOG_SOCKET_PATH) < 0) + { + fprintf(stderr, "Failed to initialize logging system\n"); + return -1; + } + + // Handle SIGINT for graceful shutdown + struct sigaction sa; + sa.sa_handler = handle_sigint; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, NULL); + + // Make sure PLC starts in STOP state + plc_set_state(PLC_STATE_STOPPED); + + // Initialize watchdog + if (watchdog_init() != 0) + { + log_error("Failed to initialize watchdog"); + return -1; + } + + // Initialize plugin driver system + plugin_driver = plugin_driver_create(); + if (plugin_driver) + { + log_info("[PLUGIN]: Plugin driver system created"); + // Load plugin configuration + if (plugin_driver_load_config(plugin_driver, "./plugins.conf") == 0) + { + // Start plugins + plugin_driver_init(plugin_driver); + plugin_driver_start(plugin_driver); + log_info("[PLUGIN]: Plugin driver system initialized"); + } + else + { + log_error("[PLUGIN]: Failed to load plugin configuration"); + } + } + + // Start UNIX socket server + if (setup_unix_socket() != 0) + { + log_error("Failed to set up UNIX socket"); + return -1; + } + + // Launch status printing thread + pthread_t stats_thread; + if (pthread_create(&stats_thread, NULL, print_stats_thread, NULL) != 0) + { + log_error("Failed to create stats thread"); + return -1; + } + + // Start PLC + if (plc_set_state(PLC_STATE_RUNNING) != true) + { + log_error("Failed to set PLC state to RUNNING"); + } + + while (keep_running) + { + // Sleep forever in the main thread + sleep(1); + } + + // Cleanup plugin driver system + if (plugin_driver) + { + plugin_driver_destroy(plugin_driver); + } + + // Cleanup + log_info("Shutting down..."); + plc_state_manager_cleanup(); + pthread_join(stats_thread, NULL); + return 0; +} diff --git a/core/src/plc_app/plc_state_manager.c b/core/src/plc_app/plc_state_manager.c new file mode 100644 index 00000000..3deec131 --- /dev/null +++ b/core/src/plc_app/plc_state_manager.c @@ -0,0 +1,221 @@ +#include +#include + +#include "../drivers/plugin_driver.h" +#include "image_tables.h" +#include "plc_state_manager.h" +#include "scan_cycle_manager.h" +#include "utils/log.h" +#include "utils/utils.h" + +static PLCState plc_state = PLC_STATE_STOPPED; +static pthread_mutex_t state_mutex = PTHREAD_MUTEX_INITIALIZER; + +struct timespec timer_start; +pthread_t plc_thread; +PluginManager *plc_program = NULL; + +extern plc_timing_stats_t plc_timing_stats; +extern atomic_long plc_heartbeat; +extern plugin_driver_t *plugin_driver; + +void *plc_cycle_thread(void *arg) +{ + PluginManager *pm = (PluginManager *)arg; + + // Initialize PLC + set_realtime_priority(); + symbols_init(pm); + ext_config_init__(); + ext_glueVars(); + + log_info("Starting main loop"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_RUNNING; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: RUNNING"); + + plc_timing_stats.scan_count = 0; + + // Get the start time for the running program + clock_gettime(CLOCK_MONOTONIC, &timer_start); + + while (plc_state == PLC_STATE_RUNNING) + { + scan_cycle_time_start(); + plugin_mutex_take(&plugin_driver->buffer_mutex); + + // Execute the PLC cycle + ext_config_run__(tick__++); + ext_updateTime(); + + // Update Watchdog Heartbeat + atomic_store(&plc_heartbeat, time(NULL)); + + plugin_mutex_give(&plugin_driver->buffer_mutex); + scan_cycle_time_end(); + + // Calculate next start time + timer_start.tv_nsec += *ext_common_ticktime__; + normalize_timespec(&timer_start); + + // Sleep until the next cycle should start + sleep_until(&timer_start); + } + + return NULL; +} + +int load_plc_program(PluginManager *pm) +{ + if (pm == NULL) + { + log_error("Failed to load PLC Program: PluginManager is NULL"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + + return -1; + } + + if (plugin_manager_load(pm)) + { + log_info("Loading PLC application"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_INIT; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: INIT"); + + if (pthread_create(&plc_thread, NULL, plc_cycle_thread, pm) != 0) + { + log_error("Failed to create PLC cycle thread"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + + return -1; + } + return 0; + } + else + { + log_error("Failed to load PLC application"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: EMPTY"); + + return -1; + } +} + +int unload_plc_program(PluginManager *pm) +{ + if (pm && pm == plc_program) + { + // Signal the PLC thread to stop + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_STOPPED; + pthread_mutex_unlock(&state_mutex); + + // Wait for the PLC thread to finish + pthread_join(plc_thread, NULL); + + // Destroy the plugin manager + plugin_manager_destroy(pm); + plc_program = NULL; + + log_info("PLC program unloaded successfully"); + + log_info("PLC State: STOPPED"); + return 0; + } + else + { + log_error("No PLC program loaded or mismatched plugin manager"); + return -1; + } +} + +PLCState plc_get_state(void) +{ + PLCState state; + pthread_mutex_lock(&state_mutex); + state = plc_state; + pthread_mutex_unlock(&state_mutex); + return state; +} + +bool plc_set_state(PLCState new_state) +{ + pthread_mutex_lock(&state_mutex); + if (plc_state == new_state) + { + pthread_mutex_unlock(&state_mutex); + return false; + } + plc_state = new_state; + pthread_mutex_unlock(&state_mutex); + + // Handle transition to running + if (new_state == PLC_STATE_RUNNING) + { + if (plc_program == NULL) + { + char *libplc_path = find_libplc_file(libplc_build_dir); + if (libplc_path == NULL) + { + log_error("Failed to find libplc file"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + return false; + } + + plc_program = plugin_manager_create(libplc_path); + free(libplc_path); + + if (plc_program == NULL) + { + log_error("Failed to create PluginManager"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + return false; + } + } + if (load_plc_program(plc_program) < 0) + { + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + return false; + } + } + + // Handle transition to stopped + else if (new_state == PLC_STATE_STOPPED) + { + if (unload_plc_program(plc_program) < 0) + { + return false; + } + } + + return true; +} + +void plc_state_manager_cleanup(void) +{ + if (plc_program) + { + unload_plc_program(plc_program); + } +} diff --git a/core/src/plc_app/plc_state_manager.h b/core/src/plc_app/plc_state_manager.h new file mode 100644 index 00000000..3cb6583e --- /dev/null +++ b/core/src/plc_app/plc_state_manager.h @@ -0,0 +1,35 @@ +#ifndef PLC_STATE_MANAGER_H +#define PLC_STATE_MANAGER_H + +#include "plcapp_manager.h" +#include + +typedef enum +{ + PLC_STATE_INIT, + PLC_STATE_RUNNING, + PLC_STATE_STOPPED, + PLC_STATE_ERROR, + PLC_STATE_EMPTY +} PLCState; + +/** + * @brief Get the current PLC state. + * @return PLCState The current PLC state + */ +PLCState plc_get_state(void); + +/** + * @brief Set the PLC state. In case of a state change, it will load or unload the PLC program as needed. + * @param new_state The new PLC state to set + * @return true if the state was changed, false if it was already in the desired state + */ +bool plc_set_state(PLCState new_state); + +/** + * @brief Cleanup the PLC state manager and unloads the plugin manager. + * @return void + */ +void plc_state_manager_cleanup(void); + +#endif // PLC_STATE_MANAGER_H \ No newline at end of file diff --git a/core/src/plc_app/plcapp_manager.c b/core/src/plc_app/plcapp_manager.c new file mode 100644 index 00000000..e8090af0 --- /dev/null +++ b/core/src/plc_app/plcapp_manager.c @@ -0,0 +1,113 @@ +#include "plcapp_manager.h" +#include +#include +#include +#include +#include + +#include "utils/log.h" + +struct PluginManager +{ + char *so_path; + void *handle; +}; + +char *find_libplc_file(const char *build_dir) +{ + DIR *dir = opendir(build_dir); + if (!dir) + { + log_error("Failed to open build directory: %s", build_dir); + return NULL; + } + + struct dirent *entry; + char *found_path = NULL; + + while ((entry = readdir(dir)) != NULL) + { + if (strncmp(entry->d_name, "libplc_", 7) == 0 && strstr(entry->d_name, ".so") != NULL) + { + size_t path_len = strlen(build_dir) + strlen(entry->d_name) + 2; + found_path = malloc(path_len); + if (found_path) + { + snprintf(found_path, path_len, "%s/%s", build_dir, entry->d_name); + } + break; + } + } + + closedir(dir); + + if (!found_path) + { + log_error("No libplc_*.so file found in %s", build_dir); + } + + return found_path; +} + +PluginManager *plugin_manager_create(const char *so_path) +{ + PluginManager *pm = calloc(1, sizeof(PluginManager)); + if (!pm) + { + return NULL; + } + pm->so_path = strdup(so_path); + pm->handle = NULL; + return pm; +} + +void plugin_manager_destroy(PluginManager *pm) +{ + if (!pm) + { + return; + } + if (pm->handle) + { + dlclose(pm->handle); + } + free(pm->so_path); + free(pm); +} + +bool plugin_manager_load(PluginManager *pm) +{ + if (!pm) + { + return false; + } + if (pm->handle) + { + return true; // already loaded + } + + pm->handle = dlopen(pm->so_path, RTLD_NOW); + if (!pm->handle) + { + log_error("Failed to load plugin %s: %s", pm->so_path, dlerror()); + return false; + } + return true; +} + +void *plugin_manager_get_symbol(PluginManager *pm, const char *symbol_name) +{ + if (!pm || !pm->handle) + { + return NULL; + } + dlerror(); // clear old error + void *sym = dlsym(pm->handle, symbol_name); + char *err = dlerror(); + if (err) + { + log_error("dlsym error: %s", err); + return NULL; + } + return sym; +} diff --git a/core/src/plc_app/plcapp_manager.h b/core/src/plc_app/plcapp_manager.h new file mode 100644 index 00000000..ee94260b --- /dev/null +++ b/core/src/plc_app/plcapp_manager.h @@ -0,0 +1,58 @@ +#ifndef PLUGIN_MANAGER_H +#define PLUGIN_MANAGER_H + +#include + +typedef struct PluginManager PluginManager; + +/** + * @brief Find the libplc_*.so file in the build directory + * + * @param[in] build_dir The build directory to search + * @return A dynamically allocated string with the full path, or NULL on failure + */ +char *find_libplc_file(const char *build_dir); + +/** + * @brief Create a plugin manager for a given .so path + * + * @param[in] so_path The path to the .so file + * @return A pointer to the created PluginManager, or NULL on failure + */ +PluginManager *plugin_manager_create(const char *so_path); + +/** + * @brief Destroy the plugin manager and unload the library + * + * @param[in] pm The plugin manager to destroy + */ +void plugin_manager_destroy(PluginManager *pm); + +/** + * @brief Ensure the library is loaded + * + * @param[in] pm The plugin manager to load + * @return true if the library is loaded, false otherwise + */ +bool plugin_manager_load(PluginManager *pm); + +/** + * @brief Get a raw symbol (void*), you normally won’t call this directly + * + * @param[in] pm The plugin manager to get the symbol from + * @param[in] symbol_name The name of the symbol to get + * @return A pointer to the symbol, or NULL on failure + */ +void *plugin_manager_get_symbol(PluginManager *pm, const char *symbol_name); + +/** + * @brief Type-safe function getter + * + * @param[in] pm The plugin manager to get the function from + * @param[in] type The type of the function + * @param[in] name The name of the function + * @return A pointer to the function, or NULL on failure + */ +#define plugin_manager_get_func(pm, type, name) ((type)plugin_manager_get_symbol((pm), (name))) + +#endif // PLUGIN_MANAGER_H diff --git a/core/src/plc_app/scan_cycle_manager.c b/core/src/plc_app/scan_cycle_manager.c new file mode 100644 index 00000000..e52c8aef --- /dev/null +++ b/core/src/plc_app/scan_cycle_manager.c @@ -0,0 +1,95 @@ +#include +#include +#include + +#include "scan_cycle_manager.h" +#include "utils/utils.h" + +static uint64_t expected_start_us = 0; +static uint64_t last_start_us = 0; + +plc_timing_stats_t plc_timing_stats = +{ + .scan_time_min = INT64_MAX, + .cycle_latency_min = INT64_MAX, + .cycle_time_avg = 0, + .cycle_time_min = INT64_MAX, + .cycle_latency_avg = 0, + .scan_count = 0, + .overruns = 0 +}; + +static uint64_t ts_now_us(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC_RAW, &ts); + return (uint64_t)ts.tv_sec * 1000000ull + ts.tv_nsec / 1000; +} + + +void scan_cycle_time_start() +{ + uint64_t now_us = ts_now_us(); + + if (plc_timing_stats.scan_count == 0) + { + // Ignore full calculations for the first cycle + expected_start_us = now_us + *ext_common_ticktime__ / 1000; // Convert ns to us + last_start_us = now_us; + plc_timing_stats.scan_count++; + + return; + } + + // Calculate cycle time + int64_t cycle_time_us = now_us - last_start_us; + if (cycle_time_us < plc_timing_stats.cycle_time_min) + { + plc_timing_stats.cycle_time_min = cycle_time_us; + } + if (cycle_time_us > plc_timing_stats.cycle_time_max) + { + plc_timing_stats.cycle_time_max = cycle_time_us; + } + plc_timing_stats.cycle_time_avg += (cycle_time_us - plc_timing_stats.cycle_time_avg) / plc_timing_stats.scan_count; + + // Calculate cycle latency + int64_t latency_us = (int64_t)(now_us - expected_start_us); + if (latency_us < plc_timing_stats.cycle_latency_min) + { + plc_timing_stats.cycle_latency_min = latency_us; + } + if (latency_us > plc_timing_stats.cycle_latency_max) + { + plc_timing_stats.cycle_latency_max = latency_us; + } + plc_timing_stats.cycle_latency_avg += (latency_us - plc_timing_stats.cycle_latency_avg) / plc_timing_stats.scan_count; + + last_start_us = now_us; + expected_start_us += *ext_common_ticktime__ / 1000; // Convert ns to us + + plc_timing_stats.scan_count++; +} + +void scan_cycle_time_end() +{ + uint64_t now_us = ts_now_us(); + + // Calculate scan time + int64_t scan_time_us = now_us - last_start_us; + if (scan_time_us < plc_timing_stats.scan_time_min) + { + plc_timing_stats.scan_time_min = scan_time_us; + } + if (scan_time_us > plc_timing_stats.scan_time_max) + { + plc_timing_stats.scan_time_max = scan_time_us; + } + plc_timing_stats.scan_time_avg += (scan_time_us - plc_timing_stats.scan_time_avg) / plc_timing_stats.scan_count; + + // Check for overrun + if (now_us > expected_start_us) + { + plc_timing_stats.overruns++; + } +} \ No newline at end of file diff --git a/core/src/plc_app/scan_cycle_manager.h b/core/src/plc_app/scan_cycle_manager.h new file mode 100644 index 00000000..5d78b791 --- /dev/null +++ b/core/src/plc_app/scan_cycle_manager.h @@ -0,0 +1,27 @@ +#ifndef SCAN_CYCLE_MANAGER_H +#define SCAN_CYCLE_MANAGER_H + +#include + +typedef struct +{ + int64_t scan_time_min; + int64_t scan_time_max; + int64_t scan_time_avg; + + int64_t cycle_time_min; + int64_t cycle_time_max; + int64_t cycle_time_avg; + + int64_t cycle_latency_min; + int64_t cycle_latency_max; + int64_t cycle_latency_avg; + + int64_t scan_count; + int64_t overruns; +} plc_timing_stats_t; + +void scan_cycle_time_start(); +void scan_cycle_time_end(); + +#endif // SCAN_CYCLE_MANAGER_H \ No newline at end of file diff --git a/core/src/plc_app/unix_socket.c b/core/src/plc_app/unix_socket.c new file mode 100644 index 00000000..8505c031 --- /dev/null +++ b/core/src/plc_app/unix_socket.c @@ -0,0 +1,271 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "debug_handler.h" +#include "plc_state_manager.h" +#include "unix_socket.h" +#include "utils/log.h" +#include "utils/utils.h" + +extern volatile sig_atomic_t keep_running; +extern PLCState plc_state; + +// helper: read one line terminated by '\n' from a socket +static ssize_t read_line(int fd, char *buffer, size_t max_length) +{ + size_t total_read = 0; + char ch; + while (total_read < max_length - 1) + { + ssize_t bytes_read = read(fd, &ch, 1); + if (bytes_read <= 0) + { + return bytes_read; // error or connection closed + } + if (ch == '\n') + { + break; // end of line + } + buffer[total_read++] = ch; + } + buffer[total_read] = '\0'; // null-terminate the string + return total_read; +} + +void handle_unix_socket_commands(const char *command, char *response, size_t response_size) +{ + if (strcmp(command, "PING") == 0) + { + log_debug("Received PING command"); + strncpy(response, "PING:OK\n", response_size); + } + else if (strcmp(command, "STATUS") == 0) + { + log_debug("Received STATUS command"); + PLCState current_state = plc_get_state(); + + if (current_state == PLC_STATE_INIT) + strncpy(response, "STATUS:INIT\n", response_size); + else if (current_state == PLC_STATE_RUNNING) + strncpy(response, "STATUS:RUNNING\n", response_size); + else if (current_state == PLC_STATE_STOPPED) + strncpy(response, "STATUS:STOPPED\n", response_size); + else if (current_state == PLC_STATE_ERROR) + strncpy(response, "STATUS:ERROR\n", response_size); + else if (current_state == PLC_STATE_EMPTY) + strncpy(response, "STATUS:EMPTY\n", response_size); + else + strncpy(response, "STATUS:UNKNOWN\n", response_size); + } + else if (strcmp(command, "STOP") == 0) + { + log_debug("Received STOP command"); + if (plc_set_state(PLC_STATE_STOPPED)) + strncpy(response, "STOP:OK\n", response_size); + else + strncpy(response, "STOP:ERROR\n", response_size); + } + else if (strcmp(command, "START") == 0) + { + log_debug("Received START command"); + PLCState current_state = plc_get_state(); + if (current_state != PLC_STATE_RUNNING) + { + if (plc_set_state(PLC_STATE_RUNNING)) + { + strncpy(response, "START:OK\n", response_size); + } + else + { + strncpy(response, "START:ERROR\n", response_size); + } + } + else + { + strncpy(response, "START:ERROR_ALREADY_RUNNING\n", response_size); + log_error("Received START command but PLC is already RUNNING"); + } + } + else if (strncmp(command, "DEBUG:", 6) == 0) + { + log_debug("Received DEBUG command"); + uint8_t debug_data[4096] = {0}; + size_t data_length = parse_hex_string(&command[6], debug_data); + if (data_length > 0) + { + data_length = process_debug_data(debug_data, data_length); + if (data_length > 0) + { + bytes_to_hex_string(debug_data, data_length, response, response_size, "DEBUG:"); + size_t len = strlen(response); + if (len < response_size - 1) + { + response[len] = '\n'; + response[len + 1] = '\0'; + } + } + else + { + strncpy(response, "DEBUG:ERROR_PROCESSING\n", response_size); + } + } + else + { + strncpy(response, "DEBUG:ERROR_PARSING\n", response_size); + } + } + else + { + log_error("Unknown command received: %s", command); + strncpy(response, "COMMAND:ERROR\n", response_size); + } + + // Always ensure null termination + response[response_size - 1] = '\0'; +} + +void *unix_socket_thread(void *arg) +{ + (void)arg; + int *server_fd_pt = (int *)arg; + int client_fd; + char command_buffer[COMMAND_BUFFER_SIZE]; + + if (server_fd_pt == NULL) + { + log_error("Server file descriptor is NULL"); + return NULL; + } + + int server_fd = *server_fd_pt; + if (server_fd < 0) + { + log_error("Failed to set up UNIX socket"); + return NULL; + } + + while (keep_running) + { + client_fd = accept(server_fd, NULL, NULL); + if (client_fd < 0) + { + if (errno == EINTR) + { + continue; // Interrupted by signal, retry + } + log_error("Unix socket accept failed: %s", strerror(errno)); + + // Retry after a short delay + sleep(1); + continue; + } + + log_info("Unix socket client connected"); + + while (keep_running) + { + ssize_t bytes_read = read_line(client_fd, command_buffer, COMMAND_BUFFER_SIZE); + if (bytes_read > 0) + { + log_debug("Received command: %s", command_buffer); + + // Handle the command + char response[MAX_RESPONSE_SIZE] = {0}; + handle_unix_socket_commands(command_buffer, response, MAX_RESPONSE_SIZE); + if (strlen(response) > 0) + { + ssize_t bytes_written = write(client_fd, response, strlen(response)); + if (bytes_written <= 0) + { + log_error("Error writing on unix socket: %s", strerror(errno)); + } + } + } + else if (bytes_read == 0) + { + log_info("Unix socket client disconnected"); + break; + } + else + { + log_error("Unix socket read failed: %s", strerror(errno)); + break; + } + } + close(client_fd); + } + + close_unix_socket(server_fd); + return NULL; +} + +void close_unix_socket(int server_fd) +{ + if (server_fd >= 0) + { + close(server_fd); + unlink(SOCKET_PATH); + log_info("UNIX socket server closed"); + } +} + +int setup_unix_socket() +{ + int server_fd; + struct sockaddr_un address; + + // Remove any existing socket file + unlink(SOCKET_PATH); + + // Create socket + if ((server_fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) + { + log_error("Socket creation failed: %s", strerror(errno)); + return -1; + } + + // Configure socket address structure + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + strncpy(address.sun_path, SOCKET_PATH, sizeof(address.sun_path) - 1); + + // Bind socket to the address + if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) + { + log_error("Socket bind failed: %s", strerror(errno)); + close(server_fd); + return -1; + } + + // Listen for incoming connections + if (listen(server_fd, MAX_CLIENTS) < 0) + { + log_error("Socket listen failed: %s", strerror(errno)); + close(server_fd); + return -1; + } + + log_info("UNIX socket server setup at %s", SOCKET_PATH); + + // Create a thread to handle socket commands + pthread_t socket_thread; + int *fd_ptr = malloc(sizeof(int)); + *fd_ptr = server_fd; + if (pthread_create(&socket_thread, NULL, unix_socket_thread, fd_ptr) != 0) + { + log_error("Failed to create UNIX socket thread: %s", strerror(errno)); + close(server_fd); + free(fd_ptr); + return -1; + } + + return 0; +} diff --git a/core/src/plc_app/unix_socket.h b/core/src/plc_app/unix_socket.h new file mode 100644 index 00000000..fc0ec452 --- /dev/null +++ b/core/src/plc_app/unix_socket.h @@ -0,0 +1,13 @@ +#ifndef UNIX_SOCKET_H +#define UNIX_SOCKET_H + +#define SOCKET_PATH "/run/runtime/plc_runtime.socket" +#define COMMAND_BUFFER_SIZE 8192 +#define MAX_RESPONSE_SIZE 16384 +#define MAX_CLIENTS 1 + +int setup_unix_socket(); +void close_unix_socket(); +void *unix_socket_thread(void *arg); + +#endif // UNIX_SOCKET_H diff --git a/core/src/plc_app/utils/log.c b/core/src/plc_app/utils/log.c new file mode 100644 index 00000000..099454dc --- /dev/null +++ b/core/src/plc_app/utils/log.c @@ -0,0 +1,257 @@ +#include "log.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static LogLevel current_level = LOG_LEVEL_INFO; +static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER; +int socket_fd = -1; +bool print_logs = false; + +extern volatile sig_atomic_t keep_running; + +void log_set_level(LogLevel level) { current_level = level; } + +// Create circular buffer for unsent logs +#define LOG_BUFFER_SIZE 1024 +#define LOG_MESSAGE_SIZE 2048 +char log_buffer[LOG_BUFFER_SIZE][LOG_MESSAGE_SIZE]; +int log_buffer_start = 0; +int log_buffer_end = 0; + + +void *log_thread_management(void *arg) +{ + char *unix_socket_path = (char *)arg; + + while(keep_running) + { + if (socket_fd < 0) + { + struct sockaddr_un addr; + socket_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (socket_fd < 0) + { + log_error("Log socket creation failed: %s", strerror(errno)); + // Wait before retrying + sleep(1); + continue; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, unix_socket_path, sizeof(addr.sun_path) - 1); + if (connect(socket_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) + { + log_error("Log socket connection failed: %s", strerror(errno)); + close(socket_fd); + socket_fd = -1; + } + } + + // Wait before rechecking the connection + sleep(1); + } + + close(socket_fd); + socket_fd = -1; + + return NULL; +} + +void store_on_buffer(const char *msg) +{ + strncpy(log_buffer[log_buffer_end], msg, sizeof(log_buffer[log_buffer_end]) - 1); + log_buffer[log_buffer_end][sizeof(log_buffer[log_buffer_end]) - 1] = '\0'; + log_buffer_end = (log_buffer_end + 1) % LOG_BUFFER_SIZE; + + // If buffer is full, move start forward + if (log_buffer_end == log_buffer_start) + { + log_buffer_start = (log_buffer_start + 1) % LOG_BUFFER_SIZE; + } +} + +char *retrieve_from_buffer() +{ + if (log_buffer_start == log_buffer_end) + { + return NULL; // Buffer is empty + } + + char *msg = log_buffer[log_buffer_start]; + log_buffer_start = (log_buffer_start + 1) % LOG_BUFFER_SIZE; + return msg; +} + +int log_init(char *unix_socket_path) +{ + // Create a copy of the socket path in the heap + char *path_copy = malloc(strlen(unix_socket_path) + 1); + if (!path_copy) + { + perror("Failed to allocate memory for socket path"); + return -1; + } + strcpy(path_copy, unix_socket_path); + + // Create the logging thread + pthread_t thread_id; + if (pthread_create(&thread_id, NULL, log_thread_management, path_copy) != 0) + { + free(path_copy); + perror("Failed to create log thread"); + return -1; + } + + return 0; // Success +} + + +static const char *level_to_str(LogLevel level) +{ + switch (level) + { + case LOG_LEVEL_DEBUG: + return "DEBUG"; + case LOG_LEVEL_INFO: + return "INFO"; + case LOG_LEVEL_WARN: + return "WARN"; + case LOG_LEVEL_ERROR: + return "ERROR"; + default: + return "UNKNOWN"; + } +} + +static void log_write(LogLevel level, const char *fmt, va_list args) +{ + if (level < current_level) + { + return; + } + + // Capture time for timestamp + time_t now = time(NULL); + struct tm t; + localtime_r(&now, &t); + + char stdout_msg[LOG_MESSAGE_SIZE]; + char log_msg[LOG_MESSAGE_SIZE]; + + if (print_logs) + { + // Create a copy of va_list for stdout formatting + va_list args_copy; + va_copy(args_copy, args); + + // Format for stdout + char time_buf[20]; + strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", &t); + + int n = snprintf(stdout_msg, sizeof(stdout_msg), "[%s] [%s] ", time_buf, level_to_str(level)); + n += vsnprintf(stdout_msg + n, sizeof(stdout_msg) - n, fmt, args_copy); + snprintf(stdout_msg + n, sizeof(stdout_msg) - n, "\n"); + + // Cleanup + va_end(args_copy); + } + + // Format the log message in JSON format + int n = snprintf(log_msg, sizeof(log_msg), "{\"timestamp\":\"%ld\",\"level\":\"%s\",\"message\":\"", (long)now, level_to_str(level)); + n += vsnprintf(log_msg + n, sizeof(log_msg) - n, fmt, args); + snprintf(log_msg + n, sizeof(log_msg) - n, "\"}\n"); + + // Send to unix socket if connected + pthread_mutex_lock(&log_mutex); + if (socket_fd >= 0) + { + // Send any buffered messages first + char *buffered_msg = retrieve_from_buffer(); + while (buffered_msg != NULL) + { + if (write(socket_fd, buffered_msg, strlen(buffered_msg)) == -1) + { + // On error, close the socket to trigger reconnection + close(socket_fd); + socket_fd = -1; + // Rewind index to re-store the message + log_buffer_start = (log_buffer_start - 1 + LOG_BUFFER_SIZE) % LOG_BUFFER_SIZE; + break; + } + buffered_msg = retrieve_from_buffer(); + } + + // Send current message + if (socket_fd >= 0) + { + if (write(socket_fd, log_msg, strlen(log_msg)) == -1) + { + // On error, close the socket to trigger reconnection + close(socket_fd); + socket_fd = -1; + + // Store message in buffer + store_on_buffer(log_msg); + } + } + } + else + { + store_on_buffer(log_msg); + } + + // Print to stdout if enabled + if (print_logs) + { + fputs(stdout_msg, stdout); + } + + pthread_mutex_unlock(&log_mutex); +} + +void log_info(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + log_write(LOG_LEVEL_INFO, fmt, args); + va_end(args); +} + +void log_debug(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + log_write(LOG_LEVEL_DEBUG, fmt, args); + va_end(args); +} + +void log_warn(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + log_write(LOG_LEVEL_WARN, fmt, args); + va_end(args); +} + +void log_error(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + log_write(LOG_LEVEL_ERROR, fmt, args); + va_end(args); +} diff --git a/core/src/plc_app/utils/log.h b/core/src/plc_app/utils/log.h new file mode 100644 index 00000000..8f683806 --- /dev/null +++ b/core/src/plc_app/utils/log.h @@ -0,0 +1,61 @@ +#ifndef LOG_H +#define LOG_H + +#include + +#define LOG_SOCKET_PATH "/run/runtime/log_runtime.socket" + +typedef enum { + LOG_LEVEL_DEBUG, + LOG_LEVEL_INFO, + LOG_LEVEL_WARN, + LOG_LEVEL_ERROR +} LogLevel; + +/** + * @brief Initialize the logging system + * @param[in] unix_socket_path The path to the UNIX socket for logging + * @return 0 on success, -1 on failure + */ +int log_init(char *unix_socket_path); + +/** + * @brief Set the log level + * + * @param[in] level The log level to set + */ +void log_set_level(LogLevel level); + +/** + * @brief Log an informational message + * + * @param[in] fmt The format string + * @param[in] ... The values to format + */ +void log_info(const char *fmt, ...); + +/** + * @brief Log a debug message + * + * @param[in] fmt The format string + * @param[in] ... The values to format + */ +void log_debug(const char *fmt, ...); + +/** + * @brief Log a warning message + * + * @param[in] fmt The format string + * @param[in] ... The values to format + */ +void log_warn(const char *fmt, ...); + +/** + * @brief Log an error message + * + * @param[in] fmt The format string + * @param[in] ... The values to format + */ +void log_error(const char *fmt, ...); + +#endif diff --git a/core/src/plc_app/utils/utils.c b/core/src/plc_app/utils/utils.c new file mode 100644 index 00000000..9ea41d69 --- /dev/null +++ b/core/src/plc_app/utils/utils.c @@ -0,0 +1,150 @@ +#include "utils.h" +#include +#include +#include +#include + +unsigned long long *ext_common_ticktime__ = NULL; +unsigned long tick__ = 0; +char *ext_plc_program_md5 = NULL; + +void normalize_timespec(struct timespec *ts) +{ + while (ts->tv_nsec >= 1e9) + { + ts->tv_nsec -= 1e9; + ts->tv_sec++; + } +} + +void sleep_until(struct timespec *ts) +{ + clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ts, NULL); +} + +void timespec_diff(struct timespec *a, struct timespec *b, struct timespec *result) +{ + // Calculate the difference in seconds + result->tv_sec = a->tv_sec - b->tv_sec; + + // Calculate the difference in nanoseconds + result->tv_nsec = a->tv_nsec - b->tv_nsec; + + // Handle borrowing if nanoseconds are negative + if (result->tv_nsec < 0) + { + // Borrow 1 second (1e9 nanoseconds) + --result->tv_sec; + result->tv_nsec += 1000000000L; + } +} + +// configure SCHED_FIFO priority +void set_realtime_priority(void) +{ + struct sched_param param; + param.sched_priority = 20; // Priority between 1 and 99 + + if (sched_setscheduler(0, SCHED_FIFO, ¶m) != 0) + { + log_error("sched_setscheduler failed: %s", strerror(errno)); + } + else + { + log_info("Scheduler set to SCHED_FIFO, priority %d", param.sched_priority); + } +} + +size_t parse_hex_string(const char *hex_string, uint8_t *data) +{ + size_t count = 0; + const char *ptr = hex_string; + + while (*ptr != '\0') + { + // Skip leading spaces + while (*ptr == ' ') + { + ptr++; + } + + if (*ptr == '\0') + { + break; + } + + // Read two hex digits + unsigned int value; + int scanned = sscanf(ptr, "%2x", &value); + if (scanned != 1) + { + break; + } + + data[count++] = (uint8_t)value; + + // Move past the parsed value (2 hex chars) + while (*ptr != '\0' && *ptr != ' ') + { + ptr++; + } + } + + return count; +} + +void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t out_size, const char *prepend) +{ + size_t pos = 0; + + // Add prepend string first, if provided + if (prepend != NULL) + { + size_t prepend_len = strlen(prepend); + if (prepend_len >= out_size) + { + // Not enough space even for prepend + if (out_size > 0) + { + out_str[0] = '\0'; + } + return; + } + strcpy(out_str, prepend); + pos = prepend_len; + } + + for (size_t i = 0; i < len; i++) + { + // Each byte needs up to 3 chars: "xx " + null terminator at the end + int written = snprintf(out_str + pos, out_size - pos, "%02x", bytes[i]); + if (written < 0 || (size_t)written >= out_size - pos) + { + // Stop if buffer is full or error + break; + } + + pos += written; + + if (i < len - 1) + { + if (pos + 1 >= out_size) + { + break; + } + out_str[pos++] = ' '; + out_str[pos] = '\0'; + } + } + + // Ensure null termination + if (pos < out_size) + { + out_str[pos] = '\0'; + } + else + { + out_str[out_size - 1] = '\0'; + } +} + diff --git a/core/src/plc_app/utils/utils.h b/core/src/plc_app/utils/utils.h new file mode 100644 index 00000000..5e04384a --- /dev/null +++ b/core/src/plc_app/utils/utils.h @@ -0,0 +1,65 @@ +#ifndef UTILS_H +#define UTILS_H + +#include +#include +#include +#include + +#include "log.h" + +extern unsigned long long *ext_common_ticktime__; +extern unsigned long tick__; +extern char *ext_plc_program_md5; + + +/** + * @brief Normalize a timespec structure + * + * @param ts The timespec structure to normalize + */ +void normalize_timespec(struct timespec *ts); + +/** + * @brief Sleep until a specific timespec + * + * @param ts The timespec to sleep until + */ +void sleep_until(struct timespec *ts); + +/** + * @brief Calculate the difference between two timespec structures + * + * @param a The first timespec + * @param b The second timespec + * @param result The timespec to store the result + */ +void timespec_diff(struct timespec *a, struct timespec *b, + struct timespec *result); + +/** + * @brief Set the realtime priority object + */ +void set_realtime_priority(void); + +/** + * @brief Parse a hex string into a byte array + * + * @param hex_string The hex string to parse + * @param data The byte array to store the result + * @return The number of bytes parsed + */ +size_t parse_hex_string(const char *hex_string, uint8_t *data); + +/** + * @brief Convert a byte array to a hex string + * + * @param bytes The byte array to convert + * @param len The length of the byte array + * @param out_str The string to store the result + * @param out_size The size of the output string buffer + * @param prepend An optional string to prepend to the output (can be NULL) + */ +void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t out_size, const char *prepend); + +#endif // UTILS_H diff --git a/core/src/plc_app/utils/watchdog.c b/core/src/plc_app/utils/watchdog.c new file mode 100644 index 00000000..cd630c1c --- /dev/null +++ b/core/src/plc_app/utils/watchdog.c @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include +#include + +#include "watchdog.h" +#include "log.h" +#include "utils.h" +#include "../plc_state_manager.h" + +atomic_long plc_heartbeat; +extern PLCState plc_state; + +void *watchdog_thread(void *arg) +{ + (void)arg; + long last = atomic_load(&plc_heartbeat); + + while (1) + { + sleep(2); // Watch every 2 seconds + + if (plc_get_state() != PLC_STATE_RUNNING) + { + continue; // Only monitor when PLC is running + } + + long now = atomic_load(&plc_heartbeat); + if (now == last) + { + fprintf(stderr, "[Watchdog] No heartbeat! PLC unresponsive.\n"); // Use stderr to ensure visibility and avoid lockups in log system + exit(EXIT_FAILURE); + } + + last = now; + } + + return NULL; +} + +int watchdog_init() +{ + pthread_t wd_thread; + if (pthread_create(&wd_thread, NULL, watchdog_thread, NULL) != 0) + { + log_error("Failed to create watchdog thread"); + return -1; + } + pthread_detach(wd_thread); // Detach the thread to avoid memory leaks + return 0; +} diff --git a/core/src/plc_app/utils/watchdog.h b/core/src/plc_app/utils/watchdog.h new file mode 100644 index 00000000..238423ba --- /dev/null +++ b/core/src/plc_app/utils/watchdog.h @@ -0,0 +1,12 @@ +#ifndef WATCHDOG_H +#define WATCHDOG_H + + +/** + * @brief Initialize the watchdog + * @return int 0 on success, -1 on failure + */ +int watchdog_init(); + + +#endif // WATCHDOG_H \ No newline at end of file diff --git a/docs/PLUGIN_VENV_GUIDE.md b/docs/PLUGIN_VENV_GUIDE.md new file mode 100644 index 00000000..feb7e282 --- /dev/null +++ b/docs/PLUGIN_VENV_GUIDE.md @@ -0,0 +1,189 @@ +# Virtual Environment Guide for Python Plugins + +This document describes how to use separate virtual environments (venv) for Python plugins in the OpenPLC Runtime, allowing each plugin to have its own dependencies without conflicts. + +## Overview + +The separated VENV system allows you to: + +* Let each Python plugin use specific library versions +* Avoid conflicts between dependencies of different plugins +* Simplify plugin development and maintenance +* Keep compatibility with existing plugins + +## File Structure + +``` +openplc-runtime/ +β”œβ”€β”€ venvs/ # Directory for virtual environments +β”‚ β”œβ”€β”€ modbus_slave/ # venv for the Modbus plugin +β”‚ └── mqtt_client/ # venv for the MQTT plugin +β”œβ”€β”€ core/src/drivers/plugins/python/ +β”‚ β”œβ”€β”€ modbus_slave_plugin/ +β”‚ β”‚ β”œβ”€β”€ simple_modbus.py +β”‚ β”‚ β”œβ”€β”€ modbus_slave_config.json +β”‚ β”‚ └── requirements.txt # Plugin-specific dependencies +β”‚ └── mqtt_plugin/ +β”‚ β”œβ”€β”€ plugin.py +β”‚ β”œβ”€β”€ config.json +β”‚ └── requirements.txt +└── scripts/ + └── manage_plugin_venvs.sh # Management script +``` + +## How to Use + +### 1. Creating a Plugin with a VENV + +1. **Create the plugin directory:** + + ```bash + mkdir core/src/drivers/plugins/python/my_plugin + ``` + +2. **Create the requirements.txt file:** + + ```bash + echo "pymodbus==3.6.4" > core/src/drivers/plugins/python/my_plugin/requirements.txt + echo "paho-mqtt==2.1.0" >> core/src/drivers/plugins/python/my_plugin/requirements.txt + ``` + +3. **Create the virtual environment:** + + ```bash + ./scripts/manage_plugin_venvs.sh create my_plugin + ``` + +4. **Configure plugins.conf:** + + ``` + my_plugin,./core/src/drivers/plugins/python/my_plugin/plugin.py,1,0,./config.json,./venvs/my_plugin + ``` + +### 2. Managing Virtual Environments + +#### Create a VENV for a plugin: + +```bash +./scripts/manage_plugin_venvs.sh create PLUGIN_NAME +``` + +#### List all VENVs: + +```bash +./scripts/manage_plugin_venvs.sh list +``` + +#### Install dependencies: + +```bash +./scripts/manage_plugin_venvs.sh install PLUGIN_NAME +``` + +#### Remove a VENV: + +```bash +./scripts/manage_plugin_venvs.sh remove PLUGIN_NAME +``` + +#### VENV information: + +```bash +./scripts/manage_plugin_venvs.sh info PLUGIN_NAME +``` + +## plugins.conf Format + +### New format (with VENV): + +``` +# name,path,enabled,type,config_path,venv_path +modbus_slave,./core/src/drivers/plugins/python/modbus_slave_plugin/simple_modbus.py,1,0,./config.json,./venvs/modbus_slave +``` + +### Old format (without VENV – still compatible): + +``` +# name,path,enabled,type,config_path +example_plugin,./core/src/drivers/examples/example_python_plugin.py,1,0,./example_config.ini +``` + +## Practical Example + +### Modbus Plugin with a specific VENV: + +1. **Create requirements.txt:** + + ```bash + cat > core/src/drivers/plugins/python/modbus_slave_plugin/requirements.txt << EOF + pymodbus==3.6.4 + asyncio-mqtt==0.16.2 + EOF + ``` + +2. **Create the virtual environment:** + + ```bash + ./scripts/manage_plugin_venvs.sh create modbus_slave + ``` + +3. **Configure plugins.conf:** + + ``` + modbus_slave,./core/src/drivers/plugins/python/modbus_slave_plugin/simple_modbus.py,1,0,./core/src/drivers/plugins/python/modbus_slave_plugin/modbus_slave_config.json,./venvs/modbus_slave + ``` + +4. **Verify installation:** + + ```bash + ./scripts/manage_plugin_venvs.sh info modbus_slave + ``` + +## Compatibility + +* **Existing plugins:** Continue working normally without changes +* **Legacy system:** If `venv_path` is empty or missing, the system Python is used +* **Python versions:** Works with Python 3.6+ + +## Troubleshooting + +### Plugin can’t find a module: + +* Check if the venv was created: `./scripts/manage_plugin_venvs.sh list` +* Check if dependencies were installed: `./scripts/manage_plugin_venvs.sh info PLUGIN_NAME` +* Check the path in `plugins.conf` + +### Dependency conflicts: + +* Each plugin has its own isolated venv +* Use specific versions in `requirements.txt` +* Recreate the venv if needed: `./scripts/manage_plugin_venvs.sh remove PLUGIN_NAME && ./scripts/manage_plugin_venvs.sh create PLUGIN_NAME` + +### Build error: + +* Recompile after changes: `./scripts/compile.sh` +* Verify Python headers are installed: `sudo apt install python3-dev` + +## Limitations + +* Each venv uses additional disk space +* Slightly longer startup time +* Requires Python 3.3+ for the native `venv` + +## Technical Architecture + +### Implementation: + +1. **plugin\_config.h:** Added `venv_path` field to the structure +2. **plugin\_config.c:** Parser updated to read the optional 6th field +3. **plugin\_driver.c:** Logic to set up `sys.path` before importing the plugin +4. **manage\_plugin\_venvs.sh:** Full management script + +### Loading flow: + +1. The system reads `plugins.conf` +2. If `venv_path` is specified, it sets up `sys.path` to include the venv’s site-packages +3. Imports the plugin’s Python module +4. Executes `init`/`start`/`stop`/`cleanup` functions as usual + +This system maintains full compatibility with existing plugins while enabling dependency isolation when needed. diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..c3f0d82a --- /dev/null +++ b/install.sh @@ -0,0 +1,171 @@ +#!/bin/bash +set -e + +# Check for root privileges +check_root() +{ + if [[ $EUID -ne 0 ]]; then + echo "ERROR: This script must be run as root" >&2 + echo "Example: sudo ./install.sh" >&2 + exit 1 + fi +} + +# Make sure we are root before proceeding +check_root + +# Detect the project root directory +# This works whether the script is called from project root, Docker, or anywhere else +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENPLC_DIR="$SCRIPT_DIR" +VENV_DIR="$OPENPLC_DIR/venvs/runtime" +SCRIPTS_DIR="$OPENPLC_DIR/scripts" + +# Ensure we're in the project directory +cd "$OPENPLC_DIR" + +echo "OpenPLC Runtime Installation" +echo "Project directory: $OPENPLC_DIR" +echo "Working directory: $(pwd)" + +install_dependencies() +{ + source /etc/os-release + echo "Distro: $ID" + + case "$ID" in + ubuntu|debian) + install_deps_apt "$1" + ;; + centos) + if [[ "$VERSION_ID" == 7* ]]; then + install_deps_yum "$1" + else + install_deps_dnf "$1" + fi + ;; + rhel) + if [[ "$VERSION_ID" == 7* ]]; then + install_deps_yum "$1" + else + install_deps_dnf "$1" + fi + ;; + fedora) + install_deps_dnf "$1" + ;; + *) + echo "Unsupported Linux distro: $ID" >&2 + return 1 + ;; + esac +} + +# For Ubuntu/Debian +install_deps_apt() { + apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + python3-dev python3-pip python3-venv \ + gcc \ + make \ + cmake \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* +} + +# For CentOS 7/RHEL 7 (older) +install_deps_yum() { + yum install -y \ + gcc gcc-c++ make cmake \ + python3 python3-devel python3-pip python3-venv \ + && yum clean all +} + +# For Fedora/RHEL 8+/CentOS Stream +install_deps_dnf() { + dnf install -y \ + gcc gcc-c++ make cmake \ + python3 python3-devel python3-pip python3-venv \ + && dnf clean all +} + +compile_plc() { + echo "Preparing build directory..." + + # Always clean build directory for Docker environment or when CMake cache exists + # This prevents cross-contamination between Linux and Docker builds + if [ -d "$OPENPLC_DIR/build" ] && [ -f "$OPENPLC_DIR/build/CMakeCache.txt" ]; then + echo "Cleaning existing build directory to ensure clean build..." + rm -rf "$OPENPLC_DIR/build" + fi + + # Create build directory + if ! mkdir -p "$OPENPLC_DIR/build"; then + echo "ERROR: Failed to create build directory" >&2 + return 1 + fi + + cd "$OPENPLC_DIR/build" || { + echo "ERROR: Failed to change to build directory" >&2 + return 1 + } + + echo "Running cmake configuration..." + if ! cmake ..; then + echo "ERROR: CMake configuration failed" >&2 + cd "$OPENPLC_DIR" + return 1 + fi + + echo "Compiling with make (using $(nproc) cores)..." + if ! make -j"$(nproc)"; then + echo "ERROR: Compilation failed" >&2 + cd "$OPENPLC_DIR" + return 1 + fi + + cd "$OPENPLC_DIR" || { + echo "ERROR: Failed to return to main directory" >&2 + return 1 + } + + echo "SUCCESS: OpenPLC compiled successfully!" + return 0 +} + +# Setup runtime directory (needed for both Linux and Docker) +mkdir -p /var/run/runtime +chmod 775 /var/run/runtime 2>/dev/null || true # Ignore permission errors in Docker + +# Make scripts executable +chmod +x "$OPENPLC_DIR/install.sh" 2>/dev/null || true +chmod +x "$OPENPLC_DIR/scripts/"* 2>/dev/null || true +chmod +x "$OPENPLC_DIR/start_openplc.sh" 2>/dev/null || true + +install_dependencies +python3 -m venv "$VENV_DIR" +"$VENV_DIR/bin/python3" -m pip install --upgrade pip setuptools wheel +"$VENV_DIR/bin/python3" -m pip install -r "$OPENPLC_DIR/requirements.txt" +"$VENV_DIR/bin/python3" -m pip install -e . + +echo "Dependencies installed..." +echo "Virtual environment created at $VENV_DIR" + +echo "Compiling OpenPLC..." +if compile_plc; then + echo "Build process completed successfully!" + echo "OpenPLC Runtime v4 is ready to use." + echo "" + echo "To start the OpenPLC Runtime v4, run:" + echo "sudo ./start_openplc.sh" + + # Create installation marker + touch "$OPENPLC_DIR/.installed" + echo "Installation completed at $(date)" > "$OPENPLC_DIR/.installed" + +else + echo "ERROR: Build process failed!" >&2 + echo "Please check the error messages above for details." >&2 + exit 1 +fi diff --git a/plugins.conf b/plugins.conf new file mode 100644 index 00000000..1fcba473 --- /dev/null +++ b/plugins.conf @@ -0,0 +1,6 @@ +# Plugin configuration file +# Format: name,path,enabled,type,config_path,venv_path +# Note: venv_path is optional - leave empty or omit for system Python +modbus_slave,./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py,1,0,./core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json,./venvs/modbus_slave +# example_plugin,./core/src/drivers/examples/example_python_plugin.py,1,0,./example_plugin_config.ini, +# mqtt_client,./core/src/drivers/plugins/python/mqtt_plugin/mqtt_client.py,1,0,./core/src/drivers/plugins/python/mqtt_plugin/config.json,./venvs/mqtt_client diff --git a/project.yml b/project.yml new file mode 100644 index 00000000..7836c1ec --- /dev/null +++ b/project.yml @@ -0,0 +1,61 @@ +:project: + :name: openplc-runtime-tests + :use_exceptions: FALSE + :use_mocks: TRUE + :use_test_preprocessor: :none + :use_backtrace: :gdb + :build_root: build/test + :test_file_prefix: test_ + :release_build: FALSE # We don't need release builds for testing + +:paths: + :test: + - tests/** + :source: + - core/src/** + # Exclude files that are not part of the unit under test or have heavy external deps + # For now, we include everything and let Ceedling handle linking. + # If specific files cause issues, we can exclude them here. + :support: + - tests/support/** # Include support files (stubs, mocks, helpers) + :include: + - core/src/** + - tests/support # For custom helpers or mocks if needed + +:defines: + :test: + - TEST + - BUFFER_SIZE=128 # Define BUFFER_SIZE used by image_tables.h + - MAX_PLUGINS=16 # Define MAX_PLUGINS used by plugin_driver.h + - UNITY_INCLUDE_DOUBLE # Enable double support in Unity + +:plugins: + :enabled: [] + +:cmock: + :mock_prefix: mock_ + :when_no_prototypes: :warn + :enforce_strict_ordering: FALSE + :plugins: + - :ignore + - :callback + :treat_as: + uint8: HEX8 + uint16: HEX16 + uint32: UINT32 + bool: UINT8 + +:flags: + :test: + :compile: + - -I/usr/include/python3.10 # From pkg-config --cflags-only-I python3-embed + +:libraries: + :test: + - python3.10 # From pkg-config --libs python3-embed (Ceedling adds -l) + - dl # Required by plugin_driver.c (Ceedling adds -l) + - pthread # Required by plugin_driver.c (Ceedling adds -l) + +:unity: + :includes: + - "unity_helper.h" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..d11a5989 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "openplc-runtime" +version = "0.1.0" +description = "OpenPLC Runtime" +requires-python = ">=3.10" + +[build-system] +requires = ["setuptools>=64", "wheel", "packaging>=24.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["webserver"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..4c32f060 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +addopts = -ra -q -vvv --maxfail=3 --disable-warnings +python_files = test_*.py +log_cli = true +log_cli_level = INFO +minversion = 7.0 +pythonpath = . \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..898a574e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Flask +Flask-Login +Flask-JWT-Extended>=4.6.0 +flask_sqlalchemy +Flask-SocketIO +PyJWT +python-dotenv +pytest +pytest-flask +pre-commit +psutil diff --git a/scripts/build-docker-image-dev.sh b/scripts/build-docker-image-dev.sh new file mode 100755 index 00000000..63cf7cb0 --- /dev/null +++ b/scripts/build-docker-image-dev.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Build the docker image with tag "build-env" +docker build -t openplc-dev -f Dockerfile.dev . 2>&1 | tee install_log.txt diff --git a/scripts/build-docker-image.sh b/scripts/build-docker-image.sh new file mode 100755 index 00000000..1e57aabf --- /dev/null +++ b/scripts/build-docker-image.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Build the docker image with tag "build-env" +docker build -t build-env . 2>&1 | tee install_log.txt diff --git a/scripts/compile-clean.sh b/scripts/compile-clean.sh new file mode 100755 index 00000000..f8ad8feb --- /dev/null +++ b/scripts/compile-clean.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +BUILD_DIR="build" + +# Remove old object files from root (if any left from older builds) +find . -maxdepth 1 -name "*.o" -type f -exec rm -f {} \; + +# Clean extra .o files from build dir if needed +rm -f "$BUILD_DIR"/*.o + +# Remove old libplc_*.so files to ensure only one exists +rm -f "$BUILD_DIR"/libplc_*.so + +TIMESTAMP=$(date +%s%N) +UNIQUE_LIBPLC="libplc_${TIMESTAMP}.so" + +# Move resulting shared library to unique name +mv "$BUILD_DIR/new_libplc.so" "$BUILD_DIR/$UNIQUE_LIBPLC" diff --git a/scripts/compile.sh b/scripts/compile.sh new file mode 100755 index 00000000..60317d42 --- /dev/null +++ b/scripts/compile.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +# Paths +ROOT="core/generated" +LIB_PATH="$ROOT/lib" +SRC_PATH="$ROOT" +BUILD_PATH="build" + +FLAGS="-w -O3 -fPIC" + +check_required_files() { + local missing_files=() + + if [ ! -f "$SRC_PATH/Config0.c" ]; then + missing_files+=("$SRC_PATH/Config0.c") + fi + if [ ! -f "$SRC_PATH/Res0.c" ]; then + missing_files+=("$SRC_PATH/Res0.c") + fi + if [ ! -f "$SRC_PATH/debug.c" ]; then + missing_files+=("$SRC_PATH/debug.c") + fi + if [ ! -f "$SRC_PATH/glueVars.c" ]; then + missing_files+=("$SRC_PATH/glueVars.c") + fi + if [ ! -d "$LIB_PATH" ]; then + missing_files+=("$LIB_PATH (directory)") + fi + + if [ ${#missing_files[@]} -ne 0 ]; then + echo "[ERROR] Missing required source files:" >&2 + printf ' %s\n' "${missing_files[@]}" >&2 + exit 1 + fi +} + +check_required_files + +# Ensure build directory exists +mkdir -p "$BUILD_PATH" +if [ ! -d "$BUILD_PATH" ]; then + echo "[ERROR] Failed to create build directory: $BUILD_PATH" >&2 + exit 1 +fi + +# Compile objects into build/ +echo "[INFO] Compiling Config0.c..." +gcc $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/Config0.c" -o "$BUILD_PATH/Config0.o" +echo "[INFO] Compiling Res0.c..." +gcc $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/Res0.c" -o "$BUILD_PATH/Res0.o" +echo "[INFO] Compiling debug.c..." +gcc $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/debug.c" -o "$BUILD_PATH/debug.o" +echo "[INFO] Compiling glueVars.c..." +gcc $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/glueVars.c" -o "$BUILD_PATH/glueVars.o" +echo "[INFO] Compiling c_blocks_code.cpp..." +g++ $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/c_blocks_code.cpp" -o "$BUILD_PATH/c_blocks_code.o" + +# Link shared library into build/ +echo "[INFO] Compiling shared library..." +g++ $FLAGS -shared -o "$BUILD_PATH/new_libplc.so" "$BUILD_PATH/Config0.o" \ + "$BUILD_PATH/Res0.o" "$BUILD_PATH/debug.o" "$BUILD_PATH/glueVars.o" "$BUILD_PATH/c_blocks_code.o" diff --git a/scripts/exec.sh b/scripts/exec.sh new file mode 100755 index 00000000..f9c5ba9d --- /dev/null +++ b/scripts/exec.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +# Start the PLC webserver +./venvs/runtime/bin/python3 webserver/app.py \ No newline at end of file diff --git a/scripts/generate-gluevars.sh b/scripts/generate-gluevars.sh new file mode 100755 index 00000000..aae9fdf3 --- /dev/null +++ b/scripts/generate-gluevars.sh @@ -0,0 +1,4 @@ +#!/bin/bash +srcPATH=core/generated/plc_lib + +./xml2st --generate-gluevars $srcPATH/LOCATED_VARIABLES.h diff --git a/scripts/manage_plugin_venvs.sh b/scripts/manage_plugin_venvs.sh new file mode 100755 index 00000000..f1d65669 --- /dev/null +++ b/scripts/manage_plugin_venvs.sh @@ -0,0 +1,283 @@ +#!/bin/bash +# OpenPLC Runtime Plugin Virtual Environment Manager +# Manages virtual environments for Python plugins to avoid dependency conflicts + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +VENVS_DIR="$PROJECT_ROOT/venvs" +PLUGINS_DIR="$PROJECT_ROOT/core/src/drivers/plugins/python" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Python3 is available +check_python() { + if ! command -v python3 &> /dev/null; then + log_error "python3 is not installed or not in PATH" + exit 1 + fi + + local python_version=$(python3 --version | cut -d' ' -f2) + log_info "Using Python version: $python_version" +} + +# Create virtual environment for a plugin +create_plugin_venv() { + local plugin_name="$1" + + if [ -z "$plugin_name" ]; then + log_error "Plugin name is required" + show_usage + exit 1 + fi + + local venv_path="$VENVS_DIR/$plugin_name" + local plugin_path="$PLUGINS_DIR/${plugin_name}" + local requirements_file="$plugin_path/requirements.txt" + + log_info "Creating virtual environment for plugin: $plugin_name" + + # Create venvs directory if it doesn't exist + mkdir -p "$VENVS_DIR" + + # Check if venv already exists + if [ -d "$venv_path" ]; then + log_warning "Virtual environment already exists at: $venv_path" + read -p "Do you want to recreate it? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + log_info "Removing existing virtual environment..." + rm -rf "$venv_path" + else + log_info "Keeping existing virtual environment" + return 0 + fi + fi + + # Create virtual environment + log_info "Creating Python virtual environment at: $venv_path" + python3 -m venv "$venv_path" + + # Upgrade pip + log_info "Upgrading pip..." + "$venv_path/bin/pip" install --upgrade pip + + # Install requirements if they exist + if [ -f "$requirements_file" ]; then + log_info "Installing dependencies from: $requirements_file" + "$venv_path/bin/pip" install -r "$requirements_file" + log_success "Dependencies installed successfully" + else + log_warning "No requirements.txt found at: $requirements_file" + log_info "You can add dependencies later by creating requirements.txt and running:" + log_info " $0 install $plugin_name" + fi + + log_success "Virtual environment created successfully at: $venv_path" + log_info "To use this venv in plugins.conf, add the venv path as the 6th field:" + log_info " $plugin_name,./path/to/plugin.py,1,0,./path/to/config.json,$venv_path" +} + +# Install dependencies for existing venv +install_dependencies() { + local plugin_name="$1" + + if [ -z "$plugin_name" ]; then + log_error "Plugin name is required" + show_usage + exit 1 + fi + + local venv_path="$VENVS_DIR/$plugin_name" + local plugin_path="$PLUGINS_DIR/${plugin_name}" + local requirements_file="$plugin_path/requirements.txt" + + if [ ! -d "$venv_path" ]; then + log_error "Virtual environment not found: $venv_path" + log_info "Create it first with: $0 create $plugin_name" + exit 1 + fi + + if [ ! -f "$requirements_file" ]; then + log_error "Requirements file not found: $requirements_file" + exit 1 + fi + + log_info "Installing dependencies for plugin: $plugin_name" + "$venv_path/bin/pip" install -r "$requirements_file" + log_success "Dependencies installed successfully" +} + +# List all virtual environments +list_venvs() { + log_info "Listing plugin virtual environments in: $VENVS_DIR" + + if [ ! -d "$VENVS_DIR" ]; then + log_warning "No virtual environments directory found at: $VENVS_DIR" + return 0 + fi + + local count=0 + for venv_dir in "$VENVS_DIR"/*; do + if [ -d "$venv_dir" ] && [ -f "$venv_dir/bin/python" ]; then + local venv_name=$(basename "$venv_dir") + local python_version=$("$venv_dir/bin/python" --version 2>&1 | cut -d' ' -f2) + local pip_packages=$("$venv_dir/bin/pip" list --format=freeze | wc -l) + + echo -e "${GREEN}$venv_name${NC}" + echo " Path: $venv_dir" + echo " Python: $python_version" + echo " Packages: $pip_packages installed" + echo + ((count++)) + fi + done + + if [ $count -eq 0 ]; then + log_warning "No virtual environments found" + else + log_success "Found $count virtual environment(s)" + fi +} + +# Remove virtual environment +remove_venv() { + local plugin_name="$1" + + if [ -z "$plugin_name" ]; then + log_error "Plugin name is required" + show_usage + exit 1 + fi + + local venv_path="$VENVS_DIR/$plugin_name" + + if [ ! -d "$venv_path" ]; then + log_error "Virtual environment not found: $venv_path" + exit 1 + fi + + log_warning "This will permanently remove the virtual environment for: $plugin_name" + read -p "Are you sure? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + log_info "Removing virtual environment: $venv_path" + rm -rf "$venv_path" + log_success "Virtual environment removed successfully" + else + log_info "Operation cancelled" + fi +} + +# Show package information for a venv +show_info() { + local plugin_name="$1" + + if [ -z "$plugin_name" ]; then + log_error "Plugin name is required" + show_usage + exit 1 + fi + + local venv_path="$VENVS_DIR/$plugin_name" + + if [ ! -d "$venv_path" ]; then + log_error "Virtual environment not found: $venv_path" + exit 1 + fi + + log_info "Virtual environment information for: $plugin_name" + echo "Path: $venv_path" + echo "Python version: $("$venv_path/bin/python" --version)" + echo "Pip version: $("$venv_path/bin/pip" --version)" + echo + log_info "Installed packages:" + "$venv_path/bin/pip" list +} + +# Show usage information +show_usage() { + echo "OpenPLC Runtime Plugin Virtual Environment Manager" + echo + echo "Usage: $0 COMMAND [PLUGIN_NAME]" + echo + echo "Commands:" + echo " create PLUGIN_NAME Create virtual environment for plugin" + echo " install PLUGIN_NAME Install dependencies for existing venv" + echo " list List all plugin virtual environments" + echo " remove PLUGIN_NAME Remove virtual environment for plugin" + echo " info PLUGIN_NAME Show information about plugin venv" + echo " help Show this help message" + echo + echo "Examples:" + echo " $0 create modbus # Create venv for modbus plugin" + echo " $0 list # List all plugin venvs" + echo " $0 remove modbus # Remove modbus plugin venv" + echo + echo "Notes:" + echo " - Plugin requirements should be in: $PLUGINS_DIR/PLUGIN_NAME_plugin/requirements.txt" + echo " - Virtual environments are created in: $VENVS_DIR/" + echo " - Add venv path to plugins.conf as the 6th field to use it" +} + +# Main function +main() { + local command="$1" + local plugin_name="$2" + + # Check Python availability + check_python + + case "$command" in + "create") + create_plugin_venv "$plugin_name" + ;; + "install") + install_dependencies "$plugin_name" + ;; + "list") + list_venvs + ;; + "remove") + remove_venv "$plugin_name" + ;; + "info") + show_info "$plugin_name" + ;; + "help"|"--help"|"-h"|"") + show_usage + ;; + *) + log_error "Unknown command: $command" + show_usage + exit 1 + ;; + esac +} + +# Run main function with all arguments +main "$@" diff --git a/scripts/run-image-dev.sh b/scripts/run-image-dev.sh new file mode 100755 index 00000000..11b0798f --- /dev/null +++ b/scripts/run-image-dev.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run container mounting only source code (preserves built venv) +# Named volume for persistent runtime data (DB, .env, etc) +docker run --rm -it \ + -v $(pwd)/venvs:/workdir/venvs \ + -v $(pwd)/webserver:/workdir/webserver \ + -v $(pwd)/tests:/workdir/tests \ + -v $(pwd)/scripts:/workdir/scripts \ + -v openplc-runtime-data:/var/run/runtime \ + --cap-add=sys_nice \ + --ulimit rtprio=99 \ + --ulimit memlock=-1 \ + -p 8443:8443 \ + openplc-dev diff --git a/scripts/run-image.sh b/scripts/run-image.sh new file mode 100755 index 00000000..30b32bbe --- /dev/null +++ b/scripts/run-image.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run container mounting only source code (preserves built venv) +# Named volume for persistent runtime data (DB, .env, etc) +docker run --rm -it \ + -v $(pwd)/core:/workdir/core \ + -v $(pwd)/webserver:/workdir/webserver \ + -v $(pwd)/scripts:/workdir/scripts \ + -v openplc-runtime-data:/var/run/runtime \ + --cap-add=sys_nice \ + --ulimit rtprio=99 \ + --ulimit memlock=-1 \ + -p 8443:8443 \ + build-env diff --git a/scripts/setup-tests-env.sh b/scripts/setup-tests-env.sh new file mode 100755 index 00000000..5d7984b1 --- /dev/null +++ b/scripts/setup-tests-env.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -e + +# =========================== +# Pytest Environment Setup +# =========================== + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV_DIR="$PROJECT_ROOT/venvs/test-env" + +echo "Setting up test environment for OpenPLC Runtime" +echo "Project root: $PROJECT_ROOT" +echo "Virtualenv: $VENV_DIR" +echo "==============================" + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment..." + python3 -m venv "$VENV_DIR" +else + echo "Virtual environment already exists." +fi + +source "$VENV_DIR/bin/activate" + +echo "Upgrading pip..." +pip install --upgrade pip + +if [ -f "$PROJECT_ROOT/requirements.txt" ]; then + echo "Installing dependencies from requirements.txt..." + pip install -r "$PROJECT_ROOT/requirements.txt" +fi + +# 4. Install pytest and project in editable mode +echo "Installing pytest and local package..." +pip install pytest +pip install -e "$PROJECT_ROOT" + +if [ ! -f "$PROJECT_ROOT/pytest.ini" ]; then + echo "Creating default pytest.ini..." + cat < "$PROJECT_ROOT/pytest.ini" +[pytest] +minversion = 7.0 +addopts = -v --maxfail=3 --disable-warnings +testpaths = tests +pythonpath = . +EOF +fi + +# Existing conftest.py with fixtures is preserved; no need to create or overwrite. + +echo "Running pytest..." +pytest -vvv + +echo "All done!" diff --git a/start_openplc.sh b/start_openplc.sh new file mode 100755 index 00000000..86329a01 --- /dev/null +++ b/start_openplc.sh @@ -0,0 +1,145 @@ +#!/bin/bash +set -euo pipefail + +# Detect the project root directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENPLC_DIR="$SCRIPT_DIR" + +# Ensure we're in the project directory +cd "$OPENPLC_DIR" + +check_root() +{ + if [[ $EUID -ne 0 ]]; then + echo "ERROR: This script must be run as root" >&2 + echo "Example: sudo ./start_openplc.sh" >&2 + exit 1 + fi +} + +check_installation() +{ + if [ ! -f "$OPENPLC_DIR/.installed" ]; then + echo "ERROR: OpenPLC Runtime v4 is not installed." >&2 + echo "Please run the install script first:" >&2 + echo " sudo ./install.sh" >&2 + exit 1 + fi +} + +# Startup checks +check_installation +check_root + +echo "Starting OpenPLC Runtime" +echo "Project directory: $OPENPLC_DIR" +echo "Working directory: $(pwd)" + +# MANAGE PLUGIN VENVS +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to setup plugin virtual environments +setup_plugin_venvs() { + local plugins_dir="$OPENPLC_DIR/core/src/drivers/plugins/python" + local manage_script="$OPENPLC_DIR/scripts/manage_plugin_venvs.sh" + + log_info "Checking for plugins that need virtual environments..." + + # Check if plugins directory exists + if [ ! -d "$plugins_dir" ]; then + log_warning "Plugins directory not found: $plugins_dir" + return 0 + fi + + # Find all directories with requirements.txt + local plugins_with_requirements=() + while IFS= read -r -d '' requirements_file; do + # Get the directory name (plugin name) + local plugin_dir=$(dirname "$requirements_file") + local plugin_name=$(basename "$plugin_dir") + + # Skip if it's in examples or shared directories (common libraries) + if [[ "$plugin_dir" == *"/examples/"* ]] || [[ "$plugin_dir" == *"/shared/"* ]]; then + log_info "Skipping $plugin_name (in examples/shared directory)" + continue + fi + + plugins_with_requirements+=("$plugin_name") + log_info "Found plugin with requirements: $plugin_name" + done < <(find "$plugins_dir" -name "requirements.txt" -type f -print0) + + # If no plugins found, return + if [ ${#plugins_with_requirements[@]} -eq 0 ]; then + log_info "No plugins with requirements.txt found" + return 0 + fi + + log_info "Found ${#plugins_with_requirements[@]} plugin(s) that need virtual environments" + + # Create virtual environments for each plugin + for plugin_name in "${plugins_with_requirements[@]}"; do + local venv_path="$OPENPLC_DIR/venvs/$plugin_name" + local requirements_file="$plugins_dir/$plugin_name/requirements.txt" + + if [ -d "$venv_path" ]; then + log_info "Virtual environment already exists for $plugin_name" + + # Check if requirements.txt is newer than the venv (dependencies may have changed) + if [ "$requirements_file" -nt "$venv_path" ]; then + log_warning "Requirements file is newer than venv for $plugin_name" + log_info "Updating dependencies for $plugin_name..." + + if bash "$manage_script" install "$plugin_name"; then + log_success "Dependencies updated for $plugin_name" + else + log_error "Failed to update dependencies for $plugin_name" + return 1 + fi + else + log_info "Dependencies are up to date for $plugin_name" + fi + else + log_info "Creating virtual environment for plugin: $plugin_name" + + if bash "$manage_script" create "$plugin_name"; then + log_success "Virtual environment created for $plugin_name" + else + log_error "Failed to create virtual environment for $plugin_name" + return 1 + fi + fi + done + + log_success "All plugin virtual environments are ready" + return 0 +} + +# Setup plugin virtual environments +setup_plugin_venvs + +source "$OPENPLC_DIR/venvs/runtime/bin/activate" + +# Start the PLC webserver +"$OPENPLC_DIR/venvs/runtime/bin/python3" -m "webserver.app" \ No newline at end of file diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py new file mode 100644 index 00000000..22e4a976 --- /dev/null +++ b/tests/pytest/conftest.py @@ -0,0 +1,18 @@ +# tests/conftest.py +import pytest +from webserver.logger import get_logger +import sys +import os + +logger, buffer = get_logger("test_logger", use_buffer=True) + +@pytest.fixture(autouse=True) +def clean_logger_state(): + """Ensure buffer is cleared before each test.""" + buffer.clear() + yield + buffer.clear() + +@pytest.fixture +def test_logger(): + return logger, buffer diff --git a/tests/pytest/test_logger_buffer.py b/tests/pytest/test_logger_buffer.py new file mode 100644 index 00000000..20455076 --- /dev/null +++ b/tests/pytest/test_logger_buffer.py @@ -0,0 +1,69 @@ +# tests/pytest/test_logger_buffer.py +import pytest + +def test_buffer_stores_logs(test_logger): + logger, buffer = test_logger + logger.info("Hello World") + logs = buffer.get_logs() + assert len(logs) == 1 + assert "Hello World" in logs[0]["message"] + +def test_buffer_clear_works(test_logger): + logger, buffer = test_logger + logger.info("A") + logger.info("B") + buffer.clear() + logs = buffer.get_logs() + assert len(logs) == 0 + +def test_multiple_logs_stored(test_logger): + logger, buffer = test_logger + messages = ["First log", "Second log", "Third log"] + for msg in messages: + logger.info(msg) + logs = buffer.get_logs() + assert len(logs) == 3 + for i, msg in enumerate(messages): + assert msg in logs[i]["message"] + +def test_log_levels_stored(test_logger): + logger, buffer = test_logger + logger.debug("Debug message") + logger.info("Info message") + logger.warning("Warning message") + logger.error("Error message") + logs = buffer.get_logs() + levels = [log["level"] for log in logs] + assert levels == ["DEBUG", "INFO", "WARNING", "ERROR"] + +def test_normalize_no_microseconds(test_logger): + logger, buffer = test_logger + normalized_ts = buffer.normalize_timestamp_no_microseconds("2024-01-01T12:00:00.123456+00:00") + assert normalized_ts == "2024-01-01T12:00:00+0000" + +def test_normalize_log_record(test_logger): + logger, buffer = test_logger + logger.info("Test log for normalization") + logs = buffer.normalize_logs(buffer.get_logs()) + assert isinstance(logs, list) + assert all("timestamp" in log for log in logs) + +def test_filter_logs_by_level(test_logger): + logger, buffer = test_logger + logger.info("Info log") + logger.error("Error log") + logs = buffer.get_logs(level="ERROR") + assert len(logs) == 1 + assert "Error log" in logs[0]["message"] + +def test_filter_logs_by_min_id(test_logger): + logger, buffer = test_logger + buffer.clear() + logger.info("Log 1") + logger.info("Log 2") + logger.info("Log 3") + logs = buffer.get_logs(min_id=2) + assert len(logs) == 2 + assert "Log 2" in logs[0]["message"] + assert "Log 3" in logs[1]["message"] + diff --git a/tests/pytest/test_logger_init.py b/tests/pytest/test_logger_init.py new file mode 100644 index 00000000..0ba0b1ab --- /dev/null +++ b/tests/pytest/test_logger_init.py @@ -0,0 +1,7 @@ +# tests/pytest/test_logger_init.py +def test_logger_initializes_correctly(test_logger): + logger, buffer = test_logger + assert logger.name == "test_logger" + assert logger.level == 10 # logging.DEBUG + assert len(logger.handlers) >= 1 + assert isinstance(buffer.get_logs(), list) diff --git a/tests/pytest/test_logging.py b/tests/pytest/test_logging.py new file mode 100644 index 00000000..23024b00 --- /dev/null +++ b/tests/pytest/test_logging.py @@ -0,0 +1,39 @@ +import logging +import pytest + +from webserver.logger import get_logger, BufferHandler + +def test_logger_creates_handlers(): + # Reset previous handlers + logger, _ = get_logger("test_logger", use_buffer=True) + logger.handlers.clear() + logger, _ = get_logger("test_logger", use_buffer=True) + + # Assert logger level + assert logger.level == logging.DEBUG + + # It should have at least 2 handlers (stream + buffer) + handler_types = [type(h) for h in logger.handlers] + assert logging.StreamHandler in handler_types + assert BufferHandler in handler_types + +def test_buffer_handler_captures_logs(): + logger, _ = get_logger("buffer_logger", use_buffer=True) + + # Get the buffer handler + buffer_handler = next(h for h in logger.handlers if isinstance(h, BufferHandler)) + + # Log something + msg = "hello pytest logging" + logger.info(msg) + + # The buffer should have stored the formatted message + assert any(msg in record for record in buffer_handler.records) + +def test_logger_does_not_duplicate_handlers(): + # Calling get_logger twice should not create duplicate handlers + logger1, _ = get_logger("same_logger", use_buffer=True) + logger2, _ = get_logger("same_logger", use_buffer=True) + + assert logger1 is logger2 # same logger object + assert len(logger1.handlers) == len(logger2.handlers) # still only 2 handlers diff --git a/tests/support/plugin_driver_stubs.c b/tests/support/plugin_driver_stubs.c new file mode 100644 index 00000000..41be2288 --- /dev/null +++ b/tests/support/plugin_driver_stubs.c @@ -0,0 +1,26 @@ +#include "plugin_config.h" +#include "plugin_driver.h" + +// Mock implementations for external buffer variables +// These are normally defined in image_tables.c +IEC_BOOL *bool_input[BUFFER_SIZE][8]; +IEC_BOOL *bool_output[BUFFER_SIZE][8]; +IEC_BYTE *byte_input[BUFFER_SIZE]; +IEC_BYTE *byte_output[BUFFER_SIZE]; +IEC_UINT *int_input[BUFFER_SIZE]; +IEC_UINT *int_output[BUFFER_SIZE]; +IEC_UDINT *dint_input[BUFFER_SIZE]; +IEC_UDINT *dint_output[BUFFER_SIZE]; +IEC_ULINT *lint_input[BUFFER_SIZE]; +IEC_ULINT *lint_output[BUFFER_SIZE]; +IEC_UINT *int_memory[BUFFER_SIZE]; +IEC_UDINT *dint_memory[BUFFER_SIZE]; +IEC_ULINT *lint_memory[BUFFER_SIZE]; + +// Mock implementation for plugin_manager_destroy +// This is normally defined in plcapp_manager.c +void plugin_manager_destroy(PluginManager *manager) +{ + (void)manager; // Suppress unused parameter warning + // Mock implementation - do nothing +} \ No newline at end of file diff --git a/tests/test_basic_functionality.c b/tests/test_basic_functionality.c new file mode 100644 index 00000000..80e25074 --- /dev/null +++ b/tests/test_basic_functionality.c @@ -0,0 +1,42 @@ +#include "unity.h" + +// Test fixture setup and teardown +void setUp(void) +{ + // Called before each test +} + +void tearDown(void) +{ + // Called after each test +} + +// Basic functionality tests +void test_basic_addition(void) +{ + TEST_ASSERT_EQUAL(4, 2 + 2); +} + +void test_basic_subtraction(void) +{ + TEST_ASSERT_EQUAL(0, 5 - 5); +} + +void test_string_comparison(void) +{ + TEST_ASSERT_EQUAL_STRING("hello", "hello"); +} + +void test_null_pointer(void) +{ + char *ptr = NULL; + TEST_ASSERT_NULL(ptr); +} + +void test_not_null_pointer(void) +{ + int value = 42; + int *ptr = &value; + TEST_ASSERT_NOT_NULL(ptr); + TEST_ASSERT_EQUAL(42, *ptr); +} \ No newline at end of file diff --git a/tests/test_plugin_config.c b/tests/test_plugin_config.c new file mode 100644 index 00000000..60c68fc8 --- /dev/null +++ b/tests/test_plugin_config.c @@ -0,0 +1,181 @@ +#include "plugin_config.h" +#include "plugin_driver.h" +#include "unity.h" +#include + +// Mock functions for standard library calls used in plugin_config.c +// Cmock will generate these automatically when we #include "mock_stdlib.h" or similar, +// but for direct functions like fopen, fgets, etc., we might need to create them manually +// or use a more generic mock approach if Cmock doesn't handle them out of the box. +// For simplicity, we'll assume Cmock can handle these or we'll create simple wrappers. +// Let's start by assuming Cmock handles them. If not, we'll adjust. + +// Helper function to create a temporary config file for testing +static void create_test_config_file(const char *filename, const char *content) +{ + FILE *file = fopen(filename, "w"); + if (file) + { + fprintf(file, "%s", content); + fclose(file); + } +} + +// Helper function to clean up the test config file +static void remove_test_config_file(const char *filename) +{ + remove(filename); +} + +void setUp(void) +{ + // This function is called before each test +} + +void tearDown(void) +{ + // This function is called after each test +} + +// Note: External buffer variables and mock functions are now defined in +// tests/support/test_plugin_driver_stubs.c and will be linked automatically + +// Test Case 1: Test parsing a valid configuration file +// This covers "Teste de leitura e parsing de configuraΓ§Γ΅es" +void test_parse_plugin_config_ValidFile_ShouldSucceed(void) +{ + const char *test_config_filename = "test_config_valid.conf"; + const char *config_content = + "# This is a comment\n" + "\n" // Empty line + "plugin1,../path/to/plugin1.py,1,0,./config1.ini\n" + "plugin2,./plugins/plugin2.so,0,1,./config2.conf\n" + "plugin3,/another/path/plugin3.py,1,0,./config3.ini,/path/to/venv3\n"; + + create_test_config_file(test_config_filename, config_content); + + plugin_config_t configs[MAX_PLUGINS]; + int expected_count = 3; + + int result = parse_plugin_config(test_config_filename, configs, MAX_PLUGINS); + + TEST_ASSERT_EQUAL_INT(expected_count, result); + + // Validate plugin1 + TEST_ASSERT_EQUAL_STRING("plugin1", configs[0].name); + TEST_ASSERT_EQUAL_STRING("../path/to/plugin1.py", configs[0].path); + TEST_ASSERT_EQUAL_INT(1, configs[0].enabled); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_PYTHON, configs[0].type); // 0 for Python from plugin_config.h + TEST_ASSERT_EQUAL_STRING("./config1.ini", configs[0].plugin_related_config_path); + TEST_ASSERT_EQUAL_STRING("", configs[0].venv_path); // No venv_path specified + + // Validate plugin2 + TEST_ASSERT_EQUAL_STRING("plugin2", configs[1].name); + TEST_ASSERT_EQUAL_STRING("./plugins/plugin2.so", configs[1].path); + TEST_ASSERT_EQUAL_INT(0, configs[1].enabled); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_NATIVE, configs[1].type); // 1 for Native from plugin_config.h + TEST_ASSERT_EQUAL_STRING("./config2.conf", configs[1].plugin_related_config_path); + TEST_ASSERT_EQUAL_STRING("", configs[1].venv_path); // No venv_path specified + + // Validate plugin3 + TEST_ASSERT_EQUAL_STRING("plugin3", configs[2].name); + TEST_ASSERT_EQUAL_STRING("/another/path/plugin3.py", configs[2].path); + TEST_ASSERT_EQUAL_INT(1, configs[2].enabled); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_PYTHON, configs[2].type); // 0 for Python from plugin_config.h + TEST_ASSERT_EQUAL_STRING("./config3.ini", configs[2].plugin_related_config_path); + TEST_ASSERT_EQUAL_STRING("/path/to/venv3", configs[2].venv_path); // venv_path specified + + remove_test_config_file(test_config_filename); +} + +// Test Case 2: Test parsing a file with more plugins than max_configs +void test_parse_plugin_config_TooManyPlugins_ShouldRespectMaxConfigs(void) +{ + const char *test_config_filename = "test_config_toomany.conf"; + char config_content[1024]; + int plugins_to_write = MAX_PLUGINS + 5; // Write more than MAX_PLUGINS + int i; + int pos = 0; + + for (i = 0; i < plugins_to_write; ++i) + { + pos += snprintf(config_content + pos, sizeof(config_content) - pos, + "plugin%d,/path/plugin%d.py,1,0,./config%d.ini\n", i, i, i); + } + + create_test_config_file(test_config_filename, config_content); + + plugin_config_t configs[MAX_PLUGINS]; + int expected_count = MAX_PLUGINS; + + int result = parse_plugin_config(test_config_filename, configs, MAX_PLUGINS); + + TEST_ASSERT_EQUAL_INT(expected_count, result); + // Optionally, check if the first MAX_PLUGINS entries are correctly parsed + for (i = 0; i < expected_count; ++i) + { + char expected_name[20]; + snprintf(expected_name, sizeof(expected_name), "plugin%d", i); + TEST_ASSERT_EQUAL_STRING(expected_name, configs[i].name); + } + + remove_test_config_file(test_config_filename); +} + +// Test Case 3: Test parsing a non-existent file +void test_parse_plugin_config_NonExistentFile_ShouldReturnNegative(void) +{ + const char *non_existent_filename = "non_existent_config.conf"; + plugin_config_t configs[MAX_PLUGINS]; + + int result = parse_plugin_config(non_existent_filename, configs, MAX_PLUGINS); + + TEST_ASSERT_LESS_THAN(0, result); +} + +// Test Case 4: Test parsing a file with a malformed line (e.g., missing fields) +void test_parse_plugin_config_MalformedLine_ShouldSkipLine(void) +{ + const char *test_config_filename = "test_config_malformed.conf"; + const char *config_content = "plugin1,../path/to/plugin1.py,1,0,./config1.ini\n" + "malformed_line\n" // This line should be skipped + "plugin2,./plugins/plugin2.so,0,1,./config2.conf\n"; + + create_test_config_file(test_config_filename, config_content); + + plugin_config_t configs[MAX_PLUGINS]; + int expected_count = 2; // Only the valid lines should be parsed + + int result = parse_plugin_config(test_config_filename, configs, MAX_PLUGINS); + + TEST_ASSERT_EQUAL_INT(expected_count, result); + + // Validate plugin1 + TEST_ASSERT_EQUAL_STRING("plugin1", configs[0].name); + + // Validate plugin2 (which should now be at index 1) + TEST_ASSERT_EQUAL_STRING("plugin2", configs[1].name); + + remove_test_config_file(test_config_filename); +} + +// Test Case 5: Test parsing a file with only comments and empty lines +void test_parse_plugin_config_CommentsAndEmptyOnly_ShouldReturnZero(void) +{ + const char *test_config_filename = "test_config_empty.conf"; + const char *config_content = "# Comment line 1\n" + "\n" + "# Comment line 2\n" + "\n"; + + create_test_config_file(test_config_filename, config_content); + + plugin_config_t configs[MAX_PLUGINS]; + int expected_count = 0; + + int result = parse_plugin_config(test_config_filename, configs, MAX_PLUGINS); + + TEST_ASSERT_EQUAL_INT(expected_count, result); + + remove_test_config_file(test_config_filename); +} \ No newline at end of file diff --git a/tests/test_plugin_driver.c b/tests/test_plugin_driver.c new file mode 100644 index 00000000..5ee286da --- /dev/null +++ b/tests/test_plugin_driver.c @@ -0,0 +1,249 @@ +#include "plugin_config.h" // For plugin_config_t, etc. +#include "plugin_driver.h" +#include "unity.h" +#include +#include +#include + +// Simple mock control variables +static int mock_calloc_should_fail = 0; +static int mock_pthread_mutex_init_should_fail = 0; +static void *mock_calloc_return_value = NULL; +static int mock_calloc_call_count = 0; +static int mock_pthread_mutex_init_call_count = 0; +static int mock_free_call_count = 0; + +// Mock implementations - override the real functions +void *calloc(size_t num, size_t size) +{ + mock_calloc_call_count++; + if (mock_calloc_should_fail) + { + return NULL; + } + if (mock_calloc_return_value) + { + return mock_calloc_return_value; + } + // Default: use real malloc and zero it + void *ptr = malloc(num * size); + if (ptr) + { + memset(ptr, 0, num * size); + } + return ptr; +} + +int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr) +{ + (void)mutex; + (void)attr; // Suppress unused warnings + mock_pthread_mutex_init_call_count++; + return mock_pthread_mutex_init_should_fail ? -1 : 0; +} + +void free(void *ptr) +{ + mock_free_call_count++; + // For unit tests, we only track the call count + // Memory will be freed automatically when the test process ends + // This avoids recursion issues with dlsym + (void)ptr; // Suppress unused parameter warning +} + +// Mock reset function +void reset_mocks(void) +{ + mock_calloc_should_fail = 0; + mock_pthread_mutex_init_should_fail = 0; + mock_calloc_return_value = NULL; + mock_calloc_call_count = 0; + mock_pthread_mutex_init_call_count = 0; + mock_free_call_count = 0; +} + +// Note: External buffer variables and plugin_manager_destroy are now defined in +// tests/support/test_plugin_driver_stubs.c and will be linked automatically + +void setUp(void) +{ + // Reset all mocks before each test + reset_mocks(); +} + +void tearDown(void) +{ + // Clean up after each test if needed + reset_mocks(); +} + +// Test Case 1: Test for driver creation - success case +void test_plugin_driver_create_ShouldAllocateAndInitializeDriver(void) +{ + // Setup: Configure mocks for success (default behavior is success) + // No special setup needed - mocks will succeed by default + + // Call the function under test + plugin_driver_t *driver = plugin_driver_create(); + + // Assertions + TEST_ASSERT_NOT_NULL_MESSAGE(driver, "Driver creation should not return NULL"); + + // Verify that calloc was called + TEST_ASSERT_EQUAL_INT_MESSAGE(1, mock_calloc_call_count, "calloc should be called once"); + + // Verify that pthread_mutex_init was called + TEST_ASSERT_EQUAL_INT_MESSAGE(1, mock_pthread_mutex_init_call_count, + "pthread_mutex_init should be called once"); + + // Verify internal state - all fields should be zero-initialized by calloc + TEST_ASSERT_EQUAL_INT(0, driver->plugin_count); + + // Cleanup + free(driver); +} + +// Test Case 2: Test driver creation - calloc failure +void test_plugin_driver_create_CallocFails_ShouldReturnNULL(void) +{ + // Setup: Configure calloc to fail + mock_calloc_should_fail = 1; + + // Call the function under test + plugin_driver_t *driver = plugin_driver_create(); + + // Assertions + TEST_ASSERT_NULL_MESSAGE(driver, "Driver creation should return NULL if calloc fails"); + + // Verify that calloc was called + TEST_ASSERT_EQUAL_INT_MESSAGE(1, mock_calloc_call_count, "calloc should be called once"); + + // Verify that pthread_mutex_init was NOT called (since calloc failed) + TEST_ASSERT_EQUAL_INT_MESSAGE(0, mock_pthread_mutex_init_call_count, + "pthread_mutex_init should not be called if calloc fails"); +} + +// Test Case 3: Test driver creation - mutex init failure +void test_plugin_driver_create_MutexInitFails_ShouldFreeAndReturnNULL(void) +{ + // Setup: Configure pthread_mutex_init to fail + mock_pthread_mutex_init_should_fail = 1; + + // Call the function under test + plugin_driver_t *driver = plugin_driver_create(); + + // Assertions + TEST_ASSERT_NULL_MESSAGE(driver, + "Driver creation should return NULL if pthread_mutex_init fails"); + + // Verify that all expected functions were called + TEST_ASSERT_EQUAL_INT_MESSAGE(1, mock_calloc_call_count, "calloc should be called once"); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, mock_pthread_mutex_init_call_count, + "pthread_mutex_init should be called once"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, mock_free_call_count, "free should be called once to clean up after mutex init failure"); +} + +// Test Case 4: Test data structure manipulation (simplified) +void test_plugin_driver_data_structure_ShouldStorePluginInfo(void) +{ + // Setup: Create a mock driver instance + plugin_driver_t driver; + memset(&driver, 0, sizeof(plugin_driver_t)); + + // Note: For this test we'll use a simple approach without mocking parse_plugin_config + // In a real scenario, you'd mock parse_plugin_config for better isolation + + // Simulate the outcome of a successful parse_plugin_config call + plugin_config_t mock_configs[3]; + strncpy(mock_configs[0].name, "py_plugin", MAX_PLUGIN_NAME_LEN); + strncpy(mock_configs[0].path, "../path/to/py_plugin.py", MAX_PLUGIN_PATH_LEN); + mock_configs[0].enabled = 1; + mock_configs[0].type = PLUGIN_TYPE_PYTHON; + strncpy(mock_configs[0].plugin_related_config_path, "./py_config.ini", MAX_PLUGIN_PATH_LEN); + mock_configs[0].venv_path[0] = '\0'; + + strncpy(mock_configs[1].name, "native_plugin", MAX_PLUGIN_NAME_LEN); + strncpy(mock_configs[1].path, "./plugins/native_plugin.so", MAX_PLUGIN_PATH_LEN); + mock_configs[1].enabled = 0; + mock_configs[1].type = PLUGIN_TYPE_NATIVE; + strncpy(mock_configs[1].plugin_related_config_path, "./native_config.conf", + MAX_PLUGIN_PATH_LEN); + mock_configs[1].venv_path[0] = '\0'; + + strncpy(mock_configs[2].name, "py_plugin_venv", MAX_PLUGIN_NAME_LEN); + strncpy(mock_configs[2].path, "/another/path/py_plugin.py", MAX_PLUGIN_PATH_LEN); + mock_configs[2].enabled = 1; + mock_configs[2].type = PLUGIN_TYPE_PYTHON; + strncpy(mock_configs[2].plugin_related_config_path, "./py_config3.ini", MAX_PLUGIN_PATH_LEN); + strncpy(mock_configs[2].venv_path, "/path/to/venv3", MAX_PLUGIN_PATH_LEN); + + int config_count = 3; + + // Fill driver.plugins with mock_configs to simulate what parse_plugin_config would do + for (int i = 0; i < config_count && i < MAX_PLUGINS; i++) + { + memcpy(&driver.plugins[i].config, &mock_configs[i], sizeof(plugin_config_t)); + } + driver.plugin_count = config_count; + + // In a complete implementation, you would mock python_plugin_get_symbols here + // For example: + // python_plugin_get_symbols_ExpectAndReturn(&driver.plugins[0], 0); // Success for py_plugin + // python_plugin_get_symbols_ExpectAndReturn(&driver.plugins[2], 0); // Success for + // py_plugin_venv + + // For this test, we're just testing the data structure population + // In a more complete test, you would mock plugin_driver_load_config entirely + // For now, we just test that our mock data was set up correctly + + // Assertions - testing the setup we created (simulating successful config loading) + TEST_ASSERT_EQUAL_INT_MESSAGE(3, driver.plugin_count, "Driver plugin count should be 3"); + + // Validate plugin 1 (Python) + TEST_ASSERT_EQUAL_STRING("py_plugin", driver.plugins[0].config.name); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_PYTHON, driver.plugins[0].config.type); + + // Validate plugin 2 (Native) + TEST_ASSERT_EQUAL_STRING("native_plugin", driver.plugins[1].config.name); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_NATIVE, driver.plugins[1].config.type); + + // Validate plugin 3 (Python with venv) + TEST_ASSERT_EQUAL_STRING("py_plugin_venv", driver.plugins[2].config.name); + TEST_ASSERT_EQUAL_INT(PLUGIN_TYPE_PYTHON, driver.plugins[2].config.type); + TEST_ASSERT_EQUAL_STRING("/path/to/venv3", driver.plugins[2].config.venv_path); + + // No cleanup needed for driver if it's stack allocated +} + +// Test Case 5: Test calling plugins that failed initialization +// This test focuses on the `plugin_driver_init` function and how it handles +// plugins where the `init` function (Python or Native) returns an error. +// plugins where the `init` function (Python or Native) returns an error. +void test_plugin_driver_Init_WhenPluginInitFails_ShouldHaltAndReturnError(void) +{ + // This test requires extensive mocking of Python C API and plugin structures. + // Simplified for now to show intent. + + plugin_driver_t driver; + memset(&driver, 0, sizeof(plugin_driver_t)); + driver.plugin_count = 1; // One plugin that fails + + strncpy(driver.plugins[0].config.name, "bad_python_plugin", MAX_PLUGIN_NAME_LEN); + driver.plugins[0].config.type = PLUGIN_TYPE_PYTHON; + + // For this test, we'll simulate the plugin init failure + // In a real implementation, you would mock the Python C API calls + // This is a simplified version to show the testing structure + + // --- Call the function under test --- + int result = plugin_driver_init(&driver); + + // --- Assertions --- + // For now, we just test that the function can be called + // In a real implementation, you would mock Python C API to simulate failure + TEST_ASSERT_TRUE_MESSAGE(result == 0 || result == -1, + "plugin_driver_init should return valid result"); + + // Note: Cleanup of mocked resources would typically be handled by Cmock or test teardown. +} diff --git a/webserver/DEBUG_WEBSOCKET.md b/webserver/DEBUG_WEBSOCKET.md new file mode 100644 index 00000000..daea267b --- /dev/null +++ b/webserver/DEBUG_WEBSOCKET.md @@ -0,0 +1,260 @@ +# WebSocket Debug Protocol + +## Overview + +The OpenPLC Runtime v4 provides a secure WebSocket endpoint for debugger communication over HTTPS. This allows the OpenPLC Editor to maintain a persistent, authenticated connection for real-time variable polling without the overhead of repeated TLS handshakes. + +## Connection + +### Endpoint +``` +wss://:8443/api/debug +``` + +### Authentication +The WebSocket connection requires JWT authentication via query parameter: +``` +wss://localhost:8443/api/debug?token= +``` + +The JWT token is obtained through the standard REST API login endpoint: +```bash +POST https://localhost:8443/api/login +Content-Type: application/json + +{ + "username": "admin", + "password": "password" +} +``` + +Response: +```json +{ + "access_token": "eyJhbGc..." +} +``` + +## Protocol + +### Connection Events + +#### `connect` +Emitted by server when connection is successfully established and authenticated. + +**Server Response:** +```json +{ + "status": "ok" +} +``` + +#### `disconnect` +Connection closed (either by client or server). + +### Debug Communication + +#### `debug_command` (Client β†’ Server) +Send debug command to the runtime. + +**Request:** +```json +{ + "command": "41 DE AD 00 00" +} +``` + +Where `command` is a hex string representing the debug command bytes (same format as Arduino/Modbus implementation). + +**Response Event:** `debug_response` + +#### `debug_response` (Server β†’ Client) +Response to debug command. + +**Success Response:** +```json +{ + "success": true, + "data": "7E 12 34 56 78" +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": "Error message" +} +``` + +## Debug Command Format + +The debug commands follow the same protocol as the Arduino/Modbus implementation: + +### Function Codes +- `0x41` - DEBUG_INFO: Get debug information +- `0x42` - DEBUG_SET: Set trace variable +- `0x43` - DEBUG_GET: Get trace data +- `0x44` - DEBUG_GET_LIST: Get list of variable values +- `0x45` - DEBUG_GET_MD5: Get MD5 hash + +### Example: Get MD5 Hash +**Request:** +```json +{ + "command": "45 DE AD 00 00" +} +``` + +**Response (Success):** +```json +{ + "success": true, + "data": "7E 61 62 63 64 65 66 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 00" +} +``` + +### Example: Get Variables List +**Request:** +```json +{ + "command": "44 00 03 00 00 00 01 00 02" +} +``` +(Get 3 variables: indexes 0, 1, 2) + +**Response (Success):** +```json +{ + "success": true, + "data": "7E 00 02 00 00 00 64 00 0A 01 00 01 01" +} +``` + +## Client Implementation Example (JavaScript) + +```javascript +import io from 'socket.io-client'; + +// Obtain JWT token first +const response = await fetch('https://localhost:8443/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'password' }) +}); +const { access_token } = await response.json(); + +// Connect to WebSocket with authentication +const socket = io('wss://localhost:8443/api/debug', { + transports: ['websocket'], + query: { token: access_token }, + rejectUnauthorized: false // Only for self-signed certificates in development +}); + +socket.on('connected', (data) => { + console.log('Connected:', data); + + // Send debug command + socket.emit('debug_command', { + command: '45 DE AD 00 00' // Get MD5 + }); +}); + +socket.on('debug_response', (response) => { + if (response.success) { + console.log('Debug data:', response.data); + } else { + console.error('Debug error:', response.error); + } +}); + +socket.on('disconnect', () => { + console.log('Disconnected'); +}); +``` + +## Client Implementation Example (Python) + +### Installation +```bash +pip3 install python-socketio[client] +``` + +### Test Script +```python +#!/usr/bin/env python3 +import socketio + +# Get JWT token from /api/login first +TOKEN = "your_jwt_token_here" + +sio = socketio.Client(ssl_verify=False) + +@sio.event(namespace='/api/debug') +def connect(): + print("Connected to WebSocket!") + +@sio.event(namespace='/api/debug') +def connected(data): + print(f"Server confirmed: {data}") + +@sio.event(namespace='/api/debug') +def debug_response(data): + print(f"\nDebug response:") + print(f" Success: {data.get('success')}") + if data.get('success'): + print(f" Data: {data.get('data')}") + else: + print(f" Error: {data.get('error')}") + +@sio.event(namespace='/api/debug') +def disconnect(): + print("Disconnected") + +# Connect with token in auth dict (preferred method) +try: + sio.connect( + 'https://localhost:8443', + auth={'token': TOKEN}, + namespaces=['/api/debug'], + transports=['websocket'] + ) + + # Send debug command + sio.emit('debug_command', { + 'command': '41 DE AD 00 00' # DEBUG_INFO command + }, namespace='/api/debug') + + sio.sleep(2) # Wait for response + sio.disconnect() + +except Exception as e: + print(f"Error: {e}") +``` + +**Note:** You can also pass the token via query string as a fallback: +```python +sio.connect( + f'https://localhost:8443?token={TOKEN}', + namespaces=['/api/debug'], + transports=['websocket'] +) +``` + +## Benefits + +1. **Single TLS Handshake**: WebSocket connection is established once per debug session +2. **Persistent Connection**: No reconnection overhead during variable polling +3. **Bidirectional**: Efficient request-response pattern +4. **Secure**: JWT authentication + HTTPS/WSS encryption +5. **Compatible**: Uses same debug protocol format as existing Arduino/Modbus implementation + +## Migration Notes + +For OpenPLC Editor developers: + +1. Replace TCP socket (port 502) connection with WebSocket connection +2. Obtain JWT token via REST API login before connecting +3. Use same hex string command format for debug commands +4. Parse responses in same format as Modbus implementation +5. Connection lifetime matches debug session (connect when debug starts, disconnect when debug stops) diff --git a/webserver/__init__.py b/webserver/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/webserver/app.py b/webserver/app.py new file mode 100644 index 00000000..89555228 --- /dev/null +++ b/webserver/app.py @@ -0,0 +1,261 @@ +import os +import shutil +import ssl +import threading +from pathlib import Path +from typing import Callable, Final + +import flask +import flask_login +from webserver.credentials import CertGen +from webserver.debug_websocket import init_debug_websocket +from webserver.plcapp_management import ( + MAX_FILE_SIZE, + BuildStatus, + analyze_zip, + build_state, + run_compile, + safe_extract, +) +from webserver.restapi import ( + app_restapi, + db, + register_callback_get, + register_callback_post, + restapi_bp, +) +from webserver.runtimemanager import RuntimeManager +from webserver.logger import get_logger, LogParser + +logger, _ = get_logger("logger", use_buffer=True) + +app = flask.Flask(__name__) +app.secret_key = str(os.urandom(16)) +login_manager = flask_login.LoginManager() +login_manager.init_app(app) + +runtime_manager = RuntimeManager( + runtime_path="./build/plc_main", + plc_socket="/run/runtime/plc_runtime.socket", + log_socket="/run/runtime/log_runtime.socket", +) + +runtime_manager.start() + +BASE_DIR: Final[Path] = Path(__file__).parent +CERT_FILE: Final[Path] = (BASE_DIR / "certOPENPLC.pem").resolve() +KEY_FILE: Final[Path] = (BASE_DIR / "keyOPENPLC.pem").resolve() +HOSTNAME: Final[str] = "localhost" + + +def handle_start_plc(data: dict) -> dict: + response = runtime_manager.start_plc() + return {"status": response} + + +def handle_stop_plc(data: dict) -> dict: + response = runtime_manager.stop_plc() + return {"status": response} + + +def handle_runtime_logs(data: dict) -> dict: + if "id" in data: + min_id = int(data["id"]) + else: + min_id = None + if "level" in data: + level = data["level"] + else: + level = None + response = runtime_manager.get_logs(min_id=min_id, level=level) + return {"runtime-logs": response} + + +def handle_compilation_status(data: dict) -> dict: + return { + "status": build_state.status.name, + "logs": build_state.logs[:], # all lines + "exit_code": build_state.exit_code, + } + + +def handle_status(data: dict) -> dict: + response = runtime_manager.status_plc() + if response is None: + return {"status": "No response from runtime"} + return {"status": response} + + +def handle_ping(data: dict) -> dict: + response = runtime_manager.ping() + return {"status": response} + + +GET_HANDLERS: dict[str, Callable[[dict], dict]] = { + "start-plc": handle_start_plc, + "stop-plc": handle_stop_plc, + "runtime-logs": handle_runtime_logs, + "compilation-status": handle_compilation_status, + "status": handle_status, + "ping": handle_ping, +} + + +def restapi_callback_get(argument: str, data: dict) -> dict: + """ + Dispatch GET callbacks by argument. + """ + # logger.debug("GET | Received argument: %s, data: %s", argument, data) + handler = GET_HANDLERS.get(argument) + if handler: + return handler(data) + return {"error": "Unknown argument"} + + +def handle_upload_file(data: dict) -> dict: + if build_state.status == BuildStatus.COMPILING: + return { + "UploadFileFail": "Runtime is compiling another program, please wait", + "CompilationStatus": build_state.status.name, + } + + build_state.clear() # remove all previous build logs + + if "file" not in flask.request.files: + build_state.status = BuildStatus.FAILED + return { + "UploadFileFail": "No file part in the request", + "CompilationStatus": build_state.status.name, + } + + zip_file = flask.request.files["file"] + + if zip_file.content_length > MAX_FILE_SIZE: + build_state.status = BuildStatus.FAILED + return { + "UploadFileFail": "File is too large", + "CompilationStatus": build_state.status.name, + } + + try: + build_state.status = BuildStatus.UNZIPPING + safe, valid_files = analyze_zip(zip_file) + if not safe: + build_state.status = BuildStatus.FAILED + return { + "UploadFileFail": "Uploaded ZIP file failed safety checks", + "CompilationStatus": build_state.status.name, + } + + extract_dir = "core/generated" + if os.path.exists(extract_dir): + shutil.rmtree(extract_dir) + + safe_extract(zip_file, extract_dir, valid_files) + + # Start compilation in a separate thread + build_state.status = BuildStatus.COMPILING + + task_compile = threading.Thread( + target=run_compile, + args=(runtime_manager,), + kwargs={"cwd": extract_dir}, + daemon=True, + ) + + task_compile.start() + + return {"UploadFileFail": "", "CompilationStatus": build_state.status.name} + + except (OSError, IOError) as e: + build_state.status = BuildStatus.FAILED + build_state.log(f"[ERROR] File system error: {e}") + return { + "UploadFileFail": f"File system error: {e}", + "CompilationStatus": build_state.status.name, + } + except Exception as e: + build_state.status = BuildStatus.FAILED + build_state.log(f"[ERROR] Unexpected error: {e}") + return { + "UploadFileFail": f"Unexpected error: {e}", + "CompilationStatus": build_state.status.name, + } + + +POST_HANDLERS: dict[str, Callable[[dict], dict]] = { + "upload-file": handle_upload_file, +} + + +def restapi_callback_post(argument: str, data: dict) -> dict: + """ + Dispatch POST callbacks by argument. + """ + # logger.debug("POST | Received argument: %s, data: %s", argument, data) + handler = POST_HANDLERS.get(argument) + + if not handler: + return {"PostRequestError": "Unknown argument"} + + return handler(data) + + +def run_https(): + # rest api register + app_restapi.register_blueprint(restapi_bp, url_prefix="/api") + register_callback_get(restapi_callback_get) + register_callback_post(restapi_callback_post) + + socketio = init_debug_websocket(app_restapi, runtime_manager.runtime_socket) + + with app_restapi.app_context(): + try: + db.create_all() + db.session.commit() + # logger.info("Database tables created successfully.") + except Exception: + # logger.error("Error creating database tables: %s", e) + pass + + try: + cert_gen = CertGen(hostname=HOSTNAME, ip_addresses=["127.0.0.1"]) + + # Check if certificate exists. If not, generate one + if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): + # logger.info("Generating https certificate...") + print( + "Generating https certificate..." + ) # TODO: remove this temporary print once logger is functional again + cert_gen.generate_self_signed_cert(cert_file=CERT_FILE, key_file=KEY_FILE) + else: + logger.warning("Credentials already generated!") + + context = (CERT_FILE, KEY_FILE) + socketio.run( + app_restapi, + debug=False, + host="0.0.0.0", + port=8443, + ssl_context=context, + use_reloader=False, + log_output=False, + allow_unsafe_werkzeug=True, + ) + + except FileNotFoundError: + # logger.error("Could not find SSL credentials! %s", e) + pass + except ssl.SSLError: + # logger.error("SSL credentials FAIL! %s", e) + pass + except KeyboardInterrupt: + # logger.info("HTTP server stopped by KeyboardInterrupt") + pass + finally: + logger.info("Runtime manager stopped") + runtime_manager.stop() + + +if __name__ == "__main__": + run_https() diff --git a/webserver/config.py b/webserver/config.py new file mode 100644 index 00000000..53613909 --- /dev/null +++ b/webserver/config.py @@ -0,0 +1,94 @@ +import os +import re +import secrets +from pathlib import Path + +from dotenv import load_dotenv +from webserver.logger import get_logger, LogParser + +logger, buffer = get_logger("logger", use_buffer=True) + +# Use /var/run/runtime for persistent data to avoid Docker volume permission issues +# This directory is created in the Dockerfile and can use a named volume +ENV_PATH = Path("/var/run/runtime/.env") +DB_PATH = Path("/var/run/runtime/restapi.db") +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + +# Function to validate environment variable values +def is_valid_env(var_name, value): + if var_name == "SQLALCHEMY_DATABASE_URI": + return value.startswith("sqlite:///") + elif var_name in ("JWT_SECRET_KEY", "PEPPER"): + return bool(re.fullmatch(r"[a-fA-F0-9]{64}", value)) + return False + + +# Function to generate a new .env file with valid defaults +def generate_env_file(): + jwt = secrets.token_hex(32) + pepper = secrets.token_hex(32) + uri = f"sqlite:///{DB_PATH}" + + with open(ENV_PATH, "w") as f: + f.write("FLASK_ENV=development\n") + f.write(f"SQLALCHEMY_DATABASE_URI={uri}\n") + f.write(f"JWT_SECRET_KEY={jwt}\n") + f.write(f"PEPPER={pepper}\n") + + os.chmod(ENV_PATH, 0o600) + logger.info(".env file created at %s", ENV_PATH) + + # Ensure the database file exists and is writable + # Deletion is required because new secrets will change the database saved hashes + if os.path.exists(DB_PATH): + os.remove(DB_PATH) + logger.warning("Deleted existing database file: %s", DB_PATH) + + +# Load .env file +if not os.path.isfile(ENV_PATH): + logger.info(".env file not found, creating one...") + generate_env_file() + +load_dotenv(dotenv_path=ENV_PATH, override=False) + +# Mandatory settings – raise immediately if not provided +try: + for var in ("SQLALCHEMY_DATABASE_URI", "JWT_SECRET_KEY", "PEPPER"): + val = os.getenv(var) + if not val or not is_valid_env(var, val): + raise RuntimeError(f"Environment variable '{var}' is invalid or missing") +except RuntimeError as e: + logger.error("%s", e) + # Need to regenerate .env file and remove the database as well + response = ( + input( + "Do you want to regenerate the .env file? This will delete your database. [y/N]: " + ) + .strip() + .lower() + ) + if response == "y": + print("Regenerating .env with new valid values...") + generate_env_file() + load_dotenv(ENV_PATH) + else: + print("Exiting due to invalid environment configuration.") + exit(1) + + +class Config: + SQLALCHEMY_DATABASE_URI = os.environ["SQLALCHEMY_DATABASE_URI"] + JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] + PEPPER = os.environ["PEPPER"] + + +class DevConfig(Config): + SQLALCHEMY_TRACK_MODIFICATIONS = False # keep performance parity with prod + DEBUG = True + + +class ProdConfig(Config): + SQLALCHEMY_TRACK_MODIFICATIONS = False + DEBUG = False + ENV = "production" diff --git a/webserver/credentials.py b/webserver/credentials.py new file mode 100644 index 00000000..97130fa2 --- /dev/null +++ b/webserver/credentials.py @@ -0,0 +1,272 @@ +import os +import re +import subprocess +from ipaddress import ip_address +from pathlib import Path + + +def validate_hostname(hostname: str) -> str: + """ + Validate and sanitize hostname for use in certificate generation. + + Ensures the hostname: + - Is not empty or too long (max 253 characters per RFC 1123) + - Contains only valid characters (alphanumeric, dots, hyphens) + - Doesn't contain DN special characters that could cause injection + + Args: + hostname: The hostname to validate + + Returns: + The validated hostname + + Raises: + ValueError: If hostname is invalid + """ + if not hostname or str(hostname) == '': + raise ValueError("Hostname must be a non-empty string") + + hostname = str(hostname) # Make sure this is a string + hostname = hostname.strip() + + if len(hostname) > 253: + raise ValueError(f"Hostname too long: {len(hostname)} characters (max 253)") + + hostname_pattern = re.compile( + r'^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$' + ) + + if not hostname_pattern.match(hostname): + raise ValueError( + f"Invalid hostname format: '{hostname}'. " + "Hostname must contain only alphanumeric characters, dots, and hyphens" + ) + + dn_special_chars = set('/=+,<>#;"*\\\n\r\t') + if any(char in hostname for char in dn_special_chars): + raise ValueError( + f"Hostname contains invalid characters. " + f"DN special characters are not allowed: {dn_special_chars}" + ) + + return hostname + + +def validate_ip_address(ip: str) -> str: + """ + Validate IP address for use in certificate SAN. + + Args: + ip: The IP address string to validate + + Returns: + The validated IP address as a string + + Raises: + ValueError: If IP address is invalid + """ + if not ip or str(ip) == '': + raise ValueError("IP address must be a non-empty string") + + ip = str(ip) # Make sure this is a string + + try: + ip_obj = ip_address(ip.strip()) + return str(ip_obj) + except ValueError as e: + raise ValueError(f"Invalid IP address '{ip}': {e}") from e + + +def validate_file_path(file_path: str, base_dir: str | None = None) -> Path: + """ + Validate file path to prevent path traversal attacks. + + Args: + file_path: The file path to validate + base_dir: Optional base directory to restrict paths to + + Returns: + Validated Path object + + Raises: + ValueError: If path is invalid or contains traversal sequences + """ + if not file_path or str(file_path) == '': + raise ValueError("File path must be a non-empty string") + + file_path = str(file_path) # Make sure this is a string + + path = Path(file_path).resolve() + + if base_dir: + base = Path(base_dir).resolve() + try: + path.relative_to(base) + except ValueError as e: + raise ValueError( + f"Path '{file_path}' is outside allowed directory '{base_dir}'" + ) from e + + return path + + +class CertGen: + """Generates a self-signed TLS certificate and private key using OpenSSL CLI.""" + + MAX_SAN_ENTRIES = 100 + + def __init__(self, hostname, ip_addresses=None): + """ + Initialize certificate generator with validated inputs. + + Args: + hostname: The hostname for the certificate CN and DNS SAN + ip_addresses: Optional list of IP addresses for IP SANs + + Raises: + ValueError: If inputs are invalid + """ + self.hostname = validate_hostname(hostname) + + self.ip_addresses = [] + if ip_addresses: + if not isinstance(ip_addresses, (list, tuple)): + raise ValueError("ip_addresses must be a list or tuple") + + if len(ip_addresses) > self.MAX_SAN_ENTRIES: + raise ValueError( + f"Too many IP addresses: {len(ip_addresses)} " + f"(max {self.MAX_SAN_ENTRIES})" + ) + + for ip in ip_addresses: + validated_ip = validate_ip_address(ip) + self.ip_addresses.append(validated_ip) + + def generate_self_signed_cert(self, cert_file="cert.pem", key_file="key.pem"): + """ + Generate a self-signed certificate using OpenSSL CLI. + + Args: + cert_file: Path where certificate will be saved + key_file: Path where private key will be saved + + Returns: + Success message string + + Raises: + ValueError: If file paths are invalid + RuntimeError: If certificate generation fails + """ + print(f"Generating self-signed certificate for {self.hostname}...") + + cert_path = str(validate_file_path(cert_file)) + key_path = str(validate_file_path(key_file)) + + san_list = [f"DNS:{self.hostname}"] + for ip in self.ip_addresses: + san_list.append(f"IP:{ip}") + + if len(san_list) > self.MAX_SAN_ENTRIES: + raise ValueError( + f"Too many SAN entries: {len(san_list)} (max {self.MAX_SAN_ENTRIES})" + ) + + san_string = ",".join(san_list) + + cmd = [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:4096", + "-sha256", + "-nodes", + "-keyout", + key_path, + "-out", + cert_path, + "-days", + "36500", + "-subj", + f"/CN={self.hostname}", + "-addext", + f"subjectAltName={san_string}", + ] + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + print(f"Certificate saved to {cert_path}") + print(f"Private key saved to {key_path}") + return f"Certificate generated successfully for {self.hostname}" + except subprocess.CalledProcessError as e: + error_msg = f"Error generating certificate: {e.stderr}" + print(error_msg) + raise RuntimeError(error_msg) from e + except FileNotFoundError as exc: + error_msg = "OpenSSL not found. Please ensure OpenSSL is installed." + print(error_msg) + raise RuntimeError(error_msg) from exc + + def is_certificate_valid(self, cert_file): + """ + Check if the certificate exists and is not expired using OpenSSL. + + Args: + cert_file: Path to the certificate file + + Returns: + True if certificate is valid, False otherwise + """ + try: + cert_path = str(validate_file_path(cert_file)) + except ValueError as e: + print(f"Invalid certificate path: {e}") + return False + + if not os.path.exists(cert_path): + print(f"Certificate file not found: {cert_path}") + return False + + try: + result = subprocess.run( + [ + "openssl", + "x509", + "-in", + cert_path, + "-noout", + "-checkend", + "0", + ], + check=False, + capture_output=True, + text=True, + ) + + if result.returncode == 0: + date_result = subprocess.run( + [ + "openssl", + "x509", + "-in", + cert_path, + "-noout", + "-enddate", + ], + check=True, + capture_output=True, + text=True, + ) + expiry_line = date_result.stdout.strip() + print(f"Certificate is valid. {expiry_line}") + return True + print("Certificate has expired.") + return False + + except subprocess.CalledProcessError as e: + print(f"Error checking certificate validity: {e.stderr}") + return False + except FileNotFoundError: + print("OpenSSL not found. Please ensure OpenSSL is installed.") + return False diff --git a/webserver/debug_websocket.py b/webserver/debug_websocket.py new file mode 100644 index 00000000..89c2e430 --- /dev/null +++ b/webserver/debug_websocket.py @@ -0,0 +1,169 @@ +""" +WebSocket debug endpoint for OpenPLC Runtime v4 + +This module provides a secure WebSocket interface for debugger communication. +It receives debug commands in hex format, forwards them to the Unix socket, +and returns responses through the WebSocket connection. +""" + +from flask import request +from flask_jwt_extended import decode_token +from flask_socketio import SocketIO, emit +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError + +from webserver.logger import get_logger + +logger, _ = get_logger("debug_ws", use_buffer=True) + +_socketio = None # pylint: disable=invalid-name +_unix_client = None # pylint: disable=invalid-name + + +def init_debug_websocket(app, unix_client_instance): + """ + Initialize the WebSocket server for debug communication. + + Args: + app: Flask application instance + unix_client_instance: SyncUnixClient instance for communicating with C core + """ + global _socketio, _unix_client + + _unix_client = unix_client_instance + + try: + from werkzeug import serving # pylint: disable=import-outside-toplevel + + _original_server_log = serving.BaseWSGIServer.log + + def _filtered_server_log(self, log_type, message, *args): + """Filter out specific error messages from server logs""" + if ( + log_type == "error" + and "Error on request" in message + and "write() before start_response" in message + ): + logger.debug("Suppressed WSGI disconnect error from server log") + return None + return _original_server_log(self, log_type, message, *args) + + serving.BaseWSGIServer.log = _filtered_server_log + logger.debug("Patched werkzeug server logging to suppress disconnect errors") + except Exception as e: + logger.warning("Failed to patch error suppression: %s", e) + + _socketio = SocketIO( + app, + cors_allowed_origins="*", + async_mode="threading", + logger=False, + engineio_logger=False, + ping_timeout=60, + ping_interval=25, + allow_upgrades=False, + ) + + @_socketio.on("connect", namespace="/api/debug") + def handle_connect(auth): + """Handle WebSocket connection with JWT authentication""" + try: + token = None + if auth and isinstance(auth, dict): + token = auth.get("token") + + if not token: + token = request.args.get("token") + + if not token: + logger.warning("Debug WebSocket connection attempt without token") + return False + + try: + decoded = decode_token(token) + logger.info("Debug WebSocket connected for user %s", decoded.get("sub")) + emit("connected", {"status": "ok"}) + return True + + except (ExpiredSignatureError, InvalidTokenError) as e: + logger.warning("Debug WebSocket auth failed: %s", e) + return False + + except Exception as e: + logger.error("Error during debug WebSocket connection: %s", e) + return False + + @_socketio.on("disconnect", namespace="/api/debug") + def handle_disconnect(): + """Handle WebSocket disconnection""" + logger.info("Debug WebSocket disconnected") + + @_socketio.on("debug_command", namespace="/api/debug") + def handle_debug_command(data): + """ + Handle debug command from the client. + + Expected data format: + { + 'command': 'hex string of debug data (e.g., "41 00 00")' + } + + Returns debug response in same hex format + """ + try: + if not _unix_client or not _unix_client.is_connected(): + logger.error("Unix socket not connected") + emit( + "debug_response", + {"success": False, "error": "Runtime not connected"}, + ) + return + + command_hex = data.get("command", "") + if not command_hex: + logger.warning("Empty debug command received") + emit("debug_response", {"success": False, "error": "Empty command"}) + return + + logger.debug("Debug command received: %s", command_hex) + + unix_command = f"DEBUG:{command_hex}\n" + response = _unix_client.send_and_receive(unix_command, timeout=2.0) + + if response is None: + logger.warning("No response from runtime") + emit( + "debug_response", + {"success": False, "error": "No response from runtime"}, + ) + return + + if response.startswith("DEBUG:"): + response_hex = response[6:].strip() + logger.debug("Debug response: %s", response_hex) + emit("debug_response", {"success": True, "data": response_hex}) + elif response.startswith("DEBUG:ERROR"): + error_msg = ( + response.split(":", 2)[2] + if len(response.split(":")) > 2 + else "Unknown error" + ) + logger.warning("Debug error from runtime: %s", error_msg) + emit("debug_response", {"success": False, "error": error_msg}) + else: + logger.warning("Unexpected response format: %s", response) + emit( + "debug_response", + {"success": False, "error": "Unexpected response format"}, + ) + + except Exception as e: + logger.error("Error processing debug command: %s", e) + emit("debug_response", {"success": False, "error": str(e)}) + + logger.info("Debug WebSocket endpoint initialized at /api/debug") + return _socketio + + +def get_socketio(): + """Get the SocketIO instance""" + return _socketio diff --git a/webserver/logger/__init__.py b/webserver/logger/__init__.py new file mode 100644 index 00000000..3dec2e03 --- /dev/null +++ b/webserver/logger/__init__.py @@ -0,0 +1,38 @@ +# logger/__init__.py +import logging +import sys + +from .logger import get_logger +from .parser import LogParser +from .bufferhandler import BufferHandler +from .formatter import JsonFormatter + +__all__ = ["get_logger", "LogParser", "BufferHandler", "JsonFormatter"] +__version__ = "0.1" +__author__ = "Autonomy" +__license__ = "MIT" +__description__ = "RestAPI interface for runtime core" + +# Single global buffer for all logs +shared_buffer_handler = BufferHandler() + +formatter = JsonFormatter() +shared_buffer_handler.setFormatter(formatter) + +def get_logger(name="runtime", use_buffer: bool = False): + """Return a logger that shares the same buffer handler.""" + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + logger.propagate = False + + # Always ensure a StreamHandler exists + if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(JsonFormatter()) + logger.addHandler(stream_handler) + + if use_buffer: + if not any(isinstance(h, BufferHandler) for h in logger.handlers): + logger.addHandler(shared_buffer_handler) + + return logger, shared_buffer_handler diff --git a/webserver/logger/bufferhandler.py b/webserver/logger/bufferhandler.py new file mode 100644 index 00000000..ed2f224a --- /dev/null +++ b/webserver/logger/bufferhandler.py @@ -0,0 +1,98 @@ +# logger/bufferhandler.py +import logging +from collections import deque +from typing import List, Optional +import json +from datetime import datetime, timezone +from threading import Lock +from . import config + + +class BufferHandler(logging.Handler): + """ + Custom logging handler that stores log records in memory (FIFO). + Logs are formatted using the attached formatter (JSON). + """ + def __init__(self, capacity: int = 1000): + super().__init__() + self.buffer = deque(maxlen=capacity) + self.records = [] # Store formatted log records as strings + self._lock = Lock() + + def emit(self, record: logging.LogRecord) -> None: + with self._lock: + try: + record.log_id = config.LoggerConfig.next_log_id() + formatted_record = self.format(record) + self.records.append(formatted_record) + self.buffer.append(formatted_record) + except Exception: + self.handleError(record) + + def filter_logs(self, logs, level=None, min_id=None, max_id=None): + result = logs + if level is not None: + result = [log for log in result if log.get("level") == level] + if min_id is not None: + result = [log for log in result if log.get("id", 0) >= min_id] + if max_id is not None: + result = [log for log in result if log.get("id", 0) <= max_id] + return result + + def get_logs(self, count: Optional[int] = None, + min_id: Optional[int] = None, + level: Optional[str] = None) -> List[str]: + """Retrieve logs from buffer.""" + with self._lock: + filtered_logs = [json.loads(item) for item in self.buffer] + filtered_logs = self.filter_logs(filtered_logs, level=level, min_id=min_id) + if count is not None and count < len(filtered_logs): + filtered_logs = filtered_logs[-count:] + return filtered_logs + + def normalize_timestamp_no_microseconds(self, ts: str) -> str: + """Normalize ISO 8601 timestamp to remove microseconds.""" + dt = datetime.fromisoformat(ts) + return dt.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%S%z") + + def normalize_logs(self, json_logs: List[dict]) -> List[dict]: + """Normalize a list of log entries (dicts).""" + normalized = [] + for data in json_logs: + try: + # Normalize timestamp (convert unix timestamp β†’ ISO 8601) + ts = data.get("timestamp") + + # If it's numeric (e.g., 1759843183), convert it to ISO 8601 UTC + if ts and str(ts).isdigit(): + ts_dt = datetime.fromtimestamp(int(ts), tz=timezone.utc) + data["timestamp"] = ts_dt.isoformat() + + # If it's ISO 8601 but has microseconds, strip them + if "timestamp" in data: + data["timestamp"] = self.normalize_timestamp_no_microseconds(data["timestamp"]) + + # Ensure minimal required fields + data.setdefault("level", "INFO") + data.setdefault("message", "") + + normalized.append(data) + + except (json.JSONDecodeError, TypeError, ValueError) as e: + # If something is not JSON, safely wrap it + normalized.append({ + "id": None, + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "ERROR", + "message": f"Malformed log: {data} ({e})", + }) + + return normalized + + def clear(self) -> None: + self.buffer.clear() + self.records.clear() + config.LoggerConfig.reset_log_id() + + def __len__(self): + return len(self.buffer) diff --git a/webserver/logger/config.py b/webserver/logger/config.py new file mode 100644 index 00000000..219b3b22 --- /dev/null +++ b/webserver/logger/config.py @@ -0,0 +1,22 @@ +# logger/config.py +import threading +import logging + +class LoggerConfig: + log_id: int = 0 + log_level: int = logging.INFO + use_buffer: bool = False + _log_id_lock = threading.Lock() + + @classmethod + def next_log_id(cls) -> int: + """Thread-safe increment and return of global log ID.""" + with cls._log_id_lock: + cls.log_id += 1 + return cls.log_id + + @classmethod + def reset_log_id(cls) -> None: + """Reset the global log ID to zero.""" + with cls._log_id_lock: + cls.log_id = 0 diff --git a/webserver/logger/formatter.py b/webserver/logger/formatter.py new file mode 100644 index 00000000..3d7b2181 --- /dev/null +++ b/webserver/logger/formatter.py @@ -0,0 +1,31 @@ +# logger/formatter.py +from datetime import datetime, timezone +import logging +import json +from . import config + +class JsonFormatter(logging.Formatter): + """Format log records as JSON strings.""" + + def format(self, record): + msg = record.getMessage() + + # Try to detect pre-formatted JSON + try: + log_entry = json.loads(msg) + log_entry["id"] = config.LoggerConfig.log_id + # Already JSON β€” just make sure timestamp exists + if "timestamp" not in log_entry: + log_entry["timestamp"] = datetime.now(timezone.utc).isoformat() + + except json.JSONDecodeError: + # Not JSON, so create our standard JSON structure + log_entry = { + "id": config.LoggerConfig.log_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "message": msg, + } + + return json.dumps(log_entry) + diff --git a/webserver/logger/logger.py b/webserver/logger/logger.py new file mode 100644 index 00000000..86ebdca2 --- /dev/null +++ b/webserver/logger/logger.py @@ -0,0 +1,28 @@ +# logger/logger.py +import logging +import sys +from .formatter import JsonFormatter +from .bufferhandler import BufferHandler + + +def get_logger(name: str = "logger", + level: int = logging.INFO, + use_buffer: bool = False): + """Return a logger instance with custom formatting.""" + + collector_logger = logging.getLogger(name) + collector_logger.setLevel(logging.DEBUG) + + # Always ensure a StreamHandler exists + if not any(isinstance(h, logging.StreamHandler) for h in collector_logger.handlers): + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(JsonFormatter()) + collector_logger.addHandler(stream_handler) + + buffer_handler = None + if use_buffer and not any(isinstance(h, BufferHandler) for h in collector_logger.handlers): + buffer_handler = BufferHandler() + buffer_handler.setFormatter(JsonFormatter()) + collector_logger.addHandler(buffer_handler) + + return collector_logger, buffer_handler diff --git a/webserver/logger/logger_module_documentation.md b/webserver/logger/logger_module_documentation.md new file mode 100644 index 00000000..06e7aa5e --- /dev/null +++ b/webserver/logger/logger_module_documentation.md @@ -0,0 +1,318 @@ +# Python Logging Module Documentation + +## Overview + +This logging module provides a structured, JSON-based logging system with in-memory buffering. It is designed for runtime environments where log data needs to be serialized, transmitted, or parsed consistently. The module integrates seamlessly with the Python `logging` standard library but extends it with JSON formatting, a shared buffer, and parsing utilities. + +--- + +## Module Structure + +``` +logger/ +β”‚ +β”œβ”€β”€ __init__.py # Main entry point, global configuration +β”œβ”€β”€ logger.py # Factory for logger instances +β”œβ”€β”€ config.py # configuration variables and thread safety lock +β”œβ”€β”€ formatter.py # JSON formatter for structured logs +β”œβ”€β”€ bufferhandler.py # In-memory log buffer (not shown above) +└── parser.py # Parses and normalizes incoming log lines +``` + +--- + +## Components + +### 1. `get_logger()` (from `__init__.py` and `logger.py`) + +#### Purpose + +Creates and returns a configured logger instance. The logger outputs JSON-formatted messages and can optionally store logs in a shared buffer. + +#### Signature + +```python +get_logger(name="runtime", use_buffer: bool = False) +``` + +#### Behavior + +- Sets log level to `DEBUG`. +- Ensures a `StreamHandler` is attached (outputs to `stdout`). +- Optionally adds a shared `BufferHandler` for in-memory collection. +- Uses `JsonFormatter` for consistent output. + +#### Returns + +```python +(logger_instance, buffer_handler) +``` + +#### Example + +```python +from logger import get_logger + +logger, buffer = get_logger("logger", use_buffer=True) +logger.info("System initialized") +``` + +--- + +### 2. `JsonFormatter` + +#### Location + +`logger/formatter.py` + +#### Purpose + +Formats log records as structured JSON objects, ensuring every log entry contains `id`, `timestamp`, `level`, and `message` fields. + +#### Key Features + +- Automatically adds timestamps in ISO 8601 UTC format. +- Detects pre-formatted JSON and augments it with missing fields. +- Includes a global `log_id` for log correlation. + +#### Example Output + +```json +{ + "id": "1", + "timestamp": "2025-10-22T19:03:04.123456Z", + "level": "INFO", + "message": "System initialized" +} +``` + +--- + +### 3. `BufferHandler` + +#### Location + +`logger/bufferhandler.py` + +#### Purpose + +Stores log records in memory for later retrieval or transmission. Useful for APIs that expose logs or batch log uploads. + +#### Notes + +- Shared across all logger instances. +- Can be flushed or accessed by components that need recent log history. + +--- + +### 4. `LogParser` + +#### Location + +`logger/parser.py` + +#### Purpose + +Normalizes incoming log lines into structured JSON and re-logs them via the Python logging system. + +#### Key Features + +- Detects JSON logs and preserves their structure. +- Supports pattern `[LEVEL] message` using regex. +- Defaults to INFO level if no match is found. +- Converts parsed logs to JSON and forwards to the configured logger. + +#### Example Usage + +```python +from logger import LogParser, get_logger + +logger, _ = get_logger("logger") +parser = LogParser(logger) + +parser.parse_and_log("[ERROR] Connection failed") +parser.parse_and_log('{"level":"INFO","message":"Reconnected"}') +``` + +#### Example Output + +```json +{ + "timestamp": "1729587834", + "level": "ERROR", + "message": "Connection failed" +} +``` + +--- + +## Thread Safety + +This logging architecture is designed to be **thread-safe**, following Python’s built-in `logging` guarantees: + +- The Python `logging` module ensures that handler operations are protected by internal locks (`threading.RLock`). +- Each handler (e.g., `StreamHandler`, `BufferHandler`) can safely receive log records from multiple threads simultaneously. +- The shared `BufferHandler` ensures consistent appends by relying on the thread-safe `logging.Handler.emit()` method. +- For applications with heavy concurrency (e.g., multithreaded servers), this architecture prevents race conditions during log writes or JSON serialization. + +**Recommendations for Multithreaded Use:** + +- Avoid modifying shared handler state (like buffer lists) outside `emit()` or synchronized contexts. +- Use `QueueHandler` or `QueueListener` for extremely high-throughput scenarios. +- Always use a dedicated logger instance per module, not per thread. + +--- + +## Example: Combined Usage + +```python +from logger import get_logger, LogParser + +# Initialize logger +logger, buffer = get_logger("runtime", use_buffer=True) + +# Log directly +logger.info("Startup complete") + +# Parse and re-log an external message +parser = LogParser(logger) +parser.parse_and_log("[WARNING] Low voltage detected") + +# Access buffered logs +for record in buffer.buffer: + print(record) +``` + +**Sample Output:** + +```json +{"id": "runtime-01", "timestamp": "2025-10-22T19:12:44.312Z", "level": "INFO", "message": "Startup complete"} +{"id": "runtime-01", "timestamp": "2025-10-22T19:12:44.325Z", "level": "WARNING", "message": "Low voltage detected"} +``` + +--- + +## Development & Testing + +This section guides developers on how to **test and validate the logging module** during development. + +--- + +### 1. Unit Testing + +The module can be tested using Python’s built-in `unittest` framework or `pytest`. + +**Example: `test_logger.py`** + +```python +import unittest +from logger import get_logger, LogParser + +class TestLogger(unittest.TestCase): + def setUp(self): + self.logger, self.buffer = get_logger("test_logger", use_buffer=True) + self.parser = LogParser(self.logger) + + def test_info_log(self): + self.logger.info("Test info message") + self.assertIn("Test info message", self.buffer.buffer[-1]) + + def test_warning_log(self): + self.logger.warning("Test warning") + log_entry = self.buffer.buffer[-1] + self.assertIn('"level": "WARNING"', log_entry) + + def test_parse_plain_text(self): + self.parser.parse_and_log("[ERROR] External error") + log_entry = self.buffer.buffer[-1] + self.assertIn('"level": "ERROR"', log_entry) + self.assertIn("External error", log_entry) + + def test_parse_json_log(self): + json_line = '{"level": "INFO", "message": "External JSON"}' + self.parser.parse_and_log(json_line) + log_entry = self.buffer.buffer[-1] + self.assertIn("External JSON", log_entry) + +if __name__ == "__main__": + unittest.main() +``` + +Run tests: + +```bash +python -m unittest test_logger.py +# or with pytest +pytest test_logger.py +``` + +--- + +### 2. Buffer Validation + +Check that logs are properly stored in the buffer and old entries are trimmed if using a capacity limit. + +```python +logger, buffer = get_logger("runtime", use_buffer=True) + +for i in range(1100): # assuming default capacity 1000 + logger.info(f"Message {i}") + +assert len(buffer.buffer) <= 1000 +``` + +--- + +### 3. Parser Validation + +Ensure the `LogParser` correctly handles: + +1. JSON input +2. Regex pattern `[LEVEL] message` +3. Plain-text messages + +**Test Example:** + +```python +parser.parse_and_log("[INFO] Hello World") +parser.parse_and_log('{"level":"DEBUG","message":"Debug JSON"}') +parser.parse_and_log("Just a string message") +``` + +Check that all entries are JSON-formatted in the buffer. + +--- + +### 4. Thread-Safety Testing + +Simulate concurrent logging from multiple threads: + +```python +import threading + +def log_messages(logger, count=100): + for i in range(count): + logger.info(f"Thread log {i}") + +logger, buffer = get_logger("thread_test", use_buffer=True) + +threads = [threading.Thread(target=log_messages, args=(logger,)) for _ in range(5)] +[t.start() for t in threads] +[t.join() for t in threads] + +print(f"Total logs in buffer: {len(buffer.buffer)}") +``` + +All entries should appear in the buffer without corruption or race conditions. + +--- + +### 5. Coverage + +Measure test coverage: + +```bash +pytest --cov=logger test_logger.py +``` + +This ensures all log paths, parser cases, and formatter scenarios are exercised. diff --git a/webserver/logger/parser.py b/webserver/logger/parser.py new file mode 100644 index 00000000..05bcda62 --- /dev/null +++ b/webserver/logger/parser.py @@ -0,0 +1,74 @@ +# logger/parser.py +import logging +import re +import time +import json + +LOG_PATTERN = re.compile(r'^\[(?P\w+)\]\s*(?P.*)$') + +LEVEL_MAP = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, +} + + +class LogParser: + def __init__(self, collector_logger: logging.Logger): + self.collector_logger = collector_logger + + def parse_and_log(self, line: str): + """Parse incoming log line and re-log it in normalized JSON format.""" + sline = line.strip() + if not sline: + return + + timestamp = int(time.time()) + level_name = "INFO" + level = logging.INFO + message = sline + + # Case 1: JSON log already + try: + parsed = json.loads(sline) + if isinstance(parsed, dict) and "message" in parsed: + # Preserve incoming JSON fields, but ensure timestamp is present + parsed.setdefault("timestamp", str(timestamp)) + level_name = parsed.get("level", "INFO") + level = LEVEL_MAP.get(level_name, logging.INFO) + log_entry = parsed + else: + raise ValueError("Not a valid log JSON dict") + except (json.JSONDecodeError, ValueError): + # Case 2: Regex log like "[INFO] Something" + match = LOG_PATTERN.match(sline) + if match: + level_name = match["level"] + level = LEVEL_MAP.get(level_name, logging.INFO) + message = match["message"] + else: + message = sline + + log_entry = { + "timestamp": str(timestamp), + "level": level_name, + "message": message + } + + # Create final JSON string + json_log = json.dumps(log_entry, ensure_ascii=False) + + # Push into Python logging + record = self.collector_logger.makeRecord( + name="external", + level=level, + fn="", + lno=0, + msg=json_log, + args=(), + exc_info=None + ) + record.source = "external" + self.collector_logger.handle(record) diff --git a/webserver/plcapp_management.py b/webserver/plcapp_management.py new file mode 100644 index 00000000..da32a806 --- /dev/null +++ b/webserver/plcapp_management.py @@ -0,0 +1,212 @@ +from dataclasses import dataclass, field +from enum import Enum, auto +import os +import zipfile +import subprocess +import threading +from typing import Final + +from webserver.runtimemanager import RuntimeManager +from webserver.logger import get_logger, LogParser + +logger, _ = get_logger("runtime", use_buffer=True) + + +MAX_FILE_SIZE: Final[int] = 10 * 1024 * 1024 # 10 MB per file +MAX_TOTAL_SIZE: Final[int] = 50 * 1024 * 1024 # 50 MB total +DISALLOWED_EXT = (".exe", ".dll", ".sh", ".bat", ".js", ".vbs", ".scr") + +class BuildStatus(Enum): + IDLE = auto() + UNZIPPING = auto() + COMPILING = auto() + SUCCESS = auto() + FAILED = auto() + +@dataclass +class BuildProcess: + status: BuildStatus = BuildStatus.IDLE + logs: list[str] = field(default_factory=list) + exit_code: int | None = None + + def log(self, msg: str): + # logger.info(msg) + self.logs.append(msg) + + def clear(self): + self.status = BuildStatus.IDLE + self.logs.clear() + self.exit_code = None + + +build_state = BuildProcess() # global-ish singleton for status + + +def analyze_zip(zip_path) -> tuple[bool, list]: + """Analyze the ZIP file for safety before extraction.""" + build_state.status = BuildStatus.UNZIPPING + + if not zipfile.is_zipfile(zip_path): + build_state.log("[ERROR] Not a valid PLC Program file.\n") + return False, [] + + with zipfile.ZipFile(zip_path, "r") as zf: + total_size = 0 + safe = True + valid_files = [] + + for info in zf.infolist(): + filename = info.filename + uncompressed_size = info.file_size + compressed_size = info.compress_size + ext = os.path.splitext(filename)[1].lower() + + # Check for path traversal or absolute paths + if filename.startswith("/") or ".." in filename or ":" in filename: + # logger.warning("Dangerous path: %s", filename) + safe = False + + # Check uncompressed size + if uncompressed_size > MAX_FILE_SIZE: + logger.warning("File too large: %s (%d bytes)", + filename, uncompressed_size) + safe = False + + # Check compression ratio (ZIP bomb detection) + if compressed_size > 0 and uncompressed_size / compressed_size > 1000: + # logger.warning("Suspicious compression ratio in %s", + # filename) + safe = False + + # Check disallowed extensions + if ext in DISALLOWED_EXT: + logger.warning("Disallowed extension: %s", + filename) + safe = False + + total_size += uncompressed_size + valid_files.append(info) + + # Check total size + if total_size > MAX_TOTAL_SIZE: + # logger.warning("Total uncompressed size too large: %d bytes", + # total_size) + safe = False + + if safe: + logger.debug("ZIP file looks safe to extract (based on static checks).") + else: + logger.warning("ZIP file failed safety checks.") + + return safe, valid_files + + +def safe_extract(zip_path, dest_dir, valid_files): + """Extract files safely to a target directory. + - Skips macOS metadata (__MACOSX, .DS_Store) + - Auto-strips a single common root folder if present + """ + build_state.status = BuildStatus.UNZIPPING + + with zipfile.ZipFile(zip_path, "r") as zf: + # Detect roots (ignoring macOS junk) + roots = set() + for info in valid_files: + if info.filename.startswith("__MACOSX/") or info.filename.endswith(".DS_Store"): + continue + parts = info.filename.split("/", 1) + if parts and parts[0]: + roots.add(parts[0]) + strip_root = len(roots) == 1 + + for info in valid_files: + filename = info.filename + + # Skip macOS junk and directories + if filename.startswith("__MACOSX/") or filename.endswith(".DS_Store") or filename.endswith("/"): + continue + + # Optionally strip single root folder + if strip_root: + parts = filename.split("/", 1) + if len(parts) == 2: + filename = parts[1] + else: + filename = parts[0] + + out_path = os.path.join(dest_dir, filename) + out_path = os.path.abspath(out_path) + + # Ensure extraction stays inside destination + if not out_path.startswith(os.path.abspath(dest_dir)): + # logger.warning("Skipping suspicious path: %s", filename) + continue + + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + with zf.open(info) as src, open(out_path, "wb") as dst: + dst.write(src.read()) + + logger.debug("Extracted: %s", out_path) + +def run_compile(runtime_manager: RuntimeManager, cwd: str = "core/generated"): + """Run compile script synchronously (wait for completion) and update status/logs.""" + script_path: str = "./scripts/compile.sh" + + build_state.status = BuildStatus.COMPILING + build_state.log(f"[INFO] Starting compilation\n") + + def stream_output(pipe, prefix): + for line in iter(pipe.readline, ''): + msg = f"{prefix}{line}" + build_state.log(msg) + pipe.close() + + def wait_and_finish(proc: subprocess.Popen, step_name: str): + exit_code = proc.wait() + build_state.exit_code = exit_code + if exit_code == 0: + build_state.status = BuildStatus.SUCCESS + build_state.log(f"[INFO] {step_name} finished successfully\n") + else: + build_state.status = BuildStatus.FAILED + build_state.log(f"[ERROR] {step_name} failed (exit={exit_code})\n") + + # --- Compile step --- + compile_proc = subprocess.Popen( + ["bash", script_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + threading.Thread(target=stream_output, args=(compile_proc.stdout, ""), daemon=True).start() + threading.Thread(target=stream_output, args=(compile_proc.stderr, "[ERROR] "), daemon=True).start() + + # Block until compile finishes + wait_and_finish(compile_proc, "Build") + + # Stop PLC before cleanup + runtime_manager.stop_plc() + + # --- Cleanup step --- + cleanup_proc = subprocess.Popen( + ["bash", "./scripts/compile-clean.sh"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + threading.Thread(target=stream_output, args=(cleanup_proc.stdout, ""), daemon=True).start() + threading.Thread(target=stream_output, args=(cleanup_proc.stderr, "[ERROR] "), daemon=True).start() + + # Block until cleanup finishes + wait_and_finish(cleanup_proc, "Cleanup") + + # Restart PLC only if everything succeeded + if build_state.status == BuildStatus.SUCCESS: + runtime_manager.start_plc() + else: + build_state.log("[WARNING] PLC program has not been updated because the build failed\n") diff --git a/webserver/restapi.py b/webserver/restapi.py new file mode 100644 index 00000000..179ad448 --- /dev/null +++ b/webserver/restapi.py @@ -0,0 +1,293 @@ +import os +from typing import Callable, Optional + +import webserver.config +from flask import Blueprint, Flask, jsonify, request +from flask_jwt_extended import ( + JWTManager, + create_access_token, + get_jwt, + jwt_required, + verify_jwt_in_request, +) +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import check_password_hash, generate_password_hash +from webserver.logger import get_logger, LogParser + +logger, buffer = get_logger("logger", use_buffer=True) + +env = os.getenv("FLASK_ENV", "development") + +app_restapi = Flask(__name__) + +if env == "production": + app_restapi.config.from_object(webserver.config.ProdConfig) +else: + app_restapi.config.from_object(webserver.config.DevConfig) + +restapi_bp = Blueprint("restapi_blueprint", __name__) +_handler_callback_get: Optional[Callable[[str, dict], dict]] = None +_handler_callback_post: Optional[Callable[[str, dict], dict]] = None +jwt = JWTManager(app_restapi) +db = SQLAlchemy(app_restapi) + +jwt_blacklist = set() + + +@jwt.token_in_blocklist_loader +def check_if_token_revoked(jwt_header, jwt_payload): + try: + jti = jwt_payload["jti"] + return jti in jwt_blacklist + except KeyError as e: + logger.error("Error revoking JWT: %s", e) + except Exception as e: + logger.error("Error revoking JWT: %s", e) + return False + +class User(db.Model): # type: ignore[name-defined] + __tablename__ = "users" + + id: int = db.Column(db.Integer, primary_key=True) + username: str = db.Column(db.Text, nullable=False, unique=True) + password_hash: str = db.Column(db.Text, nullable=False) + role: str = db.Column(db.String(20), default="user") + + # Use PBKDF2 with SHA256 and 600,000 iterations for password hashing + derivation_method: str = "pbkdf2:sha256:600000" + + def set_password(self, password: str) -> str: + password = password + app_restapi.config["PEPPER"] + self.password_hash = generate_password_hash( + password, method=self.derivation_method + ) + return self.password_hash + + def check_password(self, password: str) -> bool: + password = password + app_restapi.config["PEPPER"] + return check_password_hash(self.password_hash, password) + + def to_dict(self): + return {"id": self.id, "username": self.username, "role": self.role} + + +@jwt.user_identity_loader +def user_identity_lookup(user): + return str(user.id) + + +@jwt.user_lookup_loader +def user_lookup_callback(_jwt_header, jwt_data): + identity = jwt_data["sub"] + return User.query.filter_by(id=identity).one_or_none() + + +def register_callback_get(callback: Callable[[str, dict], dict]): + global _handler_callback_get + _handler_callback_get = callback + logger.debug("GET Callback registered successfully for rest_blueprint!") + + +def register_callback_post(callback: Callable[[str, dict], dict]): + global _handler_callback_post + _handler_callback_post = callback + logger.debug("POST Callback registered successfully for rest_blueprint!") + + +@restapi_bp.route("/create-user", methods=["POST"]) +def create_user(): + # check if there are any users in the database + try: + users_exist = User.query.first() is not None + except Exception as e: + logger.error("Error checking for users: %s", e) + return jsonify({"msg": f"User creation error: {e}"}), 401 + + # if there are no users, we don't need to verify JWT + if users_exist and verify_jwt_in_request(optional=True) is None: + return jsonify({"msg": "User already created!"}), 401 + + data = request.get_json() + username = data.get("username") + password = data.get("password") + role = data.get("role", "user") + + if not username or not password: + return jsonify({"msg": "Missing username or password"}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({"msg": "Username already exists"}), 409 + + # Create a new user + user = User(username=username, role=role) + user.set_password(password) + db.session.add(user) + db.session.commit() + + return jsonify({"msg": "User created", "id": user.id}), 201 + + +# verify existing users individually +@restapi_bp.route("/get-user-info/", methods=["GET"]) +@jwt_required() +def get_user_info(user_id): + try: + user = User.query.get(user_id) + except Exception as e: + logger.error("Error retrieving user: %s", e) + return jsonify({"msg": f"User retrieval error: {e}"}), 500 + + if not user: + return jsonify({"msg": "User not found"}), 404 + + return jsonify(user.to_dict()) + + +@restapi_bp.route("/get-users-info", methods=["GET"]) +def get_users_info(): + # If there are no users, we don't need to verify JWT + try: + verify_jwt_in_request() + except Exception: + # logger.warning( + # "No JWT token provided, checking for users without authentication" + # ) + try: + users_exist = User.query.first() is not None + except Exception as e: + # logger.error("Error checking for users: %s", e) + return jsonify({"msg": "User retrieval error"}), 500 + + if not users_exist: + return jsonify({"msg": "No users found"}), 404 + return jsonify({"msg": "Users found"}), 200 + + try: + users = User.query.all() + except Exception as e: + logger.error("Error retrieving users: %s", e) + return jsonify({"msg": "User retrieval error"}), 500 + + return jsonify([user.to_dict() for user in users]), 200 + + +# password change for specific user by any authenticated user +@restapi_bp.route("/password-change/", methods=["PUT"]) +@jwt_required() +def change_password(user_id): + data = request.get_json() + old_password = data.get("old_password") + new_password = data.get("new_password") + + if not old_password or not new_password: + return jsonify({"msg": "Both old and new passwords are required"}), 400 + + try: + user = User.query.get(user_id) + except Exception as e: + logger.error("Error retrieving user: %s", e) + return jsonify({"msg": "User retrieval error"}), 500 + + if not user: + return jsonify({"msg": "User not found"}), 404 + + if not user.check_password(old_password): + return jsonify({"msg": "Old password is incorrect"}), 403 + + user.set_password(new_password) + db.session.commit() + + return ( + jsonify({"msg": f"Password for user {user.username} updated successfully"}), + 200, + ) + + +# delete a user by ID +@restapi_bp.route("/delete-user/", methods=["DELETE"]) +@jwt_required() +def delete_user(user_id): + try: + user = User.query.get(user_id) + except Exception as e: + logger.error("Error retrieving user: %s", e) + return jsonify({"msg": f"User retrieval error: {e}"}), 500 + + if not user: + return jsonify({"msg": "User not found"}), 404 + + db.session.delete(user) + db.session.commit() + revoke_jwt() + return jsonify({"msg": f"User {user.username} deleted successfully"}), 200 + + +# login endpoint +@restapi_bp.route("/login", methods=["POST"]) +def login(): + username = request.json.get("username", None) + password = request.json.get("password", None) + + try: + user = User.query.filter_by(username=username).one_or_none() + logger.debug("User found: %s", user) + except Exception as e: + logger.error("Error retrieving user: %s", e) + return jsonify({"msg": f"User retrieval error: {e}"}), 500 + + if not user or not user.check_password(password): + return jsonify("Wrong username or password"), 401 + + access_token = create_access_token(identity=user) + return jsonify(access_token=access_token) + + +# logout endpoint +@restapi_bp.route("/logout", methods=["POST"]) +@jwt_required() +def logout(): + revoke_jwt() + return jsonify({"msg": "User logged out successfully"}), 200 + + +def revoke_jwt(): + # Add the JWT ID to the blacklist + try: + jti = get_jwt()["jti"] + jwt_blacklist.add(jti) + except KeyError as e: + logger.error("Error revoking JWT: %s", e) + except AttributeError as e: + logger.error("Error revoking JWT: %s", e) + except Exception as e: + logger.error("Error revoking JWT: %s", e) + + +@restapi_bp.route("/", methods=["GET"]) +@jwt_required() +def restapi_plc_get(command): + if _handler_callback_get is None: + return jsonify({"error": "No handler registered"}), 500 + + try: + data = request.args.to_dict() + result = _handler_callback_get(command, data) + return jsonify(result), 200 + except Exception as e: + # logger.error("Error in restapi_plc_get: %s", e) + return jsonify({"error": str(e)}), 500 + + +@restapi_bp.route("/", methods=["POST"]) +@jwt_required() +def restapi_plc_post(command): + if _handler_callback_post is None: + return jsonify({"error": "No handler registered"}), 500 + + try: + data = request.get_json(silent=True) or {} + result = _handler_callback_post(command, data) + return jsonify(result), 200 + except Exception as e: + # logger.error("Error in restapi_plc_post: %s", e) + return jsonify({"error": str(e)}), 500 diff --git a/webserver/runtimemanager.py b/webserver/runtimemanager.py new file mode 100644 index 00000000..979cb7b3 --- /dev/null +++ b/webserver/runtimemanager.py @@ -0,0 +1,270 @@ +import os +import socket +import subprocess +import threading +import time + +import psutil +from webserver.unixserver import UnixLogServer +from webserver.unixclient import SyncUnixClient +from webserver.logger import get_logger, LogParser + +logger, buffer = get_logger("logger", use_buffer=True) + + +class RuntimeManager: + def __init__(self, runtime_path, plc_socket, log_socket): + self.runtime_path = runtime_path + self.plc_socket = plc_socket + self.log_socket = log_socket + self.process = None + self.log_server = UnixLogServer(log_socket) + self.runtime_socket = SyncUnixClient(plc_socket) + self.monitor_thread = threading.Thread(target=self._monitor, daemon=True) + self.running = False + + def find_running_process(self): + """ + Find the running PLC runtime process + """ + # Find the running PLC runtime process by executable path + for proc in psutil.process_iter(["pid", "exe", "cmdline"]): + try: + # First try to match by executable path (most reliable) + if proc.info["exe"] and os.path.samefile( + proc.info["exe"], self.runtime_path + ): + return proc + + # Alternatively, match by command line (fallback) + cmdline = proc.info.get("cmdline") + if cmdline and isinstance(cmdline, (list, tuple)) and len(cmdline) > 0: + cmdline_str = " ".join( + str(arg) for arg in cmdline if arg is not None + ) + if self.runtime_path in cmdline_str: + return proc + + except (OSError, psutil.Error, TypeError, ValueError): + continue + return None + + def _safe_start_log_server(self): + try: + self.log_server.start() + except (OSError, socket.error) as e: + logger.error("Failed to start log server: %s", e) + except Exception as e: + logger.error("Failed to start log server (unexpected): %s", e) + + def _safe_connect_runtime_socket(self): + try: + self.runtime_socket.connect() + except (FileNotFoundError, OSError, socket.error) as e: + logger.error("Failed to connect to runtime socket: %s", e) + except Exception as e: + logger.error("Failed to connect to runtime socket (unexpected): %s", e) + + def _safe_stop_log_server(self): + try: + self.log_server.stop() + except (OSError, socket.error) as e: + logger.error("Failed to stop log server: %s", e) + except Exception as e: + logger.error("Failed to stop log server (unexpected): %s", e) + + def _safe_close_runtime_socket(self): + try: + self.runtime_socket.close() + except (OSError, socket.error) as e: + logger.error("Failed to close runtime socket: %s", e) + except Exception as e: + logger.error("Failed to close runtime socket (unexpected): %s", e) + + def start(self): + """ + Start the runtime manager and the PLC runtime process + """ + if self.running: + logger.warning("Runtime manager already running") + return + + self.running = True + + # Ensure UNIX socket paths exist + plc_socket_dir = os.path.dirname(self.plc_socket) + log_socket_dir = os.path.dirname(self.log_socket) + if not os.path.exists(plc_socket_dir): + try: + os.makedirs(plc_socket_dir) + logger.info("Created directory for PLC socket: %s", plc_socket_dir) + except OSError as e: + logger.error("Failed to create directory for PLC socket: %s", e) + if not os.path.exists(log_socket_dir): + try: + os.makedirs(log_socket_dir) + logger.info("Created directory for log socket: %s", log_socket_dir) + except OSError as e: + logger.error("Failed to create directory for log socket: %s", e) + + # Start runtime process if not already running + running_process = self.find_running_process() + if running_process: + logger.info( + "Found existing PLC runtime process with PID %d", running_process.pid + ) + self.process = running_process + self._safe_start_log_server() + self._safe_connect_runtime_socket() + else: + logger.info("Starting PLC runtime core...") + self._safe_start_log_server() + try: + self.process = subprocess.Popen([self.runtime_path]) + except (OSError, subprocess.SubprocessError) as e: + logger.error("Failed to start PLC runtime process: %s", e) + self.process = None + time.sleep(1) # Give time to start + self._safe_connect_runtime_socket() + + # Start monitor thread + if not self.monitor_thread.is_alive(): + self.monitor_thread = threading.Thread(target=self._monitor, daemon=True) + self.monitor_thread.start() + + def is_runtime_alive(self): + """ + Check if the PLC runtime process is alive + """ + if self.process is None: + return False + if isinstance(self.process, psutil.Process): + if ( + self.process.is_running() + and self.process.status() != psutil.STATUS_ZOMBIE + ): + return True + elif isinstance(self.process, subprocess.Popen): + if self.process.poll() is None: + return True + return False + + def _monitor(self): + """ + Monitor the PLC runtime process and restart if it dies + """ + while self.running: + if not self.is_runtime_alive(): + # Process died, restart + logger.warning("PLC runtime process died, restarting...") + self._safe_stop_log_server() + self._safe_close_runtime_socket() + + self._safe_start_log_server() + try: + self.process = subprocess.Popen([self.runtime_path]) + except (OSError, subprocess.SubprocessError) as e: + logger.error("Failed to start PLC runtime process: %s", e) + self.process = None + time.sleep(1) # Give time to start + self._safe_connect_runtime_socket() + else: + # Make sure log server and socket are connected + if not self.log_server.running: + self._safe_start_log_server() + if not self.runtime_socket.is_connected(): + self._safe_connect_runtime_socket() + + time.sleep(2) + + def stop(self): + """ " + Stop the runtime manager and the PLC runtime process + """ + try: + self.runtime_socket.send_message("STOP\n") + except (OSError, socket.error) as e: + logger.error("Failed to send STOP to PLC runtime: %s", e) + except Exception as e: + logger.error("Failed to send STOP to PLC runtime (unexpected): %s", e) + self.running = False + self.monitor_thread.join(timeout=5) + time.sleep(1) + if self.process: + if isinstance(self.process, psutil.Process): + self.process.terminate() + try: + self.process.wait(timeout=5) + except (psutil.TimeoutExpired, psutil.Error): + self.process.kill() + elif isinstance(self.process, subprocess.Popen): + self.process.terminate() + try: + self.process.wait(timeout=5) + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + self.process.kill() + self.process = None + self._safe_stop_log_server() + self._safe_close_runtime_socket() + + def get_logs(self, min_id=None, level=None): + """ + Get current logs from the runtime + """ + try: + _logs = buffer.normalize_logs(buffer.get_logs(min_id=min_id, level=level)) + return _logs + except AttributeError as e: + logger.error("Failed to get logs from buffer: %s", e) + return [] + + def ping(self): + """ + Send PING and wait for PONG + """ + try: + return self.runtime_socket.send_and_receive("PING\n") + except (OSError, socket.error) as e: + logger.error("Failed to ping PLC runtime: %s", e) + return "PING:ERROR\n" + except Exception as e: + logger.error("Failed to ping PLC runtime (unexpected): %s", e) + return "PING:ERROR\n" + + def start_plc(self): + """ + Send START command + """ + try: + return self.runtime_socket.send_and_receive("START\n") + except (OSError, socket.error) as e: + logger.error("Failed to start PLC runtime: %s", e) + return "START:ERROR\n" + except Exception as e: + logger.error("Failed to start PLC runtime (unexpected): %s", e) + return "START:ERROR\n" + + def stop_plc(self): + """ + Send STOP command + """ + try: + return self.runtime_socket.send_and_receive("STOP\n") + except (OSError, socket.error) as e: + logger.error("Failed to stop PLC runtime: %s", e) + return "STOP:ERROR\n" + except Exception as e: + logger.error("Failed to stop PLC runtime (unexpected): %s", e) + + def status_plc(self): + """ + Send STATUS command + """ + try: + return self.runtime_socket.send_and_receive("STATUS\n") + except (OSError, socket.error) as e: + logger.error("Failed to get PLC status: %s", e) + return "STATUS:ERROR\n" + except Exception as e: + logger.error("Failed to get PLC status (unexpected): %s", e) + return "STATUS:ERROR\n" diff --git a/webserver/unixclient.py b/webserver/unixclient.py new file mode 100644 index 00000000..33a73e15 --- /dev/null +++ b/webserver/unixclient.py @@ -0,0 +1,140 @@ +import os +import socket +from threading import Lock +from typing import Optional +from webserver.logger import get_logger + +logger, _ = get_logger(use_buffer=True) +mutex = Lock() + + +class SyncUnixClient: + def __init__(self, socket_path="/run/runtime/plc_runtime.socket"): + self.socket_path = socket_path + self.sock: Optional[socket.socket] = None + + def is_connected(self): + with mutex: + if self.sock is None: + return False + return True + + def connect(self): + """Connect to the Unix socket server""" + if not os.path.exists(self.socket_path): + raise FileNotFoundError(f"Socket not found: {self.socket_path}") + + try: + logger.debug("Connecting to socket %s", self.socket_path) + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(1.0) # 1s timeout on blocking calls + self.sock.connect(self.socket_path) + logger.debug("Connected to server socket %s", self.socket_path) + except Exception as e: + logger.error("Failed to connect: %s", e) + + def send_message(self, msg: str): + if not self.sock: + raise RuntimeError("Socket not connected") + + with mutex: + data = msg.encode() + try: + self.sock.sendall(data) + # logger.info("Sent message: %s", data) + except Exception as e: + logger.error("Error sending message: %s", e) + + def recv_message(self, timeout: float = 0.5) -> Optional[str]: + """Receive message from the server. Reads until newline to ensure complete message.""" + if not self.sock: + raise RuntimeError("Socket not connected") + + with mutex: + self.sock.settimeout(timeout) + try: + buffer = bytearray() + max_size = 8192 * 2 + 256 + + while len(buffer) < max_size: + chunk = self.sock.recv(4096) + if not chunk: + if buffer: + break + return None + + buffer.extend(chunk) + + if b"\n" in buffer: + break + + if not buffer: + return None + + message = buffer.decode("utf-8").strip() + logger.debug( + "Received message: %s", + message[:200] + "..." if len(message) > 200 else message, + ) + return message + except socket.timeout: + logger.warning("Timeout waiting for message") + return None + except Exception: + return None + + def send_and_receive(self, msg: str, timeout: float = 0.5) -> Optional[str]: + """ + Send a message and receive response atomically with mutex held. + This ensures no other thread can interleave send/recv operations. + """ + if not self.sock: + raise RuntimeError("Socket not connected") + + with mutex: + data = msg.encode() + try: + self.sock.sendall(data) + except Exception as e: + logger.error("Error sending message: %s", e) + return None + + self.sock.settimeout(timeout) + try: + buffer = bytearray() + max_size = 8192 * 2 + 256 + + while len(buffer) < max_size: + chunk = self.sock.recv(4096) + if not chunk: + if buffer: + break + return None + + buffer.extend(chunk) + + if b"\n" in buffer: + break + + if not buffer: + return None + + message = buffer.decode("utf-8").strip() + logger.debug( + "Received message: %s", + message[:200] + "..." if len(message) > 200 else message, + ) + return message + except socket.timeout: + logger.warning("Timeout waiting for message") + return None + except Exception: + return None + + def close(self): + if self.sock: + logger.debug("Closing connection") + try: + self.sock.close() + finally: + self.sock = None diff --git a/webserver/unixserver.py b/webserver/unixserver.py new file mode 100644 index 00000000..bdc7f527 --- /dev/null +++ b/webserver/unixserver.py @@ -0,0 +1,94 @@ +import socket +import threading +import os +from webserver.logger import get_logger, LogParser + +logger, _ = get_logger("runtime", use_buffer=True) +parser = LogParser(logger) + + +class UnixLogServer: + def __init__(self, socket_path="/run/runtime/log_runtime.socket"): + self.socket_path = socket_path + self.server_socket = None + self.clients = [] + self.lock = threading.Lock() + self.running = False + + def start(self): + """Start the Unix socket server""" + if self.running: + logger.warning("Server already running") + return + + try: + # Ensure the socket does not already exist + try: + os.unlink(self.socket_path) + except OSError: + if os.path.exists(self.socket_path): + raise + + self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server_socket.bind(self.socket_path) + self.server_socket.listen(1) + self.running = True + threading.Thread(target=self._accept_clients, daemon=True).start() + logger.info("Log server started at %s", self.socket_path) + except (OSError, socket.error) as e: + logger.error("Failed to start server: %s", e) + except Exception as e: + logger.error("Failed to start server (unexpected): %s", e) + + def _accept_clients(self): + """Accept incoming client connections""" + while self.running: + try: + client_sock, _ = self.server_socket.accept() + with self.lock: + self.clients.append(client_sock) + threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True).start() + logger.info("Client connected") + except (OSError, socket.error) as e: + logger.error("Socket error: %s", e) + except Exception as e: + logger.error("Error accepting client: %s", e) + + def _handle_client(self, client_sock: socket.socket): + """Handle communication with a connected client""" + try: + with client_sock.makefile('r') as f: + for line in f: + parser.parse_and_log(line) + except (OSError, socket.error) as e: + logger.error("Socket error: %s", e) + except Exception as e: + logger.error("Error handling client: %s", e) + finally: + with self.lock: + self.clients.remove(client_sock) + client_sock.close() + logger.info("Client disconnected") + + def stop(self): + """Stop the Unix socket server""" + if not self.running: + logger.warning("Server not running") + return + + self.running = False + if self.server_socket: + self.server_socket.close() + self.server_socket = None + with self.lock: + for client in self.clients: + client.close() + self.clients.clear() + try: + os.unlink(self.socket_path) + except OSError: + if os.path.exists(self.socket_path): + logger.error("Failed to remove socket file") + except Exception as e: + logger.error("Error during server shutdown: %s", e) + logger.info("Log server stopped")